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>
10 KiB
Contract: region-request
Component: WebApi (SatelliteProvider.Api) producing rows via RegionProcessing (SatelliteProvider.Services.RegionProcessing)
Producer task: AZ-808 — _docs/02_tasks/done/AZ-808_region_endpoint_validation.md (validator + this contract); AZ-812 — _docs/02_tasks/done/AZ-812_region_field_rename_to_osm.md (OSM-convention wire-format lat/lon)
Consumer tasks: gps-denied-onboard AZ-777 Phase 2 (seeds Derkachi reference tile catalog via this endpoint)
Version: 1.0.0
Status: frozen
Last Updated: 2026-05-22
Purpose
Defines the HTTP contract for POST /api/satellite/request — the region-onboarding endpoint that enqueues a square region of tiles for asynchronous backfill from Google Maps. Callers submit a (lat, lon, sizeMeters, zoomLevel) envelope identified by a client-provided id (idempotency key); the API responds immediately with the queued region's status. Actual tile downloads run in the background via RegionProcessingService (system-flows.md Flow F2). Callers poll GET /api/satellite/region/{id} until status == completed.
This is the v1.0.0 of the contract — published alongside AZ-808's validator landing. There is no prior contract document. AZ-812 had already renamed the wire-format fields Latitude/Longitude → lat/lon (OSM convention) earlier in cycle 8; this contract publishes the post-rename shape directly with no transitional period.
Endpoint
POST /api/satellite/request
Content-Type: application/json
Authorization: Bearer <JWT>
The request MUST carry a valid JWT (AZ-487). No permissions claim is required. Anonymous requests are rejected with HTTP 401.
Shape
Request body
{
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
"lat": 47.461747,
"lon": 37.647063,
"sizeMeters": 200,
"zoomLevel": 18,
"stitchTiles": false
}
Per-field constraints:
| Field | Type | Required | Description | Constraints |
|---|---|---|---|---|
id |
UUID | yes | Client-provided idempotency key. POSTing the same id twice returns the existing region (idempotent per AZ-362). |
Non-zero GUID. 00000000-... → HTTP 400. |
lat |
number | yes | Region centre latitude (WGS84, decimal degrees). | [-90.0, 90.0]. |
lon |
number | yes | Region centre longitude (WGS84, decimal degrees). | [-180.0, 180.0]. |
sizeMeters |
number | yes | Square region side length. | [100.0, 10000.0]. Anything larger → HTTP 400. |
zoomLevel |
integer | yes | Slippy-map zoom level for the resulting tiles. | [0, 22]. |
stitchTiles |
bool | yes | If true, a stitched composite image is produced once all tiles are present. No default — caller MUST declare intent. | true / false. |
Strict parsing: unknown fields at root are rejected with HTTP 400 by JsonSerializerOptions.UnmappedMemberHandling.Disallow (AZ-795). Missing required fields are caught by [JsonRequired] on the DTO and surface as HTTP 400 with the field name in errors.
Response body
{
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
"status": "queued",
"csvFilePath": null,
"summaryFilePath": null,
"tilesDownloaded": 0,
"tilesReused": 0,
"createdAt": "2026-05-22T12:34:56.789Z",
"updatedAt": "2026-05-22T12:34:56.789Z"
}
Per-field semantics:
| Field | Type | Description |
|---|---|---|
id |
UUID | Echo of the request id. |
status |
string enum | "queued" immediately after enqueue; transitions through "processing" → "completed" (or "failed") on the background worker. |
csvFilePath |
string | null | Path to the per-region tile-manifest CSV. Null until processing produces it. |
summaryFilePath |
string | null | Path to the human-readable summary. Null until processing produces it. |
tilesDownloaded |
integer | Count of tiles fetched fresh from Google Maps. Updated as processing progresses. |
tilesReused |
integer | Count of tiles served from existing cache. Updated as processing progresses. |
createdAt |
ISO-8601 UTC | Initial enqueue timestamp. Stable across retries (per AZ-362 idempotency). |
updatedAt |
ISO-8601 UTC | Last status-row write. Bumps as the background worker progresses. |
Endpoint summary
| Method | Path | Request body | Response | Status codes |
|---|---|---|---|---|
POST |
/api/satellite/request |
RequestRegionRequest |
RegionStatusResponse |
200, 400, 401 |
Error shape
All 400 responses conform to _docs/02_document/contracts/api/error-shape.md v1.0.0. Two enforcement layers produce identically-shaped bodies:
- JSON deserializer rules — wire-format failures: unknown fields (
UnmappedMemberHandling.Disallow), missing[JsonRequired]properties, type mismatches. Surface viaBadHttpRequestException(JsonException)→GlobalExceptionHandler. RegionRequestValidator(FluentValidation, AZ-808) — business rules: non-zeroid, range checks forlat/lon/sizeMeters/zoomLevel. Surface viaValidationEndpointFilter<RequestRegionRequest>.
Example body for a missing-id failure (the pre-AZ-808 silent-coercion gap surfaced by the 2026-05-22 black-box probe):
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"id": ["The id field is required."]
}
}
Example body for a zero-Guid id (validator-level rejection):
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"id": ["`id` must be a non-zero GUID (the caller's idempotency key)."]
}
}
Invariants
- Inv-1:
idMUST be a non-zero GUID. Pre-AZ-808, omittingidsilently coerced toGuid.Emptyand queued a region under the zero key; AZ-808 fails this with HTTP 400 anderrors["id"]. - Inv-2:
lat ∈ [-90.0, 90.0]. Out-of-range → 400 witherrors["lat"]. - Inv-3:
lon ∈ [-180.0, 180.0]. Out-of-range → 400 witherrors["lon"]. - Inv-4:
sizeMeters ∈ [100.0, 10000.0]. Out-of-range → 400 witherrors["sizeMeters"]. Pre-AZ-808 this rule lived as an inlineifin the handler; AZ-808 moves it into the validator. - Inv-5:
zoomLevel ∈ [0, 22](slippy-map zoom range, matchingtile-inventory.mdInv-8). - Inv-6:
stitchTilesMUST be explicitly provided. No defaulting tofalse— callers declare intent. - Inv-7: Unknown fields at root are rejected with HTTP 400 + the field name in
errors. - Inv-8 (idempotency, AZ-362): Two POSTs with the same
idreturn the existing region resource with HTTP 200 and do NOT enqueue duplicate background processing. The post-renamelat/lonwire format does not affect this invariant. - Inv-9 (async semantics): The endpoint returns immediately after enqueuing. Status transitions to
completed/failedhappen on the backgroundRegionProcessingService. Callers MUST pollGET /api/satellite/region/{id}to observe completion.
Non-Goals
- Not covered: tile body fetch. The background worker writes tiles into the
tilestable; callers fetch bodies viaGET /tiles/{z}/{x}/{y}after polling showsstatus == completed. - Not covered: backward-compatibility shim for
Latitude/Longitudewire field names. AZ-812 ships v1.0.0 of this contract directly with the post-rename names; pre-rename callers receive HTTP 400 witherrors["latitude"]: ["could not be mapped"]. There is no transitional accept-both period. - Not covered: geofencing semantics. Geofences are a Route concern, not a Region concern; documented in
route-create.md(forthcoming, AZ-809). - Not covered: cancellation of a queued region. The current API has no DELETE / cancel verb. Tracked separately if needed.
Versioning Rules
- Patch (1.0.x): Documentation clarifications, additional invariants that do not change wire behaviour.
- Minor (1.x.0): Adding an optional response field consumers may safely ignore (e.g. ETA estimate); relaxing a range constraint within
[-90,90]/[-180,180]envelope (e.g. accepting decimal degrees with extra precision). - Major (2.0.0): Changing a field name; tightening a range constraint (breaks today's valid callers); making
stitchTilesoptional with a default again; removing idempotency.
Test Cases
| Case | Input | Expected | Notes |
|---|---|---|---|
| happy-path | {id:<guid>, lat:47.46, lon:37.64, sizeMeters:200, zoomLevel:18, stitchTiles:false} |
HTTP 200 + RegionStatusResponse(status="queued") | AC-2 |
| missing-id | body without id field |
HTTP 400 + errors["id"] |
Inv-1 (probe gap) |
| zero-guid-id | id: "00000000-..." |
HTTP 400 + errors["id"] |
Inv-1 |
| missing-lat | body without lat |
HTTP 400 + errors["lat"] |
JsonRequired |
| lat-out-of-range | lat: 91 |
HTTP 400 + errors["lat"] |
Inv-2 |
| missing-lon | body without lon |
HTTP 400 + errors["lon"] |
JsonRequired |
| lon-out-of-range | lon: 181 |
HTTP 400 + errors["lon"] |
Inv-3 |
| missing-sizeMeters | body without sizeMeters |
HTTP 400 + errors["sizeMeters"] |
JsonRequired |
| sizeMeters-out-of-range | sizeMeters: 1000000 |
HTTP 400 + errors["sizeMeters"] |
Inv-4 |
| missing-zoomLevel | body without zoomLevel |
HTTP 400 + errors["zoomLevel"] |
JsonRequired |
| zoomLevel-out-of-range | zoomLevel: 30 |
HTTP 400 + errors["zoomLevel"] |
Inv-5 |
| missing-stitchTiles | body without stitchTiles |
HTTP 400 + errors["stitchTiles"] |
Inv-6 |
| lat-type-mismatch | lat: "fifty" |
HTTP 400 (deserializer JsonException) | wire-format failure |
| unknown-root-field | body with unknownField: 1 |
HTTP 400 + errors["unknownField"] |
Inv-7 |
| legacy-latitude-name | body with latitude: instead of lat: |
HTTP 400 + errors["latitude"] |
AZ-812 hard switch |
| auth-anonymous | no Bearer token | HTTP 401 | Standard .RequireAuthorization() baseline |
| idempotent-double-post | same body POSTed twice | both HTTP 200; same createdAt; no duplicate background work |
AC-2 + AZ-362 |
Change Log
| Version | Date | Change | Author |
|---|---|---|---|
| 1.0.0 | 2026-05-22 | Initial contract for POST /api/satellite/request. Publishes the post-AZ-812 OSM-convention wire format (lat/lon) and the AZ-808 strict-validation rules (non-zero id, range-checked lat/lon/sizeMeters/zoomLevel, explicit stitchTiles, unknown-field rejection). References error-shape.md v1.0.0 for the 400 body shape and tile-inventory.md v2.0.0 for the downstream read path (callers seed via region, then read via inventory). |
autodev (Step 10, cycle 8) |