Skip to content

Generate the OpenAPI specification from the firmware route table - #801

Open
chrisgleissner wants to merge 11 commits into
test-mergefrom
feature/openapi-spec-generator
Open

Generate the OpenAPI specification from the firmware route table#801
chrisgleissner wants to merge 11 commits into
test-mergefrom
feature/openapi-spec-generator

Conversation

@chrisgleissner

@chrisgleissner chrisgleissner commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Closes #800.

The REST API now has a machine-readable description that the build produces from the firmware itself, one document per product family. Both are committed, both are attached to every CI build, and every device serves its own copy at /openapi.yaml with a Swagger UI page at /api.html.

What the build produces

file paths operations size
doc/api/rest_api_openapi_u2.yaml 43 54 122 KB
doc/api/rest_api_openapi_u64.yaml 46 58 132 KB

make openapi writes them, make openapi_check rebuilds them in memory and fails when the committed files differ, and make openapi_test runs the generator's own tests. openapi_check is a prerequisite of every firmware target and a CI step that runs before the builds, so a route change that is not reflected in the document stops the build the way an over-size image does. The two files are also uploaded as a new openapi_<version> build artifact.

Where the description lives

Every REST call is registered through the API_CALL macro in software/api/routes.h. That registration already carries the verb, the route, the command, the query parameters and which of them are required. The parts a route cannot know, the prose and the response shapes, are written in an API_DOC block directly above it:

API_DOC(GET, machine, readmem,
    TAG("Machine")
    SUMMARY("Read C64 memory")
    DESCRIPTION("Performs a DMA read on the cartridge bus and returns the "
                "bytes as a binary attachment. The read may not pass $FFFF.")
    PATH("/v1/machine:readmem", "readMemory", "")
    PARAM("address", "string", "Start address in hexadecimal, 0000 to FFFF.", "", "D020")
    PARAM("length", "integer(1..65536)", "Number of bytes to read.", "256", "2")
    RESPONSE("200", "application/octet-stream", "", "The bytes read.", "")
    RESPONSE_ERROR("400", "Invalid address", "")
)
API_CALL(GET, machine, readmem, NULL, ARRAY( { {"address", P_REQUIRED}, {"length", P_OPTIONAL} }))

API_DOC is defined as #define API_DOC(...) and nothing else. Because its parameters do not appear in the replacement list, the preprocessor never expands what is inside it either, so SUMMARY, PARAM and the rest need no definitions and cannot collide with anything else in the tree. The compiler still checks that the block is a balanced token sequence with terminated string literals.

The only other change to the running firmware is one #include line in routes.h. No handler, no route table and no runtime behaviour is touched.

The workflow, and who keeps the documents current

The author does. Changing the REST API is three steps:

  1. Edit the API_CALL, or the API_DOC block above it, or both.
  2. Run make openapi. It reads the route sources and rewrites doc/api/rest_api_openapi_u2.yaml and ..._u64.yaml. It needs no device, no toolchain and no packages: a few hundred milliseconds of standard library Python.
  3. Commit the two regenerated files in the same commit as the code.

Forgetting step 2 is caught rather than shipped, because a firmware build will not complete without it: openapi_check is a prerequisite of every firmware target, so a local make u64 fails the same way CI does.

CI does not write them, it re-derives them and compares. make openapi_check rebuilds both documents in memory from the sources in the checkout and compares the rendered bytes against the committed files, failing and naming the stale one. Comparing rather than overwriting is deliberate: a step that regenerated and committed would hide the author's mistake instead of reporting it.

It runs in two places, so a stale document is caught whether or not CI is involved:

  • the Check OpenAPI Specification step, before the firmware builds;
  • as a prerequisite of every firmware target in the root Makefile.

What that looks like when an author edits a committed document by hand and forgets to regenerate:

$ make openapi_check
openapi: the committed specification no longer matches the sources:
  doc/api/rest_api_openapi_u64.yaml is out of date
Run `make openapi` and commit the result.
make: *** [Makefile:30: openapi_check] Error 1

and when they are current, which is what CI prints:

Ran 109 tests in 0.171s
OK
openapi: 2 documents match the sources

Why the documents are in git

Two things follow from the documents being versioned beside the code rather than produced and thrown away.

The contract changes visibly. A pull request that adds a parameter, changes a status code or renames a route shows that in the diff of doc/api/, next to the code that caused it. Reviewing an API change stops depending on somebody noticing the implication of a route_*.cc edit.

