Skip to content

feat: OpenAPI document for the v1 API with contract tests - #78

Merged
darwin67 merged 4 commits into
mainfrom
feat/openapi-contract
Oct 11, 2026
Merged

darwin67 merged 4 commits into
mainfrom
feat/openapi-contract

Conversation

@darwin67

Copy link
Copy Markdown
Member

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_spex 3.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

  1. feat: the document.
    • TextbinWeb.ApiSpec describes all 21 operations:
      • Token creation and identity.
      • Organizations, workspaces, the audit log and workspace recovery.
      • Paste create, list, show and delete.
      • The removed-paste lifecycle, under both the personal and the workspace-scoped routes.
      • Raw paste content.
    • TextbinWeb.ApiSpec.Schemas holds the request and response schemas.
    • mix openapi.export writes priv/openapi.json, the copy for client generation, such as the TypeScript SDK the RFD mentions. It also puts contract changes in front of reviewers.
    • Tests fail if:
      • an /api/v1 route has no operation in the document, or the document has an operation with no route;
      • a schema reference doesn't resolve;
      • priv/openapi.json is out of date.
  2. 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.
    • Response schemas are closed (additionalProperties: false). An undocumented field therefore fails a test, just like a missing or mistyped one.
    • Every operation is exercised for its success response and its main errors.
    • The two RFD 3 items this PR and feat: paste links and removed-paste lifecycle in the API #71 complete are ticked.

How drift gets caught. These are ordinary mix test tests, 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 undocumented surprise field to PasteJSON and changed title to 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, run mix openapi.export, and commit priv/openapi.json.

Test plan

  • mix test: 580 tests, 0 failures, 12 of them new contract tests.
  • mix credo reports no issues. mix compile --warnings-as-errors is clean.
  • The tests fail on an undocumented response field and on a field with the wrong type.

🤖 Generated with Claude Code

https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6


Generated by Claude Code

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
@github-actions github-actions Bot added the feat label Oct 11, 2026
claude and others added 2 commits October 11, 2026 05:25
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.
@darwin67

darwin67 commented Oct 11, 2026 •

Copy link
Copy Markdown
Member Author

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.

@darwin67
darwin67 marked this pull request as ready for review October 11, 2026 14:05
@darwin67
darwin67 added this pull request to stack #80 October 11, 2026 15:37
@darwin67
darwin67 merged commit 2a2fb09 into main Oct 11, 2026
17 checks passed
@darwin67
darwin67 deleted the feat/openapi-contract branch October 11, 2026 15:38
darwin67 added a commit that referenced this pull request Oct 11, 2026
## 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>
@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