Files
satellite-provider/_docs/02_tasks/done/AZ-809_route_endpoint_validation.md
Oleksandr Bezdieniezhnykh 5e056b2334 [AZ-809] Strict validation for POST /api/satellite/route
Third concrete child of AZ-795 (cycle 8 batch 3). FluentValidation +
[JsonRequired] + UnmappedMemberHandling.Disallow combine to reject every
malformed payload at the API boundary with RFC 7807 ValidationProblemDetails.

Validators (SatelliteProvider.Api/Validators/, all new)
- CreateRouteRequestValidator: id non-empty, name/description length,
  regionSizeMeters/zoomLevel ranges, points count [2, 500], cross-field
  createTilesZip => requestMaps. Chains RoutePointValidator (per-point)
  and GeofencePolygonValidator (per-polygon, guarded by When(Geofences != null)).
  OverridePropertyName("geofences.polygons") on the geofences chain so
  FluentValidation's default leaf-only key policy doesn't drop the parent
  path on deep expressions like req.Geofences!.Polygons.
- RoutePointValidator: lat/lon ranges; OverridePropertyName("lat"/"lon")
  chained AFTER InclusiveBetween (the extension is defined on
  IRuleBuilderOptions<T, TProperty>, so the generic type is only
  inferable after the first concrete rule) so error keys match the
  wire format (`points[i].lat`) rather than the C# property name
  (`points[i].latitude`).
- GeofencePolygonValidator: per-corner range checks via private nested
  GeoCornerValidator; cross-field NW.Lat > SE.Lat and NW.Lon < SE.Lon
  invariants emit at errors["geofences.polygons[i].northWest"].

DTOs (SatelliteProvider.Common/DTO/, [JsonRequired] additions only)
- CreateRouteRequest: id, name, regionSizeMeters, zoomLevel, points,
  requestMaps, createTilesZip
- RoutePoint: Latitude, Longitude
- GeofencePolygon: NorthWest, SouthEast; Geofences: Polygons
- GeoPoint: Lat, Lon

Tests
- Unit: 26 methods total — 16 in CreateRouteRequestValidatorTests, 6 in
  GeofencePolygonValidatorTests, 4 in RoutePointValidatorTests. Each
  RuleFor/RuleForEach chain has at least one positive + one negative case.
- Integration: CreateRouteValidationTests.cs — 16 methods (happy + 15
  failure modes) wired into smoke + full suites. Covers empty body,
  missing/zero id, empty name, out-of-range regionSizeMeters/zoomLevel,
  points count < 2, per-point lat/lon out-of-range, geofence invariants,
  missing requestMaps, cross-field createTilesZip, unknown root field,
  nested type mismatch.
- Manual probe: scripts/probe_route_validation.sh curl-exercises every
  failure mode end-to-end + happy path.

Docs
- New contract _docs/02_document/contracts/api/route-creation.md v1.0.0
  with nested DTO chain, invariants, per-field test cases table, and
  advisories on the legacy service-layer RouteValidator + the
  input/output RoutePoint vs RoutePointDto naming asymmetry.
- system-flows.md F4 sequence diagram extended with the validation-filter
  branch; preconditions + error scenarios reference the new contract.
- modules/api_program.md: CreateRoute handler section added; Api/Validators
  bumped to AZ-808/AZ-809/AZ-811.
- modules/common_dtos.md: DTO descriptions updated with [JsonRequired]
  annotations and constraint summaries.
- tests/blackbox-tests.md BT-06/BT-N03/BT-N04/BT-N05 align with the new
  wire format and named error keys.
- tests/security-tests.md SEC-04 references GlobalExceptionHandler's
  JsonException branch + AZ-353 correlationId.
- _docs/03_implementation/batch_03_cycle8_report.md + reviews/batch_03_cycle8_review.md
  (PASS_WITH_NOTES — F1 Low: OverridePropertyName documented inline,
  F2 + F3 Info: pre-existing advisories for follow-up).

Smoke green (mode=smoke, exit 0). AZ-809 transitioned to In Testing on Jira.
Task file moved to _docs/02_tasks/done/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:49:48 +03:00

14 KiB
Raw Permalink Blame History

Strict validation for route-creation endpoint (POST /api/satellite/route)

