mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 07:11:13 +00:00
[AZ-794] [AZ-795] [AZ-796] Strict input validation + z/x/y rename
AZ-794: rename inventory wire fields tileZoom/tileX/tileY -> z/x/y to match the slippy-map URL convention. Contract bumped to v2.0.0. AZ-795: shared validation infrastructure -- FluentValidation + ValidationEndpointFilter + GlobalValidatorConfig (camelCase paths). GlobalExceptionHandler now converts JsonException (UnmappedMember + JsonRequired) into RFC 7807 ValidationProblemDetails. JSON layer hardened with UnmappedMemberHandling.Disallow + camelCase naming policy. New error-shape.md contract. AZ-796: InventoryRequestValidator covers 9 rules (XOR tiles vs locationHashes, cap 1000, z 0..22, x/y in slippy bounds, hash length/charset). 16 unit tests + 16 integration tests + a manual curl probe script. Adjacent fixes uncovered by the new strict layer: - IdempotentPostTests RoutePoint payload corrected to lat/lon (the DTO has used JsonPropertyName for ages; previously silently ignored under PascalCase fallback). - TileInventoryTests slippy x/y reduced to fit z=18 bounds. - docker-compose.yml host port for Postgres moved 5432 -> 5433 to avoid sibling-project conflict; appsettings.Development + README + AGENTS + architecture + containerization docs aligned. New coderule (suite + repo): API consumer-facing OpenAPI descriptions must not contain task IDs, contract filenames, or version-bump history -- internal change tracking belongs in commits/contract docs/changelogs. Existing offending descriptions in Program.cs cleaned up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
# Contract: tile-inventory
|
||||
|
||||
**Component**: WebApi (`SatelliteProvider.Api`) producing rows via TileDownloader (`SatelliteProvider.Services.TileDownloader`)
|
||||
**Producer task**: AZ-505 — `_docs/02_tasks/todo/AZ-505_tile_inventory_http2_leaflet_index.md`
|
||||
**Producer task**: AZ-505 — `_docs/02_tasks/done/AZ-505_tile_inventory_http2_leaflet_index.md` (initial); AZ-794 — `_docs/02_tasks/done/AZ-794_inventory_field_rename_osm.md` (v2.0.0 wire-format rename); AZ-796 — `_docs/02_tasks/done/AZ-796_inventory_endpoint_validation.md` (FluentValidation + ProblemDetails wiring)
|
||||
**Consumer tasks**: `gps-denied-onboard` AZ-316 (`c11_tile_downloader`), future mission-planner UI cache-sizing flows
|
||||
**Version**: 1.0.0
|
||||
**Version**: 2.0.0
|
||||
**Status**: frozen
|
||||
**Last Updated**: 2026-05-12
|
||||
**Last Updated**: 2026-05-22
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -27,14 +27,14 @@ The request MUST carry a valid JWT (AZ-487). No `permissions` claim is required
|
||||
|
||||
### Request body
|
||||
|
||||
Exactly one of `tiles` OR `locationHashes` MUST be populated. Sending both, or neither, is HTTP 400.
|
||||
Exactly one of `tiles` OR `locationHashes` MUST be populated. Sending both, or neither, is HTTP 400 (validation enforced by `InventoryRequestValidator` per AZ-796).
|
||||
|
||||
```jsonc
|
||||
// Form A — coord-keyed
|
||||
// Form A — coord-keyed (v2.0.0; AZ-794 renamed tileZoom/tileX/tileY → z/x/y)
|
||||
{
|
||||
"tiles": [
|
||||
{ "tileZoom": 18, "tileX": 154321, "tileY": 95812 },
|
||||
{ "tileZoom": 18, "tileX": 154322, "tileY": 95812 }
|
||||
{ "z": 18, "x": 154321, "y": 95812 },
|
||||
{ "z": 18, "x": 154322, "y": 95812 }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -51,28 +51,31 @@ Per-field constraints:
|
||||
|
||||
| Field | Type | Required | Description | Constraints |
|
||||
|-------|------|----------|-------------|-------------|
|
||||
| `tiles` | `TileCoord[]` | yes (XOR `locationHashes`) | Slippy-map tile coords | Up to 5000 entries per request. Each entry MUST have all three of `tileZoom`, `tileX`, `tileY`. |
|
||||
| `tiles` | `TileCoord[]` | yes (XOR `locationHashes`) | Slippy-map tile coords | Up to 5000 entries per request. Each entry MUST have all three of `z`, `x`, `y`. |
|
||||
| `locationHashes` | `UUID[]` | yes (XOR `tiles`) | Pre-computed UUIDv5 `location_hash` values | Up to 5000 entries per request. Each entry MUST be RFC 4122 UUID. |
|
||||
|
||||
Hard cap: **5000 entries per request** (`SatelliteProvider.Common.DTO.TileInventoryLimits.MaxEntriesPerRequest`). Anything larger → HTTP 400. The cap is 2× the AC-4 perf gate (2500 tiles).
|
||||
|
||||
Strict parsing: unknown fields at root or nested under any tile entry are rejected with HTTP 400 by `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (AZ-795). The error body conforms to `error-shape.md` v1.0.0.
|
||||
|
||||
### `TileCoord` (per entry under `tiles`)
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `tileZoom` | integer | yes | Slippy-map zoom level |
|
||||
| `tileX` | integer | yes | Slippy-map tile column |
|
||||
| `tileY` | integer | yes | Slippy-map tile row |
|
||||
| Field | Type | Required | Description | Range |
|
||||
|-------|------|----------|-------------|-------|
|
||||
| `z` | integer | yes | Slippy-map zoom level | 0–22 (matches `tile_zoom` schema constraint) |
|
||||
| `x` | integer | yes | Slippy-map tile column | 0 ≤ x < 2^z |
|
||||
| `y` | integer | yes | Slippy-map tile row | 0 ≤ y < 2^z |
|
||||
|
||||
### Response body
|
||||
|
||||
```jsonc
|
||||
// v2.0.0 — coord triple uses z/x/y (AZ-794)
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"tileZoom": 18,
|
||||
"tileX": 154321,
|
||||
"tileY": 95812,
|
||||
"z": 18,
|
||||
"x": 154321,
|
||||
"y": 95812,
|
||||
"locationHash": "ad8c1c4c-2b27-5af4-902f-9c8baeed1e84",
|
||||
"present": true,
|
||||
"id": "5d83…",
|
||||
@@ -82,9 +85,9 @@ Hard cap: **5000 entries per request** (`SatelliteProvider.Common.DTO.TileInvent
|
||||
"resolutionMPerPx": 0.78125
|
||||
},
|
||||
{
|
||||
"tileZoom": 18,
|
||||
"tileX": 154322,
|
||||
"tileY": 95812,
|
||||
"z": 18,
|
||||
"x": 154322,
|
||||
"y": 95812,
|
||||
"locationHash": "5b8d0c2e-7f1a-5d3b-9c5e-1f3a8e7d2b6c",
|
||||
"present": false,
|
||||
"id": null,
|
||||
@@ -101,10 +104,10 @@ Per-entry fields:
|
||||
|
||||
| Field | Type | Present when... | Description |
|
||||
|-------|------|-----------------|-------------|
|
||||
| `tileZoom` | integer | always (Form A); zeroed (Form B) | Echoes the request entry's `tileZoom` when input was `tiles`; `0` when input was `locationHashes` (caller already knows the cell). |
|
||||
| `tileX` | integer | always (Form A); zeroed (Form B) | Same as `tileZoom`. |
|
||||
| `tileY` | integer | always (Form A); zeroed (Form B) | Same as `tileZoom`. |
|
||||
| `locationHash` | UUIDv5 | always | `UUIDv5(TileNamespace, "{tileZoom}/{tileX}/{tileY}")`. Populated even when `present=false` so callers can persist the deterministic hash. |
|
||||
| `z` | integer | always (Form A); zeroed (Form B) | Echoes the request entry's `z` when input was `tiles`; `0` when input was `locationHashes` (caller already knows the cell). |
|
||||
| `x` | integer | always (Form A); zeroed (Form B) | Same as `z`. |
|
||||
| `y` | integer | always (Form A); zeroed (Form B) | Same as `z`. |
|
||||
| `locationHash` | UUIDv5 | always | `UUIDv5(TileNamespace, "{z}/{x}/{y}")`. Populated even when `present=false` so callers can persist the deterministic hash. |
|
||||
| `present` | bool | always | `true` iff a row exists in `tiles` with this `location_hash`. |
|
||||
| `id` | UUID | present=true | Most-recent row's `tiles.id`. Deterministic UUIDv5 for AZ-503+ rows; random for legacy rows. |
|
||||
| `capturedAt` | ISO-8601 UTC | present=true | `tiles.captured_at`. |
|
||||
@@ -120,15 +123,35 @@ Order invariant: `results[i]` corresponds to `request.tiles[i]` (or `request.loc
|
||||
|--------|------|--------------|----------|--------------|
|
||||
| `POST` | `/api/satellite/tiles/inventory` | `TileInventoryRequest` | `TileInventoryResponse` | 200, 400, 401 |
|
||||
|
||||
## Error shape
|
||||
|
||||
All `400` responses conform to `_docs/02_document/contracts/api/error-shape.md` v1.0.0. Both wire-format failures (unknown fields, type mismatches; via the JSON deserializer) and business-rule failures (XOR violation, missing `z`/`x`/`y`, out-of-range zoom; via `InventoryRequestValidator`) emit a `ValidationProblemDetails` body with an `errors` map keyed by JSON-path-style field names. Example:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"tiles[0].z": ["The z field is required."],
|
||||
"tiles[1]": ["The JSON property 'tileZoom' could not be mapped to any .NET member contained in type 'TileCoord'."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The first key shows a FluentValidation rule miss; the second shows a deserializer rejection of the *old* v1.x field name (callers still on the old shape get an explicit failure, not a silent 200 with zeroed coordinates — exactly the behaviour AZ-794 + AZ-796 were filed to enforce after the AZ-777 Phase 1 Jetson probe).
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Inv-1**: Exactly one of `request.tiles` and `request.locationHashes` is populated and non-empty. Both-populated → 400; both-empty → 400.
|
||||
- **Inv-2**: `len(response.results) == len(request.tiles)` OR `len(request.locationHashes)` — never less, never more.
|
||||
- **Inv-3**: `response.results[i].locationHash` is deterministic from `request.tiles[i]` (UUIDv5 over `"{zoom}/{x}/{y}"` with `Uuidv5.TileNamespace`) when Form A is used, or equals `request.locationHashes[i]` when Form B is used.
|
||||
- **Inv-3**: `response.results[i].locationHash` is deterministic from `request.tiles[i]` (UUIDv5 over `"{z}/{x}/{y}"` with `Uuidv5.TileNamespace`) when Form A is used, or equals `request.locationHashes[i]` when Form B is used.
|
||||
- **Inv-4**: `response.results[i].present == true` iff a row exists in `tiles` with `location_hash = response.results[i].locationHash`.
|
||||
- **Inv-5**: When `present=true`, the returned row is the most-recent across sources/flights ordered by `(captured_at DESC, updated_at DESC, id DESC)` — same rule as `ITileRepository.GetByTileCoordinatesAsync` per `tile-storage` v2.0.0.
|
||||
- **Inv-6**: When `present=false`, `id` / `capturedAt` / `source` / `flightId` / `resolutionMPerPx` are all `null`.
|
||||
- **Inv-7**: `request.tiles.length` and `request.locationHashes.length` MUST be ≤ `TileInventoryLimits.MaxEntriesPerRequest` (5000); over the cap → 400.
|
||||
- **Inv-8** (AZ-795 / AZ-796): Each `tiles[i].z` MUST satisfy `0 ≤ z ≤ 22`. Each `tiles[i].x` and `tiles[i].y` MUST satisfy `0 ≤ value < 2^z`. Out-of-range → 400 with `errors["tiles[i].z|x|y"]` populated.
|
||||
- **Inv-9** (AZ-795): Unknown fields at root or in any nested object are rejected with HTTP 400; the error key names the offending JSON path.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
@@ -139,12 +162,13 @@ Order invariant: `results[i]` corresponds to `request.tiles[i]` (or `request.loc
|
||||
- **Not covered**: production deployment topology. Dev Kestrel runs `Http1AndHttp2` directly over TLS on port 8080 with a self-signed cert (`./certs/api.pfx`, generated by `scripts/run-tests.sh`) so ALPN can advertise `h2` — browsers and programmatic clients (httpx `http2=True`, .NET `HttpClient` with `HttpVersionPolicy.RequestVersionExact`) both multiplex over a single TLS connection. In production, TLS is expected to terminate at the ingress (Envoy / nginx / ALB) and Kestrel runs HTTP/2 cleartext behind it; AZ-505 verifies the protocol multiplexing semantics here, not the production termination layer.
|
||||
- **Not covered**: PMTiles or tar/multipart bundle endpoints. Rejected by AZ-503 parent rationale (HTTP/2 multistream is sufficient).
|
||||
- **Not covered**: write operations. Inventory is read-only; UAV writes go through `POST /api/satellite/upload` (`uav-tile-upload.md` v1.1.0).
|
||||
- **Not covered**: backward-compatibility shim for v1.0.0 (`tileZoom/tileX/tileY`) field names. Per AZ-794 (Option 1 hard switch — single known consumer), v2.0.0 is the only accepted body shape; v1.x consumers receive HTTP 400 with `errors[*]: ["could not be mapped"]`. There is no transitional accept-both period.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behavior.
|
||||
- **Minor (1.x.0)**: Adding an optional response field that consumers may safely ignore (e.g., the future `estimatedBytes`); raising the entry cap; adding a third request form alongside the current two.
|
||||
- **Major (2.0.0)**: Changing the response ordering rule; removing `present`; lowering the entry cap; making `flightId` required; adding voting / trust filtering to the read path.
|
||||
- **Patch (2.0.x)**: Documentation clarifications, additional invariants that do not change wire behavior.
|
||||
- **Minor (2.x.0)**: Adding an optional response field that consumers may safely ignore (e.g., the future `estimatedBytes`); raising the entry cap; adding a third request form alongside the current two.
|
||||
- **Major (3.0.0)**: Changing the response ordering rule; removing `present`; lowering the entry cap; making `flightId` required; adding voting / trust filtering to the read path; renaming the `z`/`x`/`y` triple again.
|
||||
|
||||
## Test Cases
|
||||
|
||||
@@ -152,9 +176,15 @@ Order invariant: `results[i]` corresponds to `request.tiles[i]` (or `request.loc
|
||||
|------|-------|----------|-------|
|
||||
| ordering-mixed-present-absent | 25 coords, 12 seeded + 13 absent, interleaved | 25 entries in request order; 12 present (id/capturedAt/source populated), 13 absent (only locationHash populated) | AC-1 |
|
||||
| most-recent-across-sources | Cell with `google_maps captured_at=T1` and `uav captured_at=T2 > T1`; coord request | `present=true`, `source='uav'`, `id` = UAV row's id | Inv-5 |
|
||||
| validation-both-populated | Body with both `tiles` and `locationHashes` | HTTP 400 | Inv-1 |
|
||||
| validation-neither-populated | Empty body or body with both fields empty | HTTP 400 | Inv-1 |
|
||||
| validation-over-cap | 5001 entries | HTTP 400 | Inv-7 |
|
||||
| validation-both-populated | Body with both `tiles` and `locationHashes` | HTTP 400 + ValidationProblemDetails | Inv-1 + AZ-796 |
|
||||
| validation-neither-populated | Empty body or body with both fields empty | HTTP 400 + ValidationProblemDetails | Inv-1 + AZ-796 |
|
||||
| validation-over-cap | 5001 entries | HTTP 400 + ValidationProblemDetails | Inv-7 + AZ-796 |
|
||||
| validation-missing-z | `tiles: [{ x: 1, y: 1 }]` | HTTP 400 + `errors["tiles[0].z"]` populated | Inv-8 + AZ-796 |
|
||||
| validation-out-of-range-z | `tiles: [{ z: 30, x: 1, y: 1 }]` | HTTP 400 + `errors["tiles[0].z"]` mentioning range | Inv-8 + AZ-796 |
|
||||
| validation-out-of-range-x | `tiles: [{ z: 0, x: 5, y: 0 }]` (2^0 = 1) | HTTP 400 + `errors["tiles[0].x"]` populated | Inv-8 + AZ-796 |
|
||||
| validation-unknown-root-field | Body with `unknownField: 42` plus `tiles: [...]` | HTTP 400 + `errors["unknownField"]` | Inv-9 + AZ-795 |
|
||||
| validation-unknown-nested-field | `tiles: [{ z: 18, x: 1, y: 1, foo: 42 }]` | HTTP 400 + `errors["tiles[0].foo"]` | Inv-9 + AZ-795 |
|
||||
| validation-old-field-name-tileZoom | `tiles: [{ tileZoom: 18, tileX: 1, tileY: 1 }]` (v1.x shape) | HTTP 400 + `errors["tiles[0].tileZoom"]` ("could not be mapped") | AZ-794 + Inv-9 |
|
||||
| auth-anonymous | No Bearer token | HTTP 401 | Standard `.RequireAuthorization()` baseline |
|
||||
| perf-2500-tiles | 2500-entry request against populated DB | p95 ≤ 1000 ms over 20 calls | AC-4 |
|
||||
| http2-multiplexing | 20 concurrent `GET /tiles/{z}/{x}/{y}` over a single H2 connection | All 20 responses `HttpResponseMessage.Version == 2.0`; ETag + Cache-Control preserved | AC-5; cross-references `tile-inventory.md` because Kestrel H2 is configured in the same PBI |
|
||||
@@ -163,4 +193,5 @@ Order invariant: `results[i]` corresponds to `request.tiles[i]` (or `request.loc
|
||||
|
||||
| Version | Date | Change | Author |
|
||||
|---------|------|--------|--------|
|
||||
| 2.0.0 | 2026-05-22 | **BREAKING**: per-entry coord triple renamed `tileZoom/tileX/tileY` → `z/x/y` (request `tiles[i]` and response `results[i]`) to align with the URL slippy-map convention already used by `GET /tiles/{z}/{x}/{y}`. AZ-794 ships as Option 1 hard switch; v1.x clients receive HTTP 400 with explicit "could not be mapped" errors. Adds Inv-8 (range constraints on z/x/y) + Inv-9 (unknown-field rejection); references `error-shape.md` v1.0.0 for the uniform 400 body shape. AZ-796 wires `InventoryRequestValidator` for Inv-1 / Inv-7 / Inv-8 enforcement. Cycle 7 — autodev Step 10. | autodev (Step 10, cycle 7) |
|
||||
| 1.0.0 | 2026-05-12 | Initial contract — `POST /api/satellite/tiles/inventory` with Form A (coords) / Form B (hashes) XOR validation, 5000-entry cap, most-recent-across-sources selection rule, ordering invariant. Produced by AZ-505. | autodev (Step 10, cycle 6) |
|
||||
|
||||
Reference in New Issue
Block a user