Serving & Clients
With the API defined and the handlers implemented, two things are derived from
the same Api value: a server that mounts the routes and a typed client
whose methods mirror the endpoints. This page wires both, then enumerates the
full surface of HttpApiBuilder, HttpApiClient, and HttpApiTest.
Assembling the server
Section titled “Assembling the server”HttpApiBuilder.layer(Api) turns the API plus its handler groups into routes.
Provide each group’s handler Layer, then serve the result with a platform HTTP
server. The example also mounts interactive docs and
exposes the OpenAPI JSON.
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"import { Layer } from "effect"import { HttpRouter } from "effect/unstable/http"import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"import { createServer } from "node:http"import { Api } from "../api/Api.ts"import { AuthorizationLayer } from "./Authorization.ts"import { SystemApiHandlers } from "./System/http.ts"import { Users } from "./Users.ts"import { UsersApiHandlers } from "./Users/http.ts"
// Build the API routes and serve the OpenAPI document at /openapi.jsonconst ApiRoutes = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json"}).pipe( // Provide every group's handler Layer (and their dependencies) Layer.provide([ UsersApiHandlers.pipe(Layer.provide([Users.layer, AuthorizationLayer])), SystemApiHandlers ]))
// Serve interactive Scalar docs at /docsconst DocsRoute = HttpApiScalar.layer(Api, { path: "/docs" })
// Combine all the route layersconst AllRoutes = Layer.mergeAll(ApiRoutes, DocsRoute)
// Mount the routes on a Node HTTP server listening on port 3000const HttpServerLayer = HttpRouter.serve(AllRoutes).pipe( Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })))
// Launch and run. Layer.launch keeps the server alive until interrupted.Layer.launch(HttpServerLayer).pipe(NodeRuntime.runMain)HttpApiBuilder.layer options
Section titled “HttpApiBuilder.layer options”layer registers every group’s routes onto the ambient HttpRouter. It returns
a Layer that fails loudly (via Effect.die) if a group’s handler layer was not
provided, naming the missing HttpApiBuilder.group(api, "<id>", ...).
The only option is openapiPath — a `/${string}` path on which to
serve the generated OpenAPI 3.1 JSON document. Omit it to skip the OpenAPI route.
import { HttpApiBuilder } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
// With the OpenAPI document mounted at GET /openapi.jsonHttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" })
// Without — just the API routes, no spec endpointHttpApiBuilder.layer(Api)The layer requires the platform services a router needs to encode responses:
Etag.Generator, FileSystem, HttpPlatform, Path, the HttpRouter, and the
ToService of every group (i.e. the handler layers).
Serverless / Web standard handler
Section titled “Serverless / Web standard handler”For environments that speak the Fetch API (edge functions, workers), produce a
(request: Request) => Promise<Response> handler instead of binding a port.
HttpRouter.toWebHandler builds the handler from a route Layer;
HttpServer.layerServices supplies the platform services a router needs without
a running server:
import { Layer } from "effect"import { HttpRouter, HttpServer } from "effect/unstable/http"
// `handler` is (request: Request) => Promise<Response>; `dispose` tears down// the scoped runtime the handler was built with.export const { handler, dispose } = HttpRouter.toWebHandler( AllRoutes.pipe(Layer.provide(HttpServer.layerServices)))The typed client
Section titled “The typed client”HttpApiClient.make(Api) builds a client whose shape exactly follows the API:
non-topLevel groups become nested namespaces, endpoints become methods, and
topLevel groups attach their methods directly to the root. Inputs and outputs
are typed and validated against the same schemas the server uses.
import { Context, flow, Layer, Schedule } from "effect"import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"import { HttpApiClient } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"import { AuthorizationClient } from "./AuthorizationClient.ts"
export class ApiClient extends Context.Service< ApiClient, // ForApi derives the full client interface from the API definition HttpApiClient.ForApi<typeof Api>>()("acme/ApiClient") { static readonly layer = Layer.effect( ApiClient, HttpApiClient.make(Api, { // transformClient adjusts the underlying HttpClient: set the base URL, // add retries, attach default headers, etc. transformClient: (client) => client.pipe( HttpClient.mapRequest(flow( HttpClientRequest.prependUrl("http://localhost:3000") )), HttpClient.retryTransient({ schedule: Schedule.exponential(100), times: 3 }) ) }) ).pipe( // Provide the client implementation of any required client middleware Layer.provide(AuthorizationClient), // Provide an HttpClient backend. FetchHttpClient works in the browser and // edge runtimes; NodeHttpClient / BunHttpClient are also available. Layer.provide(FetchHttpClient.layer) )}Calling the API is now just calling methods. The compiler enforces argument shapes and infers the result and error types from the endpoint definitions:
import { Effect } from "effect"import { UserId } from "../domain/User.ts"import { ApiClient } from "./ApiClient.ts"
export const program = Effect.gen(function*() { const client = yield* ApiClient
// topLevel "system" group -> method on the root yield* client.health()
// "users" group -> client.users.<endpoint>. Each method takes one object // whose keys (params / query / payload / headers) mirror the endpoint inputs. // `id` decodes to the branded UserId, so build it with UserId.make. const user = yield* client.users.getById({ params: { id: UserId.make(1) } }) const created = yield* client.users.create({ payload: { name: "Ada", email: "ada@acme.dev" } }) const all = yield* client.users.list({ query: { search: "ada" } })
return { user, created, all }}).pipe(Effect.provide(ApiClient.layer))How requests are encoded
Section titled “How requests are encoded”Each generated method takes a single request object whose keys mirror the
endpoint’s params, query, payload, and headers schemas. An endpoint with
no inputs accepts void (client.health()), so the only optional key is always
responseMode (below). Encoding follows fixed rules:
- Path params are encoded with the endpoint’s
paramsschema and substituted into the path template (/users/:id). - Payloads on body methods (
POST,PUT,PATCH) are encoded to a request body. On bodyless methods (GET,DELETE) the payload is encoded into URL parameters instead. - Multipart payloads must be supplied as a
FormDatainstance — the type of thepayloadkey narrows toFormDatafor multipart endpoints. - Query values and headers are encoded with their respective schemas and appended/set on the request.
Decoding & error channel
Section titled “Decoding & error channel”A successful response is decoded with the matching success schema for its status
code; a declared error response decodes into the endpoint’s error type. The
effect’s error channel is the union of: the endpoint’s declared _Error type,
any middleware errors, HttpClientError.HttpClientError, and Schema.SchemaError
when decoding fails. An unmatched status fails as an HttpClientError carrying a
DecodeError.
Response mode
Section titled “Response mode”Every generated method accepts an optional responseMode on its request object.
The mode is the ClientResponseMode type from HttpApiEndpoint:
type ClientResponseMode = "decoded-only" | "decoded-and-response" | "response-only"It controls what the call returns and which parts of the pipeline run:
"decoded-only"(the default) — returns the decoded success value."decoded-and-response"— returns a tuple[value, response], pairing the decoded value with the rawHttpClientResponse(handy for reading status codes or response headers alongside the body)."response-only"— skips success and error decoding and returns the rawHttpClientResponse. Because nothing is decoded, the endpoint error type andSchemaErrordrop out of the error channel — use this for custom response handling.
import { Effect } from "effect"import { UserId } from "../domain/User.ts"import { ApiClient } from "./ApiClient.ts"
export const modes = Effect.gen(function*() { const client = yield* ApiClient const params = { id: UserId.make(1) }
// Default: the decoded User const user = yield* client.users.getById({ params }) // => User
// Decoded value paired with the raw response — read headers / status alongside it const [user2, response] = yield* client.users.getById({ params, responseMode: "decoded-and-response" }) // => [User, HttpClientResponse] response.status // => 200
// Raw response only — no decoding, narrower error channel const raw = yield* client.users.getById({ params, responseMode: "response-only" }) // => HttpClientResponse const body = yield* raw.text}).pipe(Effect.provide(ApiClient.layer))Testing without a network
Section titled “Testing without a network”HttpApiTest builds an in-memory client that drives your real handlers
through the same encode/decode/routing pipeline as a live server — no port, no
sockets. Provide the handler layers for the groups under test, then call the
client just like the real one.
import { assert, describe, it } from "@effect/vitest"import { Effect } from "effect"import { HttpApiTest } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"import { UsersApiHandlers } from "../server/Users/http.ts"
describe("Users API", () => { it.effect("create + getById round-trip", () => Effect.gen(function*() { // Build the in-memory client for the "users" group. Unselected groups // are still present on the client but die if called. const client = yield* HttpApiTest.groups(Api, ["users"])
const created = yield* client.users.create({ payload: { name: "Ada", email: "ada@acme.dev" } }) assert.strictEqual(created.name, "Ada")
const fetched = yield* client.users.getById({ params: { id: created.id } }) assert.deepStrictEqual(fetched, created) }).pipe( // Supply the real handler layer for the selected group Effect.provide(UsersApiHandlers) ))})HttpApiTest.groups is itself an Effect, so it must be yield*-ed inside a
test effect; the selected groups are taken from the environment (provide their
HttpApiBuilder.group layers). See Testing for the broader testing
story.
HttpApiBuilder reference
Section titled “HttpApiBuilder reference”Server-side assembly: turning groups and endpoints into routes.
HttpApiBuilder.layer
Section titled “HttpApiBuilder.layer”Registers an entire HttpApi onto the ambient HttpRouter, requiring every
group’s handler layer. Accepts { openapiPath } to additionally serve the
OpenAPI document. (See options above.)
import { HttpApiBuilder } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const ApiRoutes = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" })// => Layer<never, never, HttpRouter | ToService<...> | platform services>HttpApiBuilder.group
Section titled “HttpApiBuilder.group”Implements all endpoints in one group. The build callback receives a mutable
Handlers instance; call handlers.handle(name, handler) (or handleRaw) for
each endpoint. The result Layer provides that group’s ApiGroup service.
import { Effect } from "effect"import { HttpApiBuilder } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
export const SystemApiHandlers = HttpApiBuilder.group( Api, "system", Effect.fn(function*(handlers) { return handlers.handle("health", () => Effect.void) }))// => Layer<ApiGroup<"Api", "system">, ...>HttpApiBuilder.Handlers
Section titled “HttpApiBuilder.Handlers”The mutable handler collection passed to group’s callback. Its methods refine
the type-level set of not-yet-implemented endpoints:
handle(name, handler, options?)— implement an endpoint with automatic payload decoding and success encoding.handleRaw(name, handler, options?)— opt out of automatic payload decoding and receive the raw request (e.g. for streaming bodies).
Both accept { uninterruptible?: boolean }. The Handlers.Item, Handlers.Error,
and Handlers.Context helper types describe the registered handler and the error
and service channels it contributes.
import { Effect } from "effect"import { HttpApiBuilder } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
export const UsersApiHandlers = HttpApiBuilder.group( Api, "users", Effect.fn(function*(handlers) { return handlers .handle("getById", (req) => getUser(req.params.id)) .handle("create", (req) => createUser(req.payload)) }))
declare function getUser(id: unknown): Effect.Effect<any>declare function createUser(payload: unknown): Effect.Effect<any>HttpApiBuilder.endpoint
Section titled “HttpApiBuilder.endpoint”Builds the server-side HTTP Effect for a single endpoint, given its group
name, endpoint name, and a handler. Useful when mounting one endpoint manually
rather than a whole group via layer.
import { Effect } from "effect"import { HttpApiBuilder } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const healthRoute = HttpApiBuilder.endpoint(Api, "system", "health", () => Effect.void)// => Effect<Effect<HttpServerResponse, never, ...>, never, ...>HttpApiBuilder.handlerToRoute
Section titled “HttpApiBuilder.handlerToRoute”Lower-level helper that converts a group + a Handlers.Item + a Context into an
HttpRouter.Route. This is the primitive layer and HttpApiTest use internally;
reach for it only when building custom routing on top of HttpApi.
import { HttpApiBuilder } from "effect/unstable/httpapi"
// HttpApiBuilder.handlerToRoute(group, handlerItem, context)// => HttpRouter.Route — the method + path + compiled handler effectHttpApiClient reference
Section titled “HttpApiClient reference”Client-side derivation: turning groups and endpoints into callable methods.
HttpApiClient.make
Section titled “HttpApiClient.make”The common entry point. Takes the HttpClient from the Effect environment and
returns the full client. Options: transformClient (adjust the underlying
HttpClient), transformResponse (wrap the per-call decode effect), and
baseUrl (prepended to every request URL).
import { Effect } from "effect"import { HttpApiClient } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const make = HttpApiClient.make(Api, { baseUrl: "http://localhost:3000" })// => Effect<Client<...>, never, HttpClient | MiddlewareClient<...>>HttpApiClient.makeWith
Section titled “HttpApiClient.makeWith”Like make, but takes an explicit HttpClient ({ httpClient }) instead of
reading it from the environment — for when you already hold a concrete or
transformed client. Supports transformResponse and baseUrl. This is what
HttpApiTest.groups uses with its in-memory client.
import { Effect } from "effect"import { HttpClient } from "effect/unstable/http"import { HttpApiClient } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const makeWith = HttpClient.HttpClient.pipe( Effect.flatMap((httpClient) => HttpApiClient.makeWith(Api, { httpClient, baseUrl: "http://localhost:3000" }) ))HttpApiClient.group
Section titled “HttpApiClient.group”Builds the typed client object for one group only, filtering the API down to
that group. Returns a Client.Group (the nested object of endpoint methods).
import { Effect } from "effect"import { HttpClient } from "effect/unstable/http"import { HttpApiClient } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const usersClient = HttpClient.HttpClient.pipe( Effect.flatMap((httpClient) => HttpApiClient.group(Api, { group: "users", httpClient }) ))// usersClient.getById({ params: { id } }), usersClient.create({ payload })HttpApiClient.endpoint
Section titled “HttpApiClient.endpoint”Builds the typed method for a single endpoint in a group. Returns just the
callable function. Supports transformClient, transformResponse, and baseUrl.
import { Effect } from "effect"import { HttpClient } from "effect/unstable/http"import { HttpApiClient } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
const getById = HttpClient.HttpClient.pipe( Effect.flatMap((httpClient) => HttpApiClient.endpoint(Api, { group: "users", endpoint: "getById", httpClient }) ))// yield* getById({ params: { id } }) => UserHttpApiClient.urlBuilder
Section titled “HttpApiClient.urlBuilder”Mirrors the client layout but returns URL strings instead of executing
requests — it encodes only params and query. Useful for links, redirects, or
constructing requests by hand. Accepts an optional baseUrl.
import { Schema } from "effect"import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
const Api = HttpApi.make("Api").add( HttpApiGroup.make("users").add( HttpApiEndpoint.get("getUser", "/users/:id", { params: { id: Schema.String } }) ))
const buildUrl = HttpApiClient.urlBuilder(Api, { baseUrl: "https://api.example.com" })
buildUrl.users.getUser({ params: { id: "123" } })// => "https://api.example.com/users/123"HttpApiClient.makeClient
Section titled “HttpApiClient.makeClient”Internal driver shared by make, makeWith, group, and endpoint. It reflects
over the API, building per-endpoint encode/decode functions and invoking
onGroup / onEndpoint callbacks. You will not normally call it directly — use
one of the constructors above.
Client types: Client, ForApi, Client.Group, Client.Method, UrlBuilder
Section titled “Client types: Client, ForApi, Client.Group, Client.Method, UrlBuilder”Client<Groups, E, R>— the generated client object: nested objects for non-topLevelgroups, methods on the root fortopLevelgroup endpoints.ForApi<Api, E, R>— derivesClientstraight from anHttpApitype. Use it as the service shape:Context.Service<ApiClient, HttpApiClient.ForApi<typeof Api>>.Client.Group<Groups, Name, E, R>— the object for one group (returned byHttpApiClient.group).Client.Method<Endpoint, E, R>— the function generated for one endpoint; generic over the responseMode.Client.ResponseMode/Client.Response<Success, Mode>— the per-callresponseModetype and the value it produces.UrlBuilder<Api>— the shape returned byurlBuilder.
import { Context } from "effect"import { HttpApiClient } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
export class ApiClient extends Context.Service< ApiClient, HttpApiClient.ForApi<typeof Api>>()("acme/ApiClient") {}HttpApiTest reference
Section titled “HttpApiTest reference”HttpApiTest.groups
Section titled “HttpApiTest.groups”Creates an in-memory client for the named groups of an HttpApi. Handlers for the
selected groups are taken from the environment (provide their
HttpApiBuilder.group layers); endpoints in unselected groups are wired with
placeholder handlers that die if called, catching accidental out-of-scope calls.
The returned client is a real HttpApiClient — middleware, platform services, and
the optional baseUrl behave exactly as in production; only the HTTP transport is
replaced with an in-memory router dispatch.
import { Effect } from "effect"import { HttpApiTest } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"import { UsersApiHandlers } from "../server/Users/http.ts"
const test = Effect.gen(function*() { // baseUrl is optional; defaults to "http://localhost:3000" const client = yield* HttpApiTest.groups(Api, ["users"], { baseUrl: "http://localhost:3000" })
const created = yield* client.users.create({ payload: { name: "Ada", email: "ada@acme.dev" } }) // => User (driven through the real handler, in memory)}).pipe(Effect.provide(UsersApiHandlers))The next step is publishing the contract as OpenAPI.