mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 10:51:13 +00:00
[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:
@@ -0,0 +1,166 @@
|
||||
# Batch 21 — AZ-489 C12 FlightsApiClient
|
||||
|
||||
**Date**: 2026-05-12
|
||||
**Tracker**: Jira AZ-489 (Epic AZ-253 / E-C12) — transitioned To Do → In Progress → Done.
|
||||
**Cycle**: 1
|
||||
**Status**: complete; 28 unit tests green; full repo 713 passed / 2 skipped (pre-existing CI tooling skips).
|
||||
|
||||
## Scope landed
|
||||
|
||||
AZ-489 delivers the operator-workstation → parent-suite `flights` REST service
|
||||
boundary plus the offline JSON fallback, exactly as frozen by
|
||||
`_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md`
|
||||
v1.0.0. ADR-010's primary cold-start path now has a real source for both the
|
||||
cache bbox and the takeoff origin.
|
||||
|
||||
The implementation is strictly scoped to AZ-489 — the CLI flag plumbing
|
||||
(AZ-326), the orchestrator phase 0 (AZ-328), and the
|
||||
`OperatorToolServices` aggregate dataclass all stay out of scope.
|
||||
|
||||
### Public surface
|
||||
|
||||
* `src/gps_denied_onboard/components/c12_operator_tooling/flights_api/`
|
||||
package with:
|
||||
* `interface.py` — `FlightsApiClient` Protocol (`@runtime_checkable`) +
|
||||
`FlightDto` + `WaypointDto` + `WaypointObjective` + `WaypointSource`.
|
||||
DTOs are plain `@dataclass(frozen=True, slots=True)`, matching the
|
||||
project's `LatLonAlt` / `PoseEstimate` pattern.
|
||||
* `errors.py` — exception hierarchy: `FlightsApiError` (base) →
|
||||
`FlightsApiUnreachableError`, `FlightsApiAuthError`,
|
||||
`FlightNotFoundError`, `FlightsApiSchemaError`,
|
||||
`FlightFileNotFoundError`, `EmptyWaypointsError`,
|
||||
`WaypointSchemaError`.
|
||||
* `_parser.py` — shared JSON-payload → `FlightDto` validator used by
|
||||
both online and offline paths so they satisfy FAC-INV-1 (same shape,
|
||||
same error types).
|
||||
* `bbox.py` — `bbox_from_waypoints(waypoints, *, buffer_m=1000.0)`
|
||||
envelopes lat/lon and inflates by a horizontal-distance buffer via
|
||||
`WgsConverter.latlonalt_to_local_enu` + `local_enu_to_latlonalt`
|
||||
(FAC-INV-3); `takeoff_origin_from_flight(flight)` passes
|
||||
`waypoints[0]` through without rounding (FAC-INV-4).
|
||||
* `file_loader.py` — `load_flight_file(*, path)` reads JSON via
|
||||
`orjson`, delegates to the parser; raises
|
||||
`FlightFileNotFoundError` / `FlightsApiSchemaError` /
|
||||
`WaypointSchemaError` as documented.
|
||||
* `httpx_client.py` — `HttpxFlightsApiClient` against the parent-suite
|
||||
REST API. Single retry on transient 5xx + connection errors per
|
||||
FAC-INV-5. Token redaction enforced at every log site. `transport`
|
||||
+ `sleep` are constructor-injectable for unit tests.
|
||||
* `src/gps_denied_onboard/runtime_root/c12_factory.py` —
|
||||
`build_flights_api_client(config) -> FlightsApiClient`. Tiny factory:
|
||||
one concrete strategy; httpx's defaults already enforce TLS verify ON
|
||||
and the system trust store.
|
||||
* `src/gps_denied_onboard/runtime_root/__init__.py` —
|
||||
`build_flights_api_client` re-exported alongside the other
|
||||
`build_*` factories.
|
||||
* `src/gps_denied_onboard/components/c12_operator_tooling/__init__.py`
|
||||
— flights_api surface re-exported as the C12 public API.
|
||||
* `pyproject.toml` — `httpx>=0.28,<1.0` added to main deps.
|
||||
|
||||
### HTTP client choice
|
||||
|
||||
Selected `httpx` over `requests` (user-confirmed). Rationale:
|
||||
|
||||
* `httpx.MockTransport` is part of httpx — no `responses` /
|
||||
`requests-mock` test dep required.
|
||||
* No prior C12 / C11 HTTP code yet uses `requests`; the only listing
|
||||
is a pyproject.toml dep entry without a consumer. Picking `httpx`
|
||||
costs one new dep but avoids two (httpx vs requests + responses).
|
||||
* TLS verify is on by default; the constructor does not accept a
|
||||
`verify=False` toggle from config.
|
||||
|
||||
The AZ-489 spec doc + the C12 description + the flights_api_client
|
||||
contract already reference `httpx`; no design drift was introduced
|
||||
by this choice.
|
||||
|
||||
## Test coverage
|
||||
|
||||
`tests/unit/c12_operator_tooling/test_az489_flights_api_client.py` — 28
|
||||
tests across AC-1..AC-18 plus extra coverage.
|
||||
|
||||
| AC | Test focus | Outcome |
|
||||
|----|-----------|---------|
|
||||
| AC-1 | Online 200 + 3-waypoint payload → `FlightDto`; ONE INFO log; ZERO token leak | green |
|
||||
| AC-2 | Online 404 → `FlightNotFoundError`; transport hit once; no retry | green |
|
||||
| AC-3 | Online 401 → `FlightsApiAuthError`; no retry; token absent from logs and exception text | green |
|
||||
| AC-4 | Online 503 then 200 → success; ONE retry; ONE WARN `c12.flights.fetch.retry` | green |
|
||||
| AC-5 | Online 503 always → `FlightsApiUnreachableError` after ONE retry | green |
|
||||
| AC-6 | Missing `lat_deg` in waypoint payload → `WaypointSchemaError` naming the field | green |
|
||||
| AC-7 | Offline well-formed JSON → equivalent `FlightDto` | green |
|
||||
| AC-8 | Offline missing file → `FlightFileNotFoundError` with path in message | green |
|
||||
| AC-9 | `bbox_from_waypoints(())` → `EmptyWaypointsError` | green |
|
||||
| AC-10 | Bbox 1 km horizontal buffer at 50° N within 5% of expected lat/lon deltas | green |
|
||||
| AC-11 | `takeoff_origin_from_flight` returns `waypoints[0]` byte-equal (no rounding) | green |
|
||||
| AC-12 | `isinstance(HttpxFlightsApiClient(), FlightsApiClient) is True`; same for factory output | green |
|
||||
| AC-13 | Online + offline DTOs from the same payload compare equal | green |
|
||||
| AC-14 | Shuffled ordinals `[2, 0, 1]` returned sorted | green |
|
||||
| AC-15 | Ordinal gap `[0, 1, 3]` → `WaypointSchemaError` | green |
|
||||
| AC-16 | `lat_deg=200` → `WaypointSchemaError` naming the field + value | green |
|
||||
| AC-17 | Token redaction across happy, 401, 404, 500 — token literal never in logs | green |
|
||||
| AC-18 | Persistent `httpx.ConnectError` → `FlightsApiUnreachableError` after one 1 s retry | green |
|
||||
| Bonus | Malformed JSON file → `FlightsApiSchemaError` | green |
|
||||
| Bonus | Negative `buffer_m` → `ValueError` | green |
|
||||
| Bonus | Zero `buffer_m` → envelope-only bbox; matches expected within 1% | green |
|
||||
| Bonus | Missing top-level `flight_id` in JSON file → `FlightsApiSchemaError` | green |
|
||||
| Bonus | Negative ordinal → `WaypointSchemaError` | green |
|
||||
| Bonus | `takeoff_origin_from_flight(empty_flight)` → `EmptyWaypointsError` | green |
|
||||
|
||||
Full repo run: 713 passed, 2 skipped — same skip baseline as Batch 20.
|
||||
|
||||
## Quality gates
|
||||
|
||||
* `ruff check` on every changed file — clean (fixed: `UP037` quoted
|
||||
annotation in c12_factory; `RUF100` unused `noqa` in test file).
|
||||
* `ReadLints` on the changed surface — no diagnostics.
|
||||
* Full `pytest` — 713 passed, 2 skipped (pre-existing tooling skips
|
||||
for `cmake` and `actionlint` in CI-only scaffold tests).
|
||||
|
||||
## Architectural notes
|
||||
|
||||
* **FAC-INV-1 enforcement**: online and offline paths share the SAME
|
||||
`parse_flight_payload` validator; they differ only in how the bytes
|
||||
are sourced (httpx response vs file read). The AC-13 equality test
|
||||
is the visible proof.
|
||||
* **FAC-INV-3 horizontal-distance buffer**: implemented as a local-ENU
|
||||
round-trip centred at the bbox envelope's midpoint. Picking the
|
||||
midpoint as the ENU origin (NOT one of the corners) keeps the
|
||||
inflation symmetric at high latitudes; the AC-10 test exercises 50° N
|
||||
and validates against the metres-per-degree expectations.
|
||||
* **FAC-INV-5 retry semantics**: the implementation issues at most ONE
|
||||
retry — on transient 5xx OR connection error. 401/403/404 + schema
|
||||
errors are never retried. The retry path emits a single WARN log
|
||||
`c12.flights.fetch.retry`; the test fixture injects a fake `sleep`
|
||||
to avoid the real 1 s backoff while still asserting the timing.
|
||||
* **FAC-INV-7 token redaction**: the `HttpxFlightsApiClient` never logs
|
||||
the auth_token in any code path. Every structured log emits the
|
||||
literal `"<redacted>"`. The AC-17 parametrised test exercises happy,
|
||||
401, 404, and 500 paths and asserts the token literal is absent from
|
||||
the captured log buffer.
|
||||
|
||||
## Cross-task notes
|
||||
|
||||
* **`OperatorToolServices` aggregate dataclass deferred**: the AZ-489
|
||||
spec mentions extending an `OperatorToolServices` dataclass at
|
||||
`runtime_root/c12_factory.py`, but that dataclass doesn't exist yet
|
||||
— it's part of AZ-328's territory (the build-cache orchestrator).
|
||||
Per scope-discipline this batch ships ONLY the `build_flights_api_client`
|
||||
factory function; AZ-328 will create the aggregate and wire the
|
||||
flights client through it.
|
||||
* **AZ-326 (CLI flag plumbing)** can now consume the new
|
||||
`FlightsApiClient` and `--flight-file` path; the CLI must validate
|
||||
the `--flight-id` vs `--flight-file` exclusivity and translate
|
||||
`FlightsApiError` subclasses to the documented exit codes.
|
||||
* **AZ-328 (build-cache orchestrator)** can now wire the flight-resolve
|
||||
phase 0 against the new client + DTOs.
|
||||
* **AZ-419 (FT-P-11 cold-start scenario)** has both forward deps
|
||||
resolved on this side (AZ-489 done) and waits on AZ-490 for the C5
|
||||
`set_takeoff_origin` entrypoint.
|
||||
|
||||
## Tracker
|
||||
|
||||
* Jira AZ-489 transitioned To Do → In Progress → Done.
|
||||
* Comment added on AZ-489 summarising deliverables + deviations from
|
||||
the original Jira description (Jira description predates the AZ-489
|
||||
spec doc; the doc is the authoritative source).
|
||||
* AZ-489 spec file moved from `_docs/02_tasks/todo/` to
|
||||
`_docs/02_tasks/done/`.
|
||||
Reference in New Issue
Block a user