Skip to content
Open
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
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 `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.
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,
oas: { 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 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());
});
});
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.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;
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 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<Routes extends Record<BasePath, unknown>> {
constructor(
public routes: Routes,
public cache: Cache,
public oas?: OasInfo,
) {}

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