Files
satellite-provider/_docs/02_document/contracts/api/tile-latlon.md
T
Oleksandr Bezdieniezhnykh 34ee1e0b83 [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>
2026-05-22 16:29:41 +03:00

10 KiB

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/ZoomLevellat/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>]: ["\` 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

{
  "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):

{
  "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:

{
  "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)