Compatibility becomes checkable. Two committed revisions of the same file are what every OpenAPI diff tool takes as input, so oasdiff, openapi-diff or Redocly's diff can say whether a change is breaking, against any earlier commit or tag. Nothing in this PR wires that up, but the input it needs now exists and is trustworthy, which it would not be for a document maintained by hand.

What the generator refuses

tools/openapi/generate.py cross checks the two halves before it writes anything. Each of these stops the build:

  • a call with no API_DOC block, or a block with no call;
  • a parameter that only one of them knows about;
  • a BODY where the call has no body handler, or a body handler with no BODY;
  • a path template that no longer starts with its route or end with its command;
  • a {placeholder} with no PATH_PARAM, or a PATH_PARAM no path uses;
  • an operationId used twice, a schema name that does not exist, a tag that is not defined;
  • an example that is not valid JSON;
  • the committed documents differing from what the sources say.

Marking the calls that need care

Nothing is omitted: all 53 registrations appear in the document for the product that serves them, and the generator fails the build if one is missing. Hiding a call would only make an agent guess. Three kinds of call are marked instead, each declared at the call site and validated:

  • GET /v1/help is registered and was never implemented, so it carries deprecated: true and says why. Every generator marks the method deprecated.
  • The hardware surface is one tag. debugreg, measure and heap are all under Diagnostics, described as hardware debugging rather than as a way to drive the machine.
  • An operation with consequences beyond returning an answer carries x-ultimate-caution, naming what those are from a closed vocabulary the generator enforces: destructive, machine-state, persistent, power, diagnostic, idempotent. The names follow the shape of MCP tool annotations, so a server wrapping this API maps them onto its own.

One CAUTION directive produces both renderings, so they cannot disagree:

description: |2-
  Turns the C64 off. ...

  **Caution (power):** There is no call that turns the machine back on. It has to be
  done at the machine.
x-ultimate-caution:
  hints:
    - power
  note: There is no call that turns the machine back on. It has to be done at the machine.

The prose is what a person reading the operation in Swagger UI or Redoc sees, above the Try it out button. The field is what an agent gates on. The document explains the vocabulary in its own description, so a consumer does not have to find this pull request to know what a token means.

Two documents, derived rather than declared

Which calls a product serves is decided at compile time by two things, and both are read from the tree rather than restated:

  • the macros the sources are compiled with, so machine:debugreg is in the Ultimate 64 document only;
  • the route sources each product's makefiles compile, so route_streams.cc is in the Ultimate 64 makefiles only and the cartridge document has no /v1/streams paths at all.

The generator also requires every makefile within a family to compile the same set, so a product drifting away from its family is a build failure rather than a wrong document.

Nothing reaches the firmware image

The prose has no use at run time and the U2 application partition has little to spare. Two pieces of evidence:

  • g++ -E over a file containing an API_DOC block emits zero occurrences of its text, and the block compiles clean under -Wall -Wextra -pedantic.
  • strings over the three application ELFs built from this branch finds zero occurrences of the documentation text:
target/u2/riscv/ultimate/result/ultimate.elf     0
target/u64/nios2/ultimate/result/ultimate.elf    0
target/u64ii/riscv/ultimate/result/ultimate.elf  0

The device serves its own contract

The document rides in the updater and is written to /Flash/html next to index.html, so GET /openapi.yaml returns the contract for the firmware that device is running. GET /api.html is a 1.6 KB page that loads Swagger UI from a CDN and points it at the local document; when the browser has no internet it shows a link to the raw file instead. Its request interceptor rewrites the host of every call to the device the page was served from, so Try it out talks to that machine rather than to the example host in the document. index.html gains one link to it. The static file server already serves that directory on every product, so httpd is unchanged.

Verified in a browser against an Ultimate 64 Elite with both files in place: Swagger UI renders all 58 operations in the nine tag groups, with no console errors. The device serves the document as text/plain, because the static file server's type table has no .yaml entry, and Swagger UI parses it regardless.

Cost, measured:

product flash disk in use before after used
Ultimate II (AT45) 892 KB 126 KB 250 KB 28%
Ultimate II (W25Q) 800 KB 126 KB 250 KB 31%
Ultimate II+ 1984 KB ~285 KB 409 KB 21%
Ultimate II+L 6080 KB 285 KB measured 409 KB 7%
Ultimate 64 4000 KB 305 KB measured 439 KB 11%
Ultimate 64 Elite II (50T) 12192 KB ~305 KB 439 KB 4%

The updater images grow by the size of the document plus the 2 KB explorer page. Measured on the CI artifacts of the commit before this work and the current head:

