Skip to content

feat: keep the API and its OpenAPI document in sync continuously - #79

Merged
darwin67 merged 8 commits into
feat/openapi-contractfrom
feat/openapi-in-controllers
Oct 11, 2026
Merged

darwin67 merged 8 commits into
feat/openapi-contractfrom
feat/openapi-in-controllers

Conversation

@darwin67

Copy link
Copy Markdown
Member

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)

  • Each controller action declares its operation with OpenApiSpex.ControllerSpecs, directly above the function.
  • TextbinWeb.ApiSpec assembles the document from the router.
  • TextbinWeb.ApiDocs holds the shared pieces: responses, UUID path parameters, and the paste query fields.
  • Workspace-scoped paste routes now have their own workspace_* actions. open_api_spex looks operations up by action, and only the workspace routes have a workspace_id. in_workspace/1 derives each workspace variant from its personal spec, so the personal and workspace operations can't diverge.
  • A test fails if any API action has no operation. Without it, such a route would silently drop out of the document.

2. Requests must match the spec (feat!)

  • TextbinWeb.ApiRequestValidation validates path parameters, query parameters and JSON bodies against the action's operation.
  • Anything else gets a 422 in the existing ValidationError shape: {"errors": {"field": ["message"]}}.
  • Request schemas are closed, and an operation with no query parameters accepts none.
  • Raw paste uploads keep any content type, since it's stored with the paste. open_api_spex only matches bodies by exact type, so these skip body validation, but their query parameters are still checked.
  • Breaking changes:
    • Bodies must be JSON.
    • {"paste": {...}} and bare JSON strings are rejected; send {"data": ...}.
    • A malformed UUID is now a 422, not a 400 (or a 204 for delete).
    • POST /auth/tokens returns a 422 for a missing email or password, or a non-string name.

3. Every API response in every test is checked (test)

  • TextbinWeb.ApiResponseCheck sits in the :api pipeline and hands each response to a checker. It's switched on only in config/test.exs, at compile time, so production is unaffected.
  • The checker fails the test unless the status is documented and the body matches its schema. Every existing API test is therefore a contract test with no edits.
  • After a full mix test, the suite fails if any documented response was never produced, so stale statuses can't linger. Partial runs skip this check.
  • ApiContractTest now covers what every operation shares, across all of them:
    • a 401 with no token;
    • a 422 for an undeclared query parameter;
    • each documented 404 for unknown IDs;
    • purge's 503 when storage can't delete.

4. Spec corrections found by the coverage check (fix)

  • "Workspace not found" (404) is no longer listed on personal-workspace operations; that workspace always exists.
  • 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

Change Result
Extra surprise field in PasteJSON 23 paste tests fail: createPasteInWorkspace 201 response does not match its operation: Unexpected field: surprise
410 instead of 404 for a missing paste Fails with getPasteInWorkspace returned 410, which its operation does not document (documented: [200, 401, 404, 422])
A documented 409 nothing sends The full run exits 1 and lists getPaste 409
Undocumented query parameter or legacy body 422, e.g. {"errors":{"bogus":["Unexpected field: bogus"]}}

When you change the API

Edit the operation above the action, run mix openapi.export, and commit priv/openapi.json. If anything is missed, the tests fail. AGENTS.md says the same.

Test plan

  • mix test: 577 tests, 0 failures. The full run passes the coverage check.
  • mix credo reports no issues. mix compile --warnings-as-errors is clean.
  • I ran the CLI end to end against a validating local server: whoami, create with a title and reference, show, delete, deleted, restore, and permanent delete all pass. A bad ID and unknown parameters return readable field errors.
  • The deliberate breakages in the table above are each caught.

🤖 Generated with Claude Code

https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6


Generated by Claude Code

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
claude and others added 2 commits October 11, 2026 15:08
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.
@darwin67

darwin67 commented Oct 11, 2026 •

Copy link
Copy Markdown
Member Author

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.

@darwin67
darwin67 marked this pull request as ready for review October 11, 2026 15:37
@darwin67 darwin67 changed the title feat!: keep the API and its OpenAPI document in sync continuously feat: keep the API and its OpenAPI document in sync continuously Oct 11, 2026
@darwin67
darwin67 added this pull request to stack #80 October 11, 2026 15:37
@darwin67
darwin67 merged commit cb3a894 into main Oct 11, 2026
25 checks passed
@darwin67
darwin67 deleted the feat/openapi-in-controllers branch October 11, 2026 15:38
@chaba2-bot chaba2-bot Bot mentioned this pull request Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants