Files
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

184 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Module: Common/DTO
## Purpose
Data transfer objects used across all layers — API requests/responses, inter-service communication, and queue messages.
## Public Interface
### GeoPoint
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: `==`, `!=`
### Direction
Result of a directional calculation between two points.
- `Distance` (double): distance in meters
- `Azimuth` (double): bearing in degrees (0360)
### SatTile
Represents a single map tile with its spatial bounds.
- `X`, `Y` (int): tile coordinates in the slippy map scheme
- `Zoom` (int): zoom level
- `LeftTop`, `BottomRight` (GeoPoint): computed bounding box corners (via `GeoUtils.TileToWorldPos`)
- `Url` (string): download URL
- `FileName → string`: formatted as `{X}.{Y}.{Zoom}.jpg`
### TileMetadata
Metadata about a stored tile (mirrors `TileEntity` but without DB-specific concerns).
- `Id` (Guid), `TileZoom`, `TileX`, `TileY` (int), `Latitude`, `Longitude` (double)
- `TileSizeMeters` (double), `TileSizePixels` (int), `ImageType` (string)
- `Version` (int?), `FilePath` (string)
- `CreatedAt`, `UpdatedAt` (DateTime)
### RequestRegionRequest (renamed by AZ-812 cycle 8 — OSM convention)
API request body for `POST /api/satellite/request` (region enqueue). Defined in `SatelliteProvider.Common/DTO/RequestRegionRequest.cs`. Moved out of `Program.cs` by AZ-369.
- `Id` (Guid), `Lat` (double, JSON: `"lat"`), `Lon` (double, JSON: `"lon"`), `SizeMeters` (double)
- `ZoomLevel` (int, default 18), `StitchTiles` (bool, default false)
- AZ-812 renamed C# props `Latitude/Longitude``Lat/Lon` and added `[JsonPropertyName("lat")]` / `[JsonPropertyName("lon")]` to make the wire format unambiguous. With `JsonSerializerOptions.UnmappedMemberHandling.Disallow` active (AZ-795), the old `latitude`/`longitude` wire shape now returns HTTP 400.
### RegionRequest
Internal queue message for async region processing (not a wire-format DTO — exchanged between the API handler and `RegionProcessingService` background worker via `IRegionRequestQueue`). Distinct from `RequestRegionRequest` above; intentionally kept on `Latitude`/`Longitude` because the queue is in-process only.
- `Id` (Guid), `Latitude`, `Longitude` (double), `SizeMeters` (double)
- `ZoomLevel` (int), `StitchTiles` (bool)
### RegionStatus
Response DTO for region status queries.
- `Id` (Guid), `Status` (string), `CsvFilePath`, `SummaryFilePath` (string?)
- `TilesDownloaded`, `TilesReused` (int), `CreatedAt`, `UpdatedAt` (DateTime)
### RoutePoint
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. 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.
- All fields from the route entity plus `Points` (List\<RoutePointDto\>)
- `MapsReady` (bool), `TilesZipPath` (string?)
### GeofencePolygon
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. 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`.
- `Latitude`, `Longitude` (double)
- `TileZoom` (int)
- `TileSizeMeters` (double)
- `CapturedAt` (DateTime, UTC; subject to AZ-488 Rule 4 future-skew / age checks)
- `FlightId` (Guid?, JSON: `"flightId"`) — AZ-503 optional flight identifier. When set, the per-item `tiles.id` becomes `Uuidv5(TileNamespace, "{z}/{x}/{y}/uav/{flightId}")`, the on-disk path is `./tiles/uav/{flightId}/{z}/{x}/{y}.jpg`, and the UPSERT conflict key separates this row from rows belonging to other flights at the same cell. When `null`, the per-item id uses the zero-UUID `00000000-0000-0000-0000-000000000000` placeholder and the on-disk path uses the literal `none` segment (`./tiles/uav/none/{z}/{x}/{y}.jpg`). The placeholder UUID is purely a key-space marker — it never lands in the `flight_id` column (which stays `NULL`); the UPSERT uses `COALESCE(flight_id, '00000000-...')` for the conflict check.
### UavTileBatchMetadataPayload (added AZ-488)
JSON envelope deserialized from the `metadata` form field of a UAV batch upload.
- `Items` (IReadOnlyList\<UavTileMetadata\>)
### UavTileBatchUploadResponse (added AZ-488)
Wire response for `POST /api/satellite/upload`. Returned with HTTP 200 regardless of per-item outcomes; envelope-level failures (auth, oversize, deserialization) bypass this shape.
- `Items` (List\<UavTileUploadResultItem\>)
### UavTileUploadResultItem (added AZ-488)
Per-item result inside `UavTileBatchUploadResponse`.
- `Index` (int): zero-based index into the request batch.
- `Status` (string): one of `UavTileUploadStatus.Accepted` / `UavTileUploadStatus.Rejected`.
- `TileId` (Guid?): set on accept (matches the new/updated `tiles.id`); null on reject.
- `RejectReason` (string?): closed-enum reason code from `UavTileRejectReasons`; null on accept.
- `RejectDetails` (string?): short human-readable note. MUST NOT leak server-internal paths / exception types / hostnames (AZ-488 Security NFR; covered by SEC-11).
### UavTileUploadStatus (added AZ-488, static string constants)
- `Accepted = "accepted"`
- `Rejected = "rejected"`
### UavTileRejectReasons (added AZ-488, static string constants — closed enumeration v1.0.0)
Authoritative reject-reason codes for the UAV upload quality gate. Adding a new code requires a minor-version bump of `_docs/02_document/contracts/api/uav-tile-upload.md`.
- `InvalidFormat = "INVALID_FORMAT"` — Rule 1 (content-type or JPEG magic bytes).
- `SizeOutOfBand = "SIZE_OUT_OF_BAND"` — Rule 2 (bytes outside `[MinBytes, MaxBytes]`).
- `WrongDimensions = "WRONG_DIMENSIONS"` — Rule 3 (image width/height ≠ `MapConfig.TileSizePixels`).
- `CapturedAtFuture = "CAPTURED_AT_FUTURE"` — Rule 4 (timestamp ahead of now + `CapturedAtFutureSkewSeconds`).
- `CapturedAtTooOld = "CAPTURED_AT_TOO_OLD"` — Rule 4 (timestamp older than `MaxAgeDays`).
- `ImageTooUniform = "IMAGE_TOO_UNIFORM"` — Rule 5 (luminance variance below `MinLuminanceVariance`).
- `StorageFailure = "STORAGE_FAILURE"` — reserved for the orphan-row-recovery path when the on-disk write succeeds but the DB UPSERT fails; surfaced per-item without failing the envelope (AZ-488 Reliability NFR).
### TileCoord (added AZ-505, renamed AZ-794 cycle 7)
Single tile coordinate triple used by the inventory endpoint Form A request shape and as the per-entry input echo on the response.
- `Z` (int) `[JsonRequired]` — slippy zoom level. Wire name `"z"`.
- `X` (int) `[JsonRequired]` — slippy x at that zoom. Wire name `"x"`.
- `Y` (int) `[JsonRequired]` — slippy y at that zoom. Wire name `"y"`.
- Defined in `SatelliteProvider.Common/DTO/TileInventory.cs`. Matches `tile-inventory.md` v2.0.0 Shape (the rename from `tileZoom/tileX/tileY` shipped in AZ-794; the `[JsonRequired]` markers + the global `UnmappedMemberHandling.Disallow` mean missing axes and the legacy field names both surface as HTTP 400 with `ValidationProblemDetails` per `error-shape.md` v1.0.0).
### TileInventoryRequest (added AZ-505)
API request body for `POST /api/satellite/tiles/inventory`. Carries one of two XOR-exclusive batch shapes.
- `Tiles` (`IReadOnlyList<TileCoord>?`) — Form A: coords-by-value. The server computes `location_hash = Uuidv5(TileNamespace, "{z}/{x}/{y}")` per entry.
- `LocationHashes` (`IReadOnlyList<Guid>?`) — Form B: hashes-by-reference. Used when the caller already has UUIDv5 location hashes (typical for the onboard cross-repo path).
- Exactly one of `Tiles` / `LocationHashes` must be populated and non-empty; both-populated or neither → HTTP 400 (`tile-inventory.md` v2.0.0 Inv-1, enforced by `InventoryRequestValidator` via `ValidationEndpointFilter<TileInventoryRequest>` in cycle 7).
- Total entries (in either field) ≤ `TileInventoryLimits.MaxEntriesPerRequest` (5000); over-cap → HTTP 400 (Inv-7).
### TileInventoryEntry (added AZ-505, coord fields renamed AZ-794 cycle 7)
Per-entry result inside `TileInventoryResponse`. One entry per request entry, in the SAME order as the request (`tile-inventory.md` Inv-2).
- `Z`, `X`, `Y` (int) — echoed coord triple matching the request entry; wire names `"z"`, `"x"`, `"y"` (renamed from `"tileZoom"`/`"tileX"`/`"tileY"` by AZ-794). Always populated; when Form B was used, these are 0 (the caller already knows the hash).
- `LocationHash` (Guid) — always populated; UUIDv5 of `"{z}/{x}/{y}"` from `Uuidv5.LocationHashForTile` (Form A) or echoed from request (Form B).
- `Present` (bool) — `true` iff a row exists in `tiles` with this `location_hash` (Inv-4).
- `Id` (Guid?) — `tiles.id` of the most-recent row across sources/flights (`captured_at DESC, updated_at DESC, id DESC`, Inv-5); null when `Present=false` (Inv-6).
- `CapturedAt` (DateTime?), `Source` (string?), `FlightId` (Guid?), `ResolutionMPerPx` (double?) — populated on the most-recent row; all null when `Present=false`.
### TileInventoryResponse (added AZ-505)
API response body for `POST /api/satellite/tiles/inventory`.
- `Results` (`IReadOnlyList<TileInventoryEntry>`) — one entry per request entry; `Results.Count` always equals the request entry count (Inv-2).
### TileInventoryLimits (added AZ-505, static constants)
- `MaxEntriesPerRequest = 5000` — request-body cap enforced by `InventoryRequestValidator` (per-array cap; `tile-inventory.md` v2.0.0 Inv-7).
## Internal Logic
- `GeoPoint` uses a precision tolerance of `0.00005` degrees (~5.5 meters) for equality comparison.
- `SatTile` eagerly computes its bounding box corners on construction by calling `GeoUtils.TileToWorldPos`.
## Dependencies
- `GeoPoint`, `Direction` — no imports
- `SatTile``SatelliteProvider.Common.Utils.GeoUtils`
- All others — no internal dependencies (or only `System.Text.Json.Serialization`)
## Consumers
- All services, repositories, and API endpoints consume these DTOs
- `RegionRequest` is the message type for `IRegionRequestQueue`
## Data Models
These ARE the data models (DTOs). They map closely to the database entities but are decoupled from the persistence layer.
## Configuration
None consumed directly.
## External Integrations
None.
## Security
None.
## Tests
No dedicated DTO tests.