Skip to content

Describe the API with OpenAPI: /openapi.json and /docs - #11

Merged
avinoth merged 13 commits into
mainfrom
claude/keepjson-tasks-zdcoz5-task10
Oct 10, 2026
Merged

avinoth merged 13 commits into
mainfrom
claude/keepjson-tasks-zdcoz5-task10

Conversation

@avinoth

@avinoth avinoth commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Implements docs/TODO.md §10. All its boxes are ticked. Stacked on #10: merge the earlier PRs first, in order.

Changes

  • Every route uses createRoute + .openapi(...): GET /health, POST /bins, and GET, PUT and DELETE /bins/:id. Definitions live in src/bins/spec.ts and src/openapi.ts.
    • The spec covers path params, request headers, the request body, success bodies and their headers (Location, X-Bin-*, Retry-After).
    • Every error response per route uses one shared { error: { code, message } } schema whose code enum covers the whole table in api.md.
  • Bearer auth: a bearerAuth scheme, required on PUT and DELETE and optional on GET.
  • GET /openapi.json: serves the OpenAPI 3.1 spec.
  • GET /docs: a Swagger UI page that loads swagger-ui-dist@5.32.15 from jsDelivr with SRI hashes. No new runtime dependency.
  • docs/api.md: lists the two new routes.

Behaviour is unchanged

Request headers, the id param and the request body are described as plain OpenAPI objects, not Zod schemas. So zod-openapi adds no validators: parseBinBody, resolveExpiresAt, requireKey and the 404 for malformed ids keep their order and error codes. The rate-limit middleware still runs first. All 276 earlier tests pass unchanged.

Notes

  • Authorization and Content-Type aren't listed as header parameters, because OpenAPI 3.1 ignores those. They're described through the security scheme and the request body instead.
  • Spec validation: the spec passed a real OpenAPI 3.1 validator run outside the test pool. That validator can't run inside the Workers test pool, so the tests check the structure instead.
  • SRI hashes: they match the npm tarball of swagger-ui-dist@5.32.15, which jsDelivr serves unchanged. I couldn't reach the CDN from the sandbox to confirm directly, so please open /docs once after deploying.

Tests

25 new tests:

  • Spec contents: OpenAPI 3.1, the title, exactly the documented paths and methods, the exact status codes per operation, each error code under its status, the full code enum, every $ref resolves, the response headers, params, bodies and security.
  • The two new endpoints: /openapi.json and /docs send CORS and no-store headers. They are tested with D1 and the limiters set to throw, so a call to either would fail the test.

Verification

pnpm check passes: typecheck, lint, and 301 tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX


Generated by Claude Code

claude added 11 commits October 9, 2026 04:30
… §0)

- pnpm project with dev/deploy/test/typecheck/lint/format/check/db:migrate:local scripts
- wrangler.jsonc with the DB binding, CREATE_LIMITER/WRITE_LIMITER, hourly cron and Instance vars
- Env type generated by `wrangler types` (`pnpm typegen`), so @cloudflare/workers-types isn't needed
- src/config.ts parses vars with zod, applying defaults and rejecting MAX_BIN_BYTES > 1,000,000
- GET /health, tests for /health and config parsing, Biome, CI workflow

Tests use @cloudflare/vitest-plugin, the renamed and maintained
@cloudflare/vitest-pool-workers (the old name is deprecated on npm).
AGENTS.md and TODO.md now name the new package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
- migrations/0001_bins.sql: the schema drafted in docs/api.md → Storage.
- Tests apply the migrations to the local D1 through a vitest setup file
  (readD1Migrations in vitest.config.ts, applyD1Migrations in the worker).
- src/bins/repository.ts holds all SQL: insert, findLive (leaves out
  expired Bins), replaceContent (live Bins only), remove, and
  deleteExpired(now, limit) for the Expiry sweep.
- src/bins/keys.ts: Bin ids (nanoid), writeKey (kj_w_) and readKey (kj_r_)
  generation, hex SHA-256 hashKey and a constant-time matches.
