# 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=&lon=&zoom= Authorization: Bearer ``` 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[]: ["\`\` 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[]: ["Unknown query parameter ..."]`. Catches typos and hostile probes. 2. **`GetTileByLatLonQueryValidator`** (FluentValidation, runs second) — range-checks `lat` / `lon` / `zoom` with `errors[]: ["... 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[]`. 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) |