Files
Oleksandr Bezdieniezhnykh 34ee1e0b83 [AZ-808] [AZ-811] Strict validation on region POST + lat/lon GET
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>
2026-05-22 16:29:41 +03:00

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/Longitudelat/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:

  1. JSON deserializer rules — wire-format failures: unknown fields (UnmappedMemberHandling.Disallow), missing [JsonRequired] properties, type mismatches. Surface via BadHttpRequestException(JsonException)GlobalExceptionHandler.
  2. RegionRequestValidator (FluentValidation, AZ-808) — business rules: non-zero id, range checks for lat / lon / sizeMeters / zoomLevel. Surface via ValidationEndpointFilter<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: id MUST be a non-zero GUID. Pre-AZ-808, omitting id silently coerced to Guid.Empty and queued a region under the zero key; AZ-808 fails this with HTTP 400 and errors["id"].
  • Inv-2: lat ∈ [-90.0, 90.0]. Out-of-range → 400 with errors["lat"].
  • Inv-3: lon ∈ [-180.0, 180.0]. Out-of-range → 400 with errors["lon"].
  • Inv-4: sizeMeters ∈ [100.0, 10000.0]. Out-of-range → 400 with errors["sizeMeters"]. Pre-AZ-808 this rule lived as an inline if in the handler; AZ-808 moves it into the validator.
  • Inv-5: zoomLevel ∈ [0, 22] (slippy-map zoom range, matching tile-inventory.md Inv-8).
  • Inv-6: stitchTiles MUST be explicitly provided. No defaulting to false — 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 id return the existing region resource with HTTP 200 and do NOT enqueue duplicate background processing. The post-rename lat/lon wire format does not affect this invariant.
  • Inv-9 (async semantics): The endpoint returns immediately after enqueuing. Status transitions to completed/failed happen on the background RegionProcessingService. Callers MUST poll GET /api/satellite/region/{id} to observe completion.

Non-Goals

  • Not covered: tile body fetch. The background worker writes tiles into the tiles table; callers fetch bodies via GET /tiles/{z}/{x}/{y} after polling shows status == completed.
  • Not covered: backward-compatibility shim for Latitude/Longitude wire field names. AZ-812 ships v1.0.0 of this contract directly with the post-rename names; pre-rename callers receive HTTP 400 with errors["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 stitchTiles optional 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)