updater before now growth
update.u2r 1,797,792 1,924,504 126,712
update.u2p 2,432,148 2,558,864 126,716
update.u2l 2,923,368 3,050,088 126,720
update.u64 5,384,800 5,522,100 137,300
update.ue2 8,556,036 8,693,340 137,304
update.cfw 8,556,072 8,693,376 137,304

The tightest is the Ultimate II, which links into a 4 MB window and is now at 1.92 MB. The runtime application is not affected, and the app_space gate measures that image, not the updater.

The Ultimate II was checked specifically, because it is the product with the least room. It has networking through a USB AX88772 Ethernet adapter, and usb_ax88772.cc, httpd.cc and the static file middleware are all in its source list, with Web Remote Control Service enabled by default. CI builds and links all five products with the document embedded, the Ultimate II included.

What it looks like on the device

All four are an Ultimate 64 Elite on firmware built from this branch, serving its own document.

GET /openapi.yaml. The header explains what the document is, where it came from, and what the caution vocabulary means, so a reader who arrives with nothing else has everything they need.

The document served by the device

GET /api.html. Operations grouped by tag. GET /v1/help is struck through, which is Swagger UI rendering deprecated: true.

Swagger UI on the device

An operation that has consequences shows what they are above the Try it out button, and the shared 403 appears with its example.

A cautioned operation

Try it out, against the device that served the page. The request URL is http://u64/v1/info, not the ultimate64 default in the document, because the page rewrites the host to wherever it came from. The response is that machine answering, alongside the example the document declares.

Try it out

How it is verified

Against the standard. Three independent checks, all clean on both documents:

spectral lint --ruleset spectral:oas --fail-severity=hint   No results with a severity of 'hint' or higher found!
redocly lint (recommended ruleset)                          Your API descriptions are valid.
openapi-spec-validator (OpenAPI 3.1)                        u2 VALID, u64 VALID

Spectral's first run found 130 real problems, which are fixed here: examples were serialised as strings of JSON rather than as values, and two configuration response schemas put additionalProperties across an allOf where it also caught the errors array. Generating a client also showed the server URL repeating the /v1 that the path keys already carry, which would have made every generated client build /v1/v1/....

Against a real generator. openapi-python-client turns each document into a typed client package. The openapi-contract suite does this during the run and then calls the device through the generated code:

[05] a client generates from the u64 document ... OK (1.811s)
     package ultimate_64_rest_api_client
[06] the generated client reads the interface version ... OK (0.190s)
     version 0.1
[07] the generated client reads memory and agrees with the raw call ... OK (0.042s)
     $D020 reads fef6

Against real device traffic. tests/lib/openapi_contract.py checks each answer against the document: that the status code is declared for that operation, and that a JSON body validates against the declared schema, using openapi-schema-validator for the OpenAPI 3.1 dialect. The check sits in tests/lib/rest.py, which is the only HTTP client the suites use, so ./run-tests --validate-openapi applies it to every existing suite without a suite being changed. It is off by default; the new suite turns it on for itself.

Run against an Ultimate 64 Elite on firmware built from this branch:

$ ./run-tests -H u64 -s openapi-validator -s openapi-contract
OK  all 2 suite runs passed.

$ ./run-tests -H u64 -s readmem-writemem -s menu-screen --validate-openapi
OK  all 2 suite runs passed.
     OK   e2e overlay    30.2s (2 ok)

That second run put every response those two suites received through the validator, 58 checks in all, without either suite being changed.

The check costs 0.08 to 0.13 ms per response with the validators cached.

In CI. The build for this branch is green, and the openapi_<version> artifact it produced is byte identical to the two committed documents.

Both products, through their own generated clients. A client was generated from each document and pointed at the matching device, an Ultimate 64 Elite and an Ultimate II+L, both on firmware 3.15. Every call answered as its document says. The two SDKs differ in the way that matters: the Ultimate II package has 54 operations and no streams module and no read_debug_register, the Ultimate 64 package has 58 and both. The device agrees: /v1/machine:debugreg and /v1/streams/video:stop answer 200 on the Ultimate 64 and 404 on the cartridge.

Unit tests. 120 tests under tools/openapi, run by make openapi_test, covering the C++ reading, the #if evaluation, the pairing rules, every refusal listed above, the YAML writer, and the shape of the real documents. 16 more in tests/lib/openapi_contract_test.py, registered as the device-free openapi-validator suite, pin the validator's verdicts so that a validator which accepts too much or refuses too much is caught before it runs against hardware.

Using it

make openapi          write the two documents
make openapi_check    fail if the committed ones are stale
make openapi_test     the generator's own tests