Task: AZ-809_route_endpoint_validation Name: Strict validation for route-creation endpoint Description: Add FluentValidation-backed strict input validation to POST /api/satellite/route (route creation — client submits ordered waypoints + optional geofence polygons; producer interpolates intermediate points every ≈ 200 m and — if requestMaps=true — enqueues a region request per route point for async tile backfill). Reject malformed payloads with RFC 7807 ValidationProblemDetails (HTTP 400). Third concrete child of AZ-795; reuses the shared infra wired in cycle 7. Complexity: 5 points (14 rules — was 13 before the 2026-05-22 probe added the Id rule; 3 validator classes; cross-field constraint; new contract doc) Dependencies: AZ-795 (HARD — shared infra); AZ-796 (single-DTO reference); AZ-808 (no-prior-contract pattern, same batch) Component: SatelliteProvider.Api/Validators + SatelliteProvider.Common (CreateRouteRequest, RoutePoint, GeofencePolygon, GeoPoint DTOs) Tracker: AZ-809 (https://denyspopov.atlassian.net/browse/AZ-809) Epic: AZ-795 — Strict input validation across all public endpoints Originating ticket: gps-denied-onboard AZ-777 Phase 2 (cross-repo, 2026-05-22) — route-based seeding is the consumer's preferred imagery seeding path; black-box probe surfaced silent-coercion + input/output naming asymmetry

Scope

Add FluentValidation-backed strict input validation to POST /api/satellite/route. 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's preferred imagery seeding path is route-based (flight-track waypoints) rather than bbox-based, so this endpoint is the primary integration target for the Derkachi reference tile catalog. A black-box probe (2026-05-22) confirmed real silent-coercion behavior and an input/output naming asymmetry (see Probe-confirmed gaps below).

Jira AZ-809 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:

  1. Id silently coerces to zero-Guid when omitted. Same gap as the Region endpoint (AZ-808). CreateRouteRequest.Id has no [Required] and no [JsonRequired], so the deserializer yields zero-Guid. Validator must reject missing/zero Id.
  2. Happy path works end-to-end for both requestMaps:false (route storage only, instant) and requestMaps:true (route + background tile backfill, ~15s for a 2-point 132m route at z=18). Validator must NOT regress.
  3. Input/output naming asymmetry on points (new finding). Input points: [{"lat":..,"lon":..}] (OSM short form, per [JsonPropertyName("lat")] on RoutePoint). But the response echoes points as {"latitude":..,"longitude":..,"pointType":..,"sequenceNumber":..,"segmentIndex":..,"distanceFromPrevious":..}. This is a DTO round-trip inconsistency on the same object type. NOT in scope for this validation task, but surfaced as AC-10 (advisory) so the parent-suite team can decide whether to file a follow-up.
  4. UnmappedMemberHandling.Disallow is active globally (verified via AZ-808 probe), so unknown-field rejection (rule 13) will work out-of-the-box once WithValidation<T>() is wired.

Endpoint surface

POST /api/satellite/route

Current wire format (per CreateRouteRequest, probe-confirmed 2026-05-22):

{
  "id": "a1b2c3d4-...",
  "name": "derkachi-flight-1",
  "description": "AZ-777 Phase 2 seed route",
  "regionSizeMeters": 1000,
  "zoomLevel": 18,
  "points": [
    { "lat": 50.10, "lon": 36.10 },
    { "lat": 50.11, "lon": 36.11 }
  ],
  "geofences": {
    "polygons": [
      { "northWest": { "lat": 50.15, "lon": 36.05 },
        "southEast": { "lat": 50.05, "lon": 36.15 } }
    ]
  },
  "requestMaps": true,
  "createTilesZip": false
}

Response (current, probe-confirmed): HTTP 200 with RouteResponse (id, name, description, regionSizeMeters, zoomLevel, totalDistanceMeters, totalPoints, points[], requestMaps, mapsReady, csvFilePath, summaryFilePath, stitchedImagePath, tilesZipPath, createdAt, updatedAt). Note response uses latitude/longitude for echoed points — see AC-10.

Background processing per Flow F5 if requestMaps=true; client polls GET /api/satellite/route/{id} until mapsReady:true.

Required validations

  1. Body present — null/empty body → 400 (errors.$).
  2. id required, non-zero Guid — NEW (probe-confirmed gap, same as AZ-808). Missing or 00000000-... → 400 with errors.id.
  3. name required — non-empty string, length [1, 200]. Missing/empty → 400 with errors.name.
  4. description optional — if present, length [0, 1000]. Over cap → 400 with errors.description.
  5. regionSizeMeters required — double, in [100.0, 10000.0] (align with region endpoint). Out-of-range or missing → 400 with errors.regionSizeMeters.
  6. zoomLevel required — int, in [0, 22] (align with TileCoordValidator). Out-of-range or missing → 400 with errors.zoomLevel.
  7. points required, non-empty — at least 2 entries (current Flow F4 precondition), at most 500 entries (cap to prevent runaway region-enqueue — confirm cap with parent-suite team). Below 2 or above 500 → 400 with errors.points.
  8. Per-point: lat required, double, in [-90.0, 90.0]; lon required, double, in [-180.0, 180.0]. Missing/out-of-range → 400 with errors.points[i].lat or .lon.
  9. geofences optional — if present:
    • polygons required, non-empty.
    • Per-polygon: northWest + southEast both required, each with valid lat/lon.
    • Cross-field invariant: northWest.lat > southEast.lat AND northWest.lon < southEast.lon (i.e. NW is genuinely north-of and west-of SE).
    • Violations → 400 with errors.geofences.polygons[i].<field>.
  10. requestMaps required — bool. Missing → 400 with errors.requestMaps.
  11. createTilesZip required — bool. Missing → 400 with errors.createTilesZip.
  12. Cross-field constraint: createTilesZip == true implies requestMaps == true (can't zip what wasn't downloaded). Violation → 400 with errors.$ or errors.createTilesZip.
  13. Unknown root or nested fields rejected — covered by AZ-795's UnmappedMemberHandling.Disallow (probe-confirmed active globally via AZ-808). Any unknown field at any nesting level → 400 with errors.<path> ("could not be mapped to any .NET member").
  14. Type mismatch — e.g. "lat": "fifty" at any nesting level → 400 with errors.<path>. Covered by AZ-795's GlobalExceptionHandler.

Implementation pattern (mirror AZ-796, extended for nesting)

  1. New files (all under SatelliteProvider.Api/Validators/):
    • CreateRouteRequestValidator.cs — root validator with rules 27, 1012.
    • RoutePointValidator.cs — per-point validator (rule 8); invoked via RuleForEach(x => x.Points).SetValidator(new RoutePointValidator()).
    • GeofencePolygonValidator.cs — per-polygon validator (rule 9); invoked via RuleForEach(x => x.Geofences.Polygons).SetValidator(new GeofencePolygonValidator()) (guarded by When(x => x.Geofences != null)).
  2. Mark required props on CreateRouteRequest, RoutePoint, Geofences, GeofencePolygon, GeoPoint with [JsonRequired] per the cycle-7 TileCoord pattern. Pay special attention to Id (probe confirmed it's not enforced today).
  3. Add .WithValidation<CreateRouteRequest>() to the MapPost("/api/satellite/route", ...) chain.
  4. Unit tests: SatelliteProvider.Tests/Validators/CreateRouteRequestValidatorTests.cs + RoutePointValidatorTests.cs + GeofencePolygonValidatorTests.cs (≥ 13 test methods total — one per RuleFor/RuleForEach chain; new id-rule method must reproduce the probe's missing-id case).
  5. Integration tests: SatelliteProvider.IntegrationTests/CreateRouteValidationTests.cs (new file) — ≥ 14 methods (1 happy + 1 per failure-mode AC).
  6. Manual probe: scripts/probe_route_validation.sh. MUST include missing-id, NW-southeast-inverted polygon, points-too-few, createTilesZip-without-requestMaps.

New contract doc

Create _docs/02_document/contracts/api/route-creation.md v1.0.0. Like the region endpoint, this has no formal contract today. Cover:

  • Endpoint, auth, request body (with nested DTO recursion), response body (RouteResponse shape — acknowledge the input/output point-naming asymmetry; reference AC-10 advisory), error shape (reference error-shape.md v1.0.0).
  • Invariants (client-provided non-zero Id; one routeId per request; min 2 points; max 500 points; polygon NW>SE; cross-field createTilesZip implies requestMaps).
  • Test cases table (same format as tile-inventory.md v2.0.0). MUST include missing-id, geofence NW/SE inversion, createTilesZip cross-field, points-too-few cases.
  • Cross-link to Flow F4 (Route Creation) + Flow F5 (Route Map Processing background) + region-request.md (referenced by F5 enqueue path).

Coordination with sibling tickets

  • Parent (AZ-795): depends on shared infra already landed in cycle 7.
  • AZ-796 (inventory): reference for single-DTO validator pattern.
  • AZ-808 (region): reference for endpoint without prior contract doc (same precondition: must create new region-request.md); coordinate field-name conventions across the two contracts. The naming inconsistency RequestRegionRequest.sizeMeters vs CreateRouteRequest.regionSizeMeters (same concept, different names) is flagged in AC-9.
  • AZ-812 (region field rename): tangentially related — AZ-812 is bringing Region into the lat/lon convention that Route already uses. No direct dependency on this task.
  • AZ-777 (gps-denied-onboard): consumer-side dependency — Phase 2 cannot proceed safely until this validator lands AND route-creation.md exists.

Acceptance criteria

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

AC-2: Happy path unchanged — a valid body still returns HTTP 200 + RouteResponse; background F5 processing still runs when requestMaps=true; probe's 2-point 132m route still completes (mapsReady:true) in under 20 seconds.

AC-3: All three validators (CreateRouteRequestValidator, RoutePointValidator, GeofencePolygonValidator) live in their own files under SatelliteProvider.Api/Validators/ and are unit-tested (≥ 1 test per RuleFor/RuleForEach chain, ≥ 13 methods total).

AC-4: SatelliteProvider.IntegrationTests/CreateRouteValidationTests.cs covers happy + 13+ failure modes with full ValidationProblemDetails assertion. MUST include Post_WithMissingId_ReturnsBadRequest (reproducing the 2026-05-22 probe's silent-coercion case).

AC-5: _docs/02_document/contracts/api/route-creation.md v1.0.0 created and published.

AC-6: _docs/02_document/system-flows.md F4 + F5 updated to reference the new contract doc + error shape.

AC-7: OpenAPI spec marks all required fields at every nesting level, declares ranges, and documents the 400 response.

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

AC-9 (advisory — surface in PR, parent-suite to decide): the inconsistency RequestRegionRequest.sizeMeters vs CreateRouteRequest.regionSizeMeters is named differently for the same concept. Either keep the discrepancy and document why, or harmonize to a single name in a follow-up MAJOR contract bump for both.

AC-10 (advisory — surface in PR, parent-suite to decide): the input/output point-naming asymmetry on this endpoint (input points: [{"lat":..,"lon":..}], response points: [{"latitude":..,"longitude":..}] for the same RoutePoint round-trip) is a DTO inconsistency. Probe-confirmed 2026-05-22. Either keep + document, or file a follow-up to harmonize.

Out of scope

  • Route processing semantics (Flow F5 background, ZIP creation, point-in-polygon geofence filtering) — validation lives at the API layer only.
  • GET /api/satellite/route/{id} status endpoint (separate task if needed; Guid binding is framework-handled).
  • Performance — nested validation overhead is negligible vs interpolation + background region enqueue.
  • Route interpolation algorithm — unchanged.
  • Input/output point-naming asymmetry fix — surfaced as AC-10 advisory only.

Constraints

  • Breaking behavior change — callers today omitting id (silently getting zero-Guid) or sending malformed nested bodies will start getting 400. Known consumer set: gps-denied-onboard (uses correct body shape with id and lat/lon points, per black-box probe 2026-05-22). Other consumers TBD by parent-suite team.
  • No regression in any existing RouteCreationTests.cs happy-path coverage.
  • Cross-field constraint (rule 12) requires custom When/Otherwise or a top-level Must() rule — FluentValidation 12.0.0 supports both; pick the more readable one.

References

  • Jira AZ-809: https://denyspopov.atlassian.net/browse/AZ-809
  • Parent Epic: AZ-795
  • Reference implementations: AZ-796 (single-DTO pattern), AZ-808 (no-prior-contract pattern, same batch)
  • Tangentially related: AZ-812 (region field rename to OSM)
  • Cycle-7 retro: _docs/06_metrics/retro_2026-05-22_cycle7.md (flagged this endpoint as a per-endpoint child of AZ-795)
  • Originating consumer discovery: gps-denied-onboard AZ-777 Phase 2 (route-based seeding is the consumer's preferred path; 2026-05-22 black-box probe surfaced silent-coercion + naming asymmetry)
  • Related contract docs: error-shape.md v1.0.0, tile-inventory.md v2.0.0 (both produced by AZ-795+AZ-796 cycle 7)