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>
12 KiB
Strict validation for region-request endpoint (POST /api/satellite/request)
Task: AZ-808_region_endpoint_validation
Name: Strict validation for region-request endpoint
Description: Add FluentValidation-backed strict input validation to POST /api/satellite/request (region onboarding — enqueues a square region of tiles for async Google-Maps backfill). Reject malformed payloads with RFC 7807 ValidationProblemDetails (HTTP 400). Second concrete child of AZ-795; reuses the shared infra wired in cycle 7.
Complexity: 3 points (7 validation rules — was 6 before the 2026-05-22 probe added the Id rule)
Dependencies: AZ-795 (HARD — shared infra already landed in cycle 7); AZ-796 (reference implementation pattern); AZ-812 (field-naming coordination — see below)
Component: SatelliteProvider.Api/Validators + SatelliteProvider.Common (RequestRegionRequest DTO)
Tracker: AZ-808 (https://denyspopov.atlassian.net/browse/AZ-808)
Epic: AZ-795 — Strict input validation across all public endpoints
Originating ticket: gps-denied-onboard AZ-777 Phase 2 (cross-repo, 2026-05-22) — consumer needs this endpoint to seed Derkachi reference tile catalog; black-box probe surfaced concrete silent-coercion behavior
Scope
Add FluentValidation-backed strict input validation to POST /api/satellite/request (region onboarding — enqueues a square region of tiles for async Google-Maps backfill). Reject malformed payloads with RFC 7807 ValidationProblemDetails (HTTP 400) per the Epic's error-shape.md v1.0.0 contract.
Originating discovery: AZ-777 Phase 2 (gps-denied-onboard) — the consumer needs to call this endpoint to seed the Derkachi reference tile catalog. A black-box probe (2026-05-22) confirmed real silent-coercion behavior that this task fixes (see Probe-confirmed gaps below).
Jira AZ-808 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
Probe-confirmed gaps (2026-05-22)
A black-box probe of the running producer captured these concrete behaviors that this task must close:
Idsilently coerces to zero-Guid when omitted. Body{"latitude":49.94,"longitude":36.31,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}(noid) returned HTTP 200 with"id":"00000000-0000-0000-0000-000000000000"andstatus:queued. The[Required]DataAnnotation onRequestRegionRequest.Idis NOT enforced — the deserializer just yields the default Guid. This is the same silent-coercion class that motivated AZ-795. Validator must reject zero-Guid + missing-Id with the same RFC 7807 shape as the inventory validator.UnmappedMemberHandling.DisallowIS active for this endpoint. Sending the wrong field name ({"lat":49.94,...}) returned HTTP 400 with the proper ValidationProblemDetails shape:{"errors":{"lat":["The JSON property 'lat' could not be mapped to any .NET member contained in type 'SatelliteProvider.Common.DTO.RequestRegionRequest'."]}}. So rule 8 (unknown-field rejection) is already covered by AZ-795 cycle-7 shared infra; this task only needs to verify it stays active after wiringWithValidation<T>().- Happy path works end-to-end. With the correct shape
{"id":"<guid>","latitude":..,"longitude":..,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}: HTTP 200 + regionId + 9 tiles downloaded from Google Maps + accessible viaGET /tiles/{z}/{x}/{y}(13 KB JPEG verified). Validator must NOT regress this path.
Field-naming coordination with AZ-812
This spec uses the current wire format (latitude, longitude) because that's what the DTO ships today and that's what the validator must reject malformed values for. AZ-812 (mirror of AZ-794 for inventory) is filed to rename these to lat/lon for OSM-style consistency across all satellite-provider endpoints.
If AZ-812 lands before this task, rewrite all field references in this spec from latitude/longitude to lat/lon before implementing. If AZ-812 lands after this task, AZ-812 must also update the validator + contract doc + integration tests. Pick the ordering during planning to avoid double migration.
Endpoint surface
POST /api/satellite/request
Current wire format (per RequestRegionRequest.cs, probe-confirmed 2026-05-22):
{
"id": "<guid>",
"latitude": 50.10,
"longitude": 36.10,
"sizeMeters": 5000,
"zoomLevel": 18,
"stitchTiles": false
}
Response: HTTP 200 with RegionStatusResponse (id, status, csvFilePath, summaryFilePath, tilesDownloaded, tilesReused, createdAt, updatedAt). Async — the actual tile downloads happen in the background via RegionProcessingService (Flow F3). Caller polls GET /api/satellite/region/{id} until status:completed.
Required validations
- Body present — null/empty body → 400 (
errors.$). idrequired, non-zero Guid — NEW (probe-confirmed gap). Missing or00000000-...→ 400 witherrors.id. UseRuleFor(x => x.Id).NotEmpty()(FluentValidation'sNotEmpty()rejects default-Guid).latituderequired — double, in[-90.0, 90.0]. Out-of-range or missing → 400 witherrors.latitude.longituderequired — double, in[-180.0, 180.0]. Out-of-range or missing → 400 witherrors.longitude.sizeMetersrequired — double, in[100.0, 10000.0](matches current inline check inRequestRegion Handlerperapi_program.md). Out-of-range or missing → 400 witherrors.sizeMeters.zoomLevelrequired — int, in[0, 22](align withTileCoordValidatorslippy-map range used by AZ-796 for the inventory endpoint). Out-of-range or missing → 400 witherrors.zoomLevel.stitchTilesrequired — bool. Missing → 400 witherrors.stitchTiles(no defaulting tofalse— force the caller to declare intent).- Unknown root fields rejected — already covered by AZ-795's
UnmappedMemberHandling.Disallow(probe-confirmed active). Verify it stays active after wiringWithValidation<T>(). - Type mismatch — e.g.
"latitude": "fifty"→ 400 witherrors.latitude("could not be parsed"). Already covered by AZ-795'sGlobalExceptionHandler; verify it triggers for this endpoint.
Implementation pattern (mirror AZ-796)
- New file:
SatelliteProvider.Api/Validators/RegionRequestValidator.cs—AbstractValidator<RequestRegionRequest>with rules 2–7. - Mark
RequestRegionRequestprops with[JsonRequired](replacing or supplementing the existing[Required]DataAnnotation — the latter is not enforced bySystem.Text.Json, as the probe confirmed). Apply toId,Latitude,Longitude,SizeMeters,ZoomLevel,StitchTiles. - Add
.WithValidation<RequestRegionRequest>()to theMapPost("/api/satellite/request", ...)chain inProgram.cs. - Unit tests:
SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs— one test perRuleFor(...)(≥ 6 methods covering id, latitude, longitude, sizeMeters, zoomLevel, stitchTiles). - Integration tests:
SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs(new file) — ≥ 9 methods (1 happy + 1 per failure-mode AC — including missing-id reproducing the probe's silent-coercion case). - Manual probe:
scripts/probe_region_validation.sh(mirrorsscripts/probe_inventory_validation.shfrom AZ-796). MUST include the missing-id test case.
New contract doc
Create _docs/02_document/contracts/api/region-request.md v1.0.0. The region endpoint has no formal contract today (only system-flows.md F2 + module docs). The contract doc must cover:
- Endpoint, auth, request body, response body (use the actual
RegionStatusResponseshape: id, status, csvFilePath, summaryFilePath, tilesDownloaded, tilesReused, createdAt, updatedAt), error shape (referenceerror-shape.mdv1.0.0). - Invariants (one regionId per request; client-provided non-zero Id; size cap; async semantics — caller must poll
GET /api/satellite/region/{id}). - Test cases mirroring the validator rules (same
Case | Input | Expected | Notestable format astile-inventory.mdv2.0.0). MUST include the missing-id case. - Cross-link to
RegionStatusflow (F3) and the consumer-facing inventory contract (tile-inventory.md— callers seed via region, then read via inventory). - Reference to AZ-812 (field-naming follow-up).
Coordination with sibling tickets
- Parent (AZ-795): depends on shared infra already landed in cycle 7.
- AZ-796 (inventory): reference implementation — copy the validator + integration-test layout 1:1.
- AZ-812 (region field rename): hard coordination on field names. See Field-naming coordination with AZ-812 above.
- AZ-777 (gps-denied-onboard): consumer-side dependency — Phase 2 cannot proceed safely until this validator lands AND the contract doc exists. Consumer has black-box-probed the endpoint and can use it today, but silent-coercion bugs make Phase 2 fragile until validation is in place.
- Sibling validation tasks created in the same batch: AZ-809 (route), AZ-810 (UAV upload metadata), AZ-811 (lat/lon GET).
Acceptance criteria
AC-1: Each of the 9 validations above rejects with HTTP 400 + ValidationProblemDetails (single-rule precision; unrelated rules NOT in the errors map).
AC-2: Happy path unchanged — a valid body still returns HTTP 200 + RegionStatusResponse; background processing still runs; the probe's 9-tile Derkachi case ({"id":"<guid>","latitude":49.94,"longitude":36.31,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}) still completes in under 10 seconds.
AC-3: RegionRequestValidator lives in its own file under SatelliteProvider.Api/Validators/ and is unit-tested (≥ 1 test per RuleFor).
AC-4: SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs covers happy + 8+ failure modes with full ValidationProblemDetails assertion (use the existing ProblemDetailsAssertions helper from AZ-795). MUST include Post_WithMissingId_ReturnsBadRequest (reproducing the 2026-05-22 probe's silent-coercion case).
AC-5: _docs/02_document/contracts/api/region-request.md v1.0.0 created and published.
AC-6: _docs/02_document/system-flows.md F2 updated to reference the new contract doc + error shape.
AC-7: OpenAPI spec marks RequestRegionRequest fields required, declares ranges, and documents the 400 response (matches AZ-796 Swashbuckle annotations).
AC-8: Manual probe script exercises each failure mode end-to-end via curl + JWT.
Out of scope
- The Region API's processing semantics (Flow F3 —
RegionProcessingService) — validation lives at the API layer only. - Any change to
IRegionService.RequestRegionAsyncsignature beyond accepting the validated DTO. GET /api/satellite/region/{id}status endpoint (separate task if path-parameter validation needed; current Guid binding is framework-handled).- The field-name rename (
Latitude/Longitude→Lat/Lon) — handled by AZ-812. - Performance — validation overhead is negligible vs the async enqueue + Google Maps round-trip.
Constraints
- Breaking behavior change — any consumer today omitting
id(silently getting zero-Guid) or sending malformed values will start getting 400. Known consumer set: gps-denied-onboard (currently uses correct body shape with id, per black-box probe 2026-05-22). Other consumers TBD by parent-suite team. - No regression in any existing
RegionRequestTests.cshappy-path coverage.
References
- Jira AZ-808: https://denyspopov.atlassian.net/browse/AZ-808
- Parent Epic: AZ-795 (shared infra; error-shape contract)
- Reference implementation: AZ-796 (inventory endpoint)
- Coordination: AZ-812 (region field-name rename to OSM convention)
- Cycle-7 retro:
_docs/06_metrics/retro_2026-05-22_cycle7.md(flagged this endpoint as next in line) - Originating consumer discovery: gps-denied-onboard AZ-777 Phase 2 (2026-05-22 black-box probe)
- Related contract docs:
error-shape.mdv1.0.0,tile-inventory.mdv2.0.0 (both produced by AZ-795+AZ-796 cycle 7)