Repository navigation
feat: keep the API and its OpenAPI document in sync continuously - #79
Conversation
open_api_spex finds an operation by controller action, not by route, so the personal routes (/api/v1/pastes) and the workspace routes (/api/v1/workspaces/:workspace_id/pastes) cannot share actions once their specs live in the controller: only one of them has a workspace_id. Route the workspace variants to workspace_* actions that delegate to the personal ones, and spell out the personal routes instead of `resources`. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
The OpenAPI document was a hand-written map in TextbinWeb.ApiSpec, away from the code it describes, so changing an endpoint meant remembering a second file. Each action now declares its operation with OpenApiSpex.ControllerSpecs, directly above the function, and ApiSpec builds the paths from the router. - TextbinWeb.ApiDocs holds the building blocks the annotations share: JSON and error responses, UUID path parameters, the paste query fields, and in_workspace/1, which derives each workspace-scoped paste operation from its personal-workspace spec. - The exported document is unchanged apart from dropping `deprecated: false` and two empty parameter descriptions. - The route test now checks that every API action declares an operation, since an action without one would otherwise drop out of the document silently. - .formatter.exs imports open_api_spex so `operation` and `tags` are formatted without parentheses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
Each API controller now runs TextbinWeb.ApiRequestValidation, which
checks path parameters, query parameters and JSON bodies against the
operation the action declares, and rejects anything else with a 422 in
the existing ValidationError shape ({"errors": {"field": ["message"]}}).
An undocumented parameter no longer works, so the document and the
accepted input cannot drift apart.
- The router's :api pipeline puts TextbinWeb.ApiSpec on the conn.
- Request schemas are closed (additionalProperties: false), and an
operation that declares no query parameters accepts none.
- Raw paste uploads keep their content type, which is stored with the
paste: open_api_spex matches bodies by exact content type, so a body the
operation only lists as */* skips body validation. Its query parameters
are still checked.
- Every validated operation documents its 422.
Breaking changes (the API is pre-1.0):
- Request bodies must be JSON. The {"paste": {...}} wrapper and bare JSON
string bodies are rejected; send {"data": ...}.
- A malformed UUID in the path is a 422 instead of a 400 (or a 204 for
delete). The controllers' own UUID checks are gone.
- POST /api/v1/auth/tokens answers 422 for a missing email or password
(was 400), for a non-string name (was silently defaulted), and renders
token-creation failures as field errors.
Tests send JSON through a json_conn/1 helper and expect the new statuses.
The CLI was checked end to end against a local server: whoami, create
with title and reference, show, delete, deleted, restore and permanent
delete all pass, and bad ids or unknown parameters come back as readable
field errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
Checking which documented responses the test suite actually produces turned up two that cannot happen: - "Workspace not found" (404) on the personal-workspace paste operations. The personal workspace always exists; the 404 now belongs to the workspace-scoped variants only, added by in_workspace/1. - deletePaste's 503. Since the soft-delete lifecycle, deleting only marks a paste removed and never touches storage, so there is no storage failure to report. The controller branch that rendered it is gone too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
Contract tests used to cover only the requests one test file made, so a changed JSON view or a new status elsewhere drifted until someone noticed. Now every API request any test makes is checked. - TextbinWeb.ApiResponseCheck sits in the :api pipeline and hands each response to a checker just before it is sent. It is a no-op unless config names a checker (compile-time; only config/test.exs does), so production is unaffected. Being in the pipeline, it also sees the 401s the pipeline sends itself. - TextbinWeb.ApiContract (test support) finds the route's operation and raises unless the status is documented and the body validates against its schema (wire types first, then closed objects). The first failure is kept and re-raised, so the report names the field or status, not the 500 page Phoenix renders afterwards. - Each checked response is recorded. After a full `mix test`, the suite fails if a documented response was never produced, so stale statuses cannot linger in the document; a run narrowed to files or tags only skips this. A workspace-scoped operation shares coverage with its personal one, since they run the same action. One response Plug.Test cannot produce (a request body the socket fails to deliver) is exempted with its reason, and a stale exemption fails too. - ApiContractTest now produces what every operation shares, across the whole API: 401 without a token, 422 for an undeclared query parameter, and each documented 404 for unknown ids, plus purge's 503 when storage cannot delete. The per-operation response tests it had are gone; the feature tests' own requests now do that job. - AGENTS.md says how API changes work now. Checked by breaking things on purpose: an extra field in PasteJSON fails 23 paste tests with "Unexpected field: surprise", a 410 instead of 404 fails with "getPaste returned 410, which its operation does not document", and a documented 409 nothing sends fails the full run with exit status 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
Raw paste uploads are the one request body ApiRequestValidation does not validate, because open_api_spex matches bodies by exact content type. The controller then reads and spools the body itself. Existing tests stopped at about 8 KB, a single read, so nothing showed that a body several read chunks long still arrives unread and whole once validation has run. This sends 300 KB of random bytes with validated query fields and checks the stored bytes and fields. The same path was checked by hand against a local server with disk storage: an 800 KB pipe from the CLI, a 300 KB binary file (@path), and a chunked curl upload all stored byte-identical content; an oversized stream got 413 and an undeclared query field 422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
Reject _json wrappers and undeclared parsed/raw bodies before actions run. Derive paste scope and deletion dispatch only from path parameters. Leave non-JSON paste upload media unread ahead of standard parsers, then spool exact bytes with query-only metadata. Regressions reproduce validation/controller mismatches and verify unchanged lifecycle persistence plus exact upload bytes across personal/workspace routes. 581 tests, full status coverage and Credo pass; OpenAPI export is unchanged. Oracle signed off.
|
Oracle review complete: signed off after resolving three request-boundary blockers. Reject _json wrappers so validation and controller consumption cannot see different objects; reject undeclared JSON/raw bodies on bodyless operations; derive scope/deletion dispatch only from route path params. A first-position paste upload parser now leaves non-JSON media types unread for the existing bounded spooler, with raw metadata taken only from query params. Regressions failed before fixes (conflicting _json create accepted, auth wrapper error, body-injected workspace deletion accepted, form bytes altered). Afterward tests cover rejection/unchanged persistence, every bodyless mutation operation with JSON/raw input, and exact-byte personal/workspace uploads for URL-encoded, multipart and +json content, including invalid JSON bytes. Preserved a concurrent remote 300KB multi-chunk upload regression via a history-preserving merge; oracle signed off on that final integration too. Final validation: mix precommit (582 ExUnit tests plus JS tests), full response-status coverage and mix credo passed; mix openapi.export is unchanged. Corrections committed and pushed without rewriting published history. No route authentication changes, merge to base, or deployment. Review session: https://ampcode.com/threads/T-01a12594-e2f1-75be-b343-9239564f5c84 Post-push CI is green at the final reviewed head: memory/local/S3 Elixir test matrix, Rust tests, formatting/linting, and production-container build passed. Release/publication jobs skipped as expected. |
Summary
Stacked on #78. In #78 the spec is a hand-written file away from the code, and only one test file checks responses, so drift goes unnoticed until someone looks. This PR makes the spec part of the code and enforces it on every request and in every test. No backwards compatibility is kept, since the API is pre-1.0.
1. The spec lives next to each action (
refactor×2)OpenApiSpex.ControllerSpecs, directly above the function.TextbinWeb.ApiSpecassembles the document from the router.TextbinWeb.ApiDocsholds the shared pieces: responses, UUID path parameters, and the paste query fields.workspace_*actions.open_api_spexlooks operations up by action, and only the workspace routes have aworkspace_id.in_workspace/1derives each workspace variant from its personal spec, so the personal and workspace operations can't diverge.operation. Without it, such a route would silently drop out of the document.2. Requests must match the spec (
feat!)TextbinWeb.ApiRequestValidationvalidates path parameters, query parameters and JSON bodies against the action's operation.ValidationErrorshape:{"errors": {"field": ["message"]}}.open_api_spexonly matches bodies by exact type, so these skip body validation, but their query parameters are still checked.{"paste": {...}}and bare JSON strings are rejected; send{"data": ...}.POST /auth/tokensreturns a 422 for a missing email or password, or a non-string name.3. Every API response in every test is checked (
test)TextbinWeb.ApiResponseChecksits in the:apipipeline and hands each response to a checker. It's switched on only inconfig/test.exs, at compile time, so production is unaffected.mix test, the suite fails if any documented response was never produced, so stale statuses can't linger. Partial runs skip this check.ApiContractTestnow covers what every operation shares, across all of them:4. Spec corrections found by the coverage check (
fix)deletePaste's 503 is removed. Delete no longer touches storage since the soft-delete lifecycle, so it can't fail there.Drift caught on purpose
surprisefield inPasteJSONcreatePasteInWorkspace 201 response does not match its operation: Unexpected field: surprisegetPasteInWorkspace returned 410, which its operation does not document (documented: [200, 401, 404, 422])getPaste 409{"errors":{"bogus":["Unexpected field: bogus"]}}When you change the API
Edit the
operationabove the action, runmix openapi.export, and commitpriv/openapi.json. If anything is missed, the tests fail.AGENTS.mdsays the same.Test plan
mix test: 577 tests, 0 failures. The full run passes the coverage check.mix credoreports no issues.mix compile --warnings-as-errorsis clean.whoami,createwith a title and reference,show,delete,deleted,restore, and permanent delete all pass. A bad ID and unknown parameters return readable field errors.🤖 Generated with Claude Code
https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
Generated by Claude Code