./run-tests -H u64 -s openapi-contract        the new suite
./run-tests -H u64 --validate-openapi         every suite, checked against the document

Three host packages are added to tests/requirements.txt: openapi-python-client, openapi-schema-validator and openapi-spec-validator, plus PyYAML. They are needed by the E2E gate only. The firmware build needs nothing that is not already there: the generator is standard library only and writes its own YAML.

Limits

  • /Flash/html is written by the updater, so a device that had only its application replaced, which is what a JTAG load does, keeps the document from whichever release last ran the updater. The suite reports that as a warning naming the cause rather than as a failure.
  • The document describes only what a route registration and its block say. It does not describe the ordering constraints between calls, such as pausing the machine before a large read.
  • Three calls on the cartridges can only refuse: GET and POST /v1/machine:input, and PUT /v1/machine:poweroff. The firmware registers them and answers 501 with a reason, which is more use to a client than a 404, so the cartridge document describes them as refusals with no success response. Spectral's operation-success-response warns about exactly those three, which is it reporting the firmware's shape rather than a defect in the document. Everything else is clean at hint severity.
  • GET /v1/help is registered by the firmware and does nothing useful. It is documented as what it is, because leaving it out would mean the document no longer matches the route table.

@chrisgleissner

Copy link
Copy Markdown
Collaborator Author

Hi @GideonZ,

thanks for reviewing and approving this PR.

It ensures that the programmatic description of our REST API is generated from the source code. This in turn ensures that the two can never again diverge. It also simplifies REST API evolution.

Thanks
Christian

@barryw barryw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Christian, thank you for the careful implementation and especially for putting the generator under tests. I reviewed head 94e99bcd, ran make openapi_test locally (120 tests pass), and ran make openapi_check (both committed documents match). The overall source-adjacent approach looks strong.

I found a few cases where the generated contract can still become misleading or where the new explorer increases the trust placed in external code. I have included concrete reproductions and kept existing firmware behavior separate from changes introduced here.

1. External Swagger code runs with device-page privileges

api.html loads swagger-ui-dist@5 from UNPKG. A script loaded through src runs in the context of the current page and can do anything the page can do, even when fetched cross-origin (MDN). Here that includes invoking the local firmware API and observing an X-Password entered into Swagger.

There are two compounding details:

  • @5 is a semver range rather than an immutable version. UNPKG documents that ranges resolve to the newest matching release (UNPKG).
  • crossorigin does not provide integrity or sandboxing. SRI is the browser mechanism that locks an external script to reviewed content (MDN SRI).

I realize that index.html already loads CDN JavaScript. The concern is more acute on an interactive API explorer because it deliberately exposes powerful operations and authentication. The smallest safe option is to serve the YAML without executing third-party code. If the embedded explorer is retained, could the dependency at least use an exact version plus an integrity hash, with a small static test preventing an unpinned script from returning?

2. The machine-readable security requirement is unconditional

The prose correctly says that X-Password is required only when a password is configured, but the generated root declares only NetworkPassword. In OpenAPI this means that the scheme is required globally. The OpenAPI 3.1 specification explicitly says that optional security is represented by including an empty Security Requirement Object, {}, as an alternative (OAS 3.1).

Would this describe the actual conditional behavior more accurately?

security:
  - {}
  - NetworkPassword: []

The existing prose can continue explaining when the server actually demands the header. A unit assertion on the generated root would keep the machine-readable and human-readable descriptions aligned.

3. Product equivalence currently compares source filenames, not compiler definitions

PROFILES supplies only an empty U2 definition set and U64=1, while compiled_sources checks only whether each target Makefile names the same route files. The real targets use differing definitions such as U2, U2P=1, U2P=2, U64=1, U64=2, RISCV, and NIOS.

I confirmed that the current API_CALL registrations are gated only by U64, so the two documents generated today appear correct. The risk is to the stronger guarantee that firmware and contract cannot diverge: a future call under #if U2P, #if RISCV, or #if U64 == 2 can pass the current check.

A minimal adversarial fixture demonstrates this: adding OPTIONS += -DBIG to the fixture target Makefile while leaving the profile definitions empty causes the generator to omit the call that the target compiles, and generation still succeeds.

Could the gate compare active call tables for each actual target definition set, or at minimum reject conditional identifiers that the profile has not explicitly modeled? A fixture with identical route filenames but different active calls would make a good regression test.

4. Duplicate document keys can silently replace earlier declarations

There are several fail-open collision paths in document.py:

Against the exact PR head I confirmed:

