Files
satellite-provider/_docs/02_document/contracts/api/error-shape.md
T

10 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.1 Status: frozen Last Updated: 2026-06-25

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 rejectionJsonSerializerOptions.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 rejectionFluentValidation (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)

{
  "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].foo": ["The field value is invalid."]
  }
}

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 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, or raw framework exception messages).
  • 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; the message is the static string "The field value is invalid." (deserializer path) or a FluentValidation rule message (validator 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; deserializer failures use "The field value is invalid.".

Information disclosure (4xx messages)

Source Client-visible message Server-side detail
GlobalExceptionHandler + inner JsonException errors[<path>]: "The field value is invalid." Full JsonException in server logs when logged
GlobalExceptionHandler + BadHttpRequestException (no JsonException) detail: "The request could not be processed." Framework message not echoed
UavUploadValidationFilter metadata parse errors["metadata"]: "metadata could not be parsed as JSON." No ex.Message echo
UavTileUploadHandler metadata parse (defense-in-depth) Envelope error: same static string as filter No ex.Message echo
FluentValidation rules Rule-specific consumer-oriented strings Unchanged

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"] = "The field value is invalid." 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.1 2026-06-25 Sanitize deserializer/binding 400 messages — static strings replace raw JsonException / BadHttpRequestException text (AZ-1113). Adds Information Disclosure section. autodev (Step 10, cycle 10)
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)