mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 14:11:15 +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:
@@ -88,7 +88,7 @@ The N-source storage contract is authoritative in `_docs/02_document/contracts/d
|
||||
|
||||
| Config | Development | Production |
|
||||
|--------|-------------|------------|
|
||||
| Database | localhost:5432 (Docker) | Container network `db:5432` |
|
||||
| Database | localhost:5433 (Docker) | Container network `db:5432` |
|
||||
| Secrets | appsettings.Development.json | Environment variables |
|
||||
| Logging | Console + File | File (./logs/) |
|
||||
| API URL | http://localhost:5100 | http://0.0.0.0:5100 |
|
||||
@@ -200,3 +200,28 @@ The authoritative source/flight markers are the `tiles.source` and `tiles.flight
|
||||
**Decision**: Use `IHostedService` implementations that consume from the in-process queue.
|
||||
|
||||
**Consequences**: Clean separation of request handling and processing; lifecycle managed by the host.
|
||||
|
||||
## 9. Input Validation (AZ-795)
|
||||
|
||||
Every public HTTP endpoint MUST reject malformed or out-of-range payloads with HTTP 400 + RFC 7807 `ValidationProblemDetails`. The shared infrastructure landed in AZ-795 (cycle 7) is two collaborating layers:
|
||||
|
||||
1. **Deserializer-level rejection** — `JsonSerializerOptions.UnmappedMemberHandling.Disallow` configured in `Program.cs` (`ConfigureHttpJsonOptions`) catches unknown fields, type mismatches, and malformed JSON. The framework wraps the resulting `JsonException` in `BadHttpRequestException`; `GlobalExceptionHandler` extracts the JSON path and emits a structured `ValidationProblemDetails` body.
|
||||
2. **Business-rule rejection** — `FluentValidation` 12.0.0 validators registered via `AddValidatorsFromAssemblyContaining<Program>()` and wired through the generic `ValidationEndpointFilter<T>` (`SatelliteProvider.Api/Validators/ValidationEndpointFilter.cs`). Endpoints opt in via `RouteHandlerBuilder.WithValidation<T>()`; the filter calls `Results.ValidationProblem(result.ToDictionary())` on failure.
|
||||
|
||||
Both layers produce the wire shape documented in `_docs/02_document/contracts/api/error-shape.md` (v1.0.0).
|
||||
|
||||
### Validator coverage
|
||||
|
||||
| Endpoint | Request DTO | Validator | Status | Owning task |
|
||||
|----------|-------------|-----------|--------|-------------|
|
||||
| `POST /api/satellite/tiles/inventory` | `TileInventoryRequest` | `InventoryRequestValidator` | covered | AZ-796 (cycle 7) |
|
||||
| `GET /tiles/{z}/{x}/{y}` | route params | (route-constraint only — `:int` covers types; AZ-795 deserializer guards body shape on POST endpoints only) | covered by route-constraint | AZ-487 (cycle 1, JWT gate) |
|
||||
| `GET /api/satellite/tiles/latlon` | query params | (query-binding type checks via `[FromQuery]`; future AZ-795 child task to add explicit FluentValidation) | partial | future AZ-795 child |
|
||||
| `POST /api/satellite/upload` | `UavTileBatchUploadRequest` (multipart) | (envelope-level validation in `UavTileUploadHandler`; future AZ-795 child to formalize as FluentValidation) | partial | future AZ-795 child |
|
||||
| `POST /api/satellite/request` | `RequestRegionRequest` | (inline `SizeMeters` range check; future AZ-795 child) | partial | future AZ-795 child |
|
||||
| `POST /api/satellite/route` | `CreateRouteRequest` | (typed `ArgumentException` path → 400; future AZ-795 child) | partial | future AZ-795 child |
|
||||
| `GET /api/satellite/region/{id:guid}` | route param | (route-constraint `:guid`) | covered by route-constraint | — |
|
||||
| `GET /api/satellite/route/{id:guid}` | route param | (route-constraint `:guid`) | covered by route-constraint | — |
|
||||
| `GET /api/satellite/tiles/mgrs` | (stub) | n/a — returns 501 | n/a | AZ-356 |
|
||||
|
||||
The `partial` rows are tracked under the AZ-795 epic; per-endpoint child tickets to be filed by parent-suite team after enumerating the surface from the OpenAPI spec.
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Contract: error-shape
|
||||
|
||||
**Component**: WebApi (`SatelliteProvider.Api`) — applies to every public HTTP endpoint
|
||||
**Producer task**: AZ-795 — `_docs/02_tasks/done/AZ-795_strict_validation_epic.md`
|
||||
**Consumer tasks**: every per-endpoint child of AZ-795 (first: AZ-796) plus every `gps-denied-onboard` HTTP client and every future browser/CLI consumer
|
||||
**Version**: 1.0.0
|
||||
**Status**: frozen
|
||||
**Last Updated**: 2026-05-22
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the uniform RFC 7807 ProblemDetails / ValidationProblemDetails shape every public endpoint emits for client (4xx) errors. The contract exists so consumers can pattern-match against a single error payload regardless of which endpoint they called and regardless of whether the failure happened at the deserializer (unknown field, type mismatch) or at a FluentValidation rule (missing field, out-of-range value, business invariant).
|
||||
|
||||
The contract is enforced by two collaborating pieces of shared infrastructure:
|
||||
|
||||
1. **Deserializer-level rejection** — `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (.NET 8+) catches unknown fields, type mismatches, and malformed JSON. The framework's `BadHttpRequestException` carries a `System.Text.Json.JsonException` as its inner exception; `GlobalExceptionHandler` (`SatelliteProvider.Api/GlobalExceptionHandler.cs`) extracts the JSON path and emits a `ValidationProblemDetails` body.
|
||||
2. **Business-rule rejection** — `FluentValidation` (12.0.0) validators wired through the generic `ValidationEndpointFilter<T>` (`SatelliteProvider.Api/Validators/ValidationEndpointFilter.cs`). Endpoints opt in via `RouteHandlerBuilder.WithValidation<T>()`. The filter calls `Results.ValidationProblem(result.ToDictionary())`, which produces an identically-shaped body.
|
||||
|
||||
Both paths produce `Content-Type: application/problem+json`. Both populate the same `errors` map keyed by request-body field path.
|
||||
|
||||
## Shape
|
||||
|
||||
### Validation failures (HTTP 400)
|
||||
|
||||
```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'."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-field rules:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `type` | URI string | yes | RFC 7231 §6.5.1 link for 400 (FluentValidation default) or RFC 9110 link for 5xx (server errors). |
|
||||
| `title` | string | yes | Human-readable summary. Validation failures use `"One or more validation errors occurred."`; non-validation 400s use `"Bad Request"`. |
|
||||
| `status` | integer | yes | Echoes the HTTP status code. |
|
||||
| `errors` | object\<string, string[]\> | required for validation failures | Keys are JSON-path-style request-body field names. Values are arrays of error messages. Empty array is not allowed for a present key. |
|
||||
| `traceId` | string | optional | Correlation identifier for log lookup. Populated for 5xx; may be populated for 4xx if FluentValidation surfaces it. |
|
||||
|
||||
### Field-path keys in `errors`
|
||||
|
||||
Field-path keys MUST follow the same casing as the request body's JSON (camelCase root + dotted/indexed access for nested types). Examples:
|
||||
|
||||
| Failure type | Example field-path key |
|
||||
|--------------|------------------------|
|
||||
| Missing root field | `tiles` |
|
||||
| Missing nested field on tile entry | `tiles[0].z` |
|
||||
| Out-of-range value | `tiles[3].z` |
|
||||
| Unknown root field | `unknownField` (or `$` if path unavailable) |
|
||||
| Unknown field inside nested object | `tiles[0].foo` |
|
||||
| Both/neither XOR violation | `$` (request-body root) |
|
||||
|
||||
The deserialization-failure path (unknown field, type mismatch) sets the key to the JSON path System.Text.Json reports. FluentValidation paths use the property names from `RuleFor(x => x.Tiles)` etc., which automatically produce camelCase paths matching the request body.
|
||||
|
||||
### Generic 4xx errors (no validation context)
|
||||
|
||||
Some 4xx responses (auth failures, not-found, framework binding errors that aren't JSON deserialization) emit the simpler ProblemDetails shape — no `errors` map:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://datatracker.ietf.org/doc/html/rfc9110#name-401-unauthorized",
|
||||
"title": "Unauthorized",
|
||||
"status": 401
|
||||
}
|
||||
```
|
||||
|
||||
| Status | Title | Notes |
|
||||
|--------|-------|-------|
|
||||
| 400 (non-validation) | `"Bad Request"` | Framework binding failures that don't carry a JsonException (rare). |
|
||||
| 401 | (varies) | Emitted by the JwtBearer middleware via `WWW-Authenticate`; body content depends on framework version. |
|
||||
| 403 | (varies) | Authorization failure. Body shape governed by ASP.NET Core defaults. |
|
||||
| 404 | (varies) | Per-endpoint default; some endpoints emit a custom NotFound body (e.g. region/route). |
|
||||
| 501 | `"Not implemented"` | Stub endpoints (e.g. `/api/satellite/tiles/mgrs`). |
|
||||
|
||||
### 5xx errors
|
||||
|
||||
Server errors emit the simpler ProblemDetails shape with a `correlationId` extension property pointing at the server log entry. The body NEVER contains the original exception message or stack trace (sanitization landed in AZ-353):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://datatracker.ietf.org/doc/html/rfc9110#name-500-internal-server-error",
|
||||
"title": "Internal Server Error",
|
||||
"status": 500,
|
||||
"detail": "An unexpected error occurred. Use the correlationId to look up the server log entry.",
|
||||
"correlationId": "0HMBR..."
|
||||
}
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Inv-1**: Every 4xx and 5xx response sets `Content-Type: application/problem+json`.
|
||||
- **Inv-2**: Validation failures (HTTP 400 from FluentValidation OR from JSON deserialization with a JsonException inner exception) always include an `errors` object.
|
||||
- **Inv-3**: Each `errors` entry has at least one message. Empty arrays are forbidden.
|
||||
- **Inv-4**: Field-path keys in `errors` use the same casing as the request body (camelCase root, dotted/indexed access for nested types).
|
||||
- **Inv-5**: 5xx responses include a `correlationId` extension property; 4xx responses do not. No 4xx response leaks server-internal state (DB connection strings, secrets, internal stack frames).
|
||||
- **Inv-6**: Unknown fields at root or in any nested object are rejected with HTTP 400 — not silently dropped. The error key names the offending field path.
|
||||
- **Inv-7**: Type mismatches (e.g. string where integer expected) are rejected with HTTP 400 and the error key names the offending field path.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Not covered**: i18n / translated error messages. Messages are English-only; consumers translate on their side if needed.
|
||||
- **Not covered**: error codes. The `errors` map carries human-readable strings, not stable error codes. Consumers MUST NOT pattern-match on the string content; they pattern-match on field paths.
|
||||
- **Not covered**: rate-limit or quota errors. Those are a separate concern with their own contract (TBD).
|
||||
- **Not covered**: 1xx / 3xx responses. Those are framework-level and not shaped by this contract.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behavior.
|
||||
- **Minor (1.x.0)**: Adding an optional extension field to ProblemDetails (e.g., `correlationId` becoming standard for 4xx as well as 5xx). Adding new field-path conventions that are backward-compatible (e.g., a new `[i]` indexing rule).
|
||||
- **Major (2.0.0)**: Changing `errors` map shape (e.g. swapping to error-code keys). Changing `Content-Type`. Renaming `errors` to anything else. Removing `correlationId` from 5xx.
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Case | Input | Expected | Notes |
|
||||
|------|-------|----------|-------|
|
||||
| validation-missing-field | Inventory request with `tiles: [{ "z": 18 }]` (x, y missing) | HTTP 400 + `errors["tiles[0].x"]` and `errors["tiles[0].y"]` populated | Inv-2, Inv-4 |
|
||||
| validation-out-of-range | Inventory request with `tiles: [{ "z": 30, "x": 1, "y": 1 }]` | HTTP 400 + `errors["tiles[0].z"]` mentioning supported zoom range | Inv-2 |
|
||||
| validation-unknown-root-field | Body `{ "unknownField": 42, "tiles": [...] }` | HTTP 400 + `errors["unknownField"]` populated with "could not be mapped" | Inv-6 |
|
||||
| validation-unknown-nested-field | Body `{ "tiles": [{ "z": 18, "x": 1, "y": 1, "foo": 42 }] }` | HTTP 400 + `errors["tiles[0].foo"]` populated | Inv-6 |
|
||||
| validation-type-mismatch | Body `{ "tiles": [{ "z": "eighteen" }] }` | HTTP 400 + `errors["tiles[0].z"]` populated | Inv-7 |
|
||||
| validation-xor-both-populated | Body with both `tiles` and `locationHashes` populated | HTTP 400 + `errors["$"]` (or root key) populated | Inv-2 |
|
||||
| 5xx-includes-correlation-id | Endpoint throws unhandled exception | HTTP 500 + `correlationId` extension matching `httpContext.TraceIdentifier` | Inv-5 |
|
||||
| 5xx-no-secret-leak | Exception message contains a connection string with `Password=hunter2` | HTTP 500 body contains neither the password nor the connection string | Inv-5 |
|
||||
|
||||
## Change Log
|
||||
|
||||
| Version | Date | Change | Author |
|
||||
|---------|------|--------|--------|
|
||||
| 1.0.0 | 2026-05-22 | Initial contract — uniform RFC 7807 ValidationProblemDetails shape for FluentValidation business-rule failures + JSON deserialization failures, including unknown-field rejection (`UnmappedMemberHandling.Disallow`). Sanitized ProblemDetails for 5xx (preserves AZ-353). Produced by AZ-795. | autodev (Step 10, cycle 7) |
|
||||
@@ -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) |
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
| Service | Image | Ports (host:container) | Purpose |
|
||||
|---------|-------|------------------------|---------|
|
||||
| postgres | postgres:16 | 5432:5432 | Database |
|
||||
| postgres | postgres:16 | 5433:5432 | Database (host port 5433 chosen to avoid conflicts with sibling-project Postgres instances on dev laptops) |
|
||||
| api | Custom (Dockerfile) | 18980:8080, 18981:8081 | Application |
|
||||
|
||||
## Volumes
|
||||
|
||||
@@ -126,9 +126,12 @@ The cycle-1 (AZ-487) and cycle-2 (AZ-488) code reviews each surfaced an F1 (Low
|
||||
- `SatelliteProvider.Api/Authentication/AuthenticationServiceCollectionExtensions.cs` (added by AZ-487; `AddSatelliteJwt(IConfiguration)` registers `JwtBearer` with the suite-wide HS256 contract from `suite/_docs/10_auth.md`; validates `JWT_SECRET` ≥ 32 bytes at startup)
|
||||
- `SatelliteProvider.Api/Authentication/PermissionsRequirement.cs` + `PermissionsAuthorizationHandler` + `SatellitePermissions` (added by AZ-488; custom requirement that accepts a `permissions` claim shaped as either a single string or a JSON array; powers the `UavUploadPolicy` requiring the `GPS` permission)
|
||||
- `SatelliteProvider.Api/DTOs/UavTileBatchUploadRequest.cs` (added by AZ-488; multipart form binding envelope — kept in WebApi because it depends on `IFormFileCollection` + `[FromForm]`, both API-layer types)
|
||||
- `SatelliteProvider.Api/Validators/ValidationEndpointFilter.cs` + `ValidationEndpointFilterExtensions.cs` (added by AZ-795; generic `IEndpointFilter<T>` that runs the registered `IValidator<T>` and returns `Results.ValidationProblem` on failure; opt-in via `RouteHandlerBuilder.WithValidation<T>()`)
|
||||
- `SatelliteProvider.Api/Validators/InventoryRequestValidator.cs` + `TileCoordValidator` (added by AZ-796; FluentValidation rules for `POST /api/satellite/tiles/inventory` — XOR `tiles`/`locationHashes`, per-array cap, slippy-map range checks)
|
||||
- `SatelliteProvider.Api/Validators/GlobalValidatorConfig.cs` (added by AZ-795/AZ-796; idempotent `ApplyOnce()` configures `ValidatorOptions.Global.PropertyNameResolver` so `errors`-map keys are camelCase per `error-shape.md` Inv-4; called from `Program.cs` and from the test assembly's `ModuleInitializer`)
|
||||
- **Internal**: (none)
|
||||
- **Owns**: `SatelliteProvider.Api/**`
|
||||
- **PackageReferences (added by AZ-487, bumped by AZ-496, then by AZ-500)**: `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.7 (pinned to the same minor patch as `Microsoft.AspNetCore.OpenApi` 10.0.7; AZ-496 bumped both packages from 8.0.21 → 8.0.25 in cycle 3 to close cycle-1 D1 + cycle-2 D3 supply-chain findings, then AZ-500 bumped both 8.0.25 → 10.0.7 in cycle 4 as part of the .NET 8 → .NET 10 migration; AZ-500 also bumped `Swashbuckle.AspNetCore` 6.6.2 → 10.1.7 here to land Microsoft.OpenApi 2.x compat required by ASP.NET Core 10).
|
||||
- **PackageReferences (added by AZ-487, bumped by AZ-496, then by AZ-500; AZ-795 added FluentValidation)**: `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.7 (pinned to the same minor patch as `Microsoft.AspNetCore.OpenApi` 10.0.7; AZ-496 bumped both packages from 8.0.21 → 8.0.25 in cycle 3 to close cycle-1 D1 + cycle-2 D3 supply-chain findings, then AZ-500 bumped both 8.0.25 → 10.0.7 in cycle 4 as part of the .NET 8 → .NET 10 migration; AZ-500 also bumped `Swashbuckle.AspNetCore` 6.6.2 → 10.1.7 here to land Microsoft.OpenApi 2.x compat required by ASP.NET Core 10). `FluentValidation` + `FluentValidation.DependencyInjectionExtensions` 12.0.0 added by AZ-795 to back the strict-input-validation epic.
|
||||
- **Imports from**: Common (incl. AZ-488 UAV DTOs + `UavQualityConfig`), DataAccess, TileDownloader (incl. AZ-488 `IUavTileUploadHandler`), RegionProcessing, RouteManagement
|
||||
- **Consumed by**: (none — top-level entry point)
|
||||
|
||||
|
||||
@@ -123,9 +123,9 @@ Adopted into satellite-provider cycle 7 with the recommended ordering: shared va
|
||||
|
||||
| Task | Title | Depends On | Points | Status |
|
||||
|------|-------|-----------|--------|--------|
|
||||
| AZ-794 | Inventory body fields: rename `tileZoom/tileX/tileY` → `z/x/y` (OSM convention) | — (coordinate release with AZ-795 / AZ-796) | 3 | To Do (cycle 7) |
|
||||
| AZ-795 | Strict input validation across all public endpoints (FluentValidation + ProblemDetails) — **Epic with shared-infra ship** | — (children gated on shared infra landing first) | — (epic; shared-infra estimate 5–8 pts; per-endpoint children ~3 pts each) | To Do (cycle 7) |
|
||||
| AZ-796 | Strict validation for inventory endpoint (POST /api/satellite/tiles/inventory) | AZ-795 (HARD — shared infra); coordinate with AZ-794 | 3 | To Do (cycle 7) |
|
||||
| AZ-794 | Inventory body fields: rename `tileZoom/tileX/tileY` → `z/x/y` (OSM convention) | — (coordinate release with AZ-795 / AZ-796) | 3 | Done (cycle 7) |
|
||||
| AZ-795 | Strict input validation across all public endpoints (FluentValidation + ProblemDetails) — **Epic with shared-infra ship** | — (children gated on shared infra landing first) | — (epic; shared-infra estimate 5–8 pts; per-endpoint children ~3 pts each) | Done — shared infra shipped (cycle 7); future per-endpoint child tasks open |
|
||||
| AZ-796 | Strict validation for inventory endpoint (POST /api/satellite/tiles/inventory) | AZ-795 (HARD — shared infra); coordinate with AZ-794 | 3 | Done (cycle 7) |
|
||||
|
||||
## Execution Order
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Inventory API: rename body fields to OSM-style z/x/y
|
||||
|
||||
**Task**: AZ-794_inventory_field_rename_osm
|
||||
**Name**: Rename inventory body fields tileZoom/tileX/tileY → z/x/y (OSM convention)
|
||||
**Description**: Align the `POST /api/satellite/tiles/inventory` body shape with the URL-path slippy-map convention (`{z}/{x}/{y}`). Same coordinate concept is named two different ways inside the same API today; this task makes the body match the URL.
|
||||
**Complexity**: 3 points (recommended; final call by parent-suite team)
|
||||
**Dependencies**: — (coordinate release window with AZ-795 / AZ-796 to bundle the breaking changes)
|
||||
**Component**: SatelliteProvider.Api + SatelliteProvider.Common (DTOs)
|
||||
**Tracker**: AZ-794 (https://denyspopov.atlassian.net/browse/AZ-794)
|
||||
**Epic**: — (related to AZ-795 input-validation epic via Jira "Relates" link)
|
||||
**Originating ticket**: gps-denied-onboard AZ-777 Phase 1 (Jetson probe, 2026-05-22)
|
||||
|
||||
## Origin
|
||||
|
||||
Surfaced during gps-denied-onboard AZ-777 Phase 1 Jetson probing of the parent-suite `satellite-provider` service. The reviewing engineer noted that the inventory endpoint uses two different naming conventions for the same coordinate concept:
|
||||
|
||||
- URL: `GET /tiles/{z}/{x}/{y}` — OSM/XYZ-standard short names.
|
||||
- Body: `{"tiles": [{"tileZoom":12,"tileX":2424,"tileY":1424}]}` — verbose .NET-style names.
|
||||
|
||||
Conformance to the URL convention end-to-end (both URL and body) aligns with 20 years of slippy-map ecosystem norms (OSM, Google, Mapbox, Bing, Leaflet, MapLibre) and removes the need for consumers to translate at the boundary. Jira AZ-794 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
|
||||
|
||||
## Problem
|
||||
|
||||
Same coordinate concept; two names; same API:
|
||||
- URL path uses `z`, `x`, `y`.
|
||||
- Request and response bodies use `tileZoom`, `tileX`, `tileY`.
|
||||
|
||||
Every consumer that thinks in slippy-map vocabulary has to translate at the boundary. Wire size is also ~3× larger than necessary on the field names (a 900-tile inventory request carries ~5 KB of `tileZoom`/`tileX`/`tileY` vs ~1.7 KB of `z`/`x`/`y`).
|
||||
|
||||
## Outcome
|
||||
|
||||
- `POST /api/satellite/tiles/inventory` request body uses `{"tiles": [{"z":...,"x":...,"y":...}]}` per tile entry.
|
||||
- Response body uses `{"results": [{"z":...,"x":...,"y":...,"locationHash":...,"present":...,...}]}` per entry. All non-coord fields (`locationHash`, `present`, `id`, `capturedAt`, `source`, `flightId`, `resolutionMPerPx`) unchanged.
|
||||
- OpenAPI / Swagger spec updated to match.
|
||||
- Schema doc `_docs/02_document/contracts/api/tile-inventory.md` bumped (v1.x.0 → next major) with a Migration / Coexistence section.
|
||||
- Integration tests updated.
|
||||
- Release notes / migration guide entry naming AZ-794 as the breaking-rename owner.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- DTOs for the inventory request + response — exact file path to be confirmed by the implementer (likely `SatelliteProvider.Common/DTO/TileInventory.cs` per AZ-505 spec).
|
||||
- The MapPost handler / minimal-api endpoint registration in `SatelliteProvider.Api/Program.cs` (or wherever the inventory endpoint is wired today).
|
||||
- `SatelliteProvider.IntegrationTests/TileInventoryTests.cs` — update payload builders + response assertions.
|
||||
- `_docs/02_document/contracts/api/tile-inventory.md` — major version bump with Change Log entry naming this task.
|
||||
- Release notes / migration guide.
|
||||
|
||||
### Excluded
|
||||
|
||||
- The `GET /tiles/{z}/{x}/{y}` endpoint — already uses the correct names; no change.
|
||||
- Strict input validation — owned by AZ-796 (sibling under epic AZ-795).
|
||||
- Other endpoint renames.
|
||||
- Internal storage / query / repo-method names (`tile_zoom`, `tile_x`, `tile_y` DB columns) — wire-format change only.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Request body uses short names**
|
||||
Given a POST body `{"tiles":[{"z":12,"x":2424,"y":1424}]}` with valid JWT
|
||||
When `POST /api/satellite/tiles/inventory` is called
|
||||
Then HTTP 200 with `results[0].z == 12`, `results[0].x == 2424`, `results[0].y == 1424` and a deterministic non-zero `locationHash`.
|
||||
|
||||
**AC-2: Response body uses short names**
|
||||
Given a successful inventory call
|
||||
When the response is parsed
|
||||
Then every `results[i]` object contains `z`, `x`, `y` keys (not `tileZoom`, `tileX`, `tileY`). All other fields (`locationHash`, `present`, `id`, `capturedAt`, `source`, `flightId`, `resolutionMPerPx`) are unchanged byte-for-byte from the pre-rename contract.
|
||||
|
||||
**AC-3: OpenAPI spec accuracy**
|
||||
Given `/swagger/v1/swagger.json` (or equivalent)
|
||||
When the InventoryRequest + InventoryEntry schemas are inspected
|
||||
Then they declare `z`, `x`, `y` (not the old names) as the required coordinate properties.
|
||||
|
||||
**AC-4: Migration guidance documented**
|
||||
Given the rename ships
|
||||
Then `_docs/02_document/contracts/api/tile-inventory.md` is bumped to a new major version, the Change Log entry names AZ-794 and the breaking-rename, and a Migration / Coexistence section either (a) names the hard-switch release with the consumer-side bump coordinated, or (b) documents the accept-both transition window.
|
||||
|
||||
## Rollout — pick one
|
||||
|
||||
- **Option 1 — hard switch** (recommended while consumer count is small): rename atomically, bump API version, ship coordinated consumer update in the same release.
|
||||
- **Option 2 — accept-both transition**: server accepts both `z` and `tileZoom` on input for one release; response always uses short names; deprecation notice in release notes; remove long names in next release.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Breaking change** — coordinate with all known consumers before shipping. Known consumer at filing time: `gps-denied-onboard` `HttpTileDownloader` in `src/gps_denied_onboard/components/c11_tile_manager/tile_downloader.py`. Parent-suite team to enumerate the full consumer set before deciding rollout cadence.
|
||||
- **No internal storage rename** — DB columns (`tile_zoom`, `tile_x`, `tile_y`) stay as named. Wire-format change only; internal Postgres schema is out of scope.
|
||||
- **Coordinate with AZ-795 / AZ-796** — if validation strictness ships in the same release as the rename, the validators must use the new short names from day one. Recommended ordering: ship AZ-794 first, then AZ-795 shared infra, then AZ-796.
|
||||
|
||||
## References
|
||||
|
||||
- Jira AZ-794: https://denyspopov.atlassian.net/browse/AZ-794
|
||||
- Related: AZ-795 (validation epic), AZ-796 (inventory validation child)
|
||||
- Originating discovery: gps-denied-onboard AZ-777 (Phase 1 Jetson probe, 2026-05-22)
|
||||
- Current contract doc: `_docs/02_document/contracts/api/tile-inventory.md` v1.0.0
|
||||
- Known consumer side: `gps-denied-onboard/src/gps_denied_onboard/components/c11_tile_manager/tile_downloader.py`
|
||||
@@ -0,0 +1,153 @@
|
||||
# Strict input validation across all public endpoints (FluentValidation + ProblemDetails)
|
||||
|
||||
**Task**: AZ-795_strict_validation_epic
|
||||
**Name**: Strict input validation across all public endpoints
|
||||
**Type**: Epic
|
||||
**Description**: Every public HTTP endpoint must reject malformed input with structured 4xx errors instead of silently coercing missing fields to zero / ignoring unknown fields. Recommended approach: FluentValidation + global ProblemDetails filter + `JsonSerializerOptions.UnmappedMemberHandling.Disallow`.
|
||||
**Complexity**: — (epic; rolls up children. Estimate: 5–8 pts shared infra + ~3 pts per per-endpoint child)
|
||||
**Dependencies**: — (per-endpoint children depend on shared infra landing first)
|
||||
**Component**: SatelliteProvider.Api (DI wiring + global filter + DTOs + validators)
|
||||
**Tracker**: AZ-795 (https://denyspopov.atlassian.net/browse/AZ-795)
|
||||
**Children**: AZ-796 (inventory endpoint — first concrete child); sibling per-endpoint tasks to be added by parent-suite team
|
||||
**Originating ticket**: gps-denied-onboard AZ-777 Phase 1 (Jetson probe, 2026-05-22)
|
||||
|
||||
## Origin
|
||||
|
||||
Discovered during gps-denied-onboard AZ-777 Phase 1 Jetson probing on 2026-05-22. A hand-typed inventory request with the wrong field names (`{"z","x","y"}` instead of the current `{"tileZoom","tileX","tileY"}`) returned **HTTP 200** with `(0,0,0)` coordinates and an identical `locationHash` for every entry. Real client bugs masquerade as valid results because the deserializer silently treats unknown fields as missing and missing fields as `default(int) = 0`.
|
||||
|
||||
For a service that's the single source of truth about which satellite tiles exist, permissive parsing is actively dangerous: corruption downstream, confident wrong answers, hours of debugging on the consumer side.
|
||||
|
||||
Jira AZ-795 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
|
||||
|
||||
## Problem
|
||||
|
||||
Every public-facing JSON endpoint on satellite-provider inherits the same Postel-permissive parsing default:
|
||||
- Missing required fields → silently `default(T)` (e.g. `0` for `int`).
|
||||
- Unknown fields → silently dropped (no `[JsonExtensionData]` capture, no log entry).
|
||||
- Wrong types → silently coerced where possible, silently dropped where not.
|
||||
|
||||
No structured error response. The only contract-level signal a misbehaving client gets today is downstream weirdness (wrong `locationHash`, repeated identical results, etc.) — many hops away from the actual cause.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Every public-facing JSON endpoint rejects malformed input with **HTTP 400 + RFC 7807 ProblemDetails** body naming the offending field(s).
|
||||
- Validators are testable in isolation (unit tests per `RuleFor`) and enforced by the HTTP layer without per-controller try/catch boilerplate.
|
||||
- Unknown-field rejection is wired at the deserializer level so typos can't reach a validator.
|
||||
- Uniform error response shape across all endpoints.
|
||||
- New `_docs/02_document/contracts/api/error-shape.md` v1.0.0 documenting the ProblemDetails contract every endpoint conforms to.
|
||||
|
||||
## Recommended approach
|
||||
|
||||
1. **FluentValidation** for input DTOs (declarative, composable, validators are testable units). Final stack choice belongs to the parent-suite team; if FluentValidation is ruled out by existing constraints, alternatives are stock DataAnnotations + custom model binders or hand-written `IValidator<T>`.
|
||||
2. **Global error filter / ASP.NET model-state behavior** that emits RFC 7807 ProblemDetails for every validation failure. No per-endpoint try/catch boilerplate.
|
||||
3. **Unknown-field rejection** at the deserializer: `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (.NET 8+) or `Newtonsoft.Json` `MissingMemberHandling.Error`. Catches typos like `{"Z":12}` (uppercase) that no validator can catch after deserialization.
|
||||
|
||||
## Error response contract (uniform across all endpoints)
|
||||
|
||||
```json
|
||||
{
|
||||
"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]": ["Unexpected field: 'tileZoom'."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stable enough for consumers to pattern-match. Field names in `errors` paths must use the same casing as the request body (post-AZ-794 short names for the inventory endpoint).
|
||||
|
||||
## Scope
|
||||
|
||||
### Included — Shared infrastructure (this epic owns)
|
||||
|
||||
- DI wiring for FluentValidation (or chosen alternative) in `SatelliteProvider.Api/Program.cs` (or appropriate composition root).
|
||||
- Global error filter / `Configure<ApiBehaviorOptions>(...)` for ProblemDetails formatting.
|
||||
- `JsonSerializerOptions` configuration for unknown-field rejection.
|
||||
- A validator-coverage table in `_docs/02_document/architecture.md` (or equivalent) listing each public endpoint and its validator class.
|
||||
- Shared test fixtures for ProblemDetails assertions in `SatelliteProvider.IntegrationTests`.
|
||||
- New contract artifact: `_docs/02_document/contracts/api/error-shape.md` v1.0.0 (the ProblemDetails shape every endpoint conforms to).
|
||||
|
||||
### Included — Per-endpoint child tasks
|
||||
|
||||
- One child Task per public-facing endpoint that has a JSON body. Each consumes the shared infra.
|
||||
- Each child uses the AC template below.
|
||||
- Parent-suite team enumerates the full endpoint surface from `/swagger/v1/swagger.json` / route map and creates the children.
|
||||
- First child (concrete reference implementation): **AZ-796** — inventory endpoint.
|
||||
|
||||
### Excluded
|
||||
|
||||
- Authentication / authorization changes (JWT contract owned by AZ-494).
|
||||
- Endpoint renaming (**AZ-794** owns the inventory body-field rename).
|
||||
- Rate-limiting / quota (separate concern).
|
||||
- Internal-only admin endpoints, health probes, metrics scrapers (parent-suite team owns in/out decision per endpoint).
|
||||
|
||||
## Acceptance Criteria template (every child task must satisfy)
|
||||
|
||||
**AC-1: Missing required field → 400**
|
||||
Given a POST body that omits a required field
|
||||
When the endpoint is called
|
||||
Then HTTP 400 with `errors.<field>` listing the missing field.
|
||||
|
||||
**AC-2: Unknown field → 400**
|
||||
Given a POST body with an unrecognized field at root or in nested objects
|
||||
When the endpoint is called
|
||||
Then HTTP 400 with `errors[].<location>` naming the unexpected field.
|
||||
|
||||
**AC-3: Wrong type → 400**
|
||||
Given a POST body with a field of unexpected JSON type (e.g. string where integer expected)
|
||||
When the endpoint is called
|
||||
Then HTTP 400 with `errors.<field>` describing the type mismatch.
|
||||
|
||||
**AC-4: Out-of-range value → 400**
|
||||
Given a POST body with a value outside its supported range
|
||||
When the endpoint is called
|
||||
Then HTTP 400 with `errors.<field>` describing the valid range.
|
||||
|
||||
**AC-5: Empty array where non-empty required → 400**
|
||||
Given a POST body where a required non-empty collection is empty
|
||||
When the endpoint is called
|
||||
Then HTTP 400 with `errors.<field>` describing the constraint.
|
||||
|
||||
**AC-6: Validator class is its own file + unit-tested**
|
||||
A `IValidator<RequestDto>` (or equivalent) class exists in its own file under `SatelliteProvider.Api/Validators/` (or per-suite convention), with a unit test per `RuleFor(...)`.
|
||||
|
||||
**AC-7: Integration tests cover one happy + one failure path per AC**
|
||||
`SatelliteProvider.IntegrationTests` adds a fixture that POSTs each bad-payload variant and asserts `status == 400` + ProblemDetails shape + specific `errors[].<location>` path.
|
||||
|
||||
**AC-8: OpenAPI / Swagger spec accuracy**
|
||||
`/swagger/v1/swagger.json` marks required fields, declares ranges, and documents the new 400 response shape.
|
||||
|
||||
## Test requirements
|
||||
|
||||
- **Unit**: one xUnit class per validator. Tests cover each `RuleFor(...)` / equivalent.
|
||||
- **Integration**: `SatelliteProvider.IntegrationTests` adds one fixture per endpoint covering all AC variants (~7–10 new tests per endpoint).
|
||||
- **Contract**: OpenAPI spec snapshot test confirms the published schema rejects what the validator rejects.
|
||||
- **Cross-cutting**: shared `ProblemDetailsAssertions` helper in test infra so every endpoint's failure tests use the same assertion shape.
|
||||
|
||||
## Migration / breaking-change strategy
|
||||
|
||||
Tightening validation is a **breaking behavior change**: clients that today get 200 OK with nonsense will start getting 400. Three approaches the parent-suite team can pick from:
|
||||
|
||||
1. **Hard switch** — ship in one release with a clear "Breaking" note. Cleanest for low-consumer-count (currently 1).
|
||||
2. **Soft warning then enforce** — log warnings for one release when malformed input arrives; enforce in the next.
|
||||
3. **API versioning** — keep `/v1` permissive, add `/v2` strict, migrate consumers, remove `/v1`.
|
||||
|
||||
Recommendation: **#1** while the consumer set is small (currently 1 known: `gps-denied-onboard`).
|
||||
|
||||
## Constraints
|
||||
|
||||
- Shared infra MUST land before any per-endpoint child task — children are gated on it.
|
||||
- Coordinate with **AZ-794** (inventory rename) — recommended ordering ships AZ-794 first so this epic's validators use the final names from day one.
|
||||
- Parent-suite team enumerates the full consumer set before deciding rollout cadence (not just `gps-denied-onboard`).
|
||||
- Per-endpoint child tasks added by parent-suite team after enumerating endpoint surface from OpenAPI / route map. Do NOT create all children up-front — let them be added as the team decomposes.
|
||||
|
||||
## References
|
||||
|
||||
- Jira AZ-795: https://denyspopov.atlassian.net/browse/AZ-795
|
||||
- First child: AZ-796 (inventory endpoint)
|
||||
- Related: AZ-794 (inventory rename), AZ-777 (originating discovery in gps-denied-onboard)
|
||||
- Originating discovery: gps-denied-onboard AZ-777 Phase 1 Jetson probe (2026-05-22)
|
||||
- ASP.NET ProblemDetails reference: https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors
|
||||
- FluentValidation reference: https://docs.fluentvalidation.net/
|
||||
@@ -0,0 +1,124 @@
|
||||
# Strict validation for inventory endpoint (POST /api/satellite/tiles/inventory)
|
||||
|
||||
**Task**: AZ-796_inventory_endpoint_validation
|
||||
**Name**: Strict validation for inventory endpoint
|
||||
**Description**: Add FluentValidation-backed strict input validation to `POST /api/satellite/tiles/inventory`. Reject malformed payloads with RFC 7807 ProblemDetails (HTTP 400). First concrete child of the validation-hardening epic (AZ-795); serves as reference implementation pattern for sibling per-endpoint tasks.
|
||||
**Complexity**: 3 points (recommended)
|
||||
**Dependencies**: AZ-795 (HARD — shared FluentValidation + ProblemDetails + unknown-field-rejection infra must land first); coordinate with AZ-794 (rename)
|
||||
**Component**: SatelliteProvider.Api/Validators + SatelliteProvider.Common (DTOs)
|
||||
**Tracker**: AZ-796 (https://denyspopov.atlassian.net/browse/AZ-796)
|
||||
**Epic**: AZ-795 — Strict input validation across all public endpoints
|
||||
**Originating ticket**: gps-denied-onboard AZ-777 Phase 1 (Jetson probe, 2026-05-22)
|
||||
|
||||
## Origin
|
||||
|
||||
Discovered during gps-denied-onboard AZ-777 Phase 1 Jetson probing on 2026-05-22 — see parent epic AZ-795 for full context. This ticket scopes the strict-validation work to the **inventory endpoint** as the first concrete reference implementation; sibling per-endpoint child tasks will follow the same pattern.
|
||||
|
||||
Jira AZ-796 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
|
||||
|
||||
## Problem
|
||||
|
||||
`POST /api/satellite/tiles/inventory` today accepts malformed payloads silently:
|
||||
- Missing required fields (`z`, `x`, `y`) → silently coerced to `0`, producing `locationHash` collisions and `(0,0,0)` echoed back as if the client had asked for tile (0,0,0).
|
||||
- Unknown fields (typos like `{"Z":12}` uppercase) → silently dropped, then required field appears missing → silently 0.
|
||||
- Wrong types → silently coerced where possible.
|
||||
- No structured 4xx response. Real client bugs surface downstream as "all my inventory results have the same locationHash" — many hops from the actual cause.
|
||||
|
||||
Concrete reproducer (from the originating probe):
|
||||
|
||||
```bash
|
||||
curl -sk -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
|
||||
-d '{"tiles":[{"z":12,"x":2424,"y":1424},{"z":12,"x":2425,"y":1425}]}' \
|
||||
https://satellite-provider:8080/api/satellite/tiles/inventory
|
||||
```
|
||||
|
||||
Returns HTTP 200 with both `results` entries carrying `tileZoom:0, tileX:0, tileY:0` and identical `locationHash`. Expected: HTTP 400 naming `z`, `x`, `y` as unexpected fields (pre-AZ-794) or 200 with correct echo (post-AZ-794) — but never silently-wrong 200.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `POST /api/satellite/tiles/inventory` rejects malformed payloads with HTTP 400 + RFC 7807 ProblemDetails matching the shape defined by the parent epic AZ-795.
|
||||
- An `IValidator<InventoryRequestDto>` (or equivalent) covers all 9 validation rules listed below.
|
||||
- Integration tests cover one happy path + one failure path per validation rule.
|
||||
- OpenAPI spec marks required fields, declares ranges, and documents the new 400 response.
|
||||
- Schema doc `_docs/02_document/contracts/api/tile-inventory.md` updated to reference the validation rules + error contract.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- `InventoryRequestValidator` (or equivalent class) in `SatelliteProvider.Api/Validators/` — full coverage of the 9 validation rules below.
|
||||
- Wiring of the validator into the MapPost / minimal-api endpoint registration in `SatelliteProvider.Api/Program.cs` (or wherever the inventory endpoint is wired today). Wiring leverages the shared infra from AZ-795.
|
||||
- Unit tests for the validator (`SatelliteProvider.UnitTests` or appropriate — one test method per `RuleFor(...)`).
|
||||
- Integration tests in `SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs` (new file) — happy + failure case per AC.
|
||||
- Update to `_docs/02_document/contracts/api/tile-inventory.md` documenting the validation rules + error shape.
|
||||
- Update to `/swagger/v1/swagger.json` (via XML doc comments / Swashbuckle annotations) marking required fields + ranges + 400 response.
|
||||
|
||||
### Excluded
|
||||
|
||||
- Shared infra wiring (parent epic AZ-795 owns this).
|
||||
- Validation for other endpoints (sibling child tasks under AZ-795 will be added by parent-suite team).
|
||||
- The field rename itself (AZ-794).
|
||||
- Auth / JWT changes.
|
||||
- Performance considerations (existing AZ-505 perf gates remain in effect; validation overhead expected to be negligible vs DB round-trip).
|
||||
|
||||
## Required validations (9 rules)
|
||||
|
||||
Naming below assumes AZ-794 (rename) has shipped. If validators land BEFORE AZ-794, swap `z/x/y` for `tileZoom/tileX/tileY` and re-rename when AZ-794 lands.
|
||||
|
||||
1. **Body present** — null/empty body → 400.
|
||||
2. **`tiles` field required** — missing → 400 with `errors.tiles: ["required"]`.
|
||||
3. **`tiles` non-empty** — empty array → 400 with `errors.tiles: ["must contain at least 1 entry"]`.
|
||||
4. **`tiles` max size** — to be confirmed with parent-suite (existing AZ-505 spec uses 5000 for the EITHER/OR body shape; reaffirm here or align). Over the cap → 400.
|
||||
5. **Each entry has `z`, `x`, `y`** — any missing → 400 with `errors.tiles[i].<field>: ["required"]`.
|
||||
6. **Each field is non-negative integer** — wrong type or negative → 400.
|
||||
7. **`z` within supported zoom range** — out of range → 400 with `errors.tiles[i].z: ["must be between {min} and {max}"]`. Range to be confirmed with parent-suite (existing AZ-484 / AZ-503 schemas suggest 0–22; reaffirm here).
|
||||
8. **`x` / `y` within tile-axis bounds for given `z`** — i.e. `0 <= x,y < 2^z` — out of range → 400 with `errors.tiles[i].x` or `.y`.
|
||||
9. **Unknown fields at root or in tile entries** — 400 with `errors[].<location>: ["unexpected field: '<name>'"]`. Requires `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (.NET 8+) at the deserializer level — this is part of AZ-795 shared infra and must be wired first.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Each of the 9 validations rejects with HTTP 400 + ProblemDetails**
|
||||
Given a POST body that violates exactly ONE validation rule (one failure case per rule)
|
||||
When `POST /api/satellite/tiles/inventory` is called with valid JWT
|
||||
Then HTTP 400; response body matches the parent epic's ProblemDetails shape; `errors[].<location>` names the specific failing field; `errors[]` array does NOT include unrelated rules (single-rule precision).
|
||||
|
||||
**AC-2: Happy path unchanged**
|
||||
Given a POST body that satisfies all 9 validations
|
||||
When `POST /api/satellite/tiles/inventory` is called with valid JWT
|
||||
Then HTTP 200 with the existing result shape (one entry per requested tile, same ordering, fields preserved from the pre-validation contract). No regression in existing `TileInventoryTests.cs` happy-path assertions.
|
||||
|
||||
**AC-3: Validator class is its own file + unit-tested**
|
||||
A `InventoryRequestValidator` (or equivalent) class exists in its own file under `SatelliteProvider.Api/Validators/` (or per-suite convention). xUnit test class has one test method per `RuleFor(...)` — i.e. ≥ 9 unit-test methods.
|
||||
|
||||
**AC-4: Integration tests cover happy + failure per rule**
|
||||
`SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs` (new file) has ≥ 10 test methods: 1 happy path + 9 failure cases. Each failure case POSTs the malformed payload, asserts `status == 400`, asserts ProblemDetails shape, asserts the specific `errors[].<location>` matches the rule.
|
||||
|
||||
**AC-5: OpenAPI spec accuracy**
|
||||
Given `/swagger/v1/swagger.json` (or equivalent)
|
||||
When the InventoryRequest schema + endpoint operation are inspected
|
||||
Then required fields are marked `required: true`, integer types are declared with `minimum`/`maximum` per the validation rules, the endpoint declares a 400 response with the ProblemDetails schema.
|
||||
|
||||
**AC-6: Schema doc updated**
|
||||
`_docs/02_document/contracts/api/tile-inventory.md` is updated (Change Log entry naming AZ-796) to document the validation rules + error contract. No version bump required (additive — error shape is a previously-undefined contract; clients that send valid payloads see no change).
|
||||
|
||||
**AC-7: Manual probe captures each failure mode end-to-end**
|
||||
A `scripts/probe_inventory_validation.sh` (or Postman / Bruno collection) is committed that exercises each failure mode via real `curl` with a JWT, capturing the actual response body for documentation/regression.
|
||||
|
||||
## Coordination with sibling tickets
|
||||
|
||||
- **Parent (AZ-795)**: shared FluentValidation + ProblemDetails + unknown-field-rejection infra must land first.
|
||||
- **AZ-794 (inventory rename)**: if it ships first, validators use `z/x/y` from day one. If it ships in the same release, coordinate field names so this ticket lands once with the final names. If it ships later, validators initially use `tileZoom/tileX/tileY` and get renamed at AZ-794 ship time — less ideal but acceptable.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Breaking behavior change** — clients that today get 200 with nonsense will start getting 400. Coordinate rollout with all known consumers per AZ-795's migration strategy section.
|
||||
- **No regression in existing `TileInventoryTests.cs`** happy-path assertions (AZ-505 AC coverage).
|
||||
- **No change to internal repository / DB query path** — validation lives at the API layer only.
|
||||
|
||||
## References
|
||||
|
||||
- Jira AZ-796: https://denyspopov.atlassian.net/browse/AZ-796
|
||||
- Parent epic: AZ-795 (shared infra; error-shape contract)
|
||||
- Related: AZ-794 (rename), AZ-505 (existing inventory endpoint spec)
|
||||
- Originating discovery: gps-denied-onboard AZ-777 Phase 1 Jetson probe (2026-05-22)
|
||||
- Current contract doc: `_docs/02_document/contracts/api/tile-inventory.md` v1.0.0
|
||||
@@ -6,9 +6,9 @@ step: 10
|
||||
name: Implement
|
||||
status: in_progress
|
||||
sub_step:
|
||||
phase: 0
|
||||
name: awaiting-invocation
|
||||
detail: ""
|
||||
phase: 12
|
||||
name: tracker-in-testing
|
||||
detail: "batch 1 of 1; AZ-794 + AZ-795 + AZ-796 implementation complete; full Docker Compose suite green (311 unit tests + integration tests including 16 new inventory-validation cases); task files archived todo/ -> done/; ready to commit + push and transition Jira tickets to In Testing"
|
||||
retry_count: 0
|
||||
cycle: 7
|
||||
tracker: jira
|
||||
|
||||
Reference in New Issue
Block a user