Skip to content

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.

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 spec
const ApiRoutes = HttpApiBuilder.layer(Api, {
openapiPath: "/openapi.json"
})

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" })

Merge the docs route with your API routes when serving (see Serving & clients):

import { Layer } from "effect"
const AllRoutes = Layer.mergeAll(ApiRoutes, DocsRoute)

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 description
class 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` object
class 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" }
}))
{}

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 OpenAPISpec
HttpApi.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 requirement
HttpApi.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" } }

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(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")

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

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

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"

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" }

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" }

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"] } }
}]
})
)

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 === true

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

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" }

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 }

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" field

override is applied before transform, so a transform sees the already-merged object.

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.

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 annotation

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.

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 /reference

Options: path (a /${string}, default /docs) and scalar (a ScalarConfig).

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

Passed through as Scalar’s HTML configuration. All fields are optional.

  • theme — a ScalarThemeId color 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 (default false).
  • hideTestRequestButton — hide the “Test Request” button (default false).
  • hideSearch — hide the sidebar search bar (default false).
  • 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 from window.location.origin; server rendering needs it explicit), e.g. "http://localhost:3000".
  • withDefaultFonts — load Scalar’s Inter / JetBrains Mono fonts (default true; set false when supplying custom fonts).
  • defaultOpenAllTags — open all tags by default (default false).
  • showOperationId — display the operationId in each operation (default false).
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 }"
}
})

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 error

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 /swagger

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

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:id route segments become {id} path parameters; the operation’s operationId, tags, parameters, responses, and (for body methods) requestBody are 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: Basic and Http schemes become type: "http", ApiKey becomes type: "apiKey" with the configured name/in. A Description annotation on the security definition becomes the scheme description; a Format annotation sets bearerFormat.
  • EncodingsHttpApiSchema encodings 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 into anyOf.

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.