diff --git a/docs/TODO.md b/docs/TODO.md index a05b670..896923f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/docs/api.md b/docs/api.md index 65ccbb7..0e4a345 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | diff --git a/src/bins/routes.ts b/src/bins/routes.ts index 9513bd2..9515988 100644 --- a/src/bins/routes.ts +++ b/src/bins/routes.ts @@ -1,4 +1,5 @@ import { OpenAPIHono } from "@hono/zod-openapi"; +import type { TypedResponse } from "hono"; import type { AppEnv } from "../context"; import { ApiError } from "../errors"; import { requireKey } from "./auth"; @@ -6,17 +7,12 @@ 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(); -// 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. @@ -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; }); -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"); @@ -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"); diff --git a/src/bins/spec.ts b/src/bins/spec.ts new file mode 100644 index 0000000..19a5b81 --- /dev/null +++ b/src/bins/spec.ts @@ -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; + +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"), + }, +}); diff --git a/src/errors.ts b/src/errors.ts index b5d4f15..63cd875 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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 { diff --git a/src/index.ts b/src/index.ts index 5c9b414..c8dd4fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); @@ -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 `.", +}); + +// 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 | 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) => { diff --git a/src/openapi.ts b/src/openapi.ts new file mode 100644 index 0000000..95a3f98 --- /dev/null +++ b/src/openapi.ts @@ -0,0 +1,162 @@ +import { z } from "@hono/zod-openapi"; +import { RATE_LIMIT_PERIOD_SECONDS } from "./bins/rate-limit"; +import { ERROR_CODES, type ErrorCode } from "./errors"; + +// The OpenAPI description of the contract in docs/api.md. Schemas here only describe requests and responses: request +// headers, path parameters and bodies are never given to zod-openapi as Zod schemas, so it validates nothing itself +// and the handlers keep their own checks, order and error codes (docs/api.md → Validation and errors). + +/** The name of the `Authorization: Bearer ` security scheme. */ +export const BEARER_AUTH = "bearerAuth"; + +/** An operation's security requirements: each entry is one way to be allowed, and `{}` means no key at all. */ +type SecurityRequirements = Record[]; + +/** The operation needs a key. */ +export const REQUIRED_BEARER_AUTH: SecurityRequirements = [{ [BEARER_AUTH]: [] }]; + +/** The operation takes a key, but may need none: a public Bin can be read without one. */ +export const OPTIONAL_BEARER_AUTH: SecurityRequirements = [{}, { [BEARER_AUTH]: [] }]; + +/** The `info` of the spec served at `/openapi.json`. */ +export const SPEC_INFO = { + title: "keepjson", + // The version of the contract in docs/api.md. + version: "0.1.0", + description: [ + "Store JSON without an account: one call saves a Bin, one call fetches it.", + "", + "- A Bin's body is the raw JSON, with no envelope. Its metadata travels in `X-Bin-*` response headers.", + "- Creating a Bin returns a Write Key, and a Read Key as well for a Private Bin. Keys are shown only once.", + " Send one as `Authorization: Bearer `.", + "- Timestamps are ISO 8601 UTC. Every response sends `Cache-Control: no-store`.", + "- Errors are `{ error: { code, message } }`.", + "", + "A Write Key embedded in frontend code gives every visitor write access to its Bin.", + ].join("\n"), +}; + +/** An ISO 8601 UTC timestamp, as in headers and JSON bodies. */ +export const IsoTimestamp = z.iso.datetime().openapi({ example: "2026-10-09T12:00:00.000Z" }); + +/** The error response body of docs/api.md → Conventions, shared by every error. */ +export const ErrorSchema = z + .object({ + error: z.object({ + code: z.enum(ERROR_CODES).openapi({ description: "Stable and machine-readable: branch on this." }), + message: z.string().openapi({ description: "Readable by people. It may change." }), + }), + }) + .openapi("Error", { description: "Every error response has this shape." }); + +type ErrorBody = z.infer; + +/** Each error code's status, when it is sent (docs/api.md → Validation and errors) and an example message. */ +const ERRORS = { + invalid_json: { status: 400, when: "The body isn't valid JSON.", message: "Body is not valid JSON" }, + invalid_ttl: { + status: 400, + when: "`X-Bin-TTL` isn't a positive integer, or is larger than the Instance's maximum TTL.", + message: "X-Bin-TTL must be a positive integer of seconds", + }, + unauthorized: { + status: 401, + when: "A key is needed and none was sent.", + message: "This Bin needs a key: send Authorization: Bearer ", + }, + forbidden: { + status: 403, + when: "The key sent doesn't grant this action.", + message: "The key sent doesn't grant this", + }, + not_found: { + status: 404, + when: "Unknown id, malformed id, or an expired Bin (these can't be told apart).", + message: "Bin not found", + }, + payload_too_large: { + status: 413, + when: "The body is larger than the Instance's `MAX_BIN_BYTES`.", + message: "Body is larger than 102400 bytes", + }, + unsupported_media_type: { + status: 415, + when: "`Content-Type` isn't `application/json`.", + message: "Content-Type must be application/json", + }, + invalid_top_level: { + status: 422, + when: "Valid JSON, but not an object or array.", + message: "Body must be a JSON object or array", + }, + rate_limited: { + status: 429, + when: "Rate limit exceeded: wait `Retry-After` seconds.", + message: "Too many requests: try again later", + }, + internal_error: { + status: 500, + when: "An unexpected server error. The details are not returned.", + message: "Internal error", + }, +} as const satisfies Record; + +type ErrorStatus = (typeof ERRORS)[C]["status"]; + +const RetryAfterHeaders = z.object({ + "Retry-After": z + .number() + .int() + .positive() + .openapi({ description: "Seconds to wait before trying again.", example: RATE_LIMIT_PERIOD_SECONDS }), +}); + +type ErrorResponseConfig = { + description: string; + headers?: typeof RetryAfterHeaders; + content: { + "application/json": { + schema: typeof ErrorSchema; + examples: Record; + }; + }; +}; + +/** + * The error responses of a route, one per status, for the given codes: each uses the shared error schema and shows + * an example of every code it can carry. A `429` also describes `Retry-After`. + */ +export function errorResponses(...codes: C[]): { [S in ErrorStatus]: ErrorResponseConfig } { + const responses: Record = {}; + for (const code of codes) { + const { status, when, message } = ERRORS[code]; + responses[status] ??= { description: "", content: { "application/json": { schema: ErrorSchema, examples: {} } } }; + const response = responses[status]; + response.description = [response.description, `\`${code}\`: ${when}`].filter(Boolean).join("\n\n"); + response.content["application/json"].examples[code] = { summary: code, value: { error: { code, message } } }; + if (status === 429) response.headers = RetryAfterHeaders; + } + return responses as { [S in ErrorStatus]: ErrorResponseConfig }; +} + +/** The page at `/docs`: Swagger UI, loaded from a CDN at a pinned version and checked with SRI, reading `specUrl`. */ +export function docsPage(specUrl: string): string { + const cdn = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.15"; + return ` + + + + + keepjson API + + + +
+ + + + +`; +} diff --git a/test/openapi.test.ts b/test/openapi.test.ts new file mode 100644 index 0000000..204074a --- /dev/null +++ b/test/openapi.test.ts @@ -0,0 +1,336 @@ +import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import { env, exports } from "cloudflare:workers"; +import { beforeAll, describe, expect, it } from "vitest"; +import { app } from "../src/index"; + +const BASE_URL = "https://keepjson.test"; +const ORIGIN = "https://a.example"; + +/** Every error code in docs/api.md → Validation and errors, with its status. */ +const ERROR_STATUSES: Record = { + invalid_json: 400, + invalid_ttl: 400, + unauthorized: 401, + forbidden: 403, + not_found: 404, + payload_too_large: 413, + unsupported_media_type: 415, + invalid_top_level: 422, + rate_limited: 429, + internal_error: 500, +}; + +/** The routes of docs/api.md → Routes, with the status codes each one can answer. */ +const OPERATIONS: { path: string; method: string; statuses: number[]; errorCodes: string[] }[] = [ + { path: "/health", method: "get", statuses: [200, 500], errorCodes: ["internal_error"] }, + { + path: "/bins", + method: "post", + statuses: [201, 400, 413, 415, 422, 429, 500], + errorCodes: [ + "invalid_json", + "invalid_ttl", + "payload_too_large", + "unsupported_media_type", + "invalid_top_level", + "rate_limited", + "internal_error", + ], + }, + { + path: "/bins/{id}", + method: "get", + statuses: [200, 401, 403, 404, 500], + errorCodes: ["unauthorized", "forbidden", "not_found", "internal_error"], + }, + { + path: "/bins/{id}", + method: "put", + statuses: [200, 400, 401, 403, 404, 413, 415, 422, 429, 500], + errorCodes: [ + "invalid_json", + "unauthorized", + "forbidden", + "not_found", + "payload_too_large", + "unsupported_media_type", + "invalid_top_level", + "rate_limited", + "internal_error", + ], + }, + { + path: "/bins/{id}", + method: "delete", + statuses: [204, 401, 403, 404, 429, 500], + errorCodes: ["unauthorized", "forbidden", "not_found", "rate_limited", "internal_error"], + }, +]; + +const METADATA_HEADERS = ["X-Bin-Id", "X-Bin-Private", "X-Bin-Created-At", "X-Bin-Updated-At", "X-Bin-Expires-At"]; + +// A loose view of the OpenAPI spec: just enough structure for the checks below. +type Json = Record; +type Parameter = { name: string; in: string; required?: boolean; schema?: Json }; +type MediaType = { schema?: Json; examples?: Record }; +type ResponseSpec = { description: string; headers?: Record; content?: Record }; +type Operation = { + summary?: string; + description?: string; + parameters?: Parameter[]; + requestBody?: { required?: boolean; content: Record }; + responses: Record; + security?: Record[]; +}; +type Spec = { + openapi: string; + info: { title: string; version: string; description?: string }; + paths: Record>; + components: { schemas: Record; securitySchemes: Record }; +}; + +let spec: Spec; + +beforeAll(async () => { + const res = await exports.default.fetch(`${BASE_URL}/openapi.json`); + expect(res.status).toBe(200); + spec = (await res.json()) as Spec; +}); + +function operation(path: string, method: string): Operation { + const op = spec.paths[path]?.[method]; + if (!op) throw new Error(`No ${method.toUpperCase()} ${path} in the spec`); + return op; +} + +function response(path: string, method: string, status: number): ResponseSpec { + const res = operation(path, method).responses[String(status)]; + if (!res) throw new Error(`No ${status} for ${method.toUpperCase()} ${path} in the spec`); + return res; +} + +/** Follows a local `$ref` such as `#/components/schemas/Error`; undefined when it doesn't resolve. */ +function resolveRef(ref: string): unknown { + if (!ref.startsWith("#/")) return undefined; + let node: unknown = spec; + for (const raw of ref.slice(2).split("/")) { + const key = raw.replaceAll("~1", "/").replaceAll("~0", "~"); + if (typeof node !== "object" || node === null || !(key in node)) return undefined; + node = (node as Json)[key]; + } + return node; +} + +/** `schema` itself, or what its `$ref` points at. */ +function deref(schema: Json | undefined): Json | undefined { + return typeof schema?.$ref === "string" ? (resolveRef(schema.$ref) as Json | undefined) : schema; +} + +function collectRefs(node: unknown, refs: string[] = []): string[] { + if (Array.isArray(node)) { + for (const item of node) collectRefs(item, refs); + } else if (typeof node === "object" && node !== null) { + for (const [key, value] of Object.entries(node)) { + if (key === "$ref" && typeof value === "string") refs.push(value); + else collectRefs(value, refs); + } + } + return refs; +} + +/** A header name in the spec, matched in any case (HTTP header names are case-insensitive). */ +function headerNames(res: ResponseSpec): string[] { + return Object.keys(res.headers ?? {}).map((name) => name.toLowerCase()); +} + +function headerParameters(op: Operation): string[] { + return (op.parameters ?? []).filter((p) => p.in === "header").map((p) => p.name.toLowerCase()); +} + +/** Calls the app directly with an env in which D1 and the limiters throw, and rate limiting is on. */ +async function sendWithoutD1(path: string): Promise { + const fail = () => { + throw new Error("must not be called"); + }; + const throwingDb = new Proxy({} as D1Database, { get: fail }); + const throwingLimiter: RateLimit = { limit: fail }; + const testEnv: Env = { + ...env, + DB: throwingDb, + RATE_LIMIT_ENABLED: "true", + CREATE_LIMITER: throwingLimiter, + WRITE_LIMITER: throwingLimiter, + }; + const ctx = createExecutionContext(); + const res = await app.fetch(new Request(`${BASE_URL}${path}`, { headers: { Origin: ORIGIN } }), testEnv, ctx); + await waitOnExecutionContext(ctx); + return res; +} + +describe("GET /openapi.json", () => { + it("returns JSON with no-store and CORS headers", async () => { + const res = await exports.default.fetch(`${BASE_URL}/openapi.json`, { headers: { Origin: ORIGIN } }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toMatch(/^application\/json/); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Expose-Headers")).toContain("X-Bin-Id"); + }); + + it("isn't rate limited and doesn't touch D1", async () => { + const res = await sendWithoutD1("/openapi.json"); + expect(res.status).toBe(200); + expect(res.headers.get("Retry-After")).toBeNull(); + expect(((await res.json()) as Spec).openapi).toMatch(/^3\.1\./); + }); + + it("is an OpenAPI 3.1 spec titled keepjson", () => { + expect(spec.openapi).toMatch(/^3\.1\./); + expect(spec.info.title).toBe("keepjson"); + expect(spec.info.version).toMatch(/\S/); + expect(spec.info.description).toMatch(/Bin/); + }); + + it("lists exactly the routes in docs/api.md", () => { + expect(Object.keys(spec.paths).sort()).toEqual(["/bins", "/bins/{id}", "/health"]); + expect(Object.keys(spec.paths["/health"] ?? {})).toEqual(["get"]); + expect(Object.keys(spec.paths["/bins"] ?? {})).toEqual(["post"]); + expect(Object.keys(spec.paths["/bins/{id}"] ?? {}).sort()).toEqual(["delete", "get", "put"]); + }); + + it.each(OPERATIONS)("$method $path lists its documented status codes", ({ path, method, statuses }) => { + const op = operation(path, method); + expect(op.summary).toMatch(/\S/); + expect(Object.keys(op.responses).map(Number).sort()).toEqual([...statuses].sort()); + for (const res of Object.values(op.responses)) expect(res.description).toMatch(/\S/); + }); + + it.each(OPERATIONS)("$method $path shows each error code under its status", ({ path, method, errorCodes }) => { + const shown: string[] = []; + for (const [status, res] of Object.entries(operation(path, method).responses)) { + if (Number(status) < 400) continue; + const media = res.content?.["application/json"]; + expect(media?.schema?.$ref).toBe("#/components/schemas/Error"); + for (const example of Object.values(media?.examples ?? {})) { + const { code } = example.value.error; + expect(ERROR_STATUSES[code]).toBe(Number(status)); + shown.push(code); + } + } + expect(shown.sort()).toEqual([...errorCodes].sort()); + }); + + it("has one shared error schema whose code enum is every code in docs/api.md", () => { + const error = spec.components.schemas.Error as { + required?: string[]; + properties: { error: { required?: string[]; properties: { code: { enum: string[] }; message: Json } } }; + }; + expect(error.required).toEqual(["error"]); + expect(error.properties.error.required?.sort()).toEqual(["code", "message"]); + expect(error.properties.error.properties.code.enum.sort()).toEqual(Object.keys(ERROR_STATUSES).sort()); + expect(error.properties.error.properties.message.type).toBe("string"); + }); + + it("resolves every $ref within the spec", () => { + const refs = collectRefs(spec); + expect(refs.length).toBeGreaterThan(0); + for (const ref of refs) expect(resolveRef(ref), ref).toBeDefined(); + }); + + it("describes the metadata headers on every response that sends them", () => { + for (const [path, method, status] of [ + ["/bins", "post", 201], + ["/bins/{id}", "get", 200], + ["/bins/{id}", "put", 200], + ] as const) { + const res = response(path, method, status); + for (const header of METADATA_HEADERS) expect(headerNames(res)).toContain(header.toLowerCase()); + expect(res.headers?.["X-Bin-Expires-At"]?.required).not.toBe(true); + expect(res.headers?.["X-Bin-Id"]?.required).toBe(true); + } + expect(headerNames(response("/bins", "post", 201))).toContain("location"); + }); + + it("describes Retry-After on every 429", () => { + for (const { path, method, statuses } of OPERATIONS) { + if (statuses.includes(429)) expect(headerNames(response(path, method, 429))).toContain("retry-after"); + } + }); + + it("describes the request headers and the id path parameter", () => { + expect(headerParameters(operation("/bins", "post")).sort()).toEqual(["x-bin-private", "x-bin-ttl"]); + for (const method of ["get", "put", "delete"]) { + const id = operation("/bins/{id}", method).parameters?.find((p) => p.in === "path"); + expect(id).toMatchObject({ name: "id", required: true, schema: { type: "string" } }); + } + }); + + it("describes the create and replace bodies as any JSON object or array", () => { + for (const [path, method] of [ + ["/bins", "post"], + ["/bins/{id}", "put"], + ] as const) { + const body = operation(path, method).requestBody; + expect(body?.required).toBe(true); + expect(Object.keys(body?.content ?? {})).toEqual(["application/json"]); + const schema = deref(body?.content["application/json"]?.schema) as Json & { [K in "anyOf" | "oneOf"]?: Json[] }; + expect((schema.anyOf ?? schema.oneOf)?.map((s) => s.type).sort()).toEqual(["array", "object"]); + } + expect(operation("/bins/{id}", "get").requestBody).toBeUndefined(); + }); + + it("describes the success bodies", () => { + const created = deref(response("/bins", "post", 201).content?.["application/json"]?.schema) as { + properties: Json; + required: string[]; + }; + expect(Object.keys(created.properties).sort()).toEqual( + ["createdAt", "expiresAt", "id", "private", "readKey", "writeKey"].sort(), + ); + expect(created.required).not.toContain("readKey"); + + const replaced = deref(response("/bins/{id}", "put", 200).content?.["application/json"]?.schema) as { + properties: Json; + }; + expect(Object.keys(replaced.properties).sort()).toEqual( + ["createdAt", "expiresAt", "id", "private", "updatedAt"].sort(), + ); + + expect(Object.keys(response("/bins/{id}", "get", 200).content ?? {})).toEqual(["application/json"]); + expect(response("/bins/{id}", "delete", 204).content).toBeUndefined(); + expect(response("/health", "get", 200).content?.["application/json"]?.schema).toBeDefined(); + }); + + it("declares Bearer auth: required on PUT and DELETE, optional on GET /bins/{id}, absent elsewhere", () => { + expect(spec.components.securitySchemes.bearerAuth).toMatchObject({ type: "http", scheme: "bearer" }); + expect(operation("/bins/{id}", "put").security).toEqual([{ bearerAuth: [] }]); + expect(operation("/bins/{id}", "delete").security).toEqual([{ bearerAuth: [] }]); + expect(operation("/bins/{id}", "get").security).toEqual(expect.arrayContaining([{}, { bearerAuth: [] }])); + expect(operation("/bins", "post").security ?? []).toEqual([]); + expect(operation("/health", "get").security ?? []).toEqual([]); + }); + + it("uses the vocabulary of CONTEXT.md", () => { + const text = JSON.stringify(spec).toLowerCase(); + for (const word of ["document", "record", "masterkey", "apikey", "token"]) expect(text).not.toContain(word); + }); +}); + +describe("GET /docs", () => { + it("returns an HTML page that loads /openapi.json", async () => { + const res = await exports.default.fetch(`${BASE_URL}/docs`, { headers: { Origin: ORIGIN } }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toMatch(/^text\/html/); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + const html = await res.text(); + expect(html).toMatch(/^/i); + expect(html).toContain('"/openapi.json"'); + }); + + it("isn't rate limited and doesn't touch D1", async () => { + const res = await sendWithoutD1("/docs"); + expect(res.status).toBe(200); + expect(res.headers.get("Retry-After")).toBeNull(); + }); +});