OpenAPI 3.1 Guide: Design-First API Development with Swagger

15 min readAPIs · Web Development

Introduction

OpenAPI is the most widely adopted standard for describing HTTP APIs. Originally called Swagger (and still often called that informally), it lets you define your API's endpoints, parameters, request bodies, response schemas, and authentication requirements in a single YAML or JSON file. That file becomes the contract between your backend and every client that consumes it — frontend teams, mobile apps, third-party integrators, and automated testing tools all work from the same source of truth.

OpenAPI 3.1 (released in 2021) brought full alignment with JSON Schema draft 2020-12, making schema reuse and validation dramatically more consistent. This guide covers everything you need to design, document, and generate code from an OpenAPI 3.1 specification.

Shortcut: Already have an OpenAPI spec? Use the OpenAPI to Axios Client or OpenAPI to SDK tools to generate TypeScript client code instantly.

Why Design-First Beats Code-First

There are two ways to adopt OpenAPI. In the code-first approach, you write your API implementation and then generate the spec from annotations or decorators in your code. In the design-first approach, you write the spec first, review it with stakeholders, then generate server stubs and client code from it.

Design-first wins for most teams for three reasons:

  • Frontend and backend can work in parallel. Once the spec is agreed upon, the frontend team generates a mock server from it and starts building UI. The backend team implements against the same contract. No waiting.
  • Breaking changes are caught before any code is written. Renaming a field or changing a response type in the YAML triggers a review conversation, not a production incident.
  • Documentation is never out of sync. Code-first annotations drift — developers forget to update them. A design-first spec is the implementation target, so the code must match the spec, not the other way around.

Anatomy of an OpenAPI 3.1 Document

An OpenAPI document is a YAML (or JSON) file with a specific top-level structure. Here is a minimal but complete example for a blog API:

openapi: 3.1.0

info:
  title: Blog API
  version: 1.0.0
  description: |
    Manage posts and authors.
    Rate limit: 1000 requests per hour per API key.
  contact:
    name: API Support
    email: [email protected]
  license:
    name: MIT

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

paths:
  /posts:
    get:
      summary: List published posts
      operationId: listPosts
      tags: [Posts]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: Paginated list of posts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostList'
        '401':
          $ref: '#/components/responses/Unauthorized'

components:
  schemas: {}       # defined below
  parameters: {}    # defined below
  responses: {}     # defined below
  securitySchemes: {} # defined below

The top-level sections and their purpose:

SectionRequiredPurpose
openapiYesVersion string — must be "3.1.0" (or "3.0.x" for older)
infoYesAPI title, version, description, contact, license
serversNoBase URLs for the API (production, staging, local)
pathsYes*All endpoints — each path maps to operations (GET, POST, etc.)
componentsNoReusable schemas, parameters, responses, security schemes
securityNoGlobal security requirement applied to all paths by default
tagsNoGroup operations for documentation and SDK organization
externalDocsNoLink to external documentation

* Either paths or webhooks must be present.

Defining Paths and Operations

The paths object is the heart of your spec. Each key is a URL path (with optional path parameters in curly braces), and each nested key is an HTTP method.

paths:
  /posts/{postId}:
    get:
      summary: Get a post by ID
      operationId: getPost      # unique identifier — used in code generation
      tags: [Posts]
      security:
        - BearerAuth: []        # override global security for this operation
      parameters:
        - name: postId
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
          description: Numeric ID of the post
      responses:
        '200':
          description: The requested post
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Post'
              example:
                id: 42
                title: "OpenAPI Guide"
                published: true
        '404':
          description: Post not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    patch:
      summary: Update a post
      operationId: updatePost
      tags: [Posts]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdatePostInput'
      responses:
        '200':
          description: Updated post
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Post'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      summary: Delete a post
      operationId: deletePost
      tags: [Posts]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: integer }
      responses:
        '204':
          description: Post deleted — no response body
        '404':
          $ref: '#/components/responses/NotFound'

Key things to get right on every operation:

  • operationId — unique across the entire spec, used as the function name in generated clients. Use camelCase verbs: listPosts, createPost, updatePost, deletePost.
  • tags — group related operations. SDK generators typically create one class or module per tag.
  • Always document error responses — at minimum 401, 404, and 422. Clients need to know the error shape to handle them correctly.
  • Use $ref everywhere you can — defining schemas inline is fine for one-offs, but any schema used in more than one place belongs in components.

Schema Objects: Typing Your Data

OpenAPI 3.1 schema objects are a superset of JSON Schema. You define the shape of every request body and response in components/schemas, then reference them with$ref. Here is the schema set for the blog API:

