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>
9.1 KiB
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:
- Deserializer-level rejection —
JsonSerializerOptions.UnmappedMemberHandling.Disallow(.NET 8+) catches unknown fields, type mismatches, and malformed JSON. The framework'sBadHttpRequestExceptioncarries aSystem.Text.Json.JsonExceptionas its inner exception;GlobalExceptionHandler(SatelliteProvider.Api/GlobalExceptionHandler.cs) extracts the JSON path and emits aValidationProblemDetailsbody. - Business-rule rejection —
FluentValidation(12.0.0) validators wired through the genericValidationEndpointFilter<T>(SatelliteProvider.Api/Validators/ValidationEndpointFilter.cs). Endpoints opt in viaRouteHandlerBuilder.WithValidation<T>(). The filter callsResults.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)
{
"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:
{
"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):
{
"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
errorsobject. - Inv-3: Each
errorsentry has at least one message. Empty arrays are forbidden. - Inv-4: Field-path keys in
errorsuse the same casing as the request body (camelCase root, dotted/indexed access for nested types). - Inv-5: 5xx responses include a
correlationIdextension 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
errorsmap 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.,
correlationIdbecoming 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
errorsmap shape (e.g. swapping to error-code keys). ChangingContent-Type. Renamingerrorsto anything else. RemovingcorrelationIdfrom 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) |