OpenAPI
- Category: Core
Every query, mutation, and action is already a
real HTTP endpoint with a typed input. The @point0/openapi package reads those
points and emits an OpenAPI document — one operation per endpoint, request and
response schemas pulled from your validation schemas. You add it as one
middleware on the root; it serves the raw JSON spec plus
two ready UIs (Scalar and Swagger).
import { openapi } from '@point0/openapi'
import { basicAuth } from '@point0/basic-auth'
export const root = Point0.lets
.root()
// ...
.middleware(
openapi({
route: '/openapi.json', // serves the raw JSON spec
scalar: '/scalar', // Scalar UI
swagger: '/swagger', // Swagger UI
filter: 'all', // include queries, mutations, pages — not just actions
before: basicAuth({ users: { admin: 'admin' } }), // protect the docs
}),
)
.root()GET /openapi.json now returns the spec, /scalar and /swagger render it.
The rest of this page shows where each operation comes from and how to shape it.
Installing
@point0/openapi is a separate package. Install it and import openapi from
it:
bun add @point0/openapiThe spec is only generated on the server. On the client the middleware is a no-op pass-through, so it ships nothing to the browser.
For schemas to make it into the spec, the root needs a schema helper registered — that is what teaches Point0 how to turn a zod/valibot/… schema into JSON Schema:
import { zodSchemaHelper } from '@point0/core/schema/zod'
export const root = Point0.lets.root().schemaHelper(zodSchemaHelper()).root()Without a matching helper (or a Standard-Schema jsonSchema.input), a point's
request/response schemas can't be extracted and the operation is emitted without
them.
What becomes an operation
Each point with an endpoint becomes one path → method → operation:
- A query →
POST /_point0/<scope>/query/<kebab-name> - A mutation →
POST /_point0/<scope>/mutation/<kebab-name> - An action → its declared method and route, e.g.
PUT /api/ideas/:id - A page/layout →
GETat its route — but only when it's an endpoint (SSR on, or it has a server.loader); see page or endpoint.
Scope, type, and name are all kebab-cased in the path, so a query named
recentIdeas in the root scope is served at
POST /_point0/root/query/recent-ideas. A point with no endpoint (a
client-loader-only query, a loader-less page) is skipped entirely. Route params
are converted to OpenAPI syntax — :id becomes {id}, so an action on
/api/ideas/:id shows up at /api/ideas/{id}.
filter — what gets included
By default only actions appear in the spec. That is rarely what you want, so
every example passes filter: 'all':
openapi({ route: '/openapi.json', filter: 'all' }) // every endpoint point
openapi({ route: '/openapi.json' }) // default: actions only
openapi({ route: '/openapi.json', filter: 'action' }) // same as default, explicit
openapi({
route: '/openapi.json',
filter: (point) => point.tags.includes('public'), // a custom predicate
})The predicate receives each ready point and runs after the endpoint check, so you only ever filter points that already have an endpoint.
summary and operationId
These are derived from the point automatically, so a fresh spec is already readable:
// a query named `idea` →
// summary: "root:query:idea" (the point id: scope:type:name)
// operationId: "ideaQuery" (camelCase of name + '_' + type)Two gotchas, both from how the point is named:
- Actions get no auto-summary. A query/mutation/page gets
summarydefaulted to its point id; an action shows a summary only if you set one with.openapi(). - An action's
operationIdis its name — but only when the action was given an explicit name. An action whose name is still the auto-generated"METHOD /route"gets nooperationId. Name it (or setoperationIdvia.openapi()) to get a stable one.
Anything you set explicitly always wins over the derived value.
Request schemas
The request side is read straight from the point's validation schemas — you write the schema once, it both validates the request and documents it.
.params(...)→ path parameters (alwaysrequired: true).search(...)→ query parameters (requiredper the schema).headers(...)→ header parameters.cookies(...)→ cookie parameters.body(...)(actions) /.input(...)(queries & mutations) → request body
export const ideaUpdateAction = root.lets
.action('PUT', '/api/ideas/:id')
.params(z.object({ id: z.string().min(1) })) // → path param {id}, required
.search(z.object({ draft: z.boolean().optional() })) // → query param, optional
.body(z.object({ title: z.string().min(1), content: z.string().min(1) })) // → requestBody
.action(/* ... */)The request body is application/json by default. If the body schema contains a
file or blob field, the content type switches to multipart/form-data and the
JSON variant is dropped:
.body(z.object({ avatar: z.file() }))
// → requestBody content is multipart/form-data;
// avatar serializes to { type: 'string', format: 'binary', ... }requestBody.required is false when every field in the body is optional, and
true otherwise.
Auto-added header parameters
Two header parameters can appear that you did not declare:
X-Point0-Transform— added to every endpoint when a transformer (e.g.superjson) is set on the root. It isenum: ['true', 'false'], optional, and controls whether the response body is run through the transformer. Suppress it withhideTransformHeader: true.X-Point0-Output-Type— added to a page endpoint when SSR is on (enumofdata/queryClientDehydratedState/html), selecting the output format of the page response.
Response schemas — .response()
The response type is not inferred from your loader's return type yet —
declare it with .response() if you want it in the spec. Without it, an
endpoint shows a bare 200: { description: 'Successful response' }.
export const ideaQuery = root.lets
.query()
.input(z.object({ id: z.number() }))
.response(z.object({ idea: ideaSchema })) // → 200, application/json, this schema
.loader(/* ... */)
.query()A plain schema is wrapped as a 200 JSON response. To document several
statuses, pass a map keyed by status code:
.response({
200: z.object({ idea: ideaSchema }), // → description: 'Successful response'
404: z.object({ message: z.string() }), // → description: 'Error response' (4xx)
})A 4xx status gets the description 'Error response'; anything else gets
'Successful response'. For full control — custom content types, descriptions,
or examples — pass an already-normalized response object:
.response({
200: {
description: 'The idea',
content: { 'application/json': { schema: z.object({ idea: ideaSchema }) } },
},
}).response() is available on the endpoint points — queries, mutations, and
actions.
.openapi() — operation metadata
.openapi() sets the OpenAPI fields that aren't generated — summary,
description, operationId, tags, deprecated, externalDocs, security,
and so on:
export const ideaUpdateAction = root.lets
.action('PUT', '/api/ideas/:id')
.body(z.object({ title: z.string().min(1), content: z.string().min(1) }))
.response(z.object({ idea: ideaSchema }))
.openapi({
summary: 'Update an idea',
description: 'Updates an existing idea and returns it',
operationId: 'updateIdea',
tags: ['ideas'],
deprecated: false,
})
.action(/* ... */)You cannot set parameters, requestBody, or responses through
.openapi() — those are generated from your schemas (and .response()). The
argument type omits exactly those three keys; everything else on an OpenAPI
operation object is allowed, including x-* extensions.
Call .openapi() more than once and the objects merge — last call wins per key,
except tags, which are unioned and de-duplicated across calls:
.openapi({ tags: ['ideas'], summary: 'A' })
.openapi({ tags: ['public'], summary: 'B' })
// → summary: 'B', tags: ['ideas', 'public']tags and description are never auto-generated
Point0 maps tags and description to the operation only when you set them
via .openapi() — there is no derivation from the point's name, scope, or its
.tag() / .description() metadata. If you want grouped, described operations,
pass tags and description to .openapi() explicitly. (summary,
operationId, deprecated from .openapi() are mapped the same way — set, and
they land on the operation.)
Models and $ref
Schemas you register as named models on the root with .models({...}) are
emitted under components.schemas, and any inline request/response schema that
exactly matches a model is replaced by a $ref to it:
export const root = Point0.lets
.root()
.schemaHelper(zodSchemaHelper())
.models({ idea: ideaSchema }) // → components.schemas.idea
.root()
// an action that returns { idea: ideaSchema } now references it:
// "idea": { "$ref": "#/components/schemas/idea" }Matching is by a deterministic signature, so two structurally identical inline
schemas collapse to the same $ref.
The UIs: Scalar and Swagger
scalar and swagger each take either a string (the route to serve the UI at)
or an object — UI options plus a route:
openapi({
route: '/openapi.json',
scalar: '/scalar', // string form
swagger: { route: '/swagger', showExtensions: true }, // object form + options
})Each UI defaults its spec url to the route you set for the JSON spec, so you
don't repeat it. Omit scalar or swagger and that UI route simply isn't
served. Scalar options are @scalar/api-reference config (e.g. theme,
onLoaded); Swagger options are swagger-ui config. Both UIs load their
bundles from a CDN.
Protecting the docs
The before option runs middleware only on the doc routes
(json/scalar/swagger), not on every request — the natural place to put auth in
front of your spec. The canonical guard is @point0/basic-auth:
import { basicAuth } from '@point0/basic-auth'
openapi({
route: '/openapi.json',
scalar: '/scalar',
swagger: '/swagger',
filter: 'all',
before: basicAuth({ users: serverEnv.OPENAPI_CREDENTIALS }), // env-driven in prod
})Without credentials the doc routes return 401 with a WWW-Authenticate
header; with valid credentials they serve normally. before accepts a single
middleware or an array.
Caching
The generated spec is cached by default, keyed by the JSON route. Tune it
with cache:
openapi({ route: '/openapi.json' }) // default: cached under '/openapi.json'
openapi({ route: '/openapi.json', cache: false }) // never cache — rebuild every hit
openapi({ route: '/openapi.json', cache: 'specs' }) // cache under a custom key
openapi({ route: '/openapi.json', cache: true }) // explicit: cache under the json routeBecause caching is on by default, schema changes you make at runtime won't show
up until the process restarts — pass cache: false if you need a fresh spec on
every request (e.g. while iterating).
Document-level fields
openapi(...) also accepts any top-level field of an OpenAPI document — they
are merged into the output, with your values overriding the generated ones:
openapi({
route: '/openapi.json',
filter: 'all',
openapi: '3.0.0', // spec version; default '3.0.0'. Use '3.1.x' for OpenAPI 3.1 types
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
})The openapi version string is a type discriminator: a '3.1...' value
switches the option and output types to OpenAPI 3.1. info, servers, tags,
security, components, and the rest are passed straight through.
Generating the spec without the middleware
For tooling that needs the spec outside a request (writing it to a file at build
time, say), getOpenapiSchemaFromPoints is exported — it's what the middleware
calls internally:
import { getOpenapiSchemaFromPoints } from '@point0/openapi'
const spec = getOpenapiSchemaFromPoints(points, {
info: { title: 'My API', version: '1.0.0' },
})It takes an array of ready points (or { point } wrappers) and the same
document-level options as the middleware, and returns the spec object.
Reference
openapi(options) options
| Option | Type | Default |
|---|---|---|
route | string (required) | — |
filter | 'all' | 'action' | (point) => boolean | 'action' (actions only) |
scalar | string | ScalarOptions & { route } | not served |
swagger | string | SwaggerOptions & { route } | not served |
before | MiddlewareFn | MiddlewareFn[] | none |
cache | string | boolean | on, keyed by route |
openapi | `3.0${string}` | `3.1${string}` | '3.0.0' |
info, servers, tags, security, components, … | any OpenAPI Document field | — |
models | Record<string, InputSchema> | gathered from points |
helpers | SchemaHelper[] | gathered from points |
hideTransformHeader | boolean | false |
Point methods
| Method | On | Sets |
|---|---|---|
.openapi() | query, mutation, action (endpoint points) | operation metadata — summary, description, operationId, tags, deprecated, … (not parameters/requestBody/responses) |
.response() | query, mutation, action | response schema(s) → responses in the spec |
Request schemas come from the validation methods (.params,
.search, .headers, .cookies, .body, .input); named models come from
.models() on the root.
What is and isn't mapped today
- Mapped: path/method, request parameters and body (from validation
schemas),
requestBody.required, multipart detection for file bodies, responses (from.response()),$refdedup against.models(), theX-Point0-TransformandX-Point0-Output-Typeheaders, and every operation field you pass to.openapi()(summary,description,operationId,tags,deprecated, …). - Auto-derived:
summary(point id, non-actions only),operationId(camelCase of name+type for non-actions; the action name for explicitly-named actions). - Not derived:
tagsanddescriptionare never generated from the point — supply them via.openapi(). Response types are not inferred from the loader's return type — declare them via.response().
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️