components:
  schemas:

    Post:
      type: object
      required: [id, title, content, published, authorId, createdAt]
      properties:
        id:
          type: integer
          readOnly: true         # never required in write operations
        title:
          type: string
          minLength: 1
          maxLength: 200
        content:
          type: string
        published:
          type: boolean
          default: false
        views:
          type: integer
          minimum: 0
          default: 0
          readOnly: true
        authorId:
          type: integer
        tags:
          type: array
          items:
            type: string
        createdAt:
          type: string
          format: date-time
          readOnly: true
        deletedAt:
          type: string
          format: date-time
          nullable: true         # 3.0 syntax — in 3.1 use: type: [string, 'null']

    CreatePostInput:
      type: object
      required: [title, content, authorId]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        content:
          type: string
        tags:
          type: array
          items:
            type: string
          default: []

    UpdatePostInput:
      type: object
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        content:
          type: string
        published:
          type: boolean
        tags:
          type: array
          items:
            type: string

    PostList:
      type: object
      required: [data, total, page, limit]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Post'
        total:
          type: integer
        page:
          type: integer
        limit:
          type: integer

    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
        message:
          type: string
        details:
          type: object
          additionalProperties: true

OpenAPI scalar types and their TypeScript equivalents when generating client code:

OpenAPI type + formatTypeScript typeNotes
type: integernumber32-bit integer
type: integer, format: int64number | stringMay exceed JS safe integer range
type: numbernumber64-bit float
type: stringstring
type: string, format: datestringISO 8601 date — generators may use Date
type: string, format: date-timestringISO 8601 datetime — generators may use Date
type: string, format: uuidstringValidated as UUID by some validators
type: string, format: binaryBlob | FileUsed for file uploads
type: booleanboolean
type: array, items: $refT[]T is the referenced schema type
type: objectRecord<string, unknown>Without properties defined
enum: [a, b, c]"a" | "b" | "c"String literal union

Composition: allOf, anyOf, oneOf

OpenAPI 3.1 inherits JSON Schema's composition keywords. These map directly to TypeScript intersection and union types when generating code:

# allOf: intersection — type must satisfy ALL listed schemas
# TypeScript: type A = Base & Extended
AdminPost:
  allOf:
    - $ref: '#/components/schemas/Post'
    - type: object
      required: [editedBy]
      properties:
        editedBy:
          type: integer

# oneOf: exactly ONE of the schemas — discriminated union
# TypeScript: type Notification = EmailNotif | PushNotif
Notification:
  oneOf:
    - $ref: '#/components/schemas/EmailNotification'
    - $ref: '#/components/schemas/PushNotification'
  discriminator:
    propertyName: type
    mapping:
      email: '#/components/schemas/EmailNotification'
      push:  '#/components/schemas/PushNotification'

# anyOf: one OR more schemas — use sparingly (hard to validate)
FlexibleInput:
  anyOf:
    - type: string
    - type: integer

Parameters and Reusable Components

Parameters appear in four locations: path, query,header, and cookie. Define them once incomponents/parameters and reference them everywhere:

components:
  parameters:
    PageParam:
      name: page
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        default: 1
      description: Page number for pagination

    LimitParam:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
      description: Results per page

    SortParam:
      name: sort
      in: query
      schema:
        type: string
        enum: [created_at, views, title]
        default: created_at

  responses:
    Unauthorized:
      description: Authentication required
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: UNAUTHORIZED
            message: "Bearer token missing or invalid"

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    ValidationError:
      description: Request body failed validation
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
              - type: object
                properties:
                  fields:
                    type: object
                    additionalProperties:
                      type: array
                      items:
                        type: string
          example:
            code: VALIDATION_ERROR
            message: "Request validation failed"
            fields:
              title: ["must not be empty"]
              authorId: ["must be a positive integer"]

Security Schemes

Define your authentication method in components/securitySchemes, then apply it globally or per-operation. Generated clients will include the correct headers or parameters automatically.

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT      # informational only — not validated
      description: |
        JWT obtained from POST /auth/token.
        Include as: Authorization: Bearer <token>

    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: API key for server-to-server calls

    OAuth2:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://auth.example.com/oauth/authorize
          tokenUrl: https://auth.example.com/oauth/token
          scopes:
            posts:read: Read published posts
            posts:write: Create and update posts
            posts:delete: Delete posts

# Apply BearerAuth to all operations by default
security:
  - BearerAuth: []

# Override for specific operations — public endpoint, no auth
paths:
  /posts:
    get:
      security: []           # empty array = no security required

Generating Client Code from Your Spec

This is where the investment in writing a good spec pays off. A single spec file generates typed clients for every language and framework your consumers use.

TypeScript Axios client

Use the OpenAPI to Axios Client tool to paste your spec and get a typed Axios client back instantly. The generated code creates one function per operationId:

