mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 13:51:14 +00:00
34ee1e0b83
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>
173 lines
10 KiB
Markdown
173 lines
10 KiB
Markdown
# 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
|
|
|
|
```jsonc
|
|
{
|
|
"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
|
|
|
|
```jsonc
|
|
{
|
|
"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):
|
|
|
|
```jsonc
|
|
{
|
|
"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):
|
|
|
|
```jsonc
|
|
{
|
|
"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) |
|