Skip to content

Derive the OpenAPI document from the code instead of declaring it alongside - #591

Draft
bburda wants to merge 17 commits into
mainfrom
feat/openapi-derivation
Draft

Derive the OpenAPI document from the code instead of declaring it alongside#591
bburda wants to merge 17 commits into
mainfrom
feat/openapi-derivation

Conversation

@bburda

@bburda bburda commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Pull Request

Summary

The generated OpenAPI document asserted a great deal the gateway does not honour, and omitted a great deal it does. The common cause was that facts about a route were declared separately from the handler implementing them, so the two drifted with nothing checking.

This derives what can be derived, and where it cannot be derived, declares it once at a seam that also does the work.

The governing rule, now stated in design/openapi_derivation.rst: if a fact about a route can be derived from the handler, it must not be declared separately; where it cannot, the declaration lives at a seam that also does the work.

What now derives itself

  • The success status comes from the handler's return type (Created<T>, Accepted<T>, status_payload_t) rather than a hand-attached number.
  • Feature gates declare the status they return, so a gated route publishes its 501.
  • The typed route registry is the single source for paths, parameters, response headers and error sets.
  • The RBAC permission table is generated from the route registrations. Enforcement did not move - check_authorization still fails closed where it always did, because docs, Swagger UI and plugin routes are registered outside the registry and would lose their rule.
  • The <entity-path>/docs sub-documents are projected from the real document instead of rebuilt by hand.
  • Per-operation security is published only where the deployment actually checks it, keyed on the auth policy rather than on auth.enabled.

Measured against a running gateway

before after
Operations declaring a 2xx they cannot return 45 0
Routes that answer 501 without declaring it 28 0
Schemas unreachable from any operation 15 0
Operations publishing a body with no content 9 0
format: binary (invalid in OpenAPI 3.1) 12 0
Operations with no description 12 0
Error codes documented 21 43 of 46
Dangling $ref in /docs sub-documents 12 0

Verification probed all 239 documented operations against live gateways in five configurations - feature gates on and off, auth off and all three require_auth_for values, plugin loaded and not - and found no status the gateway emits that the document does not declare.

Defects found and fixed along the way

These were not documentation problems:

  • A downstream validation refusal was reported to clients as 503, because the classification matched a substring of the error message. Replaced with a two-state outcome set at the transport's no-response paths, defaulting to the client-error side so a server error must be asked for.
  • fault_code was published with a maximum of 256 while the fault manager enforced 128; values in between produced a server error. Bounds reconciled.
  • Raising that bound exposed silent data loss: a long fault code pushed the rosbag directory name past NAME_MAX, the exception was caught and only logged, and the bag was never written. The name is now bounded and carries a digest so truncated codes stay distinguishable.
  • Any 401/403/429 answered before the request body was read stranded the payload and poisoned the keep-alive connection, so the next request on it received a spurious 400.
  • DELETE /{entity}/configurations/{config_id} did not enforce the length bound its siblings enforce and the document published.
  • The /docs sub-documents published a request body the handler rejects, and a 200 body the gateway never returns.

Enforcement, so this does not come back

  • A contract test walking the served document: unique operation identity, resolving $refs, no phantom 2xx, every advertised collection served, every operation described, no role published where none is enforced.
  • A build-time status recorder asserting every status the gateway emits is declared.
  • A startup completeness check over the shipped route set.
  • A checker asserting every error code with a non-test emitter appears in the documented table.
  • A checker resolving every test citation in the design docs against the test tree.

Issue


Type

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

Testing

Gateway unit suite and the integration suite, plus contract suites added here: test_openapi_contract, test_openapi_callability, test_openapi_error_coverage, test_openapi_response_drift, test_rbac_contract, test_auth_policy_contract, test_locking_disabled_contract.

Reviewers can reproduce the central claim directly:

curl -s localhost:8080/api/v1/docs | jq -S . > spec.json
# then drive any documented operation and compare the status against its declaration

Re-verified after rebasing onto current main (25 commits had landed since this
work started): gateway builds with zero warnings, 2678 unit tests and
137 contract cases pass, no orphaned processes left behind. The one conflict
was the per-target coverage list, which main replaced with a directory-scope
module that instruments every target automatically.

Two tests are red and are not caused by this branch:

  • test_external_app_fault_rollup fails roughly two runs in three on an idle machine. Diagnosed here: RosbagCapture::start() runs from the FaultManagerNode constructor while the node's graph cache is still empty, so nothing is buffered for the first 500 ms and a fault confirmed in that window gets no bag. Written up on [BUG] A fault confirmed right after a post-fault recording window gets no rosbag #574, which is a different entry path into the same sink; left unfixed by owner decision.
  • test_opcua_secured launches the fault manager with no parameters, so storage defaults to /var/lib/ros2_medkit, which the test runner cannot create; create_storage() rethrows and the node aborts before its service appears. One launch parameter fixes it; out of scope here.

