Steve Kinney

Full-Stack TypeScript

Generating OpenAPI Contracts from Zod Schemas

Let’s through the process of adding OpenAPI documentation to a TypeScript Express server using Zod for validation.

Extend Zod Schemas with OpenAPI Metadata

Create a new file openapi.ts that will convert your Zod schemas to OpenAPI format:

import { extendZodWithOpenApi, generateSchema } from '@anatine/zod-openapi';
import { OpenAPIObject } from 'openapi3-ts/oas31';
import { z } from 'zod';
import * as schemas from './your-schemas'; // Import your Zod schemas

extendZodWithOpenApi(z);

// Define TaskSchema with OpenAPI metadata
const TaskSchema = schemas.TaskSchema.openapi({
	description: 'A task item',
	example: {
		id: 1,
		title: 'Complete OpenAPI integration',
		description: 'Add OpenAPI specs to the tasks API',
		completed: false,
	},
});

const TasksSchema = schemas.TasksSchema.openapi({
	description: 'A collection of task items',
});

const NewTaskSchema = schemas.NewTaskSchema.openapi({
	description: 'Data required to create a new task',
	example: {
		title: 'Create a new task',
		description: 'This is a new task to be created',
	},
});

const UpdateTaskSchema = schemas.UpdateTaskSchema.openapi({
	description: 'Data for updating an existing task',
	example: {
		title: 'Updated task title',
		description: 'Updated task description',
		completed: true,
	},
});

Define the OpenAPI Document

Add the full OpenAPI specification to your openapi.ts file:

export const openApiDocument: OpenAPIObject = {
	openapi: '3.1.0',
	info: {
		title: 'Tasks API',
		version: '1.0.0',
		description: 'API for managing tasks',
		contact: {
			name: 'Steve Kinney',
			email: 'hello@stevekinney.net',
		},
	},
	paths: {
		'/tasks': {
			get: {
				tags: ['Tasks'],
				summary: 'Get all tasks',
				description: 'Retrieve all tasks, optionally filtered by completion status',
				parameters: [
					{
						name: 'completed',
						in: 'query',
						required: false,
						schema: {
							type: 'boolean',
						},
						description: 'Filter tasks by completion status',
					},
				],
				responses: {
					'200': {
						description: 'List of tasks',
						content: {
							'application/json': {
								schema: generateSchema(TasksSchema),
							},
						},
					},
					'500': {
						description: 'Server error',
						content: {
							'application/json': {
								schema: generateSchema(ErrorResponseSchema),
							},
						},
					},
				},
			},
			post: {
				tags: ['Tasks'],
				summary: 'Create a new task',
				description: 'Add a new task to the database',
				requestBody: {
					content: {
						'application/json': {
							schema: generateSchema(NewTaskSchema),
						},
					},
					required: true,
				},
				responses: {
					'201': {
						description: 'Task created successfully',
					},
					'400': {
						description: 'Invalid input',
						content: {
							'application/json': {
								schema: generateSchema(ErrorResponseSchema),
							},
						},
					},
					'500': {
						description: 'Server error',
						content: {
							'application/json': {
								schema: generateSchema(ErrorResponseSchema),
							},
						},
					},
				},
			},
		},
		'/tasks/{id}': {
			get: {
				tags: ['Tasks'],
				summary: 'Get a task by ID',
				description: 'Retrieve a single task by its ID',
				parameters: [
					{
						name: 'id',
						in: 'path',
						required: true,
						schema: {
							type: 'integer',
						},
						description: 'ID of the task to retrieve',
					},
				],
				responses: {
					'200': {
						description: 'Task found',
						content: {
							'application/json': {
								schema: generateSchema(TaskSchema),
							},
						},
					},
					'404': {
						description: 'Task not found',
						content: {
							'application/json': {
								schema: generateSchema(ErrorResponseSchema),
							},
						},
					},
					'500': {
						description: 'Server error',
						content: {
							'application/json': {
								schema: generateSchema(ErrorResponseSchema),
							},
						},
					},
				},
			},
			put: {
				tags: ['Tasks'],
				summary: 'Update a task',
				description: 'Update an existing task by its ID',
				parameters: [
					{
						name: 'id',
						in: 'path',
						required: true,
						schema: {
							type: 'integer',
						},
						description: 'ID of the task to update',
					},
				],
				requestBody: {
					content: {
						'application/json': {
							schema: generateSchema(UpdateTaskSchema),
						},
					},
					required: true,
				},
				responses: {
					'200': {
						description: 'Task updated successfully',
					},
					'404': {
						description: 'Task not found',
						content: {
							'application/json': {
								schema: generateSchema(ErrorResponseSchema),
							},
						},
					},
					'400': {
						description: 'Invalid input',
						content: {
							'application/json': {
								schema: generateSchema(ErrorResponseSchema),
							},
						},
					},
					'500': {
						description: 'Server error',
						content: {
							'application/json': {
								schema: generateSchema(ErrorResponseSchema),
							},
						},
					},
				},
			},
			delete: {
				tags: ['Tasks'],
				summary: 'Delete a task',
				description: 'Delete a task by its ID',
				parameters: [
					{
						name: 'id',
						in: 'path',
						required: true,
						schema: {
							type: 'integer',
						},
						description: 'ID of the task to delete',
					},
				],
				responses: {
					'200': {
						description: 'Task deleted successfully',
					},
					'404': {
						description: 'Task not found',
						content: {
							'application/json': {
								schema: generateSchema(ErrorResponseSchema),
							},
						},
					},
					'500': {
						description: 'Server error',
						content: {
							'application/json': {
								schema: generateSchema(ErrorResponseSchema),
							},
						},
					},
				},
			},
		},
	},
	components: {
		schemas: {
			Task: generateSchema(TaskSchema),
			Tasks: generateSchema(TasksSchema),
			NewTask: generateSchema(NewTaskSchema),
			UpdateTask: generateSchema(UpdateTaskSchema),
			ErrorResponse: generateSchema(ErrorResponseSchema),
		},
	},
};

And then integrate with your Express server:

// server.ts
import express from 'express';
import swaggerUi from 'swagger-ui-express';
import { openApiDocument } from './openapi';

export async function createServer() {
	const app = express();
	app.use(express.json());

	// Serve OpenAPI docs
	app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));

	// Expose OpenAPI spec as JSON
	app.get('/openapi.json', (req, res) => {
		res.json(openApiDocument);
	});

	// Your existing routes...

	return app;
}

Accessessing the Documentation

Once implemented, you can access:

  • Interactive API documentation at: /api-docs
  • Raw OpenAPI JSON specification at: /openapi.json

Last modified on .