hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses API documentation with OpenAPI and @hectoday/http

Why documentation

  • Undocumented APIs are unusable
  • The OpenAPI standard
  • Project setup

Describing your API

  • Paths and operations
  • Request parameters
  • Request bodies
  • Responses

Schemas and reuse

  • Component schemas
  • Zod to OpenAPI
  • Error schemas

Beyond endpoints

  • Authentication documentation
  • Tags and grouping
  • Examples and descriptions

Serving and consuming

  • Serving the spec
  • Interactive docs with Scalar
  • Generating client SDKs

Wrapping up

  • Versioned specs
  • Checklist

Serving the spec

We’ve spent the last four sections building up an OpenAPI spec piece by piece: paths, parameters, request bodies, responses, schemas, security, tags, examples. But right now, that spec only exists as concepts. Clients can’t fetch it, documentation UIs can’t render it, and code generators can’t consume it.

In this lesson, we’ll make the spec available as a live endpoint. The @hectoday/openapi package does the heavy lifting.

The openapi() function

You’ve already seen the basic setup in the Project Setup lesson. Let’s look at it in detail:

import { setup, route } from "@hectoday/http";
import { openapi } from "@hectoday/openapi";

const routes = [
  route.get("/v2/books", {
    request: { query: BookQuerySchema },
    response: { 200: BookListSchema },
    resolve: (c) => {
      /* ... */
    },
  }),
  route.post("/v2/books", {
    request: { body: CreateBookSchema },
    response: {
      201: BookSchema,
      400: ValidationErrorSchema,
      401: ErrorSchema,
    },
    resolve: (c) => {
      /* ... */
    },
  }),
  route.get("/v2/books/:id", {
    request: {
      params: z.object({ id: z.uuid() }),
    },
    response: {
      200: BookSchema,
      404: ErrorSchema,
    },
    resolve: (c) => {
      /* ... */
    },
  }),
  route.delete("/v2/books/:id", {
    request: {
      params: z.object({ id: z.uuid() }),
    },
    response: {
      204: z.object({}),
      401: ErrorSchema,
      403: ErrorSchema,
      404: ErrorSchema,
    },
    resolve: (c) => {
      /* ... */
    },
  }),
];

const api = openapi(routes, {
  info: {
    title: "Book Catalog API",
    version: "2.0.0",
    description: "A catalog of books, authors, and reviews.",
  },
  servers: [
    { url: "http://localhost:3000", description: "Development" },
    { url: "https://api.example.com", description: "Production" },
  ],
  tags: [
    { name: "Books", description: "Book catalog management" },
    { name: "Reviews", description: "Book reviews and ratings" },
  ],
  securitySchemes: {
    bearerAuth: {
      type: "http",
      scheme: "bearer",
      bearerFormat: "JWT",
      description: "JWT token obtained from POST /v2/auth/login",
    },
  },
  security: [{ bearerAuth: [] }],
});

export const app = setup({
  routes: [...routes, api.spec(route), api.docs(route)],
});

Let’s walk through what happens here.

The openapi() function takes two arguments: your routes array and a config object. It reads every route’s request and response schemas, converts the Zod types to OpenAPI schemas using zod-openapi, and builds the full OpenAPI 3.1 document.

The config object provides the metadata that goes into the spec: the API title, version, and description in info. The servers array lists where the API is hosted (useful for documentation UIs that let you switch environments). The tags organize endpoints into groups. The securitySchemes and security define authentication.

The function returns an object with two methods: spec(route) and docs(route). Each one creates a route descriptor that you can add to your app.

api.spec(route) creates a GET /openapi.json endpoint that returns the full OpenAPI document as JSON. This is the machine-readable spec that tools and clients consume.

api.docs(route) creates a GET /docs endpoint that serves an interactive documentation UI (Scalar). We’ll look at that in the next lesson.

You spread both into your routes array: [...routes, api.spec(route), api.docs(route)]. Now your app serves its own documentation alongside its actual API endpoints.

What the generated spec looks like

Once the server is running, fetch the spec:

curl http://localhost:3000/openapi.json | jq .

You’ll get a full OpenAPI 3.1 document. Every route becomes a path. Every Zod schema becomes an OpenAPI schema. Path parameters like :id are automatically converted to the OpenAPI {id} format. Query parameters, request bodies, and responses are all generated from the Zod schemas you defined on each route.

If a route has path parameters in its URL pattern (like /v2/books/:id) but no request.params schema, the package automatically infers string parameters for each path segment. If you do provide a request.params schema, it validates that the schema matches the path segments and uses your types instead.

Custom spec and docs paths

By default, the spec is served at /openapi.json and the docs at /docs. You can customize these:

const api = openapi(routes, {
  info: { title: "Book Catalog API", version: "2.0.0" },
  specPath: "/api/spec.json",
  docsPath: "/api/docs",
});

Now the spec is at /api/spec.json and the docs at /api/docs.

Server URLs for different environments

The servers config tells documentation UIs where your API is hosted:

servers: [
  { url: "http://localhost:3000", description: "Development" },
  { url: "https://staging-api.example.com", description: "Staging" },
  { url: "https://api.example.com", description: "Production" },
],

In the documentation UI, this shows up as a dropdown. Clients can select which environment they want to make requests against. During development they use localhost. When testing against staging, they switch to the staging URL. This is a small detail that makes the interactive docs much more practical.

Now that the spec is being served, let’s look at how the interactive documentation UI works.

Exercises

Exercise 1: Add the openapi() call to your app. Start the server and fetch /openapi.json with curl. Verify it’s valid JSON and contains your endpoints.

Exercise 2: Add servers for development and production. Check the generated spec to see them listed.

Exercise 3: Try custom specPath and docsPath. Verify the endpoints move to the new paths.

Why serve the spec as a JSON endpoint instead of a static file?

← Examples and descriptions Interactive docs with Scalar →

© 2026 hectoday. All rights reserved.