mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 19:11:14 +00:00
5e056b2334
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>
184 lines
13 KiB
Markdown
184 lines
13 KiB
Markdown
# 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 (0–360)
|
||
|
||
### 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.
|