duplicate path+method -> only the replacement operation remains
duplicate PARAM       -> the replacement declaration remains
duplicate RESPONSE    -> the first description remains with the replacement schema

All three builds succeed. These look best handled by rejecting the second declaration, matching the existing duplicate-BODY and duplicate-operationId behavior. Three small fixture tests would close the gap.

5. CI checks freshness and custom unit tests, but not OpenAPI validity

The workflow runs only make openapi_test && make openapi_check. The standard validator and generated-client checks live in the hardware-oriented E2E suite, so they are not part of this PR build gate.

The documents may be valid today, but a future generator change can produce fresh, unit-tested YAML that is invalid under OpenAPI 3.1. Could the host-only validation and client-generation portions be split into a CI target? Even running only openapi-spec-validator in CI would independently verify the artifact that gets uploaded.

6. Existing firmware issue exposed by the documentation: 203 versus 204

This is not introduced by this PR. The generator accurately repeats existing firmware wording: routes.h emits HTTP/1.1 203 No Content, and the new description repeats it. RFC 9110 defines 203 as Non-Authoritative Information and 204 as No Content (203, 204).

I would treat the firmware status change as a separate compatibility decision. For this PR, it may be worth calling 203 a legacy/non-standard response rather than presenting 203 No Content as standard HTTP semantics.

Small fail-closed parser checks

Two low-cost validation cases also reproduced on the PR head:

_as_declared("boolean", "ture") -> False
string_literal("\x41")         -> "x41"

The first silently turns a typo into a valid value. The second silently changes a valid C hexadecimal escape instead of decoding or rejecting it. Rejecting unsupported values/escapes would be safer than generating a plausible but incorrect contract, and each case is a very small unit test.

Thanks again for building this around deterministic generation and tests. The comments above are aimed at preserving the strong no-divergence guarantee as the API evolves, not at changing the overall design.

@chrisgleissner

chrisgleissner commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the very thorough review @barryw . It is much appreciated.

I will address your findings.

Derive each target's macros from its own makefile, refuse a second
declaration, pin the explorer's third-party code, and validate the
documents in CI.

Claude-Session: https://claude.ai/code/session_012Cpx38cC2t4pK9c73GnP1A
@chrisgleissner

Copy link
Copy Markdown
Collaborator Author

Thanks @barryw. Every reproduction in your review was correct against 94e99bcd; I reproduced each one before changing anything. All six findings and both parser cases are addressed in 5cf8f9b7, along with five more that my own review of the branch turned up. Each fix has a test that fails when the fix is reverted, and I checked that by reverting each one in turn.

make openapi_test is now 186 tests, up from 120.


1. External Swagger code runs with device-page privileges

Fixed, and further than the minimum you asked for. html/api.html now:

  • names an exact release, swagger-ui-dist@5.32.14, rather than the @5 range;
  • carries an integrity digest and crossorigin="anonymous" on both the stylesheet and the script;
  • carries referrerpolicy="no-referrer", so the device's host name does not reach unpkg in a Referer header;
  • declares a Content Security Policy.

The policy is what bounds the damage if the pinned bytes are ever served by something other than unpkg:

default-src 'none';
script-src 'sha256-MbbxGLnf0+hcHG4EyZkflTUx/Y6JjKwXOJWeTY6Re8s=' https://unpkg.com/swagger-ui-dist@5.32.14/;
style-src 'unsafe-inline' https://unpkg.com/swagger-ui-dist@5.32.14/;
img-src 'self' data:;
font-src 'self' data:;
connect-src 'self';
base-uri 'none';
form-action 'none'

connect-src 'self' is the directive that matters most for your concern: it means nothing the page loads can send the device's answers, or a password typed into Try it out, anywhere but back to the device. The page's own inline script is admitted by its digest rather than by 'unsafe-inline', so an injected inline script does not run either. style-src needs 'unsafe-inline' because Swagger UI sets element styles from JavaScript.

I verified all of this in a browser against a local server, rather than only asserting it in a test:

check result
page renders 58 operations in 9 tag groups, no console messages
fetch('https://example.com') from the page blocked
injected inline <script> does not execute
<script src> appended for a different CDN refused, one CSP violation logged
one character changed in the script's integrity Chromium refuses the file, SwaggerUIBundle is undefined, the offline notice stays visible

That last case also confirms the recorded digest independently: Chromium reported the computed digest as Dt83RhU85ZmX7werw9uTFCzmauXUoSyx3pdzTQMABtsnFmooJy4Vz9/ACh7n5m1A, which is the value in the page.

