Skip to main content
Semola automatically generates OpenAPI 3.1.0 specifications from your route definitions, providing automatic API documentation with full type safety.

Generating OpenAPI Spec

Use the getOpenApiSpec() method to generate an OpenAPI specification from your defined routes:
import { Api } from "semola/api";
import { z } from "zod";

const api = new Api({
  prefix: "/api/v1",
  openapi: {
    title: "My API",
    description: "A type-safe REST API",
    version: "1.0.0",
  },
});

// Define routes...
api.defineRoute({
  path: "/users/:id",
  method: "GET",
  summary: "Get user by ID",
  operationId: "getUserById",
  tags: ["Users"],
  request: {
    params: z.object({
      id: z.uuid(),
    }),
  },
  response: {
    200: z.object({
      id: z.string(),
      name: z.string(),
      email: z.email(),
    }),
    404: z.object({
      message: z.string(),
    }),
  },
  handler: async (c) => {
    // Handler implementation
  },
});

// Generate OpenAPI spec
const spec = await api.getOpenApiSpec();
console.log(JSON.stringify(spec, null, 2));

OpenAPI 3.1.0 Benefits

Semola generates OpenAPI 3.1.0 specifications, which provide several advantages over 3.0:
  • Full JSON Schema Compatibility: Uses standard JSON Schema Draft 2020-12, removing the need for OpenAPI-specific schema extensions
  • Better Null Handling: Uses standard JSON Schema type unions instead of the custom nullable keyword
  • Modern Features: Support for tuple validation, conditional schemas (if/then/else), and $ref with sibling keywords
  • Improved Type Safety: More precise exclusiveMinimum/exclusiveMaximum as numbers rather than booleans
The generated spec is compatible with modern OpenAPI tooling including Swagger UI, Redoc, and OpenAPI Generator.

Configuration Options

Configure OpenAPI generation in the API constructor:
const api = new Api({
  prefix: "/api/v1", // URL prefix for all routes
  openapi: {
    title: "My API",                    // Required: API title
    description: "A type-safe REST API", // Optional: API description
    version: "1.0.0",                    // Required: API version
  },
});

Additional OpenAPI Options

You can provide additional OpenAPI configuration:
const api = new Api({
  prefix: "/api/v1",
  openapi: {
    title: "My API",
    description: "A comprehensive REST API",
    version: "1.0.0",
    servers: [
      { url: "https://api.example.com", description: "Production" },
      { url: "https://staging.example.com", description: "Staging" },
    ],
    securitySchemes: {
      bearerAuth: {
        type: "http",
        scheme: "bearer",
        bearerFormat: "JWT",
      },
    },
  },
});

Route Documentation

Enhance your OpenAPI spec with route-level documentation:
api.defineRoute({
  path: "/users/:id",
  method: "GET",
  
  // OpenAPI documentation fields
  summary: "Get user by ID",
  description: "Retrieves a user's profile information by their unique identifier",
  operationId: "getUserById",
  tags: ["Users"],
  
  request: {
    params: z.object({
      id: z.uuid(),
    }),
  },
  response: {
    200: UserSchema,
    404: ErrorSchema,
  },
  handler: async (c) => {
    // Handler implementation
  },
});

Documentation Fields

  • summary: Short description of the endpoint (appears in API docs)
  • description: Detailed description (supports Markdown)
  • operationId: Unique identifier for the operation (used by code generators)
  • tags: Array of tags for grouping endpoints in documentation

Schema Reuse

To optimize your OpenAPI specification and reduce redundancy, you can define reusable schemas using the .meta({ id: "SchemaName" }) method. Schemas with an ID are extracted to components.schemas and referenced using $ref instead of being inlined everywhere they’re used.

Benefits

  • Smaller spec size: Schema defined once, referenced multiple times
  • Better maintainability: Update schema in one place
  • Improved readability: Cleaner OpenAPI specifications
  • Backward compatible: Schemas without .meta({ id }) are inlined as before

