Files
Oleksandr Bezdieniezhnykh 490902c80a [AZ-810] Strict validation for POST /api/satellite/upload metadata
Adds the per-endpoint child of AZ-795 ("Strict Input Validation Epic")
for the UAV upload multipart endpoint. Three new validators land under
SatelliteProvider.Api/Validators/:

- UavTileBatchMetadataPayloadValidator: items NotNull + NotEmpty +
  count <= MaxBatchSize + RuleForEach dispatching to the per-item
  validator.
- UavTileMetadataValidator: lat / lon / tileZoom range, tileSizeMeters
  > 0, capturedAt within [now - MaxAgeDays, now + future-skew]; uses an
  injectable TimeProvider so unit tests can drive a fixed clock.
- UavUploadValidationFilter: IEndpointFilter that reads the multipart
  `metadata` form field, deserializes it with the strict global
  JsonSerializerOptions (so UnmappedMemberHandling.Disallow +
  [JsonRequired] from AZ-795 are honored), runs the FluentValidation
  chain, and enforces the cross-field `items.Count == files.Count`
  envelope rule. FluentValidation errors are prefixed with `metadata.`
  so wire keys look like `errors["metadata.items[0].latitude"]`.

[JsonRequired] is added to every non-optional axis on
UavTileMetadata and UavTileBatchMetadataPayload; FlightId stays
nullable per AZ-503 anonymous-flight semantics.

Coverage: 13 unit tests + 16 integration tests + 1 curl probe script
exercise the happy path and every failure mode. All 9 ACs covered;
no regression in AZ-488 UavUploadTests payloads (traced against the
new rules).

Documentation: uav-tile-upload.md bumped v1.1.0 -> v1.2.0 with the
new validation rules section + 400-shape examples + changelog entry.
api_program.md updated to describe the three new validators + filter
+ the AddTransient<UavUploadValidationFilter>() DI registration.

Reports: batch_04_cycle8_report.md + reviews/batch_04_cycle8_review.md
record the PASS_WITH_WARNINGS verdict (2 Low DRY-in-tests findings:
FixedTimeProvider duplication crossed the cycle-2 "promote to shared"
threshold; PostBatch helper duplicated between two integration
suites). Both deferred to follow-up PBIs.

Task spec archived: _docs/02_tasks/todo/AZ-810... -> done/.
Jira: AZ-810 transitioned In Progress -> In Testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 13:32:19 +03:00

28 KiB
Raw Permalink Blame History

Module: Api/Program.cs

Purpose

Application entry point. Configures DI container, sets up middleware, defines minimal API endpoints, runs database migrations on startup, and starts background services.

Public Interface

API Endpoints

