In modern web development, building robust and well-documented APIs is crucial for seamless communication between different parts of your application and third-party services. Effective API documentation helps developers understand how to interact with your API, reduces integration issues, and enhances the overall developer experience. Swagger and Zod are powerful tools that simplify API documentation and validation. Swagger provides a framework for automatic generation of interactive API documentation, while Zod is a TypeScript-first schema declaration and validation library that ensures your data structures are well-defined and validated at runtime. Swagger Zod In this post, we'll explore how to use Zod to define your API schemas, integrate these schemas with Swagger to generate detailed API documentation, and generate TypeScript types from your Zod schemas. By the end of this tutorial, you'll have a setup that leverages Zod and Swagger, along with TypeScript's type safety. What You Will Learn Setting Up the Environment: Preparing your development environment with the necessary tools. Creating API Schemas with Zod: Defining and validating data structures using Zod. Integrating Zod with Swagger: Documenting your schemas with Swagger to generate interactive API docs. Putting It All Together: Building a sample API with Zod, Swagger, and TypeScript. Setting Up the Environment: Preparing your development environment with the necessary tools. Setting Up the Environment Creating API Schemas with Zod: Defining and validating data structures using Zod. Creating API Schemas with Zod Creating API Schemas with Zod Integrating Zod with Swagger: Documenting your schemas with Swagger to generate interactive API docs. Integrating Zod with Swagger Integrating Zod with Swagger Putting It All Together: Building a sample API with Zod, Swagger, and TypeScript. Putting It All Together Putting It All Together By the end of this tutorial, you'll understand how to document and validate your APIs using Swagger and Zod, and leverage TypeScript for enhanced development workflow. Let's get started! Setting Up the Environment Before we dive into creating and documenting API schemas, we need to set up our development environment. This involves installing Node.js, setting up a new project, and installing the necessary packages. Prerequisites Make sure you have the following installed on your machine: Node.js: You can download it from nodejs.org. npm (Node Package Manager) or Yarn: These come with Node.js but you can also install Yarn separately if you prefer. Node.js: You can download it from nodejs.org. Node.js nodejs.org npm (Node Package Manager) or Yarn: These come with Node.js but you can also install Yarn separately if you prefer. npm Yarn Initializing a New Project First, create a new directory for your project and navigate into it: mkdir zod-swagger-api cd zod-swagger-api mkdir zod-swagger-api cd zod-swagger-api Initialize a new Node.js project: npm init -y npm init -y This will create a package.json file in your project directory. Installing Necessary Packages Next, install the necessary packages for Swagger, Zod, and TypeScript: npm install express swagger-jsdoc swagger-ui-express zod npm install express swagger-jsdoc swagger-ui-express zod To use TypeScript, we also need to install TypeScript and some type definitions: npm install typescript @types/node @types/express ts-node --save-dev npm install typescript @types/node @types/express ts-node --save-dev Setting Up TypeScript Create a tsconfig.json file to configure TypeScript: { "compilerOptions": { "target": "ES2020", "module": "commonjs", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist" }, "include": ["src/**/*"], "exclude": ["node_modules"] } { "compilerOptions": { "target": "ES2020", "module": "commonjs", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist" }, "include": ["src/**/*"], "exclude": ["node_modules"] } Create a src directory for your TypeScript files: mkdir src mkdir src Creating the Express Server Create a new file src/index.ts and set up a basic Express server: import express from 'express'; const app = express(); const port = 5001; app.use(express.json()); app.get('/', (req, res) => { res.send('Hello World!'); }); app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); import express from 'express'; const app = express(); const port = 5001; app.use(express.json()); app.get('/', (req, res) => { res.send('Hello World!'); }); app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); To run the server, add the following script to your package.json: "scripts": { "start": "ts-node src/index.ts" } "scripts": { "start": "ts-node src/index.ts" } Now, start the server: npm start npm start You should see the message Server running on http://localhost:5001 in your terminal, and you can visit http://localhost:5001 in your browser to see the "Hello World!" message. http://localhost:5001 http://localhost:5001 With your environment set up, you're now ready to start creating API schemas with Zod and documenting them with Swagger. In the next section, we'll dive into defining your API schemas using Zod. Creating API Schemas with Zod Now that our environment is set up, let's start defining API schemas using Zod. Zod is a powerful TypeScript-first schema declaration and validation library that helps ensure your data structures are well-defined and validated at runtime. Writing Your First Schema Let's start by defining a simple schema for a user object. Create a new file src/schemas/user.ts: src/schemas/user.ts import { z } from 'zod'; const UserSchema = z.object({ id: z.string().uuid(), name: z.string().min(1), email: z.string().email(), }); type User = z.infer<typeof UserSchema>; export { UserSchema, User }; import { z } from 'zod'; const UserSchema = z.object({ id: z.string().uuid(), name: z.string().min(1), email: z.string().email(), }); type User = z.infer<typeof UserSchema>; export { UserSchema, User }; In this schema: id is a string that must be a valid UUID. name is a string that must have at least 1 character. email is a string that must be a valid email address. id is a string that must be a valid UUID. name is a string that must have at least 1 character. email is a string that must be a valid email address. The z.infer utility is used to generate a TypeScript type from the schema. z.infer Validating Data with Zod To validate data against the schema, use the safeParse method. Update src/index.ts to include a POST endpoint that validates incoming user data: safeParse src/index.ts import express from 'express'; import { UserSchema } from './schemas/user'; const app = express(); const port = 5001; app.use(express.json()); app.get('/', (req, res) => { res.send('Hello World!'); }); app.post('/users', (req, res) => { const result = UserSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } res.status(200).json(result.data); }); app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); import express from 'express'; import { UserSchema } from './schemas/user'; const app = express(); const port = 5001; app.use(express.json()); app.get('/', (req, res) => { res.send('Hello World!'); }); app.post('/users', (req, res) => { const result = UserSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } res.status(200).json(result.data); }); app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); In this example: We import the UserSchema from our schema file. We create a POST /users endpoint that validates the request body against the UserSchema. If validation fails, we return a 400 status code with the validation errors. If validation succeeds, we return the validated data with a 201 status code. We import the UserSchema from our schema file. We create a POST /users endpoint that validates the request body against the UserSchema. If validation fails, we return a 400 status code with the validation errors. If validation succeeds, we return the validated data with a 201 status code. With this setup, we can ensure that only valid user data is accepted by our API. In the next section, we'll integrate our Zod schemas with Swagger to generate interactive API documentation. Integrating Zod with Swagger To integrate Zod with Swagger, we'll use the swagger-ui-express and @asteasolutions/zod-to-openapi packages. Follow these steps to set up the integration: swagger-ui-express @asteasolutions/zod-to-openapi Step 1: Install Required Packages First, install the necessary packages: npm install swagger-ui-express @asteasolutions/zod-to-openapi npm install --save-dev @types/swagger-ui-express npm install swagger-ui-express @asteasolutions/zod-to-openapi npm install --save-dev @types/swagger-ui-express Set Up Zod Schemas and OpenAPI Integration Update Zod schemas for your API in src/schemas/user.ts: src/schemas/user.ts import { z } from 'zod'; import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; // Extend Zod with OpenAPI capabilities extendZodWithOpenApi(z); export const UserSchema = z.object({ id: z.string().uuid().openapi({ description: 'Unique identifier for the user' }), name: z.string().min(1).openapi({ description: 'Name of the user' }), email: z.string().email().openapi({ description: 'Email address of the user' }), }).openapi({ description: 'User Schema' }); export type User = z.infer<typeof UserSchema>; export const CreateUserSchema = z.object({ name: z.string().openapi({ description: 'Name of the user' }), email: z.string().email().openapi({ description: 'Email address of the user' }), }).openapi({ description: 'Create User Schema' }); export type CreateUser = z.infer<typeof UserSchema>; import { z } from 'zod'; import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; // Extend Zod with OpenAPI capabilities extendZodWithOpenApi(z); export const UserSchema = z.object({ id: z.string().uuid().openapi({ description: 'Unique identifier for the user' }), name: z.string().min(1).openapi({ description: 'Name of the user' }), email: z.string().email().openapi({ description: 'Email address of the user' }), }).openapi({ description: 'User Schema' }); export type User = z.infer<typeof UserSchema>; export const CreateUserSchema = z.object({ name: z.string().openapi({ description: 'Name of the user' }), email: z.string().email().openapi({ description: 'Email address of the user' }), }).openapi({ description: 'Create User Schema' }); export type CreateUser = z.infer<typeof UserSchema>; Integrate schemas with OpenAPI using @asteasolutions/zod-to-openapi. Below is an example setup: @asteasolutions/zod-to-openapi import express from 'express'; import swaggerUi from 'swagger-ui-express'; import { z } from 'zod'; import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; import { CreateUserSchema, UserSchema } from './schemas/user'; // Initialize Express app const app = express(); app.use(express.json()); // Create OpenAPI registry const registry = new OpenAPIRegistry(); // Register schemas and paths registry.registerPath({ method: 'get', path: '/users/{id}', description: 'Get a user by ID', summary: 'Get User', request: { params: z.object({ id: z.string() }), }, responses: { 200: { description: 'User found', content: { 'application/json': { schema: UserSchema, }, }, }, }, tags: ['Users'], }); registry.registerPath({ method: 'post', path: '/users', description: 'Create a new user', summary: 'Create User', request: { body: { content: { 'application/json': { schema: CreateUserSchema, }, }, }, }, responses: { 200: { description: 'User created', content: { 'application/json': { schema: UserSchema, }, }, }, }, tags: ['Users'], }); // Generate OpenAPI specification const generator = new OpenApiGeneratorV3(registry.definitions); const openApiSpec = generator.generateDocument({ openapi: '3.0.0', info: { title: 'User API', version: '1.0.0', }, }); // Setup Swagger UI app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiSpec)); // Example endpoints app.get('/users/:id', (req, res) => { res.json({ id: req.params.id, name: 'John Doe', email: 'john.doe@example.com' }); }); app.post('/users', (req, res) => { const result = UserSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } res.status(200).json(result.data); }); // Start the server const port = 5001; app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`); console.log(`Swagger UI available at http://localhost:${port}/api-docs`); }); import express from 'express'; import swaggerUi from 'swagger-ui-express'; import { z } from 'zod'; import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; import { CreateUserSchema, UserSchema } from './schemas/user'; // Initialize Express app const app = express(); app.use(express.json()); // Create OpenAPI registry const registry = new OpenAPIRegistry(); // Register schemas and paths registry.registerPath({ method: 'get', path: '/users/{id}', description: 'Get a user by ID', summary: 'Get User', request: { params: z.object({ id: z.string() }), }, responses: { 200: { description: 'User found', content: { 'application/json': { schema: UserSchema, }, }, }, }, tags: ['Users'], }); registry.registerPath({ method: 'post', path: '/users', description: 'Create a new user', summary: 'Create User', request: { body: { content: { 'application/json': { schema: CreateUserSchema, }, }, }, }, responses: { 200: { description: 'User created', content: { 'application/json': { schema: UserSchema, }, }, }, }, tags: ['Users'], }); // Generate OpenAPI specification const generator = new OpenApiGeneratorV3(registry.definitions); const openApiSpec = generator.generateDocument({ openapi: '3.0.0', info: { title: 'User API', version: '1.0.0', }, }); // Setup Swagger UI app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiSpec)); // Example endpoints app.get('/users/:id', (req, res) => { res.json({ id: req.params.id, name: 'John Doe', email: 'john.doe@example.com' }); }); app.post('/users', (req, res) => { const result = UserSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } res.status(200).json(result.data); }); // Start the server const port = 5001; app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`); console.log(`Swagger UI available at http://localhost:${port}/api-docs`); }); In this example: We import and extend Zod with OpenAPI capabilities using extendZodWithOpenApi. We define Zod schemas with OpenAPI metadata using .openapi() to provide descriptions. We create an OpenAPIRegistry and register our API paths with corresponding Zod schemas. We generate the OpenAPI specification using OpenApiGeneratorV3. We set up Swagger UI with the generated OpenAPI spec. Finally, we run an Express server with a sample endpoint and Swagger UI documentation. We import and extend Zod with OpenAPI capabilities using extendZodWithOpenApi. We define Zod schemas with OpenAPI metadata using .openapi() to provide descriptions. We create an OpenAPIRegistry and register our API paths with corresponding Zod schemas. We generate the OpenAPI specification using OpenApiGeneratorV3. We set up Swagger UI with the generated OpenAPI spec. Finally, we run an Express server with a sample endpoint and Swagger UI documentation. With this setup, you can visit http://localhost:5001/api-docs to see the interactive API documentation generated by Swagger. http://localhost:5001/api-docs Building a Sample API Now that we have set up our environment, defined and validated our API schemas with Zod, integrated Zod with Swagger for documentation, let's put everything together to build a sample API. Defining More Schemas Let's extend our API with a few more schemas. For example, let's add schemas for Post and Comment. Post Comment Create a new file src/schemas/post.ts: src/schemas/post.ts import { z } from 'zod'; import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; // Extend Zod with OpenAPI capabilities extendZodWithOpenApi(z); export const CreatePostSchema = z.object({ title: z.string().min(1).openapi({ description: 'Post title' }), content: z.string().min(1).openapi({ description: 'Post content' }), authorId: z.string().uuid().openapi({ description: 'Post author unique identifier' }), }); export const PostSchema = CreatePostSchema.extend({ id: z.string().uuid().openapi({ description: 'Unique identifier for the post' }), }); export type CreatePost = z.infer<typeof CreatePostSchema>; export type Post = z.infer<typeof PostSchema>; export const CreateCommentSchema = z.object({ postId: z.string().uuid().openapi({ description: 'Unique identifier for the post where comment belongs' }), content: z.string().min(1).openapi({ description: 'Comment text' }), authorId: z.string().uuid().openapi({ description: 'Unique identifier for the author' }), }); export const CommentSchema = CreateCommentSchema.extend({ id: z.string().uuid().openapi({ description: 'Unique identifier for the comment' }), }); export type CreateComment = z.infer<typeof CreateCommentSchema>; export type Comment = z.infer<typeof CommentSchema>; import { z } from 'zod'; import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; // Extend Zod with OpenAPI capabilities extendZodWithOpenApi(z); export const CreatePostSchema = z.object({ title: z.string().min(1).openapi({ description: 'Post title' }), content: z.string().min(1).openapi({ description: 'Post content' }), authorId: z.string().uuid().openapi({ description: 'Post author unique identifier' }), }); export const PostSchema = CreatePostSchema.extend({ id: z.string().uuid().openapi({ description: 'Unique identifier for the post' }), }); export type CreatePost = z.infer<typeof CreatePostSchema>; export type Post = z.infer<typeof PostSchema>; export const CreateCommentSchema = z.object({ postId: z.string().uuid().openapi({ description: 'Unique identifier for the post where comment belongs' }), content: z.string().min(1).openapi({ description: 'Comment text' }), authorId: z.string().uuid().openapi({ description: 'Unique identifier for the author' }), }); export const CommentSchema = CreateCommentSchema.extend({ id: z.string().uuid().openapi({ description: 'Unique identifier for the comment' }), }); export type CreateComment = z.infer<typeof CreateCommentSchema>; export type Comment = z.infer<typeof CommentSchema>; Updating the API Endpoints Update src/index.ts to add endpoints for creating and retrieving posts and comments: src/index.ts import express from 'express'; import swaggerUi from 'swagger-ui-express'; import { z } from 'zod'; import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; import { CreateUserSchema, UserSchema } from './schemas/user'; import { CommentSchema, CreateCommentSchema, CreatePostSchema, Post, PostSchema, Comment, CreateComment, CreatePost } from './schemas/post'; // Initialize Express app const app = express(); app.use(express.json()); // Create OpenAPI registry const registry = new OpenAPIRegistry(); // Register schemas and paths registry.registerPath({ method: 'get', path: '/users/{id}', description: 'Get a user by ID', summary: 'Get User', request: { params: z.object({ id: z.string() }), }, responses: { 200: { description: 'User found', content: { 'application/json': { schema: UserSchema, }, }, }, }, tags: ['Users'], }); registry.registerPath({ method: 'post', path: '/users', description: 'Create a new user', summary: 'Create User', request: { body: { content: { 'application/json': { schema: CreateUserSchema, }, }, }, }, responses: { 200: { description: 'User created', content: { 'application/json': { schema: UserSchema, }, }, }, }, tags: ['Users'], }); registry.registerPath({ method: 'post', path: '/posts', description: 'Create a new post', summary: 'Create post', request: { body: { content: { 'application/json': { schema: CreatePostSchema, }, }, }, }, responses: { 200: { description: 'Created post', content: { 'application/json': { schema: PostSchema, }, }, }, }, tags: ['Posts'], }); registry.registerPath({ method: 'post', path: '/comments', description: 'Create a new comment', summary: 'Create comment', request: { body: { content: { 'application/json': { schema: CreateCommentSchema, }, }, }, }, responses: { 200: { description: 'Created post', content: { 'application/json': { schema: CommentSchema, }, }, }, }, tags: ['Comments'], }); // Generate OpenAPI specification const generator = new OpenApiGeneratorV3(registry.definitions); const openApiSpec = generator.generateDocument({ openapi: '3.0.0', info: { title: 'User API', version: '1.0.0', }, }); // Setup Swagger UI app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiSpec)); // Example endpoints app.get('/users/:id', (req, res) => { res.json({ id: req.params.id, name: 'John Doe', email: 'john.doe@example.com' }); }); app.post('/users', (req, res) => { const result = CreateUserSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } res.status(200).json({ ...result.data, id: 'a779e093-1fe2-43e7-9fa7-878ed00852ff' }); }); app.post('/posts', (req, res) => { const result = CreatePostSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } const post: CreatePost = result.data; res.status(200).json({ ...post, id: '2a6546c5-5481-4dd0-8975-d3ab888506b7' }); }); app.post('/comments', (req, res) => { const result = CreateCommentSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } const comment: CreateComment = result.data; res.status(200).json({ ...comment, id: 'fd41790f-cfb9-40ad-9dba-51380f90706a' }); }); // Start the server const port = 5001; app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`); console.log(`Swagger UI available at http://localhost:${port}/api-docs`); }); import express from 'express'; import swaggerUi from 'swagger-ui-express'; import { z } from 'zod'; import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; import { CreateUserSchema, UserSchema } from './schemas/user'; import { CommentSchema, CreateCommentSchema, CreatePostSchema, Post, PostSchema, Comment, CreateComment, CreatePost } from './schemas/post'; // Initialize Express app const app = express(); app.use(express.json()); // Create OpenAPI registry const registry = new OpenAPIRegistry(); // Register schemas and paths registry.registerPath({ method: 'get', path: '/users/{id}', description: 'Get a user by ID', summary: 'Get User', request: { params: z.object({ id: z.string() }), }, responses: { 200: { description: 'User found', content: { 'application/json': { schema: UserSchema, }, }, }, }, tags: ['Users'], }); registry.registerPath({ method: 'post', path: '/users', description: 'Create a new user', summary: 'Create User', request: { body: { content: { 'application/json': { schema: CreateUserSchema, }, }, }, }, responses: { 200: { description: 'User created', content: { 'application/json': { schema: UserSchema, }, }, }, }, tags: ['Users'], }); registry.registerPath({ method: 'post', path: '/posts', description: 'Create a new post', summary: 'Create post', request: { body: { content: { 'application/json': { schema: CreatePostSchema, }, }, }, }, responses: { 200: { description: 'Created post', content: { 'application/json': { schema: PostSchema, }, }, }, }, tags: ['Posts'], }); registry.registerPath({ method: 'post', path: '/comments', description: 'Create a new comment', summary: 'Create comment', request: { body: { content: { 'application/json': { schema: CreateCommentSchema, }, }, }, }, responses: { 200: { description: 'Created post', content: { 'application/json': { schema: CommentSchema, }, }, }, }, tags: ['Comments'], }); // Generate OpenAPI specification const generator = new OpenApiGeneratorV3(registry.definitions); const openApiSpec = generator.generateDocument({ openapi: '3.0.0', info: { title: 'User API', version: '1.0.0', }, }); // Setup Swagger UI app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiSpec)); // Example endpoints app.get('/users/:id', (req, res) => { res.json({ id: req.params.id, name: 'John Doe', email: 'john.doe@example.com' }); }); app.post('/users', (req, res) => { const result = CreateUserSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } res.status(200).json({ ...result.data, id: 'a779e093-1fe2-43e7-9fa7-878ed00852ff' }); }); app.post('/posts', (req, res) => { const result = CreatePostSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } const post: CreatePost = result.data; res.status(200).json({ ...post, id: '2a6546c5-5481-4dd0-8975-d3ab888506b7' }); }); app.post('/comments', (req, res) => { const result = CreateCommentSchema.safeParse(req.body); if (!result.success) { return res.status(400).json(result.error); } const comment: CreateComment = result.data; res.status(200).json({ ...comment, id: 'fd41790f-cfb9-40ad-9dba-51380f90706a' }); }); // Start the server const port = 5001; app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`); console.log(`Swagger UI available at http://localhost:${port}/api-docs`); }); In this example, we: Create PostSchema and CommentSchema Use zod extend method to avoid code duplication Import the PostSchema and CommentSchema. Add endpoints for creating posts and comments. Update the OpenAPI document to include these new endpoints. Add API handlers for new endpoints Create PostSchema and CommentSchema Use zod extend method to avoid code duplication extend Import the PostSchema and CommentSchema. Add endpoints for creating posts and comments. Update the OpenAPI document to include these new endpoints. Add API handlers for new endpoints With this setup, you can visit http://localhost:5001/api-docs to see the updated interactive API documentation generated by Swagger. http://localhost:5001/api-docs Congratulations! You've successfully built a sample API using Zod, Swagger, and TypeScript. Your API is now well-documented, type-safe, and ready for further development. Areas for improvement Code from this tutorial is not meant to be production-ready and has number of things to be improved: Move swagger and zod configuration from src/index.ts into separate file(s) zod library has enormous capabilities, we've covered only basics It is better to have a dedicated file for each api handler for easier developing, testing and maintaining Real application would typically have additional abstraction layer (e.g for error handling) Move swagger and zod configuration from src/index.ts into separate file(s) src/index.ts zod library has enormous capabilities, we've covered only basics It is better to have a dedicated file for each api handler for easier developing, testing and maintaining Real application would typically have additional abstraction layer (e.g for error handling) Sources github repo zod zod-to-openapi swagger-jsdoc swagger-ui-express github repo github repo zod zod zod-to-openapi zod-to-openapi swagger-jsdoc swagger-jsdoc swagger-ui-express swagger-ui-express