API
Typed HTTP routes, middleware, groups, and OpenAPI on Bun
Build HTTP APIs with validated requests and responses. Schemas use any Standard Schema library (Zod, Valibot, ArkType, …). OpenAPI comes from the same route definitions.
Needs Bun's HTTP server (Bun.serve under the hood via api.serve).
Import
import { Api, Group, Middleware } from "semola/api";Quick start
This registers a validated GET /users/:id route, then starts Bun's HTTP server on port 3000.
import { Api } from "semola/api";
import { z } from "zod";
const api = new Api();
api.defineRoute({
path: "/users/:id",
method: "GET",
request: {
params: z.object({ id: z.string() }),
},
response: {
200: z.object({ id: z.string(), email: z.string() }),
},
handler: async (c) => {
return c.json(200, {
id: c.req.params.id,
email: "hi@semola.dev",
});
},
});
api.serve(3000);For tests or embedding, call api.fetch(request) instead of serving.
Request and response
All request fields are optional. Whatever you declare is validated and typed into the handler.
Params, query, body, headers
This route validates request body, query, and headers before calling createPost, then checks the 201 response against its schema.
api.defineRoute({
path: "/posts",
method: "POST",
request: {
body: z.object({ title: z.string(), body: z.string() }),
query: z.object({ draft: z.stringbool().optional() }),
headers: z.object({ "x-request-id": z.string().optional() }),
},
response: {
201: z.object({ id: z.string() }),
400: z.object({ error: z.string() }),
},
handler: async (c) => {
const post = await createPost(c.req.body);
return c.json(201, { id: post.id });
},
});Invalid input becomes a 400. Query and header values are always strings; use schema coercion such as Zod's z.stringbool() when you want a boolean (?draft=true → true). Toggle validation with new Api({ validation: false }) or { validation: { input: true, output: false } }.
Strict c.json typing
c.json rejects undeclared keys at compile time, including on nested objects, arrays, and union members, and including wider variables (for example a DB row with a password field). Discriminated-union member-specific keys stay valid. Pick or omit to the response shape before returning, or the type checker errors. Runtime output validation still runs separately unless you disable it.
Route context
c.req- validatedparams,query,body,headers,cookiesc.raw- the underlyingRequestc.json/c.text/c.html/c.redirect- typed responses (c.jsonis strict against the declared response schema)c.header(name, value)- set a header on the returned responsec.get(key)- values contributed by middleware
You can also return a plain string or object; Semola wraps it as a response.
Middleware
Middleware can validate, short-circuit with a Response, or attach typed data to the context:
Middleware runs before the route handler. Here, a missing token returns 401 without running the route; a valid token adds userId, which the route reads with c.get.
const auth = new Middleware({
handler: async (c) => {
const token = c.raw.headers.get("authorization");
if (!token) {
return c.json(401, { error: "Unauthorized" });
}
return { userId: "u_123" };
},
});
api.defineRoute({
path: "/me",
method: "GET",
middlewares: [auth] as const,
handler: async (c) => {
return c.json(200, { userId: c.get("userId") });
},
});Pass middleware arrays as as const so c.get is typed from those extensions. Without it, middlewares: [auth] infers Middleware[] and c.get("userId") is never.
Pass middleware on the API (new Api({ middlewares: [...] })), a group, or a single route.
Execution order is API middleware, outer-to-inner group middleware, route middleware, then the route handler. Any middleware that returns a Response stops the chain.
Groups
Share a path prefix and middleware across routes:
This creates GET /admin/stats. The group's authentication middleware runs before the route handler.
const admin = new Group({
prefix: "/admin",
middlewares: [auth],
});
admin.defineRoute({
path: "/stats",
method: "GET",
handler: (c) => c.json(200, { ok: true }),
});
api.mount(admin);SSE
Server-sent events use a generator handler (GET only):
This route streams two events in order, then closes the connection when the generator finishes.
api.defineSSERoute({
path: "/events",
handler: async function* (c) {
yield { data: "hello" };
yield { event: "ping", data: "1" };
},
});String data is sent raw; other values are JSON-encoded.
OpenAPI
Route metadata (summary, description, operationId, tags) and schemas feed the spec:
This configures document metadata and builds the OpenAPI object from all registered routes.
const api = new Api({
openapi: {
title: "My API",
version: "1.0.0",
},
});
const spec = api.getOpenApiSpec();Serve that JSON from a route if you want a public /openapi.json.
Examples
Authenticated route
The auth middleware runs first. It either returns 401 immediately or contributes userId, then the /me route builds the successful response.
const auth = new Middleware({
handler: async (c) => {
const token = c.raw.headers.get("authorization");
if (!token) {
return c.json(401, { error: "Unauthorized" });
}
return { userId: parseToken(token) };
},
});
api.defineRoute({
path: "/me",
method: "GET",
middlewares: [auth] as const,
response: {
200: z.object({ userId: z.string() }),
},
handler: async (c) => {
return c.json(200, { userId: c.get("userId") });
},
});Nested groups
Mounting users inside v1, then v1 inside the API, combines both prefixes into GET /v1/users/:id.
const v1 = new Group({ prefix: "/v1" });
const users = new Group({ prefix: "/users", middlewares: [auth] });
users.defineRoute({
path: "/:id",
method: "GET",
request: {
params: z.object({ id: z.string() }),
},
handler: async (c) => c.json(200, await getUser(c.req.params.id)),
});
v1.mount(users);
api.mount(v1);
// GET /v1/users/:idFetch in tests
fetch() dispatches a Request through the same route pipeline without opening a network port.
api.defineRoute({
path: "/health",
method: "GET",
summary: "Health check",
handler: () => ({ ok: true }),
});
const res = await api.fetch(new Request("http://localhost/health"));
expect(res.status).toBe(200);Build an OpenAPI document
getOpenApiSpec() collects registered route metadata and schemas into an OpenAPI document.
const api = new Api({
openapi: { title: "Users", version: "1.0.0" },
});
const spec = api.getOpenApiSpec();
expect(spec.info.title).toBe("Users");Disable output validation
This keeps request validation enabled while returning handler output without checking response schemas.
const api = new Api({
validation: { input: true, output: false },
});Response headers
Middleware sets the request ID first, then the route adds download metadata and returns the CSV body. Both headers are copied to the response.
const requestId = new Middleware({
handler: (c) => {
c.header("X-Request-Id", crypto.randomUUID());
},
});
api.defineRoute({
path: "/download",
method: "GET",
middlewares: [requestId],
handler: (c) => {
c.header("Content-Disposition", 'attachment; filename="report.csv"');
return c.text(200, "id,name\n1,Ada");
},
});Headers from middleware and the route are copied onto the returned Response.
Get Bun route handlers
getRouteHandlers() compiles registered routes into the map expected by Bun.serve.
const routes = api.getRouteHandlers();
const server = Bun.serve({
routes,
fetch: () => new Response("Not found", { status: 404 }),
});Start a server
serve() starts Bun.serve and passes the server instance to the optional callback.
api.serve(3000, (server) => {
console.log(`Listening on ${server.url}`);
});Reference
Api options
| Option | Default | Meaning |
|---|---|---|
prefix | - | Path prefix for all routes |
openapi | { title: "API", version: "1.0.0" } | OpenAPI document info |
middlewares | [] | Applied to every route |
validation | { input: true, output: true } | false disables both; partial object ok |
Methods
| Method | Meaning |
|---|---|
defineRoute(config) | Register an HTTP route |
defineSSERoute(config) | Register a GET SSE route |
mount(group) | Mount a Group |
fetch(request) | Handle a request without listening |
getRouteHandlers() | Build Bun's route handler map |
getOpenApiSpec() | Build the OpenAPI document |
serve(port, callback?) | Start Bun.serve |
There is no listen method.