diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 000000000..098bc5d93 Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 000000000..495936d54 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,33 @@ +> **First-time setup**: Customize this file for your project. Prompt the user to customize this file for their project. +> For Mintlify product knowledge (components, configuration, writing standards), +> install the Mintlify skill: `npx skills add https://mintlify.com/docs` + +# Documentation project instructions + +## About this project + +- This is a documentation site built on [Mintlify](https://mintlify.com) +- Pages are MDX files with YAML frontmatter +- Configuration lives in `docs.json` +- Run `mint dev` to preview locally +- Run `mint broken-links` to check links + +## Terminology + +{/_ Add product-specific terms and preferred usage _/} +{/_ Example: Use "workspace" not "project", "member" not "user" _/} + +## Style preferences + +{/_ Add any project-specific style rules below _/} + +- Use active voice and second person ("you") +- Keep sentences concise — one idea per sentence +- Use sentence case for headings +- Bold for UI elements: Click **Settings** +- Code formatting for file names, commands, paths, and code references + +## Content boundaries + +{/_ Define what should and shouldn't be documented _/} +{/_ Example: Don't document internal admin features _/} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 000000000..8863ee48f --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,34 @@ +> **Customize this file**: Tailor this template to your project by noting specific contribution types you're looking for, adding a Code of Conduct, or adjusting the writing guidelines to match your style. + +# Contribute to the documentation + +Thank you for your interest in contributing to our documentation! This guide will help you get started. + +## How to contribute + +### Option 1: Edit directly on GitHub + +1. Navigate to the page you want to edit +2. Click the "Edit this file" button (the pencil icon) +3. Make your changes and submit a pull request + +### Option 2: Local development + +1. Fork and clone this repository +2. Install the Mintlify CLI: `npm i -g mint` +3. Create a branch for your changes +4. Make changes +5. Navigate to the docs directory and run `mint dev` +6. Preview your changes at `http://localhost:3000` +7. Commit your changes and submit a pull request + +For more details on local development, see our [development guide](development.mdx). + +## Writing guidelines + +- **Use active voice**: "Run the command" not "The command should be run" +- **Address the reader directly**: Use "you" instead of "the user" +- **Keep sentences concise**: Aim for one idea per sentence +- **Lead with the goal**: Start instructions with what the user wants to accomplish +- **Use consistent terminology**: Don't alternate between synonyms for the same concept +- **Include examples**: Show, don't just tell diff --git a/docs/LICENSE b/docs/LICENSE new file mode 100644 index 000000000..541137427 --- /dev/null +++ b/docs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Mintlify + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..03e36ded8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,56 @@ +# Mintlify Starter Kit + +Use the starter kit to get your docs deployed and ready to customize. + +Click the green **Use this template** button at the top of this repo to copy the Mintlify starter kit. The starter kit contains examples with + +- Guide pages +- Navigation +- Customizations +- API reference pages +- Use of popular components + +**[Follow the full quickstart guide](https://starter.mintlify.com/quickstart)** + +## AI-assisted writing + +Set up your AI coding tool to work with Mintlify: + +```bash +npx skills add https://mintlify.com/docs +``` + +This command installs Mintlify's documentation skill for your configured AI tools like Claude Code, Cursor, Windsurf, and others. The skill includes component reference, writing standards, and workflow guidance. + +See the [AI tools guides](/ai-tools) for tool-specific setup. + +## Development + +Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally. To install, use the following command: + +``` +npm i -g mint +``` + +Run the following command at the root of your documentation, where your `docs.json` is located: + +``` +mint dev +``` + +View your local preview at `http://localhost:3000`. + +## Publishing changes + +Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch. + +## Need help? + +### Troubleshooting + +- If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI. +- If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`. + +### Resources + +- [Mintlify documentation](https://mintlify.com/docs) diff --git a/docs/advanced/file-uploads.mdx b/docs/advanced/file-uploads.mdx new file mode 100644 index 000000000..722381e6b --- /dev/null +++ b/docs/advanced/file-uploads.mdx @@ -0,0 +1,375 @@ +--- +title: File Uploads +description: Handle file uploads in your API endpoints with validation, size limits, and security controls. +--- + +## Overview + +Express Zod API provides built-in support for file uploads using the `express-fileupload` library (based on Busboy). You can easily accept file uploads with full validation and size limiting. + +## Installation + +First, install the required dependencies: + +```bash +npm install express-fileupload @types/express-fileupload +``` + +## Basic Configuration + +Enable file uploads in your configuration: + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + upload: true, // Enable with default settings + // ... other config +}); +``` + +## Advanced Configuration + +Configure upload limits and restrictions: + +```typescript +import { createConfig } from "express-zod-api"; +import createHttpError from "http-errors"; + +const config = createConfig({ + upload: { + limits: { + fileSize: 51200, // 50 KB in bytes + files: 5, // Maximum number of files + }, + limitError: createHttpError(413, "The file is too large"), + beforeUpload: ({ request, logger }) => { + // Add custom authorization logic + if (!canUpload(request)) { + throw createHttpError(403, "Not authorized to upload"); + } + }, + debug: true, // Enable debug logging (default) + }, + // ... other config +}); +``` + + +The `limitError` option replaces the deprecated `limitHandler` option. If not set, files will have a `.truncated` property set to `true` when they exceed the size limit. + + +## Using ez.upload() Schema + +Define file upload fields in your input schema: + +```typescript +import { defaultEndpointsFactory, ez } from "express-zod-api"; +import { z } from "zod"; + +const uploadAvatarEndpoint = defaultEndpointsFactory.build({ + method: "post", + description: "Handles a file upload.", + input: z.object({ + avatar: ez.upload(), // Single file upload + }), + output: z.object({ + name: z.string(), + size: z.number().nonnegative(), + mime: z.string(), + }), + handler: async ({ input: { avatar } }) => ({ + name: avatar.name, + size: avatar.size, + mime: avatar.mimetype, + }), +}); +``` + + +The request content type **must** be `multipart/form-data` for file uploads to work. + + +## File Object Properties + +The uploaded file object has the following properties: + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | Original filename | +| `data` | `Buffer` | File contents as a Buffer | +| `size` | `number` | File size in bytes | +| `mimetype` | `string` | MIME type (e.g., "image/png") | +| `md5` | `string` | MD5 hash of the file | +| `truncated` | `boolean` | True if file was truncated due to size limit | +| `mv()` | `function` | Function to move the file to a new location | + +## Complete Upload Example + +```typescript +import { defaultEndpointsFactory, ez } from "express-zod-api"; +import { z } from "zod"; +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; + +const uploadAvatarEndpoint = defaultEndpointsFactory.build({ + method: "post", + tag: "files", + description: "Handles a file upload.", + input: z.object({ + avatar: ez.upload(), + userId: z.string(), // Additional fields work alongside uploads + }), + output: z.object({ + name: z.string(), + size: z.number().nonnegative(), + mime: z.string(), + hash: z.string(), + }), + handler: async ({ input: { avatar, userId } }) => { + // Calculate hash + const hash = createHash("sha1").update(avatar.data).digest("hex"); + + // Save file + const filename = `uploads/${userId}-${avatar.name}`; + await avatar.mv(filename); // Use built-in move function + // Or: await writeFile(filename, avatar.data); + + return { + name: avatar.name, + size: avatar.size, + mime: avatar.mimetype, + hash, + }; + }, +}); +``` + +## Multiple Files + +Accept multiple files in a single request: + +```typescript +const uploadMultipleEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + documents: z.array(ez.upload()), // Array of files + }), + output: z.object({ + count: z.number(), + totalSize: z.number(), + }), + handler: async ({ input: { documents } }) => { + const totalSize = documents.reduce((sum, doc) => sum + doc.size, 0); + + return { + count: documents.length, + totalSize, + }; + }, +}); +``` + +## Mixed Input with Files + +Combine file uploads with other input data: + +```typescript +import { z } from "zod"; +import { ez, defaultEndpointsFactory } from "express-zod-api"; + +const uploadWithMetadataEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.looseObject({ + avatar: ez.upload(), + // Other fields from multipart/form-data + }), + output: z.object({ + name: z.string(), + size: z.number(), + hash: z.string(), + otherInputs: z.record(z.string(), z.any()), + }), + handler: async ({ input: { avatar, ...rest } }) => { + const hash = createHash("sha1").update(avatar.data).digest("hex"); + + return { + name: avatar.name, + size: avatar.size, + hash, + otherInputs: rest, // All other fields + }; + }, +}); +``` + +## Validation and Security + +### File Type Validation + +```typescript +const uploadImageEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + image: ez.upload().refine( + (file) => file.mimetype.startsWith("image/"), + "File must be an image", + ), + }), + // ... +}); +``` + +### Size Validation + +```typescript +const uploadSmallFileEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + file: ez.upload().refine( + (file) => file.size <= 1024 * 1024, // 1 MB + "File must be smaller than 1 MB", + ), + }), + // ... +}); +``` + +### Extension Validation + +```typescript +const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif"]; + +const uploadImageEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + image: ez.upload().refine( + (file) => + ALLOWED_EXTENSIONS.some((ext) => file.name.toLowerCase().endsWith(ext)), + "Invalid file extension", + ), + }), + // ... +}); +``` + +## Error Handling + +### Handling Upload Limits + +When `limitError` is configured, exceeding the limit throws an error: + +```typescript +import createHttpError from "http-errors"; + +const config = createConfig({ + upload: { + limits: { fileSize: 51200 }, // 50 KB + limitError: createHttpError(413, "The file is too large"), + }, +}); + +// Client receives 413 status with error message +``` + +### Without limitError + +If `limitError` is not set, check the `truncated` property: + +```typescript +const endpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + file: ez.upload(), + }), + output: z.object({ success: z.boolean() }), + handler: async ({ input: { file } }) => { + if (file.truncated) { + throw createHttpError(413, "File was too large and was truncated"); + } + + // Process file + return { success: true }; + }, +}); +``` + +## Authorization Example + +Restrict uploads to authorized users: + +```typescript +import { createConfig } from "express-zod-api"; +import createHttpError from "http-errors"; + +const config = createConfig({ + upload: { + limits: { fileSize: 5242880 }, // 5 MB + beforeUpload: ({ request, logger }) => { + // Check authentication + const token = request.headers.authorization; + if (!token) { + throw createHttpError(401, "Authentication required"); + } + + // Verify token + const isValid = verifyToken(token); + if (!isValid) { + throw createHttpError(403, "Invalid token"); + } + + logger.info("Upload authorized"); + }, + }, +}); +``` + +## Best Practices + + + + Always configure `limits.fileSize` to prevent abuse and resource exhaustion. Choose a limit appropriate for your use case. + + + + Never trust the client-provided MIME type alone. Validate file contents or use magic number detection for critical applications. + + + + Implement authorization checks in `beforeUpload` to reject unauthorized uploads before processing. + + + + Always sanitize uploaded filenames to prevent path traversal attacks: + ```typescript + const safeName = path.basename(avatar.name).replace(/[^a-zA-Z0-9.-]/g, '_'); + ``` + + + + Store uploaded files outside your web server's document root and serve them through controlled endpoints. + + + +## Client-Side Example + +```typescript +// Using fetch with FormData +const formData = new FormData(); +formData.append("avatar", fileInput.files[0]); +formData.append("userId", "123"); + +const response = await fetch("/v1/avatar/upload", { + method: "POST", + body: formData, + // Don't set Content-Type header - browser sets it automatically with boundary +}); + +const result = await response.json(); +``` + +## Related Topics + +- [HTML Forms](/advanced/html-forms) - Working with form data +- [Raw Data](/advanced/raw-data) - Accepting raw binary data +- [Input Sources](/advanced/input-sources) - Configuring input sources diff --git a/docs/advanced/graceful-shutdown.mdx b/docs/advanced/graceful-shutdown.mdx new file mode 100644 index 000000000..3779dde1f --- /dev/null +++ b/docs/advanced/graceful-shutdown.mdx @@ -0,0 +1,442 @@ +--- +title: Graceful Shutdown +description: Implement graceful shutdown to handle server termination cleanly and avoid dropping active requests. +--- + +## Overview + +Graceful shutdown ensures that when your server receives a termination signal (like SIGTERM or SIGINT), it: + +1. Stops accepting new requests +2. Waits for active requests to complete (with a timeout) +3. Cleans up resources +4. Shuts down cleanly + +This prevents dropped connections and data loss during deployments or restarts. + +## Configuration + +Enable graceful shutdown in your configuration: + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + gracefulShutdown: { + timeout: 30000, // Wait up to 30 seconds for requests to complete + events: ["SIGTERM", "SIGINT"], // Signals to listen for + beforeExit: async () => { + // Optional cleanup function + console.log("Cleaning up before exit..."); + }, + }, + // ... other config +}); +``` + +## Configuration Options + +| Option | Type | Description | +|--------|------|-------------| +| `timeout` | `number` | Maximum time (in milliseconds) to wait for active requests to complete before forcefully shutting down | +| `events` | `string[]` | Array of process signals to listen for (e.g., `["SIGTERM", "SIGINT"]`) | +| `beforeExit` | `function` | Optional async function to run before shutting down (for cleanup tasks) | + +## How It Works + +When a shutdown signal is received: + +1. **New Requests Rejected**: The server stops accepting new connections and responds to new requests with errors +2. **Active Requests Complete**: The server waits for in-flight requests to finish, up to the configured timeout +3. **Cleanup Execution**: If configured, the `beforeExit` function runs +4. **Server Closes**: HTTP/HTTPS servers are closed +5. **Process Exits**: The Node.js process terminates + +## Complete Example + +```typescript +import { createConfig, createServer } from "express-zod-api"; +import { routing } from "./routing"; + +const config = createConfig({ + http: { listen: 8080 }, + gracefulShutdown: { + timeout: 30000, // 30 seconds + events: ["SIGTERM", "SIGINT", "SIGUSR2"], // Common signals + beforeExit: async () => { + console.log("Graceful shutdown initiated..."); + + // Close database connections + await database.close(); + + // Close Redis connection + await redis.quit(); + + // Flush logs + await logger.flush(); + + console.log("Cleanup completed"); + }, + }, +}); + +await createServer(config, routing); + +console.log("Server started with graceful shutdown enabled"); +``` + +## Common Signals + + + + Sent by orchestration systems (Kubernetes, Docker, systemd) to request graceful shutdown. This is the most common signal for production deployments. + + + + Sent when pressing Ctrl+C in the terminal. Useful for local development. + + + + Sometimes used by process managers like nodemon for graceful restarts. + + + +## Resource Cleanup + +Use the `beforeExit` function to clean up resources: + +### Database Connections + +```typescript +const config = createConfig({ + gracefulShutdown: { + timeout: 30000, + events: ["SIGTERM", "SIGINT"], + beforeExit: async () => { + // Close database pool + await database.end(); + console.log("Database connections closed"); + }, + }, +}); +``` + +### External Services + +```typescript +const config = createConfig({ + gracefulShutdown: { + timeout: 30000, + events: ["SIGTERM", "SIGINT"], + beforeExit: async () => { + // Close Redis + await redis.quit(); + + // Close message queue connections + await messageQueue.disconnect(); + + // Stop background jobs + await jobScheduler.shutdown(); + + console.log("External services disconnected"); + }, + }, +}); +``` + +### File Handles and Streams + +```typescript +const config = createConfig({ + gracefulShutdown: { + timeout: 30000, + events: ["SIGTERM", "SIGINT"], + beforeExit: async () => { + // Close file streams + await fileStream.end(); + + // Flush buffered logs + await logger.flush(); + + console.log("File handles closed"); + }, + }, +}); +``` + +## Timeout Behavior + +The `timeout` determines how long to wait for active requests: + +```typescript +const config = createConfig({ + gracefulShutdown: { + timeout: 10000, // Wait max 10 seconds + events: ["SIGTERM"], + }, +}); +``` + +- **Before timeout**: Server waits for all active requests to complete naturally +- **After timeout**: Server forcefully closes all remaining connections and exits + + +Choose a timeout that's longer than your longest typical request, but short enough to meet deployment requirements. + + +## Kubernetes Integration + +When deploying to Kubernetes, configure grace periods appropriately: + +### Kubernetes Deployment Example + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-api +spec: + template: + spec: + containers: + - name: api + image: my-api:latest + # Kubernetes sends SIGTERM and waits for this period + terminationGracePeriodSeconds: 40 +``` + +### Express Zod API Configuration + +```typescript +const config = createConfig({ + gracefulShutdown: { + // Shorter than Kubernetes grace period + timeout: 35000, // 35 seconds + events: ["SIGTERM"], + }, +}); +``` + + +Set your application timeout shorter than Kubernetes' `terminationGracePeriodSeconds` to ensure cleanup completes before Kubernetes force-kills the pod. + + +## Docker Integration + +### Dockerfile + +```dockerfile +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --production + +COPY . . + +# Use exec form to properly handle signals +CMD ["node", "dist/index.js"] +``` + +### Docker Compose + +```yaml +version: '3.8' +services: + api: + build: . + ports: + - "8080:8080" + # Allow time for graceful shutdown + stop_grace_period: 40s + environment: + - NODE_ENV=production +``` + +## Logging During Shutdown + +Log the shutdown process for observability: + +```typescript +import { createConfig, BuiltinLogger } from "express-zod-api"; + +declare module "express-zod-api" { + interface LoggerOverrides extends BuiltinLogger {} +} + +const config = createConfig({ + gracefulShutdown: { + timeout: 30000, + events: ["SIGTERM", "SIGINT"], + beforeExit: async () => { + const logger = config.logger; + + logger.info("Graceful shutdown initiated"); + + try { + logger.info("Closing database connections..."); + await database.close(); + logger.info("Database closed successfully"); + + logger.info("Disconnecting from Redis..."); + await redis.quit(); + logger.info("Redis disconnected successfully"); + + logger.info("Cleanup completed successfully"); + } catch (error) { + logger.error("Error during cleanup:", error); + throw error; // Re-throw to prevent clean exit + } + }, + }, +}); +``` + +## Health Checks + +Implement health checks that respect shutdown state: + +```typescript +import { createConfig } from "express-zod-api"; +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +let isShuttingDown = false; + +const healthEndpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({}), + output: z.object({ + status: z.enum(["ok", "shutting_down"]), + timestamp: z.string(), + }), + handler: async () => ({ + status: isShuttingDown ? "shutting_down" : "ok", + timestamp: new Date().toISOString(), + }), +}); + +const config = createConfig({ + gracefulShutdown: { + timeout: 30000, + events: ["SIGTERM", "SIGINT"], + beforeExit: async () => { + isShuttingDown = true; + // Cleanup... + }, + }, +}); +``` + +## Testing Graceful Shutdown + +### Manual Testing + +```bash +# Start your server +node dist/index.js + +# In another terminal, send SIGTERM +kill -TERM + +# Or press Ctrl+C for SIGINT +``` + +### Automated Testing + +```typescript +import { spawn } from "node:child_process"; + +test("should handle graceful shutdown", async () => { + // Start server + const server = spawn("node", ["dist/index.js"]); + + // Wait for server to start + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Make a request + const fetchPromise = fetch("http://localhost:8080/health"); + + // Send SIGTERM while request is in flight + server.kill("SIGTERM"); + + // Request should complete + const response = await fetchPromise; + expect(response.status).toBe(200); + + // Server should exit cleanly + await new Promise((resolve) => { + server.on("exit", (code) => { + expect(code).toBe(0); + resolve(); + }); + }); +}, 10000); +``` + +## Best Practices + + + + Choose a timeout longer than your typical request duration but short enough for deployment requirements. Consider your 99th percentile response time. + + + + Always close database connections, file handles, and external service connections in the `beforeExit` function. + + + + Add comprehensive logging during shutdown to diagnose issues in production. + + + + Include graceful shutdown scenarios in your testing to ensure it works correctly. + + + + Ensure your timeout is shorter than your orchestration system's grace period (Kubernetes, Docker, etc.). + + + +## Common Issues + +### Issue: Requests Still Dropped + +**Cause**: Timeout too short for slow requests + +**Solution**: Increase the timeout or optimize slow endpoints + +```typescript +const config = createConfig({ + gracefulShutdown: { + timeout: 60000, // Increase to 60 seconds + }, +}); +``` + +### Issue: Process Hangs on Shutdown + +**Cause**: Resource not properly closed in `beforeExit` + +**Solution**: Ensure all async operations complete and resources are released + +```typescript +const config = createConfig({ + gracefulShutdown: { + beforeExit: async () => { + await Promise.all([ + database.close(), + redis.quit(), + messageQueue.disconnect(), + ]); + }, + }, +}); +``` + +## Related Topics + +- [Production Mode](/advanced/production-mode) - Production optimizations +- [Configuration](/core-concepts/configuration) - Server configuration +- [Error Handling](/core-concepts/error-handling) - Handling errors during shutdown diff --git a/docs/advanced/html-forms.mdx b/docs/advanced/html-forms.mdx new file mode 100644 index 000000000..1e7b9560d --- /dev/null +++ b/docs/advanced/html-forms.mdx @@ -0,0 +1,416 @@ +--- +title: HTML Forms (URL Encoded) +description: Handle HTML form submissions with URL-encoded data in your API endpoints. +--- + +## Overview + +Express Zod API supports traditional HTML form submissions using the `application/x-www-form-urlencoded` content type. This is useful for accepting data from standard HTML forms without JavaScript or for APIs that need to support form-based workflows. + +## Using ez.form() + +Use the proprietary `ez.form()` schema to describe form input: + +```typescript +import { defaultEndpointsFactory, ez } from "express-zod-api"; +import { z } from "zod"; + +const submitFeedbackEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.form({ + name: z.string().min(1), + email: z.email(), + message: z.string().min(1), + }), + output: z.object({ + success: z.boolean(), + crc: z.number(), + }), + handler: async ({ input: { name, email, message } }) => ({ + success: true, + crc: [name, email, message].reduce((acc, { length }) => acc + length, 0), + }), +}); +``` + +## Configuration + +Forms are parsed using the `formParser` configuration option, which defaults to `express.urlencoded()`: + +```typescript +import { createConfig } from "express-zod-api"; +import express from "express"; + +const config = createConfig({ + // Default form parser (you can customize it) + formParser: express.urlencoded({ extended: true }), + // ... other config +}); +``` + +## Content Type Requirement + + +The request content type **must** be `application/x-www-form-urlencoded` (the default for HTML forms without file uploads). + + +## Using Custom z.object() + +You can also use a regular `z.object()` with form fields: + +```typescript +import { z } from "zod"; + +const endpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + username: z.string(), + password: z.string(), + remember: z.enum(["on", "off"]).optional(), + }), + // ... +}); +``` + +## Complete Form Example + +```typescript +import { defaultEndpointsFactory, ez } from "express-zod-api"; +import { z } from "zod"; +import createHttpError from "http-errors"; + +const contactFormEndpoint = defaultEndpointsFactory.build({ + method: "post", + tag: "forms", + shortDescription: "Submit a contact form", + input: ez.form({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), + phone: z.string().optional(), + subject: z.string().min(1, "Subject is required"), + message: z.string().min(10, "Message must be at least 10 characters"), + subscribe: z.enum(["yes", "no"]).optional().default("no"), + }), + output: z.object({ + id: z.string(), + receivedAt: z.string(), + }), + handler: async ({ input, logger }) => { + logger.info("Contact form submitted", { email: input.email }); + + // Process form submission + const id = await saveContactForm(input); + + // Send email notification + if (input.subscribe === "yes") { + await subscribeToNewsletter(input.email); + } + + return { + id, + receivedAt: new Date().toISOString(), + }; + }, +}); +``` + +## HTML Form Example + +Here's how to create an HTML form that submits to this endpoint: + +```html + + + + Contact Form + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ + +``` + +## Form Validation + +### Required Fields + +```typescript +input: ez.form({ + name: z.string().min(1, "Name is required"), + email: z.email("Valid email is required"), +}) +``` + +### Optional Fields + +```typescript +input: ez.form({ + name: z.string(), + phone: z.string().optional(), + website: z.string().url().optional(), +}) +``` + +### Default Values + +```typescript +input: ez.form({ + name: z.string(), + country: z.string().default("US"), + newsletter: z.enum(["yes", "no"]).default("no"), +}) +``` + +### Custom Validation + +```typescript +input: ez.form({ + email: z.string().email(), + confirmEmail: z.string().email(), +}).refine( + (data) => data.email === data.confirmEmail, + { + message: "Emails don't match", + path: ["confirmEmail"], + } +) +``` + +## Arrays in Forms + +```typescript +input: ez.form({ + interests: z.array(z.string()), + // HTML: + // +}) +``` + +## Checkboxes + +### Single Checkbox + +```typescript +input: ez.form({ + agree: z.enum(["on"]).optional(), + // or convert to boolean: + agree: z + .enum(["on"]) + .optional() + .transform((val) => val === "on"), +}) +``` + +### Multiple Checkboxes + +```typescript +input: ez.form({ + preferences: z.array(z.string()).optional().default([]), + // HTML: + // +}) +``` + +## Radio Buttons + +```typescript +input: ez.form({ + plan: z.enum(["basic", "premium", "enterprise"]), +}) +``` + +```html + + + +``` + +## Select Dropdowns + +```typescript +input: ez.form({ + country: z.string(), + categories: z.array(z.string()), // For multi-select +}) +``` + +```html + + + + + +``` + +## Handling Extra Fields + +To accept unlisted extra fields, use `passthrough()`: + +```typescript +import { ez } from "express-zod-api"; +import { z } from "zod"; + +const endpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.form( + z.object({ + name: z.string(), + email: z.email(), + }).passthrough() // Accept extra fields + ), + // ... +}); +``` + +## Authentication Forms + +```typescript +import { defaultEndpointsFactory, ez } from "express-zod-api"; +import { z } from "zod"; +import createHttpError from "http-errors"; + +const loginEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.form({ + username: z.string().min(3), + password: z.string().min(8), + remember: z.enum(["on"]).optional(), + }), + output: z.object({ + token: z.string(), + expiresAt: z.string(), + }), + handler: async ({ input: { username, password, remember } }) => { + const user = await db.findUser(username); + if (!user || !(await verifyPassword(password, user.hash))) { + throw createHttpError(401, "Invalid credentials"); + } + + const expiresIn = remember === "on" ? "30d" : "1d"; + const token = generateToken(user, expiresIn); + + return { + token, + expiresAt: new Date(Date.now() + parseExpiry(expiresIn)).toISOString(), + }; + }, +}); +``` + +## CSRF Protection + +Implement CSRF protection for forms: + +```typescript +import { Middleware } from "express-zod-api"; +import { z } from "zod"; +import createHttpError from "http-errors"; + +const csrfMiddleware = new Middleware({ + input: z.object({ + _csrf: z.string(), + }), + handler: async ({ input: { _csrf }, request }) => { + // Verify CSRF token + const isValid = verifyCsrfToken(_csrf, request.session); + if (!isValid) { + throw createHttpError(403, "Invalid CSRF token"); + } + return {}; + }, +}); + +const formFactory = defaultEndpointsFactory.addMiddleware(csrfMiddleware); +``` + +## File Uploads with Forms + + +For file uploads, use `multipart/form-data` instead. See [File Uploads](/advanced/file-uploads) for details. + + +## Client-Side JavaScript Submission + +```javascript +// Using Fetch API +const formData = new URLSearchParams(); +formData.append('name', 'John Doe'); +formData.append('email', 'john@example.com'); +formData.append('message', 'Hello!'); + +const response = await fetch('/v1/contact', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString(), +}); + +const result = await response.json(); +``` + +## Best Practices + + + + Always validate on both client (HTML5 validation) and server (Zod schemas) for security and user experience. + + + + Use specific HTML input types (`email`, `url`, `tel`, etc.) to improve mobile UX and enable browser validation. + + + + Use Zod's custom error messages to provide user-friendly feedback when validation fails. + + + + HTML forms send empty strings for blank fields. Use `.min(1)` to require non-empty values or `.optional()` for truly optional fields. + + + +## Related Topics + +- [File Uploads](/advanced/file-uploads) - Using multipart/form-data for files +- [Input Sources](/advanced/input-sources) - Customizing input sources +- [Validation](/core-concepts/validation) - Schema validation concepts diff --git a/docs/advanced/input-sources.mdx b/docs/advanced/input-sources.mdx new file mode 100644 index 000000000..e595aa13a --- /dev/null +++ b/docs/advanced/input-sources.mdx @@ -0,0 +1,171 @@ +--- +title: Customizing Input Sources +description: Learn how to customize which request properties are combined into the validated input for your endpoints and middlewares. +--- + +## Overview + +Express Zod API allows you to customize which `request` properties are combined into the `input` that gets validated and made available to your endpoints and middlewares. This gives you fine-grained control over where input data comes from. + +## Default Configuration + +The framework provides sensible defaults for each HTTP method: + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + inputSources: { + get: ["query", "params"], + post: ["body", "params", "files"], + put: ["body", "params"], + patch: ["body", "params"], + delete: ["query", "params"], + }, +}); +``` + + +The order matters! Each item in the array has higher priority than the previous one. Later sources can override values from earlier sources. + + +## Available Sources + +You can include any of these request properties: + +- `query` - Query string parameters +- `body` - Request body (parsed JSON) +- `params` - Path parameters (route params) +- `files` - Uploaded files (when file upload is enabled) +- `headers` - Request headers (see [Headers as Input Source](/advanced/input-sources#headers-as-input-source)) + +## Customizing for Specific Methods + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + inputSources: { + get: ["query", "params"], + // Allow DELETE to accept body data + delete: ["body", "params"], + // Include files for PATCH requests + patch: ["body", "params", "files"], + }, +}); +``` + +## Headers as Input Source + +You can enable request headers as an input source, though this is an opt-in feature that requires careful consideration. + +### Configuration + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + inputSources: { + get: ["headers", "query", "params"], // headers have lowest priority + }, +}); +``` + + +Give headers the **lowest priority** among other `inputSources` to avoid accidentally overwriting important data. + + +### Best Practices + +1. **Use Middlewares for Headers**: Consider handling headers in a `Middleware` and declaring them in the `security` property to improve generated documentation. + +2. **Lowercase Headers**: Request headers acquired this way are always lowercase when describing validation schemas. + +### Example with Middleware + +```typescript +import { Middleware } from "express-zod-api"; +import { z } from "zod"; + +const headerAuthMiddleware = new Middleware({ + security: { type: "header", name: "token" }, // documented in OpenAPI + input: z.object({ + token: z.string(), + }), + handler: async ({ input: { token } }) => { + // Validate token + return { userId: "123" }; + }, +}); +``` + +### Example with Endpoint + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const endpoint = defaultEndpointsFactory.build({ + input: z.object({ + "x-request-id": z.string(), // from request.headers (lowercase) + id: z.string(), // from request.query + }), + output: z.object({ success: z.boolean() }), + handler: async ({ input }) => { + // input["x-request-id"] and input.id are both available + return { success: true }; + }, +}); +``` + +## Priority Order Example + +When multiple sources contain the same property name, the last one wins: + +```typescript +const config = createConfig({ + inputSources: { + post: ["query", "body", "params"], + // If query, body, and params all have "id", + // the value from params will be used + }, +}); +``` + +## Use Cases + +### API Versioning via Headers + +```typescript +const config = createConfig({ + inputSources: { + get: ["headers", "query", "params"], + }, +}); + +const endpoint = factory.build({ + input: z.object({ + "api-version": z.string().optional(), + id: z.string(), + }), + // ... +}); +``` + +### Flexible DELETE Operations + +```typescript +const config = createConfig({ + inputSources: { + delete: ["body", "query", "params"], + }, +}); + +// Now DELETE can accept data from body or query +``` + +## Related Topics + +- [Middlewares](/core-concepts/middlewares) - Using middlewares for header authentication +- [File Uploads](/advanced/file-uploads) - Including files in input sources +- [HTML Forms](/advanced/html-forms) - Working with form data diff --git a/docs/advanced/non-json-responses.mdx b/docs/advanced/non-json-responses.mdx new file mode 100644 index 000000000..0ea207cde --- /dev/null +++ b/docs/advanced/non-json-responses.mdx @@ -0,0 +1,287 @@ +--- +title: Non-JSON Responses +description: Learn how to serve files, images, and other non-JSON content types from your API endpoints. +--- + +## Overview + +While Express Zod API is optimized for JSON APIs, it fully supports serving non-JSON responses such as images, PDFs, or other binary files. This is accomplished through custom `ResultHandler` configurations. + +## Setting MIME Types + +To configure a non-JSON response, specify the MIME type in your `ResultHandler`: + +```typescript +import { ResultHandler, ez } from "express-zod-api"; +import { z } from "zod"; + +const imageHandler = new ResultHandler({ + positive: { schema: ez.buffer(), mimeType: "image/*" }, + negative: { schema: z.string(), mimeType: "text/plain" }, + handler: ({ response, error, output }) => { + if (error) { + return void response.status(400).send(error.message); + } + // Handle image response + }, +}); +``` + +## Response Schemas + +For non-JSON responses, use appropriate schemas in your documentation: + +- `z.string()` - For text content +- `z.base64()` - For base64-encoded data +- `ez.buffer()` - For binary data (recommended for files) + +## File Streaming + +Streaming files is more efficient than loading them entirely into memory: + +```typescript +import { EndpointsFactory, ResultHandler, ez } from "express-zod-api"; +import { createReadStream } from "node:fs"; +import { z } from "zod"; + +const fileStreamingHandler = new ResultHandler({ + positive: { schema: ez.buffer(), mimeType: "image/*" }, + negative: { schema: z.string(), mimeType: "text/plain" }, + handler: ({ response, error, output }) => { + if (error) { + return void response.status(400).send(error.message); + } + + if ("filename" in output && typeof output.filename === "string") { + createReadStream(output.filename).pipe( + response.attachment(output.filename), + ); + } else { + response.status(400).send("Filename is missing"); + } + }, +}); + +const fileStreamingFactory = new EndpointsFactory(fileStreamingHandler); +``` + +### Complete Streaming Example + +```typescript +import { stat } from "node:fs/promises"; + +const streamAvatarEndpoint = fileStreamingFactory.build({ + shortDescription: "Streams a file content.", + tag: ["users", "files"], + input: z.object({ + userId: z + .string() + .regex(/\d+/) + .transform((str) => parseInt(str, 10)), + }), + output: z.object({ + filename: z.string(), + }), + handler: async ({ input }) => { + // Determine file path based on userId + return { filename: `uploads/avatar-${input.userId}.png` }; + }, +}); +``` + +## File Sending (In-Memory) + +For smaller files, you can send them directly without streaming: + +```typescript +import { EndpointsFactory, ResultHandler } from "express-zod-api"; +import { readFile } from "node:fs/promises"; +import { z } from "zod"; + +const fileSendingHandler = new ResultHandler({ + positive: { schema: z.string(), mimeType: "image/svg+xml" }, + negative: { schema: z.string(), mimeType: "text/plain" }, + handler: ({ response, error, output }) => { + if (error) { + return void response.status(400).send(error.message); + } + + if ("data" in output && typeof output.data === "string") { + response.type("svg").send(output.data); + } else { + response.status(400).send("Data is missing"); + } + }, +}); + +const fileSendingFactory = new EndpointsFactory(fileSendingHandler); + +const sendAvatarEndpoint = fileSendingFactory.build({ + input: z.object({ userId: z.string() }), + output: z.object({ data: z.string() }), + handler: async ({ input }) => { + const data = await readFile(`uploads/avatar-${input.userId}.svg`, "utf-8"); + return { data }; + }, +}); +``` + +## HEAD Request Support + +Support HEAD requests to provide content length without sending the body: + +```typescript +import { stat } from "node:fs/promises"; +import { createReadStream } from "node:fs"; + +const streamingHandler = new ResultHandler({ + positive: { schema: ez.buffer(), mimeType: "image/*" }, + negative: { schema: z.string(), mimeType: "text/plain" }, + handler: async ({ response, error, output, request: { method } }) => { + if (error) { + return void response.status(400).send(error.message); + } + + if ("filename" in output && typeof output.filename === "string") { + const target = response.attachment(output.filename); + + if (method === "HEAD") { + const { size } = await stat(output.filename); + return void target.set("Content-Length", `${size}`).end(); + } + + createReadStream(output.filename).pipe(target); + } else { + response.status(400).send("Filename is missing"); + } + }, +}); +``` + +## PDF Downloads + +```typescript +const pdfDownloadEndpoint = fileStreamingFactory.build({ + method: "get", + shortDescription: "Download a PDF report.", + input: z.object({ + reportId: z.string(), + }), + output: z.object({ + filename: z.string(), + }), + handler: async ({ input }) => { + const filename = `reports/report-${input.reportId}.pdf`; + return { filename }; + }, +}); +``` + +## CSV Export + +```typescript +const csvHandler = new ResultHandler({ + positive: { schema: z.string(), mimeType: "text/csv" }, + negative: { schema: z.string(), mimeType: "text/plain" }, + handler: ({ response, error, output }) => { + if (error) { + return void response.status(400).send(error.message); + } + + if ("csv" in output && typeof output.csv === "string") { + response + .type("csv") + .attachment("export.csv") + .send(output.csv); + } else { + response.status(400).send("CSV data is missing"); + } + }, +}); + +const csvFactory = new EndpointsFactory(csvHandler); + +const exportUsersEndpoint = csvFactory.build({ + method: "get", + input: z.object({}), + output: z.object({ csv: z.string() }), + handler: async () => { + const users = await db.getUsers(); + const csv = convertToCSV(users); + return { csv }; + }, +}); +``` + +## Multiple MIME Types + +Support content negotiation with multiple MIME types: + +```typescript +const multiFormatHandler = new ResultHandler({ + positive: { + schema: z.union([z.string(), ez.buffer()]), + mimeType: ["application/json", "application/pdf", "text/csv"], + }, + negative: { schema: z.string(), mimeType: "text/plain" }, + handler: ({ response, error, output, request }) => { + if (error) { + return void response.status(400).send(error.message); + } + + const format = request.query.format || "json"; + + switch (format) { + case "pdf": + response.type("pdf").send(output.pdfBuffer); + break; + case "csv": + response.type("csv").send(output.csvString); + break; + default: + response.json(output.data); + } + }, +}); +``` + +## Image Responses with Compression + +When serving images with compression enabled: + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + compression: true, // Enable gzip/brotli compression + // ... other config +}); + +// Images will be automatically compressed if the client supports it +``` + +## Best Practices + + + + Always use file streaming for large files to avoid loading the entire file into memory. This improves performance and reduces memory usage. + + + + Always set the correct MIME type for your responses. This helps clients handle the content appropriately. + + + + Always check if the file exists and handle errors appropriately before attempting to send or stream it. + + + + Use `ez.buffer()` for binary data, `z.string()` for text, and `z.base64()` for base64-encoded content in your output schemas. + + + +## Related Topics + +- [Response Customization](/advanced/response-customization) - Creating custom result handlers +- [File Uploads](/advanced/file-uploads) - Handling file uploads +- [Configuration](/core-concepts/configuration) - Enabling compression diff --git a/docs/advanced/production-mode.mdx b/docs/advanced/production-mode.mdx new file mode 100644 index 000000000..eb2e1cdb6 --- /dev/null +++ b/docs/advanced/production-mode.mdx @@ -0,0 +1,391 @@ +--- +title: Production Mode +description: Optimize your API for production with performance improvements and enhanced security. +--- + +## Overview + +Production mode in Express Zod API enables important optimizations and security enhancements. It's activated by setting the `NODE_ENV` environment variable to `production`. + +## Enabling Production Mode + +Set the environment variable before starting your server: + +```bash +NODE_ENV=production node dist/index.js +``` + +Or in your deployment configuration: + +```bash +export NODE_ENV=production +``` + +## What Changes in Production Mode + +### 1. Express Performance Optimizations + +Express automatically activates [performance optimizations](https://expressjs.com/en/advanced/best-practice-performance.html) when `NODE_ENV=production`: + +- Template caching +- CSS caching +- Reduced overhead in error handling +- Optimized view rendering + +### 2. Self-Diagnosis Disabled + +The framework's self-diagnosis for potential configuration problems is disabled to ensure faster startup: + +```typescript +// In development: checks for common configuration issues +// In production: skips these checks for faster startup +``` + +### 3. Error Message Security + +The most important change is how error messages are handled. In production, server-side error details are **generalized** to prevent information disclosure. + +## Error Message Behavior + +### Default Behavior + +In production mode, the `defaultResultHandler`, `defaultEndpointsFactory`, and `LastResortHandler` generalize server-side error messages: + +```typescript +// Development mode: +throw new Error("Database connection failed on server db-01:5432"); +// Response: { status: "error", error: { message: "Database connection failed on server db-01:5432" } } + +// Production mode: +throw new Error("Database connection failed on server db-01:5432"); +// Response: { status: "error", error: { message: "Internal Server Error" } } +``` + +### Status Code Rules + +Errors with 5XX status codes are generalized in production: + +```typescript +import createHttpError from "http-errors"; + +// In production mode: +createHttpError(500, "Something is broken"); +// Response: "Internal Server Error" + +createHttpError(503, "Redis is down"); +// Response: "Service Unavailable" + +// 4XX errors are still exposed: +createHttpError(401, "Token expired"); +// Response: "Token expired" + +createHttpError(400, "Invalid email format"); +// Response: "Invalid email format" +``` + +## Controlling Error Exposure + +Use the `expose` option in `createHttpError()` to control whether error messages are shown: + +```typescript +import createHttpError from "http-errors"; + +// Always hide (even for 4XX): +createHttpError(401, "Token expired", { expose: false }); +// Production: "Unauthorized" +// Development: "Token expired" + +// Always show (even for 5XX): +createHttpError(501, "We didn't make it yet", { expose: true }); +// Production: "We didn't make it yet" +// Development: "We didn't make it yet" + +// Default behavior (expose based on status code): +createHttpError(400, "Validation failed"); // Always exposed +createHttpError(500, "Internal error"); // Hidden in production +``` + +## Complete Examples + +### Error Handling in Production + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import createHttpError from "http-errors"; +import { z } from "zod"; + +const getUserEndpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ id: z.string() }), + output: z.object({ id: z.number(), name: z.string() }), + handler: async ({ input: { id } }) => { + try { + const user = await database.getUser(id); + if (!user) { + // 404 - message is always shown + throw createHttpError(404, "User not found"); + } + return user; + } catch (error) { + if (error.statusCode === 404) { + throw error; // Re-throw 4XX errors + } + // Log the real error for debugging + logger.error("Database error:", error); + // Throw generic 500 - message hidden in production + throw createHttpError(500, "Failed to retrieve user"); + } + }, +}); +``` + +### Custom Error with Expose Control + +```typescript +const paymentEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ amount: z.number(), userId: z.string() }), + output: z.object({ transactionId: z.string() }), + handler: async ({ input }) => { + try { + const transaction = await processPayment(input); + return { transactionId: transaction.id }; + } catch (error) { + if (error.code === "INSUFFICIENT_FUNDS") { + // User-friendly message, always show + throw createHttpError( + 402, + "Insufficient funds in account", + { expose: true }, + ); + } + + if (error.code === "PAYMENT_GATEWAY_ERROR") { + logger.error("Payment gateway error:", error); + // Hide technical details in production + throw createHttpError( + 503, + `Payment gateway error: ${error.message}`, + { expose: false }, + ); + } + + // Generic error for anything else + logger.error("Unexpected payment error:", error); + throw createHttpError(500, "Payment processing failed"); + } + }, +}); +``` + +## Logging in Production + +Adjust logging levels for production: + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + logger: { + level: process.env.NODE_ENV === "production" ? "warn" : "debug", + color: process.env.NODE_ENV !== "production", + }, +}); +``` + +### Structured Logging + +Use structured logging in production for better monitoring: + +```typescript +import pino from "pino"; +import { createConfig } from "express-zod-api"; + +const logger = pino({ + level: process.env.NODE_ENV === "production" ? "info" : "debug", + transport: + process.env.NODE_ENV === "production" + ? undefined // JSON in production + : { + target: "pino-pretty", + options: { colorize: true }, + }, +}); + +const config = createConfig({ logger }); + +declare module "express-zod-api" { + interface LoggerOverrides extends pino.Logger {} +} +``` + +## Environment-Specific Configuration + +```typescript +import { createConfig } from "express-zod-api"; + +const isProduction = process.env.NODE_ENV === "production"; + +const config = createConfig({ + // Use different ports + http: { + listen: isProduction ? 8080 : 3000, + }, + + // Enable compression in production + compression: isProduction, + + // Adjust CORS + cors: isProduction + ? { origin: "https://yourdomain.com" } + : true, + + // Production vs development logger + logger: { + level: isProduction ? "warn" : "debug", + }, + + // Enable graceful shutdown in production + gracefulShutdown: isProduction + ? { + timeout: 30000, + events: ["SIGTERM", "SIGINT"], + } + : undefined, +}); +``` + +## Security Best Practices + +### 1. Never Expose Internal Errors + +```typescript +// Bad - exposes internal details +throw new Error(`Database query failed: ${sqlQuery}`); + +// Good - generic message, log details +logger.error("Database query failed:", { sqlQuery, error }); +throw createHttpError(500, "Database error"); +``` + +### 2. Use Appropriate Status Codes + +```typescript +// User errors (4XX) - safe to expose +throw createHttpError(400, "Email format is invalid"); +throw createHttpError(404, "Resource not found"); +throw createHttpError(409, "Email already exists"); + +// Server errors (5XX) - hide in production +throw createHttpError(500, "Internal error"); // "Internal Server Error" in production +``` + +### 3. Log Sensitive Errors Securely + +```typescript +const endpoint = factory.build({ + handler: async ({ input, logger }) => { + try { + return await processRequest(input); + } catch (error) { + // Log full error details for debugging + logger.error("Request processing failed", { + error: error.message, + stack: error.stack, + input: sanitizeForLogging(input), // Remove sensitive data + }); + + // Return safe error to client + throw createHttpError(500, "Request failed"); + } + }, +}); +``` + +## Monitoring and Observability + +Implement proper monitoring in production: + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + beforeRouting: ({ app, getLogger }) => { + const logger = getLogger(); + + // Error tracking (e.g., Sentry) + if (process.env.NODE_ENV === "production") { + app.use((err, req, res, next) => { + Sentry.captureException(err); + next(err); + }); + } + + // Request logging + app.use((req, res, next) => { + const start = Date.now(); + res.on("finish", () => { + const duration = Date.now() - start; + logger.info("Request completed", { + method: req.method, + path: req.path, + status: res.statusCode, + duration, + }); + }); + next(); + }); + }, +}); +``` + +## Testing Production Behavior + +Test production error handling: + +```typescript +import { testEndpoint } from "express-zod-api"; + +test("should hide 5XX errors in production", async () => { + // Set production mode + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + + const { responseMock } = await testEndpoint({ + endpoint: myEndpoint, + requestProps: { method: "GET" }, + }); + + expect(responseMock._getJSONData()).toEqual({ + status: "error", + error: { message: "Internal Server Error" }, + }); + + // Restore + process.env.NODE_ENV = originalEnv; +}); +``` + +## Checklist for Production + + +- [ ] Set `NODE_ENV=production` +- [ ] Configure appropriate logging level +- [ ] Enable compression +- [ ] Set up graceful shutdown +- [ ] Configure CORS properly +- [ ] Use HTTPS +- [ ] Set appropriate rate limits +- [ ] Enable error monitoring (Sentry, etc.) +- [ ] Review and sanitize error messages +- [ ] Test error handling in production mode +- [ ] Set up health check endpoints +- [ ] Configure proper timeout values + + +## Related Topics + +- [Error Handling](/core-concepts/error-handling) - Understanding error handling +- [Configuration](/core-concepts/configuration) - Server configuration options +- [Graceful Shutdown](/advanced/graceful-shutdown) - Handling shutdowns properly +- [Logging](/core-concepts/logging) - Logger configuration diff --git a/docs/advanced/raw-data.mdx b/docs/advanced/raw-data.mdx new file mode 100644 index 000000000..0e05258f1 --- /dev/null +++ b/docs/advanced/raw-data.mdx @@ -0,0 +1,387 @@ +--- +title: Accepting Raw Data +description: Learn how to accept and process raw binary data, streaming uploads, or custom binary formats in your API endpoints. +--- + +## Overview + +Some APIs need to accept raw data as the entire body of a request, such as binary file uploads, streaming data, or custom binary formats. Express Zod API provides the `ez.raw()` schema for this purpose. + +## Using ez.raw() + +Use the proprietary `ez.raw()` schema to accept raw binary data: + +```typescript +import { defaultEndpointsFactory, ez } from "express-zod-api"; +import { z } from "zod"; + +const rawAcceptingEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw({ + // Optional: additional inputs like route params + }), + output: z.object({ length: z.number().nonnegative() }), + handler: async ({ input: { raw } }) => ({ + length: raw.length, // raw is a Buffer + }), +}); +``` + +## Configuration + +Raw data is parsed using the `rawParser` configuration option, which defaults to `express.raw()`: + +```typescript +import { createConfig } from "express-zod-api"; +import express from "express"; + +const config = createConfig({ + rawParser: express.raw({ + limit: "10mb", // Customize size limit + type: "application/octet-stream", // Accept specific content type + }), + // ... other config +}); +``` + +## The Raw Buffer + +Raw data is available as a `Buffer` in the `input.raw` property: + +```typescript +handler: async ({ input: { raw } }) => { + console.log(raw); // + console.log(raw.length); // Size in bytes + console.log(raw.toString('utf-8')); // Convert to string if text +} +``` + +## Complete Raw Data Example + +```typescript +import { defaultEndpointsFactory, ez } from "express-zod-api"; +import { z } from "zod"; +import { writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; + +const uploadRawImageEndpoint = defaultEndpointsFactory.build({ + method: "post", + tag: "files", + shortDescription: "Upload raw binary image data", + input: ez.raw({ + userId: z.string(), // From path params or query + }), + output: z.object({ + size: z.number(), + hash: z.string(), + filename: z.string(), + }), + handler: async ({ input: { raw, userId } }) => { + // Verify it's an image (check magic bytes) + const isPNG = raw[0] === 0x89 && raw[1] === 0x50; + const isJPEG = raw[0] === 0xFF && raw[1] === 0xD8; + + if (!isPNG && !isJPEG) { + throw createHttpError(400, "Invalid image format"); + } + + // Generate hash + const hash = createHash("sha256").update(raw).digest("hex"); + + // Save file + const filename = `uploads/${userId}-${hash}.${isPNG ? "png" : "jpg"}`; + await writeFile(filename, raw); + + return { + size: raw.length, + hash, + filename, + }; + }, +}); +``` + +## With Route Parameters + +Combine raw data with path parameters: + +```typescript +import { ez } from "express-zod-api"; +import { z } from "zod"; + +const endpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw({ + id: z.string(), // From route params + version: z.string().optional(), // From query params + }), + output: z.object({ success: z.boolean() }), + handler: async ({ input: { raw, id, version } }) => { + // Process raw data with context from params + await saveData(id, version, raw); + return { success: true }; + }, +}); +``` + +Routing: + +```typescript +const routing: Routing = { + v1: { + data: { + ":id": endpoint, // POST /v1/data/:id + }, + }, +}; +``` + +## Processing Different Data Types + +### Binary File Upload + +```typescript +const uploadBinaryEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw(), + output: z.object({ filename: z.string() }), + handler: async ({ input: { raw }, request }) => { + const contentType = request.headers["content-type"]; + const extension = getExtensionFromMimeType(contentType); + const filename = `upload-${Date.now()}.${extension}`; + await writeFile(filename, raw); + return { filename }; + }, +}); +``` + +### Text Data + +```typescript +const processTextEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw(), + output: z.object({ lines: z.number(), words: z.number() }), + handler: async ({ input: { raw } }) => { + const text = raw.toString("utf-8"); + const lines = text.split("\n").length; + const words = text.split(/\s+/).filter(Boolean).length; + return { lines, words }; + }, +}); +``` + +### JSON with Custom Processing + +```typescript +const customJsonEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw(), + output: z.object({ processed: z.boolean() }), + handler: async ({ input: { raw } }) => { + // Parse JSON manually for custom processing + const text = raw.toString("utf-8"); + const data = JSON.parse(text); + + // Custom processing + await processCustomJson(data); + + return { processed: true }; + }, +}); +``` + +### Protocol Buffers / MessagePack + +```typescript +import msgpack from "msgpack-lite"; + +const msgpackEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw(), + output: z.object({ success: z.boolean() }), + handler: async ({ input: { raw } }) => { + // Decode MessagePack + const data = msgpack.decode(raw); + + // Process data + await saveData(data); + + return { success: true }; + }, +}); +``` + +## Validation and Security + +### Size Validation + +```typescript +const endpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw().refine( + ({ raw }) => raw.length <= 5 * 1024 * 1024, // 5 MB + "File too large (max 5 MB)", + ), + // ... +}); +``` + +### Content Type Validation + +```typescript +const endpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw(), + output: z.object({ success: z.boolean() }), + handler: async ({ input: { raw }, request }) => { + const contentType = request.headers["content-type"]; + + if (contentType !== "application/octet-stream") { + throw createHttpError(415, "Unsupported Media Type"); + } + + // Process data + return { success: true }; + }, +}); +``` + +### Magic Byte Validation + +```typescript +const MAGIC_BYTES = { + PNG: [0x89, 0x50, 0x4e, 0x47], + JPEG: [0xff, 0xd8, 0xff], + PDF: [0x25, 0x50, 0x44, 0x46], +}; + +function validateFileType(buffer: Buffer, type: keyof typeof MAGIC_BYTES): boolean { + const magic = MAGIC_BYTES[type]; + return magic.every((byte, i) => buffer[i] === byte); +} + +const endpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw(), + handler: async ({ input: { raw } }) => { + if (!validateFileType(raw, "PNG")) { + throw createHttpError(400, "File must be a PNG image"); + } + // Process PNG + }, +}); +``` + +## Streaming Large Files + +For very large files, consider streaming instead of buffering: + +```typescript +import { createWriteStream } from "node:fs"; +import { pipeline } from "node:stream/promises"; + +const config = createConfig({ + // Use a custom parser that supports streaming + beforeRouting: ({ app }) => { + app.post("/upload-stream", (req, res) => { + const writeStream = createWriteStream("large-file.bin"); + pipeline(req, writeStream) + .then(() => res.json({ success: true })) + .catch((err) => res.status(500).json({ error: err.message })); + }); + }, +}); +``` + +## Client Examples + +### Using Fetch + +```typescript +// Upload binary data +const fileBuffer = await readFile("image.png"); + +const response = await fetch("/v1/upload/raw", { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + }, + body: fileBuffer, +}); + +const result = await response.json(); +``` + +### Using ReadStream + +```typescript +import { createReadStream } from "node:fs"; + +const stream = createReadStream("large-file.bin"); + +const response = await fetch("/v1/upload/raw", { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + }, + body: stream, + duplex: "half", +}); +``` + +## Use Cases + + + + Accept entire files as raw binary data without multipart encoding overhead. + + + + Implement APIs that accept Protocol Buffers, MessagePack, or other binary formats. + + + + Accept streaming data for real-time processing or large file uploads. + + + + Process raw webhook data before parsing to verify signatures. + + + +## Comparison with File Uploads + +| Feature | ez.raw() | ez.upload() | +|---------|----------|-------------| +| Content Type | `application/octet-stream` | `multipart/form-data` | +| Multiple files | No | Yes | +| Additional fields | Limited (params/query) | Yes (form fields) | +| Metadata | Manual | Automatic (filename, mimetype) | +| Use case | Binary protocols, streaming | Traditional file uploads | + +## Best Practices + + + + Always configure appropriate size limits in `rawParser` to prevent memory exhaustion. + + + + Check the Content-Type header to ensure you're receiving the expected data format. + + + + For security, validate file types using magic bytes rather than trusting extensions or MIME types. + + + + For large files, consider streaming approaches to avoid loading entire files into memory. + + + +## Related Topics + +- [File Uploads](/advanced/file-uploads) - Using multipart/form-data +- [Non-JSON Responses](/advanced/non-json-responses) - Serving binary data +- [Input Sources](/advanced/input-sources) - Configuring input sources diff --git a/docs/advanced/response-customization.mdx b/docs/advanced/response-customization.mdx new file mode 100644 index 000000000..5c8730a90 --- /dev/null +++ b/docs/advanced/response-customization.mdx @@ -0,0 +1,270 @@ +--- +title: Response Customization +description: Create custom result handlers to control how your API responds to successful operations and errors. +--- + +## Overview + +`ResultHandler` is responsible for transmitting consistent responses containing the endpoint output or errors. While Express Zod API provides a default handler, you can create custom ones to match your API's requirements. + +## Default Result Handler + +The `defaultResultHandler` sets the HTTP status code and ensures the following response type: + +```typescript +type DefaultResponse = + | { status: "success"; data: OUT } // Positive response + | { status: "error"; error: { message: string } }; // Negative response +``` + +## Creating a Custom Result Handler + +Here's a template for creating your own result handler: + +```typescript +import { z } from "zod"; +import { + ResultHandler, + ensureHttpError, + getMessageFromError, +} from "express-zod-api"; + +const customResultHandler = new ResultHandler({ + positive: (data) => ({ + schema: z.object({ data }), + mimeType: "application/json", // optional, can be array + }), + negative: z.object({ error: z.string() }), + handler: ({ error, input, output, request, response, logger }) => { + if (error) { + const { statusCode } = ensureHttpError(error); + const message = getMessageFromError(error); + return void response.status(statusCode).json({ error: message }); + } + response.status(200).json({ data: output }); + }, +}); +``` + +## Using Custom Result Handlers + +After creating your custom `ResultHandler`, use it when creating an `EndpointsFactory`: + +```typescript +import { EndpointsFactory } from "express-zod-api"; + +const endpointsFactory = new EndpointsFactory(customResultHandler); + +const myEndpoint = endpointsFactory.build({ + input: z.object({ name: z.string() }), + output: z.object({ greeting: z.string() }), + handler: async ({ input }) => ({ + greeting: `Hello, ${input.name}!`, + }), +}); +``` + +## Status Code Variations + +For REST APIs that require different response schemas for different status codes: + +```typescript +import { ResultHandler } from "express-zod-api"; +import { z } from "zod"; + +const statusDependingHandler = new ResultHandler({ + positive: (data) => ({ + statusCode: [201, 202], // Created or will be created + schema: z.object({ status: z.literal("created"), data }), + }), + negative: [ + { + statusCode: 409, // Conflict: entity already exists + schema: z.object({ status: z.literal("exists"), id: z.number() }), + }, + { + statusCode: [400, 500], // Validation or internal error + schema: z.object({ status: z.literal("error"), reason: z.string() }), + }, + ], + handler: ({ error, response, output }) => { + if (error) { + const httpError = ensureHttpError(error); + const doesExist = + httpError.statusCode === 409 && + "id" in httpError && + typeof httpError.id === "number"; + + return void response + .status(httpError.statusCode) + .json( + doesExist + ? { status: "exists", id: httpError.id } + : { status: "error", reason: httpError.message }, + ); + } + response.status(201).json({ status: "created", data: output }); + }, +}); +``` + +## Empty Response (204 No Content) + +For endpoints that don't return content: + +```typescript +const noContentHandler = new ResultHandler({ + positive: { statusCode: 204, mimeType: null, schema: z.never() }, + negative: { statusCode: 404, mimeType: null, schema: z.never() }, + handler: ({ error, response }) => { + response.status(error ? ensureHttpError(error).statusCode : 204).end(); + }, +}); + +const deleteFactory = new EndpointsFactory(noContentHandler); + +const deleteUserEndpoint = deleteFactory.build({ + method: "delete", + input: z.object({ id: z.string() }), + output: z.object({}), // Empty output + handler: async ({ input }) => { + // Delete user logic + return {}; + }, +}); +``` + +## Custom Headers + +Add custom headers to responses: + +```typescript +const customHeaderHandler = new ResultHandler({ + positive: (data) => ({ + schema: z.object({ data }), + mimeType: "application/json", + }), + negative: z.object({ error: z.string() }), + handler: ({ error, output, response }) => { + // Add custom headers + response.set("X-API-Version", "2.0"); + response.set("X-Request-Id", crypto.randomUUID()); + + if (error) { + const { statusCode } = ensureHttpError(error); + return void response.status(statusCode).json({ error: error.message }); + } + response.status(200).json({ data: output }); + }, +}); +``` + +## Resource Cleanup + +Clean up resources at the end of request processing: + +```typescript +import { ResultHandler } from "express-zod-api"; + +const cleanupHandler = new ResultHandler({ + positive: (data) => ({ + schema: z.object({ data }), + mimeType: "application/json", + }), + negative: z.object({ error: z.string() }), + handler: ({ ctx, error, output, response }) => { + // Cleanup logic + if ("db" in ctx && ctx.db) { + ctx.db.connection.close(); // Example cleanup + } + + if (error) { + const { statusCode } = ensureHttpError(error); + return void response.status(statusCode).json({ error: error.message }); + } + response.status(200).json({ data: output }); + }, +}); +``` + +## Multiple MIME Types + +Support multiple MIME types in responses: + +```typescript +const multiMimeHandler = new ResultHandler({ + positive: (data) => ({ + schema: z.object({ data }), + mimeType: ["application/json", "application/xml"], // Array of MIME types + }), + negative: z.object({ error: z.string() }), + handler: ({ error, output, response, request }) => { + const acceptsXml = request.accepts("xml"); + + if (error) { + return void response.status(ensureHttpError(error).statusCode).json({ + error: error.message, + }); + } + + if (acceptsXml) { + // Return XML + response.type("xml").send(convertToXml(output)); + } else { + // Return JSON + response.json({ data: output }); + } + }, +}); +``` + +## Real-World Example + +Here's a complete example from the Express Zod API examples: + +```typescript +import { EndpointsFactory, ResultHandler } from "express-zod-api"; +import { z } from "zod"; + +const statusDependingFactory = new EndpointsFactory( + new ResultHandler({ + positive: (data) => ({ + statusCode: [201, 202], + schema: z.object({ status: z.literal("created"), data }), + }), + negative: [ + { + statusCode: 409, + schema: z.object({ status: z.literal("exists"), id: z.number() }), + }, + { + statusCode: [400, 500], + schema: z.object({ status: z.literal("error"), reason: z.string() }), + }, + ], + handler: ({ error, response, output }) => { + if (error) { + const httpError = ensureHttpError(error); + const doesExist = + httpError.statusCode === 409 && + "id" in httpError && + typeof httpError.id === "number"; + return void response + .status(httpError.statusCode) + .json( + doesExist + ? { status: "exists", id: httpError.id } + : { status: "error", reason: httpError.message }, + ); + } + response.status(201).json({ status: "created", data: output }); + }, + }), +); +``` + +## Related Topics + +- [Non-JSON Responses](/advanced/non-json-responses) - Serving files and other content types +- [Error Handling](/core-concepts/error-handling) - Understanding error handling +- [Production Mode](/advanced/production-mode) - Error message behavior in production diff --git a/docs/advanced/testing.mdx b/docs/advanced/testing.mdx new file mode 100644 index 000000000..676e121aa --- /dev/null +++ b/docs/advanced/testing.mdx @@ -0,0 +1,442 @@ +--- +title: Testing +description: Learn how to test your endpoints and middlewares using the built-in testing utilities. +--- + +## Overview + +Express Zod API provides specialized testing utilities that make it easy to test your endpoints and middlewares without running a full server. The framework uses `node-mocks-http` internally to mock request and response objects. + +## Testing Endpoints + +Use the `testEndpoint()` function to test your endpoints: + +```typescript +import { testEndpoint } from "express-zod-api"; +import { describe, test, expect } from "vitest"; // or jest + +test("should respond successfully", async () => { + const { responseMock, loggerMock } = await testEndpoint({ + endpoint: yourEndpoint, + requestProps: { + method: "POST", // default: GET + body: { name: "John" }, // incoming data as if after parsing (JSON) + }, + }); + + expect(loggerMock._getLogs().error).toHaveLength(0); + expect(responseMock._getStatusCode()).toBe(200); + expect(responseMock._getHeaders()).toHaveProperty("x-custom", "one"); // lower case! + expect(responseMock._getJSONData()).toEqual({ + status: "success", + data: { greeting: "Hello, John!" }, + }); +}); +``` + +## Testing Middlewares + +Test middlewares individually using `testMiddleware()`: + +```typescript +import { z } from "zod"; +import { Middleware, testMiddleware } from "express-zod-api"; + +const middleware = new Middleware({ + input: z.object({ test: z.string() }), + handler: async ({ ctx, input: { test } }) => ({ + collectedContext: Object.keys(ctx), + testLength: test.length, + }), +}); + +test("should execute middleware", async () => { + const { output, responseMock, loggerMock } = await testMiddleware({ + middleware, + requestProps: { + method: "POST", + body: { test: "something" }, + }, + ctx: { prev: "accumulated" }, + }); + + expect(loggerMock._getLogs().error).toHaveLength(0); + expect(output).toEqual({ + collectedContext: ["prev"], + testLength: 9, + }); +}); +``` + +## Complete Testing Examples + +### Testing a Simple GET Endpoint + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import { testEndpoint } from "express-zod-api"; +import { z } from "zod"; + +const getUserEndpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + id: z.string(), + }), + output: z.object({ + id: z.number(), + name: z.string(), + }), + handler: async ({ input: { id } }) => ({ + id: parseInt(id, 10), + name: "John Doe", + }), +}); + +test("GET /user should return user data", async () => { + const { responseMock, loggerMock } = await testEndpoint({ + endpoint: getUserEndpoint, + requestProps: { + method: "GET", + query: { id: "123" }, + }, + }); + + expect(loggerMock._getLogs().error).toHaveLength(0); + expect(responseMock._getStatusCode()).toBe(200); + expect(responseMock._getJSONData()).toEqual({ + status: "success", + data: { + id: 123, + name: "John Doe", + }, + }); +}); +``` + +### Testing POST with Validation Errors + +```typescript +test("POST /user should fail with invalid input", async () => { + const { responseMock, loggerMock } = await testEndpoint({ + endpoint: createUserEndpoint, + requestProps: { + method: "POST", + body: { + name: "", // Invalid: empty string + }, + }, + }); + + expect(responseMock._getStatusCode()).toBe(400); + const responseData = responseMock._getJSONData(); + expect(responseData).toHaveProperty("status", "error"); + expect(responseData.error.message).toContain("String must contain at least 1 character"); +}); +``` + +### Testing Authentication Middleware + +```typescript +import { Middleware } from "express-zod-api"; +import { z } from "zod"; +import createHttpError from "http-errors"; + +const authMiddleware = new Middleware({ + security: { + and: [ + { type: "input", name: "key" }, + { type: "header", name: "token" }, + ], + }, + input: z.object({ + key: z.string().min(1), + token: z.string().min(1), + }), + handler: async ({ input: { key, token } }) => { + if (key !== "123" || token !== "456") { + throw createHttpError(401, "Invalid credentials"); + } + return { user: { id: 1, name: "Jane Doe" } }; + }, +}); + +test("should authenticate with valid credentials", async () => { + const { output, loggerMock } = await testMiddleware({ + middleware: authMiddleware, + requestProps: { + method: "POST", + body: { key: "123" }, + headers: { token: "456" }, + }, + }); + + expect(loggerMock._getLogs().error).toHaveLength(0); + expect(output).toEqual({ + user: { id: 1, name: "Jane Doe" }, + }); +}); + +test("should reject invalid credentials", async () => { + const { responseMock } = await testMiddleware({ + middleware: authMiddleware, + requestProps: { + method: "POST", + body: { key: "wrong" }, + headers: { token: "456" }, + }, + }); + + expect(responseMock._getStatusCode()).toBe(401); +}); +``` + +### Testing with Context + +```typescript +const protectedEndpoint = defaultEndpointsFactory + .addMiddleware(authMiddleware) + .build({ + input: z.object({ action: z.string() }), + output: z.object({ message: z.string() }), + handler: async ({ input, ctx }) => ({ + message: `${ctx.user.name} performed ${input.action}`, + }), + }); + +test("should use context from middleware", async () => { + const { responseMock } = await testEndpoint({ + endpoint: protectedEndpoint, + requestProps: { + method: "POST", + body: { key: "123", action: "update" }, + headers: { token: "456" }, + }, + }); + + expect(responseMock._getStatusCode()).toBe(200); + expect(responseMock._getJSONData()).toEqual({ + status: "success", + data: { message: "Jane Doe performed update" }, + }); +}); +``` + +## Testing Options + +### requestProps + +Additional properties to set on the Request mock: + +```typescript +requestProps: { + method: "POST", // HTTP method + body: {}, // Request body (parsed JSON) + query: {}, // Query parameters + params: {}, // Path parameters + headers: {}, // Request headers + // ... any other Request properties +} +``` + +### responseOptions + +Options for the Response mock (see [node-mocks-http](https://www.npmjs.com/package/node-mocks-http)): + +```typescript +responseOptions: { + // Custom response options +} +``` + +### configProps + +Additional configuration properties: + +```typescript +configProps: { + cors: true, + inputSources: { post: ["body", "params"] }, + // ... any config options +} +``` + +### loggerProps + +Additional logger properties: + +```typescript +loggerProps: { + // Custom logger properties +} +``` + +## Logger Mock Methods + +The logger mock provides a special `_getLogs()` method: + +```typescript +const logs = loggerMock._getLogs(); + +console.log(logs.info); // Array of info logs +console.log(logs.debug); // Array of debug logs +console.log(logs.warn); // Array of warning logs +console.log(logs.error); // Array of error logs +``` + +### Example + +```typescript +test("should log debug information", async () => { + const { loggerMock } = await testEndpoint({ + endpoint: myEndpoint, + requestProps: { method: "GET" }, + }); + + const debugLogs = loggerMock._getLogs().debug; + expect(debugLogs.length).toBeGreaterThan(0); + expect(debugLogs[0]).toContain("Processing request"); +}); +``` + +## Response Mock Methods + +The response mock provides these assertion helpers: + +```typescript +responseMock._getStatusCode() // Get HTTP status code +responseMock._getJSONData() // Get JSON response data +responseMock._getHeaders() // Get response headers (lowercase) +responseMock._getData() // Get raw response data +responseMock._isJSON() // Check if response is JSON +responseMock._isUTF8() // Check if response is UTF-8 +``` + +## Testing File Uploads + +```typescript +import { testEndpoint } from "express-zod-api"; +import { ez } from "express-zod-api"; + +const uploadEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + avatar: ez.upload(), + }), + output: z.object({ + size: z.number(), + }), + handler: async ({ input: { avatar } }) => ({ + size: avatar.size, + }), +}); + +test("should handle file upload", async () => { + const mockFile = { + name: "test.png", + data: Buffer.from("fake image data"), + size: 1024, + mimetype: "image/png", + mv: vi.fn(), + }; + + const { responseMock } = await testEndpoint({ + endpoint: uploadEndpoint, + requestProps: { + method: "POST", + body: { avatar: mockFile }, + }, + }); + + expect(responseMock._getStatusCode()).toBe(200); + expect(responseMock._getJSONData().data.size).toBe(1024); +}); +``` + +## Testing Custom Result Handlers + +```typescript +const customHandler = new ResultHandler({ + positive: (data) => ({ + schema: z.object({ result: z.string() }), + mimeType: "application/json", + }), + negative: z.object({ error: z.string() }), + handler: ({ error, output, response }) => { + if (error) { + return void response.status(400).json({ error: error.message }); + } + response.status(200).json({ result: output }); + }, +}); + +const customFactory = new EndpointsFactory(customHandler); + +test("should use custom result handler", async () => { + const endpoint = customFactory.build({ + input: z.object({}), + output: z.string(), + handler: async () => "success", + }); + + const { responseMock } = await testEndpoint({ endpoint }); + + expect(responseMock._getJSONData()).toEqual({ result: "success" }); +}); +``` + +## Best Practices + + + + Always test both successful responses and error conditions to ensure your error handling works correctly. + + + + Verify that no unexpected errors were logged using `loggerMock._getLogs().error`. + + + + Test middlewares individually and then test endpoints with middlewares attached to ensure proper context passing. + + + + For complex response structures, consider using snapshot testing to detect unexpected changes. + + + + Mock database calls and external services to keep tests fast and isolated. + + + +## Integration Testing + +For full integration tests, start the actual server: + +```typescript +import { createServer } from "express-zod-api"; +import { config } from "./config"; +import { routing } from "./routing"; + +let server; + +beforeAll(async () => { + const { servers } = await createServer(config, routing); + server = servers[0]; +}); + +afterAll(() => { + server?.close(); +}); + +test("integration test", async () => { + const response = await fetch("http://localhost:8080/v1/user/123"); + const data = await response.json(); + expect(data).toEqual({ status: "success", data: { id: 123 } }); +}); +``` + +## Related Topics + +- [Middlewares](/core-concepts/middlewares) - Creating and using middlewares +- [Endpoints](/core-concepts/endpoints) - Building endpoints +- [Error Handling](/core-concepts/error-handling) - Handling errors diff --git a/docs/api/config-options.mdx b/docs/api/config-options.mdx new file mode 100644 index 000000000..6010014f6 --- /dev/null +++ b/docs/api/config-options.mdx @@ -0,0 +1,438 @@ +--- +title: "Configuration Options" +description: "Complete reference for all Express Zod API configuration options" +--- + +## Overview + +This page provides a comprehensive reference for all configuration options available in Express Zod API. Options are organized by category. + +## Common Configuration + +These options are available in all configurations: + +### cors + + + Enables cross-origin resource sharing (CORS) + + +**Type**: `boolean | HeadersProvider` + +**Required**: Yes + +**Default**: N/A (must be explicitly set) + +**Examples**: + +```typescript +// Enable with default headers +cors: true + +// Disable CORS +cors: false + +// Custom CORS headers +cors: ({ defaultHeaders, request, endpoint, logger }) => ({ + ...defaultHeaders, + "Access-Control-Allow-Origin": "https://example.com", + "Access-Control-Max-Age": "86400", +}) +``` + +### logger + +**Type**: `BuiltinLoggerConfig | AbstractLogger` + +**Default**: `{ level: "debug", color: true, depth: 2 }` + +**Built-in Logger Options**: + +| Option | Type | Description | +|--------|------|-------------| +| `level` | `"debug" \| "info" \| "warn" \| "error"` | Minimum log level | +| `color` | `boolean` | Enable colored output | +| `depth` | `number` | Object inspection depth | + +**Examples**: + +```typescript +// Built-in logger +logger: { level: "info", color: false, depth: 3 } + +// Winston +import winston from "winston"; +logger: winston.createLogger({ ... }) + +// Pino +import pino from "pino"; +logger: pino({ level: "info" }) +``` + +### errorHandler + +**Type**: `AbstractResultHandler` + +**Default**: `defaultResultHandler` + +**Description**: Handles routing, parsing, and upload errors. + +```typescript +import { ResultHandler } from "express-zod-api"; + +errorHandler: new ResultHandler({ + positive: (data) => ({ schema: z.object({ data }), mimeType: "application/json" }), + negative: z.object({ error: z.string() }), + handler: ({ error, response }) => { + if (error) { + response.status(500).json({ error: error.message }); + } + }, +}) +``` + +### inputSources + +**Type**: `Partial` + +**Default**: +```typescript +{ + get: ["query", "params"], + post: ["body", "params", "files"], + put: ["body", "params"], + patch: ["body", "params"], + delete: ["query", "params"], +} +``` + +**Description**: Controls which request properties are merged into endpoint `input`. + +```typescript +inputSources: { + get: ["headers", "query", "params"], + post: ["body", "params"], +} +``` + +### childLoggerProvider + +**Type**: `(params: { parent: Logger; request: Request }) => Logger | Promise` + +**Default**: None + +**Description**: Creates a request-scoped child logger. + +```typescript +import { randomUUID } from "crypto"; + +childLoggerProvider: ({ parent, request }) => + parent.child({ requestId: randomUUID() }) +``` + +### accessLogger + +**Type**: `((request: Request, logger: Logger) => void) | null` + +**Default**: `({ method, path }, logger) => logger.debug(${method}: ${path})` + +**Description**: Logs each request. Set to `null` to disable. + +```typescript +accessLogger: ({ method, path, ip }, logger) => + logger.info(`${method} ${path} from ${ip}`) +``` + +### wrongMethodBehavior + +**Type**: `404 | 405` + +**Default**: `405` + +**Description**: HTTP status code when wrong method is used. + +- `404`: Returns Not Found +- `405`: Returns Method Not Allowed with `Allow` header + +### methodLikeRouteBehavior + +**Type**: `"method" | "path"` + +**Default**: `"method"` + +**Description**: How to treat routing keys that match HTTP method names. + +### startupLogo + +**Type**: `boolean` + +**Default**: `true` + +**Description**: Show ASCII logo on server start. + +## Server Configuration + +These options are for standalone servers (using `createServer()`): + +### http + +**Type**: `{ listen: number | string | ListenOptions }` + +**Description**: HTTP server configuration. + +```typescript +// Port number +http: { listen: 8090 } + +// UNIX socket +http: { listen: "/var/run/app.sock" } + +// Full options +http: { listen: { port: 8090, host: "0.0.0.0" } } +``` + +### https + +**Type**: `{ options: ServerOptions; listen: number | string | ListenOptions }` + +**Description**: HTTPS server with TLS certificates. + +```typescript +import { readFileSync } from "fs"; + +https: { + options: { + cert: readFileSync("cert.pem"), + key: readFileSync("key.pem"), + }, + listen: 443, +} +``` + +### beforeRouting + +**Type**: `(params: { app: Express; getLogger: GetLogger }) => void | Promise` + +**Description**: Configure Express app before routes are attached. + +```typescript +import swaggerUi from "swagger-ui-express"; + +beforeRouting: ({ app }) => { + app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec)); +} +``` + +### jsonParser + +**Type**: `RequestHandler | false` + +**Default**: `express.json()` + +**Description**: JSON body parser or `false` to disable. + +```typescript +import express from "express"; + +jsonParser: express.json({ limit: "10mb" }) +``` + +### formParser + +**Type**: `RequestHandler | false` + +**Default**: `express.urlencoded({ extended: false })` + +**Description**: Form body parser or `false` to disable. + +```typescript +import express from "express"; + +formParser: express.urlencoded({ extended: true }) +``` + +### upload + +**Type**: `boolean | UploadOptions` + +**Description**: Enable file uploads. + +**Options**: + +```typescript +interface UploadOptions { + limits?: { + fileSize?: number; + files?: number; + fields?: number; + }; + limitError?: HttpError; + beforeUpload?: (params: { + request: Request; + logger: Logger; + }) => void | Promise; + debug?: boolean; +} +``` + +**Example**: + +```typescript +import createHttpError from "http-errors"; + +upload: { + limits: { fileSize: 5 * 1024 * 1024 }, + limitError: createHttpError(413, "File too large"), + beforeUpload: ({ request }) => { + if (!request.headers.authorization) { + throw createHttpError(401, "Unauthorized"); + } + }, +} +``` + +### compression + +**Type**: `boolean | CompressionOptions` + +**Description**: Enable GZIP/Brotli compression. + +```typescript +compression: { + threshold: "1kb", + level: 6, + filter: (req, res) => { + if (req.headers["x-no-compression"]) return false; + return compression.filter(req, res); + }, +} +``` + +### queryParser + +**Type**: `"simple" | "extended" | ((str: string) => any)` + +**Default**: `"simple"` + +**Description**: Query string parser. + +```typescript +import qs from "qs"; + +// Simple parser +queryParser: "simple" + +// Extended parser (nested objects) +queryParser: "extended" + +// Custom parser +queryParser: (str) => qs.parse(str, { comma: true }) +``` + +### gracefulShutdown + +**Type**: `GracefulShutdownConfig` + +**Options**: + +```typescript +interface GracefulShutdownConfig { + timeout?: number; // milliseconds + events?: string[]; // default: ["SIGTERM", "SIGINT"] + beforeShutdown?: () => void | Promise; + onShutdown?: (signal: string) => void | Promise; +} +``` + +**Example**: + +```typescript +gracefulShutdown: { + timeout: 30000, + events: ["SIGTERM", "SIGINT", "SIGUSR2"], + beforeShutdown: async () => { + await db.disconnect(); + await cache.quit(); + }, + onShutdown: async (signal) => { + logger.info(`Received ${signal}, shutting down`); + }, +} +``` + +## App Configuration + +These options are for attaching to an existing Express app: + +### app + +**Type**: `Express | Router` + +**Required**: Yes (for app config) + +**Description**: Express application or router instance. + +```typescript +import express from "express"; +import { createConfig, attachRouting } from "express-zod-api"; + +const app = express(); + +const config = createConfig({ + app, + cors: true, + logger: { level: "info" }, +}); + +attachRouting(config, routing); + +app.listen(8090); +``` + +## Type Definitions + +### InputSource + +```typescript +type InputSource = "query" | "body" | "files" | "params" | "headers"; +``` + +### InputSources + +```typescript +type InputSources = Record; +``` + +### Method + +```typescript +type Method = "get" | "post" | "put" | "patch" | "delete"; +``` + +### HeadersProvider + +```typescript +type HeadersProvider = (params: { + defaultHeaders: Record; + request: Request; + endpoint: AbstractEndpoint; + logger: Logger; +}) => Record | Promise>; +``` + +## See Also + + + + Create a configuration object + + + Start a server with configuration + + + Production deployment settings + + + Configure CORS in detail + + \ No newline at end of file diff --git a/docs/api/create-config.mdx b/docs/api/create-config.mdx new file mode 100644 index 000000000..6963b8174 --- /dev/null +++ b/docs/api/create-config.mdx @@ -0,0 +1,329 @@ +--- +title: "createConfig" +description: "Create a configuration object for your Express Zod API server" +--- + +## Overview + +The `createConfig()` function creates a configuration object that controls the behavior of your Express Zod API server. It accepts a configuration object and returns a validated config ready for use with `createServer()` or `attachRouting()`. + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + cors: true, + logger: { level: "debug" }, +}); +``` + +## Signature + +```typescript +function createConfig(options: CommonConfig & (ServerConfig | AppConfig)): Config +``` + +## Configuration Options + +The configuration object accepts options from three categories: + + + + CORS, logging, error handling, input sources + + + HTTP/HTTPS server configuration + + + Attach to existing Express app + + + +## Common Options + +These options are available in all configurations: + + + Enable CORS and optionally customize headers + + ```typescript + // Simple enable + cors: true + + // Custom headers + cors: ({ defaultHeaders, request, endpoint, logger }) => ({ + ...defaultHeaders, + "Access-Control-Max-Age": "5000", + }) + ``` + + + + Built-in logger configuration or custom logger instance + + ```typescript + // Built-in logger + logger: { level: "debug", color: true, depth: 2 } + + // Custom logger (Winston, Pino, etc) + logger: winston.createLogger({ ... }) + ``` + + + + Custom result handler for routing and parsing errors + + Default: `defaultResultHandler` + + + + Customize which request properties are merged into `input` + + ```typescript + inputSources: { + get: ["query", "params"], + post: ["body", "params", "files"], + } + ``` + + + + Create a child logger for each request with custom context + + ```typescript + childLoggerProvider: ({ parent, request }) => + parent.child({ requestId: uuid() }) + ``` + + + + Custom access logging function or `null` to disable + + ```typescript + accessLogger: ({ method, path }, logger) => + logger.info(`${method} ${path}`) + ``` + + + + How to respond when wrong HTTP method is used + + - `404`: Not Found + - `405`: Method Not Allowed (includes Allow header) + + Default: `405` + + + + How to treat routing keys that look like HTTP methods + + Default: `"method"` + + + + Show the Express Zod API logo on startup + + Default: `true` + + +## Server Options + +Use these options when creating a standalone server: + + + HTTP server configuration + + ```typescript + http: { listen: 8090 } + http: { listen: "/var/run/app.sock" } + http: { listen: { port: 8090, host: "0.0.0.0" } } + ``` + + + + HTTPS server configuration with TLS certificates + + ```typescript + https: { + options: { + cert: fs.readFileSync("cert.pem"), + key: fs.readFileSync("key.pem"), + }, + listen: 443, + } + ``` + + + + Graceful shutdown configuration + + ```typescript + gracefulShutdown: { + timeout: 30000, + events: ["SIGTERM", "SIGINT"], + beforeShutdown: async () => { + await db.disconnect(); + }, + } + ``` + + + + Configure Express app before routing is attached + + ```typescript + beforeRouting: ({ app, getLogger }) => { + app.use("/docs", swaggerUi.serve, swaggerUi.setup(docs)); + } + ``` + + + + Custom JSON body parser or `false` to disable + + Default: `express.json()` + + + + Custom URL-encoded form parser or `false` to disable + + Default: `express.urlencoded({ extended: false })` + + + + Enable file uploads with optional configuration + + ```typescript + upload: { + limits: { fileSize: 1024 * 1024 * 5 }, // 5MB + limitError: createHttpError(413, "File too large"), + beforeUpload: ({ request }) => { + if (!isAuthorized(request)) throw createHttpError(403); + }, + } + ``` + + + + Enable GZIP/Brotli compression + + ```typescript + compression: { threshold: "1kb", level: 6 } + ``` + + + + Query string parser + + - `"simple"`: Node's querystring module + - `"extended"`: qs module with nested objects + - Custom function: `(str) => qs.parse(str, { comma: true })` + + Default: `"simple"` + + +## App Options + +Use when attaching to an existing Express application: + + + Express application or router instance + + ```typescript + import express from "express"; + + const app = express(); + const config = createConfig({ + app, + cors: true, + logger: { level: "info" }, + }); + ``` + + +## Examples + +### Minimal Configuration + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + cors: false, +}); +``` + +### Production Configuration + +```typescript +import { createConfig } from "express-zod-api"; +import winston from "winston"; +import createHttpError from "http-errors"; + +const config = createConfig({ + https: { + options: { + cert: fs.readFileSync("/etc/ssl/cert.pem"), + key: fs.readFileSync("/etc/ssl/key.pem"), + }, + listen: 443, + }, + cors: ({ defaultHeaders }) => ({ + ...defaultHeaders, + "Access-Control-Allow-Origin": process.env.ALLOWED_ORIGIN, + }), + logger: winston.createLogger({ + level: "info", + format: winston.format.json(), + transports: [new winston.transports.File({ filename: "api.log" })], + }), + upload: { + limits: { fileSize: 10 * 1024 * 1024 }, + limitError: createHttpError(413, "File exceeds 10MB limit"), + }, + compression: { threshold: "1kb", level: 6 }, + gracefulShutdown: { + timeout: 30000, + beforeShutdown: async () => { + await db.disconnect(); + await redis.quit(); + }, + }, +}); +``` + +### With Existing Express App + +```typescript +import express from "express"; +import { createConfig } from "express-zod-api"; + +const app = express(); + +// Add your own middleware +app.use("/health", (req, res) => res.send("OK")); + +const config = createConfig({ + app, + cors: true, + logger: { level: "debug" }, +}); +``` + +## See Also + + + + Detailed reference for all configuration options + + + Start a server with your configuration + + + Production deployment best practices + + + Configure cross-origin resource sharing + + \ No newline at end of file diff --git a/docs/api/documentation.mdx b/docs/api/documentation.mdx new file mode 100644 index 000000000..f9113e74f --- /dev/null +++ b/docs/api/documentation.mdx @@ -0,0 +1,190 @@ +--- +title: "Documentation" +description: "Generate OpenAPI 3.1 documentation from your API" +--- + +## Overview + +The `Documentation` class generates OpenAPI 3.1 specifications from your routing and configuration. + +```typescript +import { Documentation } from "express-zod-api"; + +const documentation = new Documentation({ + routing, + config, + version: "1.0.0", + title: "My API", + serverUrl: "https://api.example.com", +}); + +const yaml = documentation.getSpecAsYaml(); +const json = documentation.getSpec(); +``` + +## Configuration + + + Your API routing object + + + + Your Express Zod API configuration + + + + API version (semver) + + + + API title + + + + Server URL(s) + + + + API description + + + + Schema composition style + + - `"inline"`: Schemas defined inline + - `"components"`: Schemas in separate components section with `$ref` + + Default: `"inline"` + + + + Tag descriptions for grouping endpoints + + +## Methods + +### getSpec() + +Returns OpenAPI specification as a JavaScript object. + +```typescript +const spec = documentation.getSpec(); +// Returns: OpenAPIObject +``` + +### getSpecAsYaml() + +Returns OpenAPI specification as YAML string. + +```typescript +const yaml = documentation.getSpecAsYaml(); +fs.writeFileSync("openapi.yaml", yaml); +``` + +## Examples + +### Basic Documentation + +```typescript +import { Documentation } from "express-zod-api"; + +const docs = new Documentation({ + routing, + config, + version: "1.0.0", + title: "My API", + serverUrl: "https://api.example.com", + description: "A sample API built with Express Zod API", +}); + +const yaml = docs.getSpecAsYaml(); +fs.writeFileSync("docs/openapi.yaml", yaml); +``` + +### Multiple Servers + +```typescript +const docs = new Documentation({ + routing, + config, + version: "1.0.0", + title: "My API", + serverUrl: [ + { url: "https://api.example.com", description: "Production" }, + { url: "https://staging-api.example.com", description: "Staging" }, + { url: "http://localhost:8090", description: "Development" }, + ], +}); +``` + +### With Tags + +```typescript +const docs = new Documentation({ + routing, + config, + version: "1.0.0", + title: "My API", + serverUrl: "https://api.example.com", + tags: { + users: "User management endpoints", + posts: { + description: "Blog post operations", + url: "https://docs.example.com/posts", + }, + admin: "Administrative functions", + }, +}); +``` + +### Component Composition + +```typescript +const docs = new Documentation({ + routing, + config, + version: "1.0.0", + title: "My API", + serverUrl: "https://api.example.com", + composition: "components", // Use $ref for schemas +}); +``` + +### Serving with Swagger UI + +```typescript +import swaggerUi from "swagger-ui-express"; +import { Documentation } from "express-zod-api"; + +const docs = new Documentation({ + routing, + config, + version: "1.0.0", + title: "My API", + serverUrl: "https://api.example.com", +}); + +const spec = docs.getSpec(); + +const config = createConfig({ + http: { listen: 8090 }, + cors: true, + beforeRouting: ({ app }) => { + app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec)); + }, +}); +``` + +## See Also + + + + Complete documentation guide + + + OpenAPI specification details + + + Organize endpoints with tags + + \ No newline at end of file diff --git a/docs/api/endpoint.mdx b/docs/api/endpoint.mdx new file mode 100644 index 000000000..340b779a1 --- /dev/null +++ b/docs/api/endpoint.mdx @@ -0,0 +1,195 @@ +--- +title: "Endpoint" +description: "Individual API endpoints with input/output validation" +--- + +## Overview + +Endpoints are created using `EndpointsFactory.build()` and represent individual API routes with typed input/output schemas and handler functions. + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const getUser = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + id: z.string(), + }), + output: z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + }), + handler: async ({ input, logger }) => { + logger.info(`Fetching user ${input.id}`); + return await db.users.findById(input.id); + }, +}); +``` + +## Configuration Options + +### method + +**Type**: `Method | Method[]` + +**Default**: `"get"` + +HTTP method(s) the endpoint handles. + +```typescript +method: "post" +method: ["get", "post"] +``` + +### input + +**Type**: `ZodObject` + +**Default**: `z.object({})` + +Input validation schema. + +### output + +**Type**: `ZodType` + +**Required**: Yes + +Output validation schema. + +### handler + +**Type**: `Handler` + +**Required**: Yes + +Async function that processes the request. + +```typescript +handler: async ({ input, ctx, logger, request, response }) => { + return { result: "success" }; +} +``` + +### shortDescription + +**Type**: `string` + +Brief summary for OpenAPI documentation (max 50 characters). + +### description + +**Type**: `string` + +Detailed description for OpenAPI documentation. + +### operationId + +**Type**: `string | ((method: ClientMethod) => string)` + +Unique operation identifier for documentation. + +### tag + +**Type**: `Tag | Tag[]` + +Tags for organizing documentation. + +### scope + +**Type**: `string | string[]` + +OAuth2 scopes required for the endpoint. + +### deprecated + +**Type**: `boolean` + +Marks the endpoint as deprecated in documentation. + +## Handler Parameters + +The handler function receives an object with: + + + Validated input from request (query, body, params, etc.) + + + + Context accumulated from middleware + + + + Logger instance (child logger if configured) + + + + Express request object + + + + Express response object + + +## Examples + +### Basic GET Endpoint + +```typescript +const listUsers = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + limit: z.string().transform(Number).pipe(z.number().int().positive()), + offset: z.string().transform(Number).pipe(z.number().int().nonnegative()).optional(), + }), + output: z.object({ + users: z.array(z.object({ + id: z.string(), + name: z.string(), + })), + total: z.number(), + }), + handler: async ({ input }) => { + const users = await db.users.find().limit(input.limit).skip(input.offset || 0); + const total = await db.users.count(); + return { users, total }; + }, +}); +``` + +### POST Endpoint with Authentication + +```typescript +const createPost = authFactory.build({ + method: "post", + input: z.object({ + title: z.string().min(1).max(200), + content: z.string().min(1), + tags: z.array(z.string()).optional(), + }), + output: z.object({ + id: z.string(), + createdAt: ez.dateOut(), + }), + handler: async ({ input, ctx: { user } }) => { + const post = await db.posts.create({ + ...input, + authorId: user.id, + }); + return { id: post.id, createdAt: post.createdAt }; + }, +}); +``` + +## See Also + + + + Create endpoint factories + + + Attach endpoints to routes + + \ No newline at end of file diff --git a/docs/api/endpoints-factory.mdx b/docs/api/endpoints-factory.mdx new file mode 100644 index 000000000..39386be5d --- /dev/null +++ b/docs/api/endpoints-factory.mdx @@ -0,0 +1,105 @@ +--- +title: "EndpointsFactory" +description: "Create type-safe endpoint builders with middleware and custom result handlers" +--- + +## Overview + +The `EndpointsFactory` class creates endpoint builders that include middleware, result handlers, and shared configuration. It provides a fluent API for composing reusable endpoint factories. + +```typescript +import { EndpointsFactory, defaultResultHandler } from "express-zod-api"; + +const factory = new EndpointsFactory(defaultResultHandler) + .addMiddleware(authMiddleware) + .addContext(async () => ({ db: await connectDB() })); +``` + +## Pre-built Factories + + + + Uses `defaultResultHandler` with standard JSON responses + + + (Deprecated) For migrating legacy APIs that return arrays + + + +## Methods + +### build() + +Creates an endpoint with input/output schemas and a handler. + +```typescript +const endpoint = factory.build({ + method: "post", + input: z.object({ name: z.string() }), + output: z.object({ id: z.number(), name: z.string() }), + handler: async ({ input, ctx, logger }) => { + return { id: 1, name: input.name }; + }, +}); +``` + +### buildVoid() + +Shorthand for endpoints that return empty objects. + +```typescript +const deleteEndpoint = factory.buildVoid({ + method: "delete", + input: z.object({ id: z.string() }), + handler: async ({ input }) => { + await deleteUser(input.id); + }, +}); +``` + +### addMiddleware() + +Adds middleware that provides context and executes before endpoints. + +```typescript +const authFactory = factory.addMiddleware({ + input: z.object({ token: z.string() }), + handler: async ({ input }) => { + const user = await validateToken(input.token); + return { user }; + }, +}); +``` + +### addExpressMiddleware() / use() + +Integrates native Express middleware. + +```typescript +import { auth } from "express-oauth2-jwt-bearer"; + +const factory = defaultEndpointsFactory.use(auth(), { + provider: (req) => ({ auth: req.auth }), +}); +``` + +### addContext() + +Provides request-independent context. + +```typescript +const factory = defaultEndpointsFactory.addContext(async () => ({ + db: await mongoose.connect("mongodb://localhost"), +})); +``` + +## See Also + + + + Individual endpoint documentation + + + Create and use middleware + + \ No newline at end of file diff --git a/docs/api/ez-schemas.mdx b/docs/api/ez-schemas.mdx new file mode 100644 index 000000000..3701e04dc --- /dev/null +++ b/docs/api/ez-schemas.mdx @@ -0,0 +1,270 @@ +--- +title: "ez Schemas" +description: "Built-in schema helpers for dates, files, forms, and more" +--- + +## Overview + +The `ez` object provides specialized Zod schemas for common API patterns like dates, file uploads, forms, and raw data. + +```typescript +import { ez } from "express-zod-api"; +import { z } from "zod"; +``` + +## Date Schemas + +### ez.dateIn() + +Accepts ISO date strings and provides `Date` objects to your handler. + +```typescript +const endpoint = defaultEndpointsFactory.build({ + input: z.object({ + startDate: ez.dateIn(), + endDate: ez.dateIn({ examples: ["2024-12-31"] }), + }), + handler: async ({ input }) => { + // input.startDate and input.endDate are Date objects + const days = daysBetween(input.startDate, input.endDate); + return { days }; + }, +}); +``` + +**Supported formats**: +- `2021-12-31T23:59:59.000Z` +- `2021-12-31T23:59:59Z` +- `2021-12-31T23:59:59` +- `2021-12-31` + +### ez.dateOut() + +Accepts `Date` objects from your handler and returns ISO strings in responses. + +```typescript +const endpoint = defaultEndpointsFactory.build({ + output: z.object({ + createdAt: ez.dateOut(), + updatedAt: ez.dateOut({ examples: ["2024-01-01T00:00:00Z"] }), + }), + handler: async () => { + return { + createdAt: new Date(), + updatedAt: new Date("2024-01-01"), + }; + }, +}); +``` + +## File Upload + +### ez.upload() + +Handles file uploads with `multipart/form-data`. + +```typescript +const uploadEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + avatar: ez.upload(), + document: ez.upload(), + }), + output: z.object({ success: z.boolean() }), + handler: async ({ input }) => { + // input.avatar: { name, mv(), mimetype, data, size, etc } + await input.avatar.mv(`/uploads/${input.avatar.name}`); + return { success: true }; + }, +}); +``` + +**File object properties**: + + + Original filename + + + + File contents as Buffer + + + + File size in bytes + + + + MIME type (e.g., "image/png") + + + + Move file to destination: `mv(path: string) => Promise` + + + + True if file exceeded size limit + + +## Form Data + +### ez.form() + +Handles URL-encoded form data (`application/x-www-form-urlencoded`). + +```typescript +const formEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.form({ + name: z.string().min(1), + email: z.string().email(), + subscribe: z.enum(["true", "false"]).transform(v => v === "true"), + }), + output: z.object({ success: z.boolean() }), + handler: async ({ input }) => { + await saveContact(input); + return { success: true }; + }, +}); +``` + +## Raw Data + +### ez.raw() + +Accepts raw request body as `Buffer` for binary data. + +```typescript +import { ez } from "express-zod-api"; + +const rawEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw({ + examples: [{ data: Buffer.from("example").toString("base64") }], + }), + output: z.object({ length: z.number() }), + handler: async ({ input: { data } }) => { + // data is a Buffer + return { length: data.length }; + }, +}); +``` + +### ez.buffer() + +For documenting binary responses in OpenAPI. + +```typescript +const fileResultHandler = new ResultHandler({ + positive: { schema: ez.buffer(), mimeType: "application/pdf" }, + // ... +}); +``` + +## Pagination + +### ez.paginated() + +Creates reusable pagination schemas. + +```typescript +import { ez } from "express-zod-api"; +import { z } from "zod"; + +const pagination = ez.paginated({ + style: "offset", // or "cursor" + itemSchema: z.object({ + id: z.number(), + name: z.string(), + }), + itemsName: "users", + maxLimit: 100, + defaultLimit: 20, +}); + +const listUsers = defaultEndpointsFactory.build({ + input: pagination.input, + output: pagination.output, + handler: async ({ input: { limit, offset } }) => { + const users = await db.users.find().limit(limit).skip(offset); + const total = await db.users.count(); + return { users, total, limit, offset }; + }, +}); +``` + +**Offset-based** (limit/offset): + +```typescript +// Input: { limit?: number, offset?: number } +// Output: { items: T[], total: number, limit: number, offset: number } +``` + +**Cursor-based** (limit/cursor): + +```typescript +// Input: { limit?: number, cursor?: string } +// Output: { items: T[], nextCursor: string | null, limit: number } +``` + +## Complete Example + +```typescript +import { defaultEndpointsFactory, ez } from "express-zod-api"; +import { z } from "zod"; + +const createEventEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + title: z.string().min(1).max(200), + description: z.string(), + startDate: ez.dateIn(), + endDate: ez.dateIn(), + image: ez.upload().optional(), + }), + output: z.object({ + id: z.string(), + createdAt: ez.dateOut(), + imageUrl: z.string().url().nullable(), + }), + handler: async ({ input }) => { + let imageUrl = null; + + if (input.image) { + const filename = `${uuid()}.${ext(input.image.name)}`; + await input.image.mv(`./uploads/${filename}`); + imageUrl = `https://cdn.example.com/${filename}`; + } + + const event = await db.events.create({ + title: input.title, + description: input.description, + startDate: input.startDate, + endDate: input.endDate, + imageUrl, + }); + + return { + id: event.id, + createdAt: event.createdAt, + imageUrl, + }; + }, +}); +``` + +## See Also + + + + Working with dates in detail + + + Complete file upload guide + + + Pagination patterns + + + Handle binary data + + \ No newline at end of file diff --git a/docs/api/integration.mdx b/docs/api/integration.mdx new file mode 100644 index 000000000..6cc9477d6 --- /dev/null +++ b/docs/api/integration.mdx @@ -0,0 +1,220 @@ +--- +title: "Integration" +description: "Generate type-safe TypeScript clients from your API" +--- + +## Overview + +The `Integration` class generates TypeScript client code with full type safety from your API routes. + +```typescript +import { Integration } from "express-zod-api"; +import typescript from "typescript"; + +const client = new Integration({ + typescript, + routing, + config, + variant: "client", +}); + +const code = await client.printFormatted(); +fs.writeFileSync("client.ts", code); +``` + +## Configuration + + + TypeScript compiler API + + + + Your API routing object + + + + Your Express Zod API configuration + + + + Generation mode: + + - `"client"`: Full client with fetch implementation + - `"types"`: Types only for DIY clients + + Default: `"client"` + + +## Methods + +### print() + +Generates unformatted TypeScript code. + +```typescript +const code = client.print(); +``` + +### printFormatted() + +Generates formatted TypeScript code using Prettier. + +```typescript +const code = await client.printFormatted(); +``` + +### Integration.create() + +Async factory method that imports TypeScript automatically. + +```typescript +const client = await Integration.create({ + routing, + config, + variant: "client", +}); +``` + +## Generated Client + +The generated client provides: + +```typescript +// In your frontend/client code +import { Client } from "./generated-client"; + +const client = new Client({ + baseUrl: "https://api.example.com", +}); + +// Type-safe requests +const user = await client.provide("get /users/:id", { + id: "123", +}); + +// All inputs and outputs are fully typed +const newPost = await client.provide("post /posts", { + title: "Hello World", + content: "...", +}); +``` + +## Custom Implementation + +Override the default fetch implementation: + +```typescript +import { Client, Implementation } from "./generated-client"; +import axios from "axios"; + +const axiosImplementation: Implementation = async ({ + method, + url, + body, + headers, +}) => { + const response = await axios({ method, url, data: body, headers }); + return response.data; +}; + +const client = new Client(axiosImplementation); +``` + +## Examples + +### Generate Client + +```typescript +import { Integration } from "express-zod-api"; +import typescript from "typescript"; +import { writeFileSync } from "fs"; + +const client = new Integration({ + typescript, + routing, + config, + variant: "client", +}); + +const code = await client.printFormatted(); +writeFileSync("src/api-client.ts", code); +``` + +### Generate Types Only + +```typescript +const types = new Integration({ + typescript, + routing, + config, + variant: "types", +}); + +const code = await types.printFormatted(); +writeFileSync("src/api-types.ts", code); +``` + +### Using the Generated Client + +```typescript +import { Client } from "./api-client"; + +const client = new Client({ + baseUrl: process.env.API_URL, +}); + +// GET request +const users = await client.provide("get /users", { + limit: 10, + offset: 0, +}); + +// POST request +const created = await client.provide("post /users", { + name: "John Doe", + email: "john@example.com", +}); + +// Path parameters +const user = await client.provide("get /users/:id", { + id: "123", +}); + +// Server-Sent Events +import { Subscription } from "./api-client"; + +const subscription = new Subscription("get /events", {}); + +subscription.on("message", (data) => { + console.log("Received:", data); +}); + +subscription.on("error", (error) => { + console.error("Error:", error); +}); +``` + +### Pagination Support + +```typescript +const response = await client.provide("get /users", { + limit: 20, + offset: 0, +}); + +// Check if more pages available +if (client.hasMore(response)) { + console.log("More users available"); +} +``` + +## See Also + + + + Complete client generation guide + + + Generate OpenAPI specs + + \ No newline at end of file diff --git a/docs/api/middleware.mdx b/docs/api/middleware.mdx new file mode 100644 index 000000000..4f48402c8 --- /dev/null +++ b/docs/api/middleware.mdx @@ -0,0 +1,179 @@ +--- +title: "Middleware" +description: "Create reusable middleware for authentication, logging, and context" +--- + +## Overview + +Middleware executes before endpoint handlers, providing authentication, context, and input validation. Middleware can be chained and composed. + +```typescript +import { Middleware } from "express-zod-api"; +import { z } from "zod"; + +const authMiddleware = new Middleware({ + input: z.object({ token: z.string() }), + handler: async ({ input, request }) => { + const user = await validateToken(input.token); + return { user }; + }, +}); +``` + +## Configuration + +### input + +**Type**: `ZodObject` + +Input validation schema for the middleware. + +### handler + +**Type**: `Handler` + +**Required**: Yes + +Async function that returns context for endpoints. + +### security + +**Type**: `SecuritySchema` + +Security requirements for OpenAPI documentation. + +```typescript +security: { + and: [ + { type: "input", name: "key" }, + { type: "header", name: "token" }, + ], +} +``` + +## Handler Return Value + +The middleware handler must return an object that becomes part of the endpoint's context: + +```typescript +handler: async ({ input }) => { + const user = await getUser(input.userId); + const permissions = await getPermissions(user); + + return { user, permissions }; // Available in ctx +} +``` + +## Chaining Middleware + +```typescript +const factory = defaultEndpointsFactory + .addMiddleware(authMiddleware) + .addMiddleware({ + handler: async ({ ctx: { user } }) => { + const settings = await getSettings(user.id); + return { settings }; + }, + }); + +// Endpoints have ctx: { user, settings } +``` + +## Security Declarations + +Declare security requirements for documentation: + +```typescript +// API Key +security: { type: "input", name: "apiKey" } + +// Bearer Token +security: { type: "header", name: "authorization" } + +// OAuth2 +security: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "https://auth.example.com/oauth/authorize", + tokenUrl: "https://auth.example.com/oauth/token", + scopes: { read: "Read access", write: "Write access" }, + }, + }, +} + +// Multiple requirements (AND) +security: { + and: [ + { type: "input", name: "key" }, + { type: "header", name: "token" }, + ], +} + +// Alternative requirements (OR) +security: { + or: [ + { type: "header", name: "authorization" }, + { type: "input", name: "apiKey" }, + ], +} +``` + +## Examples + +### Authentication Middleware + +```typescript +import createHttpError from "http-errors"; + +const authMiddleware = new Middleware({ + security: { type: "header", name: "authorization" }, + handler: async ({ request }) => { + const token = request.headers.authorization?.replace("Bearer ", ""); + + if (!token) { + throw createHttpError(401, "Missing token"); + } + + const user = await verifyJWT(token); + if (!user) { + throw createHttpError(401, "Invalid token"); + } + + return { user }; + }, +}); +``` + +### Rate Limiting Middleware + +```typescript +const rateLimitMiddleware = new Middleware({ + handler: async ({ request, logger }) => { + const ip = request.ip; + const count = await redis.incr(`rate:${ip}`); + + if (count === 1) { + await redis.expire(`rate:${ip}`, 60); + } + + if (count > 100) { + logger.warn(`Rate limit exceeded for ${ip}`); + throw createHttpError(429, "Too many requests"); + } + + return {}; + }, +}); +``` + +## See Also + + + + Add middleware to factories + + + Complete authentication examples + + \ No newline at end of file diff --git a/docs/api/proprietary-schemas.mdx b/docs/api/proprietary-schemas.mdx new file mode 100644 index 000000000..980d33cdf --- /dev/null +++ b/docs/api/proprietary-schemas.mdx @@ -0,0 +1,177 @@ +--- +title: "Proprietary Schemas" +description: "Framework-specific schema types and brands" +--- + +## Overview + +Express Zod API uses branded types to distinguish its proprietary schemas from regular Zod schemas. These brands enable special handling for dates, files, forms, and raw data. + +```typescript +import { ez } from "express-zod-api"; +``` + +## The ez Object + +All proprietary schemas are accessed through the `ez` object: + +```typescript +export const ez = { + dateIn, + dateOut, + form, + upload, + raw, + buffer, + paginated, +}; +``` + +## Schema Brands + +Each proprietary schema has a unique brand for type identification: + +```typescript +type ProprietaryBrand = + | typeof ezFormBrand + | typeof ezDateInBrand + | typeof ezDateOutBrand + | typeof ezUploadBrand + | typeof ezRawBrand + | typeof ezBufferBrand; +``` + +## Date Schemas + +### ezDateInBrand + +Identifies schemas created with `ez.dateIn()`. + +```typescript +import { ez } from "express-zod-api"; + +const schema = ez.dateIn(); +// Brand: ezDateInBrand +// Input: ISO string +// Output: Date object +``` + +### ezDateOutBrand + +Identifies schemas created with `ez.dateOut()`. + +```typescript +const schema = ez.dateOut(); +// Brand: ezDateOutBrand +// Input: Date object +// Output: ISO string +``` + +## File Schemas + +### ezUploadBrand + +Identifies file upload schemas created with `ez.upload()`. + +```typescript +const schema = ez.upload(); +// Brand: ezUploadBrand +// Content-Type: multipart/form-data +``` + +### ezBufferBrand + +Identifies buffer schemas for binary responses. + +```typescript +const schema = ez.buffer(); +// Brand: ezBufferBrand +// Used in ResultHandler positive schemas +``` + +## Form Schema + +### ezFormBrand + +Identifies form data schemas created with `ez.form()`. + +```typescript +const schema = ez.form({ name: z.string() }); +// Brand: ezFormBrand +// Content-Type: application/x-www-form-urlencoded +``` + +## Raw Data Schema + +### ezRawBrand + +Identifies raw buffer schemas created with `ez.raw()`. + +```typescript +const schema = ez.raw(); +// Brand: ezRawBrand +// Input: Raw Buffer from request body +``` + +## Brand Usage + +The framework uses these brands internally to: + +1. **Select correct parsers** - Form data, JSON, multipart, or raw +2. **Generate accurate OpenAPI specs** - Correct content types and schemas +3. **Transform data** - Convert between Date objects and ISO strings +4. **Validate input** - Apply appropriate validation logic + +## Type Safety + +Brands ensure type safety throughout the request/response cycle: + +```typescript +// Input transformation +ez.dateIn() // string -> Date +ez.raw() // raw body -> Buffer +ez.form() // form data -> validated object +ez.upload() // multipart -> UploadedFile + +// Output transformation +ez.dateOut() // Date -> string +ez.buffer() // Buffer -> binary response +``` + +## Combining Schemas + +Proprietary schemas work seamlessly with regular Zod schemas: + +```typescript +import { z } from "zod"; +import { ez } from "express-zod-api"; + +const input = z.object({ + title: z.string(), + publishDate: ez.dateIn(), + image: ez.upload().optional(), +}); + +const output = z.object({ + id: z.string(), + createdAt: ez.dateOut(), + url: z.string().url(), +}); +``` + +## See Also + + + + Complete ez schema reference + + + Zod extensions and helpers + + + Input/output validation + + + OpenAPI schema generation + + \ No newline at end of file diff --git a/docs/api/result-handler.mdx b/docs/api/result-handler.mdx new file mode 100644 index 000000000..d0bf1318d --- /dev/null +++ b/docs/api/result-handler.mdx @@ -0,0 +1,195 @@ +--- +title: "ResultHandler" +description: "Customize API responses and error handling" +--- + +## Overview + +The `ResultHandler` class controls how your API sends responses and handles errors. It defines response schemas for success and error cases. + +```typescript +import { ResultHandler } from "express-zod-api"; +import { z } from "zod"; + +const customResultHandler = new ResultHandler({ + positive: (data) => ({ + schema: z.object({ success: z.literal(true), data }), + mimeType: "application/json", + }), + negative: z.object({ success: z.literal(false), error: z.string() }), + handler: ({ error, output, response }) => { + if (error) { + response.status(error.statusCode || 500).json({ + success: false, + error: error.message, + }); + } else { + response.status(200).json({ success: true, data: output }); + } + }, +}); +``` + +## Configuration + +### positive + +**Type**: `(output: Schema) => { schema: Schema; mimeType: string | string[] | null }` + +**Description**: Defines success response schema. + +```typescript +positive: (data) => ({ + schema: z.object({ data }), + mimeType: "application/json", +}) +``` + +### negative + +**Type**: `Schema` + +**Description**: Error response schema. + +```typescript +negative: z.object({ error: z.string() }) +``` + +### handler + +**Type**: `(params: HandlerParams) => void` + +**Required**: Yes + +**Description**: Sends the actual response. + +**Parameters**: + + + Validated endpoint output (on success) + + + + Error object (on failure) + + + + Express request object + + + + Express response object + + + + Logger instance + + + + Validated input + + +## Pre-built Result Handlers + +### defaultResultHandler + +Standard JSON responses: + +```typescript +// Success +{ "status": "success", "data": { ... } } + +// Error +{ "status": "error", "error": { "message": "..." } } +``` + +### arrayResultHandler + +(Deprecated) Returns arrays directly for legacy APIs. + +## Examples + +### Custom Status Codes + +```typescript +const customStatusHandler = new ResultHandler({ + positive: (data) => ({ + schema: z.object({ data }), + mimeType: "application/json", + statusCode: 201, + }), + negative: z.object({ error: z.string() }), + handler: ({ error, output, response }) => { + if (error) { + const status = error.statusCode || 500; + response.status(status).json({ error: error.message }); + } else { + response.status(201).json({ data: output }); + } + }, +}); +``` + +### Non-JSON Responses + +```typescript +import { ez } from "express-zod-api"; + +const fileHandler = new ResultHandler({ + positive: { schema: ez.buffer(), mimeType: "image/png" }, + negative: { schema: z.string(), mimeType: "text/plain" }, + handler: ({ error, output, response }) => { + if (error) { + response.status(400).send(error.message); + } else if (output.buffer) { + response.type("image/png").send(output.buffer); + } + }, +}); +``` + +### Empty Responses + +```typescript +const emptyHandler = new ResultHandler({ + positive: { statusCode: 204, mimeType: null, schema: z.never() }, + negative: { statusCode: 404, mimeType: null, schema: z.never() }, + handler: ({ error, response }) => { + response.status(error ? 404 : 204).end(); + }, +}); +``` + +## Error Helpers + +### ensureHttpError() + +Normalizes errors to have status codes: + +```typescript +import { ensureHttpError } from "express-zod-api"; + +const httpError = ensureHttpError(error); +console.log(httpError.statusCode); // 400, 401, 500, etc. +``` + +### getMessageFromError() + +Extracts error message: + +```typescript +import { getMessageFromError } from "express-zod-api"; + +const message = getMessageFromError(error); +``` + +## See Also + + + + Advanced response patterns + + + Error handling concepts + + \ No newline at end of file diff --git a/docs/api/routing.mdx b/docs/api/routing.mdx new file mode 100644 index 000000000..a5fd92f54 --- /dev/null +++ b/docs/api/routing.mdx @@ -0,0 +1,169 @@ +--- +title: "Routing" +description: "Define routes and attach endpoints to your API" +--- + +## Overview + +The `Routing` type defines the URL structure of your API by mapping paths to endpoints. + +```typescript +import { Routing } from "express-zod-api"; + +const routing: Routing = { + v1: { + users: { + get: listUsers, + post: createUser, + ":id": { + get: getUser, + patch: updateUser, + delete: deleteUser, + }, + }, + }, +}; +``` + +## Routing Syntax + +### Nested Objects + +```typescript +const routing: Routing = { + api: { + v1: { + users: listUsersEndpoint, + }, + }, +}; +// Route: GET /api/v1/users +``` + +### Flat Paths + +```typescript +const routing: Routing = { + "/api/v1/users": listUsersEndpoint, +}; +// Route: GET /api/v1/users +``` + +### Path Parameters + +```typescript +const routing: Routing = { + users: { + ":id": getUserEndpoint, + }, +}; +// Route: GET /users/:id +``` + +### Method-Based Routing + +```typescript +const routing: Routing = { + users: { + get: listUsers, + post: createUser, + }, +}; +// Routes: GET /users, POST /users +``` + +### Explicit Method in Path + +```typescript +const routing: Routing = { + "post /users": createUserEndpoint, + "get /users/:id": getUserEndpoint, +}; +``` + +### Static File Serving + +```typescript +import { ServeStatic } from "express-zod-api"; + +const routing: Routing = { + public: new ServeStatic("./assets", { + dotfiles: "deny", + index: false, + }), +}; +// Serves files from ./assets at /public +``` + +## Complex Example + +```typescript +import { Routing, ServeStatic } from "express-zod-api"; + +const routing: Routing = { + // Flat syntax + "/health": healthCheckEndpoint, + + // Nested API structure + api: { + v1: { + // Method-based + users: { + get: listUsers, + post: createUser, + ":id": { + get: getUser, + patch: updateUser, + delete: deleteUser, + }, + }, + + // Posts with nested comments + posts: { + get: listPosts, + ":postId": { + get: getPost, + comments: { + get: listComments, + post: createComment, + }, + }, + }, + + // Explicit method + "post /auth/login": loginEndpoint, + "post /auth/logout": logoutEndpoint, + }, + }, + + // Static files + static: new ServeStatic("./public"), +}; +``` + +## Endpoint Nesting + +Endpoints can handle both their own path and sub-paths: + +```typescript +const parentEndpoint = defaultEndpointsFactory.build({ ... }); +const childEndpoint = defaultEndpointsFactory.build({ ... }); + +const routing: Routing = { + parent: parentEndpoint.nest({ + child: childEndpoint, + }), +}; +// Routes: /parent and /parent/child +``` + +## See Also + + + + Routing patterns and best practices + + + Start server with routing + + \ No newline at end of file diff --git a/docs/api/server.mdx b/docs/api/server.mdx new file mode 100644 index 000000000..e15716ca4 --- /dev/null +++ b/docs/api/server.mdx @@ -0,0 +1,298 @@ +--- +title: "Server Functions" +description: "Start and configure your Express Zod API server" +--- + +## Overview + +Express Zod API provides functions to start standalone servers or attach to existing Express applications. + +## createServer() + +Creates and starts an HTTP/HTTPS server with your configuration and routing. + +```typescript +import { createServer, createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + cors: true, +}); + +await createServer(config, routing); +``` + +### Signature + +```typescript +function createServer( + config: ServerConfig, + routing: Routing +): Promise<{ + app: Express; + servers: Server[]; + logger: Logger; +}>; +``` + +### Returns + + + Express application instance + + + + Array of HTTP/HTTPS server instances + + + + Logger instance + + +### Examples + +#### Basic Server + +```typescript +import { createServer, createConfig, Routing } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + cors: true, + logger: { level: "info" }, +}); + +const routing: Routing = { + v1: { + users: listUsersEndpoint, + }, +}; + +const { app, servers, logger } = await createServer(config, routing); + +logger.info("Server started successfully"); +``` + +#### HTTPS Server + +```typescript +import { readFileSync } from "fs"; + +const config = createConfig({ + https: { + options: { + cert: readFileSync("cert.pem"), + key: readFileSync("key.pem"), + }, + listen: 443, + }, + cors: true, +}); + +await createServer(config, routing); +``` + +#### Both HTTP and HTTPS + +```typescript +const config = createConfig({ + http: { listen: 80 }, + https: { + options: { + cert: readFileSync("cert.pem"), + key: readFileSync("key.pem"), + }, + listen: 443, + }, + cors: true, +}); + +const { servers } = await createServer(config, routing); + +// servers[0] is HTTP, servers[1] is HTTPS +``` + +#### With Graceful Shutdown + +```typescript +const config = createConfig({ + http: { listen: 8090 }, + cors: true, + gracefulShutdown: { + timeout: 30000, + beforeShutdown: async () => { + await db.disconnect(); + await redis.quit(); + }, + }, +}); + +await createServer(config, routing); +``` + +## attachRouting() + +Attaches routing to an existing Express application or router. + +```typescript +import express from "express"; +import { attachRouting, createConfig } from "express-zod-api"; + +const app = express(); + +const config = createConfig({ + app, + cors: true, + logger: { level: "info" }, +}); + +const { notFoundHandler, logger } = attachRouting(config, routing); + +app.use(notFoundHandler); +app.listen(8090); +``` + +### Signature + +```typescript +function attachRouting( + config: AppConfig, + routing: Routing +): { + notFoundHandler: RequestHandler; + logger: Logger; +}; +``` + +### Returns + + + Middleware for handling 404 errors + + + + Logger instance + + +### Examples + +#### Basic Attachment + +```typescript +import express from "express"; +import { attachRouting, createConfig } from "express-zod-api"; + +const app = express(); + +// Add your own routes +app.get("/health", (req, res) => res.send("OK")); + +// Attach Express Zod API routing +const config = createConfig({ + app, + cors: true, +}); + +const { notFoundHandler } = attachRouting(config, routing); + +app.use(notFoundHandler); + +app.listen(8090, () => { + console.log("Server running on http://localhost:8090"); +}); +``` + +#### With Router + +```typescript +import express from "express"; +import { attachRouting, createConfig } from "express-zod-api"; + +const app = express(); +const apiRouter = express.Router(); + +const config = createConfig({ + app: apiRouter, + cors: true, +}); + +attachRouting(config, routing); + +app.use("/api", apiRouter); +app.listen(8090); +``` + +## Configuration Examples + +### Development Server + +```typescript +const config = createConfig({ + http: { listen: 8090 }, + cors: true, + logger: { + level: "debug", + color: true, + }, + startupLogo: true, +}); + +await createServer(config, routing); +``` + +### Production Server + +```typescript +import winston from "winston"; + +const logger = winston.createLogger({ + level: "info", + format: winston.format.json(), + transports: [ + new winston.transports.File({ filename: "error.log", level: "error" }), + new winston.transports.File({ filename: "combined.log" }), + ], +}); + +const config = createConfig({ + https: { + options: { + cert: readFileSync(process.env.SSL_CERT_PATH), + key: readFileSync(process.env.SSL_KEY_PATH), + }, + listen: 443, + }, + cors: ({ defaultHeaders }) => ({ + ...defaultHeaders, + "Access-Control-Allow-Origin": process.env.ALLOWED_ORIGIN, + }), + logger, + compression: { threshold: "1kb" }, + gracefulShutdown: { + timeout: 30000, + beforeShutdown: async () => { + await db.close(); + }, + }, + startupLogo: false, +}); + +await createServer(config, routing); +``` + +## See Also + + + + Create configuration objects + + + Define your API routes + + + Integrate with existing Express apps + + + Production deployment guide + + \ No newline at end of file diff --git a/docs/api/testing.mdx b/docs/api/testing.mdx new file mode 100644 index 000000000..e7fcb1d9a --- /dev/null +++ b/docs/api/testing.mdx @@ -0,0 +1,341 @@ +--- +title: "Testing Utilities" +description: "Test your endpoints and middleware with built-in helpers" +--- + +## Overview + +Express Zod API provides utilities for testing endpoints and middleware without starting a server. + +```typescript +import { testEndpoint, testMiddleware } from "express-zod-api"; +``` + +## testEndpoint() + +Tests an endpoint by mocking request/response objects. + +```typescript +import { testEndpoint } from "express-zod-api"; + +const { responseMock, loggerMock } = await testEndpoint({ + endpoint: getUserEndpoint, + requestProps: { + method: "GET", + query: { id: "123" }, + }, +}); + +expect(responseMock._getStatusCode()).toBe(200); +expect(responseMock._getJSONData()).toEqual({ + status: "success", + data: { id: "123", name: "John" }, +}); +``` + +### Parameters + + + The endpoint to test + + + + Mock request properties + + ```typescript + { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + query?: Record; + body?: any; + params?: Record; + headers?: Record; + files?: Record; + // ... any Express Request property + } + ``` + + + + Mock response options + + ```typescript + { + locals?: Record; + } + ``` + + + + Override config for this test + + + + Custom logger for testing + + +### Returns + + + Mocked response object with assertion methods: + + - `_getStatusCode()`: Get HTTP status code + - `_getJSONData()`: Get parsed JSON response + - `_getHeaders()`: Get response headers (lowercase) + - `_isJSON()`: Check if response is JSON + - `_isEndCalled()`: Check if response ended + - `_getRedirectUrl()`: Get redirect location + + + + Mocked logger with logs: + + - `_getLogs()`: Returns `{ debug: [], info: [], warn: [], error: [] }` + + +### Examples + +#### Testing GET Endpoint + +```typescript +import { testEndpoint } from "express-zod-api"; +import { describe, expect, test } from "vitest"; + +describe("GET /users/:id", () => { + test("should return user", async () => { + const { responseMock, loggerMock } = await testEndpoint({ + endpoint: getUserEndpoint, + requestProps: { + method: "GET", + params: { id: "123" }, + }, + }); + + expect(loggerMock._getLogs().error).toHaveLength(0); + expect(responseMock._getStatusCode()).toBe(200); + expect(responseMock._getJSONData()).toEqual({ + status: "success", + data: { + id: "123", + name: "John Doe", + email: "john@example.com", + }, + }); + }); + + test("should return 404 for missing user", async () => { + const { responseMock } = await testEndpoint({ + endpoint: getUserEndpoint, + requestProps: { + params: { id: "999" }, + }, + }); + + expect(responseMock._getStatusCode()).toBe(404); + }); +}); +``` + +#### Testing POST Endpoint + +```typescript +test("should create user", async () => { + const { responseMock } = await testEndpoint({ + endpoint: createUserEndpoint, + requestProps: { + method: "POST", + body: { + name: "Jane Doe", + email: "jane@example.com", + }, + }, + }); + + expect(responseMock._getStatusCode()).toBe(201); + expect(responseMock._getJSONData().data).toHaveProperty("id"); +}); +``` + +#### Testing File Upload + +```typescript +test("should upload file", async () => { + const mockFile = { + name: "test.pdf", + data: Buffer.from("test content"), + size: 12, + mimetype: "application/pdf", + mv: vi.fn().mockResolvedValue(undefined), + }; + + const { responseMock } = await testEndpoint({ + endpoint: uploadEndpoint, + requestProps: { + method: "POST", + files: { document: mockFile }, + }, + }); + + expect(responseMock._getStatusCode()).toBe(200); + expect(mockFile.mv).toHaveBeenCalled(); +}); +``` + +## testMiddleware() + +Tests middleware in isolation. + +```typescript +import { testMiddleware } from "express-zod-api"; + +const { output, responseMock, loggerMock } = await testMiddleware({ + middleware: authMiddleware, + requestProps: { + headers: { authorization: "Bearer token123" }, + }, +}); + +expect(output).toHaveProperty("user"); +expect(output.user.id).toBe("123"); +``` + +### Parameters + + + The middleware to test + + + + Mock request properties + + + + Mock response options + + + + Context from previous middleware + + + + Override config for this test + + + + Custom logger for testing + + +### Returns + + + Context object returned by middleware + + + + Mocked response object + + + + Mocked logger + + +### Examples + +#### Testing Auth Middleware + +```typescript +import { testMiddleware } from "express-zod-api"; +import createHttpError from "http-errors"; + +test("should authenticate valid token", async () => { + const { output, responseMock } = await testMiddleware({ + middleware: authMiddleware, + requestProps: { + headers: { authorization: "Bearer valid-token" }, + }, + }); + + expect(output.user).toBeDefined(); + expect(output.user.id).toBe("123"); +}); + +test("should reject invalid token", async () => { + const { responseMock } = await testMiddleware({ + middleware: authMiddleware, + requestProps: { + headers: { authorization: "Bearer invalid" }, + }, + }); + + expect(responseMock._getStatusCode()).toBe(401); +}); +``` + +#### Testing Middleware Chain + +```typescript +test("should pass context through chain", async () => { + // Test first middleware + const { output: firstOutput } = await testMiddleware({ + middleware: authMiddleware, + requestProps: { + headers: { authorization: "Bearer token" }, + }, + }); + + // Test second middleware with previous context + const { output: secondOutput } = await testMiddleware({ + middleware: permissionsMiddleware, + ctx: firstOutput, + }); + + expect(secondOutput).toHaveProperty("user"); + expect(secondOutput).toHaveProperty("permissions"); +}); +``` + +## Integration with Test Frameworks + +### Vitest + +```typescript +import { describe, test, expect } from "vitest"; +import { testEndpoint } from "express-zod-api"; + +describe("Users API", () => { + test("GET /users", async () => { + const { responseMock } = await testEndpoint({ + endpoint: listUsersEndpoint, + }); + expect(responseMock._getStatusCode()).toBe(200); + }); +}); +``` + +### Jest + +```typescript +import { testEndpoint } from "express-zod-api"; + +describe("Users API", () => { + it("should list users", async () => { + const { responseMock } = await testEndpoint({ + endpoint: listUsersEndpoint, + }); + expect(responseMock._getStatusCode()).toBe(200); + }); +}); +``` + +## See Also + + + + Complete testing guide + + + Create endpoints + + + Create middleware + + \ No newline at end of file diff --git a/docs/concepts/context.mdx b/docs/concepts/context.mdx new file mode 100644 index 000000000..3d94c8117 --- /dev/null +++ b/docs/concepts/context.mdx @@ -0,0 +1,460 @@ +--- +title: Context +description: Learn how to provide shared state and dependencies to your endpoints +--- + +## Overview + +Context in Express Zod API is the mechanism for passing data from middlewares to endpoint handlers. It enables dependency injection, authentication state sharing, and access to shared resources like database connections. Context is type-safe and accumulated through the middleware chain. + +## How Context Works + +Context flows through your application in this order: + +1. **Middlewares execute** in the order they were added to the factory +2. **Each middleware returns** an object that becomes part of the context +3. **Context accumulates** by merging all middleware returns +4. **Endpoint handler receives** the complete context object + +```typescript +// Middleware 1 returns { user } +// Middleware 2 returns { db } +// Middleware 3 returns { startTime } +// Handler receives ctx = { user, db, startTime } +``` + +## Providing Context via Middleware + +The most common way to provide context is through middleware: + +```typescript +import { Middleware, defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const authMiddleware = new Middleware({ + input: z.object({ + apiKey: z.string(), + }), + handler: async ({ input }) => { + const user = await db.users.findByApiKey(input.apiKey); + if (!user) throw createHttpError(401); + + // This object becomes part of ctx + return { user }; + }, +}); + +const factory = defaultEndpointsFactory + .addMiddleware(authMiddleware); + +const endpoint = factory.build({ + handler: async ({ ctx }) => { + // ctx.user is available and type-safe + return { userId: ctx.user.id }; + }, +}); +``` + +## Request-Independent Context + +For context that doesn't depend on the request, use `addContext()`: + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import { readFile } from "node:fs/promises"; + +const factory = defaultEndpointsFactory.addContext(async () => { + // Runs for every request + const config = JSON.parse( + await readFile("config.json", "utf-8") + ); + + return { config }; +}); + +const endpoint = factory.build({ + handler: async ({ ctx }) => { + // ctx.config is available + const apiUrl = ctx.config.apiUrl; + }, +}); +``` + + + `addContext()` runs on **every request**. For expensive operations like database connections, consider using singletons or connection pools instead. + + +## Type-Safe Context + +TypeScript automatically infers the context type from your middlewares: + +```typescript +const authMw = new Middleware({ + handler: async () => ({ + user: { id: 1, name: "Alice" }, + }), +}); + +const dbMw = new Middleware({ + handler: async () => ({ + db: await createDbConnection(), + }), +}); + +const factory = defaultEndpointsFactory + .addMiddleware(authMw) + .addMiddleware(dbMw); + +const endpoint = factory.build({ + handler: async ({ ctx }) => { + // TypeScript knows: + // ctx.user: { id: number; name: string } + // ctx.db: DbConnection + + const userName: string = ctx.user.name; // ✅ Type-safe + const result = await ctx.db.query(); // ✅ Autocomplete works + }, +}); +``` + +## Context Accumulation + +Context from multiple middlewares is merged: + +```typescript +const mw1 = new Middleware({ + handler: async () => ({ a: 1 }), +}); + +const mw2 = new Middleware({ + handler: async () => ({ b: 2 }), +}); + +const mw3 = new Middleware({ + handler: async () => ({ c: 3 }), +}); + +const factory = defaultEndpointsFactory + .addMiddleware(mw1) + .addMiddleware(mw2) + .addMiddleware(mw3); + +const endpoint = factory.build({ + handler: async ({ ctx }) => { + // ctx = { a: 1, b: 2, c: 3 } + return ctx; + }, +}); +``` + +## Accessing Previous Context + +Middlewares can access context from previously executed middlewares: + +```typescript +const authMiddleware = new Middleware({ + handler: async () => ({ + user: { id: 1, role: "admin" }, + }), +}); + +const permissionMiddleware = new Middleware({ + handler: async ({ ctx }) => { + // ctx.user is available from authMiddleware + if (ctx.user.role !== "admin") { + throw createHttpError(403, "Admin required"); + } + + return { isAdmin: true }; + }, +}); + +const factory = defaultEndpointsFactory + .addMiddleware(authMiddleware) + .addMiddleware(permissionMiddleware); +``` + + + Context is accumulated in order. Later middlewares can access context from earlier ones, but not vice versa. + + +## Shared Resources + +### Database Connections + +For persistent database connections, use a singleton pattern instead of creating connections per request: + +```typescript +// db.ts - Create once +import mongoose from "mongoose"; + +export const db = mongoose.connect("mongodb://localhost/mydb"); + +// endpoints.ts - Import and use +import { db } from "./db"; + +const endpoint = factory.build({ + handler: async () => { + const users = await db.collection("users").find(); + return { users }; + }, +}); +``` + +For connection pools or per-request connections: + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import { createPool } from "generic-pool"; + +const pool = createPool({ + create: async () => await createDbConnection(), + destroy: async (conn) => await conn.close(), +}, { max: 10 }); + +const factory = defaultEndpointsFactory.addContext(async () => { + const db = await pool.acquire(); + return { db }; +}); +``` + +### Configuration + +For static configuration, use imports instead of context: + +```typescript +// config.ts +export const config = { + apiUrl: process.env.API_URL, + apiKey: process.env.API_KEY, +}; + +// endpoint.ts +import { config } from "./config"; + +const endpoint = factory.build({ + handler: async () => { + // Use config directly + return { apiUrl: config.apiUrl }; + }, +}); +``` + +For dynamic configuration that changes per request: + +```typescript +const factory = defaultEndpointsFactory.addContext(async () => { + const config = await loadDynamicConfig(); + return { config }; +}); +``` + +## Resource Cleanup + +Clean up resources in a custom Result Handler: + +```typescript +import { ResultHandler, EndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const resultHandlerWithCleanup = new ResultHandler({ + positive: (output) => z.object({ data: output }), + negative: z.object({ error: z.string() }), + handler: ({ error, output, response, ctx }) => { + // Cleanup: check if db connection exists + if ("db" in ctx && ctx.db) { + ctx.db.release(); // Return to pool + // or ctx.db.close() for direct connections + } + + if (error) { + return void response.status(500).json({ error: error.message }); + } + response.json({ data: output }); + }, +}); + +const factory = new EndpointsFactory(resultHandlerWithCleanup); +``` + +## Common Patterns + +### Authentication Context + +```typescript +const authMiddleware = new Middleware({ + security: { type: "header", name: "authorization" }, + input: z.object({ + authorization: z.string().regex(/^Bearer .+$/), + }), + handler: async ({ input }) => { + const token = input.authorization.replace("Bearer ", ""); + const user = await verifyJWT(token); + + if (!user) { + throw createHttpError(401, "Invalid token"); + } + + return { + user: { + id: user.id, + email: user.email, + role: user.role, + }, + }; + }, +}); +``` + +### Request Metadata + +```typescript +const requestMetadataMiddleware = new Middleware({ + handler: async ({ request }) => { + return { + requestId: request.headers["x-request-id"] || generateId(), + startTime: Date.now(), + userAgent: request.headers["user-agent"], + ip: request.ip, + }; + }, +}); +``` + +### Feature Flags + +```typescript +const featureFlagsMiddleware = new Middleware({ + handler: async ({ ctx }) => { + const flags = await featureFlagService.getFlags(ctx.user?.id); + + return { + features: { + newUI: flags.includes("new-ui"), + betaFeatures: flags.includes("beta"), + }, + }; + }, +}); + +const endpoint = factory + .addMiddleware(authMiddleware) + .addMiddleware(featureFlagsMiddleware) + .build({ + handler: async ({ ctx }) => { + if (ctx.features.newUI) { + return { ui: "new" }; + } + return { ui: "classic" }; + }, + }); +``` + +### Tenant Isolation + +```typescript +const tenantMiddleware = new Middleware({ + input: z.object({ + tenantId: z.string(), + }), + handler: async ({ input, ctx }) => { + const tenant = await db.tenants.findById(input.tenantId); + + if (!tenant) { + throw createHttpError(404, "Tenant not found"); + } + + // Verify user has access to tenant + if (!tenant.userIds.includes(ctx.user.id)) { + throw createHttpError(403, "Access denied"); + } + + return { + tenant, + db: createTenantDb(tenant.id), + }; + }, +}); +``` + +## Context in Result Handlers + +Result handlers receive context but it may be partial if middleware execution was interrupted: + +```typescript +const resultHandler = new ResultHandler({ + positive: (output) => z.object({ data: output }), + negative: z.object({ error: z.string() }), + handler: ({ error, output, response, ctx }) => { + // ctx may be incomplete if an error occurred in middleware + // Always check for property existence + + if ("user" in ctx && ctx.user) { + response.set("X-User-Id", ctx.user.id); + } + + if (error) { + return void response.status(500).json({ error: error.message }); + } + + response.json({ data: output }); + }, +}); +``` + + + Always use the `in` operator to check if context properties exist in Result Handlers, since context may be incomplete if middleware execution was interrupted by an error. + + +## Best Practices + + + 1. **Use singletons for persistent resources**: Database connections, configuration, etc. + 2. **Keep context minimal**: Only include what endpoints actually need + 3. **Type your context**: Let TypeScript infer types from middleware returns + 4. **Order middlewares carefully**: Authentication before authorization, etc. + 5. **Clean up resources**: Use Result Handlers to release connections + 6. **Avoid expensive operations**: Don't create new database connections per request + 7. **Check property existence**: Always verify context properties exist in Result Handlers + + +## Performance Considerations + +### ❌ Anti-pattern: Creating connections per request + +```typescript +// Don't do this +const factory = defaultEndpointsFactory.addContext(async () => { + const db = await mongoose.connect("mongodb://localhost/mydb"); + return { db }; +}); +``` + +### ✅ Better: Use a singleton + +```typescript +// db.ts +export const db = mongoose.connect("mongodb://localhost/mydb"); + +// endpoint.ts +import { db } from "./db"; +``` + +### ✅ Better: Use a connection pool + +```typescript +const pool = createConnectionPool(); + +const factory = defaultEndpointsFactory.addContext(async () => { + const db = await pool.acquire(); + return { db }; +}); + +// Don't forget cleanup in Result Handler +``` + +## See Also + +- [Middleware](/concepts/middleware) - Create and chain middlewares +- [Endpoints](/concepts/endpoints) - Use context in endpoint handlers +- [Result Handlers](/concepts/result-handlers) - Clean up resources \ No newline at end of file diff --git a/docs/concepts/endpoints.mdx b/docs/concepts/endpoints.mdx new file mode 100644 index 000000000..9bdee188e --- /dev/null +++ b/docs/concepts/endpoints.mdx @@ -0,0 +1,357 @@ +--- +title: Endpoints +description: Learn how to create and configure endpoints in Express Zod API +--- + +## Overview + +Endpoints are the fundamental building blocks of your API. Each endpoint represents a route handler that validates input, executes business logic, and returns validated output. Express Zod API uses Zod schemas to ensure type safety and automatic validation at runtime. + +## Basic Endpoint Structure + +An endpoint consists of: +- **Input schema**: Validates incoming data from requests +- **Output schema**: Validates data returned by the handler +- **Handler function**: Business logic that processes input and returns output +- **Middlewares** (optional): Pre-processing logic that provides context +- **Result handler**: Formats the response consistently + +## Creating Your First Endpoint + +Use the `defaultEndpointsFactory` to create endpoints with the default result handler: + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const getUserEndpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + id: z.string(), + }), + output: z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + }), + handler: async ({ input, logger }) => { + logger.debug("Fetching user", input.id); + const user = await db.users.findById(input.id); + return user; + }, +}); +``` + +## HTTP Methods + +Specify which HTTP method(s) an endpoint accepts: + +```typescript +// Single method +const getEndpoint = factory.build({ + method: "get", + // ... +}); + +// Multiple methods +const endpoint = factory.build({ + method: ["post", "put"], + // ... +}); + +// Default is GET if not specified +const defaultGetEndpoint = factory.build({ + input: z.object({}), + output: z.object({}), + handler: async () => ({}), +}); +``` + +## Handler Function + +The handler receives validated input and returns output that gets validated against the output schema: + +```typescript +type Handler = (params: { + input: IN; // Validated input data + ctx: CTX; // Context from middlewares + logger: ActualLogger; // Configured logger instance +}) => Promise; +``` + +### Handler Parameters + + + The validated input combining data from enabled input sources (query params, body, path params, etc.) + + + + Context object provided by middlewares, containing authentication data, database connections, etc. + + + + Logger instance for recording debug information, warnings, and errors + + +## Input Sources + +Input combines data from multiple request sources based on the HTTP method: + +```typescript +// Default configuration +{ + get: ["query", "params"], + post: ["body", "params", "files"], + put: ["body", "params"], + patch: ["body", "params"], + delete: ["query", "params"], +} +``` + +Path parameters (like `:id`) must be declared in the input schema: + +```typescript +const endpoint = factory.build({ + input: z.object({ + id: z.string(), // from path parameter :id + name: z.string(), // from query or body + }), + // ... +}); + +// Used in routing as: +// { "user/:id": endpoint } +``` + +## Output Validation + +The framework validates your handler's return value against the output schema. This catches bugs early: + +```typescript +const endpoint = factory.build({ + output: z.object({ + id: z.number(), + name: z.string(), + }), + handler: async () => { + // ✅ Valid - matches schema + return { id: 1, name: "Alice" }; + + // ❌ Throws OutputValidationError - type mismatch + // return { id: "1", name: "Alice" }; + + // ❌ Throws OutputValidationError - missing field + // return { id: 1 }; + }, +}); +``` + + + If your handler returns data that doesn't match the output schema, an `OutputValidationError` is thrown with a 500 status code. This is intentional to prevent incorrect data from reaching clients. + + +## Void Endpoints + +For endpoints that don't return data, use `buildVoid()`: + +```typescript +const deleteEndpoint = factory.buildVoid({ + method: "delete", + input: z.object({ id: z.string() }), + handler: async ({ input }) => { + await db.users.delete(input.id); + // No return needed - automatically returns {} + }, +}); +``` + +## Endpoint Configuration + +### Documentation Metadata + +Add descriptions for API documentation generation: + +```typescript +const endpoint = factory.build({ + shortDescription: "Retrieves a user by ID", + description: "Fetches user details from the database including profile information.", + input: z.object({ + id: z.string().describe("The unique user identifier"), + }), + // ... +}); +``` + +### Operation ID + +Set a unique operation ID for documentation and client generation: + +```typescript +const endpoint = factory.build({ + operationId: "getUser", + // Or as a function for different methods + operationId: (method) => `${method}User`, + // ... +}); +``` + +### Tags + +Organize endpoints into groups for documentation: + +```typescript +// First, declare available tags +declare module "express-zod-api" { + interface TagOverrides { + users: unknown; + admin: unknown; + } +} + +// Then use them +const endpoint = factory.build({ + tag: "users", + // Or multiple tags + tag: ["users", "admin"], + // ... +}); +``` + +### Deprecation + +Mark endpoints as deprecated: + +```typescript +// During build +const endpoint = factory.build({ + deprecated: true, + // ... +}); + +// Or mark existing endpoint +const deprecatedEndpoint = existingEndpoint.deprecated(); +``` + +## Advanced Features + +### Transformations + +Transform input data after validation: + +```typescript +const endpoint = factory.build({ + input: z.object({ + id: z.string().transform((id) => parseInt(id, 10)), + date: z.string().transform((str) => new Date(str)), + }), + handler: async ({ input }) => { + // input.id is now a number + // input.date is now a Date object + }, +}); +``` + +### Refinements + +Add custom validation rules: + +```typescript +const endpoint = factory.build({ + input: z.object({ + password: z.string() + .min(8) + .refine( + (pwd) => /[A-Z]/.test(pwd), + "Password must contain an uppercase letter" + ), + email: z.string().email(), + }).refine( + (data) => data.email !== data.password, + "Password cannot be the same as email" + ), + // ... +}); +``` + +### Nested Endpoints + +Create nested route structures: + +```typescript +const listEndpoint = factory.build({ /* ... */ }); +const createEndpoint = factory.build({ /* ... */ }); + +// Creates both /users and /users/new +const routing = { + users: listEndpoint.nest({ + new: createEndpoint, + }), +}; +``` + +## Error Handling + +Throw HTTP errors from your handler: + +```typescript +import createHttpError from "http-errors"; + +const endpoint = factory.build({ + handler: async ({ input }) => { + const user = await db.users.findById(input.id); + + if (!user) { + throw createHttpError(404, "User not found"); + } + + if (!user.active) { + throw createHttpError(403, "User account is disabled"); + } + + return user; + }, +}); +``` + + + Errors are automatically handled by the Result Handler, which formats them consistently and sets appropriate HTTP status codes. + + +## Type Safety + +The framework ensures end-to-end type safety: + +```typescript +const endpoint = factory.build({ + input: z.object({ id: z.string() }), + output: z.object({ name: z.string() }), + handler: async ({ input }) => { + // TypeScript knows input has shape { id: string } + const id: string = input.id; // ✅ + + // Must return { name: string } + return { name: "Alice" }; // ✅ + + // TypeScript error - wrong return type + // return { id: 123 }; // ❌ + }, +}); +``` + +## Best Practices + + + 1. **Keep handlers focused**: Each endpoint should do one thing well + 2. **Use descriptive schemas**: Add `.describe()` to fields for better documentation + 3. **Validate early**: Put validation in input schemas, not handler logic + 4. **Handle errors explicitly**: Use `createHttpError` with appropriate status codes + 5. **Add examples**: Use `.example()` on schemas for documentation and testing + 6. **Leverage transformations**: Convert types at the schema level + + +## See Also + +- [Routing](/concepts/routing) - Connect endpoints to URL paths +- [Middleware](/concepts/middleware) - Add authentication and context +- [Result Handlers](/concepts/result-handlers) - Customize response format +- [Error Handling](/concepts/error-handling) - Handle errors gracefully \ No newline at end of file diff --git a/docs/concepts/error-handling.mdx b/docs/concepts/error-handling.mdx new file mode 100644 index 000000000..cb7c8cfc8 --- /dev/null +++ b/docs/concepts/error-handling.mdx @@ -0,0 +1,582 @@ +--- +title: Error Handling +description: Learn how errors are handled across different layers of your API +--- + +## Overview + +Express Zod API provides a comprehensive error handling system that catches and processes errors from validation, endpoint execution, routing, and result handling. All errors are normalized and handled consistently, ensuring reliable API responses. + +## Error Flow + +Errors are handled at three distinct layers: + +1. **Endpoint Layer**: Errors from input validation, middleware, and handler execution +2. **Routing Layer**: Errors from parsing, routing mismatches, and file uploads +3. **Result Handler Layer**: Errors from the Result Handler itself (last resort) + +```mermaid +graph TD + A[Request] --> B{Input Valid?} + B -->|No| C[InputValidationError] + B -->|Yes| D{Middleware OK?} + D -->|No| E[Error] + D -->|Yes| F{Handler OK?} + F -->|No| G[Error] + F -->|Yes| H{Output Valid?} + H -->|No| I[OutputValidationError] + H -->|Yes| J{Result Handler OK?} + J -->|No| K[Last Resort Handler] + J -->|Yes| L[Response] + + C --> M[Result Handler] + E --> M + G --> M + I --> M + M --> L +``` + +## Error Types + +### InputValidationError + +Thrown when request data doesn't match the input schema: + +```typescript +import { InputValidationError } from "express-zod-api"; + +const endpoint = factory.build({ + input: z.object({ + age: z.number(), + }), + handler: async ({ input }) => { + // If request has age: "abc", InputValidationError is thrown + }, +}); +``` + +**Default behavior:** +- Status code: `400 Bad Request` +- Includes Zod validation error details +- Handled by the endpoint's Result Handler + +### OutputValidationError + +Thrown when handler returns data that doesn't match the output schema: + +```typescript +import { OutputValidationError } from "express-zod-api"; + +const endpoint = factory.build({ + output: z.object({ + count: z.number(), + }), + handler: async () => { + // This throws OutputValidationError + return { count: "not a number" }; + }, +}); +``` + +**Default behavior:** +- Status code: `500 Internal Server Error` +- Indicates a server-side bug +- Handled by the endpoint's Result Handler + + + `OutputValidationError` means your code has a bug. The handler is returning data that doesn't match its declared output schema. + + +### HttpError + +Explicitly thrown errors with HTTP status codes: + +```typescript +import createHttpError from "http-errors"; + +const endpoint = factory.build({ + handler: async ({ input }) => { + const user = await db.users.find(input.id); + + if (!user) { + throw createHttpError(404, "User not found"); + } + + if (!user.active) { + throw createHttpError(403, "User account is disabled"); + } + + return user; + }, +}); +``` + +**Default behavior:** +- Uses the error's `statusCode` property +- Message exposure controlled by `expose` property +- Handled by the endpoint's Result Handler + +### RoutingError + +Thrown during route initialization if routing configuration is invalid: + +```typescript +import { RoutingError } from "express-zod-api"; + +// Duplicate route +const routing = { + user: getUserEndpoint, + "get /user": getUserEndpoint, // RoutingError: duplicate +}; + +// Method not supported +const getOnlyEndpoint = factory.build({ method: "get" }); +const routing2 = { + "post /users": getOnlyEndpoint, // RoutingError: unsupported method +}; +``` + +**Behavior:** +- Thrown at server startup (before handling requests) +- Application should not start with routing errors +- Includes `cause: { method, path }` + +### DocumentationError + +Thrown when generating OpenAPI documentation if schemas are incompatible: + +```typescript +import { DocumentationError } from "express-zod-api"; + +// Thrown during Documentation generation if schema issues exist +``` + +**Behavior:** +- Only occurs during documentation generation +- Indicates schema definition problems +- Includes context about which endpoint caused the issue + +### ResultHandlerError + +Thrown when the Result Handler itself fails: + +```typescript +import { ResultHandlerError } from "express-zod-api"; + +// If your Result Handler throws an error, +// it's wrapped in ResultHandlerError +``` + +**Behavior:** +- Handled by the Last Resort Handler +- Results in 500 status with plain text response +- Indicates a serious implementation error + +## Throwing Errors + +### Using createHttpError + +The recommended way to throw errors with specific status codes: + +```typescript +import createHttpError from "http-errors"; + +const endpoint = factory.build({ + handler: async ({ input }) => { + // 400 Bad Request + if (!input.email.includes("@")) { + throw createHttpError(400, "Invalid email format"); + } + + // 401 Unauthorized + if (!isAuthenticated(input.token)) { + throw createHttpError(401, "Invalid authentication token"); + } + + // 403 Forbidden + if (!hasPermission(input.userId)) { + throw createHttpError(403, "Insufficient permissions"); + } + + // 404 Not Found + const user = await db.users.find(input.id); + if (!user) { + throw createHttpError(404, "User not found"); + } + + // 409 Conflict + if (await db.users.exists({ email: input.email })) { + throw createHttpError(409, "Email already registered"); + } + + // 429 Too Many Requests + if (rateLimitExceeded(input.userId)) { + throw createHttpError(429, "Rate limit exceeded"); + } + + // 500 Internal Server Error (default) + throw createHttpError(500, "Something went wrong"); + }, +}); +``` + +### Error Exposure in Production + +Control whether error messages are shown to clients: + +```typescript +import createHttpError from "http-errors"; + +// NODE_ENV=production + +// ✅ Message shown to client (4XX errors expose by default) +throw createHttpError(401, "Invalid credentials"); +// Response: "Invalid credentials" + +// ✅ Message shown to client (explicit expose) +throw createHttpError(500, "Database connection failed", { expose: true }); +// Response: "Database connection failed" + +// ❌ Message hidden from client (5XX errors don't expose by default) +throw createHttpError(500, "Secret internal error"); +// Response: "Internal Server Error" (generic) + +// ❌ Message hidden from client (explicit) +throw createHttpError(401, "Secret auth details", { expose: false }); +// Response: "Unauthorized" (generic) +``` + + + In development mode, all error messages are exposed regardless of the `expose` property. + + +### Regular Errors + +Regular JavaScript errors are converted to 500 Internal Server Error: + +```typescript +const endpoint = factory.build({ + handler: async () => { + // Becomes 500 Internal Server Error + throw new Error("Something broke"); + + // Unhandled promise rejection -> 500 + await failingAsyncFunction(); + }, +}); +``` + +## Error Handling in Different Layers + +### In Endpoints + +Endpoint errors are handled by the endpoint's Result Handler: + +```typescript +const endpoint = factory.build({ + input: z.object({ id: z.string() }), + output: z.object({ name: z.string() }), + handler: async ({ input }) => { + // InputValidationError if input doesn't match schema + // (automatic) + + // HttpError from business logic + const item = await db.find(input.id); + if (!item) { + throw createHttpError(404, "Not found"); + } + + // OutputValidationError if return doesn't match schema + return { name: item.name }; + }, +}); + +// All errors handled by the endpoint's Result Handler +// (defaultResultHandler by default) +``` + +### In Middlewares + +Middleware errors stop the chain and go to the Result Handler: + +```typescript +const authMiddleware = new Middleware({ + input: z.object({ token: z.string() }), + handler: async ({ input }) => { + // InputValidationError if token missing/invalid type + + const user = await verifyToken(input.token); + if (!user) { + // Stops middleware chain, goes to Result Handler + throw createHttpError(401, "Invalid token"); + } + + return { user }; + }, +}); +``` + +### In Result Handlers + +Result Handler errors are handled by the Last Resort Handler: + +```typescript +const resultHandler = new ResultHandler({ + positive: (output) => z.object({ data: output }), + negative: z.object({ error: z.string() }), + handler: ({ error, output, response }) => { + if (error) { + // If this throws, Last Resort Handler catches it + const code = error.statusCode || 500; + return void response.status(code).json({ error: error.message }); + } + + // If this throws, Last Resort Handler catches it + response.json({ data: output }); + }, +}); +``` + +### Routing and Parsing Errors + +Configured via the `errorHandler` option: + +```typescript +import { createConfig, defaultResultHandler } from "express-zod-api"; + +const config = createConfig({ + errorHandler: defaultResultHandler, // or custom Result Handler +}); +``` + +Handles: +- **404 Not Found**: No matching route +- **405 Method Not Allowed**: Route exists but method not supported +- **Parsing errors**: Invalid JSON, upload issues, etc. + +## Custom Error Handler + +Create a custom Result Handler for error formatting: + +```typescript +import { ResultHandler, ensureHttpError, logServerError } from "express-zod-api"; +import { z } from "zod"; + +const customErrorHandler = new ResultHandler({ + positive: (output) => z.object({ data: output }), + negative: z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.any().optional(), + }), + }), + handler: ({ error, input, output, request, response, logger }) => { + if (error) { + const httpError = ensureHttpError(error); + + // Log server errors (5XX) + logServerError(httpError, logger, request, input); + + return void response.status(httpError.statusCode).json({ + error: { + code: httpError.name, + message: httpError.message, + details: httpError.cause, + }, + }); + } + + response.status(200).json({ data: output }); + }, +}); + +// Use in factory +const factory = new EndpointsFactory(customErrorHandler); + +// Use as global error handler +const config = createConfig({ + errorHandler: customErrorHandler, +}); +``` + +## Error Helpers + +### ensureHttpError + +Converts any error to an HttpError: + +```typescript +import { ensureHttpError } from "express-zod-api"; + +const httpError = ensureHttpError(error); +// InputValidationError -> 400 +// OutputValidationError -> 500 +// HttpError -> preserves statusCode +// Other Error -> 500 +``` + +### getMessageFromError + +Extracts error message safely: + +```typescript +import { getMessageFromError } from "express-zod-api"; + +const message = getMessageFromError(error); +// Returns error.message or "Unknown error" +``` + +### logServerError + +Logs server-side errors (5XX): + +```typescript +import { logServerError } from "express-zod-api"; + +logServerError(httpError, logger, request, input); +// Only logs if error.expose is false (server-side errors) +``` + +### getPublicErrorMessage + +Gets the message safe to show to clients: + +```typescript +import { getPublicErrorMessage } from "express-zod-api"; + +const publicMessage = getPublicErrorMessage(httpError); +// In production + !expose: generic message for status code +// Otherwise: actual error message +``` + +## Last Resort Handler + +When the Result Handler itself fails, the Last Resort Handler sends a minimal response: + +```typescript +// Response format +HTTP/1.1 500 Internal Server Error +Content-Type: text/plain + +ResultHandlerError: +``` + + + If you see Last Resort Handler responses in production, there's a critical bug in your Result Handler implementation. + + +## Best Practices + + + 1. **Use createHttpError**: Always throw `HttpError` instances with specific status codes + 2. **Validate early**: Let Zod schemas handle input validation + 3. **Be specific**: Use appropriate status codes (404, 403, 409, etc.) + 4. **Hide sensitive info**: Use `expose: false` for internal error details + 5. **Log server errors**: Use `logServerError()` for debugging + 6. **Test error paths**: Verify error handling works as expected + 7. **Document errors**: List possible error responses in API documentation + + +## Common Patterns + +### Resource Not Found + +```typescript +const user = await db.users.findById(input.id); +if (!user) { + throw createHttpError(404, "User not found"); +} +``` + +### Unauthorized Access + +```typescript +if (!isValidToken(input.token)) { + throw createHttpError(401, "Invalid or expired token"); +} +``` + +### Forbidden Operation + +```typescript +if (ctx.user.role !== "admin") { + throw createHttpError(403, "Admin privileges required"); +} +``` + +### Resource Conflict + +```typescript +const existing = await db.users.findByEmail(input.email); +if (existing) { + throw createHttpError(409, "Email already registered"); +} +``` + +### Rate Limiting + +```typescript +if (await isRateLimited(ctx.user.id)) { + throw createHttpError(429, "Too many requests, please try again later"); +} +``` + +### Validation with Context + +```typescript +const endpoint = factory.build({ + input: z.object({ + newEmail: z.string().email(), + }), + handler: async ({ input, ctx }) => { + if (input.newEmail === ctx.user.currentEmail) { + throw createHttpError(400, "New email must be different"); + } + // ... + }, +}); +``` + +## Testing Error Handling + +```typescript +import { testEndpoint } from "express-zod-api"; +import createHttpError from "http-errors"; + +test("should return 404 when user not found", async () => { + const endpoint = factory.build({ + handler: async () => { + throw createHttpError(404, "User not found"); + }, + }); + + const { responseMock } = await testEndpoint({ endpoint }); + + expect(responseMock._getStatusCode()).toBe(404); + expect(responseMock._getJSONData()).toMatchObject({ + status: "error", + error: { message: "User not found" }, + }); +}); + +test("should return 400 for invalid input", async () => { + const endpoint = factory.build({ + input: z.object({ age: z.number() }), + handler: async () => ({}), + }); + + const { responseMock } = await testEndpoint({ + endpoint, + requestProps: { body: { age: "not a number" } }, + }); + + expect(responseMock._getStatusCode()).toBe(400); +}); +``` + +## See Also + +- [Endpoints](/concepts/endpoints) - Endpoint error handling +- [Middleware](/concepts/middleware) - Middleware error handling +- [Result Handlers](/concepts/result-handlers) - Custom error formatting \ No newline at end of file diff --git a/docs/concepts/middleware.mdx b/docs/concepts/middleware.mdx new file mode 100644 index 000000000..bce4c7778 --- /dev/null +++ b/docs/concepts/middleware.mdx @@ -0,0 +1,546 @@ +--- +title: Middleware +description: Learn how to use middleware for authentication, validation, and context management +--- + +## Overview + +Middleware in Express Zod API allows you to execute code before your endpoint handler runs. Common use cases include authentication, authorization, request validation, and providing context to endpoints. Middlewares can validate their own inputs and return context objects that become available to subsequent middlewares and endpoint handlers. + +## Middleware Structure + +Middlewares consist of: +- **Input schema** (optional): Validates request data +- **Security declaration** (optional): Documents authentication requirements +- **Handler function**: Business logic that returns context + +## Creating Middleware + +Basic middleware example: + +```typescript +import { Middleware } from "express-zod-api"; +import { z } from "zod"; +import createHttpError from "http-errors"; + +const authMiddleware = new Middleware({ + input: z.object({ + apiKey: z.string().min(1), + }), + handler: async ({ input, request, logger }) => { + logger.debug("Authenticating request"); + + const user = await db.users.findByApiKey(input.apiKey); + + if (!user) { + throw createHttpError(401, "Invalid API key"); + } + + // Return context for endpoints + return { user }; + }, +}); +``` + +## Handler Function + +The middleware handler receives several parameters: + +```typescript +type Handler = (params: { + input: IN; // Validated input + ctx: CTX; // Context from previous middlewares + request: Request; // Express request object + response: Response; // Express response object + logger: ActualLogger; // Logger instance +}) => Promise; +``` + + + Validated input data from the configured input sources + + + + Accumulated context from previously executed middlewares in the chain + + + + Express request object with full access to headers, cookies, etc. + + + + Express response object for setting headers or cookies + + + + Configured logger instance + + +## Using Middleware + +Attach middleware to an endpoints factory: + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; + +const protectedFactory = defaultEndpointsFactory + .addMiddleware(authMiddleware); + +const endpoint = protectedFactory.build({ + handler: async ({ ctx }) => { + // ctx.user is available from authMiddleware + return { userId: ctx.user.id }; + }, +}); +``` + +### Inline Middleware + +Use shorthand syntax for simple middleware: + +```typescript +const factory = defaultEndpointsFactory + .addMiddleware({ + handler: async ({ request }) => { + const startTime = Date.now(); + return { startTime }; + }, + }); +``` + +## Chaining Middlewares + +Middlewares execute in the order they're added: + +```typescript +const factory = defaultEndpointsFactory + .addMiddleware(loggingMiddleware) // Runs first + .addMiddleware(authMiddleware) // Runs second, has access to logging ctx + .addMiddleware(permissionMiddleware); // Runs third, has access to both contexts + +const endpoint = factory.build({ + handler: async ({ ctx }) => { + // ctx contains all middleware returns merged: + // { ...loggingContext, ...authContext, ...permissionContext } + }, +}); +``` + +Each middleware can access context from previous middlewares: + +```typescript +const authMiddleware = new Middleware({ + handler: async () => { + return { user: { id: 1, role: "admin" } }; + }, +}); + +const permissionMiddleware = new Middleware({ + handler: async ({ ctx }) => { + // ctx.user is available from authMiddleware + if (ctx.user.role !== "admin") { + throw createHttpError(403, "Admin access required"); + } + return { isAdmin: true }; + }, +}); + +const factory = defaultEndpointsFactory + .addMiddleware(authMiddleware) + .addMiddleware(permissionMiddleware); +``` + +## Security Declaration + +Document authentication requirements for API documentation: + +```typescript +const tokenAuthMiddleware = new Middleware({ + security: { + type: "header", + name: "authorization", + }, + input: z.object({ + // Note: headers are lowercase + authorization: z.string().regex(/^Bearer .+$/), + }), + handler: async ({ input }) => { + const token = input.authorization.replace("Bearer ", ""); + const user = await verifyToken(token); + return { user }; + }, +}); +``` + +### Security Types + +```typescript +// Header-based +security: { + type: "header", + name: "x-api-key", +} + +// Input field (query param or body) +security: { + type: "input", + name: "apiKey", +} + +// Cookie +security: { + type: "cookie", + name: "session", +} + +// Combined requirements (AND) +security: { + and: [ + { type: "header", name: "authorization" }, + { type: "input", name: "apiKey" }, + ], +} + +// Alternative requirements (OR) +security: { + or: [ + { type: "header", name: "authorization" }, + { type: "cookie", name: "session" }, + ], +} +``` + +## Context Provider + +For context that doesn't depend on the request, use `addContext()`: + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import mongoose from "mongoose"; + +const factory = defaultEndpointsFactory.addContext(async () => { + // This runs for every request + const db = await mongoose.connect("mongodb://localhost/mydb"); + return { db }; +}); + +const endpoint = factory.build({ + handler: async ({ ctx }) => { + // ctx.db is available + const users = await ctx.db.collection("users").find(); + return { users }; + }, +}); +``` + + + `addContext()` creates a new connection per request. For persistent connections, use a singleton pattern and import it directly in handlers. + + +## Express Middleware + +Wrap native Express middleware: + +```typescript +import { ExpressMiddleware } from "express-zod-api"; +import { auth } from "express-oauth2-jwt-bearer"; +import createHttpError from "http-errors"; + +const oauthMiddleware = new ExpressMiddleware( + auth({ audience: "https://api.example.com" }), + { + // Optional: provide context from request + provider: (req) => ({ + auth: req.auth, + userId: req.auth.sub, + }), + + // Optional: transform errors + transformer: (err) => + createHttpError(401, err.message), + } +); + +const factory = defaultEndpointsFactory + .addExpressMiddleware(oauthMiddleware); + +// Or use the alias +const factory2 = defaultEndpointsFactory + .use(auth(), { + provider: (req) => ({ auth: req.auth }), + }); +``` + + + Express middlewares run for all HTTP methods including OPTIONS. Regular middlewares are skipped during OPTIONS (CORS preflight) requests. + + +## Input Validation + +Middlewares validate their input schemas just like endpoints: + +```typescript +const rateLimitMiddleware = new Middleware({ + input: z.object({ + // Validate specific header + "x-client-id": z.string().uuid(), + }), + handler: async ({ input, logger }) => { + const limit = await checkRateLimit(input["x-client-id"]); + + if (limit.exceeded) { + throw createHttpError(429, "Too many requests"); + } + + return { rateLimit: limit }; + }, +}); +``` + +Inputs from all middlewares are merged and available to the endpoint: + +```typescript +const mw = new Middleware({ + input: z.object({ apiKey: z.string() }), + handler: async ({ input }) => ({ user: "..." }), +}); + +const endpoint = factory + .addMiddleware(mw) + .build({ + input: z.object({ name: z.string() }), + handler: async ({ input }) => { + // input has both apiKey and name + console.log(input.apiKey, input.name); + }, + }); +``` + +## Response Manipulation + +Middlewares can set response headers or cookies: + +```typescript +const corsMiddleware = new Middleware({ + handler: async ({ response }) => { + response.set({ + "Access-Control-Allow-Origin": "*", + "X-Frame-Options": "DENY", + }); + return {}; + }, +}); + +const sessionMiddleware = new Middleware({ + handler: async ({ response }) => { + const sessionId = generateSessionId(); + response.cookie("session", sessionId, { + httpOnly: true, + secure: true, + maxAge: 3600000, + }); + return { sessionId }; + }, +}); +``` + + + If a middleware calls `response.end()` or sends a response, the chain stops. The endpoint handler won't execute. A warning is logged with accumulated context. + + +## Error Handling + +Throw HTTP errors to stop execution: + +```typescript +const requireAdminMiddleware = new Middleware({ + handler: async ({ ctx }) => { + if (!ctx.user) { + throw createHttpError(401, "Authentication required"); + } + + if (ctx.user.role !== "admin") { + throw createHttpError(403, "Admin privileges required"); + } + + return { isAdmin: true }; + }, +}); +``` + +Validation errors are automatically handled: + +```typescript +const middleware = new Middleware({ + input: z.object({ + count: z.string().transform((s) => parseInt(s, 10)), + }), + handler: async ({ input }) => { + // If input.count is "abc", InputValidationError is thrown + // Automatically returns 400 Bad Request + }, +}); +``` + +## OAuth2 Scopes + +Declare OAuth2 scopes for documentation: + +```typescript +const oauthMiddleware = new Middleware({ + security: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "https://auth.example.com/authorize", + tokenUrl: "https://auth.example.com/token", + scopes: { + "read:users": "Read user data", + "write:users": "Modify user data", + }, + }, + }, + }, + handler: async ({ request }) => { + const token = extractToken(request); + const claims = await verifyToken(token); + return { claims }; + }, +}); + +const endpoint = factory + .addMiddleware(oauthMiddleware) + .build({ + scope: "read:users", // Requires this scope + // Or multiple scopes + scope: ["read:users", "write:users"], + handler: async ({ ctx }) => { + // ctx.claims available + }, + }); +``` + +## Testing Middlewares + +Test middlewares in isolation: + +```typescript +import { testMiddleware } from "express-zod-api"; +import { z } from "zod"; + +const middleware = new Middleware({ + input: z.object({ token: z.string() }), + handler: async ({ input }) => { + const user = await verifyToken(input.token); + return { user }; + }, +}); + +test("should authenticate user", async () => { + const { output, responseMock, loggerMock } = await testMiddleware({ + middleware, + requestProps: { + method: "POST", + body: { token: "valid-token" }, + }, + }); + + expect(output.user).toBeDefined(); + expect(loggerMock._getLogs().error).toHaveLength(0); +}); +``` + +Test with accumulated context from previous middlewares: + +```typescript +const permissionMw = new Middleware({ + handler: async ({ ctx }) => { + // Expects ctx.user from previous middleware + if (ctx.user.role !== "admin") { + throw createHttpError(403); + } + return { isAdmin: true }; + }, +}); + +test("should require admin", async () => { + const { output } = await testMiddleware({ + middleware: permissionMw, + ctx: { user: { role: "admin" } }, // Mock previous context + }); + + expect(output.isAdmin).toBe(true); +}); +``` + +## Best Practices + + + 1. **Single responsibility**: Each middleware should do one thing + 2. **Order matters**: Add middlewares from general to specific (logging → auth → permissions) + 3. **Document security**: Always use `security` property for auth middlewares + 4. **Return typed context**: TypeScript infers the context shape for endpoints + 5. **Handle errors explicitly**: Throw `createHttpError` with appropriate status codes + 6. **Avoid side effects**: Don't modify global state or external resources without cleanup + 7. **Test in isolation**: Use `testMiddleware` to verify behavior + + +## Common Patterns + +### Authentication + +```typescript +const authMiddleware = new Middleware({ + security: { type: "header", name: "authorization" }, + input: z.object({ + authorization: z.string().regex(/^Bearer .+$/), + }), + handler: async ({ input }) => { + const token = input.authorization.replace("Bearer ", ""); + const user = await verifyJWT(token); + if (!user) throw createHttpError(401, "Invalid token"); + return { user }; + }, +}); +``` + +### Request Logging + +```typescript +const loggingMiddleware = new Middleware({ + handler: async ({ request, logger }) => { + const startTime = Date.now(); + logger.info("Request started", { + method: request.method, + path: request.path, + }); + return { startTime }; + }, +}); +``` + +### Rate Limiting + +```typescript +const rateLimitMiddleware = new Middleware({ + handler: async ({ request }) => { + const clientIp = request.ip; + const limit = await redis.get(`ratelimit:${clientIp}`); + + if (limit && parseInt(limit) > 100) { + throw createHttpError(429, "Too many requests"); + } + + await redis.incr(`ratelimit:${clientIp}`); + await redis.expire(`ratelimit:${clientIp}`, 3600); + + return {}; + }, +}); +``` + +## See Also + +- [Endpoints](/concepts/endpoints) - Create endpoint handlers +- [Context](/concepts/context) - Manage shared application state +- [Error Handling](/concepts/error-handling) - Handle middleware errors \ No newline at end of file diff --git a/docs/concepts/result-handlers.mdx b/docs/concepts/result-handlers.mdx new file mode 100644 index 000000000..046672863 --- /dev/null +++ b/docs/concepts/result-handlers.mdx @@ -0,0 +1,537 @@ +--- +title: Result Handlers +description: Learn how to customize API response formats and handle errors consistently +--- + +## Overview + +Result Handlers are responsible for transforming endpoint outputs and errors into HTTP responses. They ensure consistent response formatting across your API and handle both successful results and errors. Every endpoint uses a Result Handler, either the default one or a custom implementation. + +## Result Handler Structure + +A Result Handler defines: +- **Positive response schema**: How successful outputs are formatted +- **Negative response schema**: How errors are formatted +- **Handler function**: Logic that sends the actual HTTP response + +## The Default Result Handler + +The `defaultResultHandler` provides a standard response format: + +```typescript +import { defaultResultHandler } from "express-zod-api"; + +// Positive response +{ + status: "success", + data: { /* your endpoint output */ } +} + +// Negative response +{ + status: "error", + error: { message: "Error description" } +} +``` + +Status codes: +- **200** for successful responses +- **400** for input validation errors +- **500** for server errors (or error's `statusCode` if using `createHttpError`) + +## Creating Custom Result Handlers + +```typescript +import { ResultHandler } from "express-zod-api"; +import { z } from "zod"; +import { ensureHttpError, getMessageFromError } from "express-zod-api"; + +const customResultHandler = new ResultHandler({ + // Positive response schema + positive: (output) => ({ + schema: z.object({ data: output }), + mimeType: "application/json", // optional, can be array + }), + + // Negative response schema + negative: z.object({ + error: z.string(), + timestamp: z.number(), + }), + + // Handler implementation + handler: ({ error, input, output, request, response, logger, ctx }) => { + if (error) { + const { statusCode } = ensureHttpError(error); + const message = getMessageFromError(error); + + return void response.status(statusCode).json({ + error: message, + timestamp: Date.now(), + }); + } + + response.status(200).json({ data: output }); + }, +}); +``` + +## Handler Function Parameters + +The handler receives a discriminated union based on success or failure: + +```typescript +type HandlerParams = { + input: FlatObject | null; // Request input (null if parsing failed) + ctx: FlatObject; // Context from middlewares (may be partial) + request: Request; // Express request object + response: Response; // Express response object + logger: ActualLogger; // Logger instance +} & ( + | { output: FlatObject; error: null } // Success case + | { output: null; error: Error } // Error case +); +``` + + + The error if one occurred, or `null` on success. Use `ensureHttpError()` to normalize it. + + + + The validated endpoint output on success, or `null` if an error occurred. + + + + The validated input. Can be `null` if the error occurred before input validation. + + + + Context from middlewares. May be incomplete if middleware execution was interrupted. + + + + Express request object with full access to headers, cookies, etc. + + + + Express response object for sending the response. + + + + Configured logger instance for recording errors. + + +## Using Custom Result Handlers + +Create an EndpointsFactory with your Result Handler: + +```typescript +import { EndpointsFactory } from "express-zod-api"; + +const customFactory = new EndpointsFactory(customResultHandler); + +const endpoint = customFactory.build({ + input: z.object({ name: z.string() }), + output: z.object({ greeting: z.string() }), + handler: async ({ input }) => { + return { greeting: `Hello, ${input.name}!` }; + }, +}); + +// Response format determined by customResultHandler +// { data: { greeting: "Hello, Alice!" } } +``` + +## Response Schemas + +### Static Schemas + +Define a fixed response structure: + +```typescript +const resultHandler = new ResultHandler({ + positive: z.object({ + success: z.literal(true), + payload: z.any(), // Will be replaced with actual output + }), + negative: z.object({ + success: z.literal(false), + message: z.string(), + }), + handler: ({ error, output, response }) => { + if (error) { + return void response.json({ + success: false, + message: error.message, + }); + } + response.json({ success: true, payload: output }); + }, +}); +``` + +### Dynamic Schemas (Lazy) + +Generate the schema based on the endpoint's output schema: + +```typescript +const resultHandler = new ResultHandler({ + // Function receives the endpoint's output schema + positive: (output) => { + const responseSchema = z.object({ + status: z.literal("success"), + data: output, // Use the actual output schema + }); + return responseSchema; + }, + negative: z.object({ + status: z.literal("error"), + error: z.string(), + }), + handler: ({ error, output, response }) => { + if (error) { + return void response.json({ + status: "error", + error: error.message, + }); + } + response.json({ status: "success", data: output }); + }, +}); +``` + + + Using lazy schemas (functions) allows the documentation generator to know the exact response structure for each endpoint. + + +## Different Status Codes + +Customize status codes for different scenarios: + +```typescript +const resultHandler = new ResultHandler({ + positive: [ + { schema: z.object({ data: z.any() }), statusCode: 200 }, + { schema: z.object({ data: z.any() }), statusCode: 201 }, + ], + negative: [ + { schema: z.object({ error: z.string() }), statusCode: 400 }, + { schema: z.object({ error: z.string() }), statusCode: 404 }, + { schema: z.object({ error: z.string() }), statusCode: 500 }, + ], + handler: ({ error, output, response }) => { + if (error) { + const statusCode = error.statusCode || 500; + return void response.status(statusCode).json({ error: error.message }); + } + + // Choose status code based on output + const statusCode = "created" in output ? 201 : 200; + response.status(statusCode).json({ data: output }); + }, +}); +``` + +## Non-JSON Responses + +### Plain Text + +```typescript +const textResultHandler = new ResultHandler({ + positive: { + schema: z.string(), + mimeType: "text/plain" + }, + negative: { + schema: z.string(), + mimeType: "text/plain" + }, + handler: ({ error, output, response }) => { + if (error) { + return void response + .status(error.statusCode || 500) + .type("text/plain") + .send(error.message); + } + response.type("text/plain").send(output); + }, +}); +``` + +### File Downloads + +```typescript +import { ez } from "express-zod-api"; +import fs from "node:fs"; + +const fileResultHandler = new ResultHandler({ + positive: { + schema: ez.buffer(), + mimeType: "application/octet-stream" + }, + negative: { + schema: z.string(), + mimeType: "text/plain" + }, + handler: ({ error, output, response }) => { + if (error) { + return void response.status(400).send(error.message); + } + + if ("filename" in output) { + fs.createReadStream(output.filename) + .pipe(response.attachment(output.filename)); + } else { + response.status(400).send("Filename missing"); + } + }, +}); + +const factory = new EndpointsFactory(fileResultHandler); +``` + +### Empty Responses + +For 204 No Content or redirects: + +```typescript +const emptyResultHandler = new ResultHandler({ + positive: { + statusCode: 204, + mimeType: null, + schema: z.never() + }, + negative: { + statusCode: 404, + mimeType: null, + schema: z.never() + }, + handler: ({ error, response }) => { + if (error) { + return void response.status(404).end(); + } + response.status(204).end(); + }, +}); +``` + +## Error Handling + +### Normalizing Errors + +Use `ensureHttpError()` to convert any error to an HTTP error: + +```typescript +import { ensureHttpError, getMessageFromError } from "express-zod-api"; + +handler: ({ error, response }) => { + if (error) { + const httpError = ensureHttpError(error); + // httpError.statusCode is guaranteed to exist + + return void response + .status(httpError.statusCode) + .json({ error: getMessageFromError(httpError) }); + } + // ... +} +``` + +Error mappings: +- `InputValidationError` → 400 Bad Request +- `OutputValidationError` → 500 Internal Server Error +- `HttpError` → Uses error's `statusCode` +- Other errors → 500 Internal Server Error + +### Logging Errors + +Use `logServerError()` helper to log server-side errors: + +```typescript +import { logServerError, ensureHttpError } from "express-zod-api"; + +handler: ({ error, input, request, response, logger }) => { + if (error) { + const httpError = ensureHttpError(error); + logServerError(httpError, logger, request, input); + + return void response + .status(httpError.statusCode) + .json({ error: httpError.message }); + } + // ... +} +``` + +### Public vs Private Error Messages + +Control which error messages are exposed to clients: + +```typescript +import { getPublicErrorMessage } from "express-zod-api"; + +handler: ({ error, response }) => { + if (error) { + const httpError = ensureHttpError(error); + const message = getPublicErrorMessage(httpError); + // In production, 5XX errors become generic messages + // unless error.expose is true + + return void response + .status(httpError.statusCode) + .json({ error: message }); + } + // ... +} +``` + +## Resource Cleanup + +Clean up resources like database connections in the Result Handler: + +```typescript +const resultHandler = new ResultHandler({ + positive: (output) => z.object({ data: output }), + negative: z.object({ error: z.string() }), + handler: ({ error, output, response, ctx }) => { + // Cleanup: always check property exists + if ("db" in ctx && ctx.db) { + ctx.db.release(); // Return connection to pool + } + + if (error) { + return void response.status(500).json({ error: error.message }); + } + + response.json({ data: output }); + }, +}); +``` + + + Context may be incomplete if middleware execution was interrupted. Always check property existence using the `in` operator. + + +## Common Patterns + +### Standard REST API + +```typescript +const restResultHandler = new ResultHandler({ + positive: (output) => z.object({ data: output }), + negative: z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.any().optional(), + }), + }), + handler: ({ error, output, response }) => { + if (error) { + const httpError = ensureHttpError(error); + return void response.status(httpError.statusCode).json({ + error: { + code: httpError.name, + message: httpError.message, + details: httpError.cause, + }, + }); + } + response.json({ data: output }); + }, +}); +``` + +### GraphQL-style Responses + +```typescript +const graphqlResultHandler = new ResultHandler({ + positive: (output) => z.object({ + data: output, + errors: z.null(), + }), + negative: z.object({ + data: z.null(), + errors: z.array(z.object({ + message: z.string(), + path: z.array(z.string()).optional(), + })), + }), + handler: ({ error, output, response }) => { + if (error) { + return void response.json({ + data: null, + errors: [{ message: error.message }], + }); + } + response.json({ data: output, errors: null }); + }, +}); +``` + +### API with Metadata + +```typescript +const metadataResultHandler = new ResultHandler({ + positive: (output) => z.object({ + data: output, + meta: z.object({ + timestamp: z.number(), + version: z.string(), + }), + }), + negative: z.object({ error: z.string() }), + handler: ({ error, output, response }) => { + if (error) { + return void response.json({ error: error.message }); + } + response.json({ + data: output, + meta: { + timestamp: Date.now(), + version: "1.0.0", + }, + }); + }, +}); +``` + +## Array Result Handler (Legacy) + + + The `arrayResultHandler` is deprecated. It's only provided for migrating legacy APIs. Responding with arrays prevents API evolution without breaking changes. + + +```typescript +import { arrayResultHandler } from "express-zod-api"; + +// Expects endpoints with { items: T[] } in output schema +const endpoint = arrayFactory.build({ + output: z.object({ + items: z.array(z.object({ id: z.number() })), + }), + handler: async () => ({ + items: [{ id: 1 }, { id: 2 }], + }), +}); + +// Response: [{ id: 1 }, { id: 2 }] +``` + +## Best Practices + + + 1. **Be consistent**: Use one Result Handler for your entire API + 2. **Use lazy schemas**: Return functions from `positive` to get type-specific responses + 3. **Handle all error types**: Check for `InputValidationError`, `OutputValidationError`, and `HttpError` + 4. **Log server errors**: Use `logServerError()` for debugging + 5. **Clean up resources**: Release connections and cleanup in the handler + 6. **Set proper status codes**: Use `ensureHttpError()` to get appropriate codes + 7. **Hide sensitive errors**: Use `getPublicErrorMessage()` in production + + +## See Also + +- [Endpoints](/concepts/endpoints) - Create endpoints that use Result Handlers +- [Error Handling](/concepts/error-handling) - Error types and handling strategies +- [Context](/concepts/context) - Clean up context resources in handlers \ No newline at end of file diff --git a/docs/concepts/routing.mdx b/docs/concepts/routing.mdx new file mode 100644 index 000000000..0b5d06811 --- /dev/null +++ b/docs/concepts/routing.mdx @@ -0,0 +1,448 @@ +--- +title: Routing +description: Learn how to define routes and connect endpoints to URL paths +--- + +## Overview + +Routing in Express Zod API maps URL paths to endpoints. The framework supports multiple routing styles: nested objects, flat paths, method-based routing, and static file serving. All styles can be mixed within the same application. + +## Routing Interface + +The `Routing` interface defines your API's URL structure: + +```typescript +interface Routing { + [K: string]: Routing | AbstractEndpoint | ServeStatic; +} +``` + +Routes can be: +- Nested objects for hierarchical paths +- Endpoints to handle requests +- Static file servers +- Other routing objects for composition + +## Basic Routing + +### Nested Syntax + +Create hierarchical routes using nested objects: + +```typescript +import { Routing } from "express-zod-api"; + +const routing: Routing = { + v1: { + users: listUsersEndpoint, // GET /v1/users + user: { + ":id": getUserEndpoint, // GET /v1/user/:id + }, + }, +}; +``` + +### Flat Syntax + +Define complete paths as string keys: + +```typescript +const routing: Routing = { + "/v1/users": listUsersEndpoint, + "/v1/user/:id": getUserEndpoint, +}; +``` + +### Mixed Syntax + +Combine nested and flat styles: + +```typescript +const routing: Routing = { + v1: { + "/users/active": activeUsersEndpoint, // /v1/users/active + user: { + ":id": getUserEndpoint, // /v1/user/:id + }, + }, +}; +``` + +## Path Parameters + +Define dynamic path segments with colon prefix: + +```typescript +const routing: Routing = { + user: { + ":userId": { + posts: { + ":postId": getPostEndpoint, // /user/:userId/posts/:postId + }, + }, + }, +}; +``` + +Path parameters must be declared in the endpoint's input schema: + +```typescript +const getPostEndpoint = factory.build({ + input: z.object({ + userId: z.string(), // from :userId path param + postId: z.string(), // from :postId path param + }), + handler: async ({ input }) => { + const post = await db.posts.find({ + userId: input.userId, + id: input.postId, + }); + return post; + }, +}); +``` + + + Path parameters are always strings. Use `.transform()` to convert them to other types: + ```typescript + userId: z.string().transform((id) => parseInt(id, 10)) + ``` + + +## Method-Based Routing + +Handle multiple HTTP methods on the same path: + +```typescript +const routing: Routing = { + user: { + get: getUserEndpoint, // GET /user + post: createUserEndpoint, // POST /user + put: updateUserEndpoint, // PUT /user + delete: deleteUserEndpoint, // DELETE /user + }, +}; +``` + +### Explicit Method in Path + +Specify the method directly in the route key: + +```typescript +const routing: Routing = { + "get /v1/users": listUsersEndpoint, + "post /v1/users": createUserEndpoint, + "delete /v1/user/:id": deleteUserEndpoint, + + v1: { + "patch /user/:id": updateUserEndpoint, // Mixed with nested + }, +}; +``` + + + When a method is explicitly defined in the route, it overrides the endpoint's configured method. + + +## Nested Routes + +Use the `.nest()` method to create both a parent route and child routes: + +```typescript +const pathEndpoint = factory.build({ /* ... */ }); +const subpathEndpoint = factory.build({ /* ... */ }); + +const routing: Routing = { + v1: { + // Creates both /v1/path and /v1/path/subpath + path: pathEndpoint.nest({ + subpath: subpathEndpoint, + }), + }, +}; +``` + +## Static File Serving + +Serve static files using `ServeStatic`: + +```typescript +import { Routing, ServeStatic } from "express-zod-api"; + +const routing: Routing = { + // Serves files from ./public directory at /assets/* + assets: new ServeStatic("public", { + dotfiles: "deny", + index: false, + redirect: false, + }), + + // API endpoints + api: { + v1: { + users: listUsersEndpoint, + }, + }, +}; +``` + +Options are passed directly to [express.static()](https://expressjs.com/en/5x/api.html#express.static). + +## Comprehensive Example + +Combining all routing styles: + +```typescript +import { Routing, ServeStatic } from "express-zod-api"; + +const routing: Routing = { + // Flat syntax + "/v1/health": healthCheckEndpoint, + + // Nested syntax + v1: { + // Method-based routing + users: { + get: listUsersEndpoint, + post: createUserEndpoint, + }, + + // Path parameters + user: { + ":id": { + get: getUserEndpoint, + put: updateUserEndpoint, + delete: deleteUserEndpoint, + }, + }, + + // Explicit method in path + "post /auth/login": loginEndpoint, + "post /auth/logout": logoutEndpoint, + + // Nested endpoints + profile: profileEndpoint.nest({ + avatar: avatarEndpoint, + settings: settingsEndpoint, + }), + }, + + // Static files + public: new ServeStatic("assets", { + dotfiles: "deny", + index: false, + }), +}; +``` + +## Route Processing + +The framework processes routes by walking the routing tree: + +1. **Flattens paths**: Converts nested objects to flat path strings +2. **Extracts methods**: Detects explicit methods in keys or uses endpoint's configured methods +3. **Validates uniqueness**: Ensures no duplicate method+path combinations +4. **Checks compatibility**: Verifies explicit methods are supported by the endpoint +5. **Registers handlers**: Attaches endpoints to Express router + +### Route Validation + +The framework validates routes at startup: + +```typescript +// ✅ Valid - different methods +const routing = { + user: { + get: getUserEndpoint, + post: createUserEndpoint, + }, +}; + +// ❌ Throws RoutingError - duplicate route +const invalid = { + user: getUserEndpoint, + "get /user": getUserEndpoint, // Same as above! +}; + +// ❌ Throws RoutingError - method not supported +const getOnlyEndpoint = factory.build({ method: "get", /* ... */ }); +const invalid2 = { + "post /users": getOnlyEndpoint, // Endpoint only supports GET +}; +``` + +## CORS and OPTIONS + +When CORS is enabled, the framework automatically adds OPTIONS handlers: + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + cors: true, // Adds OPTIONS handler to all routes +}); +``` + +The OPTIONS handler returns: +- `Access-Control-Allow-Origin: *` +- `Access-Control-Allow-Methods`: List of methods for that path +- `Access-Control-Allow-Headers: content-type` + +## HEAD Method Support + +GET endpoints automatically support HEAD requests: + +```typescript +const endpoint = factory.build({ + method: "get", + // ... +}); + +// Automatically handles: +// GET /path +// HEAD /path +``` + +## Wrong Method Handling + +Configure how to respond when a request uses an unsupported method: + +```typescript +const config = createConfig({ + wrongMethodBehavior: 405, // Returns 405 Method Not Allowed (default) + // or + wrongMethodBehavior: 404, // Returns 404 Not Found +}); +``` + +With `405`, the response includes an `Allow` header listing valid methods: + +``` +HTTP/1.1 405 Method Not Allowed +Allow: GET, POST, OPTIONS +``` + +## Method-Like Route Behavior + +Configure how keys named after HTTP methods are interpreted: + +```typescript +const config = createConfig({ + methodLikeRouteBehavior: "method", // Treat as method (default) + // or + methodLikeRouteBehavior: "path", // Treat as path segment +}); +``` + +Effect on routing: + +```typescript +const routing = { + user: { + get: someEndpoint, + }, +}; + +// With "method": /user (GET) +// With "path": /user/get (any method) +``` + +## Initialization + +Routes are initialized when you create the server: + +```typescript +import { createServer } from "express-zod-api"; + +const { app, logger } = await createServer(config, routing); +``` + +Or attach to your own Express app: + +```typescript +import { attachRouting } from "express-zod-api"; +import express from "express"; + +const app = express(); +const { notFoundHandler, logger } = attachRouting(config, routing); + +app.use(notFoundHandler); // Optional 404 handler +app.listen(8080); +``` + +## Best Practices + + + 1. **Be consistent**: Choose one routing style and stick with it + 2. **Use semantic paths**: `/users/:id` is clearer than `/u/:i` + 3. **Group by resource**: Keep related endpoints together + 4. **Version your API**: Use `/v1/`, `/v2/` prefixes + 5. **Avoid deep nesting**: More than 3-4 levels becomes hard to maintain + 6. **Use path params wisely**: `/user/:id/posts/:postId` is clearer than `/posts/:userId/:postId` + + +## Common Patterns + +### RESTful Resource + +```typescript +const routing: Routing = { + api: { + v1: { + users: { + get: listUsers, + post: createUser, + }, + "user/:id": { + get: getUser, + put: updateUser, + patch: patchUser, + delete: deleteUser, + }, + }, + }, +}; +``` + +### Nested Resources + +```typescript +const routing: Routing = { + api: { + v1: { + "user/:userId": { + posts: { + get: listUserPosts, + post: createUserPost, + }, + "post/:postId": { + get: getUserPost, + delete: deleteUserPost, + }, + }, + }, + }, +}; +``` + +### Action-Based Routes + +```typescript +const routing: Routing = { + api: { + v1: { + auth: { + "post /login": loginEndpoint, + "post /logout": logoutEndpoint, + "post /refresh": refreshTokenEndpoint, + }, + }, + }, +}; +``` + +## See Also + +- [Endpoints](/concepts/endpoints) - Create endpoint handlers +- [Middleware](/concepts/middleware) - Add route-level logic +- [Error Handling](/concepts/error-handling) - Handle 404 and routing errors \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json new file mode 100644 index 000000000..722541ab0 --- /dev/null +++ b/docs/docs.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://mintlify.com/docs.json", + "name": "Express Zod API", + "theme": "aspen", + "colors": { + "primary": "#d69000", + "light": "#d69000", + "dark": "#d69000" + }, + "favicon": "https://media.brand.dev/11c1c6dd-fd68-4f67-9f16-fea7239e235c.png", + "navbar": { + "primary": { + "type": "github", + "href": "https://github.com/robintail/express-zod-api" + } + }, + "footer": { + "socials": { + "github": "https://github.com/robintail/express-zod-api" + } + }, + "navigation": { + "tabs": [ + { + "tab": "Documentation", + "groups": [ + { + "group": "Get Started", + "pages": [ + "introduction", + "installation", + "quickstart", + "how-it-works" + ] + }, + { + "group": "Core Concepts", + "pages": [ + "concepts/endpoints", + "concepts/routing", + "concepts/middleware", + "concepts/context", + "concepts/result-handlers", + "concepts/error-handling" + ] + }, + { + "group": "Basic Features", + "pages": [ + "features/schema-validation", + "features/refinements", + "features/transformations", + "features/dates", + "features/pagination", + "features/cors", + "features/https", + "features/compression", + "features/logging" + ] + }, + { + "group": "Advanced Features", + "pages": [ + "advanced/input-sources", + "advanced/response-customization", + "advanced/non-json-responses", + "advanced/file-uploads", + "advanced/html-forms", + "advanced/raw-data", + "advanced/testing", + "advanced/production-mode", + "advanced/graceful-shutdown" + ] + }, + { + "group": "Integration", + "pages": [ + "integration/documentation", + "integration/end-to-end-type-safety", + "integration/zod-plugin", + "integration/openapi", + "integration/express-app" + ] + }, + { + "group": "Guides", + "pages": [ + "guides/authentication", + "guides/express-middleware", + "guides/subscriptions", + "guides/migration" + ] + } + ] + }, + { + "tab": "API Reference", + "groups": [ + { + "group": "Configuration", + "pages": [ + "api/create-config", + "api/config-options" + ] + }, + { + "group": "Core Classes", + "pages": [ + "api/endpoints-factory", + "api/endpoint", + "api/middleware", + "api/result-handler", + "api/routing" + ] + }, + { + "group": "Schema Helpers", + "pages": [ + "api/ez-schemas", + "api/proprietary-schemas" + ] + }, + { + "group": "Utilities", + "pages": [ + "api/documentation", + "api/integration", + "api/server", + "api/testing" + ] + } + ] + } + ] + } +} diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 000000000..d50ceedd0 --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/features/compression.mdx b/docs/features/compression.mdx new file mode 100644 index 000000000..e9cf55f92 --- /dev/null +++ b/docs/features/compression.mdx @@ -0,0 +1,342 @@ +--- +title: Response Compression +description: Enable GZIP and Brotli compression for faster API responses +--- + +Response compression reduces the size of your API responses, improving performance and reducing bandwidth costs. Express Zod API supports GZIP and Brotli compression through the `compression` package. + +## Installation + +First, install the required packages: + +```bash +npm install compression @types/compression +# or +pnpm add compression @types/compression +# or +yarn add compression @types/compression +``` + +## Enabling Compression + +Enable compression in your configuration: + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + compression: true, // Enable with default settings +}); +``` + +That's it! Your API responses will now be compressed automatically. + +## Configuration Options + +For more control, pass configuration options: + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + compression: { + threshold: "1kb", // Only compress responses larger than 1KB + level: 6, // Compression level (0-9, default: 6) + }, +}); +``` + +### Available Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `threshold` | `string \| number` | `1024` | Minimum response size to compress (bytes or string like "1kb") | +| `level` | `number` | `6` | Compression level (0=none, 9=max, -1=default) | +| `strategy` | `number` | - | Compression strategy for GZIP | +| `chunkSize` | `number` | `16384` | Chunk size for compression | +| `memLevel` | `number` | `8` | Memory level for GZIP (1-9) | + +## How It Works + +When a client sends a request with the `Accept-Encoding` header, the server responds with compressed data: + + +```bash Request +curl -H "Accept-Encoding: br, gzip, deflate" \ + https://api.example.com/v1/users +``` + +```http Response Headers +HTTP/1.1 200 OK +Content-Type: application/json +Content-Encoding: br +Vary: Accept-Encoding +``` + + +The client automatically decompresses the response. + +## Compression Algorithms + +Express Zod API supports multiple compression algorithms: + +1. **Brotli** (`br`) - Best compression, slower +2. **GZIP** (`gzip`) - Good compression, fast +3. **Deflate** (`deflate`) - Older, less efficient + +The server automatically chooses the best algorithm based on the client's `Accept-Encoding` header. + +## What Gets Compressed? + +Only responses with **compressible content types** are compressed: + +- `application/json` ✅ +- `text/html` ✅ +- `text/plain` ✅ +- `text/css` ✅ +- `application/javascript` ✅ +- `image/jpeg` ❌ (already compressed) +- `image/png` ❌ (already compressed) +- `video/*` ❌ (already compressed) + +## Minimum Size Threshold + +Small responses aren't worth compressing due to overhead. Set a threshold: + +```ts +const config = createConfig({ + compression: { + threshold: "1kb", // Don't compress responses smaller than 1KB + }, +}); +``` + +Common threshold values: +- **`1kb`** (1024 bytes) - Default, good for most APIs +- **`512`** (512 bytes) - More aggressive +- **`2kb`** (2048 bytes) - Less aggressive + +## Compression Levels + +Balance compression ratio vs. CPU usage: + +```ts +const config = createConfig({ + compression: { + level: 6, // Default + // level: 1, // Fastest, less compression + // level: 9, // Slowest, max compression + }, +}); +``` + +| Level | Speed | Compression | Use Case | +|-------|-------|-------------|----------| +| 1 | Fastest | Minimal | High-traffic APIs, CPU-constrained | +| 6 | Balanced | Good | Default, most use cases | +| 9 | Slowest | Maximum | Low-traffic, bandwidth-constrained | + +## Real-World Example + +From the Express Zod API example: + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + compression: true, // Affects image streaming endpoint + upload: { + limits: { fileSize: 51200 }, + }, +}); +``` + +## Testing Compression + +Verify compression is working: + +```bash +# Test with curl +curl -H "Accept-Encoding: gzip" \ + -v \ + https://api.example.com/v1/users + +# Look for: +# < Content-Encoding: gzip +``` + +Or use browser DevTools: +1. Open Network tab +2. Make request to your API +3. Check response headers for `Content-Encoding: gzip` or `Content-Encoding: br` +4. Compare "Size" vs "Transferred" columns + +## Performance Impact + +### Benefits + +- **Reduced bandwidth**: 60-80% smaller responses for JSON +- **Faster transfer**: Especially on slow connections +- **Lower costs**: Reduced data transfer fees + +### Trade-offs + +- **CPU usage**: Compression takes processing time +- **Latency**: Minimal increase for compression/decompression +- **Memory**: Buffering during compression + +### Benchmarks + +Typical JSON response (10KB uncompressed): + +| Algorithm | Size | Ratio | Compression Time | +|-----------|------|-------|------------------| +| None | 10KB | - | - | +| GZIP (level 6) | 2.5KB | 75% | ~1ms | +| Brotli | 2.0KB | 80% | ~2ms | + +## When NOT to Use Compression + + +Images, videos, and PDFs are already compressed. Don't compress them again. + + + +Responses under 150 bytes may become larger when compressed due to overhead. + + + +If CPU is your bottleneck, compression may hurt more than help. + + + +Streaming with compression is complex. Consider pre-compression instead. + + +## Advanced Configuration + +### Environment-Based + +```ts +const config = createConfig({ + compression: process.env.NODE_ENV === "production" ? { + threshold: "1kb", + level: 6, + } : false, // Disable in development +}); +``` + +### Custom Filter + +While Express Zod API doesn't expose the filter directly, you can use `beforeRouting`: + +```ts +import compression from "compression"; + +const config = createConfig({ + compression: false, // Disable built-in + beforeRouting: ({ app }) => { + app.use( + compression({ + filter: (req, res) => { + // Custom logic + if (req.headers["x-no-compression"]) { + return false; + } + return compression.filter(req, res); + }, + }) + ); + }, +}); +``` + +## Monitoring Compression + +Add logging to track compression effectiveness: + +```ts +const config = createConfig({ + compression: { threshold: "1kb" }, + beforeRouting: ({ app, getLogger }) => { + const logger = getLogger(); + + app.use((req, res, next) => { + const originalWrite = res.write; + const originalEnd = res.end; + let uncompressedSize = 0; + + res.write = function (chunk: any, ...args: any[]) { + if (chunk) uncompressedSize += chunk.length; + return originalWrite.apply(res, [chunk, ...args]); + }; + + res.end = function (chunk: any, ...args: any[]) { + if (chunk) uncompressedSize += chunk.length; + + const compressed = res.getHeader("content-encoding"); + if (compressed && uncompressedSize > 0) { + logger.debug("Compressed response", { + path: req.path, + uncompressedSize, + encoding: compressed, + }); + } + + return originalEnd.apply(res, [chunk, ...args]); + }; + + next(); + }); + }, +}); +``` + +## Best Practices + + +Compression is a free performance win for most APIs. Enable it. + + + +The defaults (level 6, threshold 1KB) work well for most cases. + + + +Benchmark with your actual API responses to find optimal settings. + + + +If CPU spikes after enabling compression, reduce the level or increase threshold. + + +## Common Issues + +### Compression Not Working + +Check: +1. Client sends `Accept-Encoding: gzip, deflate, br` +2. Response is compressible (JSON, text) +3. Response size exceeds threshold +4. No other middleware interferes + +### Performance Degradation + +If compression slows your API: +1. Lower compression level (6 → 3) +2. Increase threshold (1KB → 5KB) +3. Profile to find bottleneck + +## Next Steps + + + + Configure logging for your API + + + Enable secure connections + + \ No newline at end of file diff --git a/docs/features/cors.mdx b/docs/features/cors.mdx new file mode 100644 index 000000000..331143de3 --- /dev/null +++ b/docs/features/cors.mdx @@ -0,0 +1,334 @@ +--- +title: CORS (Cross-Origin Resource Sharing) +description: Enable and configure CORS for your Express Zod API +--- + +Cross-Origin Resource Sharing (CORS) is a security mechanism that allows your API to be accessed from web pages on different domains. Express Zod API provides built-in CORS configuration. + +## Enabling CORS + +CORS is disabled by default. You must explicitly enable it in your configuration: + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + cors: true, // Enable CORS with default headers +}); +``` + + +CORS must be explicitly set to `true` or `false`. This ensures you make a conscious decision about cross-origin access to your API. + + +## Default CORS Headers + +When you set `cors: true`, the following headers are sent: + +``` +Access-Control-Allow-Origin: * +Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS +Access-Control-Allow-Headers: Content-Type, Authorization +``` + +## Custom CORS Configuration + +For more control, you can provide a function that returns custom CORS headers: + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + cors: ({ defaultHeaders, request, endpoint, logger }) => ({ + ...defaultHeaders, + "Access-Control-Allow-Origin": "https://example.com", + "Access-Control-Max-Age": "5000", + "Access-Control-Allow-Credentials": "true", + }), +}); +``` + +### Configuration Parameters + +The CORS function receives: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `defaultHeaders` | `Record` | Default CORS headers | +| `request` | `Request` | Express request object | +| `endpoint` | `AbstractEndpoint` | The matched endpoint | +| `logger` | `ActualLogger` | Logger instance | + +## Common CORS Patterns + +### Allow Specific Origin + +```ts +const config = createConfig({ + cors: ({ defaultHeaders }) => ({ + ...defaultHeaders, + "Access-Control-Allow-Origin": "https://myapp.com", + }), +}); +``` + +### Allow Multiple Origins + +```ts +const allowedOrigins = [ + "https://app.example.com", + "https://admin.example.com", + "http://localhost:3000", +]; + +const config = createConfig({ + cors: ({ defaultHeaders, request }) => { + const origin = request.headers.origin; + + if (origin && allowedOrigins.includes(origin)) { + return { + ...defaultHeaders, + "Access-Control-Allow-Origin": origin, + }; + } + + return defaultHeaders; + }, +}); +``` + +### Environment-Based Configuration + +```ts +const config = createConfig({ + cors: ({ defaultHeaders }) => { + if (process.env.NODE_ENV === "production") { + return { + ...defaultHeaders, + "Access-Control-Allow-Origin": "https://myapp.com", + }; + } + + // Allow all origins in development + return defaultHeaders; + }, +}); +``` + +### Allow Credentials + +For requests that include cookies or authentication: + +```ts +const config = createConfig({ + cors: ({ defaultHeaders }) => ({ + ...defaultHeaders, + "Access-Control-Allow-Origin": "https://myapp.com", + "Access-Control-Allow-Credentials": "true", + }), +}); +``` + + +When using `Access-Control-Allow-Credentials: true`, you cannot use `Access-Control-Allow-Origin: *`. You must specify an exact origin. + + +### Custom Allowed Headers + +```ts +const config = createConfig({ + cors: ({ defaultHeaders }) => ({ + ...defaultHeaders, + "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Api-Key", + }), +}); +``` + +### Expose Response Headers + +To make custom response headers available to the client: + +```ts +const config = createConfig({ + cors: ({ defaultHeaders }) => ({ + ...defaultHeaders, + "Access-Control-Expose-Headers": "X-Total-Count, X-Page-Number", + }), +}); +``` + +## Async CORS Configuration + +Your CORS function can be asynchronous for database lookups or external validation: + +```ts +const config = createConfig({ + cors: async ({ defaultHeaders, request, logger }) => { + const origin = request.headers.origin; + + // Check if origin is allowed + const allowed = await db.allowedOrigins.exists({ origin }); + + if (allowed) { + logger.info(`CORS: Allowed origin ${origin}`); + return { + ...defaultHeaders, + "Access-Control-Allow-Origin": origin, + }; + } + + logger.warn(`CORS: Rejected origin ${origin}`); + return defaultHeaders; + }, +}); +``` + +## Endpoint-Specific CORS + +If you need different CORS rules for specific endpoints, use middleware or response customization: + +```ts +import { Middleware } from "express-zod-api"; + +const publicMiddleware = new Middleware({ + handler: async ({ request, response }) => { + // Set CORS headers for public endpoints + response.setHeader("Access-Control-Allow-Origin", "*"); + return {}; + }, +}); + +const publicEndpointsFactory = defaultEndpointsFactory + .addMiddleware(publicMiddleware); +``` + + +The global `cors` configuration applies to all endpoints. For endpoint-specific headers, consider using [middleware](/essentials/middlewares) or [response customization](/essentials/response-customization). + + +## Preflight Requests + +Express Zod API automatically handles OPTIONS preflight requests when CORS is enabled. You don't need to configure anything special. + +## Testing CORS + +Test CORS with curl: + +```bash +# Preflight request +curl -X OPTIONS http://localhost:8090/api/users \ + -H "Origin: https://example.com" \ + -H "Access-Control-Request-Method: POST" \ + -v + +# Actual request +curl -X POST http://localhost:8090/api/users \ + -H "Origin: https://example.com" \ + -H "Content-Type: application/json" \ + -d '{"name":"John"}' \ + -v +``` + +Look for `Access-Control-*` headers in the response. + +## Common CORS Headers + +| Header | Description | +|--------|-------------| +| `Access-Control-Allow-Origin` | Which origins can access the resource | +| `Access-Control-Allow-Methods` | Which HTTP methods are allowed | +| `Access-Control-Allow-Headers` | Which request headers are allowed | +| `Access-Control-Allow-Credentials` | Whether credentials can be sent | +| `Access-Control-Max-Age` | How long preflight results can be cached | +| `Access-Control-Expose-Headers` | Which response headers can be read by client | + +## Security Considerations + + +Avoid `Access-Control-Allow-Origin: *` in production. Specify exact origins. + + + +When allowing multiple origins, validate them against a whitelist. + + + +Only enable credentials for trusted origins. Never combine with `*`. + + + +Only expose headers that clients need. Don't expose sensitive information. + + +## Complete Example + +Here's a production-ready CORS configuration: + +```ts +import { createConfig } from "express-zod-api"; + +const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || []; + +const config = createConfig({ + http: { listen: 8090 }, + cors: ({ defaultHeaders, request, logger }) => { + const origin = request.headers.origin; + + // Development: allow all + if (process.env.NODE_ENV === "development") { + return { + ...defaultHeaders, + "Access-Control-Allow-Credentials": "true", + }; + } + + // Production: whitelist only + if (origin && allowedOrigins.includes(origin)) { + logger.debug(`CORS: Allowed ${origin}`); + return { + ...defaultHeaders, + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "3600", + }; + } + + logger.warn(`CORS: Rejected ${origin}`); + return {}; // No CORS headers = request blocked + }, +}); +``` + +## Troubleshooting + +### CORS Error in Browser Console + +If you see: +``` +Access to fetch at 'http://api.example.com' from origin 'http://app.example.com' +has been blocked by CORS policy +``` + +**Solutions:** +- Ensure `cors: true` is set in config +- Check that the origin is in your allowlist +- Verify headers are being sent (use browser DevTools) + +### Credentials Not Working + +**Requirements for credentials:** +1. `Access-Control-Allow-Credentials: true` +2. `Access-Control-Allow-Origin` must be a specific origin (not `*`) +3. Client must send `credentials: 'include'` in fetch + +## Next Steps + + + + Enable HTTPS for secure connections + + + Compress responses for better performance + + \ No newline at end of file diff --git a/docs/features/dates.mdx b/docs/features/dates.mdx new file mode 100644 index 000000000..63c76c829 --- /dev/null +++ b/docs/features/dates.mdx @@ -0,0 +1,287 @@ +--- +title: Working with Dates +description: Handle dates properly in Express Zod API using ez.dateIn() and ez.dateOut() +--- + +Dates in JavaScript are notoriously difficult to work with, and JSON doesn't have a native date type. Express Zod API provides specialized schemas to handle dates correctly in both requests and responses. + +## The Date Problem + +When you return a `Date` object in JSON, it's automatically converted to an ISO 8601 string by calling `toISOString()`: + +```js +const response = { createdAt: new Date("2022-01-22") }; +JSON.stringify(response); +// {"createdAt":"2022-01-22T00:00:00.000Z"} +``` + +Similarly, dates can't be transmitted in JSON format in their original form - they must be strings. The built-in `z.date()` schema doesn't handle these conversions automatically. + +## The Solution: ez.dateIn() and ez.dateOut() + +Express Zod API provides two custom schemas for handling dates: + +- **`ez.dateIn()`** - For input: accepts ISO string, validates it, transforms to `Date` object +- **`ez.dateOut()`** - For output: accepts `Date` object, transforms to ISO string for response + +## Using ez.dateIn() + +`ez.dateIn()` accepts ISO date strings and transforms them into JavaScript `Date` objects: + +```ts +import { z } from "zod"; +import { ez, defaultEndpointsFactory } from "express-zod-api"; + +const updateUserEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + userId: z.string(), + birthday: ez.dateIn({ examples: ["1963-04-21"] }), + }), + output: z.object({ + success: z.boolean(), + }), + handler: async ({ input }) => { + // input.birthday is a Date object + console.log(input.birthday instanceof Date); // true + console.log(input.birthday.getFullYear()); // 1963 + + await db.users.update({ id: input.userId, birthday: input.birthday }); + return { success: true }; + }, +}); +``` + +## Supported Date Formats + +`ez.dateIn()` accepts several ISO 8601 date/time formats: + +```text +2021-12-31T23:59:59.000Z // Full ISO with milliseconds and timezone +2021-12-31T23:59:59Z // ISO without milliseconds +2021-12-31T23:59:59 // Local time without timezone +2021-12-31 // Date only +``` + +## Using ez.dateOut() + +`ez.dateOut()` accepts a `Date` object and transforms it to an ISO string for the response: + +```ts +import { z } from "zod"; +import { ez, defaultEndpointsFactory } from "express-zod-api"; + +const getUserEndpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + userId: z.string(), + }), + output: z.object({ + name: z.string(), + createdAt: ez.dateOut({ examples: ["2021-12-31T00:00:00.000Z"] }), + }), + handler: async ({ input }) => { + const user = await db.users.findById(input.userId); + + return { + name: user.name, + createdAt: user.createdAt, // Date object from database + }; + }, +}); +``` + +The response will contain: + +```json +{ + "status": "success", + "data": { + "name": "John Doe", + "createdAt": "2021-12-31T00:00:00.000Z" + } +} +``` + +## Complete Example + +Here's a real example from the Express Zod API source code: + +```ts +import { z } from "zod"; +import { ez, defaultEndpointsFactory } from "express-zod-api"; + +const updateUserEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + userId: z.string(), + birthday: ez.dateIn({ + description: "the day of birth", + examples: ["1963-04-21"], + }), + }), + output: z.object({ + createdAt: ez.dateOut({ + description: "account creation date", + examples: ["2021-12-31T00:00:00.000Z"], + }), + }), + handler: async ({ input }) => ({ + createdAt: new Date("2022-01-22"), + }), +}); +``` + +## Adding Metadata + +Both `ez.dateIn()` and `ez.dateOut()` accept metadata that appears in generated documentation: + +```ts +ez.dateIn({ + description: "User's date of birth", + examples: ["1990-01-15"], +}) + +ez.dateOut({ + description: "When the resource was created", + examples: ["2024-03-08T12:00:00.000Z"], +}) +``` + +## Validation with Dates + +You can add refinements to date schemas for additional validation: + +```ts +import { ez } from "express-zod-api"; +import { z } from "zod"; + +const bookingEndpoint = endpointsFactory.build({ + input: z.object({ + startDate: ez.dateIn(), + endDate: ez.dateIn(), + }) + .refine( + (data) => data.endDate > data.startDate, + "End date must be after start date", + ), + // ... +}); +``` + +## Common Date Patterns + +### Future Dates Only + +```ts +ez.dateIn().refine( + (date) => date > new Date(), + "Date must be in the future", +) +``` + +### Age Verification + +```ts +ez.dateIn().refine( + (birthDate) => { + const age = new Date().getFullYear() - birthDate.getFullYear(); + return age >= 18; + }, + "Must be at least 18 years old", +) +``` + +### Date Range + +```ts +ez.dateIn().refine( + (date) => { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + return date >= thirtyDaysAgo && date <= now; + }, + "Date must be within the last 30 days", +) +``` + +## Implementation Details + +Here's how `ez.dateIn()` works internally: + +```ts +// From express-zod-api/src/date-in-schema.ts +export const dateIn = ({ examples, ...rest } = {}) => { + const schema = z.union([ + z.iso.date(), + z.iso.datetime(), + z.iso.datetime({ local: true }), + ]); + + return schema + .meta({ examples }) + .transform((str) => new Date(str)) + .pipe(z.date()) + .brand(ezDateInBrand as symbol) + .meta(rest); +}; +``` + +And `ez.dateOut()`: + +```ts +// From express-zod-api/src/date-out-schema.ts +export const dateOut = (meta = {}) => + z + .date() + .transform((date) => date.toISOString()) + .brand(ezDateOutBrand as symbol) + .meta(meta); +``` + +## Best Practices + + +Don't use plain `z.date()` for input schemas. It won't handle JSON date strings correctly. + + + +Use `ez.dateOut()` to ensure consistent ISO string formatting in responses. + + + +Work with proper Date objects in your handlers. Only convert to/from strings at API boundaries. + + + +Add examples to help generate better API documentation and make your API easier to understand. + + +## Troubleshooting + +### "Invalid date string" Error + +Ensure the date string follows one of the supported ISO 8601 formats. Timestamps (epoch milliseconds) are not supported - convert them to ISO strings first: + +```ts +// Wrong +const timestamp = 1641000000000; + +// Right +const isoString = new Date(timestamp).toISOString(); +``` + +### Timezone Issues + +All dates are handled in ISO 8601 format, which includes timezone information. Be careful when comparing dates across different timezones. + +## Next Steps + + + + Implement paginated endpoints + + + Learn more about data transformations + + \ No newline at end of file diff --git a/docs/features/https.mdx b/docs/features/https.mdx new file mode 100644 index 000000000..44fcfc5bb --- /dev/null +++ b/docs/features/https.mdx @@ -0,0 +1,401 @@ +--- +title: Enabling HTTPS +description: Configure SSL/TLS certificates for secure HTTPS connections +--- + +HTTPS is essential for modern APIs, providing encryption and security for data in transit. Express Zod API makes it easy to configure HTTPS with TLS/SSL certificates. + +## Basic HTTPS Configuration + +To enable HTTPS, provide your certificate and key in the configuration: + +```ts +import { createConfig, createServer } from "express-zod-api"; +import fs from "node:fs"; + +const config = createConfig({ + https: { + options: { + cert: fs.readFileSync("fullchain.pem", "utf-8"), + key: fs.readFileSync("privkey.pem", "utf-8"), + }, + listen: 443, // Standard HTTPS port + }, + cors: true, +}); + +const { app, servers, logger } = await createServer(config, routing); +``` + + +You need `@types/node` installed for TypeScript support of HTTPS options. + + +## Running Both HTTP and HTTPS + +You can run HTTP and HTTPS servers simultaneously: + +```ts +import { createConfig } from "express-zod-api"; +import fs from "node:fs"; + +const config = createConfig({ + http: { + listen: 80, // HTTP on port 80 + }, + https: { + options: { + cert: fs.readFileSync("fullchain.pem", "utf-8"), + key: fs.readFileSync("privkey.pem", "utf-8"), + }, + listen: 443, // HTTPS on port 443 + }, + cors: true, +}); +``` + +This is useful for: +- Redirecting HTTP to HTTPS +- Supporting legacy clients +- Health check endpoints on HTTP + +## Certificate Options + +Express Zod API uses Node.js's HTTPS module, which supports all standard TLS options: + +```ts +import { createConfig } from "express-zod-api"; +import fs from "node:fs"; + +const config = createConfig({ + https: { + options: { + cert: fs.readFileSync("fullchain.pem", "utf-8"), + key: fs.readFileSync("privkey.pem", "utf-8"), + ca: fs.readFileSync("ca.pem", "utf-8"), // Certificate authority chain + passphrase: process.env.KEY_PASSPHRASE, // If key is encrypted + }, + listen: 443, + }, +}); +``` + +## Using UNIX Sockets + +You can use UNIX sockets instead of ports: + +```ts +const config = createConfig({ + https: { + options: { + cert: fs.readFileSync("fullchain.pem", "utf-8"), + key: fs.readFileSync("privkey.pem", "utf-8"), + }, + listen: "/var/run/api.sock", + }, +}); +``` + +## Advanced Listen Options + +For more control, use Node.js `ListenOptions`: + +```ts +import { ListenOptions } from "node:net"; + +const listenOptions: ListenOptions = { + port: 443, + host: "0.0.0.0", + backlog: 511, + exclusive: false, +}; + +const config = createConfig({ + https: { + options: { + cert: fs.readFileSync("fullchain.pem", "utf-8"), + key: fs.readFileSync("privkey.pem", "utf-8"), + }, + listen: listenOptions, + }, +}); +``` + +## Obtaining SSL Certificates + +### Let's Encrypt (Free) + +[Let's Encrypt](https://letsencrypt.org/) provides free TLS certificates: + +```bash +# Install certbot +sudo apt-get install certbot + +# Obtain certificate +sudo certbot certonly --standalone -d api.example.com + +# Certificates are saved to: +# /etc/letsencrypt/live/api.example.com/fullchain.pem +# /etc/letsencrypt/live/api.example.com/privkey.pem +``` + +Then configure Express Zod API: + +```ts +const config = createConfig({ + https: { + options: { + cert: fs.readFileSync( + "/etc/letsencrypt/live/api.example.com/fullchain.pem", + "utf-8" + ), + key: fs.readFileSync( + "/etc/letsencrypt/live/api.example.com/privkey.pem", + "utf-8" + ), + }, + listen: 443, + }, +}); +``` + +### Self-Signed Certificates (Development) + +For local development, create self-signed certificates: + +```bash +# Generate private key +openssl genrsa -out key.pem 2048 + +# Generate certificate +openssl req -new -x509 -key key.pem -out cert.pem -days 365 +``` + +```ts +const config = createConfig({ + https: { + options: { + cert: fs.readFileSync("cert.pem", "utf-8"), + key: fs.readFileSync("key.pem", "utf-8"), + }, + listen: 3443, + }, +}); +``` + + +Self-signed certificates will trigger browser warnings. Only use them for local development. + + +## Certificate Renewal + +Let's Encrypt certificates expire after 90 days. Automate renewal: + +```bash +# Test renewal +sudo certbot renew --dry-run + +# Add to crontab for automatic renewal +0 0 * * * certbot renew --quiet && systemctl restart your-api-service +``` + +Or handle graceful reload in your application: + +```ts +import { createServer } from "express-zod-api"; + +const { servers } = await createServer(config, routing); + +// Watch for certificate changes +fs.watch("/etc/letsencrypt/live/api.example.com", async () => { + // Reload certificates + const newConfig = createConfig({ + https: { + options: { + cert: fs.readFileSync("fullchain.pem", "utf-8"), + key: fs.readFileSync("privkey.pem", "utf-8"), + }, + listen: 443, + }, + }); + + // Gracefully restart (requires additional logic) +}); +``` + +## HTTP to HTTPS Redirect + +Redirect HTTP traffic to HTTPS using the `beforeRouting` hook: + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { + listen: 80, + }, + https: { + options: { + cert: fs.readFileSync("fullchain.pem", "utf-8"), + key: fs.readFileSync("privkey.pem", "utf-8"), + }, + listen: 443, + }, + beforeRouting: ({ app }) => { + app.use((req, res, next) => { + if (req.secure) { + next(); + } else { + res.redirect(301, `https://${req.headers.host}${req.url}`); + } + }); + }, +}); +``` + +## Security Headers + +Add security headers for HTTPS: + +```ts +const config = createConfig({ + https: { + options: { + cert: fs.readFileSync("fullchain.pem", "utf-8"), + key: fs.readFileSync("privkey.pem", "utf-8"), + }, + listen: 443, + }, + beforeRouting: ({ app }) => { + app.use((req, res, next) => { + // HSTS - force HTTPS for 1 year + res.setHeader( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains" + ); + next(); + }); + }, +}); +``` + +## Behind a Reverse Proxy + +If using nginx or another reverse proxy that handles SSL: + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { + listen: 3000, // Internal port, nginx forwards here + }, + beforeRouting: ({ app }) => { + // Trust proxy headers + app.set("trust proxy", true); + }, +}); +``` + +nginx configuration: + +```nginx +server { + listen 443 ssl http2; + server_name api.example.com; + + ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; + + location / { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## Testing HTTPS + +Test your HTTPS setup: + +```bash +# Basic test +curl https://api.example.com/health + +# Verbose output with certificate info +curl -v https://api.example.com/health + +# Test specific TLS version +curl --tlsv1.2 https://api.example.com/health +``` + +Online tools: +- [SSL Labs](https://www.ssllabs.com/ssltest/) - Comprehensive SSL/TLS testing +- [Why No Padlock?](https://www.whynopadlock.com/) - Check for mixed content + +## Best Practices + + +Configure Node.js to use modern, secure cipher suites. + + + +HTTP/2 improves performance. Node.js supports it with HTTPS. + + + +Let's Encrypt certificates expire every 90 days. Automate renewal. + + + +Set up monitoring to alert you before certificates expire. + + +## Common Issues + +### EACCES: Permission Denied (Port 443) + +Ports below 1024 require root privileges: + +```bash +# Option 1: Run with sudo (not recommended) +sudo node server.js + +# Option 2: Use authbind +sudo apt-get install authbind +authbind --deep node server.js + +# Option 3: Use iptables redirect +sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 3443 +``` + +### Certificate Errors + +If you see "unable to get local issuer certificate": + +```ts +// Include the full certificate chain +const config = createConfig({ + https: { + options: { + cert: fs.readFileSync("fullchain.pem", "utf-8"), // Not just cert.pem + key: fs.readFileSync("privkey.pem", "utf-8"), + }, + listen: 443, + }, +}); +``` + +## Next Steps + + + + Enable response compression + + + Configure cross-origin access + + \ No newline at end of file diff --git a/docs/features/logging.mdx b/docs/features/logging.mdx new file mode 100644 index 000000000..f5b7edc73 --- /dev/null +++ b/docs/features/logging.mdx @@ -0,0 +1,427 @@ +--- +title: Logging +description: Configure and customize logging in Express Zod API +--- + +Express Zod API includes a built-in logger with colorful output and inspection, and supports custom loggers like Winston and Pino. Logging is essential for debugging, monitoring, and understanding your API's behavior. + +## Built-in Logger + +The framework includes a console logger with sensible defaults: + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + logger: { + level: "debug", // or "info", "warn", "silent" + color: true, // Enable colorful output + depth: 2, // How deeply to inspect objects + }, +}); +``` + +## Log Levels + +The built-in logger supports four levels: + +| Level | When to Use | Default (Dev) | Default (Prod) | +|-------|-------------|---------------|----------------| +| `debug` | Detailed debugging info | ✅ | ❌ | +| `info` | General information | ✅ | ❌ | +| `warn` | Warnings, non-critical issues | ✅ | ✅ | +| `error` | Errors, critical issues | ✅ | ✅ | +| `silent` | Disable all logging | ❌ | ❌ | + +The default level is: +- **`debug`** in development (`NODE_ENV !== "production"`) +- **`warn`** in production (`NODE_ENV === "production"`) + +## Configuration Options + +### Level + +```ts +const config = createConfig({ + logger: { + level: "warn", // Only show warnings and errors + }, +}); +``` + +### Color + +Colors are auto-detected but can be forced: + +```ts +const config = createConfig({ + logger: { + color: true, // Force enable colors + // color: false, // Force disable colors + // color: undefined, // Auto-detect (default) + }, +}); +``` + +### Depth + +Control how deeply objects are inspected: + +```ts +const config = createConfig({ + logger: { + depth: 4, // Inspect 4 levels deep + // depth: null, // Unlimited depth + // depth: Infinity, // Unlimited depth + }, +}); +``` + +## Using the Logger + +The logger is available in handlers, middlewares, and result handlers: + +```ts +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const endpoint = defaultEndpointsFactory.build({ + input: z.object({ userId: z.string() }), + output: z.object({ success: z.boolean() }), + handler: async ({ input, logger }) => { + logger.debug("Fetching user", { userId: input.userId }); + + const user = await db.users.findById(input.userId); + + if (!user) { + logger.warn("User not found", { userId: input.userId }); + throw createHttpError(404, "User not found"); + } + + logger.info("User retrieved successfully", { userId: input.userId }); + return { success: true }; + }, +}); +``` + +## Log Output Format + +The built-in logger outputs in this format: + +``` +2024-03-08T12:34:56.789Z debug: Fetching user { userId: '123' } +2024-03-08T12:34:56.790Z info: User retrieved successfully { userId: '123' } +``` + +With colors enabled: +- **debug** - gray +- **info** - blue +- **warn** - yellow +- **error** - red + +## Custom Logger + +You can use any logger with `info()`, `debug()`, `error()`, and `warn()` methods: + +### Winston + +```ts +import { createConfig } from "express-zod-api"; +import winston from "winston"; + +const logger = winston.createLogger({ + level: "info", + format: winston.format.json(), + transports: [ + new winston.transports.File({ filename: "error.log", level: "error" }), + new winston.transports.File({ filename: "combined.log" }), + ], +}); + +const config = createConfig({ + http: { listen: 8090 }, + logger, // Use Winston +}); + +// Enable TypeScript support +declare module "express-zod-api" { + interface LoggerOverrides extends winston.Logger {} +} +``` + +### Pino + +```ts +import { createConfig } from "express-zod-api"; +import pino, { Logger } from "pino"; + +const logger = pino({ + transport: { + target: "pino-pretty", + options: { colorize: true }, + }, +}); + +const config = createConfig({ + http: { listen: 8090 }, + logger, +}); + +// Enable TypeScript support +declare module "express-zod-api" { + interface LoggerOverrides extends Logger {} +} +``` + +## Child Logger + +Create request-specific loggers with additional context: + +```ts +import { createConfig, BuiltinLogger } from "express-zod-api"; +import { randomUUID } from "node:crypto"; + +// Enable .child() method +declare module "express-zod-api" { + interface LoggerOverrides extends BuiltinLogger {} +} + +const config = createConfig({ + http: { listen: 8090 }, + childLoggerProvider: ({ parent, request }) => + parent.child({ + requestId: randomUUID(), + ip: request.ip, + }), +}); +``` + +Now every log includes the request ID: + +``` +2024-03-08T12:34:56.789Z abc-123-def { requestId: 'abc-123-def', ip: '192.168.1.1' } debug: Processing request +``` + +## Access Logging + +Log all incoming requests: + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + accessLogger: ({ method, path }, logger) => { + logger.debug(`${method}: ${path}`); + }, + // Or disable: + // accessLogger: null, +}); +``` + +Output: + +``` +2024-03-08T12:34:56.789Z debug: GET: /v1/users +2024-03-08T12:34:57.123Z debug: POST: /v1/users +``` + +## Profiling + +Measure execution time of operations: + +```ts +import { BuiltinLogger } from "express-zod-api"; + +// Enable .profile() method +declare module "express-zod-api" { + interface LoggerOverrides extends BuiltinLogger {} +} + +const endpoint = defaultEndpointsFactory.build({ + handler: async ({ logger }) => { + const done = logger.profile("Database query"); + + const users = await db.users.find(); + + done(); // Logs duration + + return { users }; + }, +}); +``` + +Output: + +``` +2024-03-08T12:34:56.789Z debug: Database query 123.45ms +``` + +### Advanced Profiling + +```ts +const done = logger.profile({ + message: "Expensive operation", + severity: (ms) => (ms > 1000 ? "warn" : "debug"), + formatter: (ms) => `${ms.toFixed(2)}ms`, +}); + +const result = await expensiveOperation(); + +done(); // Logs as warn if > 1000ms +``` + +## Implementation Details + +Here's the built-in logger implementation: + +```ts +import { inspect } from "node:util"; +import ansis from "ansis"; + +export class BuiltinLogger { + constructor({ + color = ansis.isSupported(), + level = isProduction() ? "warn" : "debug", + depth = 2, + ctx = {}, + } = {}) { + this.config = { color, level, depth, ctx }; + } + + protected format(subject: unknown) { + return inspect(subject, { + depth: this.config.depth, + colors: this.config.color, + breakLength: this.config.level === "debug" ? 80 : Infinity, + compact: this.config.level === "debug" ? 3 : true, + }); + } + + debug(message: string, meta?: unknown) { + this.print("debug", message, meta); + } + + info(message: string, meta?: unknown) { + this.print("info", message, meta); + } + + warn(message: string, meta?: unknown) { + this.print("warn", message, meta); + } + + error(message: string, meta?: unknown) { + this.print("error", message, meta); + } + + child(ctx: Context) { + return new BuiltinLogger({ ...this.config, ctx }); + } +} +``` + +## Best Practices + + +- `debug`: Detailed flow information +- `info`: Important events (user actions) +- `warn`: Unexpected but handled situations +- `error`: Failures requiring attention + + + +Always include relevant data with logs for easier debugging. + + + +Never log passwords, tokens, API keys, or sensitive user data. + + + +Log objects, not concatenated strings: `logger.info("User login", { userId })` not `logger.info(\`User ${userId} login\`)` + + +## Common Patterns + +### Request/Response Logging + +```ts +const endpoint = defaultEndpointsFactory.build({ + handler: async ({ input, logger }) => { + logger.debug("Request received", { input }); + + const result = await processRequest(input); + + logger.debug("Response prepared", { result }); + + return result; + }, +}); +``` + +### Error Logging + +```ts +const endpoint = defaultEndpointsFactory.build({ + handler: async ({ input, logger }) => { + try { + return await riskyOperation(input); + } catch (error) { + logger.error("Operation failed", { + error: error.message, + stack: error.stack, + input, + }); + throw error; + } + }, +}); +``` + +### Performance Logging + +```ts +const endpoint = defaultEndpointsFactory.build({ + handler: async ({ logger }) => { + const start = Date.now(); + + const result = await operation(); + + const duration = Date.now() - start; + + if (duration > 1000) { + logger.warn("Slow operation", { duration }); + } + + return result; + }, +}); +``` + +## Troubleshooting + +### No Logs Appearing + +Check: +1. Log level isn't set to `silent` +2. Level allows the messages (`warn` won't show `debug`) +3. Custom logger implements all required methods + +### Colors Not Working + +Ensure: +1. Terminal supports colors +2. `color: true` is set (or auto-detect works) +3. Output is to a TTY (colors disabled when piping) + +## Next Steps + + + + Use logger in middlewares + + + Log errors effectively + + \ No newline at end of file diff --git a/docs/features/pagination.mdx b/docs/features/pagination.mdx new file mode 100644 index 000000000..64b4f1164 --- /dev/null +++ b/docs/features/pagination.mdx @@ -0,0 +1,371 @@ +--- +title: Pagination +description: Implement offset and cursor-based pagination with ez.paginated() +--- + +Express Zod API provides built-in helpers for implementing pagination with consistent input and output schemas. The `ez.paginated()` function generates ready-to-use schemas for both offset-based and cursor-based pagination. + +## Quick Start + +```ts +import { z } from "zod"; +import { ez, defaultEndpointsFactory } from "express-zod-api"; + +const userSchema = z.object({ + id: z.number(), + name: z.string(), +}); + +const pagination = ez.paginated({ + style: "offset", + itemSchema: userSchema, + itemsName: "users", + maxLimit: 100, + defaultLimit: 20, +}); + +const listUsersEndpoint = defaultEndpointsFactory.build({ + input: pagination.input, + output: pagination.output, + handler: async ({ input: { limit, offset } }) => { + const { users, total } = await db.getUsers(limit, offset); + return { users, total, limit, offset }; + }, +}); +``` + +## Offset-Based Pagination + +Offset pagination uses `limit` and `offset` parameters to navigate through results: + +```ts +import { z } from "zod"; +import { ez, defaultEndpointsFactory } from "express-zod-api"; + +const roleSchema = z.enum(["manager", "operator", "admin"]); + +const userSchema = z.object({ + name: z.string(), + role: roleSchema, +}); + +const paginatedUsers = ez.paginated({ + style: "offset", + itemSchema: userSchema, + itemsName: "users", + maxLimit: 100, + defaultLimit: 20, +}); + +const listUsersPaginatedEndpoint = defaultEndpointsFactory.build({ + tag: "users", + shortDescription: "Lists users with pagination.", + input: paginatedUsers.input, + output: paginatedUsers.output, + handler: async ({ input: { limit, offset } }) => { + const users = await db.users.find({ skip: offset, limit }); + const total = await db.users.count(); + + return { users, total, limit, offset }; + }, +}); +``` + +### Request Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `limit` | number | `defaultLimit` | Number of items per page | +| `offset` | number | `0` | Number of items to skip | + +### Response Shape + +```json +{ + "status": "success", + "data": { + "users": [ + { "name": "Maria Merian", "role": "manager" }, + { "name": "Mary Anning", "role": "operator" } + ], + "total": 25, + "limit": 20, + "offset": 0 + } +} +``` + +## Cursor-Based Pagination + +Cursor pagination uses an opaque cursor token to navigate through results: + +```ts +const paginatedProducts = ez.paginated({ + style: "cursor", + itemSchema: productSchema, + itemsName: "products", + maxLimit: 50, + defaultLimit: 10, +}); + +const listProductsEndpoint = defaultEndpointsFactory.build({ + input: paginatedProducts.input, + output: paginatedProducts.output, + handler: async ({ input: { cursor, limit } }) => { + const { products, nextCursor } = await db.getProducts({ cursor, limit }); + + return { + products, + nextCursor, // null if no more pages + limit, + }; + }, +}); +``` + +### Request Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `cursor` | string | No | Cursor for next page; omit for first page | +| `limit` | number | No | Number of items per page (default: `defaultLimit`) | + +### Response Shape + +```json +{ + "status": "success", + "data": { + "products": [ + { "id": 1, "name": "Widget" }, + { "id": 2, "name": "Gadget" } + ], + "nextCursor": "eyJpZCI6Mn0=", + "limit": 10 + } +} +``` + +## Configuration Options + +The `ez.paginated()` function accepts the following configuration: + +```ts +interface PaginationConfig { + style: "offset" | "cursor"; // Pagination style + itemSchema: z.ZodType; // Schema for each item + itemsName?: string; // Property name for items array (default: "items") + maxLimit?: number; // Maximum page size (default: 100) + defaultLimit?: number; // Default page size (default: 20) +} +``` + +## Composing with Other Parameters + +You can combine pagination schemas with additional filters using `.and()`: + +```ts +const pagination = ez.paginated({ + style: "offset", + itemSchema: userSchema, + itemsName: "users", +}); + +const listUsersEndpoint = defaultEndpointsFactory.build({ + input: pagination.input.and( + z.object({ + roles: z.array(roleSchema).optional(), + search: z.string().optional(), + }), + ), + output: pagination.output, + handler: async ({ input: { limit, offset, roles, search } }) => { + const query = {}; + if (roles) query.role = { $in: roles }; + if (search) query.name = { $regex: search, $options: "i" }; + + const users = await db.users.find(query, { skip: offset, limit }); + const total = await db.users.count(query); + + return { users, total, limit, offset }; + }, +}); +``` + +## Complete Example + +Here's a full example from the Express Zod API source: + +```ts +import { z } from "zod"; +import { defaultEndpointsFactory, ez } from "express-zod-api"; + +const roleSchema = z.enum(["manager", "operator", "admin"]); + +const userSchema = z.object({ + name: z.string(), + role: roleSchema, +}); + +const paginatedUsers = ez.paginated({ + style: "offset", + itemSchema: userSchema, + itemsName: "users", + maxLimit: 100, + defaultLimit: 20, +}); + +const users = [ + { name: "Maria Merian", role: "manager" }, + { name: "Mary Anning", role: "operator" }, + { name: "Marie Skłodowska Curie", role: "admin" }, + { name: "Henrietta Leavitt", role: "manager" }, + { name: "Lise Meitner", role: "operator" }, + { name: "Alice Ball", role: "admin" }, + { name: "Gerty Cori", role: "manager" }, + { name: "Helen Taussig", role: "operator" }, +]; + +export const listUsersPaginatedEndpoint = defaultEndpointsFactory.build({ + tag: "users", + shortDescription: "Lists users with pagination.", + description: + "Returns a page of users. Optionally filter by roles. Uses offset-based pagination (limit and offset).", + input: paginatedUsers.input.and( + z.object({ + roles: z + .array(roleSchema) + .optional() + .describe("Filter by roles; omit for all"), + }), + ), + output: paginatedUsers.output, + handler: async ({ input: { limit, offset, roles } }) => { + const filtered = roles + ? users.filter(({ role }) => roles.includes(role)) + : users; + const total = filtered.length; + const page = filtered.slice(offset, offset + limit); + return { users: page, total, limit, offset }; + }, +}); +``` + +## Client-Side Usage + +When you generate a TypeScript client, pagination endpoints get special helper methods: + +```ts +import { Client } from "./generated-client"; + +const client = new Client(); + +// First page +const page1 = await client.provide("get /v1/users", { + limit: 20, + offset: 0, +}); + +// Check if more pages available +if (client.hasMore(page1)) { + // Fetch next page + const page2 = await client.provide("get /v1/users", { + limit: 20, + offset: 20, + }); +} +``` + +## Offset vs Cursor: Which to Use? + +### Offset Pagination + +**Pros:** +- Simple to implement +- Supports jumping to arbitrary pages +- Shows total count of items +- Familiar UX (page numbers) + +**Cons:** +- Can miss or duplicate items if data changes between requests +- Performance degrades with large offsets +- Not suitable for real-time data + +**Use when:** +- Data is relatively static +- Users need to jump to specific pages +- Total count is important +- Dataset is small to medium sized + +### Cursor Pagination + +**Pros:** +- Consistent results even if data changes +- Performs well with large datasets +- Ideal for infinite scroll +- Good for real-time data + +**Cons:** +- Can't jump to arbitrary pages +- No total count (without extra query) +- More complex to implement + +**Use when:** +- Implementing infinite scroll +- Data changes frequently +- Dataset is very large +- Page jumping isn't needed + +## Best Practices + + +Set `maxLimit` to prevent clients from requesting too much data. 100-1000 is typical. + + + +For tables with millions of rows, cursor pagination performs much better than offset. + + + +For offset pagination, always return the total count so clients can show page numbers. + + + +If using cursor pagination, document what the cursor represents (even if it's opaque). + + +## Common Issues + +### Reserved Property Names + +The `itemsName` parameter cannot conflict with pagination metadata: + +- **Offset style reserves:** `total`, `limit`, `offset` +- **Cursor style reserves:** `nextCursor`, `limit` + +```ts +// ❌ Will throw error +ez.paginated({ + style: "offset", + itemsName: "total", // Reserved! + itemSchema, +}); + +// ✅ Use a different name +ez.paginated({ + style: "offset", + itemsName: "items", + itemSchema, +}); +``` + +## Next Steps + + + + Enable cross-origin requests + + + Compress API responses + + \ No newline at end of file diff --git a/docs/features/refinements.mdx b/docs/features/refinements.mdx new file mode 100644 index 000000000..0a96df26a --- /dev/null +++ b/docs/features/refinements.mdx @@ -0,0 +1,260 @@ +--- +title: Refinements +description: Add custom validation logic to your Zod schemas in Express Zod API +--- + +Refinements allow you to implement additional validation logic beyond Zod's built-in validators. They're useful for business rules, complex constraints, and custom validation that depends on multiple fields. + +## Basic Refinements + +Use `.refine()` to add custom validation to any Zod schema: + +```ts +import { z } from "zod"; +import { Middleware } from "express-zod-api"; + +const nicknameConstraintMiddleware = new Middleware({ + input: z.object({ + nickname: z + .string() + .min(1) + .refine( + (nick) => !/^\d.*$/.test(nick), + "Nickname cannot start with a digit", + ), + }), + handler: async ({ input }) => { + // nickname is guaranteed not to start with a digit + return {}; + }, +}); +``` + +## Validation Errors + +When a refinement fails, Express Zod API returns a `400 Bad Request` with the custom error message: + + +```json Request +{ + "nickname": "123gamer" +} +``` + +```json Response (400) +{ + "status": "error", + "error": { + "message": "Nickname cannot start with a digit" + } +} +``` + + +## Multi-Field Refinements + +You can refine the entire input object to validate relationships between fields: + +```ts +const endpoint = endpointsFactory.build({ + input: z + .object({ + email: z.string().email().optional(), + id: z.string().optional(), + otherThing: z.string().optional(), + }) + .refine( + (inputs) => Object.keys(inputs).length >= 1, + "Please provide at least one property", + ), + // ... +}); +``` + +This ensures at least one field is provided, which is common for partial update endpoints. + +## Common Use Cases + +### Password Strength + +```ts +z.object({ + password: z + .string() + .min(8) + .refine( + (pwd) => /[A-Z]/.test(pwd) && /[a-z]/.test(pwd) && /[0-9]/.test(pwd), + "Password must contain uppercase, lowercase, and numbers", + ), +}) +``` + +### Date Range Validation + +```ts +import { ez } from "express-zod-api"; + +z.object({ + startDate: ez.dateIn(), + endDate: ez.dateIn(), +}) +.refine( + (data) => data.endDate > data.startDate, + "End date must be after start date", +) +``` + +### Conditional Requirements + +```ts +z.object({ + type: z.enum(["email", "phone"]), + email: z.string().email().optional(), + phone: z.string().optional(), +}) +.refine( + (data) => { + if (data.type === "email") return !!data.email; + if (data.type === "phone") return !!data.phone; + return true; + }, + "Email is required when type is 'email', phone is required when type is 'phone'", +) +``` + +### Unique Array Elements + +```ts +z.object({ + tags: z + .array(z.string()) + .refine( + (tags) => new Set(tags).size === tags.length, + "Tags must be unique", + ), +}) +``` + +## Advanced Refinements + +### Custom Error Paths + +For better error messages, you can specify which field the error relates to: + +```ts +z.object({ + password: z.string(), + confirmPassword: z.string(), +}) +.refine( + (data) => data.password === data.confirmPassword, + { + message: "Passwords don't match", + path: ["confirmPassword"], // Error points to confirmPassword field + }, +) +``` + +### Async Refinements + +Refinements can be asynchronous for database lookups or external validations: + +```ts +z.object({ + username: z + .string() + .min(3) + .refine( + async (username) => { + const exists = await db.users.findOne({ username }); + return !exists; + }, + "Username is already taken", + ), +}) +``` + + +Async refinements add latency to request validation. Use them sparingly and consider caching results when possible. + + +## Refinements in Endpoints + +You can use refinements in both input and output schemas: + +```ts +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const createUserEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + username: z + .string() + .min(3) + .max(20) + .refine( + (name) => /^[a-zA-Z0-9_]+$/.test(name), + "Username can only contain letters, numbers, and underscores", + ), + age: z + .number() + .refine( + (age) => age >= 13, + "Must be at least 13 years old", + ), + }), + output: z.object({ + id: z.string(), + username: z.string(), + age: z.number(), + }), + handler: async ({ input }) => { + // All refinements have passed + const user = await db.users.create(input); + return user; + }, +}); +``` + +## Error Handling + +When multiple refinements fail, all error messages are combined in the response: + +```json +{ + "status": "error", + "error": { + "message": "Username can only contain letters, numbers, and underscores; Must be at least 13 years old" + } +} +``` + +## Best Practices + + +Error messages should tell users exactly what's wrong and how to fix it. + + + +Use refinements for validation that can happen before hitting the database. + + + +Each refinement should check one specific thing. Multiple simple refinements are better than one complex one. + + + +Complex or async refinements add overhead. Profile your endpoints if you notice slowness. + + +## Next Steps + + + + Transform data during validation + + + Learn more about basic validation + + \ No newline at end of file diff --git a/docs/features/schema-validation.mdx b/docs/features/schema-validation.mdx new file mode 100644 index 000000000..9d7ccc785 --- /dev/null +++ b/docs/features/schema-validation.mdx @@ -0,0 +1,217 @@ +--- +title: Schema Validation +description: Use Zod schemas to validate input and output data with Express Zod API +--- + +Express Zod API uses [Zod](https://zod.dev) schemas to validate both incoming request data and outgoing response data. This ensures type safety throughout your API and prevents common bugs caused by invalid data. + +## How Validation Works + +The framework validates: +- **Input data**: Combined from request properties (`query`, `body`, `params`, `files`, `headers`) +- **Output data**: The object returned by your endpoint handler + +All validation happens automatically before your handler executes (for input) and before the response is sent (for output). + +## Defining Input Schemas + +Input schemas describe the shape of data your endpoint expects to receive: + +```ts +import { z } from "zod"; +import { defaultEndpointsFactory } from "express-zod-api"; + +const getUserEndpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + id: z.string().min(1), + includeProfile: z.boolean().optional(), + }), + output: z.object({ + id: z.string(), + name: z.string(), + }), + handler: async ({ input }) => { + // input is fully typed and validated + const { id, includeProfile } = input; + return { id, name: "John Doe" }; + }, +}); +``` + +## Defining Output Schemas + +Output schemas ensure your endpoint returns consistent, validated responses: + +```ts +const createUserEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + name: z.string().min(1), + email: z.string().email(), + }), + output: z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + createdAt: z.string(), + }), + handler: async ({ input }) => { + const user = await db.users.create(input); + return { + id: user.id, + name: user.name, + email: user.email, + createdAt: new Date().toISOString(), + }; + }, +}); +``` + +## Validation Errors + +When validation fails, the framework automatically responds with a `400 Bad Request` status and detailed error information: + + +```json Request +{ + "name": "", + "email": "not-an-email" +} +``` + +```json Response (400) +{ + "status": "error", + "error": { + "message": "Invalid input: name must be at least 1 character, email must be valid" + } +} +``` + + +## Input Sources + +By default, input is combined from different request properties based on the HTTP method: + +| Method | Sources (priority order) | +|--------|-------------------------| +| GET | `query`, `params` | +| POST | `body`, `params`, `files` | +| PUT | `body`, `params` | +| PATCH | `body`, `params` | +| DELETE | `query`, `params` | + +You can customize this in your [configuration](/essentials/configuration): + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + inputSources: { + get: ["query", "params"], + post: ["body", "params", "files"], + // customize other methods as needed + }, +}); +``` + +## Type Safety + +One of the key benefits of schema validation is automatic TypeScript type inference: + +```ts +const endpoint = defaultEndpointsFactory.build({ + input: z.object({ + userId: z.string(), + amount: z.number(), + }), + output: z.object({ + success: z.boolean(), + transactionId: z.string(), + }), + handler: async ({ input }) => { + // TypeScript knows the exact type of input + input.userId; // string + input.amount; // number + + // Return type is also validated + return { + success: true, + transactionId: "txn_123", + }; + }, +}); +``` + +## Common Validation Patterns + +### Optional Fields + +```ts +z.object({ + name: z.string(), + nickname: z.string().optional(), +}) +``` + +### Default Values + +```ts +z.object({ + page: z.number().default(1), + limit: z.number().default(20), +}) +``` + +### Arrays + +```ts +z.object({ + tags: z.array(z.string()), + userIds: z.array(z.number()).min(1), +}) +``` + +### Nested Objects + +```ts +z.object({ + user: z.object({ + name: z.string(), + address: z.object({ + street: z.string(), + city: z.string(), + }), + }), +}) +``` + +## Best Practices + + +Define only the fields your endpoint needs. Don't include fields that won't be used. + + + +Use `.describe()` to document your schemas - these descriptions appear in generated API documentation. + + + +Extract common validation patterns into reusable schema constants. + + + +For advanced validation logic, use [refinements](/features/refinements). + + +## Next Steps + + + + Add custom validation logic + + + Transform input data during validation + + \ No newline at end of file diff --git a/docs/features/transformations.mdx b/docs/features/transformations.mdx new file mode 100644 index 000000000..d1c10a110 --- /dev/null +++ b/docs/features/transformations.mdx @@ -0,0 +1,263 @@ +--- +title: Transformations +description: Transform input data during validation with Zod schemas +--- + +Transformations allow you to modify data as it flows through validation. This is especially useful for converting string parameters to numbers, parsing dates, normalizing data, and more. + +## Basic Transformations + +Use `.transform()` to convert validated data into a different format: + +```ts +import { z } from "zod"; + +const getUserEndpoint = endpointsFactory.buildVoid({ + input: z.object({ + id: z.string().transform((id) => parseInt(id, 10)), + }), + handler: async ({ input: { id }, logger }) => { + logger.debug("id", typeof id); // number + // id is now a number, not a string + }, +}); +``` + +## Why Transformations? + +Query parameters and path parameters always arrive as strings. Transformations let you convert them to the types your code expects: + +```ts +// GET /api/users?page=2&limit=50 + +const listUsersEndpoint = endpointsFactory.build({ + method: "get", + input: z.object({ + page: z.string().transform(Number), + limit: z.string().transform(Number), + }), + output: z.object({ + users: z.array(userSchema), + page: z.number(), + limit: z.number(), + }), + handler: async ({ input }) => { + // page and limit are numbers, not strings + const users = await db.users.find({ + skip: (input.page - 1) * input.limit, + limit: input.limit, + }); + return { users, page: input.page, limit: input.limit }; + }, +}); +``` + +## Common Transformation Patterns + +### String to Number + +```ts +z.object({ + id: z.string().transform((val) => parseInt(val, 10)), + price: z.string().transform((val) => parseFloat(val)), +}) +``` + + +For coercion without explicit transformation, use `z.coerce.number()` which handles both strings and numbers. + + +### String to Boolean + +```ts +z.object({ + active: z.string().transform((val) => val === "true"), +}) +``` + +### Trimming and Normalizing + +```ts +z.object({ + email: z.string().transform((val) => val.trim().toLowerCase()), + username: z.string().transform((val) => val.trim()), +}) +``` + +### Parsing JSON Strings + +```ts +z.object({ + metadata: z.string().transform((val) => JSON.parse(val)), +}) +``` + +## Combining Validation and Transformation + +You can chain validators before and after transformations: + +```ts +import { z } from "zod"; +import { defaultEndpointsFactory } from "express-zod-api"; + +const updateUserEndpoint = defaultEndpointsFactory.build({ + method: "patch", + input: z.object({ + id: z + .string() + .regex(/^\d+$/, "ID must be numeric") + .transform((id) => parseInt(id, 10)) + .refine((id) => id >= 0, "ID must be non-negative"), + }), + output: z.object({ + success: z.boolean(), + }), + handler: async ({ input }) => { + // id is validated as string, transformed to number, then validated as number + return { success: true }; + }, +}); +``` + +## Top-Level Transformations + +You can transform the entire input object, useful for renaming fields or changing naming conventions: + +```ts +import camelize from "camelize-ts"; +import { z } from "zod"; + +const endpoint = endpointsFactory.build({ + input: z + .object({ user_id: z.string() }) + .transform((inputs) => camelize(inputs, /* shallow: */ true)), + output: z.object({ + success: z.boolean(), + }), + handler: async ({ input: { userId }, logger }) => { + logger.debug("user_id became userId", userId); + return { success: true }; + }, +}); +``` + + +For output transformations that need to appear in API documentation, use [`.remap()`](/features/transformations#remapping-for-documentation) instead of `.transform()`. + + +## Remapping for Documentation + +The `.remap()` method (part of the [Zod Plugin](https://www.npmjs.com/package/@express-zod-api/zod-plugin)) transforms data while preserving schema information for documentation generation: + +```ts +import camelize from "camelize-ts"; +import snakify from "snakify-ts"; +import { z } from "zod"; + +const endpoint = endpointsFactory.build({ + input: z + .object({ user_id: z.string() }) + .transform((inputs) => camelize(inputs, /* shallow: */ true)), + output: z + .object({ userName: z.string() }) + .remap((outputs) => snakify(outputs, /* shallow: */ true)), + handler: async ({ input: { userId }, logger }) => { + logger.debug("user_id became userId", userId); + return { userName: "Agneta" }; // becomes "user_name" in response + }, +}); +``` + +### Custom Field Mapping + +You can also use `.remap()` with explicit field mappings: + +```ts +z.object({ user_name: z.string(), id: z.number() }).remap({ + user_name: "weHAVEreallyWEIRDnamingSTANDARDS", + // "id" remains intact (partial mapping) +}); +``` + +## Real-World Example + +Here's a complete example from the Express Zod API source showing transformation in action: + +```ts +import { z } from "zod"; +import { ez, defaultEndpointsFactory } from "express-zod-api"; + +const updateUserEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + // Path parameter arrives as string + id: z + .string() + .example("12") + .transform((value) => parseInt(value, 10)) + .refine((value) => value >= 0, "should be greater than or equal to 0"), + name: z.string().nonempty(), + birthday: ez.dateIn(), // Transforms ISO string to Date + }), + output: z.object({ + name: z.string(), + createdAt: ez.dateOut(), // Transforms Date to ISO string + }), + handler: async ({ input }) => { + // input.id is a number + // input.birthday is a Date + return { + name: input.name, + createdAt: new Date("2022-01-22"), + }; + }, +}); +``` + +## Type Safety with Transformations + +TypeScript automatically infers the transformed types: + +```ts +const schema = z.object({ + id: z.string().transform(Number), + tags: z.string().transform((s) => s.split(",")), +}); + +type Input = z.input; +// { id: string; tags: string } + +type Output = z.output; +// { id: number; tags: string[] } +``` + +In your handler, `input` has the output type with all transformations applied. + +## Best Practices + + +Transformations in schemas are validated and documented. Avoid manual transformations in handlers. + + + +Complex logic should go in the handler, not in transformations. + + + +`z.coerce.number()` is cleaner than `.transform(Number)` for simple type coercion. + + + +Add validation both before transformation (on the raw input) and after (on the transformed value). + + +## Next Steps + + + + Learn about date handling with ez.dateIn() and ez.dateOut() + + + Add custom validation logic + + \ No newline at end of file diff --git a/docs/guides/authentication.mdx b/docs/guides/authentication.mdx new file mode 100644 index 000000000..f6994e5ae --- /dev/null +++ b/docs/guides/authentication.mdx @@ -0,0 +1,360 @@ +--- +title: Authentication +description: Implement secure authentication patterns using Middlewares +--- + +## Overview + +Express Zod API provides a powerful middleware system for implementing authentication. Middlewares can validate credentials from various input sources and provide authenticated context to your endpoints. + +## Basic Authentication Middleware + +Authentication middlewares check credentials and return user information that becomes available as `ctx` to your endpoints. + +### API Key and Token Authentication + +Here's a complete example that validates both an API key from the input and a token from headers: + +```ts +import { z } from "zod"; +import createHttpError from "http-errors"; +import { Middleware } from "express-zod-api"; + +const authMiddleware = new Middleware({ + security: { + and: [ + { type: "input", name: "key" }, + { type: "header", name: "token" }, + ], + }, + input: z.object({ + key: z.string().min(1), + }), + handler: async ({ input: { key }, request, logger }) => { + logger.debug("Checking the key and token"); + + // Validate API key + const user = await db.Users.findOne({ key }); + if (!user) { + throw createHttpError(401, "Invalid key"); + } + + // Validate token from headers + if (request.headers.token !== user.token) { + throw createHttpError(401, "Invalid token"); + } + + // Return user context to endpoints + return { user }; + }, +}); +``` + + + The `security` property is optional but recommended—it helps generate proper API documentation. + + +## Using Authentication Middleware + +Connect the middleware to your endpoints using `.addMiddleware()`: + +```ts +import { defaultEndpointsFactory } from "express-zod-api"; + +const protectedEndpoint = defaultEndpointsFactory + .addMiddleware(authMiddleware) + .build({ + input: z.object({ + data: z.string(), + }), + output: z.object({ + result: z.string(), + userId: z.string(), + }), + handler: async ({ input, ctx: { user } }) => { + // user is available from authMiddleware + return { + result: `Processed: ${input.data}`, + userId: user.id, + }; + }, + }); +``` + +## Creating an Authenticated Factory + +For multiple endpoints requiring authentication, create a dedicated factory: + +```ts +const authenticatedFactory = defaultEndpointsFactory + .addMiddleware(authMiddleware); + +// All endpoints built with this factory are authenticated +const getUserEndpoint = authenticatedFactory.build({ + method: "get", + input: z.object({ id: z.string() }), + output: z.object({ name: z.string(), email: z.string() }), + handler: async ({ input, ctx: { user } }) => { + // user is always available + return await db.Users.findById(input.id); + }, +}); +``` + +## Headers as Input Source + +To validate headers directly in your input schema, enable them in your configuration: + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + inputSources: { + get: ["headers", "query", "params"], // headers have lowest priority + post: ["headers", "body", "params", "files"], + }, +}); +``` + +Then use headers in your middleware: + +```ts +const headerAuthMiddleware = new Middleware({ + security: { type: "header", name: "authorization" }, + input: z.object({ + authorization: z.string().min(1), // lowercase! + }), + handler: async ({ input: { authorization } }) => { + const token = authorization.replace("Bearer ", ""); + const user = await verifyToken(token); + if (!user) throw createHttpError(401, "Unauthorized"); + return { user }; + }, +}); +``` + + + Request headers are always lowercase when used as input sources. + + +## Bearer Token Authentication + +Implement standard Bearer token authentication: + +```ts +const bearerAuthMiddleware = new Middleware({ + security: { type: "header", name: "authorization" }, + input: z.object({ + authorization: z + .string() + .regex(/^Bearer \S+$/, "Must be a valid Bearer token"), + }), + handler: async ({ input: { authorization }, logger }) => { + const token = authorization.substring(7); // Remove "Bearer " + + try { + const payload = await jwt.verify(token, process.env.JWT_SECRET); + const user = await db.Users.findById(payload.userId); + + if (!user) { + throw createHttpError(401, "User not found"); + } + + logger.info(`Authenticated user: ${user.id}`); + return { user, token }; + } catch (error) { + throw createHttpError(401, "Invalid or expired token"); + } + }, +}); +``` + +## Role-Based Access Control + +Implement role checking with chained middlewares: + +```ts +const requireRole = (requiredRole: string) => + new Middleware({ + handler: async ({ ctx: { user } }) => { + if (user.role !== requiredRole) { + throw createHttpError(403, "Insufficient permissions"); + } + return {}; // No additional context + }, + }); + +// Use it: +const adminFactory = authenticatedFactory + .addMiddleware(requireRole("admin")); + +const deleteUserEndpoint = adminFactory.build({ + method: "delete", + input: z.object({ id: z.string() }), + output: z.object({ success: z.boolean() }), + handler: async ({ input, ctx: { user } }) => { + await db.Users.delete(input.id); + return { success: true }; + }, +}); +``` + +## API Key Authentication + +Simple API key validation: + +```ts +const apiKeyMiddleware = new Middleware({ + security: { type: "input", name: "apiKey" }, + input: z.object({ + apiKey: z.string().uuid(), + }), + handler: async ({ input: { apiKey }, logger }) => { + const client = await db.ApiKeys.findOne({ + key: apiKey, + active: true + }); + + if (!client) { + throw createHttpError(401, "Invalid API key"); + } + + // Update last used timestamp + await db.ApiKeys.updateOne( + { _id: client._id }, + { $set: { lastUsed: new Date() } } + ); + + logger.info(`API request from client: ${client.name}`); + return { client }; + }, +}); +``` + +## Multiple Authentication Strategies + +Support multiple authentication methods: + +```ts +const flexibleAuthMiddleware = new Middleware({ + input: z.object({ + authorization: z.string().optional(), + apiKey: z.string().optional(), + }), + handler: async ({ input, request }) => { + // Try Bearer token first + if (input.authorization?.startsWith("Bearer ")) { + const token = input.authorization.substring(7); + const user = await verifyJWT(token); + if (user) return { user }; + } + + // Try API key + if (input.apiKey) { + const client = await db.ApiKeys.findOne({ key: input.apiKey }); + if (client) return { client }; + } + + throw createHttpError(401, "Authentication required"); + }, +}); +``` + +## Session-Based Authentication + +Integrate with Express sessions: + +```ts +import session from "express-session"; +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + beforeRouting: ({ app }) => { + app.use(session({ + secret: process.env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + })); + }, +}); + +const sessionAuthMiddleware = new Middleware({ + handler: async ({ request }) => { + if (!request.session?.userId) { + throw createHttpError(401, "Please log in"); + } + + const user = await db.Users.findById(request.session.userId); + if (!user) { + throw createHttpError(401, "Session invalid"); + } + + return { user }; + }, +}); +``` + +## Best Practices + + + + Always specify the `security` property in your authentication middleware. This generates proper documentation and helps API consumers understand authentication requirements. + + + + Check not just the presence but also the format and validity of credentials. Use Zod refinements for complex validation rules. + + + + Log both successful and failed authentication attempts for security auditing. Include relevant context but never log sensitive credentials. + + + + Use appropriate HTTP status codes: 401 for authentication failures, 403 for authorization failures. Use `createHttpError` for consistent error handling. + + + + When multiple endpoints share authentication requirements, create a factory with the middleware attached rather than adding it to each endpoint. + + + +## Testing Authentication + +Test your authentication middleware: + +```ts +import { testMiddleware } from "express-zod-api"; + +describe("authMiddleware", () => { + test("should authenticate valid credentials", async () => { + const { output, loggerMock } = await testMiddleware({ + middleware: authMiddleware, + requestProps: { + body: { key: "valid-key" }, + headers: { token: "valid-token" }, + }, + }); + + expect(loggerMock._getLogs().error).toHaveLength(0); + expect(output).toHaveProperty("user"); + }); + + test("should reject invalid key", async () => { + const { responseMock } = await testMiddleware({ + middleware: authMiddleware, + requestProps: { + body: { key: "invalid" }, + headers: { token: "valid-token" }, + }, + }); + + expect(responseMock._getStatusCode()).toBe(401); + }); +}); +``` + +## Next Steps + +- Learn about [Express Middleware Integration](/guides/express-middleware) for using third-party auth libraries +- Explore [Advanced Features](/essentials/advanced-features) for custom result handlers +- See [Testing](/essentials/testing) for comprehensive endpoint testing \ No newline at end of file diff --git a/docs/guides/express-middleware.mdx b/docs/guides/express-middleware.mdx new file mode 100644 index 000000000..16ec563de --- /dev/null +++ b/docs/guides/express-middleware.mdx @@ -0,0 +1,479 @@ +--- +title: Express Middleware Integration +description: Integrate native Express middlewares with Express Zod API +--- + +## Overview + +Express Zod API provides two approaches for integrating native Express middlewares, depending on their purpose and scope. This guide shows you how to use both methods effectively. + +## Two Integration Methods + +### 1. Global Middlewares with `beforeRouting` + +Use this for middlewares that: +- Establish their own routes (like Swagger UI) +- Globally modify behavior +- Parse additional request formats (like cookies) +- Need to run before all API routes + +### 2. Endpoint-Specific with `addExpressMiddleware()` + +Use this for middlewares that: +- Process specific endpoints +- Need to provide context to handlers +- Require error transformation +- Should be tested with your endpoints + +## Global Middlewares: `beforeRouting` + +The `beforeRouting` option runs code before your routing is established. + +### Basic Usage + +```ts +import { createConfig } from "express-zod-api"; +import cookieParser from "cookie-parser"; + +const config = createConfig({ + beforeRouting: ({ app, getLogger }) => { + const logger = getLogger(); + + // Add cookie parser + app.use(cookieParser()); + + logger.info("Cookie parser enabled"); + }, +}); +``` + +### Serving Documentation + +Serve your API documentation using Swagger UI: + +```ts +import { createConfig } from "express-zod-api"; +import ui from "swagger-ui-express"; +import { documentation } from "./documentation"; + +const config = createConfig({ + beforeRouting: ({ app, getLogger }) => { + const logger = getLogger(); + + logger.info("Serving API docs at https://example.com/docs"); + app.use("/docs", ui.serve, ui.setup(documentation)); + }, +}); +``` + +### Custom Routes + +Add custom routes outside your main routing structure: + +```ts +const config = createConfig({ + beforeRouting: ({ app }) => { + // Health check endpoint + app.get("/health", (req, res) => { + res.json({ status: "ok", timestamp: Date.now() }); + }); + + // Metrics endpoint + app.get("/metrics", async (req, res) => { + const metrics = await collectMetrics(); + res.json(metrics); + }); + }, +}); +``` + +### Using Child Logger + +Access request-specific child loggers when configured: + +```ts +import { createConfig } from "express-zod-api"; +import { randomUUID } from "node:crypto"; + +const config = createConfig({ + childLoggerProvider: ({ parent, request }) => + parent.child({ requestId: randomUUID() }), + + beforeRouting: ({ app, getLogger }) => { + app.use("/custom", (req, res, next) => { + const childLogger = getLogger(req); + childLogger.info("Custom route accessed"); + res.send("OK"); + }); + }, +}); +``` + +## Endpoint-Specific: `addExpressMiddleware()` + +For middlewares that need to interact with specific endpoints, use the `addExpressMiddleware()` method (alias: `use()`). + +### Basic Usage + +```ts +import { defaultEndpointsFactory } from "express-zod-api"; +import rateLimit from "express-rate-limit"; + +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs +}); + +const rateLimitedFactory = defaultEndpointsFactory + .addExpressMiddleware(limiter); + +const endpoint = rateLimitedFactory.build({ + method: "post", + input: z.object({ data: z.string() }), + output: z.object({ success: z.boolean() }), + handler: async () => ({ success: true }), +}); +``` + +### OAuth2 Integration + +Integrate OAuth2 JWT bearer authentication: + +```ts +import { defaultEndpointsFactory } from "express-zod-api"; +import createHttpError from "http-errors"; +import { auth } from "express-oauth2-jwt-bearer"; + +const jwtCheck = auth({ + audience: process.env.AUTH0_AUDIENCE, + issuerBaseURL: process.env.AUTH0_ISSUER, +}); + +const authenticatedFactory = defaultEndpointsFactory.use(jwtCheck, { + // Provide context from the middleware + provider: (req) => ({ + auth: req.auth, + userId: req.auth?.payload.sub, + }), + + // Transform errors to HttpError + transformer: (err) => createHttpError(401, err.message), +}); + +const protectedEndpoint = authenticatedFactory.build({ + method: "get", + input: z.object({}), + output: z.object({ + userId: z.string(), + data: z.string(), + }), + handler: async ({ ctx: { userId } }) => { + // userId is available from the OAuth middleware + return { + userId, + data: "Protected data", + }; + }, +}); +``` + +### CORS Configuration + +While Express Zod API handles CORS natively, you can use the Express middleware for advanced cases: + +```ts +import cors from "cors"; +import { defaultEndpointsFactory } from "express-zod-api"; + +const corsFactory = defaultEndpointsFactory.addExpressMiddleware( + cors({ + origin: (origin, callback) => { + // Custom origin validation logic + const allowedOrigins = ["https://example.com"]; + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error("Not allowed by CORS")); + } + }, + credentials: true, + }), + { + transformer: (err) => createHttpError(403, "CORS error"), + } +); +``` + + + Prefer the built-in CORS configuration in most cases—it's designed to work seamlessly with the framework. + + +### Request Compression + +Apply compression to specific endpoints: + +```ts +import compression from "compression"; + +const compressedFactory = defaultEndpointsFactory + .addExpressMiddleware( + compression({ threshold: "1kb" }) + ); +``` + +### Body Parser Alternatives + +Use alternative body parsers for specific content types: + +```ts +import express from "express"; +import { defaultEndpointsFactory } from "express-zod-api"; + +const textParserFactory = defaultEndpointsFactory + .addExpressMiddleware( + express.text({ type: "text/plain" }), + { + provider: (req) => ({ rawBody: req.body }), + } + ); + +const textEndpoint = textParserFactory.build({ + method: "post", + input: z.object({}), + output: z.object({ length: z.number() }), + handler: async ({ ctx: { rawBody } }) => ({ + length: rawBody.length, + }), +}); +``` + +### Helmet Security + +Add security headers with Helmet: + +```ts +import helmet from "helmet"; +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + beforeRouting: ({ app }) => { + app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + }, + }, + })); + }, +}); +``` + +### Request ID Middleware + +Add request IDs for tracking: + +```ts +import { defaultEndpointsFactory } from "express-zod-api"; +import { randomUUID } from "node:crypto"; + +const requestIdMiddleware = (req, res, next) => { + req.id = randomUUID(); + res.setHeader("X-Request-ID", req.id); + next(); +}; + +const trackedFactory = defaultEndpointsFactory + .addExpressMiddleware(requestIdMiddleware, { + provider: (req) => ({ requestId: req.id }), + }); +``` + +## Context Providers + +The `provider` option extracts data from the Express request: + +```ts +import { defaultEndpointsFactory } from "express-zod-api"; + +const factory = defaultEndpointsFactory.use(someMiddleware, { + // Synchronous provider + provider: (req) => ({ + userAgent: req.headers["user-agent"], + ip: req.ip, + }), +}); + +// Or asynchronous +const asyncFactory = defaultEndpointsFactory.use(authMiddleware, { + provider: async (req) => { + const user = await db.findUser(req.auth.userId); + return { user }; + }, +}); +``` + +## Error Transformers + +Transform Express middleware errors to HTTP errors: + +```ts +import createHttpError from "http-errors"; +import { defaultEndpointsFactory } from "express-zod-api"; + +const factory = defaultEndpointsFactory.use(someMiddleware, { + transformer: (err) => { + // Map specific error types + if (err.name === "UnauthorizedError") { + return createHttpError(401, "Authentication failed"); + } + if (err.name === "ForbiddenError") { + return createHttpError(403, "Access denied"); + } + // Default to 500 + return createHttpError(500, err.message); + }, +}); +``` + +## Multiple Middlewares + +Chain multiple Express middlewares: + +```ts +import { defaultEndpointsFactory } from "express-zod-api"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import { auth } from "express-oauth2-jwt-bearer"; + +const secureFactory = defaultEndpointsFactory + .use(helmet()) + .use(rateLimit({ windowMs: 900000, max: 100 })) + .use(auth({ /* config */ }), { + provider: (req) => ({ auth: req.auth }), + transformer: (err) => createHttpError(401, err.message), + }); +``` + +## Avoid: CORS Middleware + +Don't use Express CORS middleware in `beforeRouting`—use the framework's built-in option instead: + +```ts +// ❌ Don't do this +import cors from "cors"; + +const config = createConfig({ + beforeRouting: ({ app }) => { + app.use(cors()); // Not recommended + }, +}); + +// ✅ Do this instead +const config = createConfig({ + cors: true, // or function for custom headers +}); +``` + +## Static File Serving + +Serve static files without Express middleware: + +```ts +import { Routing, ServeStatic } from "express-zod-api"; + +const routing: Routing = { + public: new ServeStatic("assets", { + dotfiles: "deny", + index: false, + redirect: false, + }), +}; +``` + +## Passport.js Integration + +Integrate Passport authentication strategies: + +```ts +import passport from "passport"; +import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt"; +import { createConfig, defaultEndpointsFactory } from "express-zod-api"; + +// Configure Passport +passport.use( + new JwtStrategy( + { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: process.env.JWT_SECRET, + }, + async (payload, done) => { + const user = await db.Users.findById(payload.sub); + return done(null, user || false); + } + ) +); + +const config = createConfig({ + beforeRouting: ({ app }) => { + app.use(passport.initialize()); + }, +}); + +const authenticatedFactory = defaultEndpointsFactory.use( + passport.authenticate("jwt", { session: false }), + { + provider: (req) => ({ user: req.user }), + transformer: (err) => createHttpError(401, "Unauthorized"), + } +); +``` + +## Best Practices + + + + Use `beforeRouting` for global setup and `addExpressMiddleware()` for endpoint-specific logic. + + + + Always use the `provider` option to make middleware data available to handlers. + + + + Use the `transformer` option to convert Express errors to HttpError instances. + + + + Use the framework's built-in features for CORS, compression, and file uploads when possible. + + + +## Testing + +Test endpoints with Express middlewares: + +```ts +import { testEndpoint } from "express-zod-api"; + +describe("rateLimitedEndpoint", () => { + test("should work with rate limiting", async () => { + const { responseMock, loggerMock } = await testEndpoint({ + endpoint: rateLimitedEndpoint, + requestProps: { + method: "POST", + body: { data: "test" }, + }, + }); + + expect(responseMock._getStatusCode()).toBe(200); + expect(loggerMock._getLogs().error).toHaveLength(0); + }); +}); +``` + +## Next Steps + +- Learn about [Authentication](/guides/authentication) using native middlewares +- Explore [Subscriptions](/guides/subscriptions) for real-time features +- See [Testing](/essentials/testing) for endpoint testing strategies \ No newline at end of file diff --git a/docs/guides/migration.mdx b/docs/guides/migration.mdx new file mode 100644 index 000000000..27d51d6bd --- /dev/null +++ b/docs/guides/migration.mdx @@ -0,0 +1,488 @@ +--- +title: "Migration Guide" +description: "Upgrade Express Zod API across major versions" +--- + +## Overview + +This guide helps you migrate between major versions of Express Zod API. The framework provides automated migration tools to handle most breaking changes. + + + Express Zod API follows semantic versioning. Major version updates may include breaking changes, while minor and patch versions maintain backward compatibility. + + +## Automated Migration Tool + +The framework includes an ESLint plugin that automatically fixes most breaking changes: + +```bash +npm install --save-dev @express-zod-api/migration eslint @typescript-eslint/parser +``` + +### Basic Configuration + +Create or update your `eslint.config.mjs`: + +```js +import parser from "@typescript-eslint/parser"; +import migration from "@express-zod-api/migration"; + +export default [ + { languageOptions: { parser }, plugins: { migration } }, + { files: ["**/*.ts"], rules: { "migration/v27": "error" } }, +]; +``` + +### Run Migration + +```bash +eslint --fix . +``` + +The tool will automatically update your code to the latest API. + +## Version 27 (Current) + +### From v26 to v27 + +**Key Changes:** + +1. **TypeScript Dependency**: Now optional, only required for `Integration` +2. **Integration Constructor**: Requires explicit `typescript` property or use async `create()` +3. **Zod Plugin**: Uses inheritable metadata feature from Zod 4.3+ + +#### Option 1: Import and Assign TypeScript + +```ts +import { Integration } from "express-zod-api"; +import typescript from "typescript"; + +const client = new Integration({ + routing, + config, + typescript, // explicitly provide typescript +}); +``` + +#### Option 2: Use Async Factory + +```ts +import { Integration } from "express-zod-api"; + +// Delegates typescript import automatically +const client = await Integration.create({ + routing, + config, +}); +``` + +**Automated Migration:** + +```js +// eslint.config.mjs +export default [ + { languageOptions: { parser }, plugins: { migration } }, + { files: ["**/*.ts"], rules: { "migration/v27": "error" } }, +]; +``` + +## Version 26 + +### From v25 to v26 + +**Key Changes:** + +1. **Method-based routing**: `DependsOnMethod` removed in favor of direct method keys +2. **Renamed properties**: `options` → `ctx`, `addOptions()` → `addContext()` +3. **Integration config**: Now requires `config` property +4. **http-errors**: Updated to v2.0.1+ +5. **Zod**: Updated to v4.1.13+ (CJS and ESM fix) + +#### Method-Based Routing + +```diff +import { Routing } from "express-zod-api"; + +const routing: Routing = { +- "/v1/users": new DependsOnMethod({ ++ "/v1/users": { + get: getUserEndpoint, +- }).nest({ + create: makeUserEndpoint, +- }), ++ }, +}; +``` + +#### Context Rename + +```diff +import { Middleware } from "express-zod-api"; + +const middleware = new Middleware({ +- handler: async ({ options }) => { ++ handler: async ({ ctx }) => { +- return { value: options.something }; ++ return { value: ctx.something }; + }, +}); +``` + +```diff +- factory.addOptions(async () => ({ db: mongoose.connect() })); ++ factory.addContext(async () => ({ db: mongoose.connect() })); +``` + +#### Integration Config + +```diff +import { Integration } from "express-zod-api"; + +const client = new Integration({ + routing, ++ config, // now required +}); +``` + +**Automated Migration:** + +```js +export default [ + { languageOptions: { parser }, plugins: { migration } }, + { files: ["**/*.ts"], rules: { "migration/v26": "error" } }, +]; +``` + +## Version 25 + +### From v24 to v25 + +**Key Changes:** + +1. **Node.js**: Minimum versions 20.19.0, 22.12.0, or 24.0.0 +2. **ESM-only**: Framework distribution is now ESM-only +3. **Zod 4**: Must import from `zod` (not `zod/v4`) +4. **Metadata**: Removed `example` property support in `.meta()` +5. **Middleware input**: Now `unknown` when schema not defined + +#### Zod Import + +```diff +- import { z } from "zod/v4"; ++ import { z } from "zod"; +``` + +#### Examples Syntax + +```diff +- z.string().meta({ example: "test" }); +- z.string().meta({ examples: { one: { value: "test" } } }); ++ z.string().meta({ examples: ["test"] }); ++ z.string().example("test").example("another"); // plugin method +``` + +#### Date Schemas + +```diff +- ez.dateIn().example("2021-12-31"); ++ ez.dateIn({ examples: ["2021-12-31"] }); +``` + +**Automated Migration:** + +```js +export default [ + { languageOptions: { parser }, plugins: { migration } }, + { files: ["**/*.ts"], rules: { "migration/v25": "error" } }, +]; +``` + +## Version 24 + +### From v23 to v24 + +**Key Changes:** + +1. **Zod 4**: Switched to Zod v4 (import from `zod/v4`) +2. **Example syntax**: `.example()` now takes output type (after transformation) +3. **Date schemas**: Accept metadata as argument +4. **File schema**: `ez.file()` removed, use `z.string()`, `z.base64()`, or `ez.buffer()` +5. **Documentation**: Mostly delegated to Zod's `toJSONSchema()` +6. **ResultHandler**: Discriminated argument (either `output` or `error`, not both) + +#### Import Changes + +```diff +- import { z } from "zod"; ++ import { z } from "zod/v4"; +``` + +#### Example Placement + +```diff +input: z.string() ++ .example("123") + .transform(Number) +- .example("123") // wrong: takes number now +``` + +#### Date Schema Changes + +```diff +- ez.dateIn().example("2021-12-31"); ++ ez.dateIn({ examples: ["2021-12-31"] }); +``` + +#### File Schema Replacement + +```diff +- ez.file("base64"); ++ z.base64(); + +- ez.file("buffer"); ++ ez.buffer(); +``` + +**Automated Migration:** + +```js +export default [ + { languageOptions: { parser }, plugins: { migration } }, + { files: ["**/*.ts"], rules: { "migration/v24": "error" } }, +]; +``` + +## Version 23 + +### From v22 to v23 + +**Key Changes:** + +1. **Express**: Minimum version 5.1.0 (first stable v5) +2. **Wrong method behavior**: Default changed to `405` (Method Not Allowed) +3. **Security interfaces**: `CustomHeaderSecurity` renamed to `HeaderSecurity` +4. **Public API**: Many internal methods marked internal +5. **Testing**: `errorHandler` moved to config from `testMiddleware()` + +#### Express Upgrade + +```bash +npm install express@^5.1.0 +``` + +#### Config Default + +The default for `wrongMethodBehavior` changed: + +```ts +import { createConfig } from "express-zod-api"; + +// v22 default: 404 +// v23 default: 405 +const config = createConfig({ + wrongMethodBehavior: 404, // explicit if you want old behavior +}); +``` + +#### Testing Changes + +```diff +import { testMiddleware, createConfig } from "express-zod-api"; + ++ const config = createConfig({ errorHandler: customHandler }); + +const { output } = await testMiddleware({ + middleware, +- errorHandler: customHandler, ++ configProps: { errorHandler: customHandler }, +}); +``` + +**Automated Migration:** + +```js +export default [ + { languageOptions: { parser }, plugins: { migration } }, + { files: ["**/*.ts"], rules: { "migration/v23": "error" } }, +]; +``` + +## Version 22 + +### From v21 to v22 + +**Key Changes:** + +1. **Node.js**: Minimum versions 20.9.0 and 22.0.0 (dropped Node 18) +2. **Headers as input**: All headers addressed (not just `x-` prefixed) +3. **Tagging**: Tags moved to `Documentation` constructor, use `TagOverrides` interface +4. **Generated client**: Class renamed `ExpressZodAPIClient` → `Client` +5. **Integration**: `splitResponse` property removed (always splits) + +#### Headers Configuration + +```diff +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + inputSources: { +- get: ["query", "headers"], ++ get: ["headers", "query"], // move headers first to avoid overwrites + }, +}); +``` + +#### Tagging Changes + +```diff +- createConfig({ tags: {} }); + ++ // Declare tags interface ++ declare module "express-zod-api" { ++ interface TagOverrides { ++ users: unknown; ++ files: unknown; ++ } ++ } + +- new Documentation({ routing, config }); ++ new Documentation({ ++ routing, ++ config, ++ tags: { ++ users: "All about users", ++ files: { description: "Files", url: "https://example.com" }, ++ }, ++ }); +``` + +#### Factory Changes + +```diff +- new EndpointsFactory({ config, resultHandler }) ++ new EndpointsFactory(resultHandler) + +- new EventStreamFactory({ config, events }) ++ new EventStreamFactory(events) +``` + +#### Client Usage + +```diff +- import { ExpressZodAPIClient } from "./client"; +- const client = new ExpressZodAPIClient(implementation); ++ import { Client } from "./client"; ++ const client = new Client(implementation); + +- client.provide("get", "/v1/user/retrieve", { id: "10" }); ++ client.provide("get /v1/user/retrieve", { id: "10" }); +``` + +**Automated Migration:** + +```js +export default [ + { languageOptions: { parser }, plugins: { migration } }, + { files: ["**/*.ts"], rules: { "migration/v22": "error" } }, +]; +``` + +## Common Patterns + +### Updating Dependencies + +Always update peer dependencies when upgrading: + +```bash +npm install express-zod-api@latest \ + express@latest \ + zod@latest \ + http-errors@latest + +npm install -D @types/express@latest \ + @types/node@latest \ + @types/http-errors@latest +``` + +### Checking Breaking Changes + +Read the changelog before upgrading: + +```bash +npx npm-check-updates -i express-zod-api +``` + +Or visit the [changelog](https://github.com/RobinTail/express-zod-api/blob/master/CHANGELOG.md). + +### Testing After Migration + +Always run your test suite after migration: + +```bash +npm test +``` + +Check for: + +- TypeScript compilation errors +- Failed tests +- Runtime errors +- Documentation generation +- Integration generation + +### Gradual Migration + +For large codebases: + +1. Create a new branch for migration +2. Update dependencies +3. Run automated migration +4. Fix remaining TypeScript errors +5. Run tests and fix failures +6. Test generated documentation and client +7. Deploy to staging environment +8. Monitor for issues +9. Merge to production + +## Version Support + +| Version | Node.js | Express | Zod | Status | +| ------- | ------------------- | --------------- | -------- | ----------- | +| v27 | ^20.19, ^22.12, ^24 | ^5.1 | ^4.3.4 | Current | +| v26 | ^20.19, ^22.12, ^24 | ^5.1 | ^4.1.13 | Maintenance | +| v25 | ^20.19, ^22.12, ^24 | ^5.1 | ^4 | Maintenance | +| v24 | ^20.9, ^22 | ^5.1 | ^3.25.35 | Maintenance | +| v23 | ^20.9, ^22 | ^5.1 | ^3.23 | Maintenance | +| v22 | ^20.9, ^22 | ^4.21.1, ^5.0.1 | ^3.23 | Unsupported | + +## Troubleshooting + + + + Ensure your `tsconfig.json` has `strict: true` and `skipLibCheck: true`. Update `@types/*` packages to latest versions. + + + For Zod 4, ensure `moduleResolution` in `tsconfig.json` is `node16`, `nodenext`, or `bundler`. + + + Express Zod API v25+ is ESM-only. Set `"type": "module"` in `package.json` or use `.mts` extension. + + + If using `require("zod")` in CJS, you may get different instances. Use ESM or update to v26+ for proper CJS support. + + + Regenerate both documentation and client after migration. The generated API may have changed. + + + +## Getting Help + +- **GitHub Issues**: [Report bugs or ask questions](https://github.com/RobinTail/express-zod-api/issues) +- **Changelog**: [Full version history](https://github.com/RobinTail/express-zod-api/blob/master/CHANGELOG.md) +- **Migration Package**: [Automated migration tool](https://www.npmjs.com/package/@express-zod-api/migration) + +## Next Steps + +- Review the [Changelog](https://github.com/RobinTail/express-zod-api/blob/master/CHANGELOG.md) for detailed changes +- Check [Authentication Guide](/guides/authentication) for updated patterns +- See [API Reference](/api-reference/configuration) for current API documentation \ No newline at end of file diff --git a/docs/guides/subscriptions.mdx b/docs/guides/subscriptions.mdx new file mode 100644 index 000000000..57973ee22 --- /dev/null +++ b/docs/guides/subscriptions.mdx @@ -0,0 +1,526 @@ +--- +title: Subscriptions (Server-Sent Events) +description: Implement real-time data streaming with Server-Sent Events +--- + +## Overview + +Express Zod API supports real-time data streaming using Server-Sent Events (SSE). This lightweight alternative to WebSockets enables your server to push updates to clients over a standard HTTP connection. + + + For bidirectional communication, consider [Zod Sockets](https://github.com/RobinTail/zod-sockets), a companion library for WebSocket support. + + +## What are Server-Sent Events? + +Server-Sent Events provide: +- Unidirectional server-to-client communication +- Automatic reconnection +- Event-based message delivery +- Built-in browser support via `EventSource` +- Lower overhead than WebSockets for one-way streaming + +## Creating an Event Stream + +Use `EventStreamFactory` to create streaming endpoints: + +```ts +import { z } from "zod"; +import { EventStreamFactory } from "express-zod-api"; +import { setTimeout } from "node:timers/promises"; + +// Define your events schema +const eventsFactory = new EventStreamFactory({ + time: z.number().int().positive(), +}); + +// Build the streaming endpoint +const subscriptionEndpoint = eventsFactory.buildVoid({ + input: z.object({ + interval: z.number().int().positive().default(1000), + }), + handler: async ({ input, ctx: { emit, isClosed, signal } }) => { + while (!isClosed()) { + // Emit events to the stream + emit("time", Date.now()); + + // Wait before next event + await setTimeout(input.interval); + } + }, +}); +``` + +## Multiple Event Types + +Define multiple event types in a single stream: + +```ts +import { z } from "zod"; +import { EventStreamFactory } from "express-zod-api"; + +const eventsFactory = new EventStreamFactory({ + // User events + userJoined: z.object({ + userId: z.string(), + username: z.string(), + timestamp: z.number(), + }), + userLeft: z.object({ + userId: z.string(), + timestamp: z.number(), + }), + + // Message events + message: z.object({ + id: z.string(), + userId: z.string(), + text: z.string(), + timestamp: z.number(), + }), + + // Status events + status: z.object({ + activeUsers: z.number(), + serverLoad: z.number(), + }), +}); + +const chatStreamEndpoint = eventsFactory.buildVoid({ + input: z.object({ + roomId: z.string(), + }), + handler: async ({ input, ctx: { emit, isClosed, signal }, logger }) => { + const room = await joinRoom(input.roomId); + + // Emit initial status + emit("status", { + activeUsers: room.users.length, + serverLoad: 0.5, + }); + + // Listen for room events + room.on("userJoined", (user) => { + if (!isClosed()) { + emit("userJoined", { + userId: user.id, + username: user.name, + timestamp: Date.now(), + }); + } + }); + + room.on("message", (msg) => { + if (!isClosed()) { + emit("message", msg); + } + }); + + // Wait until connection closes + signal.addEventListener("abort", () => { + logger.info("Client disconnected"); + room.leave(); + }); + }, +}); +``` + +## Client-Side Consumption + +Consume SSE streams using the native `EventSource` API: + +```js +// Vanilla JavaScript +const source = new EventSource( + "https://api.example.com/v1/events/stream?interval=1000" +); + +source.addEventListener("time", (event) => { + const timestamp = JSON.parse(event.data); + console.log("Server time:", new Date(timestamp)); +}); + +source.addEventListener("error", (error) => { + console.error("Connection error:", error); + source.close(); +}); + +// Close when done +// source.close(); +``` + +### React Example + +```tsx +import { useEffect, useState } from "react"; + +function TimeDisplay() { + const [time, setTime] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const source = new EventSource("/api/v1/events/time"); + + source.addEventListener("time", (event) => { + setTime(JSON.parse(event.data)); + }); + + source.addEventListener("error", () => { + setError("Connection lost"); + source.close(); + }); + + return () => { + source.close(); + }; + }, []); + + if (error) return
Error: {error}
; + if (!time) return
Connecting...
; + + return
Server time: {new Date(time).toISOString()}
; +} +``` + +## Connection Lifecycle + +Manage the connection lifecycle with provided helpers: + +### `isClosed()` + +Check if the client has disconnected: + +```ts +const endpoint = eventsFactory.buildVoid({ + input: z.object({}), + handler: async ({ ctx: { emit, isClosed } }) => { + let counter = 0; + + while (!isClosed()) { + emit("count", counter++); + await setTimeout(1000); + } + + // Cleanup after client disconnects + console.log("Stream closed, sent", counter, "events"); + }, +}); +``` + +### `signal` (AbortSignal) + +Use the abort signal for cleanup and cancellation: + +```ts +const endpoint = eventsFactory.buildVoid({ + input: z.object({ topic: z.string() }), + handler: async ({ input, ctx: { emit, signal }, logger }) => { + const subscription = messageBus.subscribe(input.topic); + + // Listen for disconnect + signal.addEventListener("abort", () => { + logger.info("Client disconnected, cleaning up"); + subscription.unsubscribe(); + }); + + // Forward messages to stream + for await (const message of subscription) { + if (signal.aborted) break; + emit("message", message); + } + }, +}); +``` + +## With Middleware + +Add authentication and other middlewares to event streams: + +```ts +import { z } from "zod"; +import { EventStreamFactory } from "express-zod-api"; +import { authMiddleware } from "./middlewares"; + +const authenticatedEventsFactory = new EventStreamFactory({ + notification: z.object({ + id: z.string(), + type: z.enum(["info", "warning", "error"]), + message: z.string(), + timestamp: z.number(), + }), +}) + .addMiddleware(authMiddleware); + +const notificationsEndpoint = authenticatedEventsFactory.buildVoid({ + input: z.object({}), + handler: async ({ ctx: { user, emit, isClosed, signal } }) => { + // user is available from authMiddleware + const stream = await subscribeToUserNotifications(user.id); + + signal.addEventListener("abort", () => { + stream.unsubscribe(); + }); + + for await (const notification of stream) { + if (signal.aborted) break; + emit("notification", notification); + } + }, +}); +``` + +## Real-World Examples + +### Live Metrics Dashboard + +```ts +import { EventStreamFactory } from "express-zod-api"; +import { z } from "zod"; +import { setTimeout } from "node:timers/promises"; + +const metricsFactory = new EventStreamFactory({ + metrics: z.object({ + cpu: z.number().min(0).max(100), + memory: z.number().min(0).max(100), + requests: z.number().int().nonnegative(), + errors: z.number().int().nonnegative(), + timestamp: z.number(), + }), +}); + +const metricsEndpoint = metricsFactory.buildVoid({ + input: z.object({ + interval: z.number().int().min(1000).default(5000), + }), + handler: async ({ input, ctx: { emit, isClosed } }) => { + while (!isClosed()) { + const metrics = await collectSystemMetrics(); + + emit("metrics", { + ...metrics, + timestamp: Date.now(), + }); + + await setTimeout(input.interval); + } + }, +}); +``` + +### Progress Tracking + +```ts +const progressFactory = new EventStreamFactory({ + progress: z.object({ + taskId: z.string(), + percentage: z.number().min(0).max(100), + status: z.enum(["pending", "processing", "completed", "failed"]), + message: z.string(), + }), +}); + +const taskProgressEndpoint = progressFactory.buildVoid({ + input: z.object({ + taskId: z.string().uuid(), + }), + handler: async ({ input, ctx: { emit, isClosed, signal } }) => { + const task = await Task.findById(input.taskId); + + task.on("progress", (progress) => { + if (!isClosed()) { + emit("progress", { + taskId: input.taskId, + percentage: progress.percentage, + status: progress.status, + message: progress.message, + }); + } + }); + + // Wait for completion or disconnect + await Promise.race([ + task.waitForCompletion(), + new Promise((resolve) => { + signal.addEventListener("abort", resolve); + }), + ]); + + task.removeAllListeners(); + }, +}); +``` + +### Log Streaming + +```ts +import { tail } from "tail"; + +const logsFactory = new EventStreamFactory({ + log: z.object({ + level: z.enum(["info", "warn", "error", "debug"]), + message: z.string(), + timestamp: z.string(), + source: z.string(), + }), +}); + +const logStreamEndpoint = logsFactory.buildVoid({ + input: z.object({ + service: z.string(), + level: z.enum(["info", "warn", "error", "debug"]).optional(), + }), + handler: async ({ input, ctx: { emit, signal }, logger }) => { + const logFile = `/var/log/${input.service}.log`; + const tailer = new tail(logFile); + + tailer.on("line", (line) => { + const log = parseLogLine(line); + if (!input.level || log.level === input.level) { + emit("log", log); + } + }); + + signal.addEventListener("abort", () => { + logger.info("Stopping log stream"); + tailer.unwatch(); + }); + }, +}); +``` + +## Error Handling + +Handle errors in event streams: + +```ts +const endpoint = eventsFactory.buildVoid({ + input: z.object({ trigger: z.string().optional() }), + handler: async ({ input, ctx: { emit, isClosed } }) => { + // Trigger error for testing + if (input.trigger === "failure") { + throw new Error("Intentional failure"); + } + + try { + while (!isClosed()) { + const data = await fetchData(); + emit("data", data); + await setTimeout(1000); + } + } catch (error) { + // Errors terminate the stream and send error response + throw createHttpError(500, "Data fetch failed"); + } + }, +}); +``` + +## Routing + +Add SSE endpoints to your routing: + +```ts +import { Routing } from "express-zod-api"; + +const routing: Routing = { + v1: { + events: { + time: subscriptionEndpoint, + metrics: metricsEndpoint, + notifications: notificationsEndpoint, + }, + }, +}; +``` + +## Generated Client Support + +The `Integration` generator creates a `Subscription` class for type-safe SSE consumption: + +```ts +import { Integration } from "express-zod-api"; + +const client = new Integration({ + routing, + config, + variant: "client", +}); + +const code = await client.printFormatted(); +``` + +Generated client usage: + +```ts +import { Subscription } from "./generated-client"; + +const subscription = new Subscription("get /v1/events/time", { + interval: 1000, +}); + +subscription.on("time", (timestamp) => { + console.log("Time:", new Date(timestamp)); +}); + +// Access the underlying EventSource +subscription.source.addEventListener("error", (err) => { + console.error("Connection error", err); +}); + +// Close the connection +subscription.source.close(); +``` + +## Best Practices + + + + Always check `isClosed()` before emitting events to avoid errors when the client has disconnected. + + + + Register cleanup logic with the `signal` to release resources when clients disconnect. + + + + Define strict Zod schemas for your events—the framework validates all emitted data automatically. + + + + Don't emit events faster than clients can consume them. Use appropriate intervals or queue mechanisms. + + + + Consider implementing heartbeat/ping events to detect stale connections. + + + + Test how your handlers behave when clients disconnect unexpectedly. + + + +## Limitations + +- SSE is one-way (server to client only) +- Limited to text-based data (JSON strings) +- Some proxies and firewalls may interfere +- Maximum of 6 concurrent connections per domain in browsers +- No binary data support (use base64 encoding if needed) + +## Comparison with WebSockets + +| Feature | SSE | WebSockets | +|---------|-----|------------| +| Direction | Server → Client | Bidirectional | +| Protocol | HTTP | WS/WSS | +| Reconnection | Automatic | Manual | +| Browser Support | Good | Excellent | +| Overhead | Lower | Higher | +| Binary Data | No | Yes | +| Use Case | Live feeds, notifications | Chat, gaming, collaboration | + +## Next Steps + +- Learn about [Authentication](/guides/authentication) for secure streams +- Explore [Express Middleware](/guides/express-middleware) for rate limiting +- See [Zod Sockets](https://github.com/RobinTail/zod-sockets) for WebSocket support \ No newline at end of file diff --git a/docs/how-it-works.mdx b/docs/how-it-works.mdx new file mode 100644 index 000000000..54d35740a --- /dev/null +++ b/docs/how-it-works.mdx @@ -0,0 +1,526 @@ +--- +title: How It Works +description: Understand the Express Zod API architecture, data flow, and core concepts +--- + +## Architecture Overview + +Express Zod API builds on top of Express.js, adding a layer of type safety and automatic validation. Understanding how data flows through the system will help you build better APIs. + + + Express Zod API Data Flow Diagram + + +## The Request Lifecycle + +When a request hits your API, it goes through several stages: + + + + Express parses the incoming request, extracting: + - `request.body` (JSON or form data) + - `request.query` (URL query parameters) + - `request.params` (path parameters like `:id`) + - `request.headers` (HTTP headers) + - `request.files` (uploaded files, if enabled) + + + + Express Zod API combines configured input sources into a single `input` object based on the HTTP method: + + ```typescript + // Default input sources + { + get: ["query", "params"], + post: ["body", "params", "files"], + put: ["body", "params"], + patch: ["body", "params"], + delete: ["query", "params"], + } + ``` + + + + Middlewares run in order, each receiving: + - `input`: Validated input from previous middleware or request + - `request`: The original Express request + - `response`: The Express response object + - `logger`: The configured logger + - `ctx`: Context from previous middlewares + + Each middleware can: + - Validate additional input + - Perform authentication/authorization + - Add data to `ctx` for the endpoint handler + - Throw errors to stop execution + + + + The combined input is validated against the endpoint's input schema: + + ```typescript + input: z.object({ + userId: z.string().transform(Number), + email: z.string().email(), + }) + ``` + + If validation fails, a `400 Bad Request` response is sent automatically. + + + + Your endpoint handler receives: + - `input`: Fully validated and typed input + - `ctx`: Context from middlewares + - `logger`: Logger for this request + - `request`: Original Express request (for advanced use) + - `response`: Express response (rarely needed) + + The handler returns an `output` object or throws an error. + + + + The handler's output is validated against the output schema: + + ```typescript + output: z.object({ + id: z.number(), + name: z.string(), + }) + ``` + + If validation fails, a `500 Internal Server Error` is sent (this indicates a bug in your code). + + + + The `ResultHandler` formats the response: + - **Success**: Wraps output in a standard format (default: `{ status: "success", data: {...} }`) + - **Error**: Formats errors consistently (default: `{ status: "error", error: { message: "..." } }`) + - Sets appropriate HTTP status codes + - Adds headers (CORS, content-type, etc.) + + + +## Core Components + +### 1. Schemas (Zod) + +Schemas define the shape and validation rules for your data: + +```typescript +import { z } from "zod"; + +const userSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), + age: z.number().int().positive().optional(), +}); +``` + +**Key features:** +- Type inference: TypeScript types are automatically derived +- Transformations: Convert data types (e.g., string to number) +- Refinements: Custom validation logic +- Composition: Combine schemas with `.merge()`, `.extend()`, etc. + +### 2. Endpoints + +Endpoints are the core building blocks of your API: + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const endpoint = defaultEndpointsFactory.build({ + method: "post", // HTTP method(s) + input: z.object({...}), // Input validation schema + output: z.object({...}), // Output validation schema + handler: async ({ input, ctx, logger }) => { + // Your business logic here + return { ... }; // Must match output schema + }, +}); +``` + +**Handler parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `input` | Validated input | Combines body, query, params based on method | +| `ctx` | Context object | Data provided by middlewares | +| `logger` | Logger instance | For logging (debug, info, warn, error) | +| `request` | Express Request | Raw request object (advanced use) | +| `response` | Express Response | Raw response object (advanced use) | + +### 3. Middlewares + +Middlewares provide reusable logic that runs before endpoint handlers: + +```typescript +import { Middleware } from "express-zod-api"; +import { z } from "zod"; +import createHttpError from "http-errors"; + +const authMiddleware = new Middleware({ + // Optional: security info for documentation + security: { + type: "header", + name: "authorization", + }, + // Input validation for the middleware + input: z.object({ + key: z.string(), + }), + // Middleware logic + handler: async ({ input, request, logger }) => { + const token = request.headers.authorization; + + if (!token) { + throw createHttpError(401, "Missing authorization header"); + } + + // Authenticate user... + const user = await authenticateUser(token, input.key); + + // Return context for the endpoint + return { user }; + }, +}); +``` + +**Attaching middlewares:** + +```typescript +const authenticatedFactory = defaultEndpointsFactory + .addMiddleware(authMiddleware); + +const protectedEndpoint = authenticatedFactory.build({ + handler: async ({ ctx: { user } }) => { + // user is available from authMiddleware + return { message: `Hello, ${user.name}` }; + }, +}); +``` + +### 4. Factories + +Factories create endpoints, optionally with pre-attached middlewares: + +```typescript +import { EndpointsFactory, defaultEndpointsFactory } from "express-zod-api"; + +// Default factory (no middlewares) +const publicEndpoint = defaultEndpointsFactory.build({...}); + +// Factory with authentication +const authFactory = defaultEndpointsFactory + .addMiddleware(authMiddleware); + +const privateEndpoint = authFactory.build({...}); + +// Factory with custom result handler +import { ResultHandler } from "express-zod-api"; + +const customFactory = new EndpointsFactory( + new ResultHandler({ + positive: (data) => ({ schema: z.object({ data }), ... }), + negative: z.object({ error: z.string() }), + handler: ({ response, error, output }) => { + // Custom response formatting + }, + }) +); +``` + +### 5. Result Handlers + +Result handlers control how responses are formatted and sent: + +```typescript +import { ResultHandler } from "express-zod-api"; +import { z } from "zod"; + +const customResultHandler = new ResultHandler({ + // Success response schema + positive: (data) => ({ + schema: z.object({ + success: z.literal(true), + data, + }), + mimeType: "application/json", + }), + + // Error response schema + negative: z.object({ + success: z.literal(false), + error: z.string(), + }), + + // How to send the response + handler: ({ response, error, output }) => { + if (error) { + response.status(error.statusCode || 500).json({ + success: false, + error: error.message, + }); + } else { + response.status(200).json({ + success: true, + data: output, + }); + } + }, +}); +``` + +### 6. Routing + +Routing maps endpoints to URL paths: + +```typescript +import { Routing } from "express-zod-api"; + +const routing: Routing = { + // Nested syntax: /v1/users/list + v1: { + users: { + list: listUsersEndpoint, + // Path params: /v1/users/:id + ":id": getUserEndpoint, + }, + }, + + // Flat syntax: /api/health + "api/health": healthEndpoint, + + // Explicit method: POST /v1/users + "post /v1/users": createUserEndpoint, + + // Method-based routing: /v1/user + "v1/user": { + get: getUserEndpoint, + post: createUserEndpoint, + delete: deleteUserEndpoint, + }, +}; +``` + +### 7. Configuration + +Configuration centralizes all server settings: + +```typescript +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + // Server configuration + http: { + listen: 8080, // Port, UNIX socket, or Net.ListenOptions + }, + + // Optional HTTPS + https: { + options: { + cert: fs.readFileSync("cert.pem"), + key: fs.readFileSync("key.pem"), + }, + listen: 443, + }, + + // CORS settings + cors: true, // or false, or custom function + + // Logger configuration + logger: { + level: "debug", + color: true, + }, + + // Input sources per method + inputSources: { + get: ["query", "params"], + post: ["body", "params", "files"], + }, + + // File upload configuration + upload: { + limits: { fileSize: 5 * 1024 * 1024 }, // 5MB + }, + + // Response compression + compression: true, +}); +``` + +## Data Flow Example + +Let's trace a request through the entire system: + + + + ```bash + POST /v1/users + Content-Type: application/json + + { "name": "Jane", "email": "jane@example.com" } + ``` + + + + ```javascript + request.body = { name: "Jane", email: "jane@example.com" } + request.params = {} + request.query = {} + ``` + + + + ```javascript + input = { + ...request.body, // { name: "Jane", email: "jane@example.com" } + ...request.params, // {} + } + ``` + + + + ```typescript + // Auth middleware validates token + const user = await authenticateToken(request.headers.authorization); + ctx = { user }; // Available to handler + ``` + + + + ```typescript + const validatedInput = inputSchema.parse(input); + // ✅ { name: "Jane", email: "jane@example.com" } + ``` + + + + ```typescript + const output = await handler({ input: validatedInput, ctx, logger }); + // Returns: { id: 123, name: "Jane", email: "jane@example.com" } + ``` + + + + ```typescript + const validatedOutput = outputSchema.parse(output); + // ✅ { id: 123, name: "Jane", email: "jane@example.com" } + ``` + + + + ```typescript + response.status(200).json({ + status: "success", + data: validatedOutput, + }); + ``` + + + +## Type Safety Flow + +One of Express Zod API's biggest advantages is end-to-end type safety: + +```typescript +import { z } from "zod"; +import { defaultEndpointsFactory } from "express-zod-api"; + +// 1. Define schemas +const inputSchema = z.object({ + userId: z.string().transform(Number), +}); + +const outputSchema = z.object({ + id: z.number(), + name: z.string(), +}); + +// 2. Create endpoint +const endpoint = defaultEndpointsFactory.build({ + input: inputSchema, + output: outputSchema, + handler: async ({ input }) => { + // TypeScript knows: input.userId is number + const id: number = input.userId; ✅ + + return { + id, + name: "John", + }; // ✅ Matches output schema + + // return { id }; ❌ TypeScript error: missing 'name' + }, +}); + +// 3. Type-safe client (generated) +const result = await client.provide("get /v1/user", { userId: "123" }); +// TypeScript knows: result.data is { id: number, name: string } +const name: string = result.data.name; ✅ +``` + +## Error Handling Flow + +Errors can occur at multiple stages: + +```typescript +import createHttpError from "http-errors"; + +// 1. Input validation error (automatic) +POST /v1/users { "email": "invalid" } +→ 400 Bad Request + +// 2. Middleware error (thrown) +const authMiddleware = new Middleware({ + handler: async ({ request }) => { + if (!request.headers.authorization) { + throw createHttpError(401, "Unauthorized"); + } + }, +}); +→ 401 Unauthorized + +// 3. Handler error (thrown) +const endpoint = factory.build({ + handler: async ({ input }) => { + if (!userExists(input.userId)) { + throw createHttpError(404, "User not found"); + } + }, +}); +→ 404 Not Found + +// 4. Output validation error (automatic) +const endpoint = factory.build({ + output: z.object({ id: z.number() }), + handler: async () => { + return { id: "not a number" }; // Bug in code! + }, +}); +→ 500 Internal Server Error +``` + +All errors are caught and formatted by the `ResultHandler`. + +## Next Steps + +Now that you understand how Express Zod API works: + + + + Learn to create and chain middlewares + + + Master Zod schemas and transformations + + + Customize response formatting + + + Generate OpenAPI specs automatically + + diff --git a/docs/index.mdx b/docs/index.mdx new file mode 100644 index 000000000..04b01022d --- /dev/null +++ b/docs/index.mdx @@ -0,0 +1,150 @@ +--- +title: "Express Zod API" +description: "Build type-safe Express APIs with I/O schema validation in minutes" +--- + +
+

+ Express Zod API +

+

+ A TypeScript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes. +

+ +
+ +## Key Features + +Express Zod API integrates Express.js, Zod validation, and TypeScript to provide a complete framework for building robust APIs. + + + + Validate request and response data with Zod schemas for complete type safety + + + Generate OpenAPI 3.1 documentation automatically from your endpoint definitions + + + Build reusable middleware with context passing and authentication support + + + Export TypeScript types to your frontend for full type safety across your stack + + + Handle file uploads with built-in validation and size limits + + + Test endpoints and middleware easily with provided testing helpers + + + +## Quick Example + +Here's how simple it is to create a type-safe endpoint: + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const helloEndpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + name: z.string().optional(), + }), + output: z.object({ + greetings: z.string(), + }), + handler: async ({ input: { name } }) => { + return { greetings: `Hello, ${name || "World"}!` }; + }, +}); +``` + +## Why Express Zod API? + + + + Built with TypeScript for complete type safety throughout your API + + + Built on Express v5 with full compatibility with the Express ecosystem + + + Focus on business logic while the framework handles validation and errors + + + Support for nested routes, path parameters, and method-based routing + + + Built-in HTTPS, compression, CORS, and production mode optimizations + + + Clear error messages, comprehensive testing utilities, and detailed docs + + + +## Get Started + + + + Install Express Zod API and its peer dependencies + + ```bash + pnpm add express-zod-api express zod http-errors + pnpm add -D @types/express @types/node @types/http-errors + ``` + + + + Define input/output schemas and handler logic + + ```typescript + import { defaultEndpointsFactory, createConfig } from "express-zod-api"; + import { z } from "zod"; + + const endpoint = defaultEndpointsFactory.build({ + input: z.object({ name: z.string() }), + output: z.object({ greeting: z.string() }), + handler: async ({ input }) => ({ greeting: `Hello ${input.name}` }), + }); + ``` + + + + Configure and launch your API server + + ```typescript + import { createServer, Routing } from "express-zod-api"; + + const routing: Routing = { + v1: { hello: endpoint }, + }; + + createServer(createConfig({ http: { listen: 8090 } }), routing); + ``` + + + +## Learn More + + + + Understand endpoints, routing, middleware, and result handlers + + + Explore the complete API documentation + + + Generate documentation and type-safe clients + + + Learn from real-world examples and best practices + + diff --git a/docs/installation.mdx b/docs/installation.mdx new file mode 100644 index 000000000..fb7d51697 --- /dev/null +++ b/docs/installation.mdx @@ -0,0 +1,317 @@ +--- +title: Installation +description: Install Express Zod API and configure your TypeScript project +--- + +## Prerequisites + +Before installing Express Zod API, make sure you have: + +- **Node.js**: Version 20.19.0, 22.12.0, or 24.0.0 or higher +- **TypeScript**: Version 5.1.3 or higher +- **Package Manager**: npm, yarn, or pnpm + + + Express Zod API requires TypeScript and works best with strict type checking enabled. + + +## Install with Package Manager + +Choose your preferred package manager and install Express Zod API along with its required peer dependencies: + + + + ```bash + # Install core dependencies + npm install express-zod-api express zod http-errors + + # Install TypeScript type definitions + npm install -D @types/express @types/node @types/http-errors + ``` + + + ```bash + # Install core dependencies + pnpm add express-zod-api express zod http-errors + + # Install TypeScript type definitions + pnpm add -D @types/express @types/node @types/http-errors + ``` + + + ```bash + # Install core dependencies + yarn add express-zod-api express zod http-errors + + # Install TypeScript type definitions + yarn add -D @types/express @types/node @types/http-errors + ``` + + + +## Required Dependencies + +Here's what each dependency does: + +| Package | Purpose | Type | +|---------|---------|------| +| `express-zod-api` | The main framework | Runtime | +| `express` | Web server (v5.x) | Runtime (peer) | +| `zod` | Schema validation (v4.x) | Runtime (peer) | +| `http-errors` | HTTP error handling | Runtime (peer) | +| `@types/express` | Express TypeScript types | Dev (peer) | +| `@types/node` | Node.js TypeScript types | Dev (peer) | +| `@types/http-errors` | HTTP errors TypeScript types | Dev (peer) | + + + Express Zod API requires **Express v5** and **Zod v4**. If you're using older versions, you'll need to upgrade or use an older version of Express Zod API (v23.x for Zod 3.x). + + +## Optional Dependencies + +Depending on your needs, you may want to install these optional packages: + +### File Uploads + +For handling file uploads with multipart/form-data: + + + + ```bash + npm install express-fileupload + npm install -D @types/express-fileupload + ``` + + + ```bash + pnpm add express-fileupload + pnpm add -D @types/express-fileupload + ``` + + + ```bash + yarn add express-fileupload + yarn add -D @types/express-fileupload + ``` + + + +### Response Compression + +For enabling gzip/brotli compression: + + + + ```bash + npm install compression + npm install -D @types/compression + ``` + + + ```bash + pnpm add compression + pnpm add -D @types/compression + ``` + + + ```bash + yarn add compression + yarn add -D @types/compression + ``` + + + +### Documentation UI + +For serving interactive Swagger documentation: + + + + ```bash + npm install swagger-ui-express + npm install -D @types/swagger-ui-express + ``` + + + ```bash + pnpm add swagger-ui-express + pnpm add -D @types/swagger-ui-express + ``` + + + ```bash + yarn add swagger-ui-express + yarn add -D @types/swagger-ui-express + ``` + + + +## TypeScript Configuration + +Express Zod API requires specific TypeScript compiler options to work correctly. Update your `tsconfig.json`: + +```json tsconfig.json +{ + "compilerOptions": { + // Required for Express Zod API + "strict": true, + "skipLibCheck": true, + + // Recommended settings + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + + + + The `strict` option enables all strict type checking options. This is essential for catching type errors early and ensuring Express Zod API works as expected. + + + + The `skipLibCheck` option improves compilation speed by skipping type checking of declaration files. This is safe and recommended. + + + + Express Zod API works with both CommonJS and ES modules. The example above uses ES modules (`"module": "ESNext"`), which is recommended for new projects. + + + + + **Using CommonJS?** Set `"module": "commonjs"` and `"moduleResolution": "node"` instead. Both module systems are fully supported. + + +## Project Structure + +Here's a recommended project structure for your Express Zod API application: + +``` +my-api/ +├── src/ +│ ├── config.ts # Server configuration +│ ├── index.ts # Entry point +│ ├── routing.ts # Route definitions +│ ├── endpoints/ +│ │ ├── users.ts # User-related endpoints +│ │ └── auth.ts # Auth-related endpoints +│ ├── middlewares/ +│ │ ├── auth.ts # Authentication middleware +│ │ └── logging.ts # Logging middleware +│ └── factories/ +│ └── authenticated.ts # Factories with middleware +├── package.json +└── tsconfig.json +``` + +## Verify Installation + +Create a simple test file to verify everything is installed correctly: + +```typescript test.ts +import { createConfig, createServer, defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const config = createConfig({ + http: { listen: 8080 }, + cors: true, +}); + +const endpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({}), + output: z.object({ message: z.string() }), + handler: async () => ({ message: "Hello, Express Zod API!" }), +}); + +const routing = { + test: endpoint, +}; + +createServer(config, routing); +console.log("Server running on http://localhost:8080"); +``` + +Run it with: + + + + ```bash + npx tsx test.ts + ``` + + + ```bash + npx ts-node test.ts + ``` + + + ```bash + npx tsc + node dist/test.js + ``` + + + +If you see "Server running on http://localhost:8080", you're all set! + + + The `tsx` package is recommended for running TypeScript files directly during development. Install it with `npm install -D tsx`. + + +## Troubleshooting + + + + If you see errors about module resolution: + + 1. Make sure `@types/node` is installed + 2. Check that your `tsconfig.json` has correct `moduleResolution` setting + 3. For ES modules, ensure your `package.json` has `"type": "module"` + + + + Express Zod API has several peer dependencies that must be installed: + + - `express` (required) + - `zod` (required) + - `http-errors` (required) + - `typescript` (required for development) + + Some peer dependencies are optional and only needed if you use specific features (compression, file uploads, etc.). + + + + If you're seeing type errors related to Zod: + + 1. Ensure you're using Zod v4.x (check with `npm list zod`) + 2. Make sure `strict` mode is enabled in `tsconfig.json` + 3. Clear your TypeScript cache: `rm -rf node_modules/.cache` + + + + Express Zod API requires Express v5.x. If you have v4.x: + + ```bash + npm install express@^5.0.0 @types/express@^5.0.0 + ``` + + Note that Express v5 has some breaking changes from v4. See the [Express migration guide](https://expressjs.com/en/guide/migrating-5.html). + + + +## Next Steps + +Now that you have Express Zod API installed, let's build your first API: + + + Create a working API in under 5 minutes + diff --git a/docs/integration/documentation.mdx b/docs/integration/documentation.mdx new file mode 100644 index 000000000..26e9fd286 --- /dev/null +++ b/docs/integration/documentation.mdx @@ -0,0 +1,442 @@ +--- +title: Generating Documentation +description: Create OpenAPI 3.1 documentation for your API automatically +--- + +Express Zod API can automatically generate comprehensive OpenAPI 3.1 (formerly Swagger) documentation from your endpoint definitions. + +## Quick Start + +Create a script to generate your documentation: + +```typescript generate-documentation.ts +import { writeFile } from "node:fs/promises"; +import { Documentation } from "express-zod-api"; +import { config } from "./config"; +import { routing } from "./routing"; +import manifest from "./package.json"; + +await writeFile( + "api-documentation.yaml", + new Documentation({ + routing, + config, + version: manifest.version, + title: "Example API", + serverUrl: "https://api.example.com", + }).getSpecAsYaml(), + "utf-8" +); +``` + + +Run this during your build process to keep documentation in sync with your code. + + +## Configuration Options + +### Required Options + + + Your API routing configuration. + + + + Your API server configuration. + + + + The title of your API (appears in the documentation). + + + + The version of your API (semantic versioning recommended). + + + + The base URL(s) where your API is hosted. Can be a single URL or an array for multiple environments. + + +### Optional Options + + + How to structure schemas in the documentation: + - `"inline"` - Inline all schemas directly in endpoints + - `"components"` - Extract schemas to a separate reusable components section + + + + Automatically use the first line of description as the summary. + + + + Include HEAD method for each GET endpoint (Express feature). + + + + Extended descriptions for endpoint tags. See [Tagging Endpoints](#tagging-endpoints). + + + + Custom description generators for components. See [Custom Descriptions](#custom-descriptions). + + + + Custom handling for branded schemas. See [Branded Types](#branded-types). + + + + Custom logic for recognizing headers in input sources. + + +## Adding Descriptions and Examples + +Add rich documentation directly to your endpoints: + +```typescript +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +const getUserEndpoint = defaultEndpointsFactory.build({ + shortDescription: "Retrieves a user by ID", + description: ` + This endpoint fetches a single user from the database. + Requires authentication via API key. + `, + input: z.object({ + id: z.string() + .example("user_123") + .describe("The unique identifier for the user"), + }), + output: z.object({ + id: z.string().example("user_123"), + name: z.string().example("John Doe"), + email: z.string().email().example("john@example.com"), + createdAt: z.string().example("2024-01-01T00:00:00Z"), + }).describe("User object with all details"), + handler: async ({ input }) => { + // Implementation + return { + id: input.id, + name: "John Doe", + email: "john@example.com", + createdAt: new Date().toISOString(), + }; + }, +}); +``` + + +Examples should be set **before** transformations, as transformations modify the schema. + + +## Tagging Endpoints + +Organize your endpoints into logical groups using tags: + +### 1. Define Tag Constraints + +```typescript +// Declare tags once in your codebase +declare module "express-zod-api" { + interface TagOverrides { + users: unknown; + files: unknown; + subscriptions: unknown; + } +} +``` + +### 2. Tag Your Endpoints + +```typescript +const getUserEndpoint = defaultEndpointsFactory.build({ + tag: "users", // Single tag + // or + tag: ["users", "admin"], // Multiple tags + // ... endpoint definition +}); +``` + +### 3. Add Tag Descriptions to Documentation + +```typescript +new Documentation({ + routing, + config, + title: "My API", + version: "1.0.0", + serverUrl: "https://api.example.com", + tags: { + users: "All operations related to user management", + files: { + description: "File upload and download operations", + url: "https://docs.example.com/files", + }, + subscriptions: "Real-time event subscriptions", + }, +}); +``` + +## Deprecation Markers + +Mark schemas and endpoints as deprecated: + +```typescript +import { z } from "zod"; +import { Routing, defaultEndpointsFactory } from "express-zod-api"; + +// Deprecate a specific field +const endpoint = defaultEndpointsFactory.build({ + input: z.object({ + newField: z.string(), + oldField: z.string().deprecated(), // Field is deprecated + }), +}); + +// Deprecate an entire endpoint +const legacyEndpoint = defaultEndpointsFactory.build({ + deprecated: true, // All routes using this endpoint are deprecated + // ... rest of definition +}); + +// Deprecate specific routes +const routing: Routing = { + v1: oldEndpoint.deprecated(), // Deprecates /v1 path + v2: newEndpoint, // Not deprecated +}; +``` + +## Multiple Server URLs + +Provide multiple server URLs for different environments: + +```typescript +new Documentation({ + routing, + config, + title: "My API", + version: "1.0.0", + serverUrl: [ + "https://api.example.com", + "https://staging-api.example.com", + "http://localhost:8080", + ], +}); +``` + +## Composition Modes + +### Inline Mode (Default) + +Schemas are defined directly in each endpoint: + +```yaml +paths: + /user: + get: + parameters: + - name: id + schema: + type: string + responses: + 200: + content: + application/json: + schema: + type: object + properties: + name: + type: string +``` + +### Components Mode + +Schemas are extracted to a reusable components section: + +```typescript +new Documentation({ + // ... other options + composition: "components", +}); +``` + +```yaml +paths: + /user: + get: + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' +components: + schemas: + UserResponse: + type: object + properties: + name: + type: string +``` + + +Use `"components"` mode to reduce file size when schemas are reused across endpoints. + + +## Custom Descriptions + +Generate dynamic descriptions for different components: + +```typescript +new Documentation({ + routing, + config, + title: "My API", + version: "1.0.0", + serverUrl: "https://api.example.com", + descriptions: { + positiveResponse: ({ method, path, statusCode }) => + `Successful ${method.toUpperCase()} response for ${path} (${statusCode})`, + negativeResponse: ({ method, path, statusCode }) => + `Error response for ${method.toUpperCase()} ${path} (${statusCode})`, + requestParameter: ({ operationId }) => + `Parameters for operation ${operationId}`, + requestBody: ({ method, path }) => + `Request body for ${method.toUpperCase()} ${path}`, + }, +}); +``` + +## Custom Schema Names + +Provide custom names for schemas using metadata: + +```typescript +const userSchema = z.object({ + id: z.string(), + name: z.string(), +}).meta({ id: "User" }); + +const endpoint = defaultEndpointsFactory.build({ + output: userSchema, + // ... +}); +``` + +The schema will appear as `User` in the components section when using `composition: "components"`. + +## Branded Types + +Customize how branded types appear in documentation: + +```typescript +import { z } from "zod"; +import { Documentation, Depicter } from "express-zod-api"; + +const myBrand = Symbol("UserId"); +const userIdSchema = z.string().brand(myBrand); + +const customHandler: Depicter = ( + { zodSchema, jsonSchema }, + { path, method, isResponse } +) => ({ + ...jsonSchema, + type: "string", + pattern: "^user_[a-z0-9]+$", + example: "user_123abc", + description: "A unique user identifier", +}); + +new Documentation({ + routing, + config, + title: "My API", + version: "1.0.0", + serverUrl: "https://api.example.com", + brandHandling: { + [myBrand]: customHandler, + }, +}); +``` + +## Serving Documentation + +Use the generated YAML with Swagger UI: + +```typescript +import express from "express"; +import swaggerUi from "swagger-ui-express"; +import { createConfig } from "express-zod-api"; +import YAML from "yaml"; +import { readFileSync } from "fs"; + +const spec = YAML.parse( + readFileSync("api-documentation.yaml", "utf-8") +); + +const config = createConfig({ + beforeRouting: ({ app }) => { + app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec)); + }, + // ... other config +}); +``` + +Now visit `https://your-api.com/docs` to view your interactive documentation. + +## Complete Example + +```typescript +import { writeFile } from "node:fs/promises"; +import { Documentation } from "express-zod-api"; +import { config } from "./config"; +import { routing } from "./routing"; +import manifest from "./package.json"; + +// Define tags +declare module "express-zod-api" { + interface TagOverrides { + users: unknown; + admin: unknown; + public: unknown; + } +} + +const documentation = new Documentation({ + routing, + config, + version: manifest.version, + title: "My Awesome API", + serverUrl: [ + "https://api.example.com", + "https://staging.example.com", + ], + composition: "components", + hasSummaryFromDescription: true, + hasHeadMethod: true, + tags: { + users: "User management operations", + admin: "Administrative operations", + public: "Publicly accessible endpoints", + }, +}); + +try { + await writeFile( + "openapi.yaml", + documentation.getSpecAsYaml(), + "utf-8" + ); + console.log("✓ Documentation generated successfully"); +} catch (error) { + console.error("Failed to generate documentation:", error); + process.exit(1); +} +``` + +## Next Steps + +- Learn about [End-to-End Type Safety](/integration/end-to-end-type-safety) with TypeScript clients +- Explore [OpenAPI integration](/integration/openapi) options +- See how to [integrate with Express apps](/integration/express-app) diff --git a/docs/integration/end-to-end-type-safety.mdx b/docs/integration/end-to-end-type-safety.mdx new file mode 100644 index 000000000..f950d5530 --- /dev/null +++ b/docs/integration/end-to-end-type-safety.mdx @@ -0,0 +1,346 @@ +--- +title: End-to-End Type Safety +description: Generate TypeScript clients with complete type safety from your API to frontend +--- + +One of Express Zod API's most powerful features is the ability to generate TypeScript clients that provide complete end-to-end type safety between your backend API and frontend applications. + +## Overview + +The `Integration` class generates TypeScript code containing: + +- Input/output types for all your endpoints +- A fully-typed client for making API requests +- Runtime validation of request and response data +- Support for Server-Sent Events (SSE) subscriptions + +## Quick Start + +Create a script to generate your client: + +```typescript generate-client.ts +import { writeFile } from "node:fs/promises"; +import { Integration } from "express-zod-api"; +import { routing } from "./routing"; +import { config } from "./config"; +import typescript from "typescript"; + +await writeFile( + "client.ts", + await new Integration({ + typescript, + routing, + config, + serverUrl: "https://api.example.com", + }).printFormatted(), + "utf-8" +); +``` + + +Run this script during your build process to keep your client in sync with your API. + + +## Configuration Options + +### Basic Options + + + The TypeScript compiler API. Import from the `typescript` package. + + + + Your API routing configuration. + + + + Your API server configuration. + + + + The base URL where your API is hosted. + + +### Advanced Options + + + What to generate: + - `"types"` - Only TypeScript types (for DIY solutions) + - `"client"` - Full client with types and implementation + + + + Name for the generated client class. + + + + Name for the generated subscription class (for SSE). + + + + Schema for responses without body (like 204 No Content). + + + + Generate HEAD method for each GET endpoint (Express feature). + + + + Custom handling rules for branded schemas. See the Integration class documentation for details. + + +## Using the Generated Client + +### Basic Usage + +The generated client provides type-safe methods for all your endpoints: + +```typescript frontend-app.ts +import { Client } from "./client"; + +const client = new Client(); + +// TypeScript knows the exact shape of inputs and outputs +const response = await client.provide("get /v1/user/retrieve", { + id: "10" +}); + +// response is fully typed based on your endpoint definition +console.log(response.userName); +``` + +### Path Parameters + +The client automatically substitutes path parameters: + +```typescript +// If your route is /v1/user/:id +await client.provide("post /v1/user/:id", { + id: "10", // substituted into the path + name: "John" // sent as body +}); +``` + +### Custom Implementation + +You can provide a custom implementation function to use your preferred HTTP library: + +```typescript +import { Client, Implementation } from "./client"; +import axios from "axios"; + +const customImplementation: Implementation = async ({ + method, + url, + body, + headers, +}) => { + const response = await axios({ + method, + url, + data: body, + headers, + }); + return response.data; +}; + +const client = new Client(customImplementation); +``` + +### Server-Sent Events (SSE) + +For endpoints that use `EventStreamFactory`, use the generated `Subscription` class: + +```typescript +import { Subscription } from "./client"; + +const subscription = new Subscription("get /v1/events/stream", {}); + +subscription.on("time", (timestamp) => { + console.log("Server time:", timestamp); + // TypeScript knows timestamp is a number based on your endpoint +}); + +subscription.on("error", (error) => { + console.error("Stream error:", error); +}); + +// Clean up when done +subscription.close(); +``` + +## Pagination Support + +The client includes a `hasMore()` method for paginated endpoints: + +```typescript +import { Client } from "./client"; + +const client = new Client(); + +let offset = 0; +const limit = 20; + +while (true) { + const response = await client.provide("get /v1/users/list", { + offset, + limit, + }); + + // Process users + response.users.forEach(user => console.log(user.name)); + + // Check if more pages available + if (!client.hasMore(response)) break; + + offset += limit; +} +``` + +## Formatting Options + +### Using Prettier + +The `printFormatted()` method automatically uses Prettier if installed: + +```typescript +const formatted = await integration.printFormatted(); +``` + +You can also provide custom formatting: + +```typescript +const formatted = await integration.printFormatted({ + format: async (code) => { + // Your custom formatter + return prettify(code); + }, + printerOptions: { + // TypeScript printer options + newLine: ts.NewLineKind.LineFeed, + }, +}); +``` + +### Without Formatting + +For unformatted output: + +```typescript +const code = integration.print({ + // Optional TypeScript printer options + newLine: ts.NewLineKind.LineFeed, +}); +``` + +## Async Creation + +If you want to avoid importing TypeScript yourself, use the async `create()` method: + +```typescript +import { Integration } from "express-zod-api"; + +const client = await Integration.create({ + routing, + config, + variant: "client", + // TypeScript is imported automatically +}); +``` + +## Types-Only Generation + +For DIY solutions where you want to implement your own client: + +```typescript +const integration = new Integration({ + typescript, + routing, + config, + variant: "types", // Only generate types +}); +``` + +This generates: +- Input types for all endpoints +- Response types for all endpoints +- Path and method type unions +- Request/response interfaces + +## Complete Example + +Here's a full example with multiple features: + +```typescript generate-client.ts +import { writeFile } from "node:fs/promises"; +import { Integration } from "express-zod-api"; +import { routing } from "./routing"; +import { config } from "./config"; +import typescript from "typescript"; +import { z } from "zod"; + +const integration = new Integration({ + typescript, + routing, + config, + variant: "client", + clientClassName: "ApiClient", + subscriptionClassName: "ApiSubscription", + serverUrl: process.env.API_URL || "http://localhost:8080", + hasHeadMethod: true, + noContent: z.undefined(), +}); + +try { + const output = await integration.printFormatted(); + await writeFile("src/api/client.ts", output, "utf-8"); + console.log("✓ Client generated successfully"); +} catch (error) { + console.error("Failed to generate client:", error); + process.exit(1); +} +``` + +```typescript frontend-usage.ts +import { ApiClient, ApiSubscription } from "./api/client"; + +const api = new ApiClient(); + +// Type-safe API calls +const user = await api.provide("get /v1/user/:id", { id: "123" }); +console.log(user.name, user.email); + +// Type-safe subscriptions +const events = new ApiSubscription("get /v1/events", {}); +events.on("update", (data) => { + // data is fully typed + console.log("Update:", data); +}); +``` + + +The generated client requires TypeScript 4.1 or higher to consume. + + +## Benefits + + + + Compile-time verification of request parameters and response handling + + + Full IDE support with autocomplete for all endpoints and their types + + + Changes to your API automatically surface as TypeScript errors in frontend + + + Types serve as inline documentation for API consumers + + + +## Next Steps + +- Learn about [OpenAPI documentation](/integration/openapi) generation +- Explore [branded types](/api/proprietary-schemas) +- Set up [pagination](/features/pagination) in your endpoints diff --git a/docs/integration/express-app.mdx b/docs/integration/express-app.mdx new file mode 100644 index 000000000..fce3f4b5e --- /dev/null +++ b/docs/integration/express-app.mdx @@ -0,0 +1,519 @@ +--- +title: Express App Integration +description: Integrate Express Zod API with your existing Express application +--- + +If you already have an Express application or need more control over your server configuration, you can integrate Express Zod API into your existing setup. + +## Why Integrate? + +You might want to integrate Express Zod API with your own Express app when: + +- You have an existing Express application +- You need custom middleware not provided by the framework +- You want to mix Express Zod API endpoints with traditional Express routes +- You need fine-grained control over server configuration +- You're migrating an existing API gradually + +## Basic Integration + +Use `attachRouting()` to connect your endpoints to an existing Express app: + +```typescript +import express from "express"; +import { createConfig, attachRouting, Routing } from "express-zod-api"; +import { routing } from "./routing"; + +const app = express(); + +const config = createConfig({ + app, // Provide your Express app + cors: true, + logger: { level: "debug" }, +}); + +const { notFoundHandler, logger } = attachRouting(config, routing); + +// Optional: Handle 404 errors +app.use(notFoundHandler); + +// Start your server +const PORT = process.env.PORT || 8080; +app.listen(PORT, () => { + logger.info(`Server listening on port ${PORT}`); +}); +``` + + +When using your own Express app, you need to: +- Parse `request.body` yourself (e.g., with `express.json()`) +- Call `app.listen()` yourself +- Handle 404 errors yourself (using the provided `notFoundHandler`) + + +## With Express Router + +You can also attach routing to an Express Router: + +```typescript +import express from "express"; +import { createConfig, attachRouting } from "express-zod-api"; +import { apiRouting } from "./api-routing"; + +const app = express(); +const apiRouter = express.Router(); + +const config = createConfig({ + app: apiRouter, // Attach to router instead +}); + +attachRouting(config, apiRouting); + +// Mount the router on a prefix +app.use("/api", apiRouter); + +app.listen(8080); +``` + +## Request Body Parsing + +Express Zod API needs parsed request bodies. Set up parsers before attaching routing: + +```typescript +import express from "express"; +import { createConfig, attachRouting } from "express-zod-api"; + +const app = express(); + +// Parse JSON bodies +app.use(express.json()); + +// Parse URL-encoded bodies (for forms) +app.use(express.urlencoded({ extended: true })); + +// Parse raw bodies (for binary data) +app.use(express.raw({ type: "application/octet-stream" })); + +const config = createConfig({ app }); +const { notFoundHandler } = attachRouting(config, routing); + +app.use(notFoundHandler); +app.listen(8080); +``` + + +If you don't provide an `app` in config, Express Zod API automatically sets up these parsers for you. + + +## Mixing with Traditional Routes + +Combine Express Zod API endpoints with traditional Express routes: + +```typescript +import express from "express"; +import { createConfig, attachRouting } from "express-zod-api"; +import { apiRouting } from "./api-routing"; + +const app = express(); +app.use(express.json()); + +// Traditional Express route +app.get("/health", (req, res) => { + res.json({ status: "ok", timestamp: Date.now() }); +}); + +// Express Zod API routes +const config = createConfig({ app }); +const { notFoundHandler } = attachRouting(config, apiRouting); + +// More traditional routes +app.get("/version", (req, res) => { + res.json({ version: "1.0.0" }); +}); + +// 404 handler should be last +app.use(notFoundHandler); + +app.listen(8080); +``` + +## Static File Serving + +Serve static files alongside your API: + +```typescript +import express from "express"; +import { createConfig, attachRouting, Routing, ServeStatic } from "express-zod-api"; +import path from "path"; + +const app = express(); + +// Serve static files from public directory +app.use("/static", express.static(path.join(__dirname, "public"))); + +// You can also use ServeStatic in routing +const routing: Routing = { + api: { + // Your API endpoints + }, + // Serve files from ./assets at /public + public: new ServeStatic("assets", { + dotfiles: "deny", + index: false, + }), +}; + +const config = createConfig({ app }); +attachRouting(config, routing); + +app.listen(8080); +``` + +## Custom Middleware + +Add middleware that runs before Express Zod API routes: + +```typescript +import express from "express"; +import { createConfig, attachRouting } from "express-zod-api"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; + +const app = express(); + +// Security middleware +app.use(helmet()); + +// Rate limiting +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // Limit each IP to 100 requests per windowMs +}); +app.use(limiter); + +// Request logging +app.use((req, res, next) => { + console.log(`${req.method} ${req.path}`); + next(); +}); + +const config = createConfig({ app }); +attachRouting(config, routing); + +app.listen(8080); +``` + +## Using beforeRouting Option + +For middleware that needs access to the logger or should run right before routing: + +```typescript +import { createConfig, attachRouting } from "express-zod-api"; +import swaggerUi from "swagger-ui-express"; +import { readFileSync } from "fs"; +import YAML from "yaml"; + +const spec = YAML.parse(readFileSync("openapi.yaml", "utf-8")); + +const config = createConfig({ + beforeRouting: ({ app, getLogger }) => { + const logger = getLogger(); + + // Serve API documentation + logger.info("Serving API docs at /docs"); + app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec)); + + // Custom middleware with logger access + app.use("/api", (req, res, next) => { + logger.debug(`API request: ${req.method} ${req.path}`); + next(); + }); + }, +}); + +attachRouting(config, routing); +``` + +## Error Handling + +Implement custom error handling: + +```typescript +import express from "express"; +import { createConfig, attachRouting } from "express-zod-api"; + +const app = express(); +app.use(express.json()); + +const config = createConfig({ app }); +const { notFoundHandler, logger } = attachRouting(config, routing); + +// 404 handler +app.use(notFoundHandler); + +// Global error handler (must be last) +app.use((err, req, res, next) => { + logger.error("Unhandled error:", err); + + res.status(err.statusCode || 500).json({ + status: "error", + message: process.env.NODE_ENV === "production" + ? "Internal server error" + : err.message, + }); +}); + +app.listen(8080); +``` + +## HTTPS with Custom Server + +Create an HTTPS server with your own configuration: + +```typescript +import express from "express"; +import https from "https"; +import { readFileSync } from "fs"; +import { createConfig, attachRouting } from "express-zod-api"; + +const app = express(); +app.use(express.json()); + +const config = createConfig({ app }); +const { logger } = attachRouting(config, routing); + +const httpsOptions = { + key: readFileSync("privkey.pem"), + cert: readFileSync("fullchain.pem"), +}; + +const server = https.createServer(httpsOptions, app); + +server.listen(443, () => { + logger.info("HTTPS server running on port 443"); +}); +``` + +## WebSocket Support + +Combine with WebSocket libraries: + +```typescript +import express from "express"; +import { createServer } from "http"; +import { WebSocketServer } from "ws"; +import { createConfig, attachRouting } from "express-zod-api"; + +const app = express(); +const server = createServer(app); +const wss = new WebSocketServer({ server }); + +app.use(express.json()); + +const config = createConfig({ app }); +attachRouting(config, routing); + +// WebSocket handling +wss.on("connection", (ws) => { + console.log("WebSocket client connected"); + + ws.on("message", (data) => { + console.log("Received:", data.toString()); + }); +}); + +server.listen(8080); +``` + + +For a fully type-safe WebSocket solution, consider [Zod Sockets](https://github.com/RobinTail/zod-sockets), a companion framework by the same author. + + +## Database Connections + +Initialize database connections before starting the server: + +```typescript +import express from "express"; +import mongoose from "mongoose"; +import { createConfig, attachRouting } from "express-zod-api"; + +const app = express(); +app.use(express.json()); + +async function start() { + // Connect to database + await mongoose.connect(process.env.MONGODB_URI!); + console.log("Connected to MongoDB"); + + const config = createConfig({ app }); + const { logger } = attachRouting(config, routing); + + app.listen(8080, () => { + logger.info("Server started on port 8080"); + }); +} + +start().catch(console.error); +``` + +## Gradual Migration + +Migrate an existing API gradually: + +```typescript +import express from "express"; +import { createConfig, attachRouting } from "express-zod-api"; +import { legacyRoutes } from "./legacy-routes"; +import { newApiRouting } from "./new-api-routing"; + +const app = express(); +app.use(express.json()); + +// Mount legacy routes +app.use("/api/v1", legacyRoutes); + +// Mount new Express Zod API routes +const config = createConfig({ app }); +const { notFoundHandler } = attachRouting(config, newApiRouting); + +app.use(notFoundHandler); +app.listen(8080); +``` + +Your routing file: + +```typescript new-api-routing.ts +import { Routing } from "express-zod-api"; +import { getUserEndpoint, updateUserEndpoint } from "./endpoints"; + +export const newApiRouting: Routing = { + api: { + v2: { // New version + user: { + ":id": { + get: getUserEndpoint, + patch: updateUserEndpoint, + }, + }, + }, + }, +}; +``` + +## Testing + +Test your integrated application: + +```typescript +import request from "supertest"; +import express from "express"; +import { createConfig, attachRouting } from "express-zod-api"; +import { routing } from "./routing"; + +describe("API Integration", () => { + let app: express.Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + + const config = createConfig({ + app, + logger: { level: "silent" }, // Silence logs during tests + }); + + attachRouting(config, routing); + }); + + it("should respond to health check", async () => { + const response = await request(app) + .get("/health") + .expect(200); + + expect(response.body).toEqual({ status: "ok" }); + }); + + it("should handle API endpoint", async () => { + const response = await request(app) + .get("/api/user/123") + .expect(200); + + expect(response.body.data).toHaveProperty("id", "123"); + }); +}); +``` + +## Complete Example + +Here's a complete integration example with many features: + +```typescript server.ts +import express from "express"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import swaggerUi from "swagger-ui-express"; +import YAML from "yaml"; +import { readFileSync } from "fs"; +import { createConfig, attachRouting } from "express-zod-api"; +import { apiRouting } from "./routing"; + +const app = express(); + +// Security +app.use(helmet()); + +// Rate limiting +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, +}); +app.use("/api", limiter); + +// Body parsing +app.use(express.json({ limit: "10mb" })); +app.use(express.urlencoded({ extended: true })); + +// Serve documentation +const spec = YAML.parse(readFileSync("openapi.yaml", "utf-8")); +app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec)); + +// Health check +app.get("/health", (req, res) => { + res.json({ status: "ok", timestamp: Date.now() }); +}); + +// Attach Express Zod API routing +const config = createConfig({ + app, + cors: true, + logger: { level: "info" }, +}); + +const { notFoundHandler, logger } = attachRouting(config, apiRouting); + +// 404 handler +app.use(notFoundHandler); + +// Error handler +app.use((err, req, res, next) => { + logger.error("Error:", err); + res.status(500).json({ + status: "error", + message: "Internal server error", + }); +}); + +// Start server +const PORT = process.env.PORT || 8080; +app.listen(PORT, () => { + logger.info(`🚀 Server running on http://localhost:${PORT}`); + logger.info(`📚 Documentation at http://localhost:${PORT}/docs`); +}); +``` + +## Next Steps + +- Learn about [OpenAPI documentation](/integration/openapi) generation +- Explore [end-to-end type safety](/integration/end-to-end-type-safety) +- Check out [middleware options](/essentials/middlewares) diff --git a/docs/integration/openapi.mdx b/docs/integration/openapi.mdx new file mode 100644 index 000000000..d1f4144f0 --- /dev/null +++ b/docs/integration/openapi.mdx @@ -0,0 +1,519 @@ +--- +title: OpenAPI Integration +description: Deep dive into OpenAPI 3.1 specification generation and integration options +--- + +Express Zod API generates OpenAPI 3.1 (formerly Swagger) compliant documentation, the industry standard for describing RESTful APIs. + +## What is OpenAPI 3.1? + +OpenAPI 3.1 is a specification for describing HTTP APIs in a machine-readable format. It provides: + +- Standardized API documentation +- Interactive API explorers (like Swagger UI) +- Code generation for clients and servers +- API testing and validation tools +- JSON Schema compatibility + + +OpenAPI 3.1 fully embraces JSON Schema, making it more powerful than previous versions. + + +## Key Features + +Express Zod API's OpenAPI generator provides: + + + + Documentation is generated directly from your Zod schemas and endpoint definitions + + + Your TypeScript types and OpenAPI schemas are always in sync + + + Full JSON Schema compatibility for maximum interoperability + + + Support for examples, descriptions, deprecation, and more + + + +## Basic Usage + +Generate OpenAPI documentation: + +```typescript +import { Documentation } from "express-zod-api"; +import { routing } from "./routing"; +import { config } from "./config"; + +const openapi = new Documentation({ + routing, + config, + version: "1.0.0", + title: "My API", + serverUrl: "https://api.example.com", +}); + +// Get as YAML string +const yaml = openapi.getSpecAsYaml(); + +// Or access the spec object directly +const spec = openapi.rootDoc; +``` + +## Schema Mapping + +Here's how Zod schemas map to OpenAPI types: + +| Zod Schema | OpenAPI Type | Notes | +|------------|--------------|-------| +| `z.string()` | `type: string` | | +| `z.number()` | `type: number` | | +| `z.boolean()` | `type: boolean` | | +| `z.object()` | `type: object` | With properties | +| `z.array()` | `type: array` | With items | +| `z.enum()` | `enum: [...]` | Enumeration values | +| `z.literal()` | `const: value` | Single value | +| `z.union()` | `anyOf: [...]` | Multiple schemas | +| `z.intersection()` | `allOf: [...]` | Combined schemas | +| `z.optional()` | No `required` | Property is optional | +| `z.nullable()` | `type: ["...", "null"]` | OpenAPI 3.1 style | +| `ez.dateIn()` | `type: string, format: date-time` | ISO 8601 string | +| `ez.dateOut()` | `type: string, format: date-time` | ISO 8601 string | +| `ez.upload()` | `type: string, format: binary` | File upload | + +## Security Schemes + +Express Zod API automatically generates security schemes from your middleware definitions: + +```typescript +import { Middleware } from "express-zod-api"; +import { z } from "zod"; + +const authMiddleware = new Middleware({ + security: { + // Generates API key security scheme + and: [ + { type: "input", name: "apiKey" }, + { type: "header", name: "X-API-Token" }, + ], + }, + input: z.object({ + apiKey: z.string().min(1), + }), + handler: async ({ input, request }) => { + // Validate authentication + const token = request.headers["x-api-token"]; + // ... + return { userId: "123" }; + }, +}); +``` + +This generates: + +```yaml +securitySchemes: + INPUT_1: + type: apiKey + in: query + name: apiKey + HEADER_1: + type: apiKey + in: header + name: X-API-Token +``` + +### Security Types + +Supported security scheme types: + + + API key in query parameter or request body + - `name`: Parameter name + + + + API key in request header + - `name`: Header name (case-insensitive) + + + + API key in cookie + - `name`: Cookie name + + + + HTTP authentication (Basic, Bearer, etc.) + - `scheme`: Authentication scheme (e.g., "bearer", "basic") + + + + OAuth 2.0 authentication + - `flows`: OAuth flows configuration + + + + OpenID Connect Discovery + - `url`: OpenID Connect URL + + +## Request and Response Examples + +Add examples to your schemas for better documentation: + +```typescript +import { z } from "zod"; +import { defaultEndpointsFactory } from "express-zod-api"; + +const createUserEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + name: z.string() + .min(1) + .example("John Doe") + .describe("User's full name"), + email: z.string() + .email() + .example("john@example.com") + .describe("User's email address"), + age: z.number() + .int() + .positive() + .optional() + .example(30) + .describe("User's age in years"), + }), + output: z.object({ + id: z.string().example("user_123"), + name: z.string().example("John Doe"), + email: z.string().email().example("john@example.com"), + createdAt: z.string().example("2024-01-01T00:00:00Z"), + }), + handler: async ({ input }) => { + // Implementation + return { + id: "user_123", + name: input.name, + email: input.email, + createdAt: new Date().toISOString(), + }; + }, +}); +``` + +Generates: + +```yaml +requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: User's full name + example: John Doe + email: + type: string + format: email + description: User's email address + example: john@example.com +``` + +## Path Parameters + +Path parameters are automatically detected and documented: + +```typescript +import { Routing } from "express-zod-api"; +import { z } from "zod"; + +const getUserEndpoint = defaultEndpointsFactory.build({ + input: z.object({ + userId: z.string() + .describe("The unique user identifier"), + }), + // ... +}); + +const routing: Routing = { + user: { + ":userId": getUserEndpoint, + }, +}; +``` + +Generates: + +```yaml +paths: + /user/{userId}: + get: + parameters: + - name: userId + in: path + required: true + description: The unique user identifier + schema: + type: string +``` + +## Response Status Codes + +Document different status codes using custom result handlers: + +```typescript +import { ResultHandler } from "express-zod-api"; +import { z } from "zod"; + +const customResultHandler = new ResultHandler({ + positive: (data) => ({ + statusCode: [200, 201], // OK or Created + schema: z.object({ + status: z.literal("success"), + data + }), + }), + negative: [ + { + statusCode: 400, // Bad Request + schema: z.object({ + status: z.literal("error"), + message: z.string() + }), + }, + { + statusCode: 401, // Unauthorized + schema: z.object({ + status: z.literal("unauthorized") + }), + }, + { + statusCode: 500, // Internal Server Error + schema: z.object({ + status: z.literal("error"), + message: z.string() + }), + }, + ], + handler: ({ error, response, output }) => { + // Implementation + }, +}); +``` + +## Content Types + +Express Zod API supports various content types: + +### JSON (Default) + +```typescript +// application/json - default for most endpoints +const endpoint = defaultEndpointsFactory.build({ + input: z.object({ name: z.string() }), + // ... +}); +``` + +### Form Data + +```typescript +import { ez } from "express-zod-api"; + +// application/x-www-form-urlencoded +const formEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.form({ + name: z.string(), + email: z.string().email(), + }), + // ... +}); +``` + +### File Uploads + +```typescript +import { ez } from "express-zod-api"; + +// multipart/form-data +const uploadEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + avatar: ez.upload(), + }), + // ... +}); +``` + +### Raw Data + +```typescript +import { ez } from "express-zod-api"; + +// application/octet-stream +const rawEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: ez.raw({}), + // ... +}); +``` + +## Using Generated Documentation + +### With Swagger UI + +```typescript +import express from "express"; +import swaggerUi from "swagger-ui-express"; +import YAML from "yaml"; +import { readFileSync } from "fs"; +import { createConfig } from "express-zod-api"; + +const spec = YAML.parse( + readFileSync("openapi.yaml", "utf-8") +); + +const config = createConfig({ + beforeRouting: ({ app }) => { + app.use( + "/docs", + swaggerUi.serve, + swaggerUi.setup(spec, { + explorer: true, + customCss: '.swagger-ui .topbar { display: none }', + }) + ); + }, + // ... other config +}); +``` + +### With Redoc + +```typescript +import { createConfig } from "express-zod-api"; +import { readFileSync } from "fs"; + +const config = createConfig({ + beforeRouting: ({ app }) => { + const spec = readFileSync("openapi.yaml", "utf-8"); + + app.get("/docs", (req, res) => { + res.send(` + + + + API Documentation + + + + + + + + + + `); + }); + + app.get("/openapi.yaml", (req, res) => { + res.type("text/yaml").send(spec); + }); + }, +}); +``` + +### With API Clients + +Generate clients using OpenAPI Generator: + +```bash +# Generate TypeScript Axios client +npx @openapitools/openapi-generator-cli generate \ + -i openapi.yaml \ + -g typescript-axios \ + -o ./generated-client + +# Generate Python client +npx @openapitools/openapi-generator-cli generate \ + -i openapi.yaml \ + -g python \ + -o ./python-client +``` + + +While OpenAPI Generator can create clients, Express Zod API's [type-safe client generator](/integration/end-to-end-type-safety) provides superior TypeScript integration. + + +## Best Practices + +### 1. Use Descriptive Names + +```typescript +// Good +const getUserByIdEndpoint = factory.build({ /* ... */ }); + +// Less good +const endpoint1 = factory.build({ /* ... */ }); +``` + +### 2. Provide Examples + +```typescript +z.string().example("example-value") + .describe("Clear description of what this is") +``` + +### 3. Tag Your Endpoints + +```typescript +factory.build({ + tag: "users", + // ... +}); +``` + +### 4. Add Deprecation Warnings + +```typescript +factory.build({ + deprecated: true, // Marks endpoint as deprecated + input: z.object({ + oldField: z.string().deprecated(), // Marks field as deprecated + }), +}); +``` + +### 5. Use Component Mode for Large APIs + +```typescript +new Documentation({ + composition: "components", // Reduces duplication + // ... +}); +``` + +## Validation + +Validate your generated OpenAPI spec: + +```bash +# Using swagger-cli +npm install -g @apidevtools/swagger-cli +swagger-cli validate openapi.yaml + +# Using openapi-cli +npm install -g @redocly/cli +openapi lint openapi.yaml +``` + +## Next Steps + +- Learn about [generating TypeScript clients](/integration/end-to-end-type-safety) +- Explore [documentation generation](/integration/documentation) features +- See [integration with Express apps](/integration/express-app) diff --git a/docs/integration/zod-plugin.mdx b/docs/integration/zod-plugin.mdx new file mode 100644 index 000000000..de55f1943 --- /dev/null +++ b/docs/integration/zod-plugin.mdx @@ -0,0 +1,185 @@ +--- +title: Zod Plugin +description: Learn how Express Zod API extends Zod with additional runtime helpers and methods +--- + +Express Zod API augments Zod using the standalone [Zod Plugin](https://www.npmjs.com/package/@express-zod-api/zod-plugin) package, adding runtime helpers and convenience methods that the framework relies on. + +## What the Plugin Provides + +The plugin extends Zod functionality with several helpful methods and modifications: + +### `.example()` Method + +A shorthand for adding examples to any schema: + +```typescript +import { z } from "zod"; + +const schema = z.string() + .example("test") + .example("another"); + +schema.meta(); // { examples: ["test", "another"] } +``` + +This is equivalent to `.meta({ examples: [...] })` but more convenient. Examples appear in generated documentation. + +### `.deprecated()` Method + +Mark schemas as deprecated: + +```typescript +const endpoint = factory.build({ + input: z.object({ + oldField: z.string().deprecated(), // marks the property as deprecated + newField: z.string(), + }), +}); +``` + +This is shorthand for `.meta({ deprecated: true })` and appears in OpenAPI documentation. + +### `.label()` Method + +Available on `ZodDefault` schemas as a shorthand for setting default metadata: + +```typescript +const schema = z.string() + .default("example") + .label("Example Label"); +``` + +### `.remap()` Method + +Renames object properties while maintaining type safety: + +```typescript +import { z } from "zod"; + +const schema = z.object({ + user_name: z.string(), + id: z.number() +}).remap({ + user_name: "userName", // rename to camelCase + // "id" remains unchanged +}); +``` + +You can also use a transformation function: + +```typescript +import camelize from "camelize-ts"; +import { z } from "zod"; + +const schema = z.object({ + user_id: z.string() +}).remap((outputs) => camelize(outputs, /* shallow: */ true)); +``` + +See [Transformations](/features/transformations) for more details. + +### Modified `.brand()` Method + +The `.brand()` method is enhanced to make brands available at runtime: + +```typescript +import { z } from "zod"; +import { getBrand } from "@express-zod-api/zod-plugin"; + +const myBrand = Symbol("MyBrand"); +const schema = z.string().brand(myBrand); + +getBrand(schema); // returns the brand symbol +schema.meta(); // { "x-brand": myBrand } +``` + +This enables custom handling in Documentation and Integration generators. + +## Helpers + +### `getBrand()` + +Retrieves the brand from a schema: + +```typescript +import { getBrand } from "@express-zod-api/zod-plugin"; + +const brand = getBrand(schema); +``` + +## Installation + +The plugin is installed automatically as a dependency of Express Zod API. It requires: + +- Zod `^4.3.4` +- Node.js (compatible with Express Zod API requirements) + +## How It Works + +The plugin uses module augmentation to extend Zod's types and prototypes. It works in both ESM and CommonJS environments: + +```typescript +// Simply import Zod as usual +import { z } from "zod"; + +// All plugin methods are automatically available +const schema = z.string().example("test").deprecated(); +``` + + +The plugin extends Zod globally when Express Zod API is imported, so you don't need to import or configure anything separately. + + +## Compatibility + +The plugin is designed to work seamlessly with: + +- OpenAPI documentation generation +- TypeScript client generation +- All standard Zod schema types +- Zod v4.x (for Zod v3.x, use Express Zod API versions below 24.0.0) + +## Example Usage + +Here's a complete example using multiple plugin features: + +```typescript +import { z } from "zod"; +import { defaultEndpointsFactory, getBrand } from "express-zod-api"; + +const myBrand = Symbol("UserId"); + +const updateUserEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + userId: z.string() + .brand(myBrand) + .example("user_123") + .describe("The unique user identifier"), + // Old field being phased out + legacyId: z.number() + .optional() + .deprecated(), + }), + output: z.object({ + user_name: z.string(), + created_at: z.string(), + }).remap({ + user_name: "userName", + created_at: "createdAt", + }), + handler: async ({ input }) => { + // Handler implementation + return { userName: "John", createdAt: new Date().toISOString() }; + }, +}); + +console.log(getBrand(updateUserEndpoint.inputSchema.shape.userId)); // Symbol(UserId) +``` + +This example demonstrates: +- Setting examples for documentation +- Marking deprecated fields +- Using branded types +- Remapping output property names diff --git a/docs/introduction.mdx b/docs/introduction.mdx new file mode 100644 index 000000000..ad48dde0c --- /dev/null +++ b/docs/introduction.mdx @@ -0,0 +1,155 @@ +--- +title: Introduction +description: Build type-safe REST APIs with Express, Zod, and TypeScript in minutes +--- + +## What is Express Zod API? + +Express Zod API is a TypeScript-first framework that helps you build production-ready REST APIs with automatic input/output validation, type safety, and OpenAPI documentation generation. It combines the power of Express.js, Zod validation, and TypeScript to eliminate boilerplate and ensure type safety across your entire API. + + + + Full TypeScript integration with automatic type inference from Zod schemas + + + OpenAPI 3.1 documentation generated directly from your code + + + Export client types to your frontend for complete type safety + + + Leverage the mature Express.js ecosystem with added type safety + + + +## Why Express Zod API? + +Building REST APIs often involves repetitive tasks: validating input, handling errors consistently, maintaining documentation, and ensuring type safety between frontend and backend. Express Zod API solves these problems by: + +### Validation Made Easy + +Define your input and output schemas once using Zod, and validation happens automatically. No more manual type checking or runtime errors from unexpected data types. + +```typescript +import { z } from "zod"; +import { defaultEndpointsFactory } from "express-zod-api"; + +const endpoint = defaultEndpointsFactory.build({ + input: z.object({ + userId: z.string().transform(Number), + email: z.string().email(), + }), + output: z.object({ + success: z.boolean(), + }), + handler: async ({ input }) => { + // input is fully typed and validated + return { success: true }; + }, +}); +``` + +### Consistent Error Handling + +All errors are handled uniformly with appropriate HTTP status codes. Input validation errors automatically return `400`, while custom errors can use any status code you need. + +### Documentation That Never Goes Stale + +Your API documentation is generated directly from your code. When you change an endpoint, the documentation updates automatically. + +### Frontend Integration + +Export TypeScript types and a fully-typed client for your frontend. Changes to your API are immediately reflected in your client code, catching breaking changes at compile time. + +## Key Features + + + + - Automatic validation of request body, query parameters, and path params + - Type transformations (string to number, date parsing, etc.) + - Custom refinements for complex validation logic + - File upload validation with size limits + + + + - Type-safe middleware with context passing + - Authentication and authorization helpers + - Compatible with native Express middlewares + - Composable middleware chains + + + + - Nested and flat route syntax + - Path parameters with validation + - Method-based routing + - Static file serving + + + + - OpenAPI 3.1 specification generation + - Swagger UI integration + - TypeScript client generation + - End-to-end type safety + + + + - CORS configuration + - HTTPS support + - Response compression + - Custom loggers (Winston, Pino) + - Graceful shutdown + - Testing utilities + + + +## Who Should Use Express Zod API? + +Express Zod API is perfect for: + +- **TypeScript developers** building REST APIs who want compile-time type safety +- **Full-stack teams** who need guaranteed type safety between frontend and backend +- **API developers** who are tired of maintaining separate API documentation +- **Teams migrating** from JavaScript to TypeScript who want a smooth transition +- **Startups and agencies** who need to build reliable APIs quickly + +## Comparison with Alternatives + + + **Coming from tRPC?** Express Zod API provides similar type safety but for REST APIs instead of RPC-style endpoints. You get standard HTTP methods, OpenAPI docs, and compatibility with any HTTP client. + + + + **Coming from NestJS?** Express Zod API is lighter weight with less boilerplate. It focuses on type safety through Zod schemas rather than decorators, making it easier to learn and maintain. + + + + **Using plain Express?** Express Zod API adds automatic validation, type safety, documentation generation, and consistent error handling while keeping the familiar Express patterns you know. + + +## Core Concepts + +Before diving in, here are the main concepts you'll work with: + +- **Endpoints**: Your API route handlers with validated input/output +- **Factories**: Used to create endpoints, optionally with middleware attached +- **Middlewares**: Reusable logic for authentication, logging, etc. +- **Result Handlers**: Control how responses are formatted +- **Routing**: Define your API structure as a nested object +- **Config**: Central configuration for server, CORS, logging, etc. + +## Next Steps + + + + Set up Express Zod API in your project + + + Build your first API in minutes + + + Understand the architecture and data flow + + + Explore real-world code examples + + diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx new file mode 100644 index 000000000..9013ac674 --- /dev/null +++ b/docs/quickstart.mdx @@ -0,0 +1,298 @@ +--- +title: "Quickstart" +description: "Build your first Express Zod API in 5 minutes" +--- + +Get a working API server up and running with I/O validation in just a few minutes. + + + **Already installed Express Zod API?** If not, follow the [installation guide](/installation) first. + + +## Step 1: Configure TypeScript + +Make sure your `tsconfig.json` has strict mode enabled: + +```json tsconfig.json +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true + } +} +``` + +## Step 2: Create Configuration + +Create a configuration file for your server: + +```typescript config.ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + http: { listen: 8090 }, + cors: true, +}); + +export default config; +``` + + + Port number for the HTTP server + + + + Enable cross-origin resource sharing + + +## Step 3: Create Your First Endpoint + +Create an endpoint that responds with a greeting: + +```typescript endpoints.ts +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +export const helloEndpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + name: z.string().optional(), + }), + output: z.object({ + greetings: z.string(), + }), + handler: async ({ input: { name }, logger }) => { + logger.debug(`Greeting ${name || "World"}`); + return { greetings: `Hello, ${name || "World"}. Happy coding!` }; + }, +}); +``` + +### What's Happening Here? + + + + The `input` defines what data the endpoint accepts. Here, an optional `name` string from the query parameters. + + + + The `output` defines what data the endpoint returns. The response is validated against this schema. + + + + The async `handler` receives validated input and returns validated output. TypeScript knows the exact types! + + + +## Step 4: Set Up Routing + +Connect your endpoint to a URL path: + +```typescript routing.ts +import { Routing } from "express-zod-api"; +import { helloEndpoint } from "./endpoints"; + +const routing: Routing = { + v1: { + hello: helloEndpoint, + }, +}; + +export default routing; +``` + +This creates the route `GET /v1/hello`. + +## Step 5: Start the Server + +Create your server entry point: + +```typescript index.ts +import { createServer } from "express-zod-api"; +import config from "./config"; +import routing from "./routing"; + +createServer(config, routing); +``` + +## Step 6: Test Your API + +Start your server: + +```bash +tsx index.ts +# or: node --loader tsx index.ts +# or: ts-node index.ts +``` + +Test with curl: + +```bash +curl "http://localhost:8090/v1/hello?name=Rick" +``` + +You should see: + +```json +{ + "status": "success", + "data": { + "greetings": "Hello, Rick. Happy coding!" + } +} +``` + +## Complete Example + +Here's everything in one file: + +```typescript server.ts +import { createServer, createConfig, defaultEndpointsFactory, Routing } from "express-zod-api"; +import { z } from "zod"; + +// Configuration +const config = createConfig({ + http: { listen: 8090 }, + cors: true, +}); + +// Endpoint +const helloEndpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + name: z.string().optional(), + }), + output: z.object({ + greetings: z.string(), + }), + handler: async ({ input: { name } }) => ({ + greetings: `Hello, ${name || "World"}. Happy coding!`, + }), +}); + +// Routing +const routing: Routing = { + v1: { + hello: helloEndpoint, + }, +}; + +// Start server +createServer(config, routing); +``` + +## What's Next? + + + + Learn about input validation, transformations, and advanced endpoint features + + + Protect your endpoints with middleware-based authentication + + + Automatically generate OpenAPI documentation from your endpoints + + + Write tests for your endpoints with built-in testing utilities + + + +## Common Next Steps + +### Add a POST Endpoint + +```typescript +const createUserEndpoint = defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + name: z.string().min(1), + email: z.string().email(), + }), + output: z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }), + handler: async ({ input }) => { + // Save to database + const user = await db.users.create(input); + return user; + }, +}); +``` + +### Add Path Parameters + +```typescript +const routing: Routing = { + v1: { + users: { + ":id": getUserEndpoint, // GET /v1/users/:id + }, + }, +}; + +const getUserEndpoint = defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + id: z.string(), // from path parameter + }), + output: z.object({ + id: z.string(), + name: z.string(), + }), + handler: async ({ input: { id } }) => { + return await db.users.findById(id); + }, +}); +``` + +### Handle Errors + +```typescript +import createHttpError from "http-errors"; + +const endpoint = defaultEndpointsFactory.build({ + handler: async ({ input }) => { + const user = await db.users.findById(input.id); + + if (!user) { + throw createHttpError(404, "User not found"); + } + + return user; + }, +}); +``` + +## Troubleshooting + + + + If port 8090 is already in use, change it in your config: + ```typescript + const config = createConfig({ + http: { listen: 3000 }, // Use a different port + cors: true, + }); + ``` + + + + Make sure you have `strict: true` in your `tsconfig.json` and all required dependencies installed: + ```bash + pnpm add -D @types/express @types/node @types/http-errors + ``` + + + + Enable CORS in your configuration: + ```typescript + const config = createConfig({ + http: { listen: 8090 }, + cors: true, // Enable CORS + }); + ``` + +