Known limitations

Stated rather than left to be discovered:

  • The concrete cache-derived items in <entity-path>/docs carry no payload schema on any gateway: all four TopicData construction sites push an empty type, so the schema-generating branch never runs in production. The items still carry the SOVD extensions and the correct write envelope.
  • A plugin GET route whose path ends in /docs is silently unreachable - route registration order puts the docs catch-all first, with no warning at registration.
  • The /docs sub-document cache bounds entries rather than bytes; caching the serialized form rather than the parsed tree cut resident growth by about 40% and the per-hit copy by about 70%, but the entry bound is still the wrong unit.
  • <entity-path>/docs answers 404 for four collections that are served and advertised (locks, scripts, fault-triggers, status), and 200 with an empty document for two nested paths that are not served.

A tooling gap this surfaced

The plugin packages do not set CMAKE_EXPORT_COMPILE_COMMANDS, so they produce
no compile_commands.json and the pre-push clang-tidy hook cannot resolve the
gateway headers they include - reporting them as missing on files that compile
clean. Nine other packages set it. It bites whoever next touches a file in
ros2_medkit_graph_provider or ros2_medkit_graph_watchdog; both are outside
this change's subject, so it is reported rather than fixed here.


Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

bburda added 16 commits August 5, 2026 09:17
Document-wide invariants no single handler owns: unique operation identity,
declared tags, resolvable refs, no malformed path keys. A violation here is a
client-visible defect even when every endpoint behaves correctly.
…turn type

45 operations declared a 2xx they cannot return, because the status was attached
by hand at the registration while the handler's return type said otherwise.

Created<T> and Accepted<T> carry the status in the type, so the registry reads it
from the signature and the document cannot drift from the wire. status_payload_t
unwraps to the schema, the serializer and the static assertions. A 202 labelled
"No content" is now unrepresentable rather than merely corrected.
28 routes could answer 501 and none of them said so, so a client met a refusal
the document never mentioned. gated_on() ties the guard and the declaration to
one expression: a route that can be gated off publishes its 501, and a gate with
an empty predicate now fails closed instead of segfaulting.
Twelve sites attached a header no operation declared. response_header() records
it where it is set; declare_derived_response static_asserts that a 201 or 202
carries its Location, so publishing the header without sending it stops
compiling. validate_completeness() runs at startup and is asserted by the suite,
so a missing declaration is reported rather than silently dropped.
…non-JSON route returns

lock_guarded() marks the writes that answer 409 to a client without the lock, and
the expected set is pinned so dropping a marker turns the suite red. Binary
downloads stop emitting format: binary, which is not valid in OpenAPI 3.1, and
the schema-less exemption now requires a non-JSON media type. The 416
cpp-httplib answers before routing is declared where it is reachable.
A created resource is answered with its Location, a fault-trigger route that
cannot serve says 501 rather than a bare error, and a parameter conversion
failure answers 400 instead of a status that told the caller nothing.
get_tracked_goal was scoped to no entity, so an execution started on one app was
reachable from another, and list_executions resolved only two of the four entity
types. Both now resolve the entity that owns the execution.

Kept as its own commit: this changes behaviour, and reverting it must not take
the documentation work with it.
…keep Location resolvable

Script handlers guessed the collection from the request path instead of reading
the segment the router matched. Separately, to_regex_path appends "/?$", so a
request with a trailing slash routes successfully and req.path() + "/" + id then
yields a double slash whose Location answers 404. Nine sites built a URI that
way; canonical_request_path and child_resource_path are now the single place that
knows about the anchor.
15 schemas were unreachable from any operation and 9 operations published a body
with no content at all. Responses, request bodies, error bodies, SSE frames and
the update payloads SOVD already defines are now bound to the types the handlers
use, with success_schema() narrowing only the declared schema where a handler
must keep returning free-form JSON so peer vendor keys survive.

Two wire defects surfaced doing it: the vendor-error sentinel was emitted without
its vendor_code on one path, so a client could not identify the error; and the
error-code table claimed completeness while its check matched any mention
anywhere in the guide. The check now reads the table itself, and the emitter scan
covers headers and in-tree plugins.
…a client must send

The document advertised entity resource collections the gateway does not serve
and omitted several it does; the correspondence is now exact in both directions
for all five types. FieldConstraints publishes the bounds the handlers enforce,
every operation carries a description, and non-trivial request bodies carry an
example.

A published bound is only added where every route carrying it rejects an
over-long value unconditionally - DELETE on a configuration did not, and now
does.
…osing bags silently

The document published a maximum of 256 while FaultManagerNode enforced 128, so a
code in between produced a server error on a value the document calls valid. The
bound is now 256 on both sides; every fault_code column is unconstrained TEXT and
every IDL field an unbounded string, so nothing downstream caps it.