The static test you asked for is tools/openapi/test_explorer.py, 16 cases. It fails if any external URL stops naming an exact release, if a digest is missing or does not match the value recorded in tools/openapi/explorer.py, if crossorigin is dropped, if the policy stops carrying default-src 'none', connect-src 'self', base-uri 'none' or form-action 'none', if script-src names any origin but the pinned one, if 'unsafe-inline' or 'unsafe-eval' appears in script-src, or if the inline script is edited without the digest being updated. I confirmed each of those five edits turns it red.

I did consider dropping the explorer and serving only the YAML. The explorer is 1.6 KB of the 126 KB the updater grows by, and it is what makes the document usable on a device with no internet-connected tooling, so pinning it seemed the better trade. The policy above is what makes me comfortable keeping it.

2. The machine-readable security requirement is unconditional

Fixed as you suggested. The generated root is now:

security:
  - {}
  - NetworkPassword: []

The prose is unchanged. test_document.SecurityTest asserts the generated value.

Writing that turned out to need a fix in the YAML writer first, which is finding 9 below: an empty mapping inside a list was being written as the quoted string '{}', so the first attempt produced security: ['{}', ...]. Swagger UI still shows the Authorize control with the optional requirement in place, which I checked in the browser.

3. Product equivalence compares source filenames, not compiler definitions

Fixed, and by removing the hand-written definition list rather than extending it.

schemas.PROFILES no longer has a defines key. routes.target_defines reads the -D flags out of each target makefile, and routes.documented_calls builds the active call table once per target, from that target's own macros and that target's own source list, then requires every target in the family to arrive at the same table. A -D flag with no value becomes 1, as the compiler does; hexadecimal values are read as numbers; commented-out makefile lines are ignored, which matters because target/u2plus/nios/ultimate/Makefile keeps an older OPTIONS line commented out.

What that reads from the real targets:

target/u2/riscv/ultimate/Makefile        {RISCV: 1, U2: 1, OS: 1, IOBASE: 0x10000000, U2P_IO_BASE: 0x10100000, CLOCK_FREQ: 50000000}
target/u2plus/nios/ultimate/Makefile     {OS: 1, U2P: 1, NIOS: 1, CLOCK_FREQ: 62500000}
target/u2plus_L/riscv/ultimate/Makefile  {U2P: 2, RISCV: 1, USB2503: 1, OS: 1, IOBASE: 0x10000000, U2P_IO_BASE: 0x10100000, CLOCK_FREQ: 50000000}
target/u64/nios2/ultimate/Makefile       {OS: 1, DEVELOPER: 0, NIOS: 1, CLOCK_FREQ: 66666667, U64: 1}
target/u64ii/riscv/ultimate/Makefile     {RISCV: 1, U64: 2, USB2513: 1, OS: 1, IOBASE: 0x10000000, U2P_IO_BASE: 0x10100000, CLOCK_FREQ: 100000000, FP_SUPPORT: 1}

A call under #if U2P, #if RISCV or #if U64 == 2 is now placed by the same macros the compiler is given, so it cannot be omitted from or added to a document by accident. #if CLOCK_FREQ == 50000000 already exists in route_machine.cc, not around a registration, which is a fair warning of how close this was.

Your adversarial fixture is now the regression test. test_routes.ProfileDefinesTest.test_a_makefile_define_reaches_the_document_without_being_restated adds OPTIONS += -DBIG to the fixture target makefile and asserts the call appears; it fails against the previous per-family definition list.

Two more cases came with it, both of which the old check would also have passed:

  • two targets of one family that serve different calls are refused, naming the call and both targets;
  • two targets of one family that serve the same calls but describe one of them differently are refused. One document covers a whole family, so an #if inside an API_DOC block has to be caught too, not only an #if around an API_CALL.

The comparison is over the full registration and the full directive list, not just the set of routes.

4. Duplicate document keys can silently replace earlier declarations

Fixed. All three of your cases are now refused, and I found a fourth of the same kind:

duplicate path+method   software/api/route_demo.cc:3: GET /v1/demo is described twice, as 'listDemos' and as 'listDemosAgain'; the second declaration is at software/api/route_demo.cc:3
duplicate PARAM         software/api/route_demo.cc:3: two PARAM directives for 'count'
duplicate RESPONSE      software/api/route_demo.cc:3: two RESPONSE directives for 200 in operation 'listDemos'
duplicate PATH_PARAM    software/api/route_demo.cc:3: two PATH_PARAM directives for 'slot'

