[AZ-489] C12 FlightsApiClient + offline JSON loader + bbox helper

ADR-010 primary cold-start path now has a real source for the cache bbox
and the takeoff origin. Single concrete strategy (`HttpxFlightsApiClient`)
behind a `@runtime_checkable` Protocol; offline JSON fallback (`load_flight_file`)
shares the same DTO shape per FAC-INV-1.

* `flights_api/interface.py` — `FlightsApiClient` Protocol + `FlightDto`
  + `WaypointDto` + `WaypointObjective` / `WaypointSource` enums (plain
  frozen-slotted dataclasses, matching project's LatLonAlt / PoseEstimate
  pattern).
* `flights_api/errors.py` — 8-class hierarchy under `FlightsApiError`.
* `flights_api/_parser.py` — shared JSON validator: range checks, lat/lon
  bounds, contiguous ordinals, finite floats, enum membership.
* `flights_api/bbox.py` — `bbox_from_waypoints` envelopes lat/lon and
  inflates by a horizontal-distance buffer via WgsConverter ENU
  round-trip (NOT degree-space); `takeoff_origin_from_flight` passes
  waypoints[0] through unrounded.
* `flights_api/file_loader.py` — orjson-backed offline loader.
* `flights_api/httpx_client.py` — concrete client with ONE retry on
  transient 5xx + connect errors; token redaction at every log site;
  test-injectable transport + sleep.
* `runtime_root/c12_factory.py` — `build_flights_api_client(config)`;
  re-exported from `runtime_root/__init__.py`. OperatorToolServices
  aggregate intentionally deferred to AZ-328 per scope discipline.
* `pyproject.toml` — `httpx>=0.28,<1.0` added (chosen over `requests`
  for native `MockTransport` testing).

