AZ-808: FluentValidation for POST /api/satellite/request - RegionRequestValidator: id non-empty, lat/lon/sizeMeters/zoomLevel ranges - RequestRegionRequest: [JsonRequired] on every property, no implicit defaults - Wired via .WithValidation<RequestRegionRequest>() in MapPost chain - Unit + integration tests + curl probe script - New contract: contracts/api/region-request.md v1.0.0 AZ-811: FluentValidation + envelope filter for GET /api/satellite/tiles/latlon - GetTileByLatLonQuery: nullable record (double?/int?) so the minimal-API binder never short-circuits with BadHttpRequestException before filters - GetTileByLatLonQueryValidator: Cascade(Stop) + NotNull + InclusiveBetween per param; missing surfaces as `\`<name>\` is required.` - RejectUnknownQueryParamsEndpointFilter: reusable IEndpointFilter that rejects any query key outside the allowed set with errors[<key>] map; catches legacy `?Latitude=` typos and hostile probes (`?debug=1&admin=1`) - Handler: [AsParameters] GetTileByLatLonQuery + .Value deref post-validator - Unit (validator + filter) + integration tests + curl probe script - New contract: contracts/api/tile-latlon.md v1.0.0 Shared hygiene - Promote AssertErrorsContainsMention from per-test-file private helpers to ProblemDetailsAssertions (closes batch-1 Low-severity DRY warning) - Sync Swagger param descriptions, README, blackbox/security/perf scripts, uuidv5 doc with the new lat/lon/zoom query-param names Docs - system-flows.md F1/F2 reference the new contracts + validation layers - modules/api_program.md adds Api/Validators + Api/DTOs sections - _autodev_state.md: batch 2 of 4 complete; next batch = AZ-809 All smoke tests green (mode=smoke, exit 0). AZ-808 + AZ-811 transitioned to In Testing on Jira. Co-authored-by: Cursor <cursoragent@cursor.com>
21 KiB
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/ZoomLevel → lat/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/tileY → z/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. |
| 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 |
| GET | /api/satellite/route/{id} |
GetRoute |
Get route with all points |
Local Records (defined in Program.cs)
GetSatelliteTilesResponse,SatelliteTile— MGRS response stubsDownloadTileResponse— tile download responseParameterDescriptionFilter— Swagger operation filter (AZ-811 cycle 8 trimmed the obsoleteLatitude/Longitude/ZoomLevelentries; the survivinglat/lon/mgrs/squareSideMeterskeys still annotate query-string params)
Api/Validators (AZ-795 epic, AZ-811 cycle 8)
RejectUnknownQueryParamsEndpointFilter—IEndpointFilterparameterized by an allowed-keys set; rejects unknown query-string parameters with RFC 7807ValidationProblemDetails. Apply BEFOREWithValidation<T>()so unknown-param errors precede range checks against the bound default value.GetTileByLatLonQueryValidator—AbstractValidator<GetTileByLatLonQuery>withlat/lon/zoomrules. Each rule chainsCascade(CascadeMode.Stop) → NotNull → InclusiveBetweenso a missing param surfaces ONLY as"\` is required."` (no spurious range error against a null sentinel).
Api/DTOs (AZ-811 cycle 8)
GetTileByLatLonQuery—record GetTileByLatLonQuery(double? Lat, double? Lon, int? Zoom)with[FromQuery(Name="lat"|"lon"|"zoom")]on each property. Bound via[AsParameters]on theGetTileByLatLonhandler. Nullable on purpose: minimal-API binding throwsBadHttpRequestExceptionfor missing non-nullable query params BEFORE endpoint filters run; that short-circuit produces a plainProblemDetailsviaGlobalExceptionHandlerwith noerrors{}envelope. Nullable types let binding always succeed so the envelope filter + validator handle the failure surface uniformly pererror-shape.mdv1.0.0. The handler dereferences.Valueonly after the validator filter passes.
Common/DTO (region API)
RequestRegionRequest—POST /api/satellite/requestbody. Moved out of Program.cs by AZ-369. Fields:Id(Guid),Lat/Lon(double, JSONlat/lonper AZ-812 cycle 8 OSM rename),SizeMeters,ZoomLevel(int, default 18),StitchTiles(bool, default false).
Api/DTOs (AZ-488)
UavTileBatchUploadRequest— multipart envelope withmetadata(JSON string) andfiles(IFormFileCollection)
Common/DTO (AZ-488)
UavTileMetadata,UavTileBatchMetadataPayload— per-item metadata + envelope shapeUavTileBatchUploadResponse,UavTileUploadResultItem— per-item response shapeUavTileUploadStatus,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 withTiles(Form A) ORLocationHashes(Form B)TileCoord—{Z, X, Y}per-entry coord under Form A. Each property is marked[JsonRequired]so missing axes surface as400at the deserializer layer (System.Text.Json throws,GlobalExceptionHandlerconverts toValidationProblemDetails).TileInventoryResponse—{Results: TileInventoryEntry[]}response shape; ordering matches requestTileInventoryEntry— per-entry response shape (Z,X,Y,LocationHash,Present, optionalId/CapturedAt/Source/FlightId/ResolutionMPerPx)TileInventoryLimits.MaxEntriesPerRequest— hard cap (5000) consumed byInventoryRequestValidator
Api/Validators (AZ-795 + AZ-796, cycle 7)
InventoryRequestValidator— FluentValidationAbstractValidator<TileInventoryRequest>. Rules: XORtiles/locationHashes,tiles.Count ≤ MaxEntriesPerRequest,locationHashes.Count ≤ MaxEntriesPerRequest, per-entryTileCoordValidator.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 resolvesIValidator<T>from DI, runs it against the bound argument, and returnsResults.ValidationProblem(result.ToDictionary())on failure. Wired per-endpoint viaRouteHandlerBuilder.WithValidation<T>().GlobalValidatorConfig.ApplyOnce()— idempotent process-wide FluentValidation configuration. SetsValidatorOptions.Global.PropertyNameResolverso error map keys are camelCase pererror-shape.mdInv-4. Called fromProgram.csand from the test assembly'sValidatorTestModuleInitializerso both contexts see identical key shapes.
Api/GlobalExceptionHandler (AZ-795, cycle 7)
GlobalExceptionHandler : IExceptionHandler— registered viaAddExceptionHandler<GlobalExceptionHandler>()+AddProblemDetails(). Intercepts unhandled exceptions and convertsBadHttpRequestException(JsonException)(unknown-member rejection, missing-required-field, type mismatch) into RFC 7807ValidationProblemDetailsmatching the FluentValidation output shape (single source of truth — seeerror-shape.mdv1.0.0 §"Both paths produce identically-shaped bodies"). 5xx errors pass through with sanitised body +correlationId(preserves AZ-353).
Internal Logic
DI Registration
- Serilog configured from
appsettings.json - Connection string extracted from
ConnectionStrings:DefaultConnection - Config bindings:
MapConfig,StorageConfig,ProcessingConfig,UavQualityConfig(AZ-488) - Request size limits (AZ-488):
KestrelServerOptions.Limits.MaxRequestBodySizeandFormOptions.MultipartBodyLengthLimitare set toUavQualityConfig.MaxBatchSize × UavQualityConfig.MaxBytes(default 100 × 5 MiB = 500 MiB) so an oversized UAV batch is rejected at the framework layer before reaching the handler. - Singletons: repositories (
TileRepository,RegionRepository,RouteRepository),GoogleMapsDownloaderV2,ITileService,IRegionService,IRouteService,IUavTileQualityGate,IUavTileUploadHandler(AZ-488) IRegionRequestQueuewith configurable capacity- Hosted services:
RegionProcessingService,RouteProcessingService - CORS policy:
TilesCors— configured origins fromCorsConfig:AllowedOrigins, falls back to allow-any - JSON options: camelCase, case-insensitive
- JWT authentication (AZ-487 + AZ-494):
AddSatelliteJwt(builder.Configuration)(extension inSatelliteProvider.Api.Authentication) registersJwtBearerwithTokenValidationParametersset per the suite auth contract: signature + lifetime + issuer + audience validation, 30 s clock skew, ≥ 32-byte HMAC key. Theissvalue comes fromJWT_ISSUERenv (fallbackJwt:Issuerconfig); theaudvalue comes fromJWT_AUDIENCEenv (fallbackJwt:Audienceconfig). All three values (secret, iss, aud) are fail-fast — the API throwsInvalidOperationExceptionat startup if any is unset or whitespace-only. Production deploys MUST set the env vars with admin-team-confirmed values;appsettings.jsonships empty so the fail-fast triggers.appsettings.Development.jsonships 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 byAddAuthorizationwith theRequiresGpsPermissionpolicy (AZ-488). - Kestrel HTTP/2 (AZ-505):
builder.WebHost.ConfigureKestrel(opts => opts.ConfigureEndpointDefaults(lo => lo.Protocols = HttpProtocols.Http1AndHttp2)). The dev listener is nowhttps://+:8080with a self-signed cert (./certs/api.pfx, generated idempotently byscripts/run-tests.shand bound viaASPNETCORE_Kestrel__Certificates__Default__Path/__Passwordindocker-compose.yml). Kestrel needs TLS for HTTP/2 protocol negotiation; ALPN advertises bothh2andhttp/1.1so HTTP/2-capable clients (browser Leaflet,HttpClientwithVersion20+RequestVersionExact, httpxhttp2=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. - 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 throughGlobalExceptionHandler, which convertsBadHttpRequestException(JsonException)(unknown-member rejection, missing-required-field, JSON type mismatch) intoValidationProblemDetailswith the sameerrors[]map shape that FluentValidation produces. This is the deserializer-layer half of the strict-validation contract —error-shape.mdv1.0.0 §"Two collaborating pieces of shared infrastructure". - Strict JSON parsing (AZ-795, cycle 7):
ConfigureHttpJsonOptionssetsPropertyNamingPolicy = CamelCase,PropertyNameCaseInsensitive = true,UnmappedMemberHandling = Disallow, and addsJsonStringEnumConverterwith camelCase naming.UnmappedMemberHandling.Disallowis 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. - FluentValidation registration (AZ-795 + AZ-796, cycle 7):
AddValidatorsFromAssemblyContaining<Program>()auto-registers everyIValidator<T>in the API assembly (currentlyInventoryRequestValidator+TileCoordValidator).GlobalValidatorConfig.ApplyOnce()runs the idempotent process-wide config — setsValidatorOptions.Global.PropertyNameResolversoerrorsmap keys are camelCase (matches the request body's casing pererror-shape.mdInv-4). Per-endpoint opt-in via.WithValidation<TileInventoryRequest>()on the inventory MapPost — the genericValidationEndpointFilter<T>resolves the validator from DI at request time and returnsResults.ValidationProblemon failure.
Startup
- Database migration via
DatabaseMigrator.RunMigrations()— throws on failure - Creates tiles and ready directories
- Swagger enabled in Development mode
- Middleware chain (order matters):
UseExceptionHandler→UseHttpsRedirection→UseCors("TilesCors")→UseAuthentication→UseAuthorization→ endpoint mapping. - Every
MapGet/MapPostendpoint is decorated with.RequireAuthorization(); the framework returns 401 before the handler runs for any anonymous, expired, or invalid-signature request.
ServeTile Handler
- Checks
IMemoryCachefor tile bytes (1h absolute, 30min sliding expiration) - If cache miss: queries
ITileRepository.GetByTileCoordinatesAsync— AZ-505 rewired this method to computelocation_hash = Uuidv5(TileNamespace, "{z}/{x}/{y}")and filter byWHERE location_hash = $1, hittingtiles_leaflet_pathas anIndex Only ScanwithHeap Fetches ≤ 1. Selection rule is unchanged (most-recent across sources/flights); wire response is byte-identical. - If no DB record: downloads tile via
GoogleMapsDownloaderV2.DownloadSingleTileAsync, createsTileEntity, inserts - Returns image bytes with cache headers (
Cache-Control: public, max-age=86400)
GetTilesInventory Handler (AZ-505 + AZ-796 cycle 7)
- Pre-handler validation (cycle 7):
ValidationEndpointFilter<TileInventoryRequest>runs BEFORE the handler. ResolvesInventoryRequestValidatorfrom DI and asserts XORtiles/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 (missingz/x/y, unknown root/nested fields, JSON type mismatch) are caught earlier by System.Text.Json and surfaced as identically-shapedValidationProblemDetailsviaGlobalExceptionHandler(AZ-795). - Handler delegates to
ITileService.GetInventoryAsync(request, ct)— body of the handler is just the service call +Results.Ok. - Service computes
location_hashfor Form A entries viaUuidv5.Create(TileNamespace, "{z}/{x}/{y}"), callsITileRepository.GetTilesByLocationHashesAsync(IReadOnlyList<Guid>), re-aligns results back to input order. - Returns
TileInventoryResponsewith one entry per input —present=trueentries carryid/capturedAt/source/flightId/resolutionMPerPx;present=falseentries carry onlylocationHash. - 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:
RejectUnknownQueryParamsEndpointFilter(new[] {"lat","lon","zoom"})runs first — rejects any unexpected query key (e.g.?latitude=typo, or hostile fingerprinting probes) with RFC 7807ValidationProblemDetailsand anerrors[<paramName>]entry.WithValidation<GetTileByLatLonQuery>()runs second — checksNotNull(missing param →errors[<paramName>]: "\` is required.") andInclusiveBetween(lat∈ [-90, 90],lon∈ [-180, 180],zoom∈ [0, 22]).CascadeMode.Stop` ensures null short-circuits the range check.- Handler dereferences
query.Lat!.Value,query.Lon!.Value,query.Zoom!.Value(validator guarantees non-null), delegates toITileService.DownloadAndStoreSingleTileAsync(lat, lon, zoom), and returnsDownloadTileResponse.
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
Validates size (100–10000m), delegates to IRegionService.RequestRegionAsync.
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:DefaultConnectionMapConfig,StorageConfig,ProcessingConfigUavQuality(AZ-488) —MinBytes,MaxBytes,MaxAgeDays,CapturedAtFutureSkewSeconds,MinLuminanceVariance,MaxBatchSize,LuminanceSampleSize. Drives the 5-rule quality gate AND the per-request body-size limits.CorsConfig:AllowedOriginsJwt:Secret— HMAC-SHA256 signing key for JWT validation (AZ-487). Resolution:JWT_SECRETenv var (preferred, opaque production secret) →Jwt:Secretconfiguration key (appsettings.Development.jsonplaceholder only). Startup fails fast if the resolved value is unset, empty, or shorter than 32 bytes.Jwt:Issuer— Expectedissclaim value (AZ-494). Resolution:JWT_ISSUERenv →Jwt:Issuerconfig. Startup fails fast if unset/empty.Jwt:Audience— Expectedaudclaim value (AZ-494). Resolution:JWT_AUDIENCEenv →Jwt:Audienceconfig. Startup fails fast if unset/empty.Serilogsection
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/uploadis wrapped in.RequireAuthorization(SatellitePermissions.UavUploadPolicy). ThePermissionsAuthorizationHandlerreads thepermissionsclaim (repeated-string OR JSON-array shape) and returns 403 ifGPSis not present.
Tests
Integration tests exercise all endpoints. Unit test project has only a dummy test.