PARAM_ENUM, PATH_PARAM_ENUM and RESPONSE_EXAMPLE are covered the same way, and a repeated RESPONSE_ERROR with the same message for the same code is refused while two different messages remain two examples, which is the existing intended use.

Rewriting _responses for this exposed something I had not noticed. A RESPONSE scoped to an operationId is meant to refine the unscoped one for the same status code, but which one won was decided by which line came first in the block, not by scope. The existing test passed only because the fixture happened to write the scoped one first. _responses now applies the unscoped directives and then the scoped ones, so scope decides, and a duplicate within one scope is refused. test_document.ScopedResponseTest asserts the same result for both orderings. The two committed documents are byte identical across this change, so nothing in the tree depended on the old positional behaviour.

5. CI checks freshness and custom unit tests, but not OpenAPI validity

Fixed. There is a new make openapi_validate and a Validate OpenAPI Specification step that runs it.

Following your note, and after a round on this, it runs openapi-spec-validator's own command line rather than a wrapper written here:

openapi_validate:
	@$(PYTHON) -c "import openapi_spec_validator" 2>/dev/null || { ...how to install...; exit 2; }
	@$(PYTHON) -m openapi_spec_validator --validation-errors all `$(OPENAPI) paths`

The file list comes from generate.py paths, so it cannot drift from the set of documents the generator writes. The validator detects the specification version from the document itself.

Three deliberate choices:

  • It is a separate CI step that installs the validator into its own virtual environment. A package index that is unreachable then reports itself as that, rather than looking like a broken document, and the firmware builds do not depend on it.
  • The version is pinned, in a new tools/openapi/requirements.txt. tests/requirements.txt now includes that file with -r instead of naming the package again, so there is one pin. This is a gate, so a validator that changes what it accepts should turn the build red when somebody updates the pin, not on an unrelated morning.
  • The target refuses to run when the package is absent, with a message saying how to install it. It never skips.

Red and green, on the committed documents:

$ make openapi_validate PYTHON=.venv/bin/python
doc/api/rest_api_openapi_u2.yaml: OK
doc/api/rest_api_openapi_u64.yaml: OK

$ # with one unexpected key inserted at the root of the u2 document
$ make openapi_validate PYTHON=.venv/bin/python
doc/api/rest_api_openapi_u2.yaml: Validation Error: [1] Unevaluated properties are not allowed ('bogus_root_key' was unexpected)
$ echo $?
2

The generated-client checks stay in the E2E suite, since generating a client is slow and the suite already does it against a device.

6. 203 versus 204

I agree this is a firmware compatibility decision and not one for this PR, so the firmware is unchanged and the document now says what the status actually is. The Responses section reads:

When such a call has nothing to return, the firmware answers with the status line 203 No Content. That is the firmware's own status, not standard HTTP: RFC 9110 defines 203 as Non-Authoritative Information, and it is 204 that means No Content. The document describes what the firmware sends, because changing the status would break clients written against the current releases. A client should read a 203 from one of these calls as "the call succeeded and there is no attachment".

The reason phrase in HTTP_REASON no longer presents 203 as though it were standard either.

Small fail-closed parser checks

Both fixed.

_as_declared no longer coerces. A value has to be a literal of the type its directive declared, so "ture" is refused with 'ture' is not a boolean; write "true" or "false". Integers are checked against the declared range as well, so PARAM("value", "integer(0..255)", ..., "300") is refused. The refusal names the block and the parameter.

string_literal now decodes \xNN and octal escapes properly, and refuses anything it does not know instead of dropping the backslash. "\x41" is A. Two related cases came out of implementing it, both refused rather than silently wrong: "\x41BC", because C reads every hexadecimal digit after \x and 0x41BC is not one byte (split the literal, "\x41" "BC", to write that); and "\400", for the same reason in octal.


From my own review of the branch

Five more, same class as the ones you found.

7. An empty mapping in a list was written as a quoted string. yaml_writer rendered [{}] as - '{}', which round-trips to the string {} rather than to an empty mapping. This is what blocked finding 2, and it would have hit anything else needing an empty collection inside a sequence. Fixed, with a round-trip test through PyYAML.

8. A control character in a key produced YAML that will not parse. A \n in a directive argument that becomes a mapping key, such as a RESPONSE_ERROR message, was written as a single-quoted scalar spanning two lines. PyYAML rejects the result with mapping values are not allowed here, so the build would have produced an unreadable document rather than failing. The writer now refuses any control character in a key, and any but a line feed or a tab in a value. A line feed in a value is still a literal block, as before.