Method Route Handler Description
GET /tiles/{z}/{x}/{y} ServeTile Slippy map tile server with in-memory caching. AZ-505 rewired the DB lookup to filter on location_hash (deterministic UUIDv5) so the read becomes an Index Only Scan against tiles_leaflet_path; the wire response is byte-identical to pre-AZ-505.
GET /api/satellite/tiles/latlon GetTileByLatLon Download single tile by lat/lon/zoom. AZ-811 (cycle 8) renamed the query params Latitude/Longitude/ZoomLevellat/lon/zoom (OSM convention) and added strict validation: range-checked lat/lon/zoom via WithValidation<GetTileByLatLonQuery>(), plus a RejectUnknownQueryParamsEndpointFilter that rejects any extra query keys (catches typos like ?latitude= that pre-AZ-811 silently bound to 0). Contract: _docs/02_document/contracts/api/tile-latlon.md v1.0.0 + _docs/02_document/contracts/api/error-shape.md v1.0.0.
POST /api/satellite/tiles/inventory GetTilesInventory Bulk tile-existence/metadata lookup (AZ-505) — body is XOR of tiles[{z,x,y}] (Form A) and locationHashes[uuid] (Form B), each capped at 5000 entries. Response is one entry per request entry, in input order. AZ-794 (cycle 7) renamed the coord triple from tileZoom/tileX/tileYz/x/y (OSM convention); AZ-796 (cycle 7) added strict input validation via WithValidation<TileInventoryRequest>() so malformed payloads return RFC 7807 ValidationProblemDetails instead of silently coercing to zero. Contracts: _docs/02_document/contracts/api/tile-inventory.md v2.0.0 + _docs/02_document/contracts/api/error-shape.md v1.0.0.
GET /api/satellite/tiles/mgrs GetSatelliteTilesByMgrs MGRS stub (returns empty)
POST /api/satellite/upload UploadUavTileBatch UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with source='uav'. Requires the RequiresGpsPermission policy. AZ-810 (cycle 8) added a strict metadata-layer validator that runs BEFORE the quality gate via the custom UavUploadValidationFilter: 14 rules covering required fields ([JsonRequired]), per-item ranges (latitude/longitude/tileZoom/tileSizeMeters/capturedAt), envelope alignment (items.Count == files.Count), and unknown-field / type-mismatch rejection via UnmappedMemberHandling.Disallow. Errors surface as RFC 7807 ValidationProblemDetails matching error-shape.md v1.0.0 with errors["metadata.…"] keys. Contract: _docs/02_document/contracts/api/uav-tile-upload.md v1.2.0 + _docs/02_document/contracts/api/error-shape.md v1.0.0.
POST /api/satellite/request RequestRegion Queue region for async tile processing
GET /api/satellite/region/{id} GetRegionStatus Get region processing status
POST /api/satellite/route CreateRoute Create route with intermediate points. AZ-809 (cycle 8) added strict pre-handler validation via WithValidation<CreateRouteRequest>(): non-zero id, name length ∈ [1, 200], description length ≤ 1000, regionSizeMeters ∈ [100, 10000], zoomLevel ∈ [0, 22], points count ∈ [2, 500] with per-point lat/lon range checks, per-polygon NW-of-SE invariants, and the createTilesZip ⇒ requestMaps cross-field rule. Deserializer-layer failures (missing [JsonRequired] axes, unknown fields, type mismatches) are caught by GlobalExceptionHandler and produce the same RFC 7807 envelope. Contract: _docs/02_document/contracts/api/route-creation.md v1.0.0 + _docs/02_document/contracts/api/error-shape.md v1.0.0.
GET /api/satellite/route/{id} GetRoute Get route with all points

Local Records (defined in Program.cs)

  • GetSatelliteTilesResponse, SatelliteTile — MGRS response stubs
  • DownloadTileResponse — tile download response
  • ParameterDescriptionFilter — Swagger operation filter (AZ-811 cycle 8 trimmed the obsolete Latitude/Longitude/ZoomLevel entries; the surviving lat/lon/mgrs/squareSideMeters keys still annotate query-string params)