- Unit tests run the repository against the real local D1 and check key
  format, prefixes and a known SHA-256 vector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
- parseBinBody(request, config) in src/bins/body.ts validates in the
  api.md order: 415 unsupported_media_type, 413 payload_too_large
  (Content-Length first, then the bytes actually read, stopping as soon
  as the limit is passed), 400 invalid_json (including invalid UTF-8),
  422 invalid_top_level. It returns the body text exactly as received,
  so PUT (§5) can reuse it.
- POST /bins stores the content and the writeKey hash through the
  repository and returns 201 with Location, the X-Bin-* metadata headers
  and { id, writeKey, private, createdAt, expiresAt }. Bins are public
  with no Expiry until §4 and §7.
- Shared pieces: ApiError and errorResponse (src/errors.ts), the config
  middleware (src/context.ts), binMetadataHeaders (src/bins/metadata.ts),
  Cache-Control: no-store on every response, and the error shape for
  unknown routes.
- parseConfig now takes any object so it accepts the Worker's Env.

Contract change: docs/api.md gains a 500 internal_error row. Unexpected
errors still need the documented error shape, and the table had no code
for them; the details are neither returned nor logged because a request
can carry a writeKey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
- GET /bins/:id returns the stored content byte-for-byte with
  Content-Type: application/json; charset=utf-8, the X-Bin-* metadata
  headers and Cache-Control: no-store.
- findLiveBin(db, id, now) in src/bins/routes.ts answers 404 not_found
  for a malformed id (checked with isValidBinId before D1 is asked), an
  unknown id or an expired Bin. PUT (§5) and DELETE (§6) can reuse it,
  and §4 adds the Private Bin check right after it in the GET handler.
- Tests: create → read round trips (object, array, empty, unicode,
  whitespace, key order, number formats, raw bytes), headers matching
  the create response with no X-Bin-Expires-At, the writeKey never sent,
  and 404s for unknown and malformed ids, including a malformed-id row
  inserted straight into D1 to prove it is never looked up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
X-Bin-Private: true on POST /bins makes a Private Bin: a kj_r_ Read Key is
returned once as readKey and only its SHA-256 is stored in read_key_hash.
Only the value "true" (any case, trimmed) counts; any other value makes a
public Bin, since the contract has no error code for this header.

GET /bins/:id on a Private Bin now needs Authorization: Bearer with the
Read Key or the Write Key (401 unauthorized without a key, 403 forbidden
with a wrong one). A public Bin ignores the Authorization header. The new
requireKey(c, bin, "read" | "write") helper in src/bins/auth.ts compares
keys in constant time, and PUT/DELETE can reuse it with "write".

docs/api.md: clarify (no change in meaning) that only "true" makes a
Private Bin and any other X-Bin-Private value makes a public one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
TODO §5. PUT needs the Write Key and checks, in the order of docs/api.md,
the id (404 for malformed, unknown or expired), then auth (401 without a
key, 403 for a Read Key or a wrong key), then the body via parseBinBody
(415, 413, 400, 422). The body is not read before auth passes.

The content is replaced through the repository: updatedAt moves forward
while createdAt, privacy and Expiry stay as they were; X-Bin-Private and
X-Bin-TTL are ignored. If the Bin disappears or expires between the
lookup and the update, the response is 404. The 200 response carries the
metadata headers and {id, private, createdAt, updatedAt, expiresAt}, and
never a key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
TODO §6. DELETE needs the Write Key and checks, in the order of
docs/api.md, the id (404 for malformed, unknown or expired, before auth),
then auth (401 without a key, 403 for a Read Key or a wrong key). The
Bin is then removed through the repository and the response is 204 with
no body. If another request deleted the Bin between the lookup and the
removal, the response is 404.

The Bin is gone immediately: a later GET or PUT gets 404, and a second
DELETE gets 404. Other Bins are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
TODO §7. POST /bins resolves the Expiry from X-Bin-TTL and the Instance's
default/maximum TTL (src/bins/expiry.ts resolveExpiresAt), returns it as
expiresAt and X-Bin-Expires-At, and rejects a bad TTL with 400 invalid_ttl.
The scheduled handler sweeps expired Bins in batches of 500 until none are
left or 20 s have passed (sweepExpired).

