[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-22 17:49:48 +03:00
parent 34ee1e0b83
commit 5e056b2334
24 changed files with 1929 additions and 50 deletions
@@ -0,0 +1,213 @@
# Contract: route-creation
**Component**: WebApi (`SatelliteProvider.Api`) producing rows via RouteManagement (`SatelliteProvider.Services.RouteManagement`) and feeding the background Route Map Processing flow (Flow F5)
**Producer task**: AZ-809 — `_docs/02_tasks/done/AZ-809_route_endpoint_validation.md` (validator + this contract)
**Consumer tasks**: `gps-denied-onboard` AZ-777 Phase 2 (preferred imagery-seeding path — route-based rather than bbox-based)
**Version**: 1.0.0
**Status**: frozen
**Last Updated**: 2026-05-22
## Purpose
Defines the HTTP contract for `POST /api/satellite/route` — the route-onboarding endpoint that stores an ordered set of waypoints, interpolates intermediate points every ~200 m, and (optionally, when `requestMaps=true`) enqueues a region request per route point so background processing pre-fetches map tiles for the entire route corridor. Geofence polygons (optional) restrict which intermediate points get region-requests. Callers poll `GET /api/satellite/route/{id}` until `mapsReady=true` (when `requestMaps=true`) or read the response directly (when `requestMaps=false`).
This is v1.0.0 — published alongside AZ-809's validator landing. There is no prior contract document; the producer-doc surface before AZ-809 was `modules/api_program.md::CreateRoute Handler` + Flow F4 + Flow F5 only.
## Endpoint
```
POST /api/satellite/route
Content-Type: application/json
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
### Request body
```jsonc
{
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
"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
}
```
Per-field constraints:
| Field | Type | Required | Description | Constraints |
|-------|------|----------|-------------|-------------|
| `id` | UUID | yes (`[JsonRequired]`) | Caller-supplied idempotency key. POSTing twice with the same `id` returns the existing route resource. | Non-zero GUID (validator rejects `00000000-...`). |
| `name` | string | yes (`[JsonRequired]`) | Human-readable route name (used in produced filenames). | Length `[1, 200]`. Empty/whitespace rejected. |
| `description` | string | no | Free-text description. | Length `[0, 1000]` when present. |
| `regionSizeMeters` | number | yes (`[JsonRequired]`) | Side length of the square region requested per route point. | `[100.0, 10000.0]` (aligned with `region-request.md::sizeMeters`). |
| `zoomLevel` | integer | yes (`[JsonRequired]`) | Slippy-map zoom level for region tiles. | `[0, 22]`. |
| `points` | array | yes (`[JsonRequired]`) | Ordered waypoints. Server interpolates additional intermediate points every ~200 m between consecutive originals. | Count `[2, 500]`. |
| `points[i].lat` | number | yes (`[JsonRequired]`) | WGS84 latitude. | `[-90.0, 90.0]`. |
| `points[i].lon` | number | yes (`[JsonRequired]`) | WGS84 longitude. | `[-180.0, 180.0]`. |
| `geofences` | object | no | When present, intermediate points outside ALL polygons get filtered before region enqueue. | See nested shape below. |
| `geofences.polygons` | array | yes (`[JsonRequired]` when `geofences` present) | One or more bbox polygons (NW corner + SE corner). | Non-empty when `geofences` present. |
| `geofences.polygons[i].northWest` | object | yes (`[JsonRequired]`) | Polygon's northwest corner. | See `GeoPoint` shape. |
| `geofences.polygons[i].southEast` | object | yes (`[JsonRequired]`) | Polygon's southeast corner. | See `GeoPoint` shape. |
| `requestMaps` | bool | yes (`[JsonRequired]`) | When `true`, enqueue background region-requests for every route point inside the geofences (or all points if no geofences). | No default — caller must declare intent. |
| `createTilesZip` | bool | yes (`[JsonRequired]`) | When `true`, AFTER all region tiles are ready, package them into a ZIP at `tilesZipPath`. Requires `requestMaps=true` (can't zip what wasn't downloaded). | No default. Cross-field invariant with `requestMaps`. |
`GeoPoint` shape (used by `northWest` / `southEast`):
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `lat` | number | yes (`[JsonRequired]`) | `[-90.0, 90.0]`. |
| `lon` | number | yes (`[JsonRequired]`) | `[-180.0, 180.0]`. |
Polygon corner cross-field invariant (`GeofencePolygonValidator`):
- `northWest.lat > southEast.lat` (NW is genuinely north-of SE).
- `northWest.lon < southEast.lon` (NW is genuinely west-of SE).
### Response body (post-AC-2 unchanged from pre-AZ-809)
```jsonc
{
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
"name": "derkachi-flight-1",
"description": "AZ-777 Phase 2 seed route",
"regionSizeMeters": 1000,
"zoomLevel": 18,
"totalDistanceMeters": 132.4,
"totalPoints": 3,
"points": [
{ "latitude": 50.10, "longitude": 36.10, "pointType": "original", "sequenceNumber": 0, "segmentIndex": 0, "distanceFromPrevious": null },
{ "latitude": 50.105, "longitude": 36.105, "pointType": "intermediate", "sequenceNumber": 1, "segmentIndex": 0, "distanceFromPrevious": 66.2 },
{ "latitude": 50.11, "longitude": 36.11, "pointType": "original", "sequenceNumber": 2, "segmentIndex": 0, "distanceFromPrevious": 66.2 }
],
"requestMaps": true,
"mapsReady": false,
"csvFilePath": null,
"summaryFilePath": null,
"stitchedImagePath": null,
"tilesZipPath": null,
"createdAt": "2026-05-22T14:00:00Z",
"updatedAt": "2026-05-22T14:00:00Z"
}
```
**Advisory AC-10**: The response echoes points as `{"latitude":..,"longitude":..}` (legacy long form) but the request accepts `{"lat":..,"lon":..}` (OSM short form). This input/output asymmetry on the same `RoutePoint` round-trip is documented and intentional for v1.0.0 — fixing it would be a major contract break. A follow-up task can harmonize the response side.
### Endpoint summary
| Method | Path | Request | Response | Status codes |
|--------|------|---------|----------|--------------|
| `POST` | `/api/satellite/route` | `CreateRouteRequest` body | `RouteResponse` (route resource snapshot) | 200, 400, 401 |
## Error shape
All `400` responses conform to `_docs/02_document/contracts/api/error-shape.md` v1.0.0. Three sources produce identically-shaped `ValidationProblemDetails` bodies:
1. **Deserializer envelope** (`UnmappedMemberHandling.Disallow` + `[JsonRequired]`) — rejects missing-required fields and unknown root/nested keys with `errors[<path>]` produced via `GlobalExceptionHandler`'s `JsonException` path.
2. **`CreateRouteRequestValidator`** — rejects non-zero-Id, name/description length, range checks on size / zoom / points-count, and the cross-field `createTilesZip ⇒ requestMaps` rule.
3. **`RoutePointValidator` + `GeofencePolygonValidator`** — invoked via `RuleForEach` / `SetValidator`; rejects per-point lat/lon out-of-range, per-polygon corner out-of-range, and the NW-north-of-SE / NW-west-of-SE invariants.
Example body for a missing-id failure (probe-confirmed pre-AZ-809 silent zero-Guid coercion):
```jsonc
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"id": ["The JSON property 'id' is required, but a value was not supplied."]
}
}
```
Example body for a nested per-point failure:
```jsonc
{
"errors": {
"points[1].lat": ["`lat` must be between -90 and 90."]
}
}
```
Example body for a polygon corner invariant failure:
```jsonc
{
"errors": {
"geofences.polygons[0].northWest": ["`northWest.lat` must be greater than `southEast.lat` (NW is north-of SE)."]
}
}
```
## Invariants
- **Inv-1**: `id` is a non-zero GUID, supplied by the caller. Re-POST with the same id returns the existing route (idempotent contract per `IdempotentPostTests`).
- **Inv-2**: `points` has at least 2 entries (Flow F4 precondition) and at most 500 entries (cap to prevent runaway region-enqueue).
- **Inv-3**: Every `points[i].lat ∈ [-90, 90]` and `points[i].lon ∈ [-180, 180]`.
- **Inv-4**: `regionSizeMeters ∈ [100, 10000]` (aligned with `region-request.md::sizeMeters`).
- **Inv-5**: `zoomLevel ∈ [0, 22]` (slippy-map range, aligned with `region-request.md` Inv-5 and `tile-inventory.md` Inv-8).
- **Inv-6** (cross-field): `createTilesZip=true ⇒ requestMaps=true` (can't zip what wasn't downloaded).
- **Inv-7** (per-polygon shape): `northWest` AND `southEast` corners both present.
- **Inv-8** (per-polygon invariant): `northWest.lat > southEast.lat` AND `northWest.lon < southEast.lon`.
- **Inv-9**: Unknown root or nested fields → 400 (deserializer's `UnmappedMemberHandling.Disallow`).
## Non-Goals
- **Not covered**: route mutation. No PUT / PATCH endpoint exists; routes are immutable post-creation.
- **Not covered**: background processing (Flow F5) — Flow F5 docs cover the region enqueue, tile download, ZIP packaging, and `mapsReady` transition.
- **Not covered**: response field renaming. The input/output naming asymmetry (`points[i].lat` request vs `points[i].latitude` response) is acknowledged in AC-10 advisory and tracked for a future major contract bump.
- **Not covered**: the legacy `RouteValidator` in `SatelliteProvider.Services.RouteManagement` — it remains as defense-in-depth for direct service-layer callers; its checks are now redundant with this contract but a separate cleanup task should consolidate.
## Versioning Rules
- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behavior.
- **Minor (1.x.0)**: Adding an optional field consumers may safely ignore; relaxing a range; supporting a new geofence shape type alongside the existing bbox.
- **Major (2.0.0)**: Renaming any request or response field; tightening any existing range; harmonizing the response point names to `lat`/`lon` (resolves AC-10); changing the `createTilesZip ⇔ requestMaps` cross-field rule semantics.
## Test Cases
| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| happy-path-no-maps | full body with `requestMaps=false` | HTTP 200 + RouteResponse (mapsReady=false, no background processing) | AC-2 |
| happy-path-with-maps | full body with `requestMaps=true` | HTTP 200; background F5 enqueues regions; `GET /api/satellite/route/{id}` shows `mapsReady=true` within ~20s for a 2-point 132m route at z=18 | AC-2 + existing RouteCreationTests |
| empty-body | `""` | HTTP 400 | Rule 1 |
| missing-id | body without `id` | HTTP 400 + `errors[id]` ("required") | Rule 2 (probe-confirmed gap) |
| zero-guid-id | `"id":"00000000-..."` | HTTP 400 + `errors[id]` ("non-zero GUID") | Rule 2 |
| empty-name | `"name":""` | HTTP 400 + `errors[name]` | Rule 3 |
| description-too-long | `"description":<1001 chars>` | HTTP 400 + `errors[description]` | Rule 4 |
| regionSize-out-of-range | `"regionSizeMeters":1000000` | HTTP 400 + `errors[regionSizeMeters]` | Rule 5 |
| zoom-out-of-range | `"zoomLevel":30` | HTTP 400 + `errors[zoomLevel]` | Rule 6 |
| points-too-few | 1-point array | HTTP 400 + `errors[points]` | Rule 7 (Flow F4 precondition) |
| points-too-many | 501-point array | HTTP 400 + `errors[points]` | Rule 7 (cap) |
| point-lat-out-of-range | `"points":[..., {"lat":91,..}]` | HTTP 400 + `errors["points[1].lat"]` | Rule 8 |
| point-lon-out-of-range | `"points":[..., {"lat":..,"lon":181}]` | HTTP 400 + `errors["points[1].lon"]` | Rule 8 |
| geofence-nw-not-north | NW.lat == SE.lat | HTTP 400 + `errors["geofences.polygons[0].northWest"]` | Rule 9 / Inv-8 |
| geofence-nw-not-west | NW.lon == SE.lon | HTTP 400 + `errors["geofences.polygons[0].northWest"]` | Rule 9 / Inv-8 |
| missing-requestMaps | body without `requestMaps` | HTTP 400 + `errors[requestMaps]` | Rule 10 |
| createTilesZip-without-requestMaps | `"requestMaps":false,"createTilesZip":true` | HTTP 400 + `errors[createTilesZip]` | Rule 12 (cross-field) |
| unknown-root-field | extra `"debug":"..."` key | HTTP 400 + `errors[debug]` | Rule 13 (`UnmappedMemberHandling.Disallow`) |
| point-lat-type-mismatch | `"points":[{"lat":"fifty",..}, ..]` | HTTP 400 (nested JSON error) | Rule 14 (`GlobalExceptionHandler`) |
| auth-anonymous | no Bearer token | HTTP 401 | Standard `.RequireAuthorization()` |
| idempotent-replay | re-POST same `id` | HTTP 200 (echoes existing resource) | `IdempotentPostTests` AC-2 |
## Change Log
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-22 | Initial contract for `POST /api/satellite/route`. Publishes the FluentValidation surface (CreateRouteRequestValidator + RoutePointValidator + GeofencePolygonValidator) + the 14 rules in AZ-809, including the probe-confirmed missing-id gap and the cross-field `createTilesZip ⇒ requestMaps` invariant. References `error-shape.md` v1.0.0, `region-request.md` v1.0.0 (F5 enqueue path), and Flows F4/F5 (cross-link). | autodev (Step 10, cycle 8) |
+11 -3
View File
@@ -15,7 +15,7 @@ Application entry point. Configures DI container, sets up middleware, defines mi
| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. |
| POST | `/api/satellite/request` | `RequestRegion` | Queue region for async tile processing |
| GET | `/api/satellite/region/{id}` | `GetRegionStatus` | Get region processing status |
| POST | `/api/satellite/route` | `CreateRoute` | Create route with intermediate points |
| POST | `/api/satellite/route` | `CreateRoute` | Create route with intermediate points. AZ-809 (cycle 8) added strict pre-handler validation via `WithValidation<CreateRouteRequest>()`: non-zero `id`, name length ∈ \[1, 200\], description length ≤ 1000, `regionSizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\], `points` count ∈ \[2, 500\] with per-point lat/lon range checks, per-polygon NW-of-SE invariants, and the `createTilesZip ⇒ requestMaps` cross-field rule. Deserializer-layer failures (missing `[JsonRequired]` axes, unknown fields, type mismatches) are caught by `GlobalExceptionHandler` and produce the same RFC 7807 envelope. Contract: `_docs/02_document/contracts/api/route-creation.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
| GET | `/api/satellite/route/{id}` | `GetRoute` | Get route with all points |
### Local Records (defined in Program.cs)
@@ -23,9 +23,11 @@ Application entry point. Configures DI container, sets up middleware, defines mi
- `DownloadTileResponse` — tile download response
- `ParameterDescriptionFilter` — Swagger operation filter (AZ-811 cycle 8 trimmed the obsolete `Latitude`/`Longitude`/`ZoomLevel` entries; the surviving `lat`/`lon`/`mgrs`/`squareSideMeters` keys still annotate query-string params)
### Api/Validators (AZ-795 epic, AZ-811 cycle 8)
### Api/Validators (AZ-795 epic, AZ-808/AZ-809/AZ-811 cycle 8)
- `RejectUnknownQueryParamsEndpointFilter``IEndpointFilter` parameterized by an allowed-keys set; rejects unknown query-string parameters with RFC 7807 `ValidationProblemDetails`. Apply BEFORE `WithValidation<T>()` so unknown-param errors precede range checks against the bound default value.
- `GetTileByLatLonQueryValidator``AbstractValidator<GetTileByLatLonQuery>` with `lat`/`lon`/`zoom` rules. Each rule chains `Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween` so a missing param surfaces ONLY as `"\`<paramName>\` is required."` (no spurious range error against a null sentinel).
- `RegionRequestValidator` (AZ-808 cycle 8) — `AbstractValidator<RequestRegionRequest>`. Post-deserialization business rules: non-zero `id`, `lat`/`lon` ranges, `sizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\]. Required-field detection lives at the deserializer layer (`[JsonRequired]` + `UnmappedMemberHandling.Disallow`).
- `CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator` (AZ-809 cycle 8) — three FluentValidation validators for the route-creation endpoint. The root validator chains `RuleForEach(req => req.Points).SetValidator(new RoutePointValidator())` for per-point checks and `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator()).OverridePropertyName("geofences.polygons")` for per-polygon checks. The `OverridePropertyName` on the geofences chain restores the full wire path (`geofences.polygons[i].northWest`) because FluentValidation's default name policy drops the parent on deep expressions like `req.Geofences!.Polygons`. `RoutePointValidator` uses `OverridePropertyName("lat"/"lon")` after each range rule so error keys match the wire format (`lat`/`lon`) rather than the camelCased C# names (`latitude`/`longitude`). The cross-field rule `createTilesZip ⇒ requestMaps` lives on the root via `Must(req => !(req.CreateTilesZip && !req.RequestMaps)).WithName("createTilesZip")`.
### Api/DTOs (AZ-811 cycle 8)
- `GetTileByLatLonQuery``record GetTileByLatLonQuery(double? Lat, double? Lon, int? Zoom)` with `[FromQuery(Name="lat"|"lon"|"zoom")]` on each property. Bound via `[AsParameters]` on the `GetTileByLatLon` handler. **Nullable on purpose**: minimal-API binding throws `BadHttpRequestException` for missing non-nullable query params BEFORE endpoint filters run; that short-circuit produces a plain `ProblemDetails` via `GlobalExceptionHandler` with no `errors{}` envelope. Nullable types let binding always succeed so the envelope filter + validator handle the failure surface uniformly per `error-shape.md` v1.0.0. The handler dereferences `.Value` only after the validator filter passes.
@@ -104,7 +106,13 @@ Binds `[AsParameters] GetTileByLatLonQuery` (record with nullable `[FromQuery(Na
The two filter layers produce identically-shaped ProblemDetails bodies. The `RejectUnknownQueryParamsEndpointFilter` is reusable — register it once per allowed-key set on any future query-string endpoint that needs the same shape-strictness.
### RequestRegion Handler
Validates size (10010000m), delegates to `IRegionService.RequestRegionAsync`.
AZ-808 (cycle 8) added strict pre-handler validation via `.WithValidation<RequestRegionRequest>()`: non-zero `id`, `lat`/`lon` ranges, `sizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\]. Missing `[JsonRequired]` axes / unknown root fields are caught at the deserializer layer by `GlobalExceptionHandler`. Post-validation, delegates to `IRegionService.RequestRegionAsync`.
### CreateRoute Handler (AZ-809 cycle 8)
Pre-handler validation via `.WithValidation<CreateRouteRequest>()`. Layered defence:
1. **Deserializer layer (System.Text.Json + `GlobalExceptionHandler`)**`[JsonRequired]` markers on `CreateRouteRequest.{Id, Name, RegionSizeMeters, ZoomLevel, Points, RequestMaps, CreateTilesZip}`, on `RoutePoint.{Latitude, Longitude}`, on `Geofences.Polygons`, on `GeofencePolygon.{NorthWest, SouthEast}`, and on `GeoPoint.{Lat, Lon}` catch missing-field payloads; `UnmappedMemberHandling.Disallow` catches unknown root + nested fields; type mismatches surface as `JsonException`. All three surface as HTTP 400 + `ValidationProblemDetails`.
2. **Validator layer (`CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator`)** — non-zero `id`, name/description length caps, `regionSizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\], `points` count ∈ \[2, 500\] with per-point range checks (error keys `points[i].lat` / `points[i].lon`), per-polygon corner ranges + `NW.Lat > SE.Lat` + `NW.Lon < SE.Lon` invariants (error keys `geofences.polygons[i].northWest`), and the `createTilesZip ⇒ requestMaps` cross-field rule.
3. **Handler** — receives a fully-validated `CreateRouteRequest` and delegates to `IRouteService.CreateRouteAsync`. The route service's own legacy `RouteValidator` (in `SatelliteProvider.Services.RouteManagement`) still runs as a defence-in-depth backstop — its checks are now strictly weaker than the validator-layer rules; tracked as an advisory clean-up in `route-creation.md`. Authenticated by `.RequireAuthorization()` (401 before validation runs for anonymous requests).
### UploadUavTileBatch Handler (AZ-488)
Buffers each `IFormFile` into memory, packages them as `UavUploadFile` records (filename, content-type, bytes), and delegates to `IUavTileUploadHandler.HandleAsync`. Envelope-level errors (mismatched batch, oversized batch, malformed metadata) are surfaced as HTTP 400 ProblemDetails; per-item rejects are returned in the HTTP 200 response payload. The endpoint is protected by `.RequireAuthorization(SatellitePermissions.UavUploadPolicy)` so 401 (no token) and 403 (no `GPS` permission) are returned before the handler runs.
+23 -14
View File
@@ -6,9 +6,9 @@ Data transfer objects used across all layers — API requests/responses, inter-s
## Public Interface
### GeoPoint
Geographic coordinate with tolerance-based equality.
- `Lat` (double): latitude, JSON property `"lat"`
- `Lon` (double): longitude, JSON property `"lon"`
Geographic coordinate with tolerance-based equality. AZ-809 (cycle 8) marked both axes `[JsonRequired]` so a polygon corner missing either axis is rejected at the deserializer layer.
- `Lat` (double, `[JsonRequired]`, JSON: `"lat"`)
- `Lon` (double, `[JsonRequired]`, JSON: `"lon"`)
- Constructor: `GeoPoint()`, `GeoPoint(double lat, double lon)`
- Equality: two points are equal if both coordinates differ by less than `0.00005` (PRECISION_TOLERANCE)
- Operator overloads: `==`, `!=`
@@ -50,20 +50,27 @@ Response DTO for region status queries.
- `TilesDownloaded`, `TilesReused` (int), `CreatedAt`, `UpdatedAt` (DateTime)
### RoutePoint
Input point in a route creation request.
- `Latitude` (double, JSON: `"lat"`), `Longitude` (double, JSON: `"lon"`)
Input point in a route creation request. AZ-809 (cycle 8) marked both axes `[JsonRequired]` so the System.Text.Json deserializer rejects missing-axis payloads with HTTP 400 + `ValidationProblemDetails` via `GlobalExceptionHandler` BEFORE the FluentValidation layer runs.
- `Latitude` (double, `[JsonRequired]`, JSON: `"lat"`)
- `Longitude` (double, `[JsonRequired]`, JSON: `"lon"`)
### RoutePointDto
Output point in a route response (includes computed fields).
- `Latitude`, `Longitude` (double), `PointType` (string: "start"/"end"/"action"/"intermediate")
- `SequenceNumber`, `SegmentIndex` (int), `DistanceFromPrevious` (double?)
- **Naming asymmetry**: input wire uses short OSM `lat`/`lon` (`RoutePoint`); response wire uses long `latitude`/`longitude` (`RoutePointDto`). Pre-existing — AZ-809 documented but did not change this. Tracked as a follow-up advisory in `_docs/02_document/contracts/api/route-creation.md`.
### CreateRouteRequest
API request body for route creation.
- `Id` (Guid), `Name` (string), `Description` (string?)
- `RegionSizeMeters` (double), `ZoomLevel` (int)
- `Points` (List\<RoutePoint\>), `Geofences` (Geofences?)
- `RequestMaps` (bool), `CreateTilesZip` (bool)
API request body for route creation. AZ-809 (cycle 8) added `[JsonRequired]` to every non-optional axis so missing fields are caught at the deserializer layer (uniform with AZ-808 region-request and AZ-795 inventory).
- `Id` (Guid, `[JsonRequired]`) — caller-supplied idempotency key; non-zero GUID
- `Name` (string, `[JsonRequired]`) — length \[1, 200\]
- `Description` (string?) — optional, length ≤ 1000 when present
- `RegionSizeMeters` (double, `[JsonRequired]`) — \[100, 10000\]
- `ZoomLevel` (int, `[JsonRequired]`) — \[0, 22\] slippy-map range
- `Points` (List\<RoutePoint\>, `[JsonRequired]`) — count ∈ \[2, 500\]
- `Geofences` (Geofences?) — optional; when present, each polygon validated
- `RequestMaps` (bool, `[JsonRequired]`) — no default; missing → 400
- `CreateTilesZip` (bool, `[JsonRequired]`) — no default; cross-field invariant requires `requestMaps=true` when `true`
### RouteResponse
API response for route queries.
@@ -71,12 +78,14 @@ API response for route queries.
- `MapsReady` (bool), `TilesZipPath` (string?)
### GeofencePolygon
Axis-aligned bounding box defined by NW and SE corners.
- `NorthWest` (GeoPoint?), `SouthEast` (GeoPoint?)
Axis-aligned bounding box defined by NW and SE corners. AZ-809 (cycle 8) marked both corners `[JsonRequired]` so a partially-specified polygon (just `northWest`, no `southEast`, or vice-versa) is rejected at the deserializer layer.
- `NorthWest` (GeoPoint?, `[JsonRequired]`, JSON: `"northWest"`)
- `SouthEast` (GeoPoint?, `[JsonRequired]`, JSON: `"southEast"`)
- Cross-corner invariants (enforced by `GeofencePolygonValidator`): `NW.Lat > SE.Lat` (NW is north-of SE) and `NW.Lon < SE.Lon` (NW is west-of SE). Equal corners fail both invariants with `errors["geofences.polygons[i].northWest"]`.
### Geofences
Container for multiple geofence polygons.
- `Polygons` (List\<GeofencePolygon\>)
Container for multiple geofence polygons. AZ-809 (cycle 8) marked `Polygons` `[JsonRequired]` so an empty `geofences: {}` envelope is rejected.
- `Polygons` (List\<GeofencePolygon\>, `[JsonRequired]`, JSON: `"polygons"`) — at least 1 polygon when `geofences` is present (validator rule, not deserializer rule).
### UavTileMetadata (added AZ-488, extended AZ-503)
Per-tile metadata payload inside a UAV batch upload (`POST /api/satellite/upload`). Indexed-correlated with the multipart `IFormFileCollection`.
+21 -13
View File
@@ -177,12 +177,13 @@ sequenceDiagram
### Description
Client submits a route (ordered waypoints + optional geofence polygons). The service interpolates intermediate points every ~200m and persists the full point set.
Client submits a route (ordered waypoints + optional geofence polygons). The service interpolates intermediate points every ~200m and persists the full point set. The wire-format contract is `_docs/02_document/contracts/api/route-creation.md` v1.0.0; failure responses follow `error-shape.md` v1.0.0.
### Preconditions
- At least 2 waypoints provided
- Valid geofence polygons (if provided)
- JWT in `Authorization: Bearer <token>` validates against the API's signing key, issuer, and audience (`.RequireAuthorization()`).
- Request body deserializes successfully: all `[JsonRequired]` axes present (`id`, `name`, `regionSizeMeters`, `zoomLevel`, `points`, `requestMaps`, `createTilesZip`, plus per-point `lat`/`lon`, per-polygon `northWest`/`southEast`, per-corner `lat`/`lon`, `geofences.polygons` when `geofences` present); no unknown root or nested fields (`UnmappedMemberHandling.Disallow`).
- `CreateRouteRequestValidator` rules pass: non-zero `id`, name length \[1, 200\], description length ≤ 1000, `regionSizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\], `points` count ∈ \[2, 500\] with each point's lat/lon in range, per-polygon corner ranges + NW-of-SE invariants, `createTilesZip ⇒ requestMaps`.
### Sequence Diagram
@@ -190,26 +191,33 @@ Client submits a route (ordered waypoints + optional geofence polygons). The ser
sequenceDiagram
participant Client
participant WebApi
participant ValidationFilter
participant RouteService
participant RouteRepo
participant GeoUtils
Client->>WebApi: POST /api/satellite/route {points, geofences, options}
WebApi->>RouteService: CreateRoute(request)
RouteService->>GeoUtils: Interpolate points between waypoints
GeoUtils-->>RouteService: All points (original + intermediate)
RouteService->>RouteRepo: InsertRoute(RouteEntity)
RouteService->>RouteRepo: InsertPoints(RoutePointEntities)
RouteService-->>WebApi: RouteResponse
WebApi-->>Client: 200 OK {route_id, total_points, total_distance}
Client->>WebApi: POST /api/satellite/route {id, name, points, geofences?, ...}
WebApi->>ValidationFilter: .WithValidation<CreateRouteRequest>()
alt validation fails
ValidationFilter-->>Client: 400 ValidationProblemDetails (errors{path→msg})
else validation passes
WebApi->>RouteService: CreateRoute(request)
RouteService->>GeoUtils: Interpolate points between waypoints
GeoUtils-->>RouteService: All points (original + intermediate)
RouteService->>RouteRepo: InsertRoute(RouteEntity)
RouteService->>RouteRepo: InsertPoints(RoutePointEntities)
RouteService-->>WebApi: RouteResponse
WebApi-->>Client: 200 OK {id, totalPoints, totalDistanceMeters, ...}
end
```
### Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Invalid points (< 2) | Validation | Count check | Return 400 |
| DB insert failure | Persist step | Exception | Return 500 |
| Missing `[JsonRequired]` axis / unknown field / type mismatch | Deserializer | `JsonException``GlobalExceptionHandler` | Return 400 `ValidationProblemDetails` (per `error-shape.md` v1.0.0) |
| Validator rule violation (range, count, cross-field) | `ValidationEndpointFilter<CreateRouteRequest>` | `CreateRouteRequestValidator` + nested `RoutePointValidator` / `GeofencePolygonValidator` | Return 400 with `errors{path→msg}` map |
| DB insert failure | Persist step | Exception | Return 500 (sanitised body + correlationId per AZ-353) |
---
+17 -14
View File
@@ -37,9 +37,9 @@
## BT-06: Simple Route Creation (2 points)
**Trigger**: POST /api/satellite/route with 2 waypoints (48.276067,37.384458) → (48.270740,37.374029), regionSize=500, zoom=18
**Expected**: Route created with interpolated intermediate points
**Pass criterion**: totalPoints > 2; every point spacing ≤ 200m; first point type="original"; last point type="original"; intermediates type="intermediate"
**Trigger**: POST /api/satellite/route with id=`<new-Guid>`, name=`<unique>`, 2 waypoints (48.276067,37.384458) → (48.270740,37.374029), regionSizeMeters=500, zoomLevel=18, requestMaps=false, createTilesZip=false. Post-AZ-809 (cycle 8) every `[JsonRequired]` axis must be present — see `_docs/02_document/contracts/api/route-creation.md` v1.0.0.
**Expected**: HTTP 200 + route created with interpolated intermediate points.
**Pass criterion**: totalPoints > 2; every point spacing ≤ 200m; first point type="original"; last point type="original"; intermediates type="intermediate".
## BT-07: Route Retrieval by ID
@@ -98,21 +98,24 @@
## BT-N03: Route with < 2 Points
**Trigger**: POST /api/satellite/route with only 1 point
**Expected**: Validation error
**Pass criterion**: HTTP 400 or validation error message
**Trigger**: POST /api/satellite/route with only 1 point (post-AZ-809 wire format: `id`/`name`/`regionSizeMeters`/`zoomLevel`/`points`/`requestMaps`/`createTilesZip`).
**Expected**: HTTP 400 + `ValidationProblemDetails` per `error-shape.md` v1.0.0; `errors["points"]` map entry from `CreateRouteRequestValidator`.
**Pass criterion**: HTTP 400; response body `Content-Type: application/problem+json`; `errors["points"]` mentions the `[2, 500]` count constraint.
**AC trace**: AZ-809 AC-1 (rule 7).
## BT-N04: Geofence with Invalid Coordinates (0,0)
## BT-N04: Geofence with Invalid Coordinates (0,0) — superseded by AZ-809
**Trigger**: POST /api/satellite/route with geofence NW=(0,0) SE=(0,0)
**Expected**: Validation error
**Pass criterion**: Error message mentioning coordinates cannot be (0,0)
**Trigger**: POST /api/satellite/route with geofence NW=(0,0) SE=(0,0).
**Expected**: HTTP 400 + `ValidationProblemDetails`. Pre-AZ-809 behavior accepted (0,0) corners but caught the equal-corners case via the legacy `RouteValidator`. Post-AZ-809, `GeofencePolygonValidator` rejects equal corners because BOTH cross-field invariants (`NW.Lat > SE.Lat` and `NW.Lon < SE.Lon`) fail.
**Pass criterion**: HTTP 400; `errors["geofences.polygons[0].northWest"]` contains both the lat and lon invariant messages.
**AC trace**: AZ-809 AC-1 (rule 9, cross-field invariant).
## BT-N05: Geofence with Inverted Corners
## BT-N05: Geofence with Inverted Corners — superseded by AZ-809
**Trigger**: POST /api/satellite/route with geofence NW.lat < SE.lat
**Expected**: Validation error
**Pass criterion**: Error message about northWest latitude > southEast latitude
**Trigger**: POST /api/satellite/route with geofence NW.lat < SE.lat (NW south-of SE).
**Expected**: HTTP 400 + `ValidationProblemDetails`. Post-AZ-809 the failure surfaces at `errors["geofences.polygons[0].northWest"]` with message "\`northWest.lat\` must be greater than \`southEast.lat\` (NW is north-of SE)".
**Pass criterion**: HTTP 400; named error key matches the wire path; message is the cross-field invariant.
**AC trace**: AZ-809 AC-1 (rule 9).
---
+3 -3
View File
@@ -20,9 +20,9 @@
## SEC-04: Malformed JSON in Route Request
**Trigger**: POST /api/satellite/route with invalid JSON body
**Expected**: Parse error returned
**Pass criterion**: HTTP 400; error message indicates parsing failure; no crash
**Trigger**: POST /api/satellite/route with invalid JSON body (truncated `{` or non-JSON text).
**Expected**: HTTP 400 + RFC 7807 `ProblemDetails`. Post-AZ-809 (cycle 8) the failure surfaces via `GlobalExceptionHandler`'s `JsonException` branch (System.Text.Json `JsonReaderException``BadHttpRequestException` → 400). No stack trace leaks; correlationId present per AZ-353.
**Pass criterion**: HTTP 400; `Content-Type: application/problem+json`; body matches `error-shape.md` v1.0.0; no internal exception type or stack frame in `detail`.
---