Raising it exposed silent loss: fault_<code>_<timestamp> pushed the rosbag
directory name past NAME_MAX, the exception was caught and only logged, and the
bag was never written. The name is bounded and carries a digest of the whole
code, so two codes sharing every kept byte still get separate directories.

Separately, get_fault decided 404 against 503 by substring-matching the store's
message, so a validation refusal from the fault manager reached the client as a
server error. The transport now records whether it got a response at all, and
the client-error side is the default so a 503 must be asked for.
…ion table, project the sub-documents

The /docs routes and the graph provider's routes were served but undocumented.
Folding them in needed OperationDesc to carry tag, operationId and role, and a
gateway-stamped marker for the coverage sweep, whose recorder attaches at the
registry mount point and structurally cannot see a plugin route.

The RBAC table is now generated from the route registrations rather than
maintained as a literal, with a short residual list for what is mounted outside
the registry. Enforcement did not move: check_authorization still fails closed
where it always did, because docs, Swagger UI and plugin routes would otherwise
lose their rule.

The <entity-path>/docs sub-documents are projected from the real document instead
of rebuilt by hand. The hand-written producers had been shipping dangling $refs -
twelve in a component's document - so a client generating code from one got
references to nothing. The cache now stores the serialized form rather than a
parsed tree, cutting resident growth by about 40% and the per-hit copy by 70%.

Also documents the derivation rule and the tier each mechanism reaches, and adds
a check resolving every test the design docs cite against the test tree.
…implicit

Pre-existing clang-tidy findings in packages this work does not otherwise touch.
The gate is mandatory, and "pre-existing" is not a reason to leave it red.
A whole-branch pass found what reviewing one slice at a time cannot: three
surfaces sit outside RouteRegistry::to_openapi_paths() and each published
something the gateway does not honour.

Per-operation security and the 401/403 declarations were keyed on auth.enabled
while enforcement asks the policy, so under require_auth_for: write the document
told a client to hold a token for 136 GETs that admit everyone, and under none it
asserted an RBAC posture the gateway does not have. Both now key on the policy.

Plugin-served operations omitted the 401/403 the middleware does emit on them -
the same pre-routing argument the code already made for 416. With locking off, 44
operations still advertised locks while the same gateway's root said
locking: false. GET /api/v1 and /docs disagreed in both directions, and nothing
compared them. A hand-written array's completeness is now a compiler check rather
than a claim the design docs made for it.

The cache-derived items in the sub-documents published a request body the handler
rejects and a 200 body the gateway never returns; they now inherit both from the
templated sibling that names the same route, and are discarded rather than
published raw when that sibling cannot be found.
…inted

The package set no CMAKE_EXPORT_COMPILE_COMMANDS, so it produced no
compile_commands.json and clang-tidy run standalone could not resolve the
gateway headers graph_provider_plugin_exports.cpp includes - reporting them as
missing on a file that compiles clean. Nine other packages already set it; this
one was the gap, and it only surfaced now because this work touches that file.
declare_parameter<int> yields int64_t, so narrowing to int before the range
check let a value past INT_MAX truncate into range and pass it - severity_floor
would have been accepted, and max_tracked_nodes could wrap to a small positive
bound. Both now clamp as int64_t and narrow afterwards.

The -Wconversion warnings these raised are pre-existing on main; this branch
already touches the file, and the gate is zero warnings.
@bburda
bburda force-pushed the feat/openapi-derivation branch from 5e5b36e to 9429c5c Compare August 5, 2026 07:55
@bburda bburda self-assigned this Aug 5, 2026
UpdateManager runs prepare/execute on their own std::async threads and
each calls ResourceChangeNotifier::notify() once the backend returns, so
a task can still be inside the backend when the gateway tears down.
~UpdateManager is what joins those tasks, but update_mgr_ is declared
before resource_change_notifier_ and C++ destroys members in reverse
declaration order, so the notifier was freed first. A task waking up
afterwards wrote to freed memory - ASan reported heap-use-after-free on
the fetch_add that opens notify(), from run_prepare() on a std::async
thread. The counter is the function's own lifetime guard, so it cannot
help here: it only protects a call already in progress, not one that
starts after the object is gone.

Give UpdateManager an explicit shutdown() that stops accepting work and
joins every in-flight task, and call it from ~GatewayNode before the
notifier is shut down. Draining at a defined point makes the ordering
independent of member declaration order. The destructor delegates to
shutdown() so standalone use keeps the previous join behaviour, and
moving the futures out makes repeat calls a no-op.

The stopped_ check in the three start_*() entry points moves under
mutex_. Previously it was read before the lock, leaving a window where a
call could pass the check and then launch after shutdown() had already
snapshotted the futures, escaping the drain.

Covered by test_openapi_response_drift under ASan, which is what first
drove this path, plus unit tests pinning that shutdown() blocks while a
task is in flight and is safe to call twice.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] The generated OpenAPI document asserts what the gateway does not honour

1 participant