mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 05:11:14 +00:00
[AZ-808] [AZ-811] Strict validation on region POST + lat/lon GET
AZ-808: FluentValidation for POST /api/satellite/request - RegionRequestValidator: id non-empty, lat/lon/sizeMeters/zoomLevel ranges - RequestRegionRequest: [JsonRequired] on every property, no implicit defaults - Wired via .WithValidation<RequestRegionRequest>() in MapPost chain - Unit + integration tests + curl probe script - New contract: contracts/api/region-request.md v1.0.0 AZ-811: FluentValidation + envelope filter for GET /api/satellite/tiles/latlon - GetTileByLatLonQuery: nullable record (double?/int?) so the minimal-API binder never short-circuits with BadHttpRequestException before filters - GetTileByLatLonQueryValidator: Cascade(Stop) + NotNull + InclusiveBetween per param; missing surfaces as `\`<name>\` is required.` - RejectUnknownQueryParamsEndpointFilter: reusable IEndpointFilter that rejects any query key outside the allowed set with errors[<key>] map; catches legacy `?Latitude=` typos and hostile probes (`?debug=1&admin=1`) - Handler: [AsParameters] GetTileByLatLonQuery + .Value deref post-validator - Unit (validator + filter) + integration tests + curl probe script - New contract: contracts/api/tile-latlon.md v1.0.0 Shared hygiene - Promote AssertErrorsContainsMention from per-test-file private helpers to ProblemDetailsAssertions (closes batch-1 Low-severity DRY warning) - Sync Swagger param descriptions, README, blackbox/security/perf scripts, uuidv5 doc with the new lat/lon/zoom query-param names Docs - system-flows.md F1/F2 reference the new contracts + validation layers - modules/api_program.md adds Api/Validators + Api/DTOs sections - _autodev_state.md: batch 2 of 4 complete; next batch = AZ-809 All smoke tests green (mode=smoke, exit 0). AZ-808 + AZ-811 transitioned to In Testing on Jira. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
# Contract: region-request
|
||||
|
||||
**Component**: WebApi (`SatelliteProvider.Api`) producing rows via RegionProcessing (`SatelliteProvider.Services.RegionProcessing`)
|
||||
**Producer task**: AZ-808 — `_docs/02_tasks/done/AZ-808_region_endpoint_validation.md` (validator + this contract); AZ-812 — `_docs/02_tasks/done/AZ-812_region_field_rename_to_osm.md` (OSM-convention wire-format `lat`/`lon`)
|
||||
**Consumer tasks**: `gps-denied-onboard` AZ-777 Phase 2 (seeds Derkachi reference tile catalog via this endpoint)
|
||||
**Version**: 1.0.0
|
||||
**Status**: frozen
|
||||
**Last Updated**: 2026-05-22
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the HTTP contract for `POST /api/satellite/request` — the region-onboarding endpoint that enqueues a square region of tiles for asynchronous backfill from Google Maps. Callers submit a `(lat, lon, sizeMeters, zoomLevel)` envelope identified by a client-provided `id` (idempotency key); the API responds immediately with the queued region's status. Actual tile downloads run in the background via `RegionProcessingService` (`system-flows.md` Flow F2). Callers poll `GET /api/satellite/region/{id}` until `status == completed`.
|
||||
|
||||
This is the v1.0.0 of the contract — published alongside AZ-808's validator landing. There is no prior contract document. AZ-812 had already renamed the wire-format fields `Latitude/Longitude` → `lat/lon` (OSM convention) earlier in cycle 8; this contract publishes the post-rename shape directly with no transitional period.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST /api/satellite/request
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <JWT>
|
||||
```
|
||||
|
||||
The request MUST carry a valid JWT (AZ-487). No `permissions` claim is required. Anonymous requests are rejected with HTTP 401.
|
||||
|
||||
## Shape
|
||||
|
||||
### Request body
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
|
||||
"lat": 47.461747,
|
||||
"lon": 37.647063,
|
||||
"sizeMeters": 200,
|
||||
"zoomLevel": 18,
|
||||
"stitchTiles": false
|
||||
}
|
||||
```
|
||||
|
||||
Per-field constraints:
|
||||
|
||||
| Field | Type | Required | Description | Constraints |
|
||||
|-------|------|----------|-------------|-------------|
|
||||
| `id` | UUID | yes | Client-provided idempotency key. POSTing the same `id` twice returns the existing region (idempotent per AZ-362). | Non-zero GUID. `00000000-...` → HTTP 400. |
|
||||
| `lat` | number | yes | Region centre latitude (WGS84, decimal degrees). | `[-90.0, 90.0]`. |
|
||||
| `lon` | number | yes | Region centre longitude (WGS84, decimal degrees). | `[-180.0, 180.0]`. |
|
||||
| `sizeMeters` | number | yes | Square region side length. | `[100.0, 10000.0]`. Anything larger → HTTP 400. |
|
||||
| `zoomLevel` | integer | yes | Slippy-map zoom level for the resulting tiles. | `[0, 22]`. |
|
||||
| `stitchTiles` | bool | yes | If true, a stitched composite image is produced once all tiles are present. No default — caller MUST declare intent. | true / false. |
|
||||
|
||||
Strict parsing: unknown fields at root are rejected with HTTP 400 by `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (AZ-795). Missing required fields are caught by `[JsonRequired]` on the DTO and surface as HTTP 400 with the field name in `errors`.
|
||||
|
||||
### Response body
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
|
||||
"status": "queued",
|
||||
"csvFilePath": null,
|
||||
"summaryFilePath": null,
|
||||
"tilesDownloaded": 0,
|
||||
"tilesReused": 0,
|
||||
"createdAt": "2026-05-22T12:34:56.789Z",
|
||||
"updatedAt": "2026-05-22T12:34:56.789Z"
|
||||
}
|
||||
```
|
||||
|
||||
Per-field semantics:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | UUID | Echo of the request `id`. |
|
||||
| `status` | string enum | `"queued"` immediately after enqueue; transitions through `"processing"` → `"completed"` (or `"failed"`) on the background worker. |
|
||||
| `csvFilePath` | string \| null | Path to the per-region tile-manifest CSV. Null until processing produces it. |
|
||||
| `summaryFilePath` | string \| null | Path to the human-readable summary. Null until processing produces it. |
|
||||
| `tilesDownloaded` | integer | Count of tiles fetched fresh from Google Maps. Updated as processing progresses. |
|
||||
| `tilesReused` | integer | Count of tiles served from existing cache. Updated as processing progresses. |
|
||||
| `createdAt` | ISO-8601 UTC | Initial enqueue timestamp. Stable across retries (per AZ-362 idempotency). |
|
||||
| `updatedAt` | ISO-8601 UTC | Last status-row write. Bumps as the background worker progresses. |
|
||||
|
||||
### Endpoint summary
|
||||
|
||||
| Method | Path | Request body | Response | Status codes |
|
||||
|--------|------|--------------|----------|--------------|
|
||||
| `POST` | `/api/satellite/request` | `RequestRegionRequest` | `RegionStatusResponse` | 200, 400, 401 |
|
||||
|
||||
## Error shape
|
||||
|
||||
All `400` responses conform to `_docs/02_document/contracts/api/error-shape.md` v1.0.0. Two enforcement layers produce identically-shaped bodies:
|
||||
|
||||
1. **JSON deserializer rules** — wire-format failures: unknown fields (`UnmappedMemberHandling.Disallow`), missing `[JsonRequired]` properties, type mismatches. Surface via `BadHttpRequestException(JsonException)` → `GlobalExceptionHandler`.
|
||||
2. **`RegionRequestValidator`** (FluentValidation, AZ-808) — business rules: non-zero `id`, range checks for `lat` / `lon` / `sizeMeters` / `zoomLevel`. Surface via `ValidationEndpointFilter<RequestRegionRequest>`.
|
||||
|
||||
Example body for a missing-id failure (the pre-AZ-808 silent-coercion gap surfaced by the 2026-05-22 black-box probe):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"id": ["The id field is required."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example body for a zero-Guid id (validator-level rejection):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"id": ["`id` must be a non-zero GUID (the caller's idempotency key)."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Inv-1**: `id` MUST be a non-zero GUID. Pre-AZ-808, omitting `id` silently coerced to `Guid.Empty` and queued a region under the zero key; AZ-808 fails this with HTTP 400 and `errors["id"]`.
|
||||
- **Inv-2**: `lat ∈ [-90.0, 90.0]`. Out-of-range → 400 with `errors["lat"]`.
|
||||
- **Inv-3**: `lon ∈ [-180.0, 180.0]`. Out-of-range → 400 with `errors["lon"]`.
|
||||
- **Inv-4**: `sizeMeters ∈ [100.0, 10000.0]`. Out-of-range → 400 with `errors["sizeMeters"]`. Pre-AZ-808 this rule lived as an inline `if` in the handler; AZ-808 moves it into the validator.
|
||||
- **Inv-5**: `zoomLevel ∈ [0, 22]` (slippy-map zoom range, matching `tile-inventory.md` Inv-8).
|
||||
- **Inv-6**: `stitchTiles` MUST be explicitly provided. No defaulting to `false` — callers declare intent.
|
||||
- **Inv-7**: Unknown fields at root are rejected with HTTP 400 + the field name in `errors`.
|
||||
- **Inv-8** (idempotency, AZ-362): Two POSTs with the same `id` return the existing region resource with HTTP 200 and do NOT enqueue duplicate background processing. The post-rename `lat`/`lon` wire format does not affect this invariant.
|
||||
- **Inv-9** (async semantics): The endpoint returns immediately after enqueuing. Status transitions to `completed`/`failed` happen on the background `RegionProcessingService`. Callers MUST poll `GET /api/satellite/region/{id}` to observe completion.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Not covered**: tile body fetch. The background worker writes tiles into the `tiles` table; callers fetch bodies via `GET /tiles/{z}/{x}/{y}` after polling shows `status == completed`.
|
||||
- **Not covered**: backward-compatibility shim for `Latitude/Longitude` wire field names. AZ-812 ships v1.0.0 of this contract directly with the post-rename names; pre-rename callers receive HTTP 400 with `errors["latitude"]: ["could not be mapped"]`. There is no transitional accept-both period.
|
||||
- **Not covered**: geofencing semantics. Geofences are a Route concern, not a Region concern; documented in `route-create.md` (forthcoming, AZ-809).
|
||||
- **Not covered**: cancellation of a queued region. The current API has no DELETE / cancel verb. Tracked separately if needed.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behaviour.
|
||||
- **Minor (1.x.0)**: Adding an optional response field consumers may safely ignore (e.g. ETA estimate); relaxing a range constraint within `[-90,90]` / `[-180,180]` envelope (e.g. accepting decimal degrees with extra precision).
|
||||
- **Major (2.0.0)**: Changing a field name; tightening a range constraint (breaks today's valid callers); making `stitchTiles` optional with a default again; removing idempotency.
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Case | Input | Expected | Notes |
|
||||
|------|-------|----------|-------|
|
||||
| happy-path | `{id:<guid>, lat:47.46, lon:37.64, sizeMeters:200, zoomLevel:18, stitchTiles:false}` | HTTP 200 + RegionStatusResponse(status="queued") | AC-2 |
|
||||
| missing-id | body without `id` field | HTTP 400 + `errors["id"]` | Inv-1 (probe gap) |
|
||||
| zero-guid-id | `id: "00000000-..."` | HTTP 400 + `errors["id"]` | Inv-1 |
|
||||
| missing-lat | body without `lat` | HTTP 400 + `errors["lat"]` | JsonRequired |
|
||||
| lat-out-of-range | `lat: 91` | HTTP 400 + `errors["lat"]` | Inv-2 |
|
||||
| missing-lon | body without `lon` | HTTP 400 + `errors["lon"]` | JsonRequired |
|
||||
| lon-out-of-range | `lon: 181` | HTTP 400 + `errors["lon"]` | Inv-3 |
|
||||
| missing-sizeMeters | body without `sizeMeters` | HTTP 400 + `errors["sizeMeters"]` | JsonRequired |
|
||||
| sizeMeters-out-of-range | `sizeMeters: 1000000` | HTTP 400 + `errors["sizeMeters"]` | Inv-4 |
|
||||
| missing-zoomLevel | body without `zoomLevel` | HTTP 400 + `errors["zoomLevel"]` | JsonRequired |
|
||||
| zoomLevel-out-of-range | `zoomLevel: 30` | HTTP 400 + `errors["zoomLevel"]` | Inv-5 |
|
||||
| missing-stitchTiles | body without `stitchTiles` | HTTP 400 + `errors["stitchTiles"]` | Inv-6 |
|
||||
| lat-type-mismatch | `lat: "fifty"` | HTTP 400 (deserializer JsonException) | wire-format failure |
|
||||
| unknown-root-field | body with `unknownField: 1` | HTTP 400 + `errors["unknownField"]` | Inv-7 |
|
||||
| legacy-latitude-name | body with `latitude:` instead of `lat:` | HTTP 400 + `errors["latitude"]` | AZ-812 hard switch |
|
||||
| auth-anonymous | no Bearer token | HTTP 401 | Standard `.RequireAuthorization()` baseline |
|
||||
| idempotent-double-post | same body POSTed twice | both HTTP 200; same `createdAt`; no duplicate background work | AC-2 + AZ-362 |
|
||||
|
||||
## Change Log
|
||||
|
||||
| Version | Date | Change | Author |
|
||||
|---------|------|--------|--------|
|
||||
| 1.0.0 | 2026-05-22 | Initial contract for `POST /api/satellite/request`. Publishes the post-AZ-812 OSM-convention wire format (`lat`/`lon`) and the AZ-808 strict-validation rules (non-zero `id`, range-checked `lat`/`lon`/`sizeMeters`/`zoomLevel`, explicit `stitchTiles`, unknown-field rejection). References `error-shape.md` v1.0.0 for the 400 body shape and `tile-inventory.md` v2.0.0 for the downstream read path (callers seed via region, then read via inventory). | autodev (Step 10, cycle 8) |
|
||||
@@ -0,0 +1,165 @@
|
||||
# Contract: tile-latlon
|
||||
|
||||
**Component**: WebApi (`SatelliteProvider.Api`) producing rows via TileDownloader (`SatelliteProvider.Services.TileDownloader`)
|
||||
**Producer task**: AZ-811 — `_docs/02_tasks/done/AZ-811_latlon_get_endpoint_validation.md` (validator + this contract; renames query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` for OSM consistency)
|
||||
**Consumer tasks**: dev / debug clients, future mission-planner UI single-tile-by-click flows; NOT currently consumed by `gps-denied-onboard` (the onboard side uses `GET /tiles/{z}/{x}/{y}` with pre-computed coords from inventory)
|
||||
**Version**: 1.0.0
|
||||
**Status**: frozen
|
||||
**Last Updated**: 2026-05-22
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the HTTP contract for `GET /api/satellite/tiles/latlon` — the single-tile-by-coordinate read endpoint that converts a `(lat, lon, zoom)` triple to a slippy-map `(z, x, y)`, downloads the tile from Google Maps if not already cached, persists it, and returns the row's metadata as `DownloadTileResponse`. The actual tile bytes are served separately via `GET /tiles/{z}/{x}/{y}` once the caller has the resulting `(z, x, y)` (or the equivalent `tilePath` from the response).
|
||||
|
||||
This is the v1.0.0 of the contract — published alongside AZ-811's validator landing. There is no prior contract document; the producer-doc surface before AZ-811 was `modules/api_program.md::GetTileByLatLon Handler` only.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /api/satellite/tiles/latlon?lat=<float>&lon=<float>&zoom=<int>
|
||||
Authorization: Bearer <JWT>
|
||||
```
|
||||
|
||||
The request MUST carry a valid JWT (AZ-487). No `permissions` claim is required. Anonymous requests are rejected with HTTP 401.
|
||||
|
||||
## Shape
|
||||
|
||||
### Query parameters
|
||||
|
||||
```
|
||||
?lat=47.461747&lon=37.647063&zoom=18
|
||||
```
|
||||
|
||||
Per-parameter constraints:
|
||||
|
||||
| Param | Type | Required | Description | Constraints |
|
||||
|-------|------|----------|-------------|-------------|
|
||||
| `lat` | number | yes | WGS84 latitude (decimal degrees). | `[-90.0, 90.0]`. |
|
||||
| `lon` | number | yes | WGS84 longitude (decimal degrees). | `[-180.0, 180.0]`. |
|
||||
| `zoom` | integer | yes | Slippy-map zoom level. | `[0, 22]`. |
|
||||
|
||||
Strict shape: any query-string parameter outside `{lat, lon, zoom}` is rejected by `RejectUnknownQueryParamsEndpointFilter` with HTTP 400 + the unknown key name in `errors`. This catches typos like `?latitude=` (pre-AZ-811 wire name) that ASP.NET model binding would otherwise silently ignore, and it also rejects hostile fingerprinting probes like `?debug=1&admin=true`.
|
||||
|
||||
**Required-field detection**: the bound DTO (`GetTileByLatLonQuery`) declares `lat` / `lon` / `zoom` as nullable (`double?`, `double?`, `int?`). Missing a query param therefore binds to `null` rather than throwing `BadHttpRequestException` from the framework binder — the request reaches the endpoint filters in all cases. `GetTileByLatLonQueryValidator` then enforces `NotNull` (chained `CascadeMode.Stop` ahead of the range rule) so a missing param surfaces as `errors[<paramName>]: ["\`<paramName>\` is required."]` exactly like any other validation failure. The handler dereferences `.Value` only after the validator filter has passed, guaranteed by the filter ordering.
|
||||
|
||||
### Response body
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "e228d1aa-25d4-556e-a72d-e0484756e165",
|
||||
"zoomLevel": 18,
|
||||
"latitude": 47.461747,
|
||||
"longitude": 37.647063,
|
||||
"tileSizeMeters": 39.84,
|
||||
"tileSizePixels": 256,
|
||||
"imageType": "jpg",
|
||||
"version": 1,
|
||||
"filePath": "tiles/18/158485/91707.jpg",
|
||||
"createdAt": "2026-05-22T12:34:56.789Z",
|
||||
"updatedAt": "2026-05-22T12:34:56.789Z"
|
||||
}
|
||||
```
|
||||
|
||||
Per-field semantics:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | UUID | Deterministic UUIDv5 of the tile (`Uuidv5.TileNamespace, "{z}/{x}/{y}"`). |
|
||||
| `zoomLevel` | integer | Echoes the request `zoom` param. |
|
||||
| `latitude` | number | Tile centre latitude (server-resolved from slippy `(z,x,y)`; may differ from the request `lat` by up to half a tile). |
|
||||
| `longitude` | number | Tile centre longitude. |
|
||||
| `tileSizeMeters` | number | Approximate ground footprint of the tile at this zoom and latitude. |
|
||||
| `tileSizePixels` | integer | Fixed at 256 (slippy-map convention). |
|
||||
| `imageType` | string | Always `"jpg"`. |
|
||||
| `version` | integer | Tile row version (bumps on each refresh). |
|
||||
| `filePath` | string | Relative path under the tile cache root (`tiles/{z}/{x}/{y}.jpg`). |
|
||||
| `createdAt` | ISO-8601 UTC | Tile row creation timestamp. |
|
||||
| `updatedAt` | ISO-8601 UTC | Tile row last-modification timestamp. |
|
||||
|
||||
Response field names are intentionally LEGACY (`zoomLevel`, `latitude`, `longitude`) — only the request shape (query params) was renamed by AZ-811. The response is shared with `tile-storage.md` for caller consistency.
|
||||
|
||||
### Endpoint summary
|
||||
|
||||
| Method | Path | Request | Response | Status codes |
|
||||
|--------|------|---------|----------|--------------|
|
||||
| `GET` | `/api/satellite/tiles/latlon` | query string `?lat&lon&zoom` | `DownloadTileResponse` | 200, 400, 401 |
|
||||
|
||||
## Error shape
|
||||
|
||||
All `400` responses conform to `_docs/02_document/contracts/api/error-shape.md` v1.0.0. Two enforcement layers produce identically-shaped bodies:
|
||||
|
||||
1. **`RejectUnknownQueryParamsEndpointFilter`** (envelope, runs first) — rejects any query key outside `{lat, lon, zoom}` with `errors[<paramName>]: ["Unknown query parameter ..."]`. Catches typos and hostile probes.
|
||||
2. **`GetTileByLatLonQueryValidator`** (FluentValidation, runs second) — range-checks `lat` / `lon` / `zoom` with `errors[<paramName>]: ["... must be between ..."]`.
|
||||
|
||||
Example body for a legacy-param-name failure (pre-AZ-811 wire format):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"Latitude": ["Unknown query parameter `Latitude`. Allowed: `lat`, `lon`, `zoom`."],
|
||||
"Longitude": ["Unknown query parameter `Longitude`. Allowed: `lat`, `lon`, `zoom`."],
|
||||
"ZoomLevel": ["Unknown query parameter `ZoomLevel`. Allowed: `lat`, `lon`, `zoom`."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example body for an out-of-range failure:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"lat": ["`lat` must be between -90 and 90."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Inv-1**: `lat ∈ [-90.0, 90.0]`. Out-of-range → 400 with `errors["lat"]`.
|
||||
- **Inv-2**: `lon ∈ [-180.0, 180.0]`. Out-of-range → 400 with `errors["lon"]`.
|
||||
- **Inv-3**: `zoom ∈ [0, 22]` (slippy-map zoom range, matching `tile-inventory.md` Inv-8 and `region-request.md` Inv-5).
|
||||
- **Inv-4** (AZ-811 envelope filter): Any query-string key outside `{lat, lon, zoom}` → 400 with `errors[<key>]`. This is the novel envelope-strictness layer introduced by AZ-811; reuse the filter on future query-string endpoints by passing a fresh allowed-keys set.
|
||||
- **Inv-5** (deterministic mapping): `(lat, lon, zoom)` deterministically resolves to a single slippy-map `(z, x, y)` and therefore to a single `Uuidv5.TileNamespace`-derived tile `id`. Re-requesting the same triple returns the SAME `id` (cache hit if the tile already exists). Cross-referenced from `common_uuidv5.md`.
|
||||
- **Inv-6** (cache reuse): If the resolved `(z, x, y)` already has a row in `tiles`, no new Google-Maps fetch occurs; the existing row's metadata is returned. The handler delegates this decision to `ITileService.DownloadAndStoreSingleTileAsync`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Not covered**: tile body fetch. This endpoint returns metadata only. Bytes are served via `GET /tiles/{z}/{x}/{y}` (slippy-map URL).
|
||||
- **Not covered**: bulk download. Use `POST /api/satellite/tiles/inventory` for batch-lookup or `POST /api/satellite/request` for region pre-fetch.
|
||||
- **Not covered**: MGRS-based input. See `GET /api/satellite/tiles/mgrs` (stub, 501).
|
||||
- **Not covered**: backward-compatibility shim for `Latitude/Longitude/ZoomLevel` query param names. AZ-811 ships v1.0.0 directly with the post-rename names; pre-rename callers receive HTTP 400 from the envelope filter naming each unknown key. There is no transitional accept-both period.
|
||||
- **Not covered**: path-parameter validation on `GET /tiles/{z}/{x}/{y}` (the slippy-map body endpoint). That endpoint uses integer-binding which framework-validates the type but not the range; a separate task may add range checks if needed.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behaviour.
|
||||
- **Minor (1.x.0)**: Adding an optional query param consumers may safely omit (e.g. `?format=png` if a non-jpg variant is later supported); adding an optional response field.
|
||||
- **Major (2.0.0)**: Changing any query-param name; tightening a range constraint that breaks current callers; removing `tileSizeMeters` from the response.
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Case | Input | Expected | Notes |
|
||||
|------|-------|----------|-------|
|
||||
| happy-path | `?lat=47.461747&lon=37.647063&zoom=18` | HTTP 200 + DownloadTileResponse | AC-2 |
|
||||
| missing-lat | `?lon=37.647063&zoom=18` | HTTP 400 + `errors["lat"]: ["\`lat\` is required."]` | Inv-1 (NotNull rule) |
|
||||
| lat-out-of-range | `?lat=91&lon=37.647063&zoom=18` | HTTP 400 + `errors["lat"]` | Inv-1 (range rule) |
|
||||
| lon-out-of-range | `?lat=47.461747&lon=181&zoom=18` | HTTP 400 + `errors["lon"]` | Inv-2 |
|
||||
| zoom-out-of-range | `?lat=47.461747&lon=37.647063&zoom=30` | HTTP 400 + `errors["zoom"]` | Inv-3 |
|
||||
| legacy-param-names | `?Latitude=47.46&Longitude=37.64&ZoomLevel=18` (pre-AZ-811 wire format) | HTTP 400 + `errors["Latitude","Longitude","ZoomLevel"]` | Inv-4 (AZ-811 envelope) |
|
||||
| hostile-extra-keys | `?lat=...&lon=...&zoom=18&debug=1&admin=true` | HTTP 400 + `errors["debug","admin"]` | Inv-4 |
|
||||
| typo-zooom | `?lat=...&lon=...&zooom=18` | HTTP 400 + `errors["zooom"]` | Inv-4 |
|
||||
| lat-type-mismatch | `?lat=fifty&lon=...&zoom=18` | HTTP 400 (model-binder JsonException-equivalent) | Wire-format failure |
|
||||
| cache-reuse | repeat happy-path | HTTP 200; same `id`; no new GET to Google Maps | Inv-5 + Inv-6 |
|
||||
| auth-anonymous | no Bearer token | HTTP 401 | Standard `.RequireAuthorization()` baseline |
|
||||
|
||||
## Change Log
|
||||
|
||||
| Version | Date | Change | Author |
|
||||
|---------|------|--------|--------|
|
||||
| 1.0.0 | 2026-05-22 | Initial contract for `GET /api/satellite/tiles/latlon`. Publishes the post-AZ-811 OSM-convention query params (`lat`/`lon`/`zoom`) and the AZ-811 two-layer strict validation (envelope filter for unknown-keys + value-validator for range checks). References `error-shape.md` v1.0.0 for the 400 body shape and `tile-inventory.md` v2.0.0 for the bulk-lookup alternative. Pre-AZ-811 query-param names (`Latitude/Longitude/ZoomLevel`) are explicitly rejected by the envelope filter — no transitional shim. | autodev (Step 10, cycle 8) |
|
||||
@@ -9,7 +9,7 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
| Method | Route | Handler | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| GET | `/tiles/{z}/{x}/{y}` | `ServeTile` | Slippy map tile server with in-memory caching. AZ-505 rewired the DB lookup to filter on `location_hash` (deterministic UUIDv5) so the read becomes an `Index Only Scan` against `tiles_leaflet_path`; the wire response is byte-identical to pre-AZ-505. |
|
||||
| GET | `/api/satellite/tiles/latlon` | `GetTileByLatLon` | Download single tile by lat/lon/zoom |
|
||||
| GET | `/api/satellite/tiles/latlon` | `GetTileByLatLon` | Download single tile by lat/lon/zoom. AZ-811 (cycle 8) renamed the query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` (OSM convention) and added strict validation: range-checked `lat`/`lon`/`zoom` via `WithValidation<GetTileByLatLonQuery>()`, plus a `RejectUnknownQueryParamsEndpointFilter` that rejects any extra query keys (catches typos like `?latitude=` that pre-AZ-811 silently bound to 0). Contract: `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
| POST | `/api/satellite/tiles/inventory` | `GetTilesInventory` | Bulk tile-existence/metadata lookup (AZ-505) — body is XOR of `tiles[{z,x,y}]` (Form A) and `locationHashes[uuid]` (Form B), each capped at 5000 entries. Response is one entry per request entry, in input order. AZ-794 (cycle 7) renamed the coord triple from `tileZoom/tileX/tileY` → `z/x/y` (OSM convention); AZ-796 (cycle 7) added strict input validation via `WithValidation<TileInventoryRequest>()` so malformed payloads return RFC 7807 `ValidationProblemDetails` instead of silently coercing to zero. Contracts: `_docs/02_document/contracts/api/tile-inventory.md` v2.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
| GET | `/api/satellite/tiles/mgrs` | `GetSatelliteTilesByMgrs` | MGRS stub (returns empty) |
|
||||
| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. |
|
||||
@@ -21,7 +21,14 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
### Local Records (defined in Program.cs)
|
||||
- `GetSatelliteTilesResponse`, `SatelliteTile` — MGRS response stubs
|
||||
- `DownloadTileResponse` — tile download response
|
||||
- `ParameterDescriptionFilter` — Swagger operation filter
|
||||
- `ParameterDescriptionFilter` — Swagger operation filter (AZ-811 cycle 8 trimmed the obsolete `Latitude`/`Longitude`/`ZoomLevel` entries; the surviving `lat`/`lon`/`mgrs`/`squareSideMeters` keys still annotate query-string params)
|
||||
|
||||
### Api/Validators (AZ-795 epic, AZ-811 cycle 8)
|
||||
- `RejectUnknownQueryParamsEndpointFilter` — `IEndpointFilter` parameterized by an allowed-keys set; rejects unknown query-string parameters with RFC 7807 `ValidationProblemDetails`. Apply BEFORE `WithValidation<T>()` so unknown-param errors precede range checks against the bound default value.
|
||||
- `GetTileByLatLonQueryValidator` — `AbstractValidator<GetTileByLatLonQuery>` with `lat`/`lon`/`zoom` rules. Each rule chains `Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween` so a missing param surfaces ONLY as `"\`<paramName>\` is required."` (no spurious range error against a null sentinel).
|
||||
|
||||
### Api/DTOs (AZ-811 cycle 8)
|
||||
- `GetTileByLatLonQuery` — `record GetTileByLatLonQuery(double? Lat, double? Lon, int? Zoom)` with `[FromQuery(Name="lat"|"lon"|"zoom")]` on each property. Bound via `[AsParameters]` on the `GetTileByLatLon` handler. **Nullable on purpose**: minimal-API binding throws `BadHttpRequestException` for missing non-nullable query params BEFORE endpoint filters run; that short-circuit produces a plain `ProblemDetails` via `GlobalExceptionHandler` with no `errors{}` envelope. Nullable types let binding always succeed so the envelope filter + validator handle the failure surface uniformly per `error-shape.md` v1.0.0. The handler dereferences `.Value` only after the validator filter passes.
|
||||
|
||||
### Common/DTO (region API)
|
||||
- `RequestRegionRequest` — `POST /api/satellite/request` body. Moved out of Program.cs by AZ-369. Fields: `Id` (Guid), `Lat`/`Lon` (double, JSON `lat`/`lon` per AZ-812 cycle 8 OSM rename), `SizeMeters`, `ZoomLevel` (int, default 18), `StitchTiles` (bool, default false).
|
||||
@@ -89,7 +96,12 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
5. Authenticated by `.RequireAuthorization()` (401 before validation runs for anonymous requests).
|
||||
|
||||
### GetTileByLatLon Handler
|
||||
Downloads a tile, persists it, returns metadata as `DownloadTileResponse`.
|
||||
Binds `[AsParameters] GetTileByLatLonQuery` (record with nullable `[FromQuery(Name="lat"|"lon"|"zoom")]` properties — see `Api/DTOs` for nullability rationale). Wire-format params are OSM-short `lat`/`lon`/`zoom` post-AZ-811. Strict validation is layered:
|
||||
1. `RejectUnknownQueryParamsEndpointFilter(new[] {"lat","lon","zoom"})` runs first — rejects any unexpected query key (e.g. `?latitude=` typo, or hostile fingerprinting probes) with RFC 7807 `ValidationProblemDetails` and an `errors[<paramName>]` entry.
|
||||
2. `WithValidation<GetTileByLatLonQuery>()` runs second — checks `NotNull` (missing param → `errors[<paramName>]: "\`<paramName>\` is required."`) and `InclusiveBetween` (`lat` ∈ [-90, 90], `lon` ∈ [-180, 180], `zoom` ∈ [0, 22]). `CascadeMode.Stop` ensures null short-circuits the range check.
|
||||
3. Handler dereferences `query.Lat!.Value`, `query.Lon!.Value`, `query.Zoom!.Value` (validator guarantees non-null), delegates to `ITileService.DownloadAndStoreSingleTileAsync(lat, lon, zoom)`, and returns `DownloadTileResponse`.
|
||||
|
||||
The two filter layers produce identically-shaped ProblemDetails bodies. The `RejectUnknownQueryParamsEndpointFilter` is reusable — register it once per allowed-key set on any future query-string endpoint that needs the same shape-strictness.
|
||||
|
||||
### RequestRegion Handler
|
||||
Validates size (100–10000m), delegates to `IRegionService.RequestRegionAsync`.
|
||||
|
||||
@@ -35,7 +35,7 @@ All members are static on `Uuidv5`:
|
||||
| `"18/12345/23456"` | `38b26f49-a966-5121-aaf4-9cc476f57869` |
|
||||
| `"18/12345/23456/google_maps/00000000-0000-0000-0000-000000000000"` | `e228d1aa-25d4-556e-a72d-e0484756e165` |
|
||||
|
||||
The second value is observable end-to-end: a fresh `GET /api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18` returns `tileId = e228d1aa-25d4-556e-a72d-e0484756e165` because `(47.461747, 37.647063)` maps to slippy `(z=18, x=158485, y=91707)` — and the integration test asserts that exact value.
|
||||
The second value is observable end-to-end: a fresh `GET /api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18` returns `tileId = e228d1aa-25d4-556e-a72d-e0484756e165` because `(47.461747, 37.647063)` maps to slippy `(z=18, x=158485, y=91707)` — and the integration test asserts that exact value. (AZ-811 cycle 8 renamed the query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` for OSM consistency.)
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@
|
||||
|
||||
### 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.
|
||||
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
|
||||
|
||||
- Valid latitude, longitude, and zoom level provided
|
||||
- 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
|
||||
@@ -80,11 +80,11 @@ sequenceDiagram
|
||||
|
||||
### Description
|
||||
|
||||
Client submits a region definition (center point, size, zoom). The request is persisted and queued for asynchronous processing.
|
||||
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 (lat, lon, size_meters, zoom_level)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## BT-01: Single Tile Download
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18
|
||||
**Precondition**: Tile not in cache
|
||||
**Expected**: HTTP 200; JSON with zoomLevel=18, tileSizePixels=256, imageType="jpg", filePath matching pattern `tiles/18/*/...`
|
||||
**Pass criterion**: All fields present and correct values
|
||||
@@ -86,13 +86,13 @@
|
||||
|
||||
## BT-N01: Invalid Coordinates (out of range)
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=91&Longitude=181&ZoomLevel=18
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?lat=91&lon=181&zoom=18
|
||||
**Expected**: Error response
|
||||
**Pass criterion**: HTTP 4xx or error in response body
|
||||
|
||||
## BT-N02: Invalid Zoom Level
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=47.46&Longitude=37.64&ZoomLevel=25
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?lat=47.46&lon=37.64&zoom=25
|
||||
**Expected**: Error response
|
||||
**Pass criterion**: HTTP 4xx or error indicating invalid zoom
|
||||
|
||||
@@ -163,7 +163,7 @@ All Cycle-2 UAV scenarios run with a JWT containing `permissions: ["GPS"]` (per
|
||||
|
||||
## BT-18: Existing Tile Endpoint Returns Identical Body with Valid Bearer
|
||||
|
||||
**Trigger**: GET `/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18` with a valid Bearer token.
|
||||
**Trigger**: GET `/api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18` with a valid Bearer token.
|
||||
**Precondition**: Tile may or may not be cached.
|
||||
**Expected**: Response body is structurally identical to BT-01 (`tileId`, `zoomLevel == 18`, `tileSizePixels == 256`, `imageType == "jpg"`, `filePath` matches `tiles/18/*/*`).
|
||||
**Pass criterion**: status == 200 AND BT-01's pass criterion AND no behavioral change vs pre-AZ-487 baseline.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## SEC-01: SQL Injection via Coordinate Parameters
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=1;DROP TABLE tiles--&Longitude=1&ZoomLevel=18
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?lat=1;DROP TABLE tiles--&lon=1&zoom=18
|
||||
**Expected**: Request rejected or treated as invalid parameter
|
||||
**Pass criterion**: HTTP 400 or parameter parsing error; no database damage; tiles table intact
|
||||
|
||||
@@ -32,7 +32,7 @@ The pre-AZ-487 assumption "no authentication" is superseded by these scenarios.
|
||||
|
||||
## SEC-05: Anonymous Request to Any Authenticated Endpoint Returns 401
|
||||
|
||||
**Trigger**: GET `/api/satellite/tiles/latlon?Latitude=...&Longitude=...&ZoomLevel=18` (or any `/api/satellite/*` endpoint) with NO `Authorization` header.
|
||||
**Trigger**: GET `/api/satellite/tiles/latlon?lat=...&lon=...&zoom=18` (or any `/api/satellite/*` endpoint) with NO `Authorization` header.
|
||||
**Precondition**: API running with `JWT_SECRET` configured.
|
||||
**Expected**: HTTP 401 Unauthorized; `WWW-Authenticate: Bearer` header present; response body does not leak validation internals.
|
||||
**Pass criterion**: status == 401 AND `WWW-Authenticate` header starts with `Bearer`.
|
||||
|
||||
Reference in New Issue
Block a user