Repository navigation
Describe the API with OpenAPI: /openapi.json and /docs #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
8e2f57b
Scaffold the Worker: Hono app, config parsing, /health, tooling (TODO…
claude 95f8d32
Add the bins schema, repository and key helpers (TODO §1)
claude 340163d
Create a Bin with POST /bins (TODO §2)
claude c0a2f97
Read a public Bin with GET /bins/:id (TODO §3)
claude dc4b4d1
Private Bins: Read Keys and Bearer auth on GET (TODO §4)
claude 1e6dd39
Replace a Bin's content with PUT /bins/:id
claude aa53abf
Delete a Bin with DELETE /bins/:id
claude a721a8d
Expire Bins: X-Bin-TTL, lazy 404 and the hourly sweep
claude 53c07ad
Rate limit POST, PUT and DELETE on /bins per client IP
claude 72d2fcf
Send CORS headers on every response, driven by CORS_ORIGINS
claude 30d4350
Describe the API with OpenAPI: /openapi.json and /docs
claude 287bb43
Attach the rate limiters with createRoute middleware
claude c1d427b
Merge main into the task 10 branch after the previous PR was squash-m…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| import { createRoute, z } from "@hono/zod-openapi"; | ||
| import { errorResponses, IsoTimestamp, OPTIONAL_BEARER_AUTH, REQUIRED_BEARER_AUTH } from "../openapi"; | ||
| import { rateLimit } from "./rate-limit"; | ||
|
|
||
| // The OpenAPI routes for Bins (docs/api.md → Routes). Request headers, the id and the body are described as plain | ||
| // OpenAPI objects rather than Zod schemas, so zod-openapi adds no validators: the handlers check them in the order | ||
| // of docs/api.md, a malformed id stays a 404 and the body is stored byte-for-byte. | ||
|
|
||
| /** | ||
| * A Bin's content: any JSON object or array. Only responses use it as a Zod schema, which zod-openapi never | ||
| * validates; request bodies point at it with a plain `$ref`, so no validator is added. | ||
| */ | ||
| export const BinContent = z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())]).openapi("BinContent", { | ||
| description: "The JSON to store: any object or array, empty included. It is stored and returned byte-for-byte.", | ||
| example: { theme: "dark", tags: ["a", "b"] }, | ||
| }); | ||
|
|
||
| export type BinContent = z.infer<typeof BinContent>; | ||
|
|
||
| const binContentRef = { $ref: "#/components/schemas/BinContent" }; | ||
|
|
||
| const BinId = z.string().openapi("BinId", { | ||
| description: "21 URL-safe characters, unguessable.", | ||
| pattern: "^[A-Za-z0-9_-]{21}$", | ||
| example: "V1StGXR8_Z5jdHi6B-myT", | ||
| }); | ||
|
|
||
| const BinMetadataHeaders = z.object({ | ||
| "X-Bin-Id": BinId, | ||
| "X-Bin-Private": z.enum(["true", "false"]).openapi({ description: "Whether the Bin is a Private Bin." }), | ||
| "X-Bin-Created-At": IsoTimestamp.openapi({ description: "When the Bin was created." }), | ||
| "X-Bin-Updated-At": IsoTimestamp.openapi({ description: "When the Bin's content was last written." }), | ||
| "X-Bin-Expires-At": IsoTimestamp.optional().openapi({ | ||
| description: "The Bin's Expiry. Left out when the Bin has no Expiry.", | ||
| }), | ||
| }); | ||
|
|
||
| const CreatedBin = z | ||
| .object({ | ||
| id: BinId, | ||
| writeKey: z.string().openapi({ | ||
| description: "The Write Key: replaces or deletes the Bin. Shown only once.", | ||
| pattern: "^kj_w_", | ||
| example: "kj_w_…", | ||
| }), | ||
| readKey: z.string().optional().openapi({ | ||
| description: "The Read Key, only for a Private Bin: reads it. Shown only once.", | ||
| pattern: "^kj_r_", | ||
| example: "kj_r_…", | ||
| }), | ||
| private: z.boolean(), | ||
| createdAt: IsoTimestamp, | ||
| expiresAt: IsoTimestamp.nullable().openapi({ description: "The Bin's Expiry, or null when it never expires." }), | ||
| }) | ||
| .openapi("CreatedBin"); | ||
|
|
||
| const ReplacedBin = z | ||
| .object({ | ||
| id: BinId, | ||
| private: z.boolean(), | ||
| createdAt: IsoTimestamp, | ||
| updatedAt: IsoTimestamp, | ||
| expiresAt: IsoTimestamp.nullable().openapi({ description: "The Bin's Expiry, or null when it never expires." }), | ||
| }) | ||
| .openapi("ReplacedBin"); | ||
|
|
||
| const idParameter = { | ||
| name: "id", | ||
| in: "path" as const, | ||
| required: true, | ||
| description: "The Bin's id. A malformed id is answered like an unknown one: `404 not_found`.", | ||
| schema: { type: "string" as const, pattern: "^[A-Za-z0-9_-]{21}$" }, | ||
| }; | ||
|
|
||
| const binBody = { | ||
| required: true, | ||
| description: | ||
| "Sent with `Content-Type: application/json` (anything else → `415`). At most the Instance's `MAX_BIN_BYTES`.", | ||
| content: { "application/json": { schema: binContentRef } }, | ||
| }; | ||
|
|
||
| export const createBinRoute = createRoute({ | ||
| method: "post", | ||
| // Rate limiting runs before the handler, so before the id, auth and body checks (docs/api.md → Validation and | ||
| // errors): a limited client gets no D1 lookup, key hashing or body parsing. | ||
| middleware: [rateLimit("CREATE_LIMITER")] as const, | ||
| path: "/", | ||
| tags: ["Bins"], | ||
| summary: "Create a Bin", | ||
| description: | ||
| "Stores the body as a new Bin and returns its keys, which are shown only once. Checks run in this order: rate " + | ||
| "limit, `X-Bin-TTL`, content type, size, JSON, top-level type.", | ||
| parameters: [ | ||
| { | ||
| name: "X-Bin-Private", | ||
| in: "header", | ||
| required: false, | ||
| description: | ||
| "`true` (any case, surrounding whitespace ignored) makes a Private Bin and issues a Read Key. Any other value " + | ||
| "makes a public Bin. Fixed at creation.", | ||
| schema: { type: "string", example: "true" }, | ||
| }, | ||
| { | ||
| name: "X-Bin-TTL", | ||
| in: "header", | ||
| required: false, | ||
| description: | ||
| "Seconds until the Bin's Expiry: ASCII digits only, not 0, and at most the Instance's maximum TTL (else " + | ||
| "`400 invalid_ttl`). Without it, the Instance's default TTL applies, otherwise its maximum, otherwise none.", | ||
| schema: { type: "integer", minimum: 1, example: 3600 }, | ||
| }, | ||
| ], | ||
| request: { body: binBody }, | ||
| responses: { | ||
| 201: { | ||
| description: "The Bin was created. `Location` is its URL.", | ||
| headers: BinMetadataHeaders.extend({ | ||
| Location: z.string().openapi({ description: "The new Bin's path.", example: "/bins/V1StGXR8_Z5jdHi6B-myT" }), | ||
| }), | ||
| content: { "application/json": { schema: CreatedBin } }, | ||
| }, | ||
| ...errorResponses( | ||
| "invalid_json", | ||
| "invalid_ttl", | ||
| "payload_too_large", | ||
| "unsupported_media_type", | ||
| "invalid_top_level", | ||
| "rate_limited", | ||
| "internal_error", | ||
| ), | ||
| }, | ||
| }); | ||
|
|
||
| export const readBinRoute = createRoute({ | ||
| method: "get", | ||
| path: "/{id}", | ||
| tags: ["Bins"], | ||
| summary: "Read a Bin", | ||
| description: | ||
| "Returns the stored JSON byte-for-byte as last written. A public Bin needs no key and ignores `Authorization`. " + | ||
| "A Private Bin needs its Read Key or Write Key.", | ||
| parameters: [idParameter], | ||
| security: OPTIONAL_BEARER_AUTH, | ||
| responses: { | ||
| 200: { | ||
| description: "The Bin's content, sent as `application/json; charset=utf-8`.", | ||
| headers: BinMetadataHeaders, | ||
| content: { "application/json": { schema: BinContent } }, | ||
| }, | ||
| ...errorResponses("unauthorized", "forbidden", "not_found", "internal_error"), | ||
| }, | ||
| }); | ||
|
|
||
| export const replaceBinRoute = createRoute({ | ||
| method: "put", | ||
| // Rate limiting runs before the handler, so before the id, auth and body checks (docs/api.md → Validation and | ||
| // errors): a limited client gets no D1 lookup, key hashing or body parsing. | ||
| middleware: [rateLimit("WRITE_LIMITER")] as const, | ||
| path: "/{id}", | ||
| tags: ["Bins"], | ||
| summary: "Replace a Bin's content", | ||
| description: | ||
| "Needs the Write Key. Body rules are the same as for creating a Bin, and last write wins. Privacy and Expiry " + | ||
| "don't change: `X-Bin-Private` and `X-Bin-TTL` are ignored. Checks run in this order: rate limit, id, key, " + | ||
| "content type, size, JSON, top-level type.", | ||
| parameters: [idParameter], | ||
| security: REQUIRED_BEARER_AUTH, | ||
| request: { body: binBody }, | ||
| responses: { | ||
| 200: { | ||
| description: "The content was replaced.", | ||
| headers: BinMetadataHeaders, | ||
| content: { "application/json": { schema: ReplacedBin } }, | ||
| }, | ||
| ...errorResponses( | ||
| "invalid_json", | ||
| "unauthorized", | ||
| "forbidden", | ||
| "not_found", | ||
| "payload_too_large", | ||
| "unsupported_media_type", | ||
| "invalid_top_level", | ||
| "rate_limited", | ||
| "internal_error", | ||
| ), | ||
| }, | ||
| }); | ||
|
|
||
| export const deleteBinRoute = createRoute({ | ||
| method: "delete", | ||
| // Rate limiting runs before the handler, so before the id, auth and body checks (docs/api.md → Validation and | ||
| // errors): a limited client gets no D1 lookup, key hashing or body parsing. | ||
| middleware: [rateLimit("WRITE_LIMITER")] as const, | ||
| path: "/{id}", | ||
| tags: ["Bins"], | ||
| summary: "Delete a Bin", | ||
| description: "Needs the Write Key. The Bin is gone immediately and permanently.", | ||
| parameters: [idParameter], | ||
| security: REQUIRED_BEARER_AUTH, | ||
| responses: { | ||
| 204: { description: "The Bin was deleted." }, | ||
| ...errorResponses("unauthorized", "forbidden", "not_found", "rate_limited", "internal_error"), | ||
| }, | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.