// Generated from operationId: getPost
export async function getPost(postId: number): Promise<Post> {
  const { data } = await axios.get<Post>(`/posts/${postId}`);
  return data;
}

// Generated from operationId: createPost
export async function createPost(body: CreatePostInput): Promise<Post> {
  const { data } = await axios.post<Post>('/posts', body);
  return data;
}

// Generated from operationId: listPosts
export async function listPosts(params?: {
  page?: number;
  limit?: number;
}): Promise<PostList> {
  const { data } = await axios.get<PostList>('/posts', { params });
  return data;
}

Python client

The OpenAPI to Python Client tool generates httpx-based async functions with Pydantic models for request and response types — ready to drop into a FastAPI or Django project.

Validation in Node.js

Use openapi-fetch (from the openapi-typescript ecosystem) for the tightest type-safe integration. It infers request/response types directly from the spec without a code generation step:

import createClient from 'openapi-fetch';
import type { paths } from './openapi'; // generated by openapi-typescript

const client = createClient<paths>({ baseUrl: 'https://api.example.com/v1' });

// TypeScript knows exact parameter and response types
const { data, error } = await client.GET('/posts/{postId}', {
  params: { path: { postId: 42 } },
});

// data is Post | undefined, error is Error | undefined
if (error) {
  console.error(error.message);
} else {
  console.log(data.title); // TypeScript knows this is string
}

OpenAPI vs GraphQL vs gRPC: When to Choose Each

All three are serious API technologies used at scale. The decision is not about which is "better" — it's about which properties matter most for your project.

Choose OpenAPI (REST) when:

  • You need a public API that third-party developers can consume without learning a new query language
  • Your API is consumed from browsers, mobile apps, or shell scripts — HTTP is the universal denominator
  • You want the widest possible tooling ecosystem: API gateways, mocking tools, documentation renderers (Redoc, Swagger UI), and testing frameworks all speak OpenAPI natively
  • Caching matters — HTTP GET responses are cacheable by CDNs and browsers without extra configuration
  • You are building CRUD-heavy APIs where resources map cleanly to URLs

Choose GraphQL when:

  • Clients need to fetch complex, nested, or highly variable data shapes in a single request
  • You have many different client types (web, mobile, partner) each needing different subsets of the same data
  • You want to avoid over-fetching and under-fetching without building per-client REST endpoints
  • Your team is building a single internal API consumed by your own frontends, not a public API

Choose gRPC when:

  • You are building service-to-service communication in a microservices backend (not browser-facing)
  • Payload efficiency and throughput are critical — Protocol Buffers are 3–10x smaller than equivalent JSON
  • You need bidirectional streaming (gRPC supports client streaming, server streaming, and full duplex)
  • Your team already uses proto files as the canonical schema for multiple polyglot services
FactorOpenAPI / RESTGraphQLgRPC
TransportHTTP/1.1 or HTTP/2HTTP/1.1 or HTTP/2HTTP/2 only
Schema formatYAML / JSON (OpenAPI spec)GraphQL SDLProtocol Buffers (.proto)
Browser supportNativeYes (via Apollo/urql)Limited (needs grpc-web)
Payload formatJSON (or any)JSONBinary (Protobuf)
CachingNative HTTP cachingManual (persisted queries)None by default
StreamingSSE / WebSocket addonSubscriptionsNative bidirectional
Code generationExcellent (many generators)Good (codegen)Excellent (protoc)
Learning curveLow (HTTP + JSON)MediumHigh
Best forPublic APIs, CRUD, mobileComplex data, internalMicroservices, high perf

Practical Tooling Tips

Validate your spec before generating

A spec with schema errors produces broken generated code. Run validation before any generation step:

# Redocly CLI (recommended — best OpenAPI 3.1 support)
npx @redocly/cli lint openapi.yaml

# Stoplight Spectral (highly configurable rulesets)
npx @stoplight/spectral-cli lint openapi.yaml

# openapi-typescript (generates TypeScript types from spec)
npx openapi-typescript openapi.yaml -o src/openapi.d.ts

Mock server from spec

Run a mock API server from your spec so frontend developers can work before the backend exists:

# Prism mock server — serves example values from your spec
npx @stoplight/prism-cli mock openapi.yaml --port 4010

# Redocly mock server (validates requests against spec too)
npx @redocly/cli preview-docs openapi.yaml

Convert Postman collections to OpenAPI

If your team already has a Postman collection and wants to migrate to an OpenAPI-first workflow, the Swagger to Postman tool goes in the other direction — from an OpenAPI spec to a Postman collection that can be imported and used for manual testing immediately.

Related Tools on ByteJSON

Z

Written by Zhisan

Independent Developer · Last updated June 2026