Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ FastAPI backend and React front-ends for the RETINA passive-radar network.
ONBOARDING, "Before you push", for what it runs and where it lies to you.
- **Every PR runs the full matrix**, whatever it is based on. Branches opened
before #187 predate that and ran nothing unless they targeted `main`.
- **The node API contract is generated.** Change a route under `/v1/nodes` or one
of its models and `contracts/nodes-v1.openapi.yaml` moves with it; regenerate it
in the same commit or CI fails. See ONBOARDING, "Before you push".
- **The node API contract is generated.** Change a route under `/v1/nodes`, one of
its models, or a configuration bound in `backend/services/node_config.py`, and
`contracts/nodes-v1.openapi.yaml` moves with it; regenerate it in the same commit
or CI fails. See ONBOARDING, "Before you push".
- **This repo is public.** Refer to hosts by SSH alias, never by address, as
`justfile` already does. No credentials, no droplet addresses, no personal
accounts in anything committed here.
Expand Down
7 changes: 5 additions & 2 deletions ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,11 @@ twice, once per copy of the shared standard in this repo. A change can pass
`ruff check` and `ruff format` by hand and still fail CI on dead code.

Touching a node route or one of its models also moves the node API's wire
contract, which is generated rather than written. Regenerate it in the same
commit, or CI fails on a file you never edited:
contract, which is generated rather than written. So does changing a
configuration bound: the schema published for `config` is built from the
validator's own tables, so `backend/services/node_config.py` moves the contract
with no route touched. Regenerate it in the same commit, or CI fails on a file
you never edited:

