Point0by 1gr14
  • Меню
    • GitHub
    • Блог
  • Introduction
    • Overview
    • Getting Started
    • Full Overview
    • Benchmarks
    • Points
  • Points
    • Page
    • Layout
    • Component
    • Provider
    • Mountable
    • Query
    • Infinite Query
    • Mutation
    • Action
    • Root
    • Base
    • Plugin
  • Methods
    • Validation
    • Loader
    • Context
    • Middleware
    • Loading & error
    • .with
    • Mapper
    • Transformer
    • Stage Methods
  • Core
    • Navigation
    • SSR
    • RSC
    • Request
    • Response
    • Error handling
    • Env
    • Head
    • MDX
    • Assets
    • File upload
    • OpenAPI
    • Query client
    • Events
    • Infer
  • Engine
    • Engine Config
    • Engine Runtime
    • CLI
    • Dev
    • Build
    • Compiler
    • Generator
    • Project MCP
    • Docs MCP
    • Importer
    • Public dir
    • Testing
    • Deploy
    • Bun or Vite
  • Extra
    • SsrStore
    • CookieStore
    • Basic Auth
    • CORS
    • Cache-Control
    • Compress
  • Examples
    • Basic
    • Vite
    • Better Auth
    • Capacitor
    • Expo
  • 1gr14
    • Главная
    • Start0
    • Поддержать
    • Обучение
    • Группа
    • Блог
    • Автор
  • Сообщество
    • Discord
    • Telegram
  • Опенсорс
    • Point0
    • Route0
    • Error0
    • Flat
  • Аккаунт
    • Войти
    • Регистрация
Point0by 1gr14
Создаю опенсорс во славу Господа Иисуса Христа ☦️
С любовью ко всем разработчикам на свете ❤️
Условия использованияПолитика конфиденциальностиСергей Дмитриев 2026 😎

OpenAPI

  • Раздел: Core

On this page

  1. Installing
  2. What becomes an operation
  3. filter — what gets included
  4. summary and operationId
  5. Request schemas
  6. Auto-added header parameters
  7. Response schemas — .response()
  8. .openapi() — operation metadata
  9. tags and description are never auto-generated
  10. Models and $ref
  11. The UIs: Scalar and Swagger
  12. Protecting the docs
  13. Caching
  14. Document-level fields
  15. Generating the spec without the middleware
  16. Reference
  17. openapi(options) options
  18. Point methods
  19. What is and isn't mapped today

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.

Installing

@point0/openapi is a separate package. Install it and import openapi from it:

bun add @point0/openapi

The 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 → GET/POST /_point0/<scope>/query/<kebab-name> — the input is a JSON-encoded ?input= query parameter on GET, a body on POST
  • A mutation → POST /_point0/<scope>/mutation/<kebab-name>
  • An action → its declared method and route, e.g. PUT /api/ideas/:id
  • A page/layout → GET 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 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 summary defaulted to its point id; an action shows a summary only if you set one with .openapi().
  • An action's 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.

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 (always required: true)
  • .search(...) → query parameters (required per 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(/* ... */)

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.

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 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 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. (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 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. 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 route

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).

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

OptionTypeDefault
routestring (required)—
filter'all' | 'action' | (point) => boolean'action' (actions only)
scalarstring | ScalarOptions & { route }not served
swaggerstring | SwaggerOptions & { route }not served
beforeMiddlewareFn | MiddlewareFn[]none
cachestring | booleanon, keyed by route
openapi`3.0${string}` | `3.1${string}`'3.0.0'
info, servers, tags, security, components, …any OpenAPI Document field—
modelsRecord<string, InputSchema>gathered from points
helpersSchemaHelper[]gathered from points
hideTransformHeaderbooleanfalse

Point methods

MethodOnSets
.openapi()query, mutation, action (endpoint points)operation metadata — summary, description, operationId, tags, deprecated, … (not parameters/requestBody/responses)
.response()query, mutation, actionresponse 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()), $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, …).
  • 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: 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().
НазадFile uploadДалееQuery client

Enjoying Point0?

Give it a star on GitHub, ask questions, and follow what's new

Star Point0

A star on GitHub helps more developers find Point0
Star on GitHub

Start0

Point0 integrated into the Start0 SaaS boilerplate — the best way to use it
Explore Start0

YouTube

Tutorials, walkthroughs, and updates
YouTube

Discord

Questions and chat in English
Join Discord

Telegram

News channel and chat in Russian
ChannelChat

Twitter

Posts and updates
Twitter