Back to Blog

API Documentation with OpenAPI 3.1: TypeSpec, Spec Generation, and Docs-as-Code

Build production API documentation with OpenAPI 3.1: generate specs from TypeScript code with Zod, author with Microsoft TypeSpec, serve with Scalar or Swagger UI, and integrate docs into your CI/CD pipeline as a first-class artifact.

Viprasol Tech Team
October 3, 2026
13 min read

API documentation has two failure modes: documentation that's maintained separately from code and drifts out of sync, and documentation generated from code that's technically accurate but useless to read.

The 2026 approach that avoids both: generate OpenAPI specs from your TypeScript types and validation schemas (so they're always accurate), then write human-readable descriptions and examples separately (so they're actually useful). CI fails if the spec drifts.


OpenAPI 3.1 Basics

OpenAPI 3.1 aligns with JSON Schema draft 2020-12, enabling richer schema definitions than 3.0:

# openapi/base.yaml โ€” start here
openapi: 3.1.0
info:
  title: Viprasol API
  version: 2.0.0
  description: |
    REST API for the Viprasol platform.
    
    ## Authentication
    All endpoints require a Bearer token obtained from `/auth/token`.
    
    ## Rate Limiting
    - 1,000 requests/minute per token (standard tier)
    - 10,000 requests/minute (enterprise tier)
    
    Rate limit headers are included in every response:
    `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
  contact:
    name: Viprasol Engineering
    url: https://viprasol.com/contact
    email: api@viprasol.com

servers:
  - url: https://api.viprasol.com/v2
    description: Production
  - url: https://api.staging.viprasol.com/v2
    description: Staging
  - url: http://localhost:3000/api/v2
    description: Local development

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

security:
  - bearerAuth: []  # Apply to all endpoints by default

Generating OpenAPI from Zod Schemas

zod-to-openapi generates an OpenAPI spec from your existing Zod schemas โ€” no duplication:

// src/api/openapi/registry.ts
import {
  OpenAPIRegistry,
  OpenApiGeneratorV31,
  extendZodWithOpenApi,
} from "@asteasolutions/zod-to-openapi";
import { z } from "zod";

// Extend Zod with OpenAPI metadata methods
extendZodWithOpenApi(z);

export const registry = new OpenAPIRegistry();

// Define reusable schemas
const UserSchema = registry.register(
  "User",
  z.object({
    id: z.string().uuid().openapi({ description: "User's unique identifier" }),
    email: z.string().email().openapi({ example: "user@example.com" }),
    name: z.string().min(1).max(100).openapi({ example: "Jane Smith" }),
    plan: z
      .enum(["free", "starter", "growth", "enterprise"])
      .openapi({ description: "Subscription plan" }),
    createdAt: z.string().datetime().openapi({
      description: "ISO 8601 timestamp",
      example: "2026-01-15T10:30:00Z",
    }),
  })
);

const PaginationSchema = z.object({
  page: z
    .number()
    .int()
    .min(1)
    .default(1)
    .openapi({ description: "Page number (1-indexed)" }),
  perPage: z
    .number()
    .int()
    .min(1)
    .max(100)
    .default(20)
    .openapi({ description: "Results per page" }),
});

const ErrorSchema = registry.register(
  "ApiError",
  z.object({
    error: z.string().openapi({ example: "RESOURCE_NOT_FOUND" }),
    message: z.string().openapi({ example: "User not found" }),
    details: z.record(z.string(), z.array(z.string())).optional().openapi({
      description: "Field-level validation errors",
      example: { email: ["Invalid email format"] },
    }),
  })
);

// Register endpoints
registry.registerPath({
  method: "get",
  path: "/users",
  summary: "List users",
  description:
    "Returns a paginated list of users for the authenticated tenant. " +
    "Results are sorted by `createdAt` descending.",
  tags: ["Users"],
  request: {
    query: PaginationSchema.extend({
      search: z.string().optional().openapi({
        description: "Filter by name or email (partial match)",
      }),
      plan: z
        .enum(["free", "starter", "growth", "enterprise"])
        .optional()
        .openapi({ description: "Filter by subscription plan" }),
    }),
  },
  responses: {
    200: {
      description: "Paginated list of users",
      content: {
        "application/json": {
          schema: z.object({
            data: z.array(UserSchema),
            pagination: z.object({
              page: z.number().int(),
              perPage: z.number().int(),
              total: z.number().int(),
              totalPages: z.number().int(),
            }),
          }),
        },
      },
    },
    401: {
      description: "Missing or invalid authentication token",
      content: { "application/json": { schema: ErrorSchema } },
    },
  },
});

registry.registerPath({
  method: "post",
  path: "/users",
  summary: "Create a user",
  description: "Creates a new user in the authenticated tenant. Sends a welcome email.",
  tags: ["Users"],
  request: {
    body: {
      description: "User creation payload",
      required: true,
      content: {
        "application/json": {
          schema: z.object({
            email: z.string().email().openapi({ example: "new@example.com" }),
            name: z.string().min(1).max(100).openapi({ example: "Alex Johnson" }),
            role: z
              .enum(["member", "admin"])
              .default("member")
              .openapi({ description: "Role within the tenant" }),
          }),
        },
      },
    },
  },
  responses: {
    201: {
      description: "User created successfully",
      content: { "application/json": { schema: UserSchema } },
    },
    409: {
      description: "A user with this email already exists",
      content: { "application/json": { schema: ErrorSchema } },
    },
    422: {
      description: "Validation failed",
      content: { "application/json": { schema: ErrorSchema } },
    },
  },
});

Generate and Write the Spec

// src/scripts/generate-openapi.ts
import { OpenApiGeneratorV31 } from "@asteasolutions/zod-to-openapi";
import { registry } from "../api/openapi/registry";
import { writeFileSync } from "fs";
import yaml from "js-yaml";

const generator = new OpenApiGeneratorV31(registry.definitions);

const spec = generator.generateDocument({
  openapi: "3.1.0",
  info: {
    title: "Viprasol API",
    version: "2.0.0",
    description: "Viprasol Platform REST API",
  },
  servers: [
    { url: "https://api.viprasol.com/v2", description: "Production" },
  ],
});

// Write both JSON and YAML formats
writeFileSync("openapi/spec.json", JSON.stringify(spec, null, 2));
writeFileSync("openapi/spec.yaml", yaml.dump(spec, { lineWidth: 120 }));

console.log("OpenAPI spec generated successfully.");
// package.json scripts
{
  "scripts": {
    "openapi:generate": "tsx src/scripts/generate-openapi.ts",
    "openapi:validate": "openapi-cli validate openapi/spec.yaml",
    "openapi:lint": "redocly lint openapi/spec.yaml"
  }
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Microsoft TypeSpec (Design-First Approach)

TypeSpec is a meta-language for defining APIs. Write TypeSpec, generate OpenAPI (and other outputs):

// api/main.tsp โ€” TypeSpec definition
import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi3";

using TypeSpec.Http;
using TypeSpec.Rest;

@service({ title: "Viprasol API", version: "2.0.0" })
@server("https://api.viprasol.com/v2", "Production")
namespace ViprasolApi;

// Shared models
model User {
  id: string;
  email: string;
  name: string;
  plan: "free" | "starter" | "growth" | "enterprise";
  createdAt: utcDateTime;
}

model PaginatedUsers {
  data: User[];
  pagination: {
    page: int32;
    perPage: int32;
    total: int32;
    totalPages: int32;
  };
}

model ApiError {
  error: string;
  message: string;
  details?: Record<string[]>;
}

// Users resource
@route("/users")
@tag("Users")
interface Users {
  @doc("List users for the authenticated tenant")
  @get
  list(
    @query page?: int32 = 1,
    @query perPage?: int32 = 20,
    @query search?: string,
    @query plan?: "free" | "starter" | "growth" | "enterprise"
  ): PaginatedUsers | ApiError;

  @doc("Create a new user")
  @post
  create(
    @body body: {
      email: string;
      name: string;
      role?: "member" | "admin";
    }
  ): {
    @statusCode statusCode: 201;
    @body user: User;
  } | {
    @statusCode statusCode: 409;
    @body error: ApiError;
  };

  @doc("Get a specific user")
  @get
  @route("/{id}")
  read(@path id: string): User | ApiError;
}
# Generate OpenAPI from TypeSpec
npx tsp compile api/main.tsp --emit @typespec/openapi3

# Output: tsp-output/@typespec/openapi3/openapi.yaml

Serving Documentation

Scalar (Recommended over Swagger UI)

// src/app/api/docs/route.ts
// Serve API docs at /api/docs

import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const spec = await import("../../../openapi/spec.json");

  // Scalar: modern, clean UI with better DX than Swagger UI
  const html = `<!DOCTYPE html>
