Typed, validated JSON APIs and OpenAPI 3.1 for Zig's std.http.Server.
zchema is a thin layer over std.http.Server. It adds JSON request
parsing, response serialization, JSON Schema validation, and OpenAPI 3.1
generation. It does not own the accept loop, the socket lifecycle, the threading
model, or any non-JSON behavior. std.http.Server.Request stays available
everywhere, so the raw stdlib path is always one call away.
The approach is borrowed from FastAPI and Pydantic: your declarative Zig types are the single source of truth, and validation and an OpenAPI spec fall out of them.
Schemas and validation come from
h0rv/jsonschema.zig (Draft 2020-12).
Requires Zig 0.16.0+.
zig fetch --save "git+https://github.com/h0rv/zchema.git"Wire the module into build.zig:
const zchema = b.dependency("zchema", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("zchema", zchema.module("zchema"));jsonschema is pulled in automatically as a transitive dependency of
zchema, so you do not need to fetch or wire it yourself.
The snippets below import the module under a short alias:
const z = @import("zchema");Keep your server loop and your routing. Adopt contracts where you want them. A raw stdlib handler:
const reader = try req.readerExpectContinue(&buf);
const raw = try reader.allocRemaining(arena, .limited(1 << 20));
const input = std.json.parseFromSliceLeaky(Echo, arena, raw, .{}) catch
return req.respond("{\"error\":\"invalid json\"}", .{ .status = .bad_request });
// ...validate by hand, serialize by hand...becomes:
const input = z.jsonBody(Echo, arena, &req, .{}) catch |err|
return z.respondError(arena, &req, err, .{});
try z.respondJson(Greeting, arena, &req, .ok, .{ .message = input.name }, .{});jsonBody reads the body under a byte limit, validates it against the schema
emitted from Echo, then parses. respondError turns a zchema error into a
structured JSON body. These helpers work inside any existing handler; you do not
need to register routes to use them.
The handler signature is the contract. Markers tell the dispatcher what each parameter and the return type mean, and the same information drives OpenAPI:
Body(T): parsed and validated request body.Path(T): path params, parsed from the{...}segments intoT.Query(T): query params, parsed intoT(fields with a default or?Tare optional).Header("name"): one request header (case-insensitive) asvalue: ?[]const u8; the name is in the type, so it is also documented as an OpenAPI header parameter. For dynamic or case-sensitive lookups usez.header(req, name)/z.headerWith(req, name, .sensitive)on a*Request.*std.http.Server.Request: the raw request.std.mem.Allocator: the per-request arena.- Return type:
Created(T)/Status(code, T)for a fixed status, a plainTfor 200, or!?Tfor "200 with T, or 404".
const Api = z.Api(.{
z.post("/users", createUser),
z.get("/users", listUsers),
z.get("/users/{id}", getUser),
z.delete("/users/{id}", deleteUser),
});
fn createUser(store: *Store, body: z.Body(CreateUser)) !z.Created(User) {
return .{ .value = try store.create(body.value.name) };
}
fn listUsers(store: *Store, page: z.Query(struct { limit: u32 = 50 })) ![]const User {
return store.list(page.value.limit);
}
fn getUser(store: *Store, path: z.Path(struct { id: u32 })) !?User {
return store.find(path.value.id); // null -> 404
}No explicit contracts are needed for the common cases above. Path and query are
parsed before the body, so they stay valid even though reading the body
invalidates req.head. Invalid params return a 422 with per-field detail.
Reach for these only when the signature cannot express it: extra response cases,
or naming a body type that is not a Body(T) param. Declare contracts and attach
them with op (or route(...).with(...)):
const CreateUserResponse = z.Response(.{
z.case(.created, User),
z.case(.unprocessable_entity, z.ErrorBody),
});
const Api = z.Api(.{
z.op(.POST, "/users", createUser, .{ .response = CreateUserResponse }),
});zchema owns the contracts, not the server. You run std.http.Server and call
Server.handle per request; it returns false when nothing matched, so you stay
in control of the loop, threading, and socket lifecycle:
const Server = z.App(Api, .{ .openapi = .{ .title = "Users API", .version = "1.0.0" } });
fn serveConnection(io: std.Io, gpa: std.mem.Allocator, ctx: *Ctx, stream: std.Io.net.Stream) void {
defer stream.close(io);
var recv: [16 * 1024]u8 = undefined;
var send: [16 * 1024]u8 = undefined;
var sr = stream.reader(io, &recv);
var sw = stream.writer(io, &send);
var http = std.http.Server.init(&sr.interface, &sw.interface);
// One arena per connection, reset (not freed) between requests so keep-alive
// requests reuse the same memory instead of allocating each time.
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
while (true) {
var req = http.receiveHead() catch return;
defer _ = arena_state.reset(.retain_capacity);
const arena = arena_state.allocator();
if (Server.handle(ctx, arena, &req, .{}) catch return) continue;
z.respondErrorBody(arena, &req, z.errorBody(.not_found, "No matching route.", &.{}), .{}) catch return;
}
}examples/users_api.zig is the full single-threaded version; examples/threaded.zig
runs a fixed pool of worker threads accepting on a shared socket (the default
init.io is std.Io.Threaded, which is safe to share across threads). For an
event loop, drive handle from a single-threaded io_uring/kqueue reactor.
Non-JSON endpoints live in the same table via z.raw, which takes the raw
request, responds itself, and is excluded from OpenAPI:
z.raw(.GET, "/health", health) // fn health(req: *std.http.Server.Request) !voidModels are plain structs. Schema metadata rides on an optional
pub const jsonschema. HTTP meaning lives in the contract wrappers, never on
the model, because models are shared across endpoints:
const CreateUser = struct {
name: []const u8,
pub const jsonschema = .{ .fields = .{ .name = .{ .minLength = 1 } } };
};Any registered Api generates an OpenAPI 3.1 document:
const doc = try z.openApiJson(Api, allocator, .{ .title = "Users API", .version = "1.0.0" });
// or stream it: try z.writeOpenApi(Api, writer, .{});Request bodies, response bodies, multiple response cases, and path and query
parameters all come from the registered types. Object schemas are hoisted into
components/schemas and referenced with $ref.
The document is validated against the official OpenAPI 3.1 JSON Schema in the test suite, so it stays compliant.
Attach operation metadata with .with(...) (or in endpoint), and document-level
metadata through OpenApiOptions:
const Api = z.Api(.{
z.get("/users/{id}", getUser).with(.{ .summary = "Fetch a user", .tags = &.{"users"} }),
});
const doc = try z.openApiJson(Api, gpa, .{
.title = "Users API",
.version = "1.0.0",
.servers = &.{.{ .url = "https://api.example.com" }},
.tags = &.{.{ .name = "users", .description = "User operations" }},
.security_schemes = &.{.{ .http = .{ .name = "BearerAuth", .scheme = "bearer", .bearer_format = "JWT" } }},
.security = &.{"BearerAuth"},
});openApiJson is just a renderer over inspectable data. The route table is
public: walk Api.routes, call operation(route) for each, and read the request
body, params, and response models. Use that to build your own artifacts (a custom
spec, client codegen, a route listing). See examples/introspect.zig.
App wraps an Api and serves your routes plus an OpenAPI spec endpoint
(/openapi.json) and a docs UI (/docs), both on by default:
const Server = z.App(Api, .{
.openapi = .{ .title = "Users API", .version = "1.0.0" },
});
// in the request loop:
if (!try Server.handle(&store, arena, &req, .{})) {
// not a route, the spec, or the docs page: fall through to raw stdlib.
}Reserved paths are checked against your routes at comptime, so registering
GET /docs or GET /openapi.json yourself is a compile error. Override or turn
things off:
z.App(Api, .{
.docs = .{
.ui = .redoc, // .scalar (default), .redoc, .swagger_ui, .elements
.ui_path = "/reference", // default "/docs"
.spec_path = "/spec.json", // default "/openapi.json"
// .enabled = false, // turn the spec and docs off entirely
},
});Scalar is the default. Its promotional and telemetry features (AI chat, MCP, telemetry) are off by default and configurable, and the CDN URLs are overridable so you can pin a version or self-host:
z.App(Api, .{
.docs = .{
.scalar = .{
.hide_models = true,
.disable_ai = true, // default
.theme = "moon",
.extra_json = "\"showSidebar\":false", // anything Scalar supports
},
.assets = .{ .script = "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.25.0" },
},
});If you would rather serve the page yourself, docsHtml, writeDocsHtml, and
respondDocs return, stream, or send the same HTML with the same DocsOptions.
The validation and schema layers do not depend on std.http; they operate on
raw bytes and Zig types. So with any server, including
http.zig, you still get request and
response validation by calling the primitives directly:
// validate a raw request body into a typed value (structured errors on failure)
const input = try z.parseAndValidate(CreateUser, req.arena, req.body() orelse "", null);
// serialize a value to JSON bytes, optionally validating it first
res.body = try z.serializeAndValidate(User, res.arena, user, false);
// emit a JSON Schema for any type
const schema = z.schemaText(CreateUser);For an OpenAPI document, declare the endpoints directly with endpoint/Spec,
passing your models. No handlers and no dispatcher are involved, so you keep your
own framework's router and just serve the generated document:
const ApiSpec = z.Spec(.{
z.endpoint(.POST, "/users", .{
.body = CreateUser,
.responses = .{ z.case(.created, User), z.case(.bad_request, z.ErrorBody) },
}),
z.endpoint(.GET, "/users/{id}", .{
.path = struct { id: u32 },
.responses = .{ z.case(.ok, User), z.case(.not_found, z.ErrorBody) },
}),
});
const doc = try z.openApiJson(ApiSpec, gpa, .{ .title = "Users API", .version = "1.0.0" });
// serve `doc` from your server at /openapi.jsonThe dispatcher, markers, and App remain std.http-specific (they own routing);
endpoint/Spec are the handler-free path that works anywhere.
examples/byo_server.zig is a complete runnable version of this pattern: a
hand-rolled router that declares a Spec, validates bodies with
parseAndValidate, and serves the spec and docs (rendered once) itself.
zchema only touches JSON. HTML, bytes, files, redirects, streaming, and
WebSockets pass straight through to the stdlib:
try req.respond(bytes, .{
.status = .ok,
.extra_headers = &.{.{ .name = "content-type", .value = "application/octet-stream" }},
});Boundary failures produce an ErrorBody following RFC 9457
(application/problem+json): type, title, status, detail, and an
errors array of {pointer, message} (JSON Pointer per field). Covered cases:
invalid JSON (400), validation failure (422), unsupported content type (415),
and body too large (413).
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "Request body failed validation.",
"errors": [{ "pointer": "/name", "message": "unexpected property" }]
}respondError(arena, req, err, .{}) maps a caught zchema error to this body;
errorBody(status, detail, fields) plus respondErrorBody send a custom one.
Declare your own error body types as response cases when you need more.
mise run test # zig build test, including example compilation
mise run check # formatting checks plus tests
zig build run # print the demo API's OpenAPI document
zig build run-users_api
zig build run-migration