Api/Validators (AZ-795 epic, AZ-808/AZ-809/AZ-811 cycle 8)

  • RejectUnknownQueryParamsEndpointFilterIEndpointFilter parameterized by an allowed-keys set; rejects unknown query-string parameters with RFC 7807 ValidationProblemDetails. Apply BEFORE WithValidation<T>() so unknown-param errors precede range checks against the bound default value.
  • GetTileByLatLonQueryValidatorAbstractValidator<GetTileByLatLonQuery> with lat/lon/zoom rules. Each rule chains Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween so a missing param surfaces ONLY as "\` is required."` (no spurious range error against a null sentinel).
  • RegionRequestValidator (AZ-808 cycle 8) — AbstractValidator<RequestRegionRequest>. Post-deserialization business rules: non-zero id, lat/lon ranges, sizeMeters ∈ [100, 10000], zoomLevel ∈ [0, 22]. Required-field detection lives at the deserializer layer ([JsonRequired] + UnmappedMemberHandling.Disallow).
  • CreateRouteRequestValidator + RoutePointValidator + GeofencePolygonValidator (AZ-809 cycle 8) — three FluentValidation validators for the route-creation endpoint. The root validator chains RuleForEach(req => req.Points).SetValidator(new RoutePointValidator()) for per-point checks and RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator()).OverridePropertyName("geofences.polygons") for per-polygon checks. The OverridePropertyName on the geofences chain restores the full wire path (geofences.polygons[i].northWest) because FluentValidation's default name policy drops the parent on deep expressions like req.Geofences!.Polygons. RoutePointValidator uses OverridePropertyName("lat"/"lon") after each range rule so error keys match the wire format (lat/lon) rather than the camelCased C# names (latitude/longitude). The cross-field rule createTilesZip ⇒ requestMaps lives on the root via Must(req => !(req.CreateTilesZip && !req.RequestMaps)).WithName("createTilesZip").
  • UavTileBatchMetadataPayloadValidator + UavTileMetadataValidator (AZ-810 cycle 8) — FluentValidation validators for the UAV upload metadata envelope. Root validator runs items count rules (non-null, non-empty, ≤ UavQualityConfig.MaxBatchSize) then RuleForEach(p => p.Items).SetValidator(new UavTileMetadataValidator(...)) so per-item errors come out as items[i].<field> (then prefixed with metadata. by UavUploadValidationFilter). Per-item rules: latitude ∈ [-90, 90], longitude ∈ [-180, 180], tileZoom ∈ [0, 22], tileSizeMeters > 0, capturedAt within \[now - MaxAgeDays, now + CapturedAtFutureSkewSeconds\]. flightId is intentionally NOT validated beyond JSON shape — AZ-503 anonymous-flight semantics require null to be valid, and malformed UUID strings are already rejected at the deserializer with a JsonException. The freshness check uses an injectable TimeProvider (defaults to TimeProvider.System) so unit tests can drive it with a fixed clock.
  • UavUploadValidationFilter (AZ-810 cycle 8) — endpoint filter for POST /api/satellite/upload. The endpoint is multipart/form-data so the generic WithValidation<T>() JSON-body filter cannot bind directly; this filter reads the metadata form field, deserializes it via the strict global JsonSerializerOptions (so UnmappedMemberHandling.Disallow + [JsonRequired] from AZ-795 are honored), runs IValidator<UavTileBatchMetadataPayload> from DI, and enforces the cross-field items.Count == files.Count rule. Error-map keys from the per-item validator are prefixed with metadata. so paths surface to the caller as errors["metadata.items[0].latitude"]. Registered as a transient via AddTransient<UavUploadValidationFilter>() and wired on the endpoint with .AddEndpointFilter<UavUploadValidationFilter>(). The downstream IUavTileUploadHandler retains its own envelope checks as defence-in-depth (covers direct handler callers in unit tests).

Api/DTOs (AZ-811 cycle 8)

  • GetTileByLatLonQueryrecord GetTileByLatLonQuery(double? Lat, double? Lon, int? Zoom) with [FromQuery(Name="lat"|"lon"|"zoom")] on each property. Bound via [AsParameters] on the GetTileByLatLon handler. Nullable on purpose: minimal-API binding throws BadHttpRequestException for missing non-nullable query params BEFORE endpoint filters run; that short-circuit produces a plain ProblemDetails via GlobalExceptionHandler with no errors{} envelope. Nullable types let binding always succeed so the envelope filter + validator handle the failure surface uniformly per error-shape.md v1.0.0. The handler dereferences .Value only after the validator filter passes.

Common/DTO (region API)

  • RequestRegionRequestPOST /api/satellite/request body. Moved out of Program.cs by AZ-369. Fields: Id (Guid), Lat/Lon (double, JSON lat/lon per AZ-812 cycle 8 OSM rename), SizeMeters, ZoomLevel (int, default 18), StitchTiles (bool, default false).

Api/DTOs (AZ-488)

  • UavTileBatchUploadRequest — multipart envelope with metadata (JSON string) and files (IFormFileCollection)

Common/DTO (AZ-488)

  • UavTileMetadata, UavTileBatchMetadataPayload — per-item metadata + envelope shape. AZ-810 cycle 8 added [JsonRequired] to every non-optional axis (latitude, longitude, tileZoom, tileSizeMeters, capturedAt on the per-item record; items on the envelope) so the deserializer rejects partial payloads with HTTP 400 before the FluentValidation + IUavTileQualityGate layers run. flightId stays optional per AZ-503 anonymous-flight semantics.
  • UavTileBatchUploadResponse, UavTileUploadResultItem — per-item response shape
  • UavTileUploadStatus, UavTileRejectReasons — string-constant enumerations exposed in the v1.0.0 contract

Common/DTO (AZ-505; renamed by AZ-794 in cycle 7)

  • TileInventoryRequest — XOR body envelope with Tiles (Form A) OR LocationHashes (Form B)
  • TileCoord{Z, X, Y} per-entry coord under Form A. Each property is marked [JsonRequired] so missing axes surface as 400 at the deserializer layer (System.Text.Json throws, GlobalExceptionHandler converts to ValidationProblemDetails).
  • TileInventoryResponse{Results: TileInventoryEntry[]} response shape; ordering matches request
  • TileInventoryEntry — per-entry response shape (Z, X, Y, LocationHash, Present, optional Id/CapturedAt/Source/FlightId/ResolutionMPerPx)
  • TileInventoryLimits.MaxEntriesPerRequest — hard cap (5000) consumed by InventoryRequestValidator

Api/Validators (AZ-795 + AZ-796, cycle 7)

  • InventoryRequestValidator — FluentValidation AbstractValidator<TileInventoryRequest>. Rules: XOR tiles/locationHashes, tiles.Count ≤ MaxEntriesPerRequest, locationHashes.Count ≤ MaxEntriesPerRequest, per-entry TileCoordValidator.
  • TileCoordValidator — per-entry rules: Z ∈ [0, 22] (slippy-map range), X ∈ [0, 2^Z), Y ∈ [0, 2^Z).
  • ValidationEndpointFilter<T> — generic minimal-API filter that resolves IValidator<T> from DI, runs it against the bound argument, and returns Results.ValidationProblem(result.ToDictionary()) on failure. Wired per-endpoint via RouteHandlerBuilder.WithValidation<T>().
  • GlobalValidatorConfig.ApplyOnce() — idempotent process-wide FluentValidation configuration. Sets ValidatorOptions.Global.PropertyNameResolver so error map keys are camelCase per error-shape.md Inv-4. Called from Program.cs and from the test assembly's ValidatorTestModuleInitializer so both contexts see identical key shapes.

Api/GlobalExceptionHandler (AZ-795, cycle 7)

  • GlobalExceptionHandler : IExceptionHandler — registered via AddExceptionHandler<GlobalExceptionHandler>() + AddProblemDetails(). Intercepts unhandled exceptions and converts BadHttpRequestException(JsonException) (unknown-member rejection, missing-required-field, type mismatch) into RFC 7807 ValidationProblemDetails matching the FluentValidation output shape (single source of truth — see error-shape.md v1.0.0 §"Both paths produce identically-shaped bodies"). 5xx errors pass through with sanitised body + correlationId (preserves AZ-353).

Internal Logic

DI Registration

  1. Serilog configured from appsettings.json
  2. Connection string extracted from ConnectionStrings:DefaultConnection
  3. Config bindings: MapConfig, StorageConfig, ProcessingConfig, UavQualityConfig (AZ-488)
  4. Request size limits (AZ-488): KestrelServerOptions.Limits.MaxRequestBodySize and FormOptions.MultipartBodyLengthLimit are set to UavQualityConfig.MaxBatchSize × UavQualityConfig.MaxBytes (default 100 × 5 MiB = 500 MiB) so an oversized UAV batch is rejected at the framework layer before reaching the handler.
  5. Singletons: repositories (TileRepository, RegionRepository, RouteRepository), GoogleMapsDownloaderV2, ITileService, IRegionService, IRouteService, IUavTileQualityGate, IUavTileUploadHandler (AZ-488)
  6. IRegionRequestQueue with configurable capacity
  7. Hosted services: RegionProcessingService, RouteProcessingService
  8. CORS policy: TilesCors — configured origins from CorsConfig:AllowedOrigins, falls back to allow-any
  9. JSON options: camelCase, case-insensitive
  10. JWT authentication (AZ-487 + AZ-494): AddSatelliteJwt(builder.Configuration) (extension in SatelliteProvider.Api.Authentication) registers JwtBearer with TokenValidationParameters set per the suite auth contract: signature + lifetime + issuer + audience validation, 30 s clock skew, ≥ 32-byte HMAC key. The iss value comes from JWT_ISSUER env (fallback Jwt:Issuer config); the aud value comes from JWT_AUDIENCE env (fallback Jwt:Audience config). All three values (secret, iss, aud) are fail-fast — the API throws InvalidOperationException at startup if any is unset or whitespace-only. Production deploys MUST set the env vars with admin-team-confirmed values; appsettings.json ships empty so the fail-fast triggers. appsettings.Development.json ships clearly-tagged DEV-ONLY values (DEV-ONLY-iss-admin-azaion-local / DEV-ONLY-aud-satellite-provider) so local dev works out-of-the-box. Followed by AddAuthorization with the RequiresGpsPermission policy (AZ-488).
  11. Kestrel HTTP/2 (AZ-505): builder.WebHost.ConfigureKestrel(opts => opts.ConfigureEndpointDefaults(lo => lo.Protocols = HttpProtocols.Http1AndHttp2)). The dev listener is now https://+:8080 with a self-signed cert (./certs/api.pfx, generated idempotently by scripts/run-tests.sh and bound via ASPNETCORE_Kestrel__Certificates__Default__Path / __Password in docker-compose.yml). Kestrel needs TLS for HTTP/2 protocol negotiation; ALPN advertises both h2 and http/1.1 so HTTP/2-capable clients (browser Leaflet, HttpClient with Version20 + RequestVersionExact, httpx http2=True) multiplex tile reads on a single TLS connection, and legacy clients fall back to HTTP/1.1. The integration-test container trusts the dev cert via /usr/local/share/ca-certificates/ + update-ca-certificates. AZ-505 AC-5 verifies the multiplex semantics here; production termination is expected at the ingress (Envoy / nginx / ALB) — Kestrel can then drop to HTTP/2 cleartext behind it without changing this code.
  12. ProblemDetails + global exception handler (AZ-795, cycle 7): AddProblemDetails() + AddExceptionHandler<GlobalExceptionHandler>() register the uniform RFC 7807 error pipeline. app.UseExceptionHandler() (in the middleware chain) routes unhandled exceptions through GlobalExceptionHandler, which converts BadHttpRequestException(JsonException) (unknown-member rejection, missing-required-field, JSON type mismatch) into ValidationProblemDetails with the same errors[] map shape that FluentValidation produces. This is the deserializer-layer half of the strict-validation contract — error-shape.md v1.0.0 §"Two collaborating pieces of shared infrastructure".
  13. Strict JSON parsing (AZ-795, cycle 7): ConfigureHttpJsonOptions sets PropertyNamingPolicy = CamelCase, PropertyNameCaseInsensitive = true, UnmappedMemberHandling = Disallow, and adds JsonStringEnumConverter with camelCase naming. UnmappedMemberHandling.Disallow is the key strict-parsing knob: any unknown root or nested field is rejected at the deserializer rather than silently dropped. Catches typos ({"Z":12} uppercase, {"tileZoom":...} post-rename) that no FluentValidation rule can see after deserialization.
  14. FluentValidation registration (AZ-795 + AZ-796, cycle 7): AddValidatorsFromAssemblyContaining<Program>() auto-registers every IValidator<T> in the API assembly (currently InventoryRequestValidator + TileCoordValidator, AZ-808 RegionRequestValidator, AZ-809 CreateRouteRequestValidator + RoutePointValidator + GeofencePolygonValidator, AZ-810 UavTileBatchMetadataPayloadValidator + UavTileMetadataValidator, AZ-811 GetTileByLatLonQueryValidator). GlobalValidatorConfig.ApplyOnce() runs the idempotent process-wide config — sets ValidatorOptions.Global.PropertyNameResolver so errors map keys are camelCase (matches the request body's casing per error-shape.md Inv-4). Per-endpoint opt-in via .WithValidation<T>() on the JSON-body endpoints — the generic ValidationEndpointFilter<T> resolves the validator from DI at request time and returns Results.ValidationProblem on failure.
  15. AZ-810 multipart validation filter (cycle 8): AddTransient<UavUploadValidationFilter>() registers the bespoke filter used by POST /api/satellite/upload. The endpoint is multipart/form-data so the generic .WithValidation<T>() JSON-body filter cannot bind; this filter reads the metadata form field, deserializes it via the strict global JsonSerializerOptions, runs the FluentValidation chain, and enforces the cross-field items.Count == files.Count envelope rule. Wired on the endpoint with .AddEndpointFilter<UavUploadValidationFilter>() between .RequireAuthorization(SatellitePermissions.UavUploadPolicy) and the metadata accept/produces annotations.

Startup

  1. Database migration via DatabaseMigrator.RunMigrations() — throws on failure
  2. Creates tiles and ready directories
  3. Swagger enabled in Development mode
  4. Middleware chain (order matters): UseExceptionHandlerUseHttpsRedirectionUseCors("TilesCors")UseAuthenticationUseAuthorization → endpoint mapping.
  5. Every MapGet/MapPost endpoint is decorated with .RequireAuthorization(); the framework returns 401 before the handler runs for any anonymous, expired, or invalid-signature request.

ServeTile Handler

  1. Checks IMemoryCache for tile bytes (1h absolute, 30min sliding expiration)
  2. If cache miss: queries ITileRepository.GetByTileCoordinatesAsync — AZ-505 rewired this method to compute location_hash = Uuidv5(TileNamespace, "{z}/{x}/{y}") and filter by WHERE location_hash = $1, hitting tiles_leaflet_path as an Index Only Scan with Heap Fetches ≤ 1. Selection rule is unchanged (most-recent across sources/flights); wire response is byte-identical.
  3. If no DB record: downloads tile via GoogleMapsDownloaderV2.DownloadSingleTileAsync, creates TileEntity, inserts
  4. Returns image bytes with cache headers (Cache-Control: public, max-age=86400)

GetTilesInventory Handler (AZ-505 + AZ-796 cycle 7)

  1. Pre-handler validation (cycle 7): ValidationEndpointFilter<TileInventoryRequest> runs BEFORE the handler. Resolves InventoryRequestValidator from DI and asserts XOR tiles/locationHashes, per-array cap (TileInventoryLimits.MaxEntriesPerRequest = 5000), z ∈ [0, 22], x ∈ [0, 2^z), y ∈ [0, 2^z) per entry. Any failure short-circuits with HTTP 400 + ValidationProblemDetails. Deserializer-layer failures (missing z/x/y, unknown root/nested fields, JSON type mismatch) are caught earlier by System.Text.Json and surfaced as identically-shaped ValidationProblemDetails via GlobalExceptionHandler (AZ-795).
  2. Handler delegates to ITileService.GetInventoryAsync(request, ct) — body of the handler is just the service call + Results.Ok.
  3. Service computes location_hash for Form A entries via Uuidv5.Create(TileNamespace, "{z}/{x}/{y}"), calls ITileRepository.GetTilesByLocationHashesAsync(IReadOnlyList<Guid>), re-aligns results back to input order.
  4. Returns TileInventoryResponse with one entry per input — present=true entries carry id / capturedAt / source / flightId / resolutionMPerPx; present=false entries carry only locationHash.
  5. Authenticated by .RequireAuthorization() (401 before validation runs for anonymous requests).

GetTileByLatLon Handler

Binds [AsParameters] GetTileByLatLonQuery (record with nullable [FromQuery(Name="lat"|"lon"|"zoom")] properties — see Api/DTOs for nullability rationale). Wire-format params are OSM-short lat/lon/zoom post-AZ-811. Strict validation is layered:

  1. RejectUnknownQueryParamsEndpointFilter(new[] {"lat","lon","zoom"}) runs first — rejects any unexpected query key (e.g. ?latitude= typo, or hostile fingerprinting probes) with RFC 7807 ValidationProblemDetails and an errors[<paramName>] entry.
  2. WithValidation<GetTileByLatLonQuery>() runs second — checks NotNull (missing param → errors[<paramName>]: "\` is required.") and InclusiveBetween (lat∈ [-90, 90],lon∈ [-180, 180],zoom∈ [0, 22]).CascadeMode.Stop` ensures null short-circuits the range check.
  3. Handler dereferences query.Lat!.Value, query.Lon!.Value, query.Zoom!.Value (validator guarantees non-null), delegates to ITileService.DownloadAndStoreSingleTileAsync(lat, lon, zoom), and returns DownloadTileResponse.

