From d7901beadc5ae8afbe7616791ba2663c0a2df88f Mon Sep 17 00:00:00 2001 From: Babissimo Date: Wed, 9 Sep 2026 12:16:10 +0100 Subject: [PATCH 1/2] Publish the node configuration schema in the contract (86cb6d7he) `RegisterRequest.config` is `dict[str, Any]`, so the generated contract described a node's configuration as a free-form object: fifteen fields and every one of their bounds were absent, where the hand-written 1.1.1 published them. The bounds were still enforced, only undiscoverable, so a client generated from the published file sent an unvalidated blob and learned the rules from 400s. The field stays untyped. A Pydantic model there would answer 422 ahead of the handler, putting a config-shaped refusal in front of identity resolution and making the difference between it and a 403 an oracle for which node identities exist. WithJsonSchema replaces what is published and leaves validation alone, so the shape reaches the document while the refusal stays behind identity resolution, and PUT /v1/nodes/config carries the same object in its openapi_extra. That also retires its cross-reference to a rationale which only ever existed in a code comment. Built from _NUMERIC_BOUNDS and _REQUIRED rather than written beside them, so twelve of the fifteen cannot drift from the checks that enforce them. The callsign and the two beam fields are checked against their own literals, so those are pinned against validate_config at the boundary itself, with nextafter either side of every published bound. NODE_API_VERSION stays at 1.1.3. The server accepts and refuses exactly what it did before this commit, so there is no change for a version to describe. The cost is recorded beside the constant: two documents now carry 1.1.3, the later a superset of the earlier, and a client cannot tell from the version alone which of the two it was generated from. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 7 +- ONBOARDING.md | 7 +- backend/routes/node_config.py | 16 +- backend/routes/node_schemas.py | 9 +- backend/routes/nodes.py | 16 +- backend/services/node_config.py | 85 +++++++- backend/tests/test_node_config_validation.py | 75 ++++++- backend/tests/test_node_openapi.py | 82 +++++++ contracts/nodes-v1.openapi.yaml | 214 ++++++++++++++++++- 9 files changed, 479 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1e20a896..22570b0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/ONBOARDING.md b/ONBOARDING.md index 91302e89..78f38bef 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -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 diff --git a/backend/routes/node_config.py b/backend/routes/node_config.py index 81e79a30..198894d2 100644 --- a/backend/routes/node_config.py +++ b/backend/routes/node_config.py @@ -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 @@ -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 schema is the same object registration's + # `config` publishes, built from the validator's own tables, so the two + # cannot state different bounds for one body. "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()}}, }, }, ) diff --git a/backend/routes/node_schemas.py b/backend/routes/node_schemas.py index 519895f9..5d721a23 100644 --- a/backend/routes/node_schemas.py +++ b/backend/routes/node_schemas.py @@ -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 @@ -153,7 +155,12 @@ class RegisterRequest(_RequestModel): # 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] + # + # Described without being enforced: WithJsonSchema replaces what is published + # and leaves validation alone, so the shape reaches a client generating from + # the contract while the refusal stays behind identity resolution. Anything + # this schema forbids still reaches the handler and is refused there. + config: Annotated[dict[str, Any], WithJsonSchema(config_json_schema())] class RegisterResponse(BaseModel): diff --git a/backend/routes/nodes.py b/backend/routes/nodes.py index a2deaf6a..a697af27 100644 --- a/backend/routes/nodes.py +++ b/backend/routes/nodes.py @@ -42,13 +42,17 @@ # 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, and only the description +# changed. Two documents therefore carry this version, the later a superset of +# the earlier, so a client pinned to 1.1.3 may or may not have the fifteen +# configuration fields and cannot tell which it holds from the version alone. +# Published without being enforced: see routes/node_schemas.py, RegisterRequest. NODE_API_VERSION = "1.1.3" # No tag here: each sub-router carries the contract's own grouping, since those diff --git a/backend/services/node_config.py b/backend/services/node_config.py index 2147245d..77402185 100644 --- a/backend/services/node_config.py +++ b/backend/services/node_config.py @@ -1,11 +1,14 @@ """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 @@ -58,6 +61,78 @@ def __init__(self, field: str, reason: str = "out of range") -> None: _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}, +} + +# 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]: + schema: dict[str, Any] = {"type": "number"} + schema["minimum" if low_inclusive else "exclusiveMinimum"] = float(low) + # The two tolerances have no ceiling, which JSON Schema says by omission. + # math.inf is not a bound, and publishing it would state a limit the server + # does not have. + if not math.isinf(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. The two operations that take a configuration publish it inline + rather than as a shared component: Pydantic resolves every `$ref` it emits + against its own definitions, and this schema is not one of its models. + + Publishing it does not enforce it. Registration's body stays untyped so + that no config-shaped refusal can reach the wire ahead of identity + resolution, which is the whole reason its refusals share one body. + """ + tabled = {field: _numeric_property(*bounds) for field, bounds in _NUMERIC_BOUNDS.items()} + 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 (tabled | _UNTABLED_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 _as_finite_float(value: Any) -> tuple[float | None, str]: """The value as a finite float, with the reason when it cannot be one. diff --git a/backend/tests/test_node_config_validation.py b/backend/tests/test_node_config_validation.py index 77b14e23..022ac626 100644 --- a/backend/tests/test_node_config_validation.py +++ b/backend/tests/test_node_config_validation.py @@ -2,7 +2,13 @@ import pytest -from services.node_config import ConfigInvalid, canonical_config, position_status, validate_config +from services.node_config import ( + ConfigInvalid, + canonical_config, + config_json_schema, + position_status, + validate_config, +) VALID = { "rx_lat": 51.42, @@ -645,3 +651,70 @@ def test_canonicalising_twice_changes_nothing(): raw = {"lat": "51.42", "lon": "-0.91", "tx_lat": 0.0, "tx_lon": 0.0, "rx_alt_ft": None} once = canonical_config(raw) assert canonical_config(once) == once + + +# ── the published schema against the checks it describes ───────────────────── +# +# config_json_schema builds twelve of the fifteen from _NUMERIC_BOUNDS, which +# the loop in validate_config reads too, so those cannot drift. The callsign and +# the two beam fields are written out beside checks that read their own +# literals, and those can. Both halves are pinned the same way here, against the +# boundary rather than against the table: what a client generating from the +# contract is entitled to rely on is that a value the document permits is one +# this end accepts. + + +def _numeric_branch(published: dict) -> dict: + """The number branch of a published property, nullable or not.""" + return published["anyOf"][0] if "anyOf" in published else published + + +@pytest.mark.parametrize("field", NUMERIC_FIELDS) +def test_every_published_bound_is_where_the_validator_refuses(field): + schema = _numeric_branch(config_json_schema()["properties"][field]) + + for keyword, outward in (("minimum", -math.inf), ("maximum", math.inf)): + if keyword in schema: + # Inclusive: the bound itself passes and the very next float out + # does not. nextafter rather than an epsilon, so the assertion sits + # on the bound rather than near it. + validate_config(dict(VALID, **{field: schema[keyword]})) + with pytest.raises(ConfigInvalid) as refusal: + validate_config(dict(VALID, **{field: math.nextafter(schema[keyword], outward)})) + assert refusal.value.field == field + + for keyword, inward in (("exclusiveMinimum", math.inf), ("exclusiveMaximum", -math.inf)): + if keyword in schema: + # Exclusive: the bound itself is refused, and the first float on the + # permitted side of it is not. + with pytest.raises(ConfigInvalid) as refusal: + validate_config(dict(VALID, **{field: schema[keyword]})) + assert refusal.value.field == field + validate_config(dict(VALID, **{field: math.nextafter(schema[keyword], inward)})) + + +def test_the_published_callsign_length_is_where_the_validator_refuses(): + schema = config_json_schema()["properties"]["tx_callsign"] + + for length in (schema["minLength"], schema["maxLength"]): + validate_config(dict(VALID, tx_callsign="x" * length)) + + for length in (schema["minLength"] - 1, schema["maxLength"] + 1): + with pytest.raises(ConfigInvalid) as refusal: + validate_config(dict(VALID, tx_callsign="x" * length)) + assert refusal.value.field == "tx_callsign" + + +def test_a_null_is_accepted_for_exactly_the_fields_published_as_nullable(): + published = config_json_schema()["properties"] + nullable = {field for field, schema in published.items() if {"type": "null"} in schema.get("anyOf", [])} + + # All six coordinates at once: a latitude without its longitude is refused, + # so the pairs cannot be exercised one field at a time. + accepted = validate_config(dict(VALID, **dict.fromkeys(nullable))) + assert {field for field in nullable if accepted[field] is None} == nullable + + for field in set(published) - nullable: + with pytest.raises(ConfigInvalid) as refusal: + validate_config(dict(VALID, **{field: None})) + assert refusal.value.field == field diff --git a/backend/tests/test_node_openapi.py b/backend/tests/test_node_openapi.py index 377aabac..08c317b7 100644 --- a/backend/tests/test_node_openapi.py +++ b/backend/tests/test_node_openapi.py @@ -11,11 +11,14 @@ finding it in CI is a round trip. """ +import math + import pytest import yaml from routes.nodes import NODE_API_SERVERS, NODE_API_VERSION from scripts.generate_openapi import CONTRACT_PATH, contract, render +from services.node_config import _NULLABLE, _NULLABLE_BEAM, _NUMERIC_BOUNDS, _REQUIRED FRAME = { "t": 1753900000.123, @@ -177,6 +180,85 @@ def test_the_timestamps_are_still_typed_as_datetimes(document): assert schemas[model]["properties"]["server_time"]["format"] == "date-time" +# ── the configuration schema ───────────────────────────────────────────────── +# +# Built from the validator's own tables rather than written beside them. What +# wants testing here is the document: that both +# operations describe one object, and that every field and bound the server +# enforces reaches it. Where those bounds actually sit is pinned against +# validate_config itself in tests/test_node_config_validation.py, which is the +# half that catches a bound moving. + + +def _published_configs(document): + """The configuration schema as each of the two operations publishes it. + + Inline in both, rather than one component the two reference: Pydantic + resolves every `$ref` it emits against its own definitions, and this schema + is not one of its models. + """ + yield ( + "POST /v1/nodes/register", + document["components"]["schemas"]["RegisterRequest"]["properties"]["config"], + ) + yield ( + "PUT /v1/nodes/config", + document["paths"]["/v1/nodes/config"]["put"]["requestBody"]["content"]["application/json"]["schema"], + ) + + +def test_the_two_operations_publish_one_configuration_schema(document): + """It is the same object on the wire. A client that generated two + incompatible types from these has been told something untrue, and the PUT + is the one a node reaches for after a `config_stale`.""" + published = dict(_published_configs(document)) + + assert published["POST /v1/nodes/register"] == published["PUT /v1/nodes/config"] + + +def test_every_field_the_validator_requires_is_published_and_required(document): + for name, schema in _published_configs(document): + assert set(schema["properties"]) == _REQUIRED, name + assert set(schema["required"]) == _REQUIRED, name + # The validator names an unknown key back to the caller rather than + # ignoring it, so a document permitting one would describe a different + # server. + assert schema["additionalProperties"] is False, name + + +def test_every_bound_the_validator_enforces_reaches_the_schema(document): + """Inclusive bounds as `minimum`/`maximum` and exclusive ones as their + `exclusive*` counterparts. A client checks a value against these before + sending, so an inclusivity published the wrong way round rejects a config + the server accepts.""" + for name, schema in _published_configs(document): + for field, (low, high, low_inclusive, high_inclusive) in _NUMERIC_BOUNDS.items(): + published = schema["properties"][field] + numeric = published["anyOf"][0] if "anyOf" in published else published + + assert numeric["minimum" if low_inclusive else "exclusiveMinimum"] == low, f"{name} {field}" + if math.isinf(high): + # The two tolerances have no ceiling, which JSON Schema says by + # omission. A published `.inf` would state a limit that is not + # there and that no client could act on. + assert "maximum" not in numeric and "exclusiveMaximum" not in numeric, f"{name} {field}" + else: + assert numeric["maximum" if high_inclusive else "exclusiveMaximum"] == high, f"{name} {field}" + + +def test_the_nullable_fields_publish_a_null_branch(document): + """The six coordinates, so a node whose owner cannot supply the geometry + still registers, and the two beam fields, which no node has characterised. + A client generated from a document that omitted these cannot express the + config the fleet actually sends.""" + for name, schema in _published_configs(document): + nullable = { + field for field, published in schema["properties"].items() if {"type": "null"} in published.get("anyOf", []) + } + + assert nullable == _NULLABLE | _NULLABLE_BEAM, name + + # ── the credential ─────────────────────────────────────────────────────────── diff --git a/contracts/nodes-v1.openapi.yaml b/contracts/nodes-v1.openapi.yaml index d83fc7c3..5f89fae6 100644 --- a/contracts/nodes-v1.openapi.yaml +++ b/contracts/nodes-v1.openapi.yaml @@ -195,12 +195,115 @@ paths: operationId: putConfig requestBody: 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: - additionalProperties: true + properties: + rx_lat: + anyOf: + - type: number + maximum: 90.0 + minimum: -90.0 + - type: 'null' + rx_lon: + anyOf: + - type: number + maximum: 180.0 + minimum: -180.0 + - type: 'null' + rx_alt_ft: + anyOf: + - type: number + maximum: 30000.0 + minimum: -1500.0 + - type: 'null' + tx_lat: + anyOf: + - type: number + maximum: 90.0 + minimum: -90.0 + - type: 'null' + tx_lon: + anyOf: + - type: number + maximum: 180.0 + minimum: -180.0 + - type: 'null' + tx_alt_ft: + anyOf: + - type: number + maximum: 30000.0 + minimum: -1500.0 + - type: 'null' + fc_hz: + type: number + maximum: 6000000000.0 + minimum: 1000000.0 + fs_hz: + type: number + maximum: 20000000.0 + minimum: 100000.0 + max_range_km: + type: number + maximum: 1000.0 + exclusiveMinimum: 0.0 + cpi_s: + type: number + maximum: 10.0 + exclusiveMinimum: 0.0 + delay_tolerance_us: + type: number + exclusiveMinimum: 0.0 + doppler_tolerance_hz: + type: number + exclusiveMinimum: 0.0 + tx_callsign: + type: string + maxLength: 32 + minLength: 1 + beam_width_deg: + anyOf: + - type: number + maximum: 360.0 + exclusiveMinimum: 0.0 + - type: 'null' + beam_azimuth_deg: + anyOf: + - type: number + exclusiveMaximum: 360.0 + minimum: 0.0 + - type: 'null' + additionalProperties: false type: object + required: + - beam_azimuth_deg + - beam_width_deg + - cpi_s + - delay_tolerance_us + - doppler_tolerance_hz + - fc_hz + - fs_hz + - max_range_km + - rx_alt_ft + - rx_lat + - rx_lon + - tx_alt_ft + - tx_callsign + - tx_lat + - tx_lon + title: NodeConfig + 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. required: true responses: '200': @@ -822,9 +925,112 @@ components: agreements: $ref: '#/components/schemas/Agreements' config: - additionalProperties: true + properties: + rx_lat: + anyOf: + - type: number + maximum: 90.0 + minimum: -90.0 + - type: 'null' + rx_lon: + anyOf: + - type: number + maximum: 180.0 + minimum: -180.0 + - type: 'null' + rx_alt_ft: + anyOf: + - type: number + maximum: 30000.0 + minimum: -1500.0 + - type: 'null' + tx_lat: + anyOf: + - type: number + maximum: 90.0 + minimum: -90.0 + - type: 'null' + tx_lon: + anyOf: + - type: number + maximum: 180.0 + minimum: -180.0 + - type: 'null' + tx_alt_ft: + anyOf: + - type: number + maximum: 30000.0 + minimum: -1500.0 + - type: 'null' + fc_hz: + type: number + maximum: 6000000000.0 + minimum: 1000000.0 + fs_hz: + type: number + maximum: 20000000.0 + minimum: 100000.0 + max_range_km: + type: number + maximum: 1000.0 + exclusiveMinimum: 0.0 + cpi_s: + type: number + maximum: 10.0 + exclusiveMinimum: 0.0 + delay_tolerance_us: + type: number + exclusiveMinimum: 0.0 + doppler_tolerance_hz: + type: number + exclusiveMinimum: 0.0 + tx_callsign: + type: string + maxLength: 32 + minLength: 1 + beam_width_deg: + anyOf: + - type: number + maximum: 360.0 + exclusiveMinimum: 0.0 + - type: 'null' + beam_azimuth_deg: + anyOf: + - type: number + exclusiveMaximum: 360.0 + minimum: 0.0 + - type: 'null' + additionalProperties: false type: object - title: Config + required: + - beam_azimuth_deg + - beam_width_deg + - cpi_s + - delay_tolerance_us + - doppler_tolerance_hz + - fc_hz + - fs_hz + - max_range_km + - rx_alt_ft + - rx_lat + - rx_lon + - tx_alt_ft + - tx_callsign + - tx_lat + - tx_lon + title: NodeConfig + 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. additionalProperties: false type: object required: From 08bec6944748a02f65bcc0050c2eb15146eed0f0 Mon Sep 17 00:00:00 2001 From: Babissimo Date: Wed, 9 Sep 2026 12:45:52 +0100 Subject: [PATCH 2/2] Address review of the published configuration schema Ten findings from review of the previous commit. The two that were defects rather than tidying: config_json_schema returned the _UNTABLED_PROPERTIES dicts by reference, so three of the fifteen properties were the module constant itself, shared by every call, while the twelve built by _numeric_property were fresh. Both callers hand their copy to a framework that mutates schema dicts in place, so one operation's published bounds could have rewritten the other's. Deep-copied on the way out, and the asymmetry that hid it is gone. The version note claimed the second 1.1.3 document was a superset of the first. It is not: `config` went from an object with no required keys and additionalProperties true to fifteen required fields with unknown keys forbidden, and its generated type from Config to NodeConfig. The document narrows, to what this server has always enforced, and renames. That is the honest statement of what carrying one version over two documents costs, and it is what the note says now. The rest: - The schema is hoisted into one NodeConfig component by generate_openapi, at the layer that already shapes the document, rather than inlined twice. Pydantic still cannot emit the $ref, but the generator can, and a substitution that fails by doing nothing is pinned by a test asserting both bodies are the reference. 103 lines of duplication leave the contract. - _numeric_property guards both ends against a non-finite bound, not just the high one. An unbounded low would have published a literal no JSON parser reads. - _REQUIRED derives from the two tables instead of restating three field names. - The boundary test parametrises off the published schema rather than a hand-written list, so a field added to either table is covered by the time it reaches the document. - numeric_branch is defined once, beside the builder whose output it reads, and selects the number branch by type rather than by position. - The untyped-config rationale is cited from routes/node_register.py rather than restated in three places. - A docstring paragraph left ragged by the previous commit is rewrapped. Co-Authored-By: Claude Opus 5 --- backend/routes/node_config.py | 6 +- backend/routes/node_schemas.py | 16 +- backend/routes/nodes.py | 16 +- backend/scripts/generate_openapi.py | 47 ++- backend/services/node_config.py | 59 ++-- backend/tests/test_node_config_validation.py | 16 +- backend/tests/test_node_openapi.py | 102 +++--- contracts/nodes-v1.openapi.yaml | 321 +++++++------------ 8 files changed, 276 insertions(+), 307 deletions(-) diff --git a/backend/routes/node_config.py b/backend/routes/node_config.py index 198894d2..1c640016 100644 --- a/backend/routes/node_config.py +++ b/backend/routes/node_config.py @@ -83,9 +83,9 @@ 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. The schema is the same object registration's - # `config` publishes, built from the validator's own tables, so the two - # cannot state different bounds for one body. + # 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`.", diff --git a/backend/routes/node_schemas.py b/backend/routes/node_schemas.py index 5d721a23..62d7a7f6 100644 --- a/backend/routes/node_schemas.py +++ b/backend/routes/node_schemas.py @@ -150,16 +150,14 @@ 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. + # 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. # - # Described without being enforced: WithJsonSchema replaces what is published - # and leaves validation alone, so the shape reaches a client generating from - # the contract while the refusal stays behind identity resolution. Anything - # this schema forbids still reaches the handler and is refused there. + # 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())] diff --git a/backend/routes/nodes.py b/backend/routes/nodes.py index a697af27..d1c4dc4d 100644 --- a/backend/routes/nodes.py +++ b/backend/routes/nodes.py @@ -48,11 +48,17 @@ # # 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, and only the description -# changed. Two documents therefore carry this version, the later a superset of -# the earlier, so a client pinned to 1.1.3 may or may not have the fifteen -# configuration fields and cannot tell which it holds from the version alone. -# Published without being enforced: see routes/node_schemas.py, RegisterRequest. +# 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 diff --git a/backend/scripts/generate_openapi.py b/backend/scripts/generate_openapi.py index 878d5505..d32e7382 100644 --- a/backend/scripts/generate_openapi.py +++ b/backend/scripts/generate_openapi.py @@ -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 @@ -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" @@ -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. @@ -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. @@ -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", {}) diff --git a/backend/services/node_config.py b/backend/services/node_config.py index 77402185..b0c2d2be 100644 --- a/backend/services/node_config.py +++ b/backend/services/node_config.py @@ -4,11 +4,10 @@ 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. +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 @@ -16,6 +15,7 @@ """ 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 @@ -59,8 +59,6 @@ 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 @@ -73,6 +71,11 @@ def __init__(self, field: str, reason: str = "out of range") -> None: "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"} @@ -92,12 +95,14 @@ def __init__(self, field: str, reason: str = "out of range") -> None: 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"} - schema["minimum" if low_inclusive else "exclusiveMinimum"] = float(low) - # The two tolerances have no ceiling, which JSON Schema says by omission. - # math.inf is not a bound, and publishing it would state a limit the server - # does not have. - if not math.isinf(high): + 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 @@ -107,15 +112,15 @@ def config_json_schema() -> dict[str, Any]: 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. The two operations that take a configuration publish it inline - rather than as a shared component: Pydantic resolves every `$ref` it emits - against its own definitions, and this schema is not one of its models. + not apply. Describing the shape is not enforcing it; registration's body + stays untyped, for the reason routes/node_register.py gives. - Publishing it does not enforce it. Registration's body stays untyped so - that no config-shaped refusal can reach the wire ahead of identity - resolution, which is the whole reason its refusals share one body. + 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. """ - tabled = {field: _numeric_property(*bounds) for field, bounds in _NUMERIC_BOUNDS.items()} + properties = {field: _numeric_property(*bounds) for field, bounds in _NUMERIC_BOUNDS.items()} + properties |= deepcopy(_UNTABLED_PROPERTIES) nullable = _NULLABLE | _NULLABLE_BEAM return { "type": "object", @@ -123,7 +128,7 @@ def config_json_schema() -> dict[str, Any]: "description": _SCHEMA_DESCRIPTION, "properties": { field: {"anyOf": [schema, {"type": "null"}]} if field in nullable else schema - for field, schema in (tabled | _UNTABLED_PROPERTIES).items() + 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. @@ -134,6 +139,20 @@ def config_json_schema() -> dict[str, Any]: } +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]: """The value as a finite float, with the reason when it cannot be one. diff --git a/backend/tests/test_node_config_validation.py b/backend/tests/test_node_config_validation.py index 022ac626..7a21a9d1 100644 --- a/backend/tests/test_node_config_validation.py +++ b/backend/tests/test_node_config_validation.py @@ -6,6 +6,7 @@ ConfigInvalid, canonical_config, config_json_schema, + numeric_branch, position_status, validate_config, ) @@ -664,14 +665,19 @@ def test_canonicalising_twice_changes_nothing(): # this end accepts. -def _numeric_branch(published: dict) -> dict: - """The number branch of a published property, nullable or not.""" - return published["anyOf"][0] if "anyOf" in published else published +# Taken from the published schema rather than from NUMERIC_FIELDS above, so a +# field added to either table is covered by the time it reaches the document. +# A hand-written list would leave a new bound published, enforced, and unchecked. +PUBLISHED_NUMERIC_FIELDS = sorted( + field + for field, published in config_json_schema()["properties"].items() + if numeric_branch(published).get("type") == "number" +) -@pytest.mark.parametrize("field", NUMERIC_FIELDS) +@pytest.mark.parametrize("field", PUBLISHED_NUMERIC_FIELDS) def test_every_published_bound_is_where_the_validator_refuses(field): - schema = _numeric_branch(config_json_schema()["properties"][field]) + schema = numeric_branch(config_json_schema()["properties"][field]) for keyword, outward in (("minimum", -math.inf), ("maximum", math.inf)): if keyword in schema: diff --git a/backend/tests/test_node_openapi.py b/backend/tests/test_node_openapi.py index 08c317b7..c483470d 100644 --- a/backend/tests/test_node_openapi.py +++ b/backend/tests/test_node_openapi.py @@ -18,7 +18,7 @@ from routes.nodes import NODE_API_SERVERS, NODE_API_VERSION from scripts.generate_openapi import CONTRACT_PATH, contract, render -from services.node_config import _NULLABLE, _NULLABLE_BEAM, _NUMERIC_BOUNDS, _REQUIRED +from services.node_config import _NULLABLE, _NULLABLE_BEAM, _NUMERIC_BOUNDS, _REQUIRED, numeric_branch FRAME = { "t": 1753900000.123, @@ -183,47 +183,51 @@ def test_the_timestamps_are_still_typed_as_datetimes(document): # ── the configuration schema ───────────────────────────────────────────────── # # Built from the validator's own tables rather than written beside them. What -# wants testing here is the document: that both -# operations describe one object, and that every field and bound the server -# enforces reaches it. Where those bounds actually sit is pinned against -# validate_config itself in tests/test_node_config_validation.py, which is the -# half that catches a bound moving. +# wants testing here is the document: that both operations reach one object, and +# that every field and bound the server enforces reaches it. Where those bounds +# actually sit is pinned against validate_config itself in +# tests/test_node_config_validation.py, which is the half that catches a bound +# moving. + +CONFIG_REF = {"$ref": "#/components/schemas/NodeConfig"} + +# The two places a configuration enters the API. +CONFIG_BODIES = { + "POST /v1/nodes/register": lambda document: document["components"]["schemas"]["RegisterRequest"]["properties"][ + "config" + ], + "PUT /v1/nodes/config": lambda document: document["paths"]["/v1/nodes/config"]["put"]["requestBody"]["content"][ + "application/json" + ]["schema"], +} -def _published_configs(document): - """The configuration schema as each of the two operations publishes it. +def _published_config(document): + return document["components"]["schemas"]["NodeConfig"] - Inline in both, rather than one component the two reference: Pydantic - resolves every `$ref` it emits against its own definitions, and this schema - is not one of its models. - """ - yield ( - "POST /v1/nodes/register", - document["components"]["schemas"]["RegisterRequest"]["properties"]["config"], - ) - yield ( - "PUT /v1/nodes/config", - document["paths"]["/v1/nodes/config"]["put"]["requestBody"]["content"]["application/json"]["schema"], - ) +def test_both_operations_reach_one_configuration_component(document): + """It is the same object on the wire, so a client that generated two + incompatible types from it has been told something untrue. -def test_the_two_operations_publish_one_configuration_schema(document): - """It is the same object on the wire. A client that generated two - incompatible types from these has been told something untrue, and the PUT - is the one a node reaches for after a `config_stale`.""" - published = dict(_published_configs(document)) + Neither operation can emit this `$ref` itself, so the generator makes it. + That is exactly the sort of substitution that fails by doing nothing, which + is what this catches: both bodies are the reference and nothing else. + """ + for name, body in CONFIG_BODIES.items(): + assert body(document) == CONFIG_REF, name - assert published["POST /v1/nodes/register"] == published["PUT /v1/nodes/config"] + assert _published_config(document)["title"] == "NodeConfig" def test_every_field_the_validator_requires_is_published_and_required(document): - for name, schema in _published_configs(document): - assert set(schema["properties"]) == _REQUIRED, name - assert set(schema["required"]) == _REQUIRED, name - # The validator names an unknown key back to the caller rather than - # ignoring it, so a document permitting one would describe a different - # server. - assert schema["additionalProperties"] is False, name + schema = _published_config(document) + + assert set(schema["properties"]) == _REQUIRED + assert set(schema["required"]) == _REQUIRED + # The validator names an unknown key back to the caller rather than ignoring + # it, so a document permitting one would describe a different server. + assert schema["additionalProperties"] is False def test_every_bound_the_validator_enforces_reaches_the_schema(document): @@ -231,19 +235,19 @@ def test_every_bound_the_validator_enforces_reaches_the_schema(document): `exclusive*` counterparts. A client checks a value against these before sending, so an inclusivity published the wrong way round rejects a config the server accepts.""" - for name, schema in _published_configs(document): - for field, (low, high, low_inclusive, high_inclusive) in _NUMERIC_BOUNDS.items(): - published = schema["properties"][field] - numeric = published["anyOf"][0] if "anyOf" in published else published + properties = _published_config(document)["properties"] + + for field, (low, high, low_inclusive, high_inclusive) in _NUMERIC_BOUNDS.items(): + numeric = numeric_branch(properties[field]) - assert numeric["minimum" if low_inclusive else "exclusiveMinimum"] == low, f"{name} {field}" - if math.isinf(high): - # The two tolerances have no ceiling, which JSON Schema says by - # omission. A published `.inf` would state a limit that is not - # there and that no client could act on. - assert "maximum" not in numeric and "exclusiveMaximum" not in numeric, f"{name} {field}" - else: - assert numeric["maximum" if high_inclusive else "exclusiveMaximum"] == high, f"{name} {field}" + assert numeric["minimum" if low_inclusive else "exclusiveMinimum"] == low, field + if math.isinf(high): + # The two tolerances have no ceiling, which JSON Schema says by + # omission. A published `.inf` would state a limit that is not + # there and that no client could act on. + assert "maximum" not in numeric and "exclusiveMaximum" not in numeric, field + else: + assert numeric["maximum" if high_inclusive else "exclusiveMaximum"] == high, field def test_the_nullable_fields_publish_a_null_branch(document): @@ -251,12 +255,10 @@ def test_the_nullable_fields_publish_a_null_branch(document): still registers, and the two beam fields, which no node has characterised. A client generated from a document that omitted these cannot express the config the fleet actually sends.""" - for name, schema in _published_configs(document): - nullable = { - field for field, published in schema["properties"].items() if {"type": "null"} in published.get("anyOf", []) - } + properties = _published_config(document)["properties"] + nullable = {field for field, published in properties.items() if {"type": "null"} in published.get("anyOf", [])} - assert nullable == _NULLABLE | _NULLABLE_BEAM, name + assert nullable == _NULLABLE | _NULLABLE_BEAM # ── the credential ─────────────────────────────────────────────────────────── diff --git a/contracts/nodes-v1.openapi.yaml b/contracts/nodes-v1.openapi.yaml index 5f89fae6..c9551700 100644 --- a/contracts/nodes-v1.openapi.yaml +++ b/contracts/nodes-v1.openapi.yaml @@ -198,112 +198,7 @@ paths: content: application/json: schema: - properties: - rx_lat: - anyOf: - - type: number - maximum: 90.0 - minimum: -90.0 - - type: 'null' - rx_lon: - anyOf: - - type: number - maximum: 180.0 - minimum: -180.0 - - type: 'null' - rx_alt_ft: - anyOf: - - type: number - maximum: 30000.0 - minimum: -1500.0 - - type: 'null' - tx_lat: - anyOf: - - type: number - maximum: 90.0 - minimum: -90.0 - - type: 'null' - tx_lon: - anyOf: - - type: number - maximum: 180.0 - minimum: -180.0 - - type: 'null' - tx_alt_ft: - anyOf: - - type: number - maximum: 30000.0 - minimum: -1500.0 - - type: 'null' - fc_hz: - type: number - maximum: 6000000000.0 - minimum: 1000000.0 - fs_hz: - type: number - maximum: 20000000.0 - minimum: 100000.0 - max_range_km: - type: number - maximum: 1000.0 - exclusiveMinimum: 0.0 - cpi_s: - type: number - maximum: 10.0 - exclusiveMinimum: 0.0 - delay_tolerance_us: - type: number - exclusiveMinimum: 0.0 - doppler_tolerance_hz: - type: number - exclusiveMinimum: 0.0 - tx_callsign: - type: string - maxLength: 32 - minLength: 1 - beam_width_deg: - anyOf: - - type: number - maximum: 360.0 - exclusiveMinimum: 0.0 - - type: 'null' - beam_azimuth_deg: - anyOf: - - type: number - exclusiveMaximum: 360.0 - minimum: 0.0 - - type: 'null' - additionalProperties: false - type: object - required: - - beam_azimuth_deg - - beam_width_deg - - cpi_s - - delay_tolerance_us - - doppler_tolerance_hz - - fc_hz - - fs_hz - - max_range_km - - rx_alt_ft - - rx_lat - - rx_lon - - tx_alt_ft - - tx_callsign - - tx_lat - - tx_lon - title: NodeConfig - 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. + $ref: '#/components/schemas/NodeConfig' required: true responses: '200': @@ -807,6 +702,113 @@ components: - streaming_allowed - node_ref title: HeartbeatResponse + NodeConfig: + type: object + title: NodeConfig + 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. + properties: + rx_lat: + anyOf: + - type: number + minimum: -90.0 + maximum: 90.0 + - type: 'null' + rx_lon: + anyOf: + - type: number + minimum: -180.0 + maximum: 180.0 + - type: 'null' + rx_alt_ft: + anyOf: + - type: number + minimum: -1500.0 + maximum: 30000.0 + - type: 'null' + tx_lat: + anyOf: + - type: number + minimum: -90.0 + maximum: 90.0 + - type: 'null' + tx_lon: + anyOf: + - type: number + minimum: -180.0 + maximum: 180.0 + - type: 'null' + tx_alt_ft: + anyOf: + - type: number + minimum: -1500.0 + maximum: 30000.0 + - type: 'null' + fc_hz: + type: number + minimum: 1000000.0 + maximum: 6000000000.0 + fs_hz: + type: number + minimum: 100000.0 + maximum: 20000000.0 + max_range_km: + type: number + exclusiveMinimum: 0.0 + maximum: 1000.0 + cpi_s: + type: number + exclusiveMinimum: 0.0 + maximum: 10.0 + delay_tolerance_us: + type: number + exclusiveMinimum: 0.0 + doppler_tolerance_hz: + type: number + exclusiveMinimum: 0.0 + tx_callsign: + type: string + minLength: 1 + maxLength: 32 + beam_width_deg: + anyOf: + - type: number + exclusiveMinimum: 0.0 + maximum: 360.0 + - type: 'null' + beam_azimuth_deg: + anyOf: + - type: number + minimum: 0.0 + exclusiveMaximum: 360.0 + - type: 'null' + required: + - beam_azimuth_deg + - beam_width_deg + - cpi_s + - delay_tolerance_us + - doppler_tolerance_hz + - fc_hz + - fs_hz + - max_range_km + - rx_alt_ft + - rx_lat + - rx_lon + - tx_alt_ft + - tx_callsign + - tx_lat + - tx_lon + additionalProperties: false NodeHealth: properties: cpu_pct: @@ -925,112 +927,7 @@ components: agreements: $ref: '#/components/schemas/Agreements' config: - properties: - rx_lat: - anyOf: - - type: number - maximum: 90.0 - minimum: -90.0 - - type: 'null' - rx_lon: - anyOf: - - type: number - maximum: 180.0 - minimum: -180.0 - - type: 'null' - rx_alt_ft: - anyOf: - - type: number - maximum: 30000.0 - minimum: -1500.0 - - type: 'null' - tx_lat: - anyOf: - - type: number - maximum: 90.0 - minimum: -90.0 - - type: 'null' - tx_lon: - anyOf: - - type: number - maximum: 180.0 - minimum: -180.0 - - type: 'null' - tx_alt_ft: - anyOf: - - type: number - maximum: 30000.0 - minimum: -1500.0 - - type: 'null' - fc_hz: - type: number - maximum: 6000000000.0 - minimum: 1000000.0 - fs_hz: - type: number - maximum: 20000000.0 - minimum: 100000.0 - max_range_km: - type: number - maximum: 1000.0 - exclusiveMinimum: 0.0 - cpi_s: - type: number - maximum: 10.0 - exclusiveMinimum: 0.0 - delay_tolerance_us: - type: number - exclusiveMinimum: 0.0 - doppler_tolerance_hz: - type: number - exclusiveMinimum: 0.0 - tx_callsign: - type: string - maxLength: 32 - minLength: 1 - beam_width_deg: - anyOf: - - type: number - maximum: 360.0 - exclusiveMinimum: 0.0 - - type: 'null' - beam_azimuth_deg: - anyOf: - - type: number - exclusiveMaximum: 360.0 - minimum: 0.0 - - type: 'null' - additionalProperties: false - type: object - required: - - beam_azimuth_deg - - beam_width_deg - - cpi_s - - delay_tolerance_us - - doppler_tolerance_hz - - fc_hz - - fs_hz - - max_range_km - - rx_alt_ft - - rx_lat - - rx_lon - - tx_alt_ft - - tx_callsign - - tx_lat - - tx_lon - title: NodeConfig - 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. + $ref: '#/components/schemas/NodeConfig' additionalProperties: false type: object required: