UNPKG

zodsei

Version:

Contract-first type-safe HTTP client with Zod validation

503 lines (398 loc) 13.1 kB
# Zodsei A contract-first, type-safe HTTP client with Zod validation for TypeScript. ## Why Zodsei? Zodsei was created to solve the limitations of existing HTTP client libraries: ### The Problem - **Zodios is unmaintained**: The original Zodios library is no longer actively maintained, leaving users without updates and bug fixes - **Poor API design**: Many existing solutions have complex, unintuitive APIs that are hard to use and maintain - **Limited flexibility**: When you can't use tRPC or oRPC, or don't control the backend, you need a flexible contract-first solution - **Type safety gaps**: Most HTTP clients lack comprehensive compile-time type checking and runtime validation ### The Solution Zodsei provides: - **Modern, clean API**: Intuitive contract definition with `{path, method, request, response}` structure - **True contract-first**: Define your API contract once, get full type safety everywhere - **Active maintenance**: Built with modern tooling and actively maintained - **Flexible architecture**: Works with any backend, no server-side requirements - **Complete type safety**: From request to response, with runtime validation ## When to Use Zodsei vs Other Solutions ### For Full-Stack Projects (Recommended Alternatives) If you're developing a **full-stack project** or have **control over the backend**, we recommend using these excellent alternatives: - **[ts-rest](https://ts-rest.com/)** - Contract-first REST APIs with full-stack type safety - **[tRPC](https://trpc.io/)** - End-to-end typesafe APIs made easy - **[oRPC](https://orpc.unnoq.com/)** - Modern RPC framework with excellent TypeScript support These libraries provide superior developer experience when you control both frontend and backend. ### When Zodsei is the Right Choice Use Zodsei when: - 🔌 **Consuming third-party APIs** - You don't control the backend - 🏢 **Working with existing REST APIs** - Legacy systems or external services - 🔄 **Migrating from unmaintained libraries** - Moving away from Zodios or similar - 🎯 **Need flexible HTTP client** - Custom requirements not covered by full-stack solutions - 📱 **Client-only applications** - Mobile apps, browser extensions, or pure frontend projects ## Features - 🔒 **Type-safe**: Full TypeScript support with automatic type inference - 📋 **Contract-first**: Define your API contract once, get type safety everywhere - ✅ **Runtime validation**: Request and response validation using Zod schemas - 🔌 **Middleware support**: Built-in retry, caching, and custom middleware - 🌐 **Multiple HTTP clients**: Support for fetch, axios, and ky through adapters - 🚀 **Minimal dependencies**: Only requires Zod, HTTP clients are optional - 📦 **Modern**: ESM/CJS dual package, works in Node.js and browsers ## Installation ```bash npm install zodsei zod # or pnpm add zodsei zod # or yarn add zodsei zod ``` ## Quick Start ### 1. Define your API contract ```typescript import { z } from 'zod'; import { defineContract } from 'zodsei'; const UserSchema = z.object({ id: z.uuid(), name: z.string(), email: z.email() }); const apiContract = defineContract({ getUser: { path: '/users/:id', method: 'get' as const, request: z.object({ id: z.uuid(), }), response: UserSchema }, createUser: { path: '/users', method: 'post' as const, request: z.object({ name: z.string().min(1), email: z.email() }), response: UserSchema } }); ``` ### 2. Create a client ```typescript import { createClient } from 'zodsei'; const client = createClient(apiContract, { baseUrl: 'https://api.example.com', validateRequest: true, validateResponse: true, // Type-safe adapter configuration - TypeScript infers the correct type based on adapter adapter: 'fetch', // 👈 This determines adapterConfig type (FetchAdapterConfig) adapterConfig: { timeout: 10000, credentials: 'include', // ✅ Valid for fetch mode: 'cors', // ✅ Valid for fetch cache: 'no-cache' // ✅ Valid for fetch // auth: { username: 'user' } // ❌ TypeScript error: not valid for fetch } }); ``` ### 3. Use the client ```typescript // Fully type-safe API calls const user = await client.getUser({ id: '123e4567-e89b-12d3-a456-426614174000' }); // user is automatically typed as { id: string, name: string, email: string } const newUser = await client.createUser({ name: 'John Doe', email: 'john@example.com' }); // newUser is also automatically typed ``` ## Core Concepts ### Type inference on endpoint methods ```ts // Fully typed response inferred from the contract const user = await client.getUser({ id: '123e4567-e89b-12d3-a456-426614174000' }); // `user` type is inferred from the endpoint response schema ``` ### Method-level type helpers: .infer ```ts // Dev-time type helpers derived from the endpoint definition type GetUserRequest = typeof client.getUser.infer.request; type GetUserResponse = typeof client.getUser.infer.response; ``` ### Method-level schemas: .schema ```ts // Runtime access to Zod schemas const reqSchema = client.getUser.schema.request; const resSchema = client.getUser.schema.response; ``` ### Contract-level schema explorer: $schema ```ts // Explore the contract at runtime const endpointPaths = client.$schema.getEndpointPaths(); const info = client.$schema.describeEndpoint('getUser'); // info: { path, method, requestSchema, responseSchema, requestType, responseType } ``` ### Nested contracts ```ts type LoginRequest = typeof client.auth.login.infer.request; const getByIdSchemas = client.users.getById.schema; ``` ### Re-exported z ```ts import { z } from 'zodsei'; // re-exported for convenience ``` ## API Reference ### Contract Definition Each endpoint in your contract should have: - `path`: The API endpoint path (supports path parameters like `:id`) - `method`: HTTP method (`'get' | 'post' | 'put' | 'delete' | 'patch'`) - `request`: Zod schema for request data - `response`: Zod schema for response data #### Basic Contract ```typescript const contract = defineContract({ endpointName: { path: '/api/path/:param', method: 'post', request: z.object({ /* request schema */ }), response: z.object({ /* response schema */ }) } }); ``` #### Nested Contracts Contracts can be nested to organize your API endpoints by feature or module: ```typescript const contract = defineContract({ auth: defineContract({ login: { path: '/auth/login', method: 'post', request: z.object({ email: z.string(), password: z.string() }), response: z.object({ token: z.string() }) }, logout: { path: '/auth/logout', method: 'post', request: z.object({}), response: z.object({ success: z.boolean() }) } }), users: defineContract({ getById: { path: '/users/:id', method: 'get', request: z.object({ id: z.string() }), response: UserSchema } }) }); // Usage with nested structure const loginResult = await client.auth.login({ email, password }); const user = await client.users.getById({ id: '123' }); ``` ### Client Configuration ```typescript interface ClientConfig { baseUrl: string; // Base URL for all requests validateRequest?: boolean; // Enable request validation (default: true) validateResponse?: boolean; // Enable response validation (default: true) headers?: Record<string, string>; // Default headers timeout?: number; // Request timeout in ms (default: 30000) retries?: number; // Number of retries (default: 0) middleware?: Middleware[]; // Custom middleware } ``` ### Middleware Zodsei supports middleware for cross-cutting concerns: #### Retry Middleware ```typescript import { retryMiddleware } from 'zodsei'; const client = createClient(contract, { baseUrl: 'https://api.example.com', middleware: [ retryMiddleware({ retries: 3, delay: 1000, backoff: 'exponential', onRetry: (attempt, error) => { console.log(`Retry attempt ${attempt}:`, error.message); } }) ] }); ``` #### Cache Middleware ```typescript import { cacheMiddleware } from 'zodsei'; const client = createClient(contract, { baseUrl: 'https://api.example.com', middleware: [ cacheMiddleware({ ttl: 60000, // Cache for 1 minute }) ] }); ``` #### Custom Middleware ```typescript const loggingMiddleware = async (request, next) => { console.log('Request:', request); const response = await next(request); console.log('Response:', response); return response; }; const client = createClient(contract, { baseUrl: 'https://api.example.com', middleware: [loggingMiddleware] }); ``` ### HTTP Adapters Zodsei supports multiple HTTP clients through a pluggable adapter system. Choose the adapter that best fits your needs: #### Quick Setup ```typescript // Fetch (default) - Zero dependencies const client = createClient(contract, { baseUrl: 'https://api.example.com' // adapter: 'fetch' is implicit }); // Axios - Full-featured HTTP client const client = createClient(contract, { baseUrl: 'https://api.example.com', adapter: 'axios' }); // Ky - Modern with built-in retry const client = createClient(contract, { baseUrl: 'https://api.example.com', adapter: 'ky' }); ``` For advanced configuration and feature comparison, see the Advanced section below. For request/response lifecycle, use client-level middleware. ### Error Handling Zodsei provides specific error types for different scenarios: ```typescript import { ValidationError, HttpError, NetworkError, TimeoutError } from 'zodsei'; try { const user = await client.getUser({ id: 'invalid-uuid' }); } catch (error) { if (error instanceof ValidationError) { console.log('Validation failed:', error.issues); } else if (error instanceof HttpError) { console.log('HTTP error:', error.status, error.message); } else if (error instanceof NetworkError) { console.log('Network error:', error.message); } else if (error instanceof TimeoutError) { console.log('Request timeout'); } } ``` ## Advanced ### Adapters: Advanced Configuration ```typescript // String-based with config const client = createClient(contract, { baseUrl: 'https://api.example.com', adapter: 'axios', adapterConfig: { timeout: 15000, withCredentials: true } }); ``` ### Feature Comparison | Feature | Fetch | Axios | Ky | |---------|-------|-------|----| | **Bundle Size** | 0KB | ~13KB | ~4KB | | **Dependencies** | None | Required | Required | | **Built-in** | ✅ Native | ❌ Install required | ❌ Install required | | **Platforms** | Node.js, Browser | Node.js, Browser | Node.js, Browser | | **Interceptors** | ❌ | ❌ | ❌ | | **Auto Retry** | ❌ | ❌ | ✅ Built-in | | **Advanced Features** | Basic | Proxy, Auth, etc. | Hooks, Timeout | | **Best For** | Simple APIs | Complex APIs | Modern APIs | ### Middleware (Recommended) Use middleware to implement cross-cutting concerns (auth, logging, retries, error handling): ```typescript const authMiddleware = async (req, next) => { const token = localStorage.getItem('token'); if (token) req.headers.Authorization = `Bearer ${token}`; return next(req); }; const client = createClient(contract, { baseUrl: 'https://api.example.com', middleware: [authMiddleware] }); ``` ### Path Parameters ```typescript const contract = defineContract({ getUserPosts: { path: '/users/:userId/posts/:postId', method: 'get' as const, request: z.object({ userId: z.string().uuid(), postId: z.string().uuid() }), response: PostSchema } }); // Usage const post = await client.getUserPosts({ userId: 'user-uuid', postId: 'post-uuid' }); ``` ### Query Parameters For GET requests, non-path parameters are automatically converted to query parameters: ```typescript const contract = defineContract({ searchUsers: { path: '/users', method: 'get' as const, request: z.object({ q: z.string(), page: z.number().optional(), limit: z.number().optional() }), response: z.object({ users: z.array(UserSchema), total: z.number() }) } }); // Usage - generates: GET /users?q=john&page=1&limit=10 const results = await client.searchUsers({ q: 'john', page: 1, limit: 10 }); ``` ### Request Body For POST/PUT/PATCH requests, the request data is sent as JSON body: ```typescript const contract = defineContract({ updateUser: { path: '/users/:id', method: 'put' as const, request: z.object({ id: z.string().uuid(), // Path parameter name: z.string().optional(), // Body field email: z.string().email().optional() // Body field }), response: UserSchema } }); // Usage const updated = await client.updateUser({ id: 'user-uuid', name: 'New Name', email: 'new@example.com' }); ``` ## License MIT ## Contributing Contributions are welcome! Please read our contributing guide and submit pull requests to our repository.