QUESTPIE
AgentsOpenapi

Serving it yourself

The route factories behind openApiModule, how each one caches, why the Scalar route needs its config passed by hand, and how to build the document with no route at all.

View markdown
ExportFromWhat it is
openApiModule@questpie/openapiThe module. Both routes plus the codegen plugin.
openApiConfig(config)@questpie/openapiIdentity factory for config/openapi.ts
openApiRoute(config?)@questpie/openapiA finished route serving the JSON document
docsRoute(config?)@questpie/openapiA finished route serving the Scalar page
generateOpenApiSpec(app, config?)@questpie/openapiThe document itself, as a promise
openApiPlugin()@questpie/openapi/pluginThe codegen plugin, already inside the module

@questpie/openapi and @questpie/openapi/server export the same things. The starters import from the short one.

openApiModule

The module carries the codegen plugin and mounts the two factories under fixed keys.

KeyFactory calledURL, with a handler at /api
openapi.jsonopenApiRoute()GET /api/openapi.json
docsdocsRoute()GET /api/docs

Nothing in the module reads specPath or docsPath. To move either route, mount the factory yourself under the file name you want.

openApiRoute(config?)

A GET route in raw mode. It builds the document on the first request and stores it against the app instance, so later requests are served from memory. A config change needs a restart.

Response detailValue
Content-Typeapplication/json
Cache-Controlpublic, max-age=3600, stale-while-revalidate=43200
ETagA hash of the document body
Access-Control-Allow-Origin*
If-None-Match hit304, with no body

Called with no argument it reads config/openapi.ts. Pass an object and that object replaces the file completely.

src/questpie/server/routes/spec.get.ts
import { openApiRoute } from "@questpie/openapi";

export default openApiRoute();

docsRoute(config?)

A GET route in raw mode serving the Scalar page. It rebuilds the document on every request. There is no cache here.

`docsRoute()` with no argument skips your config

It reads config/openapi.ts only when you pass it nothing at all. An empty object counts as something. So the bare call the module makes never reaches the file. Pass the config in yourself.

That is why /api/docs shows the title QUESTPIE API, the purple theme, and paths under / no matter what your config file says. Mount your own route to fix it:

src/questpie/server/routes/reference.get.ts
import { docsRoute } from "@questpie/openapi";

import openapi from "../config/openapi";

export default docsRoute(openapi);

Your reference now lives at GET /api/reference with your title, your theme and your base path. The module's /api/docs is still there, still showing the defaults. Drop openApiModule from modules.ts and mount both factories by hand if you want only one of them.

generateOpenApiSpec(app, config?)

Builds the document and resolves to it. No route, no server, no cache. Reach for it in a build step, a snapshot test, or a client generator.

import { writeFile } from "node:fs/promises";

import { generateOpenApiSpec } from "@questpie/openapi";

import { app } from "#questpie";

const spec = await generateOpenApiSpec(app, {
	basePath: "/api",
	info: { title: "My API", version: "1.0.0" },
});

await writeFile("openapi.json", JSON.stringify(spec, null, 2));

It is async, so await it. Routes come off app.config.routes on their own, and you never pass them. Omit the config and every default applies, including the / base path that produces //posts.

Gating the document

Neither factory can be gated by chaining. Both return a finished route definition. .handler() already ran, so there is no .access() left to call.

Write your own route instead. The app is on the handler context, and generateOpenApiSpec takes it directly.

src/questpie/server/routes/private-spec.get.ts
import { generateOpenApiSpec } from "@questpie/openapi";
import { route } from "questpie/services";

export default route()
	.get()
	.access(({ session }) => !!session?.user)
	.raw()
	.handler(async ({ app }) => {
		const spec = await generateOpenApiSpec(app, { basePath: "/api" });
		return Response.json(spec);
	});

Most teams do not bother. They leave the document public. Every collection and route still enforces its own access rules, and that is what decides who can call anything.

Types

Four types come out of the same entry point.

import type {
	OpenApiConfig,
	OpenApiModuleConfig,
	OpenApiSpec,
	ScalarConfig,
} from "@questpie/openapi";

OpenApiModuleConfig extends OpenApiConfig with scalar, specPath and docsPath. It is the shape openApiConfig() takes. OpenApiSpec is a loose description of the document, enough to reach info, paths and components without casting.

The codegen plugin

openApiModule already carries openApiPlugin(). You only import it directly if you wire plugins by hand in questpie.config.ts instead of registering the module.

The plugin does two things. It matches config/openapi.ts and files its default export under app.state.config.openapi. It also emits an AppRouteKeys type, a sorted union of every discovered route key.

On this page