Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
8 changes: 8 additions & 0 deletions .changeset/openapi-spec-serving.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@farbenmeer/tapi": patch
"@farbenmeer/bunny": patch
---

Serve the generated OpenAPI spec at `<basePath>/__tapi/openapi.json` when `openapi: { 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 `openapi: { title, version }` to your `defineApi` call instead to expose the spec.
48 changes: 48 additions & 0 deletions examples/contact-book/e2e/dev/openapi.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
5 changes: 4 additions & 1 deletion examples/contact-book/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
openapi: { title: "Contact Book", version: "1.0.0" },
})
.route("/contacts", import("./api/contacts"))
.route("/contacts/:id", import("./api/contact"));
71 changes: 71 additions & 0 deletions packages/1-tapi/src/server/create-request-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,74 @@ describe("createRequestHandler", () => {
expect(await response.json()).toEqual({ auth: "foo" });
});
});

describe("openapi.json route", () => {
test("serves the generated spec when openapi config is provided", async () => {
const sut = createRequestHandler(
defineApi({ openapi: { 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 openapi 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({ openapi: { 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({ openapi: { 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());
});
});
14 changes: 14 additions & 0 deletions packages/1-tapi/src/server/create-request-handler.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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);

Expand All @@ -62,6 +66,16 @@ export function createRequestHandler(
});
}

if (api.openapi && url.pathname === `${basePath}${OPENAPI_ROUTE}`) {
if (!openapiJson) {
const spec = await generateOpenAPISchema(api, { info: api.openapi });
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;
Expand Down
9 changes: 8 additions & 1 deletion packages/1-tapi/src/server/define-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 OpenAPIInfo {
title: string;
version: string;
}

interface Options {
cache?: Cache;
openapi?: OpenAPIInfo;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename this parameter to 'oas' instead of 'openapi'

}

export function defineApi(options: Options = {}) {
return new ApiDefinition({}, options?.cache ?? new PubSub());
return new ApiDefinition({}, options?.cache ?? new PubSub(), options?.openapi);
}

export class ApiDefinition<Routes extends Record<BasePath, unknown>> {
constructor(
public routes: Routes,
public cache: Cache,
public openapi?: OpenAPIInfo,
) {}

async invalidate(tags: string[]) {
Expand Down
98 changes: 98 additions & 0 deletions packages/1-tapi/src/server/openapi.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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",
},
},
},
},
},
},
},
},
});
});
});
1 change: 1 addition & 0 deletions packages/1-tapi/src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
10 changes: 1 addition & 9 deletions packages/3-bunny/src/cli/build.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"),
Expand Down
Loading