remove now takes `now` and deletes only live Bins, so a Bin that expires
between the lookup and the DELETE is a 404 rather than a 204.

docs/api.md changes: the contract didn't say where X-Bin-TTL sits in the
validation order or what exactly counts as a positive integer. X-Bin-TTL is
now checked right after auth, before the content type and the body, since it
is a header and can be rejected without reading the body; the accepted
syntax (ASCII digits only) and the too-large case are spelled out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
TODO §8. CREATE_LIMITER guards POST /bins and WRITE_LIMITER guards PUT and
DELETE /bins/:id, keyed on CF-Connecting-IP. Requests without that header
share one fallback bucket ("unknown") instead of going unlimited. Over the
limit the response is 429 rate_limited with Retry-After set to the
bindings' 60 s period, since a binding can't report when its window
resets. RATE_LIMIT_ENABLED=false skips the limiter call entirely.

Contract change in docs/api.md: the rate limit is now the first step of
the validation order, ahead of route/id, auth and body checks. It exists
to protect the Worker, so a limited client should not cost a D1 lookup,
key hashing or body parsing. The Rate limiting section also documents
Retry-After and the missing-IP fallback key.

The test worker now runs with RATE_LIMIT_ENABLED=false (vitest.config.ts),
because the real Miniflare CREATE_LIMITER would start rejecting the many
Bins other test files create. test/rate-limit.test.ts calls the app
directly with stub limiter bindings and rate limiting turned on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
TODO §9. Hono's cors() runs at the app level, after the config middleware
(now also app-level so the origin check reads the parsed CORS_ORIGINS for
each request) and before the routes. So /health, 404s, onError responses
and 429s all carry the CORS headers, and preflights are answered with 204
before the rate limiter or D1. `*` sends ACAO *; an allowlist echoes the
Origin only on an exact match. No Access-Control-Allow-Credentials, since
keys go in the Authorization header.

Contract change in docs/api.md: Retry-After is added to
Access-Control-Expose-Headers. It isn't a CORS-safelisted response header,
so without it a browser couldn't read how long to wait after a 429. The
CORS section now also spells out the allowlist matching, Vary: Origin,
the exposed headers, no credentials and the preflight behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
TODO §10. Every route (GET /health, POST /bins, GET/PUT/DELETE /bins/:id) is
now registered with createRoute + app.openapi, describing its request headers,
the id path parameter, the JSON body (any object or array), each success
response with its headers and body, and every error response from
docs/api.md through one shared Error schema whose code is an enum of the
error codes. PUT and DELETE require the bearerAuth scheme; GET /bins/{id}
takes it optionally, since a public Bin needs no key.

Behaviour doesn't change: request headers, the id and the body are described
as plain OpenAPI objects rather than Zod schemas, so zod-openapi adds no
validators. parseBinBody, resolveExpiresAt, requireKey and the 404 for a
malformed id keep their order and error codes, and the rate limiter is still
registered ahead of each handler. Authorization and Content-Type are
described through the security scheme and the request body rather than as
header parameters, which OpenAPI 3.1 says to ignore.

GET /openapi.json serves the OpenAPI 3.1 spec and GET /docs a Swagger UI
page loaded from jsDelivr at a pinned version with SRI. docs/api.md → Routes
lists both endpoints, because they are new public routes of the contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
Review feedback on #9: registering rateLimit() as a separate route before
the handler works, but the idiomatic @hono/zod-openapi form is the
route's own middleware option, which keeps the limiter next to the route
it guards. Behaviour is unchanged: the limiter still runs before the id,
auth and body checks, as the rate-limit tests verify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
@avinoth
avinoth changed the base branch from claude/keepjson-tasks-zdcoz5-task9 to main October 10, 2026 02:16
Comment thread src/bins/spec.ts
@avinoth
avinoth merged commit 6c6e1de into main Oct 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants