using FluentValidation; using SatelliteProvider.Common.DTO; namespace SatelliteProvider.Api.Validators; // AZ-809: FluentValidation rules for POST /api/satellite/route. 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 is handled at the deserializer level via // [JsonRequired] on CreateRouteRequest, RoutePoint, GeofencePolygon, and // GeoPoint, plus JsonSerializerOptions.UnmappedMemberHandling.Disallow // (AZ-795 global). This validator covers post-deserialization business // rules: non-zero id, name + description length, range checks on size / // zoom / points-count, per-point lat/lon ranges (via RoutePointValidator), // per-polygon corner ranges + NW-of-SE invariant (via GeofencePolygonValidator), // and the cross-field createTilesZip-implies-requestMaps rule. public sealed class CreateRouteRequestValidator : AbstractValidator { private const double MinRegionSizeMeters = 100.0; private const double MaxRegionSizeMeters = 10000.0; private const int MinZoom = 0; private const int MaxZoom = 22; private const int MinPoints = 2; private const int MaxPoints = 500; private const int MaxNameLength = 200; private const int MaxDescriptionLength = 1000; // Geofences are axis-aligned bbox rectangles used for AOI restriction // during route planning (see route-creation.md). Realistic use is 1-10 // polygons per route; cap at 50 to give 5x headroom while bounding the // validator's worst-case allocation. The global Kestrel body limit // (500 MiB, sized for the UAV upload endpoint) is not a useful gate // here because polygon JSON is small (~90 bytes per minimum-shape // polygon); without this cap a single authenticated request could // submit millions of polygons and saturate the LOH. private const int MaxPolygons = 50; public CreateRouteRequestValidator() { RuleFor(req => req.Id) .NotEmpty() .WithMessage("`id` must be a non-zero GUID (the caller's idempotency key)."); RuleFor(req => req.Name) .NotEmpty() .WithMessage("`name` is required and must not be empty or whitespace.") .MaximumLength(MaxNameLength) .WithMessage($"`name` must be at most {MaxNameLength} characters."); RuleFor(req => req.Description) .MaximumLength(MaxDescriptionLength) .When(req => req.Description is not null) .WithMessage($"`description` must be at most {MaxDescriptionLength} characters."); RuleFor(req => req.RegionSizeMeters) .InclusiveBetween(MinRegionSizeMeters, MaxRegionSizeMeters) .WithMessage($"`regionSizeMeters` must be between {MinRegionSizeMeters} and {MaxRegionSizeMeters} meters."); RuleFor(req => req.ZoomLevel) .InclusiveBetween(MinZoom, MaxZoom) .WithMessage($"`zoomLevel` must be between {MinZoom} and {MaxZoom} (slippy-map range)."); RuleFor(req => req.Points) .NotNull().WithMessage("`points` is required.") .Must(p => p is null || p.Count >= MinPoints) .WithMessage($"`points` must contain at least {MinPoints} entries.") .Must(p => p is null || p.Count <= MaxPoints) .WithMessage($"`points` must contain at most {MaxPoints} entries."); RuleForEach(req => req.Points) .SetValidator(new RoutePointValidator()); // Geofences are optional; per-polygon rules apply only when present. // FluentValidation's default property-name policy drops the parent // chain on deep expressions like `req.Geofences!.Polygons` — it emits // only the leaf `polygons`. We OverridePropertyName explicitly so the // wire-format error keys match the JSON path callers actually post: // `errors["geofences.polygons"]` and `errors["geofences.polygons[i].…"]`. When(req => req.Geofences is not null, () => { RuleFor(req => req.Geofences!.Polygons) .NotNull().WithMessage("`geofences.polygons` is required when `geofences` is present.") .NotEmpty().WithMessage("`geofences.polygons` must contain at least 1 polygon when `geofences` is present.") .Must(polygons => polygons is null || polygons.Count <= MaxPolygons) .WithMessage($"`geofences.polygons` must contain at most {MaxPolygons} polygons.") .OverridePropertyName("geofences.polygons"); RuleForEach(req => req.Geofences!.Polygons) .SetValidator(new GeofencePolygonValidator()) .OverridePropertyName("geofences.polygons"); }); // Cross-field invariant: cannot zip what wasn't downloaded. RuleFor(req => req) .Must(req => !(req.CreateTilesZip && !req.RequestMaps)) .WithName("createTilesZip") .WithMessage("`createTilesZip` requires `requestMaps` to be true (can't zip what wasn't downloaded)."); } }