Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ Do these in order. Each task is a thin end-to-end slice: test first, implement,
- [x] Tests: preflight on `/bins` and `/bins/:id`; a disallowed origin gets no ACAO header; `X-Bin-*` headers are exposed

## 10. OpenAPI
- [ ] Every route is defined with `createRoute` (request headers, bodies and all error responses described)
- [ ] `GET /openapi.json` serves the spec; `GET /docs` serves a Swagger UI or Scalar page
- [ ] Test: the spec is valid and lists every route in api.md
- [x] Every route is defined with `createRoute` (request headers, bodies and all error responses described)
- [x] `GET /openapi.json` serves the spec; `GET /docs` serves a Swagger UI or Scalar page
- [x] Test: the spec is valid and lists every route in api.md

## 11. Self-hosting docs and demo
- [ ] README: quick start with curl examples (create/read/replace/delete, private, TTL); a "Deploy to Cloudflare" button
Expand Down
3 changes: 3 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ This is what the MVP implements. Terms (Bin, Write Key, Read Key, Private Bin, E
### `GET /health`
`200 {"status":"ok"}`. Needs no auth and doesn't touch D1.

### `GET /openapi.json` and `GET /docs`
`GET /openapi.json` returns `200` with this contract as an OpenAPI 3.1 spec: every route above and below, its request headers, bodies, responses and error codes, and a `bearerAuth` security scheme. `GET /docs` returns `200` with an HTML page (Swagger UI, loaded from a CDN at a pinned version) that renders it. Like `/health`, both need no auth, aren't rate limited and don't touch D1.

### `POST /bins`: create a Bin

| Request header | Meaning |
Expand Down
20 changes: 9 additions & 11 deletions src/bins/routes.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import type { TypedResponse } from "hono";
import type { AppEnv } from "../context";
import { ApiError } from "../errors";
import { requireKey } from "./auth";
import { parseBinBody } from "./body";
import { resolveExpiresAt } from "./expiry";
import { generateBinId, generateReadKey, generateWriteKey, hashKey, isValidBinId } from "./keys";
import { binMetadataHeaders, toIso } from "./metadata";
import { rateLimit } from "./rate-limit";
import { type Bin, findLive, insert, remove, replaceContent } from "./repository";
import { type BinContent, createBinRoute, deleteBinRoute, readBinRoute, replaceBinRoute } from "./spec";

export const binRoutes = new OpenAPIHono<AppEnv>();

// Rate limiting comes first, before the id, auth and body checks (docs/api.md → Validation and errors): it protects
// the Worker, so a limited client shouldn't get D1 lookups, key hashing or body parsing. Reads aren't limited.
binRoutes.post("/", rateLimit("CREATE_LIMITER"));
binRoutes.on(["PUT", "DELETE"], "/:id", rateLimit("WRITE_LIMITER"));

binRoutes.post("/", async (c) => {
binRoutes.openapi(createBinRoute, async (c) => {
const now = Date.now();
// X-Bin-TTL is checked before the body (docs/api.md → Validation and errors): it is a header, so a bad one is
// rejected without reading the body, and parseBinBody stays the same for PUT, which ignores X-Bin-TTL.
Expand Down Expand Up @@ -53,17 +49,19 @@ binRoutes.post("/", async (c) => {
);
});

binRoutes.get("/:id", async (c) => {
binRoutes.openapi(readBinRoute, async (c) => {
const bin = await findLiveBin(c.env.DB, c.req.param("id"), Date.now());
await requireKey(c, bin, "read");

return c.body(bin.content, 200, {
// The stored text is sent as is, which c.json can't do: it would re-serialize it. It is the JSON the route describes.
const res = c.body(bin.content, 200, {
"Content-Type": "application/json; charset=utf-8",
...binMetadataHeaders(bin),
});
return res as typeof res & TypedResponse<BinContent, 200, "json">;
});

binRoutes.put("/:id", async (c) => {
binRoutes.openapi(replaceBinRoute, async (c) => {
const bin = await findLiveBin(c.env.DB, c.req.param("id"), Date.now());
// Auth comes before the body is read (docs/api.md → Validation and errors).
await requireKey(c, bin, "write");
Expand All @@ -88,7 +86,7 @@ binRoutes.put("/:id", async (c) => {
);
});

binRoutes.delete("/:id", async (c) => {
binRoutes.openapi(deleteBinRoute, async (c) => {
const bin = await findLiveBin(c.env.DB, c.req.param("id"), Date.now());
await requireKey(c, bin, "write");

Expand Down
204 changes: 204 additions & 0 deletions src/bins/spec.ts
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" };
Comment thread
avinoth marked this conversation as resolved.

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"),
},
});
25 changes: 14 additions & 11 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ import type { Context } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";

/** The error codes in docs/api.md → Validation and errors. */
export type ErrorCode =
| "invalid_json"
| "invalid_ttl"
| "unauthorized"
| "forbidden"
| "not_found"
| "payload_too_large"
| "unsupported_media_type"
| "invalid_top_level"
| "rate_limited"
| "internal_error";
export const ERROR_CODES = [
"invalid_json",
"invalid_ttl",
"unauthorized",
"forbidden",
"not_found",
"payload_too_large",
"unsupported_media_type",
"invalid_top_level",
"rate_limited",
"internal_error",
] as const;

export type ErrorCode = (typeof ERROR_CODES)[number];

/** Thrown anywhere in a request; `app.onError` turns it into the error response of docs/api.md. */
export class ApiError extends Error {
Expand Down
35 changes: 33 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { sweepExpired } from "./bins/expiry";
import { binRoutes } from "./bins/routes";
import { type AppEnv, withConfig } from "./context";
import { withCors } from "./cors";
import { ApiError, errorResponse } from "./errors";
import { BEARER_AUTH, docsPage, errorResponses, SPEC_INFO } from "./openapi";

export const app = new OpenAPIHono<AppEnv>();

Expand All @@ -18,10 +19,40 @@ app.use(async (c, next) => {
app.use(withConfig);
app.use(withCors);

app.get("/health", (c) => c.json({ status: "ok" }));
const healthRoute = createRoute({
method: "get",
path: "/health",
tags: ["Instance"],
summary: "Check that the Instance is up",
description: "Needs no key and doesn't touch D1.",
responses: {
200: {
description: "The Instance is up.",
content: { "application/json": { schema: z.object({ status: z.literal("ok") }) } },
},
...errorResponses("internal_error"),
},
});

app.openapi(healthRoute, (c) => c.json({ status: "ok" as const }, 200));

app.route("/bins", binRoutes);

app.openAPIRegistry.registerComponent("securitySchemes", BEARER_AUTH, {
type: "http",
scheme: "bearer",
description: "A Bin's Write Key (`kj_w_…`) or Read Key (`kj_r_…`), as `Authorization: Bearer <key>`.",
});

// The spec and its viewer describe every route registered above. Like /health, they need no key, aren't rate limited
// and don't touch D1. The spec is built once, on first use, when every route is registered.
let spec: ReturnType<typeof app.getOpenAPI31Document> | undefined;
app.get("/openapi.json", (c) => {
spec ??= app.getOpenAPI31Document({ openapi: "3.1.0", info: SPEC_INFO });
return c.json(spec);
});
app.get("/docs", (c) => c.html(docsPage("/openapi.json")));

app.notFound((c) => errorResponse(c, 404, "not_found", "Not found"));

app.onError((err, c) => {
Expand Down
Loading
Loading