The two filter layers produce identically-shaped ProblemDetails bodies. The RejectUnknownQueryParamsEndpointFilter is reusable — register it once per allowed-key set on any future query-string endpoint that needs the same shape-strictness.

RequestRegion Handler

AZ-808 (cycle 8) added strict pre-handler validation via .WithValidation<RequestRegionRequest>(): non-zero id, lat/lon ranges, sizeMeters ∈ [100, 10000], zoomLevel ∈ [0, 22]. Missing [JsonRequired] axes / unknown root fields are caught at the deserializer layer by GlobalExceptionHandler. Post-validation, delegates to IRegionService.RequestRegionAsync.

CreateRoute Handler (AZ-809 cycle 8)

Pre-handler validation via .WithValidation<CreateRouteRequest>(). Layered defence:

  1. Deserializer layer (System.Text.Json + GlobalExceptionHandler)[JsonRequired] markers on CreateRouteRequest.{Id, Name, RegionSizeMeters, ZoomLevel, Points, RequestMaps, CreateTilesZip}, on RoutePoint.{Latitude, Longitude}, on Geofences.Polygons, on GeofencePolygon.{NorthWest, SouthEast}, and on GeoPoint.{Lat, Lon} catch missing-field payloads; UnmappedMemberHandling.Disallow catches unknown root + nested fields; type mismatches surface as JsonException. All three surface as HTTP 400 + ValidationProblemDetails.
  2. Validator layer (CreateRouteRequestValidator + RoutePointValidator + GeofencePolygonValidator) — non-zero id, name/description length caps, regionSizeMeters ∈ [100, 10000], zoomLevel ∈ [0, 22], points count ∈ [2, 500] with per-point range checks (error keys points[i].lat / points[i].lon), per-polygon corner ranges + NW.Lat > SE.Lat + NW.Lon < SE.Lon invariants (error keys geofences.polygons[i].northWest), and the createTilesZip ⇒ requestMaps cross-field rule.
  3. Handler — receives a fully-validated CreateRouteRequest and delegates to IRouteService.CreateRouteAsync. The route service's own legacy RouteValidator (in SatelliteProvider.Services.RouteManagement) still runs as a defence-in-depth backstop — its checks are now strictly weaker than the validator-layer rules; tracked as an advisory clean-up in route-creation.md. Authenticated by .RequireAuthorization() (401 before validation runs for anonymous requests).