With Zod

import { z } from "zod";

// Define a reusable schema with an ID
const UserSchema = z
  .object({
    id: z.string().uuid(),
    name: z.string(),
    email: z.email(),
  })
  .meta({ id: "User" });

const ErrorResponse = z
  .object({
    error: z.string(),
    message: z.string(),
  })
  .meta({ id: "ErrorResponse" });

// Use across multiple routes
api.defineRoute({
  path: "/users",
  method: "POST",
  request: { body: UserSchema },
  response: { 201: UserSchema, 400: ErrorResponse },
  handler: async (c) => {
    /* ... */
  },
});

api.defineRoute({
  path: "/users/:id",
  method: "GET",
  response: { 200: UserSchema, 404: ErrorResponse },
  handler: async (c) => {
    /* ... */
  },
});

// Result: UserSchema and ErrorResponse are defined once in components.schemas
// and referenced as { "$ref": "#/components/schemas/User" } everywhere

With Valibot

import * as v from "valibot";

// Valibot uses metadata in the schema pipeline
const UserSchema = v.pipe(
  v.object({
    id: v.pipe(v.string(), v.uuid()),
    name: v.string(),
    email: v.pipe(v.string(), v.email()),
  }),
  v.metadata({ id: "User" }),
);

With ArkType

import { type } from "arktype";

// ArkType uses describe() with id metadata
const UserSchema = type({
  id: "string.uuid",
  name: "string",
  email: "string.email",
}).describe("User");

Schema Reuse Example

import { z } from "zod";
import { Api } from "semola/api";

// Define reusable schemas with IDs for OpenAPI optimization
const CreateUserSchema = z
  .object({
    name: z.string().min(1),
    email: z.email(),
  })
  .meta({ id: "CreateUserRequest" });

const UserSchema = z
  .object({
    id: z.uuid(),
    name: z.string(),
    email: z.email(),
  })
  .meta({ id: "User" });

const ErrorSchema = z
  .object({
    message: z.string(),
  })
  .meta({ id: "ErrorResponse" });

const api = new Api({
  prefix: "/api/v1",
  openapi: {
    title: "User API",
    description: "Manage users",
    version: "1.0.0",
  },
});

api.defineRoute({
  path: "/users",
  method: "POST",
  summary: "Create a new user",
  tags: ["Users"],
  request: {
    body: CreateUserSchema,
  },
  response: {
    201: UserSchema,
    400: ErrorSchema,
  },
  handler: async (c) => {
    const user = await createUser(c.req.body);
    return c.json(201, user);
  },
});

api.defineRoute({
  path: "/users/:id",
  method: "GET",
  summary: "Get user by ID",
  tags: ["Users"],
  request: {
    params: z.object({
      id: z.uuid(),
    }),
  },
  response: {
    200: UserSchema,
    404: ErrorSchema,
  },
  handler: async (c) => {
    const user = await findUser(c.req.params.id);

    if (!user) {
      return c.json(404, { message: "User not found" });
    }

    return c.json(200, user);
  },
});

// Generate OpenAPI spec
// Note: UserSchema, CreateUserSchema, and ErrorSchema are defined once
// in components.schemas and referenced everywhere via $ref
const spec = await api.getOpenApiSpec();

Generated Spec Structure

{
  "openapi": "3.1.0",
  "info": {
    "title": "User API",
    "description": "Manage users",
    "version": "1.0.0"
  },
  "paths": {
    "/api/v1/users": {
      "post": {
        "summary": "Create a new user",
        "tags": ["Users"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateUserRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Response with status 201",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "400": {
            "description": "Response with status 400",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "CreateUserRequest": {
        "type": "object",
        "properties": {
          "name": { "type": "string", "minLength": 1 },
          "email": { "type": "string", "format": "email" }
        },
        "required": ["name", "email"]
      },
      "User": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "name": { "type": "string" },
          "email": { "type": "string", "format": "email" }
        },
        "required": ["id", "name", "email"]
      },
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "message": { "type": "string" }
        },
        "required": ["message"]
      }
    }
  }
}

