using FluentValidation; using SatelliteProvider.Common.DTO; namespace SatelliteProvider.Api.Validators; // AZ-796: FluentValidation rules for POST /api/satellite/tiles/inventory. // Wired through ValidationEndpointFilter at endpoint // registration time (`WithValidation()` in Program.cs). // Failures are converted to RFC 7807 ValidationProblemDetails per // `_docs/02_document/contracts/api/error-shape.md` v1.0.0. // // Required-field detection (rules 5+) is partially handled at the deserializer // level via `[JsonRequired]` on TileCoord.Z/X/Y plus // `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (AZ-795). This // validator covers the non-deserializer-detectable rules: XOR populated, // per-array entry caps, and slippy-map range constraints. public sealed class InventoryRequestValidator : AbstractValidator { public InventoryRequestValidator() { RuleFor(req => req).Custom((req, ctx) => { var hasTiles = req.Tiles is { Count: > 0 }; var hasHashes = req.LocationHashes is { Count: > 0 }; if (hasTiles == hasHashes) { ctx.AddFailure( "$", "Populate exactly one of `tiles` or `locationHashes` (sending both, or neither, is not allowed)."); } }); RuleFor(req => req.Tiles!.Count) .LessThanOrEqualTo(TileInventoryLimits.MaxEntriesPerRequest) .OverridePropertyName("tiles") .WithMessage($"`tiles` must contain at most {TileInventoryLimits.MaxEntriesPerRequest} entries.") .When(req => req.Tiles is not null); RuleFor(req => req.LocationHashes!.Count) .LessThanOrEqualTo(TileInventoryLimits.MaxEntriesPerRequest) .OverridePropertyName("locationHashes") .WithMessage($"`locationHashes` must contain at most {TileInventoryLimits.MaxEntriesPerRequest} entries.") .When(req => req.LocationHashes is not null); RuleForEach(req => req.Tiles) .SetValidator(new TileCoordValidator()) .When(req => req.Tiles is not null); } } internal sealed class TileCoordValidator : AbstractValidator { private const int MaxZoom = 22; public TileCoordValidator() { RuleFor(c => c.Z) .InclusiveBetween(0, MaxZoom) .WithMessage($"`z` must be between 0 and {MaxZoom} (slippy-map zoom range)."); RuleFor(c => c.X) .GreaterThanOrEqualTo(0) .WithMessage("`x` must be ≥ 0.") .Must((coord, x) => coord.Z >= 0 && coord.Z <= MaxZoom && x < (1L << coord.Z)) .WithMessage(coord => $"`x` must be < 2^z = {(coord.Z >= 0 && coord.Z <= MaxZoom ? (1L << coord.Z).ToString() : "")} for z={coord.Z}."); RuleFor(c => c.Y) .GreaterThanOrEqualTo(0) .WithMessage("`y` must be ≥ 0.") .Must((coord, y) => coord.Z >= 0 && coord.Z <= MaxZoom && y < (1L << coord.Z)) .WithMessage(coord => $"`y` must be < 2^z = {(coord.Z >= 0 && coord.Z <= MaxZoom ? (1L << coord.Z).ToString() : "")} for z={coord.Z}."); } }