Repository navigation
feat: OpenAPI document for the v1 API with contract tests - #78
Conversation
RFD 3 asks for an OpenAPI document versioned with the server and checked for drift in CI. There was none. Add one, using open_api_spex: - TextbinWeb.ApiSpec describes all 21 operations: token creation and identity, organizations, workspaces, the audit log, workspace recovery, paste create/list/show/delete, the removed-paste lifecycle (both the personal and workspace-scoped routes) and raw paste content. - TextbinWeb.ApiSpec.Schemas holds the request and response schemas. Response objects are closed (additionalProperties: false), so a field added to a JSON view without being documented fails the contract tests that follow. - `mix openapi.export` writes priv/openapi.json, the copy for client generation and review. Tests fail when a route under /api/v1 has no operation in the document or the reverse, when a schema reference does not resolve, and when priv/openapi.json differs from the document in code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
TextbinWeb.ApiContract.assert_api_response/2 takes a real response and the operation it implements. The status must be one the document lists for that operation, and a JSON body must cast against its schema. Response schemas are closed, so an undocumented field fails as surely as a missing or mistyped one. Each of the 21 operations is exercised for its success response and its main errors, including binary paste content, the removed-paste lifecycle in both scopes, and the raw endpoint. Adding a field to PasteJSON, or changing a field's type, fails these tests until the document says so. Tick the two RFD 3 items this and #71 complete: canonical and raw URLs in paste responses, and OpenAPI contract tests in CI (they run in mix test, in every storage matrix job). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
CI failed "priv/openapi.json is the exported spec" while it passed locally. The document builds two kinds of list from map iteration order: each schema's `required` list (Map.keys/1) and the create-paste query parameters. Atom-keyed maps stopped iterating alphabetically in OTP 26, so CI (OTP 28) produced those lists in a different order from a local OTP 25 build, and the committed export never matched. Sort both explicitly, and regenerate priv/openapi.json. Only list order changes; the document's content is the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
Validate decoded JSON without coercion before casting for formats and closed objects. Express paste text and binary content as exclusive required representations. Add negative contract tests for quoted booleans, missing/extra fields and malformed content. Both regressions failed before the fix; 582 ExUnit tests and Credo pass. Regenerate OpenAPI export.
|
Oracle review complete: signed off after resolving both contract-validation blockers. The response checker now validates original JSON wire types without coercion before retaining casting for formats/closed-object checks. Paste uses exclusive closed text/binary alternatives, requiring all binary fields and null-only data for binary content. Regenerated priv/openapi.json. Negative tests reject quoted booleans, missing/undocumented fields, incomplete binary representations, and mixed text/binary content. Both new tests failed before the source fix; afterward 14 focused contract tests and mix precommit (582 ExUnit tests plus JS tests) passed, as did mix credo. Fix committed and pushed without rewriting published history. No deployment triggered. Review session: https://ampcode.com/threads/T-01a12594-e2f1-75be-b343-9239564f5c84 CI is green at the reviewed head. For PR78 this includes the fresh post-fix memory/local/S3 test matrix, Rust tests, formatting/linting, and production-container build; 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) - 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 - [x] `mix test`: 577 tests, 0 failures. The full run passes the coverage check. - [x] `mix credo` reports no issues. `mix compile --warnings-as-errors` is clean. - [x] 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. - [x] The deliberate breakages in the table above are each caught. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- _Generated by [Claude Code](https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Darwin D. Wu <darwin67@users.noreply.github.com>
Summary
RFD 3 asks for an OpenAPI document that is versioned with the server and checked for drift in CI. The repo didn't have one. This PR adds the document and the checks that keep it accurate.
New dependency:
open_api_spex3.22 (MPL-2.0), the standard Phoenix library for OpenAPI. The spec is written in Elixir, and the library also provides the JSON export and the schema casting the tests use. It has no open security advisories.Commits
feat: the document.TextbinWeb.ApiSpecdescribes all 21 operations:TextbinWeb.ApiSpec.Schemasholds the request and response schemas.mix openapi.exportwritespriv/openapi.json, the copy for client generation, such as the TypeScript SDK the RFD mentions. It also puts contract changes in front of reviewers./api/v1route has no operation in the document, or the document has an operation with no route;priv/openapi.jsonis out of date.test: contract tests.assert_api_response(conn, operation_id)checks a real response against the operation it implements. The status must be one the document lists, and a JSON body must match its schema.additionalProperties: false). An undocumented field therefore fails a test, just like a missing or mistyped one.How drift gets caught. These are ordinary
mix testtests, so they already run in every CI storage matrix job. Changing a JSON view, a route, or the document without updating the others fails CI. To check the tests catch something, I added an undocumentedsurprisefield toPasteJSONand changedtitleto a number. Each change failed the tests with a readable message naming the operation and the field.When you change the API: update
TextbinWeb.ApiSpec, runmix openapi.export, and commitpriv/openapi.json.Test plan
mix test: 580 tests, 0 failures, 12 of them new contract tests.mix credoreports no issues.mix compile --warnings-as-errorsis clean.🤖 Generated with Claude Code
https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6
Generated by Claude Code