mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 16:21:15 +00:00
5e056b2334
Third concrete child of AZ-795 (cycle 8 batch 3). FluentValidation +
[JsonRequired] + UnmappedMemberHandling.Disallow combine to reject every
malformed payload at the API boundary with RFC 7807 ValidationProblemDetails.
Validators (SatelliteProvider.Api/Validators/, all new)
- CreateRouteRequestValidator: id non-empty, name/description length,
regionSizeMeters/zoomLevel ranges, points count [2, 500], cross-field
createTilesZip => requestMaps. Chains RoutePointValidator (per-point)
and GeofencePolygonValidator (per-polygon, guarded by When(Geofences != null)).
OverridePropertyName("geofences.polygons") on the geofences chain so
FluentValidation's default leaf-only key policy doesn't drop the parent
path on deep expressions like req.Geofences!.Polygons.
- RoutePointValidator: lat/lon ranges; OverridePropertyName("lat"/"lon")
chained AFTER InclusiveBetween (the extension is defined on
IRuleBuilderOptions<T, TProperty>, so the generic type is only
inferable after the first concrete rule) so error keys match the
wire format (`points[i].lat`) rather than the C# property name
(`points[i].latitude`).
- GeofencePolygonValidator: per-corner range checks via private nested
GeoCornerValidator; cross-field NW.Lat > SE.Lat and NW.Lon < SE.Lon
invariants emit at errors["geofences.polygons[i].northWest"].
DTOs (SatelliteProvider.Common/DTO/, [JsonRequired] additions only)
- CreateRouteRequest: id, name, regionSizeMeters, zoomLevel, points,
requestMaps, createTilesZip
- RoutePoint: Latitude, Longitude
- GeofencePolygon: NorthWest, SouthEast; Geofences: Polygons
- GeoPoint: Lat, Lon
Tests
- Unit: 26 methods total — 16 in CreateRouteRequestValidatorTests, 6 in
GeofencePolygonValidatorTests, 4 in RoutePointValidatorTests. Each
RuleFor/RuleForEach chain has at least one positive + one negative case.
- Integration: CreateRouteValidationTests.cs — 16 methods (happy + 15
failure modes) wired into smoke + full suites. Covers empty body,
missing/zero id, empty name, out-of-range regionSizeMeters/zoomLevel,
points count < 2, per-point lat/lon out-of-range, geofence invariants,
missing requestMaps, cross-field createTilesZip, unknown root field,
nested type mismatch.
- Manual probe: scripts/probe_route_validation.sh curl-exercises every
failure mode end-to-end + happy path.
Docs
- New contract _docs/02_document/contracts/api/route-creation.md v1.0.0
with nested DTO chain, invariants, per-field test cases table, and
advisories on the legacy service-layer RouteValidator + the
input/output RoutePoint vs RoutePointDto naming asymmetry.
- system-flows.md F4 sequence diagram extended with the validation-filter
branch; preconditions + error scenarios reference the new contract.
- modules/api_program.md: CreateRoute handler section added; Api/Validators
bumped to AZ-808/AZ-809/AZ-811.
- modules/common_dtos.md: DTO descriptions updated with [JsonRequired]
annotations and constraint summaries.
- tests/blackbox-tests.md BT-06/BT-N03/BT-N04/BT-N05 align with the new
wire format and named error keys.
- tests/security-tests.md SEC-04 references GlobalExceptionHandler's
JsonException branch + AZ-353 correlationId.
- _docs/03_implementation/batch_03_cycle8_report.md + reviews/batch_03_cycle8_review.md
(PASS_WITH_NOTES — F1 Low: OverridePropertyName documented inline,
F2 + F3 Info: pre-existing advisories for follow-up).
Smoke green (mode=smoke, exit 0). AZ-809 transitioned to In Testing on Jira.
Task file moved to _docs/02_tasks/done/.
Co-authored-by: Cursor <cursoragent@cursor.com>
392 lines
20 KiB
Markdown
392 lines
20 KiB
Markdown
# Satellite Provider — System Flows
|
|
|
|
## Flow Inventory
|
|
|
|
| # | Flow Name | Trigger | Primary Components | Criticality |
|
|
|---|-----------|---------|-------------------|-------------|
|
|
| F1 | Single Tile Download | HTTP GET /api/satellite/tiles/latlon | WebApi, TileDownloader, DataAccess | High |
|
|
| F2 | Region Request | HTTP POST /api/satellite/request | WebApi, RegionProcessing, TileDownloader, DataAccess | High |
|
|
| F3 | Region Processing | Queue dequeue (background) | RegionProcessing, TileDownloader, DataAccess | High |
|
|
| F4 | Route Creation | HTTP POST /api/satellite/route | WebApi, RouteManagement, DataAccess | High |
|
|
| F5 | Route Map Processing | Queue dequeue (background) | RouteManagement, RegionProcessing, TileDownloader, DataAccess | Medium |
|
|
| F6 | Status Query | HTTP GET /api/satellite/region/{id} or /route/{id} | WebApi, DataAccess | Low |
|
|
| F7 | Leaflet Tile Serving | HTTP GET /tiles/{z}/{x}/{y} | WebApi, TileService, DataAccess, FileSystem | High |
|
|
| F8 | Tile Inventory Bulk Lookup | HTTP POST /api/satellite/tiles/inventory | WebApi, TileService, DataAccess | High |
|
|
|
|
## Flow Dependencies
|
|
|
|
| Flow | Depends On | Shares Data With |
|
|
|------|-----------|-----------------|
|
|
| F1 | — | F3 (tile cache) |
|
|
| F2 | — | F3 (triggers it) |
|
|
| F3 | F2 enqueues work | F1 (shares tile cache), F5 |
|
|
| F4 | — | F5 (triggers it) |
|
|
| F5 | F4 must create route first | F3 (submits region requests) |
|
|
| F6 | F2/F4 must exist | — |
|
|
| F7 | F1 or F3 must have populated the tile (else 404) | F1, F3, F8 (shares `tiles.location_hash`) |
|
|
| F8 | — | F1, F3, F7 (shares `tiles.location_hash`) |
|
|
|
|
---
|
|
|
|
## Flow F1: Single Tile Download
|
|
|
|
### Description
|
|
|
|
Client requests a single satellite tile by geographic coordinates and zoom level. The service checks the cache (DB), downloads from Google Maps if not cached, stores it, and returns metadata. The wire-format contract is `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0; failure responses follow `error-shape.md` v1.0.0.
|
|
|
|
### Preconditions
|
|
|
|
- Query params `lat` ∈ [-90, 90], `lon` ∈ [-180, 180], `zoom` ∈ [0, 22]. Any unknown query key (e.g. legacy `?Latitude=` typo) is rejected by `RejectUnknownQueryParamsEndpointFilter` (AZ-811 cycle 8) with HTTP 400. Range checks via `GetTileByLatLonQueryValidator`.
|
|
- Google Maps session token configured
|
|
|
|
### Sequence Diagram
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Client
|
|
participant WebApi
|
|
participant TileService
|
|
participant TileRepo
|
|
participant GoogleMaps
|
|
participant FileSystem
|
|
|
|
Client->>WebApi: GET /api/satellite/tiles/latlon?lat&lon&zoom
|
|
WebApi->>TileService: DownloadTileAsync(lat, lon, zoom)
|
|
TileService->>TileRepo: FindByCoordinates(lat, lon, zoom)
|
|
alt Tile exists in cache
|
|
TileRepo-->>TileService: TileEntity
|
|
TileService-->>WebApi: TileMetadata (cached)
|
|
else Not cached
|
|
TileService->>GoogleMaps: Download tile image
|
|
GoogleMaps-->>TileService: JPEG bytes
|
|
TileService->>FileSystem: Save to ./tiles/{zoom}/{x}/{y}.jpg
|
|
TileService->>TileRepo: Insert(TileEntity)
|
|
TileService-->>WebApi: TileMetadata (new)
|
|
end
|
|
WebApi-->>Client: JSON response
|
|
```
|
|
|
|
### Error Scenarios
|
|
|
|
| Error | Where | Detection | Recovery |
|
|
|-------|-------|-----------|----------|
|
|
| Google Maps timeout | Download step | HttpClient timeout | Return error to caller |
|
|
| Duplicate download race | Concurrent requests | ConcurrentDictionary check | Await existing download |
|
|
| Disk full | File save | IOException | Exception propagated, region fails |
|
|
|
|
---
|
|
|
|
## Flow F2: Region Request
|
|
|
|
### Description
|
|
|
|
Client submits a region definition (center point, size, zoom). The request is persisted and queued for asynchronous processing. The wire-format contract is `_docs/02_document/contracts/api/region-request.md` v1.0.0; failure responses follow `error-shape.md` v1.0.0.
|
|
|
|
### Preconditions
|
|
|
|
- Valid region parameters: non-zero `id` (UUID), `lat` ∈ [-90, 90], `lon` ∈ [-180, 180], `sizeMeters` ∈ [100, 10000], `zoomLevel` ∈ [0, 22], explicit `stitchTiles` (bool, no default). Enforced by `RegionRequestValidator` + `[JsonRequired]` at the API edge (AZ-808 cycle 8).
|
|
|
|
### Sequence Diagram
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Client
|
|
participant WebApi
|
|
participant RegionService
|
|
participant RegionRepo
|
|
participant Queue
|
|
|
|
Client->>WebApi: POST /api/satellite/request {lat, lon, size, zoom}
|
|
WebApi->>RegionService: CreateRegionRequest(dto)
|
|
RegionService->>RegionRepo: Insert(RegionEntity status=pending)
|
|
RegionRepo-->>RegionService: region_id
|
|
RegionService->>Queue: Enqueue(region_id)
|
|
RegionService-->>WebApi: region_id
|
|
WebApi-->>Client: 200 OK {region_id}
|
|
```
|
|
|
|
### Error Scenarios
|
|
|
|
| Error | Where | Detection | Recovery |
|
|
|-------|-------|-----------|----------|
|
|
| Queue full | Enqueue step | Channel at capacity | Return 503 / reject request |
|
|
| DB insert failure | Persist step | Exception | Return 500 |
|
|
|
|
---
|
|
|
|
## Flow F3: Region Processing (Background)
|
|
|
|
### Description
|
|
|
|
Background service dequeues region IDs, calculates tile grid, downloads all tiles (with concurrency control), optionally stitches them, and produces output files (CSV, summary, stitched image).
|
|
|
|
### Preconditions
|
|
|
|
- Region exists in DB with status "pending"
|
|
- Google Maps session token configured
|
|
|
|
### Sequence Diagram
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Queue
|
|
participant RegionProcessor
|
|
participant RegionService
|
|
participant TileService
|
|
participant GoogleMaps
|
|
participant RegionRepo
|
|
participant FileSystem
|
|
|
|
Queue->>RegionProcessor: Dequeue region_id
|
|
RegionProcessor->>RegionRepo: GetById(region_id)
|
|
RegionProcessor->>RegionRepo: UpdateStatus(processing)
|
|
loop For each tile in grid
|
|
RegionProcessor->>TileService: DownloadTileAsync(lat, lon, zoom)
|
|
TileService->>GoogleMaps: Download (if not cached)
|
|
end
|
|
RegionProcessor->>FileSystem: Write CSV (tile manifest)
|
|
RegionProcessor->>FileSystem: Write summary file
|
|
opt stitch_tiles = true
|
|
RegionProcessor->>FileSystem: Stitch tiles into composite image
|
|
end
|
|
RegionProcessor->>RegionRepo: UpdateStatus(completed, file paths)
|
|
```
|
|
|
|
### Data Flow
|
|
|
|
| Step | From | To | Data | Format |
|
|
|------|------|----|------|--------|
|
|
| 1 | Queue | RegionProcessor | region_id | int |
|
|
| 2 | RegionProcessor | TileService | lat, lon, zoom per tile | method call |
|
|
| 3 | TileService | FileSystem | JPEG image | file |
|
|
| 4 | RegionProcessor | FileSystem | tile manifest | CSV |
|
|
| 5 | RegionProcessor | FileSystem | region summary | TXT |
|
|
| 6 | RegionProcessor | FileSystem | composite image | JPEG |
|
|
|
|
### Error Scenarios
|
|
|
|
| Error | Where | Detection | Recovery |
|
|
|-------|-------|-----------|----------|
|
|
| Tile download failure | Per-tile loop | Exception from TileService | Log, continue with remaining tiles |
|
|
| All tiles fail | After loop | Zero tiles downloaded | Mark region as "failed" |
|
|
| Stitch failure | Image processing | ImageSharp exception | Mark region failed, tiles still available |
|
|
|
|
---
|
|
|
|
## Flow F4: Route Creation
|
|
|
|
### Description
|
|
|
|
Client submits a route (ordered waypoints + optional geofence polygons). The service interpolates intermediate points every ~200m and persists the full point set. The wire-format contract is `_docs/02_document/contracts/api/route-creation.md` v1.0.0; failure responses follow `error-shape.md` v1.0.0.
|
|
|
|
### Preconditions
|
|
|
|
- JWT in `Authorization: Bearer <token>` validates against the API's signing key, issuer, and audience (`.RequireAuthorization()`).
|
|
- Request body deserializes successfully: all `[JsonRequired]` axes present (`id`, `name`, `regionSizeMeters`, `zoomLevel`, `points`, `requestMaps`, `createTilesZip`, plus per-point `lat`/`lon`, per-polygon `northWest`/`southEast`, per-corner `lat`/`lon`, `geofences.polygons` when `geofences` present); no unknown root or nested fields (`UnmappedMemberHandling.Disallow`).
|
|
- `CreateRouteRequestValidator` rules pass: non-zero `id`, name length \[1, 200\], description length ≤ 1000, `regionSizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\], `points` count ∈ \[2, 500\] with each point's lat/lon in range, per-polygon corner ranges + NW-of-SE invariants, `createTilesZip ⇒ requestMaps`.
|
|
|
|
### Sequence Diagram
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Client
|
|
participant WebApi
|
|
participant ValidationFilter
|
|
participant RouteService
|
|
participant RouteRepo
|
|
participant GeoUtils
|
|
|
|
Client->>WebApi: POST /api/satellite/route {id, name, points, geofences?, ...}
|
|
WebApi->>ValidationFilter: .WithValidation<CreateRouteRequest>()
|
|
alt validation fails
|
|
ValidationFilter-->>Client: 400 ValidationProblemDetails (errors{path→msg})
|
|
else validation passes
|
|
WebApi->>RouteService: CreateRoute(request)
|
|
RouteService->>GeoUtils: Interpolate points between waypoints
|
|
GeoUtils-->>RouteService: All points (original + intermediate)
|
|
RouteService->>RouteRepo: InsertRoute(RouteEntity)
|
|
RouteService->>RouteRepo: InsertPoints(RoutePointEntities)
|
|
RouteService-->>WebApi: RouteResponse
|
|
WebApi-->>Client: 200 OK {id, totalPoints, totalDistanceMeters, ...}
|
|
end
|
|
```
|
|
|
|
### Error Scenarios
|
|
|
|
| Error | Where | Detection | Recovery |
|
|
|-------|-------|-----------|----------|
|
|
| Missing `[JsonRequired]` axis / unknown field / type mismatch | Deserializer | `JsonException` → `GlobalExceptionHandler` | Return 400 `ValidationProblemDetails` (per `error-shape.md` v1.0.0) |
|
|
| Validator rule violation (range, count, cross-field) | `ValidationEndpointFilter<CreateRouteRequest>` | `CreateRouteRequestValidator` + nested `RoutePointValidator` / `GeofencePolygonValidator` | Return 400 with `errors{path→msg}` map |
|
|
| DB insert failure | Persist step | Exception | Return 500 (sanitised body + correlationId per AZ-353) |
|
|
|
|
---
|
|
|
|
## Flow F5: Route Map Processing (Background)
|
|
|
|
### Description
|
|
|
|
When a route requests map tiles (`request_maps = true`), a background service creates region requests for each route point, optionally filtered by geofence, then waits for all regions to complete and produces a ZIP archive.
|
|
|
|
### Preconditions
|
|
|
|
- Route exists with `request_maps = true`
|
|
- Route points already interpolated and persisted
|
|
|
|
### Sequence Diagram
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant RouteProcessor
|
|
participant RouteRepo
|
|
participant RegionService
|
|
participant Queue
|
|
participant RegionProcessor
|
|
participant FileSystem
|
|
|
|
RouteProcessor->>RouteRepo: GetRouteWithPoints(route_id)
|
|
loop For each route point
|
|
RouteProcessor->>RouteProcessor: Check geofence (point-in-polygon)
|
|
opt Point inside geofence (or no geofence)
|
|
RouteProcessor->>RegionService: CreateRegionRequest(point)
|
|
RegionService->>Queue: Enqueue(region_id)
|
|
end
|
|
end
|
|
RouteProcessor->>RouteProcessor: Wait for all regions to complete
|
|
opt create_tiles_zip = true
|
|
RouteProcessor->>FileSystem: Create ZIP of all tiles (max 50MB)
|
|
RouteProcessor->>RouteRepo: Update tiles_zip_path
|
|
end
|
|
```
|
|
|
|
### Error Scenarios
|
|
|
|
| Error | Where | Detection | Recovery |
|
|
|-------|-------|-----------|----------|
|
|
| Region processing timeout | Wait loop | Polling timeout | Mark route partially complete |
|
|
| ZIP exceeds 50MB | ZIP creation | Size check during write | Truncate or skip |
|
|
| Geofence calculation error | Point-in-polygon | Exception | Include point (fail-open) |
|
|
|
|
---
|
|
|
|
## Flow F6: Status Query
|
|
|
|
### Description
|
|
|
|
Client polls for the status of a region or route by ID. Returns current processing state and output file paths when complete.
|
|
|
|
### Sequence Diagram
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Client
|
|
participant WebApi
|
|
participant DataAccess
|
|
|
|
Client->>WebApi: GET /api/satellite/region/{id}
|
|
WebApi->>DataAccess: GetRegionById(id)
|
|
DataAccess-->>WebApi: RegionEntity (status, file paths)
|
|
WebApi-->>Client: JSON {status, files}
|
|
```
|
|
|
|
---
|
|
|
|
## Flow F7: Leaflet Tile Serving (added AZ-310, rewired AZ-505)
|
|
|
|
### Description
|
|
|
|
Leaflet (or any HTTP/2-capable client) requests a single tile body by slippy `(z, x, y)`. The handler resolves the most-recent variant across sources/flights by `location_hash` and streams the JPEG body back. AZ-505 rewired the lookup predicate from `(tile_zoom, tile_x, tile_y)` to `location_hash = Uuidv5(TileNamespace, "{z}/{x}/{y}")` so the read hits the `tiles_leaflet_path` covering index as an `Index Only Scan`; the selection rule (`captured_at DESC, updated_at DESC, id DESC LIMIT 1`) is unchanged from AZ-484 / AZ-503-foundation. Kestrel runs `Http1AndHttp2` over TLS (`https://+:8080` in dev) so ALPN multiplexes many concurrent leaflet requests on a single TLS connection.
|
|
|
|
### Sequence Diagram
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Client
|
|
participant Kestrel
|
|
participant ServeTile
|
|
participant TileService
|
|
participant TileRepo
|
|
participant FileSystem
|
|
|
|
Client->>Kestrel: GET /tiles/{z}/{x}/{y} (HTTP/2 via TLS+ALPN)
|
|
Kestrel->>ServeTile: route match
|
|
ServeTile->>TileService: GetOrDownloadTileAsync(z, x, y)
|
|
TileService->>TileRepo: GetByTileCoordinatesAsync(z, x, y)
|
|
Note over TileRepo: WHERE location_hash = $1<br/>ORDER BY captured_at DESC, updated_at DESC, id DESC<br/>LIMIT 1<br/>Index Only Scan: tiles_leaflet_path
|
|
alt Cached
|
|
TileRepo-->>TileService: TileEntity
|
|
TileService->>FileSystem: read file_path
|
|
FileSystem-->>TileService: JPEG bytes
|
|
TileService-->>ServeTile: TileBytes (file_path, ETag, Cache-Control)
|
|
ServeTile-->>Client: 200 OK, JPEG body
|
|
else Miss
|
|
TileService->>TileService: download via GoogleMapsDownloaderV2
|
|
Note over TileService: persists row + on-disk path, falls through to ServeTile
|
|
TileService-->>ServeTile: TileBytes
|
|
ServeTile-->>Client: 200 OK, JPEG body
|
|
end
|
|
```
|
|
|
|
### Error Handling
|
|
|
|
| Failure | Detection | Handling |
|
|
|---------|-----------|----------|
|
|
| Tile not present and downloader rejects (404 from Google Maps) | `TileService.GetOrDownloadTileAsync` propagates the downloader's `HttpRequestException` | Returns 500; `ServeTile` does NOT translate this to 404 because the predicate matched (path-traversal cases below 404 earlier in routing) |
|
|
| Path traversal in `/tiles/...` segment | ASP.NET Core route binding | `400`/`404` before the handler runs (covered by SEC-02) |
|
|
|
|
---
|
|
|
|
## Flow F8: Tile Inventory Bulk Lookup (added AZ-505; renamed + strict-validated AZ-794+AZ-795+AZ-796, cycle 7)
|
|
|
|
### Description
|
|
|
|
Programmatic clients (httpx `http2=True`, .NET `HttpClient`, onboard cross-repo callers) post a batch of up to 5000 `{z, x, y}` triples (Form A; the wire field names were renamed from `tileZoom/tileX/tileY` by AZ-794, cycle 7) or up to 5000 pre-computed `location_hash` UUIDs (Form B) and get one inventory entry per input slot, in the same order. Each entry says whether the cell is present and — when present — the most-recent row's `id`, `capturedAt`, `source`, `flightId`, and `resolutionMPerPx`. No tile bodies are returned; the caller subsequently fetches bodies via F7. This is the read-half of the bulk-list contract that the onboard `gps-denied-onboard` workspace consumes to decide which Google-Maps cells it needs and which UAV variants are already on the server.
|
|
|
|
### Sequence Diagram
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Client
|
|
participant Kestrel
|
|
participant GetTilesInventory
|
|
participant TileService
|
|
participant TileRepo
|
|
|
|
Client->>Kestrel: POST /api/satellite/tiles/inventory (JWT, Form A or B)
|
|
Kestrel->>GetTilesInventory: route match
|
|
Note over Kestrel,GetTilesInventory: AZ-795 deserializer guards (UnmappedMemberHandling.Disallow + [JsonRequired])<br/>catch unknown / missing / type-mismatched fields → 400 ValidationProblemDetails<br/>via GlobalExceptionHandler (cycle 7)
|
|
GetTilesInventory->>GetTilesInventory: ValidationEndpointFilter<TileInventoryRequest><br/>(InventoryRequestValidator — AZ-796 cycle 7)
|
|
Note over GetTilesInventory: 9 business rules: XOR / non-empty / per-array cap / Z range / X+Y range
|
|
GetTilesInventory->>TileService: GetInventoryAsync(request)
|
|
Note over TileService: Form A: compute location_hash per coord<br/>via Uuidv5.LocationHashForTile<br/>Form B: echo caller-supplied hashes
|
|
TileService->>TileRepo: GetTilesByLocationHashesAsync(hashes)
|
|
Note over TileRepo: NpgsqlCommand:<br/>SELECT DISTINCT ON (location_hash) ...<br/>WHERE location_hash = ANY($1::uuid[])<br/>ORDER BY location_hash, captured_at DESC, updated_at DESC, id DESC<br/>(bypasses Dapper IEnumerable expansion)
|
|
TileRepo-->>TileService: IReadOnlyDictionary<Guid, TileEntity>
|
|
TileService->>TileService: shape into TileInventoryEntry[] in request order
|
|
TileService-->>GetTilesInventory: TileInventoryResponse
|
|
GetTilesInventory-->>Client: 200 OK, JSON (results in input order)
|
|
```
|
|
|
|
### Validation Surface (post-cycle 7 — AZ-795 + AZ-796)
|
|
|
|
| Input | Detection | Response |
|
|
|-------|-----------|----------|
|
|
| Empty / missing body | System.Text.Json (`[JsonRequired]` on `Tiles`/`LocationHashes` covered indirectly via `InventoryRequestValidator`) | 400 + `ValidationProblemDetails` |
|
|
| Both `tiles` and `locationHashes` populated | `InventoryRequestValidator` `.Must(...)` (Rule 1, XOR — `tile-inventory.md` v2.0.0 Inv-1) | 400 + `ValidationProblemDetails`, key `""` |
|
|
| Neither populated | `InventoryRequestValidator` `.Must(...)` (Rule 1, XOR) | 400 + `ValidationProblemDetails`, key `""` |
|
|
| `tiles` or `locationHashes` array is empty | `InventoryRequestValidator` `.Must(...)` (Rule 1, XOR — non-empty arm) | 400 + `ValidationProblemDetails`, key `""` |
|
|
| `tiles.Count > 5000` (`TileInventoryLimits.MaxEntriesPerRequest`) | `InventoryRequestValidator` `.Must(t => t.Count <= 5000)` (Rule 6 — Inv-7) | 400 + `ValidationProblemDetails`, key `tiles` |
|
|
| `locationHashes.Count > 5000` | `InventoryRequestValidator` `.Must(h => h.Count <= 5000)` (Rule 7 — Inv-7) | 400 + `ValidationProblemDetails`, key `locationHashes` |
|
|
| `tiles[i].z` missing OR out of range (must be `0..22` inclusive) | `[JsonRequired]` on `Z` (deserializer) + `TileCoordValidator` Rule 4 | 400 + `ValidationProblemDetails`, key `tiles[i].z` |
|
|
| `tiles[i].x` missing OR `< 0` OR `>= 2^z` | `[JsonRequired]` on `X` (deserializer) + `TileCoordValidator` Rule 5 | 400 + `ValidationProblemDetails`, key `tiles[i].x` |
|
|
| `tiles[i].y` missing OR `< 0` OR `>= 2^z` | `[JsonRequired]` on `Y` (deserializer) + `TileCoordValidator` Rule 5 | 400 + `ValidationProblemDetails`, key `tiles[i].y` |
|
|
| Legacy `tileZoom/tileX/tileY` field names | `UnmappedMemberHandling.Disallow` (deserializer; AZ-794 + AZ-795) | 400 + `ValidationProblemDetails`, key `tiles[i].tileZoom` (etc.) |
|
|
| Unknown root or nested field | `UnmappedMemberHandling.Disallow` (deserializer; AZ-795) | 400 + `ValidationProblemDetails`, key on the unknown path |
|
|
| Wrong JSON type (e.g. `"z": "18"`) | System.Text.Json type-mismatch (deserializer; AZ-795) | 400 + `ValidationProblemDetails`, key on the offending path |
|
|
| No `Authorization: Bearer …` header | `.RequireAuthorization()` | 401 before handler runs |
|
|
|
|
All 4xx bodies conform to `error-shape.md` v1.0.0. The same `ValidationProblemDetails` shape is emitted whether the failure was caught by the FluentValidation business-rule layer (`InventoryRequestValidator`) or by the deserializer layer (via `GlobalExceptionHandler`). Both layers are unit + integration tested in `SatelliteProvider.Tests/Validators/InventoryRequestValidatorTests` and `SatelliteProvider.IntegrationTests/TileInventoryValidationTests`.
|
|
|
|
### Performance
|
|
|
|
p95 ≤ 1000 ms for 2500-coord batches (AZ-505 AC-4). Cycle-6 measured: p95=66ms — well under budget. The covering index (`tiles_leaflet_path`) supplies the leading `location_hash` lookup; the projection's columns beyond the INCLUDE list (`id`, `captured_at`, `flight_id`, ...) trigger a bounded heap fetch which is documented and accepted per the AZ-505 NFR.
|