```bash
cd backend && RETINA_ENV=dev .venv/bin/python -m scripts.generate_openapi
Expand Down
16 changes: 6 additions & 10 deletions backend/routes/node_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from routes.node_responses import INVALID_CONFIG, NODE_BODY_LIMITS, SERVER_ERROR, TOO_LARGE, UNAUTHORIZED
from routes.node_schemas import ConfigResponse, ErrorBody
from services.node_auth import bearer_node, node_bearer_scheme
from services.node_config import ConfigInvalid, validate_config
from services.node_config import ConfigInvalid, config_json_schema, validate_config
from services.node_config_store import upsert_config
from services.node_pipeline import register_with_pipeline

Expand Down Expand Up @@ -83,17 +83,13 @@ def _error(status_code: int, error: str, detail: str | None = None) -> JSONRespo
"x-max-body-bytes": NODE_BODY_LIMITS["/v1/nodes/config"],
# The body is read inside the handler rather than declared, so FastAPI has
# nothing to describe it with and the published operation would otherwise
# take no body at all. This says only what registration's `config` already
# says — a free-form object — so it is not a second statement of the
# bounds, which stay in services/node_config.py. Publishing the closed
# schema from that module's own table is 86cb6d7he.
# take no body at all. The same object registration's `config` publishes,
# which scripts/generate_openapi.py then hoists into the one component
# both operations reference.
"requestBody": {
"required": True,
"description": (
"The full configuration, in the same shape as `config` on `POST /v1/nodes/register`. "
"Free-form here for the reason given on that endpoint."
),
"content": {"application/json": {"schema": {"type": "object", "additionalProperties": True}}},
"description": "The full configuration, in the same shape as `config` on `POST /v1/nodes/register`.",
"content": {"application/json": {"schema": config_json_schema()}},
},
},
)
Expand Down
17 changes: 11 additions & 6 deletions backend/routes/node_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import CoreSchema

from services.node_config import config_json_schema


def _reject_non_number(value: Any) -> Any:
"""Three shapes that are not a JSON `number` but that Pydantic's lax mode
Expand Down Expand Up @@ -148,12 +150,15 @@ class RegisterRequest(_RequestModel):
node_id: NodeId
board_model: str = Field(max_length=64)
agreements: Agreements
# Deliberately untyped. A Pydantic model here would 422 on a bad value before
# the handler runs, putting a config-shaped rejection in front of identity
# resolution and making the response an oracle for which identities exist.
# Validation is services/node_config.validate_config, called from inside the
# handler once the identity has resolved.
config: dict[str, Any]
# Deliberately untyped, for the reason routes/node_register.py's module
# docstring gives: a Pydantic model here would refuse a bad value before the
# handler runs, ahead of identity resolution. Validation is
# services/node_config.validate_config, from inside the handler.
#
# WithJsonSchema describes without enforcing: it replaces what is published
# and leaves validation alone, so anything this schema forbids still reaches
# the handler and is refused there.
config: Annotated[dict[str, Any], WithJsonSchema(config_json_schema())]


class RegisterResponse(BaseModel):
Expand Down
22 changes: 16 additions & 6 deletions backend/routes/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,23 @@
# and no client can have depended on either, since the server has never emitted
# one. See 86cb6d7cq for the configuration one, which comes back with the limit.
#
# Publishing NodeConfig would be the minor bump, since that is the one thing
# here a client cannot already do (86cb6d7he).
# 1.1.3 makes the six coordinate fields of the configuration nullable, so a node
# whose owner cannot supply the geometry can still register. A patch rather than
# a minor bump because the document gained no field a client could read.
#
# 1.1.3 makes the six coordinate fields of NodeConfig nullable, so a node whose
# owner cannot supply the geometry can still register. A patch rather than a
# minor bump for the same reason as above: NodeConfig is not published, so the
# document gains no field and no capability a client can read (86cb6d7he).
# 1.1.3 also publishes the configuration schema, built from the same tables the
# validator enforces (86cb6d7he), and the version does not move for it: the
# server accepts and refuses exactly what it did, so there is no change in
# behaviour for a version to describe.
#
# What did change is the document, and not additively. Where `config` was an
# object with no required keys and `additionalProperties: true`, it is now
# fifteen required fields with unknown keys forbidden, and its generated type is
# named NodeConfig where it was Config. So the document narrows, to what this
# server has always enforced, and renames. Two documents therefore carry this
# version and a client cannot tell them apart by it: a payload the earlier one
# called valid, one omitting cpi_s say, the later one rejects, and a client
# regenerated against the later one renames its config type.
NODE_API_VERSION = "1.1.3"

# No tag here: each sub-router carries the contract's own grouping, since those
Expand Down
47 changes: 44 additions & 3 deletions backend/scripts/generate_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
models, the security scheme from the dependency, and `info.description` from the
application's own. The three constants this file reaches for by name
(`NODE_API_VERSION`, `NODE_API_TAGS`, `NODE_API_SERVERS`) live in routes/nodes.py
beside the router they describe.
beside the router they describe. It also reaches for `config_json_schema`, to
recognise the configuration schema in the document and hoist it into a component
the two operations reference; see `_hoisted`.

Numeric bounds publish as floats (`minimum: 1.0` rather than `1`) because
FastAPI validates its own output through `openapi.models`, whose
Expand All @@ -42,6 +44,7 @@

from main import app
from routes.nodes import NODE_API_SERVERS, NODE_API_TAGS, NODE_API_VERSION, is_node_path
from services.node_config import config_json_schema

TITLE = "RETINA node ingest"

Expand All @@ -51,6 +54,15 @@

_REF_PREFIX = "#/components/schemas/"

# The configuration schema is the one component this file names into existence
# rather than finding among the application's models. Both operations that take
# a configuration carry it inline, because Pydantic resolves every `$ref` it
# emits against its own definitions and this schema is not one of its models, so
# the reference is made here: this is already the layer that shapes the
# document, and one component is what a generated client needs to produce one
# type for one wire object.
_CONFIG_SCHEMA_NAME = "NodeConfig"


def _referenced(node: Any, found: set[str]) -> None:
"""Every schema reachable from `node`, transitively.
Expand Down Expand Up @@ -85,6 +97,27 @@ def _closure(paths: dict[str, Any], schemas: dict[str, Any]) -> dict[str, Any]:
return {name: schemas[name] for name in sorted(found) if name in schemas}


def _hoisted(node: Any, inline: dict[str, Any]) -> Any:
"""`node` with every inline copy of `inline` replaced by a `$ref` to it.

Rebuilds rather than mutates: `app.openapi()` caches its result, and editing
it in place would leave the application's own docs holding a reference to a
component only this document defines.

Matched by equality on the whole schema, so a partial copy is left alone
rather than silently referred to something it does not equal.
tests/test_node_openapi.py holds the other end, that both operations really
do end up referring to it.
"""
if isinstance(node, dict):
if node == inline:
return {"$ref": _REF_PREFIX + _CONFIG_SCHEMA_NAME}
return {key: _hoisted(value, inline) for key, value in node.items()}
if isinstance(node, list):
return [_hoisted(item, inline) for item in node]
return node


def _node_paths(paths: dict[str, Any]) -> dict[str, Any]:
"""The four operations, with FastAPI's automatic 422 dropped.

Expand Down Expand Up @@ -113,8 +146,16 @@ def _node_paths(paths: dict[str, Any]) -> dict[str, Any]:

def contract() -> dict[str, Any]:
schema = app.openapi()
paths = _node_paths(schema["paths"])
components: dict[str, Any] = {"schemas": _closure(paths, schema["components"]["schemas"])}
inline_config = config_json_schema()
declared = schema["components"]["schemas"]
if _CONFIG_SCHEMA_NAME in declared:
# A Pydantic model of this name anywhere in the application would be
# published under it instead, silently, and a pinned consumer would see
# one type become another. Refused rather than clobbered.
raise RuntimeError(f"{_CONFIG_SCHEMA_NAME} is already an application model; the contract cannot inject it")
paths = _hoisted(_node_paths(schema["paths"]), inline_config)
schemas = _hoisted(declared, inline_config) | {_CONFIG_SCHEMA_NAME: inline_config}
components: dict[str, Any] = {"schemas": _closure(paths, schemas)}
# Only the node routes declare one today, but filtering keeps that true
# rather than assuming it.
declared = schema.get("components", {}).get("securitySchemes", {})
Expand Down
106 changes: 100 additions & 6 deletions backend/services/node_config.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
"""The one configuration validator, shared by registration and PUT /nodes/config,
and the one normaliser every in-process copy of a node's config passes through.

Bounds are the wire contract's, at version 1.1.1. Three checks are here and not
there because a JSON schema cannot express them: a receiver and illuminator at
the same point give the solver a degenerate baseline; bool is a subclass of int
in Python, so a plain range check accepts True as a latitude of 1; and NaN
compares false against every bound, so it survives a range check untouched.
These bounds are the wire contract's, and they are its source: config_json_schema
below is what the document publishes, so the contract is built from this module
rather than copied into it. Three checks cannot travel with them, because a JSON
schema expresses none of them: a receiver and illuminator at the same point give
the solver a degenerate baseline; bool is a subclass of int in Python, so a plain
range check accepts True as a latitude of 1; and NaN compares false against every
bound, so it survives a range check untouched.

A leaf on purpose. It takes a dict and returns a dict, knowing nothing of identity,
HTTP or status codes, so both callers can share it and it stays testable without a
database. Nothing beyond the standard library may be imported here.
"""

import math
from copy import deepcopy
from typing import Any, Literal

# About 0.11 m. Below this the receiver and illuminator are the same point as far as
Expand Down Expand Up @@ -56,7 +59,98 @@ def __init__(self, field: str, reason: str = "out of range") -> None:
# zero wherever the geodesy reads it.
_NULLABLE = {"rx_lat", "rx_lon", "rx_alt_ft", "tx_lat", "tx_lon", "tx_alt_ft"}

_REQUIRED = set(_NUMERIC_BOUNDS) | {"tx_callsign", "beam_width_deg", "beam_azimuth_deg"}
# The three fields the table above does not carry: the callsign, which is not
# numeric, and the two beam fields, whose bounds are checked beside it because
# both are nullable. Written out rather than derived, so
# tests/test_node_config_validation.py exercises every bound published here
# against what validate_config actually accepts: that is what stops these
# drifting from the checks below, since they are not the same literals.
_UNTABLED_PROPERTIES: dict[str, dict[str, Any]] = {
"tx_callsign": {"type": "string", "minLength": 1, "maxLength": 32},
"beam_width_deg": {"type": "number", "exclusiveMinimum": 0.0, "maximum": 360.0},
"beam_azimuth_deg": {"type": "number", "minimum": 0.0, "exclusiveMaximum": 360.0},
}

# Derived from the two tables rather than listed again, so a field added to
# either is required by this validator and published as required in the same
# edit.
_REQUIRED = set(_NUMERIC_BOUNDS) | set(_UNTABLED_PROPERTIES)

# Nullable beyond the coordinates, and for a different reason: no node has its
# antenna characterised, so null is what the whole fleet sends for both.
_NULLABLE_BEAM = {"beam_width_deg", "beam_azimuth_deg"}

_SCHEMA_DESCRIPTION = """\
The receiver and illuminator geometry, the radio parameters and the association
tolerances. Every field is required.

The six coordinate fields are nullable, for a node whose owner cannot supply the
geometry. Such a node registers and streams, and its detections are counted, but
it places nothing on the map until a position arrives. A latitude and its
longitude are given together or both null.

Necessary but not sufficient. A receiver and illuminator at the same point are
refused, as is a value that is not a finite number, and neither is expressible
here: both answer `400 invalid_config` naming the field."""


def _numeric_property(low: float, high: float, low_inclusive: bool, high_inclusive: bool) -> dict[str, Any]:
# An unbounded end is what JSON Schema says by omission: neither an infinity
# nor a NaN is a bound, and either would publish a ceiling the server does
# not have in a literal no JSON parser can read. Both ends are guarded, so
# adding an unbounded low to the table cannot slip one out.
schema: dict[str, Any] = {"type": "number"}
if math.isfinite(low):
schema["minimum" if low_inclusive else "exclusiveMinimum"] = float(low)
if math.isfinite(high):
schema["maximum" if high_inclusive else "exclusiveMaximum"] = float(high)
return schema


def config_json_schema() -> dict[str, Any]:
"""The configuration's published JSON Schema, built from the tables above.

Generated from what this module enforces rather than written beside it, so
the document a node is built against cannot state a bound the server does
not apply. Describing the shape is not enforcing it; registration's body
stays untyped, for the reason routes/node_register.py gives.

Fresh every call, nested dicts included. Two callers publish this and the
frameworks under both mutate schema dicts in place, so a shared sub-dict
would let one operation's published bounds rewrite the other's.
"""
properties = {field: _numeric_property(*bounds) for field, bounds in _NUMERIC_BOUNDS.items()}
properties |= deepcopy(_UNTABLED_PROPERTIES)
nullable = _NULLABLE | _NULLABLE_BEAM
return {
"type": "object",
"title": "NodeConfig",
"description": _SCHEMA_DESCRIPTION,
"properties": {
field: {"anyOf": [schema, {"type": "null"}]} if field in nullable else schema
for field, schema in properties.items()
},
# Sorted for the same reason the refusals above are: a document that
# reordered between runs would show as a diff in the CI gate.
"required": sorted(_REQUIRED),
# The validator names an unknown key back to the caller rather than
# ignoring it.
"additionalProperties": False,
}


def numeric_branch(published: dict[str, Any]) -> dict[str, Any]:
"""The number half of a published property, whether or not it is nullable.

Beside the builder because it is how the builder's output is read back, and
the alternative is each caller re-deriving where the bounds live. Selects on
the type rather than on position in the `anyOf`, so reordering the branches
cannot leave a caller reading the null one and finding no bounds at all.
"""
for alternative in published.get("anyOf", [published]):
if alternative.get("type") == "number":
return alternative
return published


def _as_finite_float(value: Any) -> tuple[float | None, str]:
Expand Down
Loading
Loading