# Strict validation for lat/lon tile GET endpoint (GET /api/satellite/tiles/latlon)

**Task**: AZ-811_latlon_get_endpoint_validation
**Name**: Strict validation for lat/lon tile GET endpoint
**Description**: Add FluentValidation-backed strict input validation to `GET /api/satellite/tiles/latlon` (single-tile download by lat/lon/zoom). Reject malformed query strings with RFC 7807 ValidationProblemDetails (HTTP 400). Fifth concrete child of AZ-795; query-string surface differs from sibling JSON-body endpoints — needs explicit unknown-query-param filter.
**Complexity**: 2 points (simple endpoint, 3 typed params + unknown-param check, reuses cycle-7 shared infra, small new contract doc)
**Dependencies**: AZ-795 (HARD — shared infra); AZ-796 (single-DTO reference); AZ-808 (no-prior-contract reference)
**Component**: SatelliteProvider.Api/Validators + small new endpoint filter (RejectUnknownQueryParamsEndpointFilter)
**Tracker**: AZ-811 (https://denyspopov.atlassian.net/browse/AZ-811)
**Epic**: AZ-795 — Strict input validation across all public endpoints
**Originating ticket**: AZ-795 cycle-7 retro (explicitly names this endpoint as a remaining per-endpoint child)

## Scope

Add FluentValidation-backed strict input validation to `GET /api/satellite/tiles/latlon`. Reject malformed query strings with **RFC 7807 ValidationProblemDetails** (HTTP 400) per the Epic's `error-shape.md` v1.0.0 contract.

Differs from siblings (AZ-796 / AZ-808 / AZ-809 / AZ-810) in that the input surface is **query string**, not a JSON body, so the unknown-field rejection knob (`UnmappedMemberHandling.Disallow`) does not apply directly — query-param-strictness needs an explicit shape check.

Originating discovery: AZ-795 cycle-7 retro — this endpoint is explicitly named as a remaining gap alongside the POST endpoints.

Jira AZ-811 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.

## Endpoint surface

`GET /api/satellite/tiles/latlon?lat=<float>&lon=<float>&zoom=<int>` (auth: JWT bearer required, no permission claim).

Response (current per `api_program.md::GetTileByLatLon Handler`): HTTP 200 with `DownloadTileResponse` (tile metadata; the actual bytes are served separately via `GET /tiles/{z}/{x}/{y}`).

Current behavior on bad input: query params bind via the framework's default model binder — missing/malformed params trigger a generic 400 or silent defaults, neither of which conforms to `error-shape.md` v1.0.0.

## Required validations

1. **`lat` query param required** — double, in `[-90.0, 90.0]`. Missing/out-of-range/malformed → 400 with `errors.lat`.
2. **`lon` query param required** — double, in `[-180.0, 180.0]`. Missing/out-of-range/malformed → 400 with `errors.lon`.
3. **`zoom` query param required** — int, in `[0, 22]` (align with `TileCoordValidator`). Missing/out-of-range/malformed → 400 with `errors.zoom`.
4. **Unknown query parameters rejected** — any query string param outside `{lat, lon, zoom}` → 400 with `errors.<paramName>`. (Requires explicit query-param-shape check inside the endpoint filter — the framework's default binder silently ignores extras.)
5. **Type mismatch** — e.g. `lat=fifty` (not parseable as double) → 400 with `errors.lat` ("could not be parsed"). Covered by AZ-795's `GlobalExceptionHandler` IF the binding throws — verify this code path triggers it (it does for `[FromBody]` deserializers; query-string parse failures may take a different path — surface in PR and adapt).

## Implementation pattern (adapted for query string)

1. Bind query params to a dedicated record: `record GetTileByLatLonQuery(double Lat, double Lon, int Zoom)`. Default `[AsParameters]` binding works; `[JsonRequired]` doesn't apply (no JSON deserializer in the path), so missing-required is detected by the validator only.
2. New file: `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` — `AbstractValidator<GetTileByLatLonQuery>` with rules 1–3.
3. Add `.WithValidation<GetTileByLatLonQuery>()` to the `MapGet("/api/satellite/tiles/latlon", ...)` chain. May require a small variant of `ValidationEndpointFilter<T>` that runs against the bound query-record rather than the body-bound record — the cycle-7 generic filter already does the bound-argument lookup, so it should Just Work; verify.
4. **Rule 4 (unknown query params)** is the novel piece: implement as a separate endpoint filter that inspects `HttpContext.Request.Query.Keys` against the allowed set `{"lat", "lon", "zoom"}`. On any extras → `Results.ValidationProblem` with one `errors` entry per unexpected key. Either:
   - Standalone filter `RejectUnknownQueryParamsEndpointFilter` (parameterized by allowed keys; reusable across future query-param endpoints).
   - Inline `Func<EndpointFilterInvocationContext, ...>` for now and extract when the second consumer arrives. Parent-suite team decides.
5. Unit tests: `SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs` (≥ 3 methods — one per RuleFor). Plus a test for the unknown-query-param filter (≥ 1 method).
6. Integration tests: `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` (new file) — ≥ 6 methods (1 happy + 1 per failure-mode AC + 1 unknown-query-param).
7. Manual probe: `scripts/probe_latlon_validation.sh` — `curl` against each failure mode.

## New contract doc

Create `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0. This endpoint has **no formal contract** today; the producer-doc surface is `modules/api_program.md::GetTileByLatLon Handler` only. Cover:

- Endpoint, auth, query params, response body (`DownloadTileResponse`), error shape (reference `error-shape.md` v1.0.0).
- Invariants (single tile per request; (lat, lon, zoom) deterministically maps to a (z, x, y) coord; output references the slippy-map URL `/tiles/{z}/{x}/{y}` for body fetch).
- Test cases table mirroring validator rules.
- Cross-link to `tile-inventory.md` v2.0.0 (related single-vs-bulk read patterns) + `GET /tiles/{z}/{x}/{y}` URL contract.

## Coordination with sibling tickets

- **Parent (AZ-795)**: depends on shared infra already landed in cycle 7.
- **AZ-796 (inventory)**: reference for `[FromBody]` validator pattern.
- **AZ-808 (region)**: reference for endpoint without prior contract doc.
- **AZ-777 (gps-denied-onboard)**: not currently a consumer (the onboard side uses `GET /tiles/{z}/{x}/{y}` directly with pre-computed coords from inventory); but this endpoint is needed for future workflows (e.g. UI-driven single-tile fetch by user-clicked coordinates).

## Acceptance criteria

**AC-1**: Each of the 5 validations above rejects with HTTP 400 + ValidationProblemDetails (single-rule precision).

**AC-2**: Happy path unchanged — a valid `?lat=&lon=&zoom=` still returns HTTP 200 + `DownloadTileResponse`; tile is still downloaded/persisted as before.

**AC-3**: `GetTileByLatLonQueryValidator` lives in its own file under `SatelliteProvider.Api/Validators/` and is unit-tested (≥ 3 methods).

**AC-4**: `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` covers happy + 4+ failure modes with full ValidationProblemDetails assertion.

**AC-5**: `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 created and published.

**AC-6**: `_docs/02_document/modules/api_program.md::GetTileByLatLon Handler` updated to reference the validator + new contract doc.

**AC-7**: OpenAPI spec marks the query params as required + ranges + 400 response.

**AC-8**: Manual probe script exercises each failure mode end-to-end via `curl` + JWT.

**AC-9**: The novel unknown-query-param rejection filter (item 4 of Implementation pattern) is documented in `_docs/02_document/modules/api_program.md` so the next query-param endpoint can reuse it cleanly.

## Out of scope

- The actual tile download / persistence semantics — unchanged.
- `GET /tiles/{z}/{x}/{y}` path-parameter validation (separate concern; the path int binder rejects malformed values at the framework layer, but range-checking `z` and `x`/`y` bounds is a gap that may warrant a separate task if parent-suite team decides).
- Performance — query-string validation overhead is negligible vs the conditional Google-Maps round-trip.

## Constraints

- **Breaking behavior change** — callers sending unknown extra query params (e.g. typo `?latitude=`) that today silently fall back to `lat=0` will start getting 400. Known consumer set: TBD by parent-suite team (gps-denied-onboard does NOT currently call this endpoint).
- No regression in any existing `TileByLatLonTests.cs` happy-path coverage.
- The unknown-query-param rejection (rule 4) is a NEW behavior on top of standard ASP.NET binding; document it loudly in the contract doc so consumers know.

## References

- Jira AZ-811: https://denyspopov.atlassian.net/browse/AZ-811
- Parent Epic: AZ-795
- Reference implementations: AZ-796 (single-DTO pattern), AZ-808 (no-prior-contract pattern, same batch)
- Cycle-7 retro: `_docs/06_metrics/retro_2026-05-22_cycle7.md` (flagged this endpoint as a per-endpoint child of AZ-795)
- Related contract docs: `error-shape.md` v1.0.0, `tile-inventory.md` v2.0.0 (both produced by AZ-795+AZ-796 cycle 7)