Tests: 28 cases across AC-1..AC-18 plus extras (malformed JSON,
negative buffer, zero buffer, missing top-level fields, negative
ordinal, empty-flight takeoff). Full repo run: 713 passed, 2 skipped.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 01:28:49 +03:00
parent e0be591b06
commit 72a06edab0
15 changed files with 2057 additions and 2 deletions
@@ -0,0 +1,238 @@
# C12 FlightsApiClient — Fetch Flight from suite flights service + offline JSON fallback
**Task**: AZ-489_c12_flights_api_client
**Name**: C12 FlightsApiClient — fetch Flight from suite flights service + offline JSON fallback
**Description**: Add a typed client module to C12 that fetches a parent-suite `Flight` (route + waypoints + altitudes) from the parent-suite `flights` REST service so C12 can derive the cache bbox and the takeoff origin directly from the operator-planned mission (ADR-010). The operator runs `operator-tool build-cache --flight-id <Guid>`; C12 calls `GET /flights/{id}` and `GET /flights/{id}/waypoints`, parses into local pydantic DTOs (`FlightDto`, `WaypointDto`) mirroring `suite/flights/Database/Entities/{Flight,Waypoint}.cs`, computes the bbox as the envelope of waypoint lat/lon plus a configurable buffer (default 1 km, horizontal-distance — not degree-space — via `WgsConverter`), and exposes the first-ordered waypoint as the takeoff origin. An `--flight-file <path>` alternative reads the same DTO shape from a local JSON export so the workflow stays usable when the workstation has no path to the flights service. The client is read-only, raises typed errors for every documented failure path, redacts the auth token in all log output, and is consumed by AZ-326 (CLI flags) + AZ-328 (orchestrator phase 0).
**Complexity**: 3 points
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-279_wgs_converter (for the bbox buffer math)
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
**Tracker**: AZ-489
**Epic**: AZ-253 (E-C12)
### Document Dependencies
- `_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md` — produced by this task (frozen Protocol + DTOs + invariants + test cases).
- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 2 (FlightsApiClient interface), § 5 (httpx + pydantic dependencies).
- `_docs/02_document/architecture.md` — ADR-010 (operator-planned mission as cold-start trust anchor).
- Parent-suite reference (read-only): `suite/flights/Database/Entities/Flight.cs`, `suite/flights/Database/Entities/Waypoint.cs`, `suite/flights/Controllers/FlightsController.cs`.
## Problem
Without `FlightsApiClient`:
- ADR-010's primary cold-start path (operator-planned mission → C10 Manifest `takeoff_origin` → C5 warm-start) cannot be wired. C10 has nowhere to get the origin from; C5 has nothing to seed.
- F1 phase 0 (Flight resolve) defined in `_docs/02_document/system-flows.md` cannot run; AZ-328's flight-resolve phase has no service to invoke.
- C12-CLI's `--flight-id` / `--flight-file` flags (AZ-326) have nothing to delegate to.
- The bbox in `BuildCacheRequest` would need to keep coming from operator-typed CLI args, drifting from the canonical mission authored in the Mission Planner UI — exactly the operator-error vector ADR-010 was created to remove.
- The offline path (`--flight-file`) has no implementation, so operator workstations without a path to the flights service can't run F1.
- The bbox buffer is currently undefined; without a contract there's no single source of truth for "how big around the route do we cache tiles".
This task delivers the client + its frozen contract. It does NOT modify the CLI flag parsing (AZ-326), the orchestrator phase ordering (AZ-328), the Manifest schema (AZ-323), or any C5 path.
## Outcome
- A `FlightsApiClient` Protocol + concrete `HttpxFlightsApiClient` implementation at `src/operator_tool/flights_api_client.py`:
- Constructor: `__init__(self, *, httpx_client: httpx.Client | None = None, wgs_converter: WgsConverter, logger: Logger, clock: Clock)`.
- Public methods per the contract: `fetch_flight`, `load_flight_file`, `bbox_from_waypoints`, `takeoff_origin_from_flight`.
- DTOs at `src/operator_tool/flights_dto.py`:
- `FlightDto` + `WaypointDto` as `@dataclass(frozen=True, slots=True)` with pydantic validators (via `pydantic.dataclasses.dataclass` or a standalone `pydantic.TypeAdapter`).
- `WaypointObjective` + `WaypointSource` enums mirroring the C# enums.
- Offline loader at `src/operator_tool/flights_file_loader.py`:
- `load_flight_file(path: Path) -> FlightDto` — reads JSON via `orjson`, validates against the same pydantic schema, raises `FlightFileNotFoundError` / `FlightsApiSchemaError` / `WaypointSchemaError` per the contract.
- Bbox helper at `src/operator_tool/bbox_from_waypoints.py`:
- `bbox_from_waypoints(waypoints, *, buffer_m, wgs_converter) -> BoundingBox` — envelopes the lat/lon, then inflates by `buffer_m` horizontal distance (NOT degree-space) using `wgs_converter.metres_to_degrees(lat, buffer_m)`.
- Error hierarchy at `src/operator_tool/flights_api_errors.py`:
- `FlightsApiError` (base) → `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`, `FlightFileNotFoundError`, `EmptyWaypointsError`, `WaypointSchemaError`.
- Composition-root factory entry at `src/gps_denied_onboard/runtime_root/c12_factory.py`:
- Extend the `OperatorToolServices` dataclass with `flights_api_client: FlightsApiClient`.
- `build_flights_api_client(config) -> FlightsApiClient` constructs the httpx client with TLS verify on (no `verify=False`), default timeout `10.0 s`, and the project's `WgsConverter`.
- Logging:
- INFO on every successful fetch (`kind="c12.flights.fetch.success"`) with `flight_id`, `waypoint_count`, `bbox` summary. NO `auth_token` in any log line.
- WARN on retry attempts (`kind="c12.flights.fetch.retry"`).
- ERROR on each failure variant (`kind="c12.flights.fetch.failed"` with the resolved error class name).
## Scope
### Included
- `FlightsApiClient` Protocol + `HttpxFlightsApiClient` concrete impl.
- `FlightDto` + `WaypointDto` + enums.
- Online path (`fetch_flight`) with one-retry-on-transient-5xx-or-connect-error semantics per FAC-INV-5.
- Offline path (`load_flight_file`) reading the same DTO shape.
- `bbox_from_waypoints` envelope + horizontal-distance buffer via `WgsConverter`.
- `takeoff_origin_from_flight` pass-through of `waypoints[0]`.
- Error hierarchy with one-line operator-friendly text per class.
- Composition-root factory + service-dataclass extension.
- Unit tests covering every AC + Protocol conformance.
### Excluded
- Flight authoring / editing (Mission Planner UI owns).
- Live updates / websockets.
- Caching `FlightDto` across runs.
- Writing build status back to the `flights` REST service.
- Wiring `--flight-id` / `--flight-file` flags into the CLI (AZ-326).
- Wiring the resolved DTO into the orchestrator phase 0 (AZ-328).
- Anything that runs on the airborne companion (this is operator-workstation-only).
## Acceptance Criteria
**AC-1: Online happy path — `fetch_flight` returns a populated `FlightDto`**
Given a fake httpx transport that returns 200 OK with a valid 3-waypoint JSON body
When `fetch_flight(flight_id=..., base_url="https://flights.example", auth_token="abc")` is called
Then a `FlightDto` is returned with `flight_id`, `name`, and 3 ordered `WaypointDto` entries (ordinals 0, 1, 2); ONE INFO log; ZERO occurrences of `"abc"` in any log line
**AC-2: Online 404 — `FlightNotFoundError` with NO retry**
Given a fake transport that returns 404
When `fetch_flight(flight_id=<unknown>)` is called
Then `FlightNotFoundError` is raised; the unknown `flight_id` is in the message; the transport is hit exactly once (no retry); ONE ERROR log
**AC-3: Online 401 — `FlightsApiAuthError` with NO retry; auth_token NOT logged**
Given a fake transport that returns 401
When `fetch_flight(auth_token="bearer-xyz")` is called
Then `FlightsApiAuthError` is raised; transport hit exactly once; the literal `"bearer-xyz"` does NOT appear in any log line
**AC-4: Online 503 (transient) — one retry, then success**
Given a fake transport that returns 503 on the first call and 200 on the second
When `fetch_flight(...)` is called
Then the call succeeds; transport is hit exactly twice; ONE WARN log `kind="c12.flights.fetch.retry"`
**AC-5: Online 503 (persistent) — one retry, then `FlightsApiUnreachableError`**
Given a fake transport that always returns 503
When `fetch_flight(...)` is called
Then `FlightsApiUnreachableError` is raised; transport hit exactly twice; ONE ERROR log
**AC-6: Online schema drift — `FlightsApiSchemaError` with field reference**
Given a fake transport that returns 200 OK with a body missing the `lat` field on a waypoint
When `fetch_flight(...)` is called
Then `FlightsApiSchemaError` is raised; the message names the missing field
**AC-7: Offline happy path — `load_flight_file` returns equivalent `FlightDto`**
Given a JSON file on disk in the documented schema (3 waypoints, ordinals 0/1/2)
When `load_flight_file(path)` is called
Then a `FlightDto` is returned with the same shape as the online happy path
**AC-8: Offline file missing — `FlightFileNotFoundError`**
Given a path that does not exist
When `load_flight_file(path)` is called
Then `FlightFileNotFoundError` is raised; the path is in the message
**AC-9: Empty waypoints — `bbox_from_waypoints` raises `EmptyWaypointsError`**
Given a `FlightDto` with zero waypoints
When `bbox_from_waypoints(flight.waypoints, buffer_m=1000.0)` is called
Then `EmptyWaypointsError` is raised; the message instructs to re-plan in the Mission Planner UI
**AC-10: Bbox is horizontal-distance buffered (NOT degree-space)**
Given 4 waypoints at the corners of a 1 km × 1 km box centred on (50.0 N, 36.2 E)
When `bbox_from_waypoints(waypoints, buffer_m=1000.0)` is called
Then the returned bbox extends ~1 km outwards on all sides (NOT 1° outwards); the lat-degree extension is approximately `1000 / 111000 ≈ 0.009°`, the lon-degree extension at 50° N is approximately `1000 / (111000 * cos(50°)) ≈ 0.014°`. Asserted within 5% tolerance against `WgsConverter`.
**AC-11: Takeoff origin is `waypoints[0]` exactly (no rounding)**
Given a `FlightDto` with `waypoints[0] = WaypointDto(ordinal=0, lat_deg=50.000000001, lon_deg=36.2, alt_m=200.0, ...)`
When `takeoff_origin_from_flight(flight)` is called
Then the returned `LatLonAlt` is `LatLonAlt(50.000000001, 36.2, 200.0)` — no rounding, no projection
**AC-12: Conformance — `isinstance(impl, FlightsApiClient)` is `True`**
**AC-13: Online + Offline byte-identical output for same source**
Given the online happy path returns DTO `online_dto` and the same JSON written to disk loads as `offline_dto`
When both are compared
Then `online_dto == offline_dto`
**AC-14: Waypoint ordering — input shuffled but ordinals contiguous returns sorted tuple**
Given waypoints with ordinals `[2, 0, 1]` in input order
When `fetch_flight` or `load_flight_file` parses them
Then the returned `FlightDto.waypoints` is ordered `[0, 1, 2]`
**AC-15: Waypoint ordinal gap — raises `WaypointSchemaError`**
Given waypoints with ordinals `[0, 1, 3]` (gap)
When parsing
Then `WaypointSchemaError` is raised; gap is named in the message
**AC-16: Waypoint out-of-range lat raises `WaypointSchemaError`**
Given a waypoint with `lat_deg = 200.0`
When parsing
Then `WaypointSchemaError`; field name in the message
**AC-17: NFR — `auth_token` redaction**
Given any failure or success path
When inspecting captured stdout/stderr logs
Then the literal `auth_token` value never appears
**AC-18: NFR — online call timeout default 10 s**
Given a fake transport that delays 11 s
When `fetch_flight(timeout_s=10.0)` is called
Then `FlightsApiUnreachableError` is raised within ~10 s plus the single 1 s retry budget; ONE WARN log on the retry
## Non-Functional Requirements
**Performance**
- Online fetch wall-clock on the happy path ≤ 1 s (single HTTP call, default 10 s timeout, network is the dominant factor).
- Offline load wall-clock ≤ 100 ms for a JSON file under 1 MB.
- `bbox_from_waypoints` is O(N) in waypoint count; ≤ 5 ms for N ≤ 1000.
**Compatibility**
- httpx per the project pin (already used by AZ-316). No new third-party deps unless pydantic is missing — verify against `requirements.txt`; if missing, add at the established version.
- TLS verify on by default; no `verify=False` anywhere in production code.
**Reliability**
- Auth token never logged.
- Online path retries at most once on transient 5xx or connect error; 401/403/404/schema errors are NEVER retried.
- Offline path is fully usable on an air-gapped operator workstation.
## Unit Tests
| AC Ref | What to Test | Required Outcome |
|--------|-------------|------------------|
| AC-1 | Happy 200 OK + 3-waypoint body | DTO; INFO log; no token in log |
| AC-2 | 404 response | `FlightNotFoundError`; no retry |
| AC-3 | 401 response | `FlightsApiAuthError`; no token in log |
| AC-4 | 503 then 200 | Success; ONE retry |
| AC-5 | 503 always | `FlightsApiUnreachableError` after one retry |
| AC-6 | Missing `lat` in response | `FlightsApiSchemaError`; field name |
| AC-7 | Offline well-formed JSON | DTO matches online shape |
| AC-8 | Offline missing file | `FlightFileNotFoundError` |
| AC-9 | Empty waypoints into `bbox_from_waypoints` | `EmptyWaypointsError` |
| AC-10 | Bbox buffer math at 50° N | Within 5% of horizontal-distance target |
| AC-11 | Takeoff origin pass-through | No rounding |
| AC-12 | Conformance | `isinstance` True |
| AC-13 | Online vs Offline DTO equality | `==` |
| AC-14 | Shuffled ordinals | Sorted output |
| AC-15 | Ordinal gap | `WaypointSchemaError` |
| AC-16 | lat=200 | `WaypointSchemaError` |
| AC-17 | Token redaction across all paths | Token absent from logs |
| AC-18 | Timeout + retry budget | Failure within bounded time |
## Constraints
- TLS verify on by default. Production composition root MUST NOT pass `verify=False` to httpx; tests use a separate test transport rather than disabling verification.
- `auth_token` field uses `pydantic.SecretStr` (or equivalent) and is NEVER `repr()`-logged.
- `bbox_from_waypoints` MUST use `WgsConverter` for the horizontal-distance buffer; naive `lat_deg ± 0.01` style buffering is rejected (fails AC-10 at high latitudes).
- The DTO shape MUST mirror `suite/flights/Database/Entities/{Flight,Waypoint}.cs`. Any field added to the C# side requires a follow-up task; this task pins the schema at the current shape.
- No companion-side code in this task — the entire package is operator-workstation-only.
- Offline `--flight-file` accepts JSON only (orjson); YAML is rejected.
## Risks & Mitigation
**Risk 1: parent-suite `Flight` schema drifts silently**
- *Risk*: `suite/flights/` adds or renames a field, online fetches succeed but parse loosely and miss the new field.
- *Mitigation*: pydantic schema is strict (`extra="forbid"`); AC-6 covers the schema-violation path; a CI tripwire compares the C# entity to the Python DTO once per build.
**Risk 2: auth_token leaks via `repr()` of an exception**
- *Risk*: a caller logs `repr(exc)` and the wrapped httpx response carries the token in headers.
- *Mitigation*: `FlightsApiAuthError`'s `__str__` and `__repr__` explicitly redact; AC-3 + AC-17 cover.
**Risk 3: bbox too tight at high latitudes due to degree-space buffer**
- *Risk*: a naive `lat ± 0.01` buffer is ~1.5× too narrow at 60° N and tiles near the route are missing.
- *Mitigation*: AC-10 + AC-NFR enforce horizontal-distance buffer via `WgsConverter`; reviewed in code review.
**Risk 4: offline JSON drifts from the parent-suite serialization**
- *Risk*: an operator hand-edits a JSON export and breaks ordinals or waypoint shape.
- *Mitigation*: AC-15 / AC-16 cover schema validation; the error message references the exact field so the operator can fix it.
## Runtime Completeness
- **Named capability**: read-only Flight resolution for C12 cache provisioning (ADR-010).
- **Production code that must exist**: real `HttpxFlightsApiClient` against the parent-suite `flights` REST service with TLS + auth, real `FlightsFileLoader` for the offline path, real `WgsConverter`-backed bbox buffer.
- **Allowed external stubs**: tests use httpx `MockTransport` and a fake clock; production wiring uses real httpx + real WgsConverter.
- **Unacceptable substitutes**: requests/urllib3 instead of httpx (project pin says httpx); naive degree-space bbox buffer; logging the auth_token "just for debugging"; verifying TLS off in production code.