<html>
  <head>
    <title>Viprasol API Documentation</title>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <script
      id="api-reference"
      type="application/json"
    >${JSON.stringify(spec)}</script>
    <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
  </body>
</html>`;

  return new NextResponse(html, {
    headers: { "Content-Type": "text/html" },
  });
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

CI/CD Integration (Docs-as-Code)

# .github/workflows/openapi.yml
name: API Documentation

on:
  push:
    branches: [main]
  pull_request:
    paths:
      - "src/api/**"
      - "openapi/**"

jobs:
  validate-spec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"

      - name: Install dependencies
        run: npm ci

      - name: Generate OpenAPI spec
        run: npm run openapi:generate

      - name: Check for spec drift
        # Fail if generated spec differs from committed spec
        run: |
          if ! git diff --exit-code openapi/spec.yaml; then
            echo "::error::OpenAPI spec is out of date. Run 'npm run openapi:generate' and commit the result."
            exit 1
          fi

      - name: Validate spec
        run: npm run openapi:validate

      - name: Lint spec
        run: npx @redocly/cli lint openapi/spec.yaml

      - name: Check for breaking changes (on PRs)
        if: github.event_name == 'pull_request'
        run: |
          git fetch origin main
          npx @optic/cli diff \
            openapi/spec.yaml \
            --base $(git show origin/main:openapi/spec.yaml) \
            --check  # Fails on breaking changes

      - name: Publish to API portal (main branch only)
        if: github.ref == 'refs/heads/main'
        run: |
          npx @redocly/cli push \
            openapi/spec.yaml \
            @viprasol/production@latest \
            --organization viprasol
        env:
          REDOCLY_AUTHORIZATION: ${{ secrets.REDOCLY_API_KEY }}

Writing Useful Descriptions

Generated schemas are accurate but need human-written context to be useful:

# Good OpenAPI description
/payments:
  post:
    summary: Create a payment
    description: |
      Creates a payment charge against the provided payment method.
      
      **Idempotency**: Pass an `Idempotency-Key` header to safely retry requests
      without creating duplicate charges. The key must be unique per charge attempt.
      
      **Webhooks**: A `payment.succeeded` or `payment.failed` event is sent to your
      webhook endpoint after the charge completes (typically within 5 seconds).
      
      **Test mode**: Use card number `4242 4242 4242 4242` with any future expiry
      to simulate successful charges in the staging environment.
    
    # Bad description (avoid):
    # description: Creates a payment.  โ† Restates the summary, adds no value

See Also


Working With Viprasol

Good API documentation is an engineering deliverable, not a task for the end of the sprint. We build API documentation into the development workflow โ€” generating accurate specs from TypeScript types, writing human-readable descriptions that developers can actually use, and validating spec consistency in CI so documentation is always current.

API engineering services โ†’ | Start a project โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.