OpenAPI + Scalar docs
@nwire/koa generates an OpenAPI 3.1 spec from your wires at runtime — no codegen step, no spec file to keep in sync. Scalar's documentation UI mounts alongside.
Enable (auto-generation)
import { httpKoa } from "@nwire/koa";
const koa = httpKoa({
prefix: "/api/v1",
openapi: {
auto: true, // walk wires + binding openapi metadata
title: "My API",
version: "1.0.0",
description: "Customer-facing API for ordering.",
},
});Then:
GET /openapi.json— the generated specGET /docs— Scalar's interactive docs UI (on by default whenever an OpenAPI spec is configured; passdocs: falseto disable, ordocs: { path, title }to customize)
No extra install — the HTTP adopter brings generation with it (@nwire/koa depends on @nwire/openapi, which owns the zod-to-OpenAPI converter). Turn auto on and /openapi.json works.
Need the spec without an HTTP server — for CI codegen, contract diffing, or a build step? Call the generator directly:
import { createApp } from "@nwire/app";
import { generateOpenApi } from "@nwire/openapi";
const app = createApp({ appName: "orders" });
const spec = await generateOpenApi(app.interface.wires, { title: "Orders API" });Pass a pre-built spec
If you want full control of the document (custom security schemes, refs, multiple servers), build the spec yourself and pass it via spec:
const koa = httpKoa({
openapi: { spec: buildMyOwnSpec() },
docs: true,
});Or lazy generation per request via generator:
const koa = httpKoa({
openapi: { generator: async () => buildSpecFromCurrentState() },
});Per-route metadata
openapi: on a binding becomes the operation's metadata:
import { createApp } from "@nwire/app";
import { post } from "@nwire/wires/http";
import { z } from "zod";
const app = createApp({ appName: "users" });
app.wire(
post("/users", {
body: z.object({ email: z.email(), name: z.string() }),
openapi: {
operation: "createUser", // operationId
summary: "Create a user",
description: "Idempotent — POSTing the same email returns the existing row.",
tags: ["users"],
},
}),
async (input) => ({ id: "u_1", ...input }),
);Wires without openapi: metadata are silently skipped in auto-generation.
Response schemas from returns
openapi.returns declares what the route actually answers with. Each entry is one response shape:
status— the HTTP status this shape answers with (defaults to200)resource— a nameddefineResource, promoted to a shared componentschema— an inline zod schema, for envelopes that aren't a named resourcedescription— the response description (falls back to the resource summary, then"Success")
Without returns, the operation gets a bare 200 Success stub — the spec stays valid, but clients can't codegen a response type.
A resource becomes a component
Type returns against a defineResource and the generator registers the resource once, by name, under components.schemas; every route that returns it gets a $ref to the shared component:
import { createApp } from "@nwire/app";
import { defineResource } from "@nwire/handler";
import { get, post } from "@nwire/wires/http";
import { z } from "zod";
const Todo = defineResource("Todo", {
summary: "One todo on the caller's list",
schema: z.object({
id: z.string(),
text: z.string(),
status: z.enum(["open", "completed"]),
userId: z.string(), // internal — not in `public`
}),
public: ["id", "text", "status"],
});
const app = createApp({ appName: "todos" });
app.wire(
post("/todos", {
body: z.object({ text: z.string().min(1) }),
openapi: {
operation: "createTodo",
summary: "Add a todo",
tags: ["todos"],
returns: [{ status: 201, resource: Todo }],
},
}),
async (input) =>
Todo.created({ id: "t_1", text: input.text, status: "open", userId: "u_1" }),
);
app.wire(
get("/todos/:id", {
params: z.object({ id: z.string() }),
openapi: {
operation: "getTodo",
summary: "Get one todo",
tags: ["todos"],
returns: [{ resource: Todo }], // status defaults to 200
},
}),
async (input) =>
Todo.ok({ id: input.id, text: "buy milk", status: "open", userId: "u_1" }),
);/openapi.json now carries the real contract (abridged):
{
"paths": {
"/todos": {
"post": {
"operationId": "createTodo",
"responses": {
"201": {
"description": "One todo on the caller's list",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Todo" }
}
}
}
}
}
},
"/todos/{id}": {
"get": {
"operationId": "getTodo",
"responses": {
"200": {
"description": "One todo on the caller's list",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Todo" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"Todo": {
"type": "object",
"properties": {
"id": { "type": "string" },
"text": { "type": "string" },
"status": { "type": "string", "enum": ["open", "completed"] }
},
"required": ["id", "text", "status"],
"description": "One todo on the caller's list"
}
}
}
}Note what the Todo component does not carry: userId. The documented shape is the resource's public projection — the same public allowlist the response builders (Todo.created / Todo.ok / Todo.list) project through before serializing. The spec documents the shape the wire actually carries, so the two can't drift: a field not on the allowlist neither serializes nor appears in the component.
Envelopes and body-less responses
Not every response is a bare resource. A list endpoint often wraps rows in an envelope — document it with an inline schema. A delete answers 204 with no body — a status-only entry, with neither resource nor schema:
import { createApp } from "@nwire/app";
import { defineResource } from "@nwire/handler";
import { del, get } from "@nwire/wires/http";
import { z } from "zod";
const Todo = defineResource("Todo", {
schema: z.object({ id: z.string(), text: z.string() }),
});
const app = createApp({ appName: "todos" });
app.wire(
get("/todos", {
openapi: {
operation: "listTodos",
returns: [
{
schema: z.object({ items: z.array(Todo.schema), total: z.number().int() }),
description: "The caller's todos with a total count",
},
],
},
}),
async () => ({ items: [], total: 0 }),
);
app.wire(
del("/todos/:id", {
params: z.object({ id: z.string() }),
openapi: {
operation: "deleteTodo",
returns: [{ status: 204, description: "Deleted — no body" }],
},
}),
async () => Todo.noContent(),
);The inline schema stays inline in the operation — no component is registered for it. The 204 entry emits a response with a description and no content.
Errors → response schemas
Declare thrown errors on the binding so they surface in the spec:
import { createApp } from "@nwire/app";
import { defineError } from "@nwire/handler";
import { get } from "@nwire/wires/http";
import { z } from "zod";
const TodoNotFound = defineError({
code: "TODO_NOT_FOUND",
status: 404,
summary: "No todo with that id",
});
const app = createApp({ appName: "todos" });
app.wire(
get("/todos/:id", {
params: z.object({ id: z.string() }),
openapi: {
operation: "getTodo",
errors: [TodoNotFound],
},
}),
async () => {
throw TodoNotFound;
},
);Each declared error with a numeric status adds a response under that status, described by its code — here, a 404 documented as TODO_NOT_FOUND.
Programmatic access
import { createApp } from "@nwire/app";
import { generateOpenApi } from "@nwire/openapi";
const app = createApp({ appName: "orders" });
const spec = await generateOpenApi(app.interface.wires, {
title: "My API",
version: "1.0.0",
serverUrl: "https://api.example.com",
prefix: "/api/v1",
});Useful for exporting the spec to a file for codegen, or for serving it from a non-Nwire host.
Where to next
- HTTP routes — schemas live on bindings.
- Resources + errors — what gets emitted as response and error schemas.