Skip to content

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)

ts
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 spec
  • GET /docs — Scalar's interactive docs UI (on by default whenever an OpenAPI spec is configured; pass docs: false to disable, or docs: { 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:

ts
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:

ts
const koa = httpKoa({
  openapi: { spec: buildMyOwnSpec() },
  docs: true,
});

Or lazy generation per request via generator:

ts
const koa = httpKoa({
  openapi: { generator: async () => buildSpecFromCurrentState() },
});

Per-route metadata

openapi: on a binding becomes the operation's metadata:

ts
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 to 200)
  • resource — a named defineResource, promoted to a shared component
  • schema — an inline zod schema, for envelopes that aren't a named resource
  • description — 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:

ts
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):

json
{
  "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:

ts
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:

ts
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

ts
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

MIT licensed.