OpenAPI & Docs UIs
Your HttpApi definition already contains everything an OpenAPI document needs:
paths, methods, parameters, request and response schemas, error status codes, and
security schemes. So the spec is generated, not hand-maintained — it can never
drift from the running server. You can produce the raw spec, serve it as JSON, or
mount a ready-made documentation UI.
Generating the spec
Section titled “Generating the spec”OpenApi.fromApi(Api) returns an OpenAPI 3.1 specification object derived from
the definition:
import { OpenApi } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
// A plain OpenAPISpec object — serialize it, write it to disk, publish it, etc.const spec = OpenApi.fromApi(Api)// => { openapi: "3.1.0", info: { title, version }, paths, components, security, tags }fromApi walks every group and endpoint, emitting OpenAPI tags, paths,
operations, parameters, request bodies, responses, security schemes, and shared
component schemas. The result is cached per HttpApi instance, so calling it
repeatedly (e.g. once per docs request) is cheap.
When assembling the server, pass openapiPath to HttpApiBuilder.layer to serve
this document at a URL:
import { HttpApiBuilder } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
// GET /openapi.json now returns the generated specconst ApiRoutes = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json"})Serving interactive docs
Section titled “Serving interactive docs”Mount a documentation UI as its own route layer. Both layers serve the OpenAPI
spec and render an explorer at the configured path (defaulting to /docs).
import { HttpApiScalar } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
// Scalar assets are inlined by default — no external CDN required.const DocsRoute = HttpApiScalar.layer(Api, { path: "/docs" })
// Or load Scalar from a CDN instead of inlining it:// const DocsRoute = HttpApiScalar.layerCdn(Api, { path: "/docs" })import { HttpApiSwagger } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const DocsRoute = HttpApiSwagger.layer(Api, { path: "/docs" })Merge the docs route with your API routes when serving (see Serving & clients):
import { Layer } from "effect"
const AllRoutes = Layer.mergeAll(ApiRoutes, DocsRoute)Annotating the spec
Section titled “Annotating the spec”Use OpenApi.annotations(...) with .annotateMerge(...) to enrich the document.
Annotations apply at every level — the whole API, a group, or an individual
endpoint — and merge together into the final output.
import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
// Group-level metadata becomes an OpenAPI tag with a descriptionclass UsersApiGroup extends HttpApiGroup.make("users") .add(/* ...endpoints... */) .annotateMerge(OpenApi.annotations({ title: "Users", description: "User management endpoints" })){}
// API-level metadata fills in the document's `info` objectclass Api extends HttpApi.make("user-api") .add(UsersApiGroup) .annotateMerge(OpenApi.annotations({ title: "Acme User API", version: "1.0.0", description: "Manage users and check service health", license: { name: "MIT" } })){}Post-processing the generated spec
Section titled “Post-processing the generated spec”override and transform are also available at the API level, letting you
post-process the whole document after generation. Use override for static extra
keys and transform for programmatic rewrites.
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
// override: shallow-merge top-level keys onto the OpenAPISpecHttpApi.make("api").annotateMerge( OpenApi.annotations({ override: { "x-logo": { url: "https://acme.com/logo.svg" } } }))// => spec gains: { "x-logo": { url: "..." } }import { HttpApi, OpenApi } from "effect/unstable/httpapi"
// transform: rewrite the full document — e.g. inject a global security requirementHttpApi.make("api").annotateMerge( OpenApi.annotations({ transform: (spec) => ({ ...spec, security: [{ bearer: [] }] }) }))// => spec.security === [{ bearer: [] }]For one-off processing without annotations, you can also transform the value
returned by fromApi directly:
import { OpenApi } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const spec = OpenApi.fromApi(Api)const published = { ...spec, info: { ...spec.info, summary: "Published build" } }Excluding endpoints
Section titled “Excluding endpoints”To keep an endpoint, group, or the whole API out of the generated document — for
internal-only routes — set exclude: true in its annotations:
import { HttpApiEndpoint, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
HttpApiEndpoint.get("internalMetrics", "/_internal/metrics", { success: HttpApiSchema.NoContent}) // Present on the server, hidden from the published spec and docs UI .annotateMerge(OpenApi.annotations({ exclude: true }))OpenApi.annotations field reference
Section titled “OpenApi.annotations field reference”OpenApi.annotations(options) builds a Context of annotations from a single
options object; pass it to .annotateMerge(...) on an API, group, or endpoint.
Each field maps to a standalone annotation class (also exported for advanced use,
e.g. building a Context by hand). Where a field applies depends on the level it
is attached to — noted per field below.
Sets the document title (API level), or the tag name (group level). Backed by the
OpenApi.Title class.
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
HttpApi.make("api").annotateMerge(OpenApi.annotations({ title: "Acme API" }))// => info.title === "Acme API"At the group level it overrides the tag name (which otherwise defaults to the group identifier):
import { HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
HttpApiGroup.make("users").annotateMerge(OpenApi.annotations({ title: "Users" }))// => tags: [{ name: "Users" }] (instead of name: "users")version
Section titled “version”Sets info.version on the document. API level only. Backed by OpenApi.Version.
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
HttpApi.make("api").annotateMerge(OpenApi.annotations({ version: "2.3.0" }))// => info.version === "2.3.0" (default is "0.0.1")description
Section titled “description”Sets a description on the API (info.description), a group tag, an endpoint
operation, or a security scheme — depending on where it is attached. Backed by
OpenApi.Description.
import { HttpApiEndpoint, OpenApi } from "effect/unstable/httpapi"
HttpApiEndpoint.get("listUsers", "/users").annotateMerge( OpenApi.annotations({ description: "Returns all users, paginated." }))// => operation.description === "Returns all users, paginated."summary
Section titled “summary”Sets a short summary: info.summary at the API level, or operation.summary on
an endpoint. Backed by OpenApi.Summary.
import { HttpApiEndpoint, OpenApi } from "effect/unstable/httpapi"
HttpApiEndpoint.get("listUsers", "/users").annotateMerge( OpenApi.annotations({ summary: "List users" }))// => operation.summary === "List users"license
Section titled “license”Sets the document license metadata (info.license). API level only. Takes an
OpenAPISpecLicense ({ name, url?, ... }). Backed by OpenApi.License.
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
HttpApi.make("api").annotateMerge( OpenApi.annotations({ license: { name: "MIT", url: "https://opensource.org/license/mit" } }))// => info.license === { name: "MIT", url: "https://opensource.org/license/mit" }externalDocs
Section titled “externalDocs”Attaches an external documentation link ({ url, description? }) to a group tag
or an endpoint operation. Backed by OpenApi.ExternalDocs.
import { HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
HttpApiGroup.make("billing").annotateMerge( OpenApi.annotations({ externalDocs: { url: "https://docs.acme.com/billing", description: "Billing guide" } }))// => tag.externalDocs === { url: "...", description: "Billing guide" }servers
Section titled “servers”Sets the document server list (servers), each entry an OpenAPISpecServer
({ url, description?, variables? }). API level only. Backed by OpenApi.Servers.
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
HttpApi.make("api").annotateMerge( OpenApi.annotations({ servers: [ { url: "https://api.acme.com" }, { url: "https://staging.acme.com", description: "Staging" } ] }))// => servers: [{ url: "https://api.acme.com" }, { url: "https://staging.acme.com", description: "Staging" }]Server URLs may be templated with variables, each an OpenAPISpecServerVariable
({ default, enum?, description? }):
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
HttpApi.make("api").annotateMerge( OpenApi.annotations({ servers: [{ url: "https://{region}.api.acme.com", variables: { region: { default: "us", enum: ["us", "eu"] } } }] }))deprecated
Section titled “deprecated”Marks an endpoint operation as deprecated (operation.deprecated). Endpoint level.
Backed by OpenApi.Deprecated.
import { HttpApiEndpoint, OpenApi } from "effect/unstable/httpapi"
HttpApiEndpoint.get("legacySearch", "/search").annotateMerge( OpenApi.annotations({ deprecated: true }))// => operation.deprecated === trueidentifier
Section titled “identifier”Overrides the generated operationId on an endpoint operation. Endpoint level.
Without it, the id is name for top-level groups, otherwise groupId.endpointName.
Backed by OpenApi.Identifier.
import { HttpApiEndpoint, OpenApi } from "effect/unstable/httpapi"
HttpApiEndpoint.get("listUsers", "/users").annotateMerge( OpenApi.annotations({ identifier: "users_list" }))// => operation.operationId === "users_list" (instead of "users.listUsers")format
Section titled “format”Sets format metadata used by security schemes — notably the bearer-token format on
an HTTP security scheme (bearerFormat). Attach it to a security definition. Backed
by OpenApi.Format.
import { OpenApi } from "effect/unstable/httpapi"
OpenApi.annotations({ format: "JWT" })// => HTTP bearer scheme gains: { bearerFormat: "JWT" }override
Section titled “override”Shallowly merges arbitrary extra fields into the generated object (the API spec,
the group tag, or the endpoint operation), via Object.assign. Use it to add
fields the generator does not model. Backed by OpenApi.Override.
import { HttpApiEndpoint, OpenApi } from "effect/unstable/httpapi"
HttpApiEndpoint.post("upload", "/upload").annotateMerge( OpenApi.annotations({ override: { "x-internal": true } }))// => operation gains: { "x-internal": true }transform
Section titled “transform”A function (node) => node applied during generation to the annotated API
document, group tag, or endpoint operation. Use it when a field needs computing
rather than a static merge. Backed by OpenApi.Transform.
import { HttpApiEndpoint, OpenApi } from "effect/unstable/httpapi"
HttpApiEndpoint.get("listUsers", "/users").annotateMerge( OpenApi.annotations({ transform: (op) => ({ ...op, "x-rateLimit": op.deprecated ? 10 : 100 }) }))// => operation gains a computed "x-rateLimit" fieldoverride is applied before transform, so a transform sees the already-merged
object.
exclude
Section titled “exclude”Omits the annotated group or endpoint from the generated spec (and therefore the
docs UIs). Backed by the OpenApi.Exclude reference (defaults to false). See
Excluding below.
Standalone annotation classes
Section titled “Standalone annotation classes”Every field has a corresponding exported class, useful when building a Context
directly (e.g. in shared middleware or schema helpers) instead of via
OpenApi.annotations. They are Context.Service keys:
OpenApi.Identifier, OpenApi.Title, OpenApi.Version, OpenApi.Description,
OpenApi.License, OpenApi.ExternalDocs, OpenApi.Servers, OpenApi.Format,
OpenApi.Summary, OpenApi.Deprecated, OpenApi.Override, and OpenApi.Transform.
OpenApi.Exclude is a Context.Reference.
import { Context } from "effect"import { OpenApi } from "effect/unstable/httpapi"
// Equivalent to the Title field of OpenApi.annotations({ title: "Users" })const ctx = Context.make(OpenApi.Title, "Users")// => Context<OpenApi.Title> carrying the Title annotationHttpApiScalar reference
Section titled “HttpApiScalar reference”HttpApiScalar mounts a Scalar API reference page on an HttpRouter. The route
renders an HTML page that embeds the OpenAPI document and boots Scalar in the
browser. Both layers register a single GET route, defaulting to /docs.
HttpApiScalar.layer
Section titled “HttpApiScalar.layer”Mounts Scalar using the bundled, inlined Scalar script — no CDN dependency at
runtime (the v4 default). Returns a Layer requiring HttpRouter.
import { HttpApiScalar } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const DocsRoute = HttpApiScalar.layer(Api, { path: "/reference", scalar: { theme: "purple", layout: "modern" }})// => Layer<never, never, HttpRouter> serving Scalar at GET /referenceOptions: path (a /${string}, default /docs) and scalar (a ScalarConfig).
HttpApiScalar.layerCdn
Section titled “HttpApiScalar.layerCdn”Same UI, but loads the Scalar bundle from jsDelivr instead of inlining it. Accepts
an extra version option to pin the Scalar package version (defaults to latest).
import { HttpApiScalar } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const DocsRoute = HttpApiScalar.layerCdn(Api, { path: "/docs", version: "1.25.0", // pin for repeatable output scalar: { theme: "moon" }})// => Layer serving Scalar from https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.25.0/...ScalarConfig options
Section titled “ScalarConfig options”Passed through as Scalar’s HTML configuration. All fields are optional.
theme— aScalarThemeIdcolor preset.layout—"modern"(default look) or"classic".proxyUrl— URL of a request proxy for the in-page API client.showSidebar— whether to show the sidebar.hideModels— hide models from sidebar, search, and content (defaultfalse).hideTestRequestButton— hide the “Test Request” button (defaultfalse).hideSearch— hide the sidebar search bar (defaultfalse).darkMode— whether dark mode is on initially.forceDarkModeState— force"dark"or"light"regardless of toggle.hideDarkModeToggle— hide the dark-mode toggle.favicon— path to a favicon image, e.g."/favicon.svg".customCss— custom CSS injected into the page.baseServerURL— origin used for relative server URLs during SSR (browsers derive this fromwindow.location.origin; server rendering needs it explicit), e.g."http://localhost:3000".withDefaultFonts— load Scalar’s Inter / JetBrains Mono fonts (defaulttrue; setfalsewhen supplying custom fonts).defaultOpenAllTags— open all tags by default (defaultfalse).showOperationId— display theoperationIdin each operation (defaultfalse).
import { HttpApiScalar } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
HttpApiScalar.layer(Api, { scalar: { theme: "deepSpace", layout: "classic", hideModels: true, showOperationId: true, baseServerURL: "http://localhost:3000", customCss: ".scalar-app { font-size: 15px }" }})ScalarThemeId
Section titled “ScalarThemeId”The string union of Scalar theme presets accepted by ScalarConfig.theme:
"alternate", "default", "moon", "purple", "solarized", "bluePlanet",
"saturn", "kepler", "mars", "deepSpace", "laserwave", "none".
import type { HttpApiScalar } from "effect/unstable/httpapi"
const theme: HttpApiScalar.ScalarThemeId = "saturn"// => valid; "ocean" would be a type errorHttpApiSwagger reference
Section titled “HttpApiSwagger reference”HttpApiSwagger.layer
Section titled “HttpApiSwagger.layer”Mounts Swagger UI on an HttpRouter, rendering an HTML page that embeds the
OpenAPI document and boots the bundled Swagger UI bundle. Registers one GET
route, defaulting to /docs. Returns a Layer requiring HttpRouter.
import { HttpApiSwagger } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const DocsRoute = HttpApiSwagger.layer(Api, { path: "/swagger" })// => Layer<never, never, HttpRouter> serving Swagger UI at GET /swaggerThe only option is path (a /${string}, default /docs). Swagger styling and
metadata come from the OpenAPI annotations on the API itself — there is no
per-UI config object like Scalar’s.
The generated spec shape
Section titled “The generated spec shape”OpenApi.fromApi returns an OpenApi.OpenAPISpec — an OpenAPI 3.1.0 document
modeling exactly what the generator produces (not the full OpenAPI spec surface):
interface OpenAPISpec { openapi: "3.1.0" info: OpenAPISpecInfo // { title, version, description?, license?, summary? } paths: OpenAPISpecPaths // Record<routePath, { get?, post?, ... }> components: OpenAPIComponents // { schemas, securitySchemes } security: Array<OpenAPISecurityRequirement> tags: Array<OpenAPISpecTag> // one per non-excluded group servers?: Array<OpenAPISpecServer>}Notable details the generator applies:
- Paths —
:idroute segments become{id}path parameters; the operation’soperationId,tags,parameters,responses, and (for body methods)requestBodyare filled from endpoint metadata. - Component schemas — additional schemas registered on the API must have identifiers; those identifiers become component keys, and invalid OpenAPI component keys are rejected during generation. A duplicate identifier throws.
- Security schemes — derived from security middleware:
BasicandHttpschemes becometype: "http",ApiKeybecomestype: "apiKey"with the configuredname/in. ADescriptionannotation on the security definition becomes the scheme description; aFormatannotation setsbearerFormat. - Encodings —
HttpApiSchemaencodings choose media types (JSON, form-url-encoded, text, binary, multipart). No-content schemas emit responses without bodies; request/response unions are grouped by status code and content type intoanyOf.
The related model exports — OpenAPISpecInfo, OpenAPISpecTag,
OpenAPISpecExternalDocs, OpenAPISpecLicense, OpenAPISpecServer,
OpenAPISpecServerVariable, OpenAPISpecPaths, OpenAPISpecPathItem,
OpenAPISpecOperation, OpenAPISpecParameter, OpenAPISpecResponses,
OpenApiSpecResponse, OpenApiSpecContent, OpenApiSpecMediaType,
OpenAPISpecRequestBody, OpenAPIComponents, OpenAPISecurityScheme
(OpenAPIHTTPSecurityScheme | OpenAPIApiKeySecurityScheme),
OpenAPISecurityRequirement, and OpenAPISpecMethodName — give you precise types
when post-processing the document.
With the spec, docs UI, and a typed client all derived from one definition, the contract stays consistent across server, clients, and documentation. For the broader request/response toolkit, see HTTP Client and Platform.