diff --git a/.changeset/openapi-spec-serving.md b/.changeset/openapi-spec-serving.md new file mode 100644 index 00000000..2e1073a5 --- /dev/null +++ b/.changeset/openapi-spec-serving.md @@ -0,0 +1,8 @@ +--- +"@farbenmeer/tapi": patch +"@farbenmeer/bunny": patch +--- + +Serve the generated OpenAPI spec at `/__tapi/openapi.json` when `oas: { title, version }` is passed to `defineApi`. + +Removed Bunny's `/.well-known/openapi.json` route in favor of the new TApi route. `createBunnyApp` no longer accepts an `apiInfo` option — pass `oas: { title, version }` to your `defineApi` call instead to expose the spec. diff --git a/examples/contact-book/e2e/dev/openapi.spec.ts b/examples/contact-book/e2e/dev/openapi.spec.ts new file mode 100644 index 00000000..a3ec2f41 --- /dev/null +++ b/examples/contact-book/e2e/dev/openapi.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test"; + +test("serves the OpenAPI spec at /api/__tapi/openapi.json", async ({ + baseURL, +}) => { + const res = await fetch(`${baseURL}/api/__tapi/openapi.json`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + + const spec = await res.json(); + + expect(spec.openapi).toMatch(/^3\./); + expect(spec.info).toEqual({ title: "Contact Book", version: "1.0.0" }); + + expect(Object.keys(spec.paths).sort()).toEqual([ + "/contacts", + "/contacts/{id}", + ]); + + expect(Object.keys(spec.paths["/contacts"]).sort()).toEqual([ + "delete", + "get", + "post", + ]); + expect(Object.keys(spec.paths["/contacts/{id}"]).sort()).toEqual([ + "delete", + "get", + "patch", + ]); + + const idParam = spec.paths["/contacts/{id}"].get.parameters.find( + (p: { name: string }) => p.name === "id", + ); + expect(idParam).toMatchObject({ + in: "path", + name: "id", + required: true, + schema: { type: "string" }, + }); + + const postBody = + spec.paths["/contacts"].post.requestBody.content["application/json"].schema; + expect(postBody.type).toBe("object"); + expect(postBody.required.sort()).toEqual(["email", "name"]); + expect(postBody.properties.name.type).toBe("string"); + expect(postBody.properties.email.type).toBe("string"); + expect(postBody.properties.phone.type).toBe("string"); +}); diff --git a/examples/contact-book/src/api.ts b/examples/contact-book/src/api.ts index 82ca6ad5..0ba5e750 100644 --- a/examples/contact-book/src/api.ts +++ b/examples/contact-book/src/api.ts @@ -3,6 +3,9 @@ import { InMemoryCache } from "@farbenmeer/tag-based-cache/in-memory-cache"; const cache = new InMemoryCache(); -export const api = defineApi({ cache }) +export const api = defineApi({ + cache, + oas: { title: "Contact Book", version: "1.0.0" }, +}) .route("/contacts", import("./api/contacts")) .route("/contacts/:id", import("./api/contact")); diff --git a/packages/1-tapi/src/server/create-request-handler.test.ts b/packages/1-tapi/src/server/create-request-handler.test.ts index f6d5d7f1..d5b06244 100644 --- a/packages/1-tapi/src/server/create-request-handler.test.ts +++ b/packages/1-tapi/src/server/create-request-handler.test.ts @@ -172,3 +172,74 @@ describe("createRequestHandler", () => { expect(await response.json()).toEqual({ auth: "foo" }); }); }); + +describe("openapi.json route", () => { + test("serves the generated spec when oas config is provided", async () => { + const sut = createRequestHandler( + defineApi({ oas: { title: "My API", version: "1.2.3" } }).route( + "/things", + { + GET: defineHandler({ authorize: () => true }, async () => + TResponse.json([]), + ), + }, + ), + { basePath: "/api" }, + ); + const response = await sut( + new Request("http://localhost:3000/api/__tapi/openapi.json"), + ); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/json"); + const body = await response.json(); + expect(body.info.title).toBe("My API"); + expect(body.info.version).toBe("1.2.3"); + expect(body.paths["/things"].get).toBeDefined(); + }); + + test("falls through to 404 when oas config is not provided", async () => { + const sut = createRequestHandler( + defineApi().route("/things", { + GET: defineHandler({ authorize: () => true }, async () => + TResponse.json([]), + ), + }), + ); + const response = await sut( + new Request("http://localhost:3000/__tapi/openapi.json"), + ); + expect(response.status).toBe(404); + }); + + test("respects basePath", async () => { + const sut = createRequestHandler( + defineApi({ oas: { title: "T", version: "0.0.1" } }).route("/x", { + GET: defineHandler({ authorize: () => true }, async () => + TResponse.json([]), + ), + }), + { basePath: "/api" }, + ); + const response = await sut( + new Request("http://localhost:3000/__tapi/openapi.json"), + ); + expect(response.status).toBe(404); + }); + + test("memoizes the spec across requests", async () => { + const sut = createRequestHandler( + defineApi({ oas: { title: "T", version: "0.0.1" } }).route("/x", { + GET: defineHandler({ authorize: () => true }, async () => + TResponse.json([]), + ), + }), + ); + const first = await sut( + new Request("http://localhost:3000/__tapi/openapi.json"), + ); + const second = await sut( + new Request("http://localhost:3000/__tapi/openapi.json"), + ); + expect(await first.text()).toBe(await second.text()); + }); +}); diff --git a/packages/1-tapi/src/server/create-request-handler.ts b/packages/1-tapi/src/server/create-request-handler.ts index bc5816c8..cbde2194 100644 --- a/packages/1-tapi/src/server/create-request-handler.ts +++ b/packages/1-tapi/src/server/create-request-handler.ts @@ -1,6 +1,7 @@ import { ZodError, z } from "zod/v4"; import { INVALIDATIONS_ROUTE, + OPENAPI_ROUTE, SESSION_COOKIE_NAME, } from "../shared/constants.js"; import { HttpError } from "../shared/http-error.js"; @@ -12,6 +13,7 @@ import type { ApiDefinition } from "./define-api.js"; import type { Handler } from "./handler.js"; import type { TRequest } from "./t-request.js"; import type { Cache } from "./cache.js"; +import { generateOpenAPISchema } from "./openapi.js"; import { streamRevalidatedTags } from "./revalidation-stream.js"; interface Options { @@ -53,6 +55,8 @@ export function createRequestHandler( routes.push({ pattern, route }); } + let openapiJson: string | undefined; + return async (req: Request) => { const url = new URL(req.url); @@ -62,6 +66,16 @@ export function createRequestHandler( }); } + if (api.oas && url.pathname === `${basePath}${OPENAPI_ROUTE}`) { + if (!openapiJson) { + const spec = await generateOpenAPISchema(api, { info: api.oas }); + openapiJson = JSON.stringify(spec); + } + return new Response(openapiJson, { + headers: { "Content-Type": "application/json" }, + }); + } + for (const { pattern, route: routePromise } of routes) { const match = url.pathname.match(pattern); const route = await routePromise; diff --git a/packages/1-tapi/src/server/define-api.ts b/packages/1-tapi/src/server/define-api.ts index 03e0c0d1..b32fc6b3 100644 --- a/packages/1-tapi/src/server/define-api.ts +++ b/packages/1-tapi/src/server/define-api.ts @@ -3,18 +3,25 @@ import type { Path as BasePath, StrictParams } from "../shared/path.js"; import type { Route } from "../shared/route.js"; import { type Cache, PubSub } from "./cache.js"; +export interface OasInfo { + title: string; + version: string; +} + interface Options { cache?: Cache; + oas?: OasInfo; } export function defineApi(options: Options = {}) { - return new ApiDefinition({}, options?.cache ?? new PubSub()); + return new ApiDefinition({}, options?.cache ?? new PubSub(), options?.oas); } export class ApiDefinition> { constructor( public routes: Routes, public cache: Cache, + public oas?: OasInfo, ) {} async invalidate(tags: string[]) { diff --git a/packages/1-tapi/src/server/openapi.test.ts b/packages/1-tapi/src/server/openapi.test.ts index 1c04980d..7942cfce 100644 --- a/packages/1-tapi/src/server/openapi.test.ts +++ b/packages/1-tapi/src/server/openapi.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; +import { z } from "zod/v4"; import { api } from "./define-api.mock.js"; +import { defineApi } from "./define-api.js"; +import { defineHandler } from "./define-handler.js"; import { generateOpenAPISchema } from "./openapi.js"; +import { TResponse } from "./t-response.js"; describe("OpenAPI", () => { test("generate basic OpenAPI schema", async () => { @@ -43,4 +47,98 @@ describe("OpenAPI", () => { }, }); }); + + test("renders a full spec for an API with dynamic params, response schema, and zod metadata", async () => { + const Widget = z + .object({ + id: z + .string() + .meta({ description: "Widget identifier", example: "w_123" }), + name: z + .string() + .meta({ description: "Display name", example: "Acme Widget" }), + count: z.number().int(), + }) + .meta({ description: "A widget resource" }); + + const widgetApi = defineApi().route("/widgets/:id", { + GET: defineHandler( + { + authorize: () => true, + params: { + id: z + .string() + .meta({ description: "Widget ID", example: "w_123" }), + }, + response: Widget, + }, + async (req) => + TResponse.json({ + id: req.params().id, + name: "Acme Widget", + count: 1, + }), + ), + }); + + const spec = await generateOpenAPISchema(widgetApi, { + info: { title: "Widgets API", version: "1.0.0" }, + }); + + expect(spec).toEqual({ + openapi: "3.1.1", + info: { title: "Widgets API", version: "1.0.0" }, + paths: { + "/widgets/{id}": { + get: { + parameters: [ + { + in: "path", + name: "id", + schema: { + type: "string", + description: "Widget ID", + example: "w_123", + }, + required: true, + description: "Widget ID", + }, + ], + responses: { + "200": { + description: "200 OK", + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { + type: "string", + description: "Widget identifier", + example: "w_123", + }, + name: { + type: "string", + description: "Display name", + example: "Acme Widget", + }, + count: { + type: "integer", + minimum: -9007199254740991, + maximum: 9007199254740991, + }, + }, + required: ["id", "name", "count"], + additionalProperties: false, + description: "A widget resource", + }, + }, + }, + }, + }, + }, + }, + }, + }); + }); }); diff --git a/packages/1-tapi/src/shared/constants.ts b/packages/1-tapi/src/shared/constants.ts index de1c0667..a64f875f 100644 --- a/packages/1-tapi/src/shared/constants.ts +++ b/packages/1-tapi/src/shared/constants.ts @@ -4,3 +4,4 @@ export const TAGS_CONTENT_TYPE = "text/tapi-tags"; export const SESSION_COOKIE_NAME = "__tapi-session"; export const INVALIDATION_POST_EVENT = "TAPI_INVALIDATE_TAGS"; export const INVALIDATIONS_ROUTE = "/__tapi/invalidations"; +export const OPENAPI_ROUTE = "/__tapi/openapi.json"; diff --git a/packages/3-bunny/src/cli/build.ts b/packages/3-bunny/src/cli/build.ts index c2e913db..f579952b 100644 --- a/packages/3-bunny/src/cli/build.ts +++ b/packages/3-bunny/src/cli/build.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { Command } from "commander"; import esbuild from "esbuild"; @@ -60,19 +60,11 @@ export const build = new Command() }, }); - const packageJson = JSON.parse( - await readFile(path.join(process.cwd(), "package.json"), "utf8"), - ); - if (options.standalone) { const serverBuild = await esbuild.build({ stdin: { contents: generateServer( path.join(srcDir, "api.ts"), - { - title: packageJson.name, - version: packageJson.version, - }, config.server, ), sourcefile: path.join(bunnyDir, "virtual", "server.js"), diff --git a/packages/3-bunny/src/cli/dev.ts b/packages/3-bunny/src/cli/dev.ts index 7102574a..4d2b6f5d 100644 --- a/packages/3-bunny/src/cli/dev.ts +++ b/packages/3-bunny/src/cli/dev.ts @@ -1,6 +1,5 @@ import { createRequestHandler, - generateOpenAPISchema, streamRevalidatedTags, type ApiDefinition, } from "@farbenmeer/tapi/server"; @@ -8,7 +7,7 @@ import { Command } from "commander"; import connect from "connect"; import esbuild from "esbuild"; import { existsSync } from "node:fs"; -import { mkdir, readFile, rm } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; import path from "node:path"; import { createServer } from "vite"; import react from "@vitejs/plugin-react"; @@ -63,7 +62,6 @@ export const dev = new Command() const tapi: { apiRequestHandler?: (req: Request) => Promise; - openAPISchema?: string; api?: ApiDefinition; } = {}; @@ -79,16 +77,6 @@ export const dev = new Command() }, }, }); - const packageJson = JSON.parse( - await readFile(path.join(process.cwd(), "package.json"), "utf8"), - ); - const schema = await generateOpenAPISchema(api, { - info: { - title: packageJson.name, - version: packageJson.version, - }, - }); - tapi.openAPISchema = JSON.stringify(schema); } const esbuildContext = await esbuild.context({ @@ -158,13 +146,6 @@ export const dev = new Command() await fromResponse(res, response); return; } - if (url.pathname === "/.well-known/openapi.json") { - res.setHeader("Content-Type", "application/json"); - res.write(tapi.openAPISchema); - res.end(); - return; - } - if (url.pathname === "/sw.js") { res.appendHeader("Cache-Control", "no-store"); res.write(` diff --git a/packages/3-bunny/src/cli/generate-server.ts b/packages/3-bunny/src/cli/generate-server.ts index 74867c4f..4b240526 100644 --- a/packages/3-bunny/src/cli/generate-server.ts +++ b/packages/3-bunny/src/cli/generate-server.ts @@ -1,24 +1,12 @@ import type { ServerConfig } from "../config"; -export function generateServer( - apiPath: string, - apiInfo: { title: string; version: string }, - serverConfig?: ServerConfig, -) { +export function generateServer(apiPath: string, serverConfig?: ServerConfig) { return ` const { createBunnyApp } = require("@farbenmeer/bunny/server") -const { readFileSync } = require("fs") -const path = require("path") - -const buildId = readFileSync(path.resolve(__dirname, "buildId.txt"), "utf-8") createBunnyApp({ api: () => import("${apiPath}"), dist: __dirname + "/dist", - apiInfo: { - title: "${apiInfo.title}", - version: "${apiInfo.version}", - }, serverConfig: ${JSON.stringify(serverConfig, null, 2)}, }).listen(parseInt(process.env.PORT ?? 3000, 10)); `; diff --git a/packages/3-bunny/src/cli/start.ts b/packages/3-bunny/src/cli/start.ts index fa55cbb9..77c1e20c 100644 --- a/packages/3-bunny/src/cli/start.ts +++ b/packages/3-bunny/src/cli/start.ts @@ -11,9 +11,6 @@ export const start = new Command() .option("--port ", "Port number (default: 3000)") .action(async (options) => { const bunnyDir = path.join(process.cwd(), ".bunny", "prod"); - const packageJson = JSON.parse( - await readFile(path.join(process.cwd(), "package.json"), "utf-8"), - ); process.env.NODE_ENV = "production"; loadEnv("production"); @@ -26,11 +23,6 @@ export const start = new Command() createBunnyApp({ api: () => import(path.join(bunnyDir, "api.cjs")), dist: path.join(bunnyDir, "dist"), - apiInfo: { - title: packageJson.name, - version: packageJson.version, - buildId, - }, serverConfig: config.server, }).listen(parseInt(options.port ?? process.env.PORT ?? "3000", 10)); }); diff --git a/packages/3-bunny/src/server/create-bunny-app.ts b/packages/3-bunny/src/server/create-bunny-app.ts index 15f4366b..7e8f989e 100644 --- a/packages/3-bunny/src/server/create-bunny-app.ts +++ b/packages/3-bunny/src/server/create-bunny-app.ts @@ -1,7 +1,6 @@ import type { ApiDefinition } from "@farbenmeer/tapi/server"; import { createRequestHandler, - generateOpenAPISchema, PubSub, streamRevalidatedTags, } from "@farbenmeer/tapi/server"; @@ -19,14 +18,12 @@ import { readFile } from "node:fs/promises"; interface BunnyServerOptions { api: () => Promise<{ api: ApiDefinition; cache?: Cache }>; dist: string; - apiInfo: { title: string; version: string; buildId: string }; serverConfig?: ServerConfig; } export function createBunnyApp({ api, dist, - apiInfo, serverConfig, }: BunnyServerOptions) { loadEnv("production"); @@ -41,7 +38,6 @@ export function createBunnyApp({ }, }), ); - let openApiJson: string | undefined; app.use(async (req, res, next) => { if (!req.url) return next(); @@ -65,20 +61,6 @@ export function createBunnyApp({ return; } - if (url.pathname === "/.well-known/openapi.json") { - if (!openApiJson) { - openApiJson = JSON.stringify( - await generateOpenAPISchema((await api()).api, { - info: apiInfo, - }), - ); - } - res.setHeader("Content-Type", "application/json"); - res.write(openApiJson); - res.end(); - return; - } - next(); }); diff --git a/packages/3-bunny/tests/test-api.ts b/packages/3-bunny/tests/test-api.ts index 73caa4fb..58ae45a9 100644 --- a/packages/3-bunny/tests/test-api.ts +++ b/packages/3-bunny/tests/test-api.ts @@ -13,11 +13,6 @@ export async function testApi( api, }), dist: "./dist", - apiInfo: { - title: "Bunny Test API", - version: "1.0.0", - buildId: "testBuildId", - }, }); const { req, res } = httpMocks.createMocks(requestOptions); diff --git a/website/src/content/docs/bunny/index.mdx b/website/src/content/docs/bunny/index.mdx index 2122944e..fd049b33 100644 --- a/website/src/content/docs/bunny/index.mdx +++ b/website/src/content/docs/bunny/index.mdx @@ -32,7 +32,7 @@ npm run dev Built-in offline support, static file caching, and tag-based cache invalidation — zero configuration. - An OpenAPI schema is auto-generated and served at `/.well-known/openapi.json`. + Pass `oas: { title, version }` to `defineApi` and Bunny serves the generated schema at `/api/__tapi/openapi.json`. Bundle your entire app and all its dependencies into a single file for easy deployment anywhere. diff --git a/website/src/content/docs/bunny/reference/cli.md b/website/src/content/docs/bunny/reference/cli.md index a3f60c37..dfccc493 100644 --- a/website/src/content/docs/bunny/reference/cli.md +++ b/website/src/content/docs/bunny/reference/cli.md @@ -30,8 +30,7 @@ bunny dev --port 3000 Runs Vite in middleware mode for the frontend with hot module replacement. The API code (`src/api.ts`) is watched and rebuilt with esbuild — changes are hot-reloaded without restarting the server. Routes: -- `/api/*` — API endpoints handled by TApi. -- `/.well-known/openapi.json` — Auto-generated OpenAPI schema. +- `/api/*` — API endpoints handled by TApi. When `oas: { title, version }` is passed to `defineApi`, the schema is served at `/api/__tapi/openapi.json`. - Everything else — Served by Vite. The service worker is disabled during development to prevent caching issues. diff --git a/website/src/content/docs/tapi/index.mdx b/website/src/content/docs/tapi/index.mdx index 665a35ce..3bd5dbf4 100644 --- a/website/src/content/docs/tapi/index.mdx +++ b/website/src/content/docs/tapi/index.mdx @@ -26,7 +26,7 @@ npm install @farbenmeer/tapi Request and response validation powered by [Zod](https://zod.dev/). Define your schema once, get validation and types together. - An OpenAPI schema is auto-generated from your route definitions and served at `/.well-known/openapi.json`. + An OpenAPI schema is auto-generated from your route definitions. Pass `oas: { title, version }` to `defineApi` to serve it at `/__tapi/openapi.json`. Integrated support for tag-based cache invalidation via `@farbenmeer/tag-based-cache`.