9. One API_CALL could name the same parameter twice. ARRAY( { {"value", P_REQUIRED}, {"value", P_OPTIONAL} } ) produced two query parameters of the same name, which is invalid under OpenAPI, and _verify_consistency did not catch it because it compared sets of names. Refused now, at the registration.

10. Nothing held the embedded document to the product that embeds it. Which document an updater writes to /Flash/html/openapi.yaml is stated twice: SRCS_YAML in the target makefile names the file, and the updater source names the symbol it becomes. Nothing checked that those two agree, so an Ultimate 64 could have shipped the cartridge document. All seven targets are correct today; tools/openapi/test_packaging.py now proves it, and also fails if a target embeds a document the generator does not write, if a source refers to a document no target embeds, or if a target ships the document without api.html. I confirmed all four go red under the corresponding edit.

11. write_api_files discarded a failure. It returned only the result of writing openapi.yaml, so a failure writing api.html was lost. It now returns the first failure.


Verification

  • make openapi_test: 186 tests, all passing. Each new guard was reverted individually and its test confirmed red.
  • make openapi_check: both documents match the sources. The only content change is the two-line security block; the 203 wording and the writer fixes leave the rest byte identical.
  • make openapi_validate: both documents valid OpenAPI 3.1, red and green as above.
  • make observability_test: 172 cases pass.
  • ./run-tests -s openapi-validator (device-free): 16 checks pass against the regenerated documents.
  • The explorer page: rendered and probed in a browser as described in finding 1.
  • Firmware: target/u2/riscv/ultimate builds and links in the CI docker image with these changes, and software/application/rv_update_u2/update.cc, which is the translation unit that includes the edited update_common.h, compiles clean with that target's own flags. The full five-product build is CI's, on this push.

One thing I have not done: template_for in tests/lib/openapi_contract.py picks the longest template when a request path matches more than one, which is a heuristic in the test harness rather than in the contract. It is right for every path in both documents today. I left it alone rather than widening this change, but it is worth a look if the path shapes ever get more ambiguous.

The image cannot create a virtual environment and refuses a write to
its own packages, so install to a directory and reach it by PYTHONPATH.

Claude-Session: https://claude.ai/code/session_012Cpx38cC2t4pK9c73GnP1A
The build image has neither pip nor ensurepip, so run this one step in a
stock Python image, and hold it to the make target with a test.

Claude-Session: https://claude.ai/code/session_012Cpx38cC2t4pK9c73GnP1A
@chrisgleissner

Copy link
Copy Markdown
Collaborator Author

One correction to finding 5 above, and how it ended up.

I wrote that the new CI step installs the validator into its own virtual environment. That is not what it does now, because it could not: the build image cannot install a Python package at all. Two pushes found this out, which is worth recording since it constrains anything similar in future.

  • python3 -m venv fails there with ensurepip is not available.
  • python3 -m pip fails there with No module named pip.

So the Validate OpenAPI Specification step runs in a stock python:3.12-slim image instead, with the workspace mounted:

docker run --rm -v $GITHUB_WORKSPACE:/mnt/project:rw --workdir /mnt/project \
--user $(id -u):$(id -g) -e HOME=/tmp python:3.12-slim /bin/sh -c \
"pip install --quiet --disable-pip-version-check --root-user-action=ignore \
   --target /tmp/openapi-packages -r tools/openapi/requirements.txt && \
 PYTHONPATH=/tmp/openapi-packages python -m openapi_spec_validator \
   --validation-errors all \$(python tools/openapi/generate.py paths)"

Nothing else in the build uses that image, and no other step depends on this one. pip install --target with PYTHONPATH rather than a virtual environment is also what works on an externally managed Python, which is what a developer on a current Ubuntu has; make openapi_validate prints that exact command when the package is missing.

That image has no make, so CI cannot invoke the make target and the invocation exists twice. Since two copies of one check is precisely the thing that drifts, tools/openapi/test_ci.py now holds them to each other: same validator module, same --validation-errors all, and both taking their file list from generate.py paths rather than naming documents. It fails if either side drops the strictness flag, if either writes out a document name, if the CI step stops installing from tools/openapi/requirements.txt, or if the step is removed. I confirmed each of those five edits turns it red.

make openapi_test is now 191 tests.

CI on 70c2e004 is green. Check OpenAPI Specification and Validate OpenAPI Specification both pass, and all five products build: U2, U2+ and U2+L through their cached-FPGA software steps, U64 and U64-II through theirs. That is also what compiles the one firmware change in this push, the write_api_files return value in update_u2p/update_common.h, for every updater that includes it.

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.

2 participants