Parameter Handling

Inline Parameters

Note: Query parameters, headers, cookies, and path parameters are always inlined (OpenAPI requirement), regardless of whether they have an ID.
api.defineRoute({
  path: "/users",
  method: "GET",
  summary: "List users with pagination",
  tags: ["Users"],
  request: {
    query: z.object({
      page: z.coerce.number().optional(),
      limit: z.coerce.number().optional(),
    }),
  },
  response: {
    200: z.object({
      users: z.array(UserSchema),
      total: z.number(),
    }),
  },
  handler: async (c) => {
    const page = c.req.query.page ?? 1;
    const limit = c.req.query.limit ?? 10;

    const { users, total } = await listUsers(page, limit);

    return c.json(200, { users, total });
  },
});

Path Parameters

Path parameters are automatically extracted and included in the OpenAPI spec:
api.defineRoute({
  path: "/users/:id",  // :id becomes {id} in OpenAPI
  method: "GET",
  request: {
    params: z.object({
      id: z.uuid(),
    }),
  },
  response: {
    200: UserSchema,
  },
  handler: async (c) => {
    // c.req.params.id is typed as string (validated UUID)
    const user = await getUser(c.req.params.id);
    return c.json(200, user);
  },
});

Middleware Integration

Middlewares can contribute to the OpenAPI spec by defining request and response schemas:
const authMiddleware = new Middleware({
  request: {
    headers: z.object({
      authorization: z.string(),
    }),
  },
  response: {
    401: z.object({ error: z.string() }),
  },
  handler: async (c) => {
    // Middleware implementation
  },
});

api.defineRoute({
  path: "/profile",
  method: "GET",
  middlewares: [authMiddleware] as const,
  response: {
    200: UserSchema,
  },
  handler: async (c) => {
    // Handler implementation
  },
});

// The generated OpenAPI spec will include:
// - authorization header requirement (from middleware)
// - 401 response schema (from middleware)
// - 200 response schema (from route)

Using the Generated Spec

Serve with Swagger UI

import { Api } from "semola/api";

const api = new Api({
  openapi: {
    title: "My API",
    version: "1.0.0",
  },
});

// Define routes...

// Serve OpenAPI spec as JSON
api.defineRoute({
  path: "/openapi.json",
  method: "GET",
  handler: async (c) => {
    const spec = await api.getOpenApiSpec();
    return c.json(200, spec);
  },
});

// Serve Swagger UI
api.defineRoute({
  path: "/docs",
  method: "GET",
  handler: async (c) => {
    return c.html(200, `
      <!DOCTYPE html>
      <html>
        <head>
          <title>API Documentation</title>
          <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
        </head>
        <body>
          <div id="swagger-ui"></div>
          <script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
          <script>
            SwaggerUIBundle({
              url: '/openapi.json',
              dom_id: '#swagger-ui',
            });
          </script>
        </body>
      </html>
    `);
  },
});

Export to File

import { writeFile } from "fs/promises";

const spec = await api.getOpenApiSpec();
await writeFile("openapi.json", JSON.stringify(spec, null, 2));

Best Practices

  1. Use .meta({ id }) for reusable schemas - Reduces spec size and improves maintainability
  2. Add descriptive summaries and tags - Makes your API documentation more useful
  3. Define operationIds - Helps code generators create better client code
  4. Include examples in schemas - Use Zod’s .describe() or Valibot’s metadata for better docs
  5. Document error responses - Include all possible status codes with schemas
  6. Use consistent naming - Schema IDs should follow a naming convention (e.g., PascalCase)
  7. Version your API - Update the version number when making breaking changes

Build docs developers (and LLMs) love