С любовью ко всем разработчикам на свете ❤️
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>PUT /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':
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.
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.
root.lets.action('GET', '/api/posts/:kind(new|top)/:id')
// 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:
.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.
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..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']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:
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 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'
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. before accepts a single middleware or an array.
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 routeSchema 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:
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.
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().