UploadUavTileBatch Handler (AZ-488)

Buffers each IFormFile into memory, packages them as UavUploadFile records (filename, content-type, bytes), and delegates to IUavTileUploadHandler.HandleAsync. Envelope-level errors (mismatched batch, oversized batch, malformed metadata) are surfaced as HTTP 400 ProblemDetails; per-item rejects are returned in the HTTP 200 response payload. The endpoint is protected by .RequireAuthorization(SatellitePermissions.UavUploadPolicy) so 401 (no token) and 403 (no GPS permission) are returned before the handler runs.

Dependencies

All project references: Common, DataAccess, Services. NuGet: Serilog.AspNetCore (8.0.3 — fallback retained on .NET 10 per AZ-500 Risk #4: no 10.x line published as of cycle 4; documented in AGENTS.md), Swashbuckle.AspNetCore (10.1.7 — bumped from 6.6.2 by AZ-500 to land Microsoft.OpenApi 2.x compat required by ASP.NET Core 10), Microsoft.AspNetCore.OpenApi (10.0.7 — bumped from 8.0.25 by AZ-500), Microsoft.AspNetCore.Authentication.JwtBearer (10.0.7 — added at 8.0.21 by AZ-487, bumped to 8.0.25 by AZ-496, bumped to 10.0.7 by AZ-500), FluentValidation + FluentValidation.DependencyInjectionExtensions (12.0.0 — added by AZ-795 to back the strict-input-validation epic), SixLabors.ImageSharp, Newtonsoft.Json.

Microsoft.OpenApi 2.x refactor note (AZ-500): the major bump (1.x → 2.x) drove three internal Swashbuckle-setup edits in this file — using Microsoft.OpenApi.Models;using Microsoft.OpenApi;; AddSecurityRequirement(...) rewritten to take a Func<OpenApiDocument, OpenApiSecurityRequirement> and use OpenApiSecuritySchemeReference("Bearer") instead of the removed OpenApiSecurityScheme.Reference shape; MapType<UavTileBatchUploadRequest> rewritten to use the new JsonSchemaType enum and IDictionary<string, IOpenApiSchema> properties bag. The Swagger document shape (paths, operations, the Bearer Authorize button, the multipart-batch upload schema) is preserved exactly — SwaggerDocument_AdvertisesBearerSecurityScheme and the AZ-353 swagger-ready integration assertions still pass. Eight ASPDEPR002 deprecation warnings (WithOpenApi(...)) remain — they're recorded in _docs/03_implementation/reviews/batch_01_cycle4_review.md as a follow-up PBI; the API is still fully functional in .NET 10 (deprecated, not removed).

Consumers

  • HTTP clients (external)
  • Integration tests (via HTTP)

Data Models

Defines several local request/response records that are not shared with other projects.

Configuration

All configuration sections are consumed here:

  • ConnectionStrings:DefaultConnection
  • MapConfig, StorageConfig, ProcessingConfig
  • UavQuality (AZ-488) — MinBytes, MaxBytes, MaxAgeDays, CapturedAtFutureSkewSeconds, MinLuminanceVariance, MaxBatchSize, LuminanceSampleSize. Drives the 5-rule quality gate AND the per-request body-size limits.
  • CorsConfig:AllowedOrigins
  • Jwt:Secret — HMAC-SHA256 signing key for JWT validation (AZ-487). Resolution: JWT_SECRET env var (preferred, opaque production secret) → Jwt:Secret configuration key (appsettings.Development.json placeholder only). Startup fails fast if the resolved value is unset, empty, or shorter than 32 bytes.
  • Jwt:Issuer — Expected iss claim value (AZ-494). Resolution: JWT_ISSUER env → Jwt:Issuer config. Startup fails fast if unset/empty.
  • Jwt:Audience — Expected aud claim value (AZ-494). Resolution: JWT_AUDIENCE env → Jwt:Audience config. Startup fails fast if unset/empty.
  • Serilog section

External Integrations

  • Google Maps (indirectly via GoogleMapsDownloaderV2)
  • PostgreSQL (via repositories and DatabaseMigrator)
  • File system (./tiles/, ./ready/)

Security

  • CORS configured (permissive by default when no origins specified)
  • Swagger only in Development; Bearer token "Authorize" button registered via AddSecurityDefinition/AddSecurityRequirement (AZ-487)
  • HTTPS redirection enabled
  • JWT bearer authentication (AZ-487) — every endpoint requires a valid HS256-signed token. Anonymous, expired, or signature-tampered requests return 401 before the handler runs.
  • Permission-claim policies (AZ-488) — POST /api/satellite/upload is wrapped in .RequireAuthorization(SatellitePermissions.UavUploadPolicy). The PermissionsAuthorizationHandler reads the permissions claim (repeated-string OR JSON-array shape) and returns 403 if GPS is not present.

Tests

Integration tests exercise all endpoints. Unit test project has only a dummy test.