With love for developers of all backgrounds around the world ❤️
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.
@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.
Each point with an endpoint becomes one path → method → operation:
GET/POST /_point0/<scope>/query/<kebab-name> — the input
is a JSON-encoded ?input= query parameter on GET, a body on POSTPOST /_point0/<scope>/mutation/<kebab-name>GET/POST /_point0/<scope>/subscription/<kebab-name>
— the streaming endpoint of a subscription, dual-method like a
query; the line-by-line stream body is not expressible in OpenAPI, so the
operation describes the endpoint, not the valuesGET/POST /_point0/<scope>/channel/<kebab-name> —
the connect endpoint of a channel, dual-method like a query (input
in ?input= on GET, a body on POST). A scope with at least one channel in the
list also gets the bare WebSocket endpoint — GET /_point0/<scope>/websocket,
the handshake itself (filter the channels out to hide it). The space joins and
the messages on the socket don't appear (OpenAPI can't express them); spaces
and handlers get no path at allPUT /api/ideas/:idGET at 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 GET/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 includedBy default only actions appear in the spec. That is rarely what you want, so
every example passes filter: 'all':
export const root = Point0.lets
.root()
.middleware(openapi({ route: '/openapi.json', filter: 'all' })) // every endpoint
.root()Leave filter out — or pass 'action', which says the same explicitly — and
only actions are included. A predicate decides per point:
export const root = Point0.lets
.root()
.middleware(
openapi({
route: '/openapi.json',
filter: (point) => point.tags.includes('public'), // a custom predicate
}),
)
.root()The predicate receives each ready point and runs after the endpoint check, so you only ever filter points that already have an endpoint.
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:
summary
defaulted to its point id; an action shows a summary only if you set one with
.openapi().operationId is its name — but only when the action was given
an explicit name. An action whose name is still the auto-generated
"METHOD /route" gets no operationId. Name it (or set operationId via
.openapi()) to get a stable one.Anything you set explicitly always wins over the derived value.
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 (always required: true).search(...) → query parameters (required per the schema).headers(...) → header parameters.cookies(...) → cookie parameters.body(...) (actions) / .input(...) (queries & mutations) → request bodyexport 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(/* ... */)A route's own params are documented even without .params(...): the path
template keeps only the param names, and a param restricted to a set of values
carries that set as a JSON Schema enum.
export const postAction = root.lets
.action('GET', '/api/posts/:kind(new|top)/:id')
.action(/* ... */)
// path: /api/posts/{kind}/{id}
// kind: { type: 'string', enum: ['new', 'top'] }
// id: { anyOf: [{ type: 'string' }, { type: 'number' }] }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:
export const avatarUploadAction = root.lets
.action('POST', '/api/avatar')
.body(z.object({ avatar: z.file() }))
.action(/* ... */)
// → 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.
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 is
enum: ['true', 'false'], optional, and controls whether the response body is
run through the transformer. Suppress it with hideTransformHeader: true.x-point0-output-type — added to a page endpoint when SSR is on for
the client that owns the page (enum of data / queryClientDehydratedState
/ html), selecting the output format of the page response. SSR resolves per
client, so the spec advertises this header by the owning client's SSR, not the
server's. On a page with no server loader there is no data output, so the
enum drops data and the header becomes required — sending none makes the
request fall back to data and the endpoint answers 400..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:
export const ideaQuery = root.lets
.query()
.input(z.object({ id: z.number() }))
.response({
200: z.object({ idea: ideaSchema }), // → description: 'Successful response'
404: z.object({ message: z.string() }), // → description: 'Error response' (4xx)
})
.loader(/* ... */)
.query()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:
export const ideaQuery = root.lets
.query()
.input(z.object({ id: z.number() }))
.response({
200: {
description: 'The idea',
content: {
'application/json': { schema: z.object({ idea: ideaSchema }) },
},
},
})
.loader(/* ... */)
.query().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:
export const ideaUpdateAction = root.lets
.action('PUT', '/api/ideas/:id')
.openapi({ tags: ['ideas'], summary: 'A' })
.openapi({ tags: ['public'], summary: 'B' })
.action(/* ... */)
// → summary: 'B', tags: ['ideas', 'public']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. (summary, operationId, deprecated
from .openapi() are mapped the same way — set, and they land on the
operation.)
$refSchemas 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.
scalar and swagger each take either a string (the route to serve the UI at)
or an object — UI options plus a route:
export const root = Point0.lets
.root()
.middleware(
openapi({
route: '/openapi.json',
scalar: '/scalar', // string form
swagger: { route: '/swagger', showExtensions: true }, // object form + options
}),
)
.root()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 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.
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'
export const root = Point0.lets
.root()
.middleware(
openapi({
route: '/openapi.json',
scalar: '/scalar',
swagger: '/swagger',
filter: 'all',
before: basicAuth({ users: serverEnv.OPENAPI_CREDENTIALS }), // env-driven
}),
)
.root()Without credentials the doc routes return 401 with a WWW-Authenticate
header. before accepts a single middleware or an array.
The generated spec is cached by default, keyed by the JSON route. Tune it
with cache:
export const root = Point0.lets
.root()
.middleware(openapi({ route: '/openapi.json', cache: false })) // rebuild every hit
.root()Left out, cache caches the spec under the JSON route; true says that
explicitly, and a string (cache: 'specs') caches it under a key you choose.
Schema changes made at runtime won't show up until the process restarts — pass
cache: false for a fresh spec on every request (e.g. while iterating).
openapi(...) also accepts any top-level field of an OpenAPI document — they
are merged into the output, with your values overriding the generated ones:
export const root = Point0.lets
.root()
.middleware(
openapi({
route: '/openapi.json',
filter: 'all',
openapi: '3.0.0', // spec version; '3.1.x' switches to OpenAPI 3.1 types
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
}),
)
.root()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.
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.
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 |
| 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.
requestBody.required, multipart detection for file bodies,
responses (from .response()), $ref dedup against .models(), the
x-point0-transform and x-point0-output-type headers, and every operation
field you pass to .openapi() (summary, description, operationId,
tags, deprecated, …).summary (point id, non-actions only), operationId
(camelCase of name+type for non-actions; the action name for explicitly-named
actions).tags and description are never generated from the point —
supply them via .openapi(). Response types are not inferred from the
loader's return type — declare them via .response().