Skip to main content

OpenAPI

Since Cuple is REST compatible, you can generate an OpenAPI 3.0 spec from your routes. It reads your TypeScript source at build time, so there's nothing to add at runtime.

npm i -D @cuple/openapi typescript

Example​

// routes.ts
export const routes = {
getPost: builder
.path("/api/posts/:id")
.paramsSchema(z.strictObject({ id: z.coerce.number() }))
.get(async ({ data }) => {
const post = await getPost(data.params.id);
if (!post) return notFound({ result: "post-not-found", message: "Post not found" });
return success({ post });
}),

createPost: builder
.meta({ description: "Creates a new blog post" })
.path("/api/posts")
.bodySchema(z.strictObject({ title: z.string(), content: z.string() }))
.post(async ({ data }) => {
return success({ id: 1 });
}),
};
// scripts/openapi.ts
import { generateOpenAPI } from "@cuple/openapi";

generateOpenAPI("./src/routes.ts", "routes", {
tsconfigPath: "./tsconfig.json",
info: { title: "Blog API", version: "1.0.0" },
outputFile: "./openapi.json",
});

In this example:

  • "routes" is the name of the exported variable that holds your routes.

  • /api/posts/:id becomes /api/posts/{id}.

  • Every possible response is included, grouped by status code, so 404 post-not-found is in the spec as well.

  • .meta({ description }) becomes the operation's description.

  • The operation id is the name of the route, e.g. getPost, or posts.getPost if it's nested.

  • NOTE: Only routes with .path() are included, the others are only reachable through the RPC endpoint.

  • NOTE: generateOpenAPI returns the document as well, so outputFile is optional.

What goes where​

CupleOpenAPI
.path("/posts/:id")path /posts/{id}
.get() / .post() / ...operation with that method
.paramsSchema(...)path parameters
.querySchema(...)query parameters
.headersSchema(...)header parameters
.bodySchema(...)application/json request body
return valuesresponses by status code
.meta({ description })description
a recursive typecomponents.schemas entry + $ref

Recursive types​

A type that contains itself — a tree of nodes, a JSON value, a Prisma Json column — can't be written out inline, because there's no end to it. It becomes a named entry under components.schemas, and the place where the type comes back becomes a $ref to it:

type Element =
| { type: "text"; contentHtml: string }
| { type: "section"; children: Element[] };
{
"components": {
"schemas": {
"Element": {
"oneOf": [
{ "type": "object", "properties": { "type": { "type": "string", "enum": ["text"] }, "contentHtml": { "type": "string" } } },
{ "type": "object", "properties": { "type": { "type": "string", "enum": ["section"] },
"children": { "type": "array", "items": { "$ref": "#/components/schemas/Element" } } } }
]
}
}
}
}

A generic that recurses into itself (type Deep<T> = { next: Deep<{ wrap: T }> }) becomes one self-referencing definition too, since every level has the same shape. Nesting two different instantiations of one generic is not recursion, and is still written out in full.

  • NOTE: components is absent from the document when nothing in the API recurses.
  • NOTE: an anonymous recursive type has no name to be filed under, so it stays an empty schema.

Inspecting routes​

@cuple/openapi is built on @cuple/inspect, which you can use directly if you need the route information for something else:

import { inspectRoutes } from "@cuple/inspect";

const { routes, definitions } = inspectRoutes("./src/routes.ts", "routes");

for (const route of routes) {
console.log(route.method.toUpperCase(), route.path.raw);
}

A schema node is { type: "ref", name } where a recursive type comes back, and definitions holds the schemas those names stand for.

Why it works this way

The spec is generated from the source, because hand-written specs drift from what the handlers return. It's read at build time because return types don't exist at runtime.

Passing a file path and a variable name instead of the routes object is a TypeScript compiler API limit: the generator needs the object's type, not its value. Passing routes directly would be better if a way is found.

OpenAPI 3.0 is only the first version written. Updating to 3.1 is planned.