docs: true up api-description.yaml with the mounted routes - #200
Conversation
The spec had drifted from the service in ways a client reading it would hit:
- `/staging/preview/{uuid}` was mounted but undocumented.
- `PUT /fileupload/{uuid}` and `POST /fileupload/finalize/{uuid}` documented a
409 for a `cryptifytoken` mismatch; both return 400, and no route in the
service returns 409.
- Finalize validates a `cryptifytoken` header, which the spec did not list, so
a client following the spec got a 400 from the extractor.
- 400 bodies are plain-text messages, not `{"message": …}` JSON.
- `/metrics` requires `Authorization: Bearer <metrics_token>` when the
deployment configures one; the spec said firewall-only and listed no 401.
- `/filedownload/{uuid}` answers `Range` requests with 206 or 416 and never
returns the documented 400, and it sets no `Content-Type` at all.
- Missing responses: 400/422/500 on init, 500/503 on chunk PUT, 500 on
finalize, 503 on `/usage`.
- `PayloadTooLarge` gained `resets_at` on the rolling-window 413.
`api_routes()` is now the single mount list, and `mod api_description_tests`
compares it against the spec so a route can no longer be added, removed, or
renamed without the spec following.
Refs encryption4all/postguard#247
The oasdiff gate (needs a maintainer to land)
name: API diff
#
# Breaking-change gate on the OpenAPI contract (#196).
#
# Cryptify's routes are unversioned, so there is no way to keep an old shape
# running next to a new one: any breaking change to api-description.yaml
# breaks the clients pinned to it (pg-js, pg-dotnet, the add-ins). This job
# diffs the PR's spec against the branch it targets and fails on anything
# oasdiff rates ERR. Additive changes pass.
#
# To land a genuinely breaking change: add a new versioned route, leave the
# old one in place, and deprecate it once privacybydesign/postguard-ops#64
# telemetry shows nobody is calling it.
#
on:
pull_request:
permissions:
contents: read
jobs:
breaking-changes:
name: oasdiff breaking changes
runs-on: ubuntu-latest
steps:
- name: Check out the pull request
uses: actions/checkout@v6
- name: Check out the base spec
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
- name: Diff the spec against the base branch
uses: oasdiff/oasdiff-action/[email protected]
with:
base: base/api-description.yaml
revision: api-description.yaml
fail-on: ERR
# Do not upload the spec to oasdiff.com for a side-by-side review
# page. The default is `true`; detection and annotations work
# without it, so nothing leaves CI.
review: falseNotes on the choices, in case you want them different:
Landing orderThe gate should land after this PR merges, not on this branch. The spec on None of these are changes to the service. The So: merge this PR, then add the workflow on What it does on real changesChecked locally with oasdiff v1.26.1 against the spec in this PR:
|
…-Range end
Follow-up on the review of this PR. All five were verified against the
handlers, not inferred from the spec:
- `PUT /fileupload/{uuid}`: the `Content-Range` end byte is exclusive
(`upload_chunk` uses `end - start`), which no version of the spec has ever
said. An external client following RFC 7233 sends one byte too many and gets
a 400. Documented on the parameter, since the spec is the contract pg-js and
pg-dotnet are written against.
- `GET /filedownload/{uuid}`: `Accept-Ranges: bytes` is set on the response
builder after both 404 early returns, so it is not "always" advertised.
Reworded, and added the header to the 416, which does carry it.
- `GET /filedownload/{uuid}`: added the 500 from a failed `file.seek` on the
range path.
- `POST /fileupload/init`: the 400 also covers a syntactically invalid JSON
body, which Rocket answers with its default error page rather than the
plain-text message. Same caveat the chunk and finalize 400s already carry.
- `StagingPreview.confirmation`: null whenever `state.sender` is unset, i.e.
when previewing before finalize — the normal staging flow, and the case the
old wording excluded.
cargo fmt/clippy/test all pass (154 tests); the spec still validates as
OpenAPI 3.0.3 with all $refs resolving.
Co-Authored-By: Claude Opus 5 <[email protected]>
There was a problem hiding this comment.
VERDICT: approve
Rules check + review sign-off. Non-blocking: one suggestion left inline, everything else is either fixed on the branch or verified as-is.
Rule check
Checked the 14 rules that plausibly apply to this diff (OpenAPI spec, Rust, PR hygiene, comment-delivered workflow YAML). One finding, now fixed; the rest pass:
oasdiff-action-uploads-specs-by-default— passes. The comment's YAML setsreview: false, pinsoasdiff/oasdiff-action/[email protected](not@main), and leavesallow-external-refsat itsfalsedefault. The landing-order note is also right, and matches the rule's gate-vs-truth sequencing: land the truing-up PR first, add the gate onmainafterwards, or the gate's debut run fails on the very commit that made the spec honest.actions-default-shell-has-no-pipefail— not applicable. Reviewed the comment-delivered YAML as in-diff code (the bot cannot push.github/workflows/, so a defect there lands via a maintainer's commit and no diff review catches it). Every step is auses:; there is norun:block and no pipe, so there is nothing for the missingpipefailto swallow. Flagging it here rather than inline because GitHub cannot anchor a comment to a path outside the diff.cryptify-content-range-end-exclusive— was breached, now fixed. Detail below.- Passing:
conventional-commit-pr-titles(pr-title.ymlexists, check green),pr-close-issue-keywords(Closes #196, own line, keyword adjacent, targetsmain),cross-repo-link-format(both cross-repo refs fully qualified asowner/repo#N),github-app-permissions/workflows-permission-recurring-blocker(workflow correctly delivered in a comment),rust-run-cargo-fmt-before-push(fmt + clippy + test all green),tests-required-on-fixes(3 committed tests, and the PR shows the failure message on the pre-fix spec),no-private-emails-in-public-artifacts(only[email protected]),no-justification-paragraphs-for-simple-changes(not a small self-evident change — the drift table is the substance),default-prs-to-ready/flip-drafts-after-ci(flipping to ready now).
Fixed on the branch (2190e96)
Four review nits plus the rule finding were one-line-ish documentation corrections, so I pushed them rather than bouncing the PR. Each was verified against the handler, not inferred from the spec:
Content-Rangeend byte is exclusive, and the spec has never said so.upload_chunkrejectsstart >= endand takes the chunk length to beend - start(src/main.rs:670,:676,:709), sobytes 200-1000/*is 800 bytes, not the 801 RFC 7233 would mean. A client written from the spec sends one byte too many and gets a 400. This is the one semantic that has already cost real time — an audit ofpostguard-jsnearly filedstoreChunk'sbytes <off>-<off+len>/*as an off-by-one before checking the server. Documented on the parameter and in CLAUDE.md. Pre-existing text, not introduced here, but this PR is the spec-truing-up PR and the spec is about to become an oasdiff-gated contract.Accept-Ranges: bytesis not "always" advertised. It is set on the response builder atsrc/main.rs:1401, after both 404 early returns, so the 404 does not carry it. Reworded to "every response that reaches the file (200, 206 and 416, but not the 404)", and added the header to the 416, which does carry it but only declaredContent-Range.GET /filedownload/{uuid}can answer 500. A failedfile.seekon the range path maps toInternalServerError. Added — the PR already documented the equally hard-to-reach 500s on init, chunk and finalize, so omitting this one read as an oversight, and an undeclared status is exactly the gap an oasdiff gate never catches later.- The init 400 has a second cause. A syntactically invalid JSON body is a 400 with Rocket's default error page (rocket 0.5.1 maps serde
Category::Syntaxto 400 and onlyCategory::Datato 422), so the 422 covers well-formed-but-wrong-shape and syntax errors land on the 400. Added the same caveat the chunk and finalize 400s already carry. StagingPreview.confirmationwas missing its dominant null case.render_confirmation_emailreturnsOk(None)wheneverstate.senderisNone(src/email.rs:334), andsenderis only populated at finalize (src/main.rs:1007). So previewing before finalize — the normal staging flow, since preview exists to inspect the mail before sending — yieldsconfirmation: nullwithconfirm: trueand no rendering failure, which the old wording excluded.
cargo fmt --all -- --check, cargo clippy --all-targets -- -D warnings, cargo test --all-targets (154 passed) all green, and the spec still validates as OpenAPI 3.0.3 through @apidevtools/swagger-parser with all $refs resolving. CI is green on 2190e96.
Left for you
One inline suggestion on the Authorization header parameter, not applied because it is a judgment call rather than a factual error: OpenAPI 3.0.3 says a header parameter named Authorization SHALL be ignored, so security: is the conformant spelling — but /usage already documents it the parameter way, so switching only /fileupload/init trades one inconsistency for another. Your call which way both should go.
Also still open, from the PR body: GET /filedownload/{uuid} sets no Content-Type at all. The spec now says application/octet-stream. If the old application/cryptify+octet-stream was intentional, the handler is the side that should move — say so and that is a separate PR.
| operationId: "initFileUpload" | ||
| parameters: | ||
| - in: "header" | ||
| name: "Authorization" |
There was a problem hiding this comment.
Non-blocking, and the only review finding I did not apply — it is a consistency call rather than a factual error.
OpenAPI 3.0.3's Parameter Object says a header parameter named Accept, Content-Type or Authorization SHALL be ignored, so this block is the one place in the diff where the newly added documentation may not survive tooling. The conformant spelling is a security requirement, and apiKeyBearer is already declared in securitySchemes for exactly this credential:
security:
- apiKeyBearer: []
- {}with the tier-degradation prose (validated → API-key tier, absent or invalid → default tier, never rejected here) moved into the operation description. That is the same shape this diff already uses correctly for /metrics 20 lines up.
The reason I left it: /usage documents the same credential as a header parameters entry too, so changing only /fileupload/init swaps one inconsistency for another. Either move both to security: or leave both as-is — worth deciding before the oasdiff gate lands and makes the shape harder to change, since dropping a documented parameter is the kind of edit the gate will have an opinion about.
b2a0cda to
2190e96
Compare
|
CI note: the red "oasdiff breaking changes" run you may have seen was the gate flagging this PR's own spec corrections — documenting the always-required Fixed by splitting: the workflow moved to #201, stacked on this branch (auto-retargets to main when this merges), so this PR is back to spec+test only and the gate arms against a corrected base. Merge order: this one first, then #201. |
Closes #196
Part of encryption4all/postguard#247 (workstream C)
The issue asks for the oasdiff gate, with a first step: check that
api-description.yamlstill describes the service. It did not, so this PR fixes the spec and adds a test that keeps it honest. The gate itself is workflow YAML, which the App cannot push, so it is in a comment below for a maintainer to land.Drift found
Every row below was checked against the handler in
src/main.rs, not inferred from the spec.GET /staging/preview/{uuid}staging_mode = true, else 404PUT /fileupload/{uuid}cryptifytokenmismatchcheck_cryptify_token->Error::BadRequest); nothing in the service returns 409POST /fileupload/finalize/{uuid}cryptifytokenmismatch, nocryptifytokenparametercryptifytokenheader (FinalizeHeaders) and answers 400 on a mismatch, so a client following the spec got a 400 out of the extractor{"message": "…"}JSONGET /metricsAuthorization: Bearer <metrics_token>is required when the deployment sets one, 401 otherwise; open when it does notGET /filedownload/{uuid}Content-Range, 404, 416; always advertisesAccept-Ranges: bytes; the documented 400 is unreachablePOST /fileupload/initPUT /fileupload/{uuid}POST /fileupload/finalize/{uuid}GET /usagePayloadTooLargeresets_aton the rolling-window 413 from finalizeAlso documented, since they were missing rather than wrong: the optional
Authorizationheader that puts an upload on the API-key tier, theX-Cryptify-SourceandX-POSTGUARD-CLIENT-VERSIONmetrics headers, andcryptify_uploads_by_app_totalin the/metricslist.Keeping it true
api_routes()is now the single mount listbuild_rocketuses, andmod api_description_testscompares it against the spec in both directions. Adding, removing, or renaming a route failscargo testuntil the spec follows. On the pre-fix spec the test fails with:The comparison ignores placeholder names, so the route's
<filename>binding and the spec's{uuid}still match. The wire contract is the same either way, and{uuid}is the better name for a segment that is always an upload UUID. Response codes and schemas are not covered; those were checked by hand this round.The spec is parsed by scanning indentation rather than with a YAML crate, to leave the dependency tree alone.
spec_layout_assumption_holdsfails loudly if the file's layout changes underneath it.One thing left to decide
GET /filedownload/{uuid}sets noContent-Typeat all (verified:res.content_type()isNone). The spec claimedapplication/cryptify+octet-stream. I documentedapplication/octet-streamand noted the missing header rather than changing the handler, since that is a behaviour change and not what this issue asked for. If the sealed-blob media type was intentional, the handler is the side that should move. Say so and I will open a separate PR.Verification
cargo fmt --all -- --check,cargo clippy --all-targets -- -D warnings,cargo test --all-targets(154 tests, up from 151) all pass locally. The spec validates as OpenAPI 3.0.3 through@apidevtools/swagger-parser, with all$refs resolving and every tag declared.The gate config in the comment below was run against oasdiff v1.26.1 locally:
No changes detected, exit 0main-> 4 errors, exit 1 (see the comment for why, and for the landing order)/usagedeleted from the spec ->api-path-removed-without-deprecation, exit 1notifyRecipientsmoved intorequired->request-property-became-required-with-default, exit 1