UNPKG

@metis-w/api-client

Version:

Modern TypeScript HTTP API client with dynamic routes, parameterized endpoints, interceptors, and advanced features

748 lines (582 loc) β€’ 24.2 kB
# Metis API Client [![NPM](https://nodei.co/npm/@metis-w/api-client.png)](https://npmjs.com/package/@metis-w/api-client) ## Features - 🎯 **Dynamic Routes** - `api.users.getProfile()`, `api.admin.users.ban()` - πŸ“Š **Parameterized Endpoints** - `api.users(123).follow()`, `api.posts('slug').view()` - πŸ”§ **TypeScript First** - Full type safety and IntelliSense support - πŸš€ **Modern Fetch API** - No XMLHttpRequest, pure modern JavaScript - πŸ”„ **Interceptors** - Request/Response middleware with logging, caching, performance - ⚑ **Automatic Retries** - Configurable retry logic with exponential backoff - 🏷️ **Case Conversion** - Automatic camelCase ↔ kebab-case conversion - πŸ“ **File Upload** - Automatic FormData handling for File/Blob objects - 🎨 **Flexible Configuration** - Per-request and global settings ## Installation ```bash npm install @metis-w/api-client ``` ## Quick Start ### Basic Usage ```typescript import { APIClient } from '@metis-w/api-client'; const api = new APIClient({ baseUrl: 'https://api.example.com', timeout: 5000 }); // Simple requests const users = await api.get('/users'); const newUser = await api.post('/users', { name: 'John', email: 'john@example.com' }); // Alternative: Use convenience functions import { createClient, createDynamicClient, IDynamicClient } from '@metis-w/api-client'; const apiClient = createClient({ baseUrl: 'https://api.example.com' }); const dynamicApi: IDynamicClient = createDynamicClient({ baseUrl: 'https://api.example.com' }); ``` #### Subpath imports (ΠΎΠΏΡ†Ρ–ΠΉΠ½ΠΎ) ```typescript import { DynamicClient } from '@metis-w/api-client/core'; import { CacheInterceptor } from '@metis-w/api-client/interceptors'; ``` ### Dynamic Routes ```typescript import { DynamicClient, IDynamicClient } from '@metis-w/api-client'; const api: IDynamicClient = new DynamicClient({ baseUrl: 'https://api.example.com', useKebabCase: true // converts getUserInfo β†’ get-user-info }); // Dynamic routing magic - no 'as any' needed! const profile = await api.users.getProfile({ id: 123 }); const result = await api.admin.users.ban({ userId: 456, reason: 'spam' }); // Multi-level routes const settings = await api.users.profile.getSettings({ theme: 'dark' }); ``` ### Parameterized Routes Enhanced parameterized routes with direct call support: ```typescript // Traditional parameterized routes with actions (creates nested paths) const userProfile = await api.users(123).getProfile(); // GET /users/123/getProfile const follow = await api.users(123).follow({ notify: true }); // POST /users/123/follow const profile = await api.users(456).profile.update({ bio: 'New bio' }); // PUT /users/456/profile/update // NEW: Direct parameterized route calls (RESTful) - URL will reflect useKebabCase if enabled (e.g. getProfile β†’ get-profile) const user = await api.users(123)(); // GET /users/123 const updated = await api.users(123)({ name: "John" }); // PUT /users/123 (with payload = update) const deleted = await api.users(123)({ method: "DELETE" }); // DELETE /users/123 (explicit method) // With query parameters const user = await api.users(123)({}, { include: "profile" }); // GET /users/123?include=profile const updated = await api.users(123)({ name: "John" }, { format: "json" }); // PUT /users/123?format=json ``` #### Direct Call Logic - **No payload**: `GET /resource/id` - Retrieve the resource - **With payload**: `PUT /resource/id` - Update the resource (default semantic) - **Explicit method**: Override with `{ method: "DELETE" }` etc. - **Query params**: Second parameter for URL query string ### HTTP Method Resolution The DynamicClient intelligently determines the appropriate HTTP method based on several factors: #### 1. Explicit Method (Highest Priority) ```typescript // Override any automatic method detection await api.users.create({ name: "John", method: "PUT" }); // Forces PUT method await api.users.delete({ confirm: true, method: "POST" }); // Forces POST method ``` #### 2. Direct HTTP Methods ```typescript // Direct method calls await api.users.get(); // GET /users/get await api.users.post(); // POST /users/post await api.users.put(); // PUT /users/put await api.users.delete(); // DELETE /users/delete await api.users.patch(); // PATCH /users/patch ``` #### 3. Semantic Analysis (Auto-detection) The library automatically detects the intent from action names: ```typescript // GET methods (reading data) await api.users.fetch(); // GET await api.users.load(); // GET await api.users.find(); // GET await api.users.retrieve(); // GET await api.users.show(); // GET await api.users.view(); // GET await api.users.getProfile(); // GET (starts with 'get') await api.users.loadSettings(); // GET (starts with 'load') // POST methods (creating data) await api.users.create(); // POST await api.users.add(); // POST await api.users.save(); // POST await api.users.store(); // POST await api.users.insert(); // POST await api.users.register(); // POST await api.users.submit(); // POST await api.users.createUser(); // POST (starts with 'create') // PUT methods (updating/replacing data) await api.users.update(); // PUT await api.users.replace(); // PUT await api.users.modify(); // PUT await api.users.edit(); // PUT await api.users.change(); // PUT await api.users.set(); // PUT await api.users.updateProfile(); // PUT (starts with 'update') // DELETE methods (removing data) await api.users.delete(); // DELETE await api.users.remove(); // DELETE await api.users.destroy(); // DELETE await api.users.clear(); // DELETE await api.users.drop(); // DELETE await api.users.deleteUser(); // DELETE (starts with 'delete') // PATCH methods (partial updates) await api.users.patch(); // PATCH await api.users.partial(); // PATCH await api.users.toggle(); // PATCH await api.users.enable(); // PATCH await api.users.disable(); // PATCH await api.users.activate(); // PATCH await api.users.deactivate(); // PATCH ``` #### 4. Custom Method Rules Define custom patterns for your API. Patterns are matched against action names (not controller names), case-insensitively. Matching supports prefix/suffix wildcards and kebab-case or camelCase action names: ```typescript const api = new DynamicClient({ baseUrl: 'https://api.example.com', methodRules: { 'validate*': 'POST', // e.g., validateInput β†’ POST '*report': 'GET', // e.g., monthlyReport β†’ GET 'verify*': 'GET', // e.g., verifyToken β†’ GET 'delete*': 'DELETE' // e.g., deleteUser β†’ DELETE } }); await api.data.validateInput(); // POST (pattern match) await api.sales.monthlyReport(); // GET (pattern match) await api.auth.verifyToken(); // GET (pattern match) ``` #### 5. Default Method Set a fallback method for unrecognized actions: ```typescript const api = new DynamicClient({ baseUrl: 'https://api.example.com', defaultMethod: 'GET' // Default to GET instead of POST }); await api.users.unknownAction(); // GET (fallback to default) ``` #### Priority Order The method resolution follows this priority: 1. **Explicit method** in payload (`{ method: "DELETE" }`) 2. **Direct HTTP method** (`get`, `post`, `put`, `delete`, `patch`) 3. **Custom method rules** (from `methodRules` config) 4. **Semantic analysis** (action name patterns) 5. **Default method** (from `defaultMethod` config, defaults to `POST`) ### File Uploads ```typescript const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; const file = fileInput.files?.[0]; if (file) { // Automatic FormData handling const response = await api.post('/upload/avatar', { file: file, userId: 123, metadata: { title: 'Profile Picture' } }); } // Multiple files const files = Array.from(fileInput.files || []); const response = await api.post('/upload/gallery', { files: files, albumId: 456, tags: ['vacation', 'summer'] }); ``` ### TypeScript Support Full TypeScript integration with intelligent type inference and strict settings. Public APIs are generic-first; a few internal defaults use `any` for flexibility. ```typescript // Import types for proper typing import { DynamicClient, IDynamicClient, APIClient } from '@metis-w/api-client'; // Define your API response types interface User { id: number; name: string; email: string; avatar?: string; } interface CreateUserRequest { name: string; email: string; password: string; } // Type-safe API calls with APIClient const client = new APIClient({ baseUrl: 'https://api.example.com' }); const user = await client.get<User>('/users/123'); // user.data is typed as User | undefined const newUser = await client.post<User, CreateUserRequest>('/users', { name: 'John Doe', email: 'john@example.com', password: 'secure123' }); // Type-safe dynamic client - no 'as any' casting needed! const dynamicClient: IDynamicClient = new DynamicClient({ baseUrl: 'https://api.example.com', defaultMethod: 'GET', methodRules: { 'auth*': 'POST' } }); // TypeScript understands these are dynamic routes with proper return types const userData = await dynamicClient.users.getProfile({ id: 123 }); // userData is typed as APIResponse<unknown> // All HTTP methods work seamlessly await dynamicClient.users.create({ name: "John" }); // POST (semantic) await dynamicClient.users.update({ id: 1 }); // PUT (semantic) await dynamicClient.users.fetch(); // GET (semantic) await dynamicClient.users.remove(); // DELETE (semantic) await dynamicClient.users.patch({ status: "active" }); // PATCH (semantic) // Custom method rules await dynamicClient.auth.login({ email: "test@test.com" }); // POST (rule) // Explicit method override await dynamicClient.users.action({ data: "test", method: "DELETE" }); // DELETE (explicit) // RESTful parameterized routes const user = await dynamicClient.users(123)(); // GET /users/123 const updated = await dynamicClient.users(123)({ name: 'John Smith' }); // PUT /users/123 // Traditional action-based parameterized routes const profile = await dynamicClient.users(123).getProfile(); // GET /users/123/getProfile const follow = await dynamicClient.users(123).follow(); // POST /users/123/follow ``` ### Generic Type Support ```typescript // Create a typed API client class TypedAPIClient extends APIClient { async getUser(id: number): Promise<User> { const response = await this.get<User>(`/users/${id}`); if (!response.success || !response.data) { throw new Error(response.error?.message || 'User not found'); } return response.data; } async createUser(userData: CreateUserRequest): Promise<User> { const response = await this.post<User, CreateUserRequest>('/users', userData); if (!response.success || !response.data) { throw new Error(response.error?.message || 'Failed to create user'); } return response.data; } } const typedApi = new TypedAPIClient({ baseUrl: 'https://api.example.com' }); const user = await typedApi.getUser(123); // Returns User directly ``` ## API Reference ### APIClient | Method | Description | |--------|-------------| | `get<T>(url, config?)` | GET request | | `post<T>(url, data?, config?)` | POST request | | `put<T>(url, data?, config?)` | PUT request | | `delete<T>(url, config?)` | DELETE request | | `patch<T>(url, data?, config?)` | PATCH request | | `interceptors.addRequestInterceptor(fn)` | Add request middleware | | `interceptors.addResponseInterceptor(fn)` | Add response middleware | | `destroy()` | Clean up client resources | ### DynamicClient All APIClient methods plus: | Method | Description | |--------|-------------| | `cache.getStats()` | Get cache statistics for dynamic routes | | `cache.clearProxyCache()` | Clear dynamic route cache | ### Configuration ```typescript type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; interface APIConfig { baseUrl: string; timeout?: number; headers?: Record<string, string>; withCredentials?: boolean; retries?: number; retryDelay?: number; useKebabCase?: boolean; // Method resolution options defaultMethod?: HTTPMethod; // Fallback method, default: 'POST' methodRules?: Record<string, HTTPMethod>; // Action-based wildcard rules } ``` ### Response Format ```typescript interface APIResponse<T> { success: boolean; data?: T; error?: { code?: number; message?: string; }; } interface ClientError { message: string; type: "network" | "timeout" | "abort" | "parse"; originalError?: Error; response?: RawResponse; } ``` ## Testing The project includes comprehensive test coverage using Jest and TypeScript. ### Running Tests ```bash # Run all tests npm test # Run tests in watch mode npm run test:watch # Run tests with coverage npm run test:coverage ``` ### Test Structure Our test suite covers all major functionality: ``` test/ β”œβ”€β”€ api-client.test.ts # Core APIClient functionality β”œβ”€β”€ dynamic-client.test.ts # Dynamic routing and parameterized endpoints β”œβ”€β”€ converter.test.ts # Case conversion utilities β”œβ”€β”€ serializer.test.ts # Data serialization and FormData handling β”œβ”€β”€ url-builder.test.ts # URL construction utilities β”œβ”€β”€ setup.ts # Test configuration and global mocks └── globals.d.ts # TypeScript definitions for test environment ``` ### Key Test Scenarios #### APIClient Tests - βœ… Basic HTTP methods (GET, POST, PUT, DELETE, PATCH) - βœ… Request/Response interceptors - βœ… Error handling and retry logic - βœ… FormData handling for file uploads - βœ… Configuration merging and validation #### DynamicClient Tests - βœ… Dynamic route generation (`api.users.getProfile()`) - βœ… Parameterized endpoints (`api.users(123).follow()`) - βœ… Multi-level routing (`api.admin.users.ban()`) - βœ… Case conversion (camelCase ↔ kebab-case) - βœ… Nested parameters and complex data structures #### Data Serialization Tests - βœ… FormData handling for file uploads - βœ… JSON serialization and content-type detection - βœ… Deep object transformation - βœ… File and Blob object detection - βœ… Edge cases and performance validation #### URL Builder Tests - βœ… URL construction with path segments - βœ… Query parameter handling - βœ… Base URL normalization - βœ… Special characters and encoding #### Case Conversion Tests - βœ… camelCase to kebab-case conversion - βœ… kebab-case to camelCase conversion - βœ… Deep object key transformation - βœ… Array handling with nested objects ### Writing Tests When contributing, follow these testing patterns: ```typescript import { APIClient } from '../src/core/api-client'; describe('APIClient', () => { let client: APIClient; beforeEach(() => { client = new APIClient({ baseUrl: 'https://test.api' }); jest.clearAllMocks(); }); afterEach(() => { client.destroy(); // Important: Clean up resources }); it('should make GET request', async () => { const mockResponse = { success: true, data: { id: 1 } }; (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => mockResponse }); const result = await client.get('/test'); expect(result).toEqual(mockResponse); }); }); ``` ## Architecture The project follows a modular architecture for maintainability and testability: ### Core Components ``` src/ β”œβ”€β”€ core/ # Main client classes β”‚ β”œβ”€β”€ api-client.ts # Core HTTP client β”‚ └── dynamic-client.ts # Dynamic routing client β”œβ”€β”€ libs/ # Reusable libraries β”‚ β”œβ”€β”€ builders/ # Request and route builders β”‚ β”œβ”€β”€ constants/ # Configuration constants β”‚ β”œβ”€β”€ managers/ # Cache, interceptor, retry managers β”‚ β”œβ”€β”€ parsers/ # Response parsing logic β”‚ └── security/ # Input sanitization β”œβ”€β”€ utils/ # Utility functions β”‚ β”œβ”€β”€ case-converter.ts # Case conversion logic β”‚ β”œβ”€β”€ data-serializer.ts# Data transformation and FormData handling β”‚ β”œβ”€β”€ url-builder.ts # URL construction β”‚ └── route-validator.ts# Route validation β”œβ”€β”€ types/ # TypeScript definitions β”‚ β”œβ”€β”€ config.ts # Configuration types β”‚ β”œβ”€β”€ request.ts # Request types β”‚ └── response.ts # Response types └── interceptors/ # Pre-built interceptors β”œβ”€β”€ logging.ts # Request/response logging β”œβ”€β”€ cache.ts # Response caching └── timing.ts # Performance monitoring ``` ### Design Principles 1. **Separation of Concerns** - Each component has a single responsibility 2. **Dependency Injection** - Components accept dependencies, making testing easier 3. **Resource Management** - Proper cleanup with `destroy()` methods 4. **Type Safety** - Strict TypeScript configuration with full type coverage 5. **Immutability** - Configurations are cloned, not mutated 6. **Error Boundaries** - Graceful error handling at every level ## Integration Examples ### Next.js Integration ```typescript // lib/api.ts import { DynamicClient, IDynamicClient } from '@metis-w/api-client'; export const api: IDynamicClient = new DynamicClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api', timeout: 10000, headers: { 'Content-Type': 'application/json', }, }); // Add auth interceptor api.interceptors.addRequestInterceptor(async (config) => { const token = localStorage.getItem('authToken'); if (token) { config.headers = { ...config.headers, Authorization: `Bearer ${token}` }; } return config; }); // pages/users/[id].tsx import { api } from '../../lib/api'; export default function UserProfile({ user }) { const handleFollow = async () => { try { await api.users(user.id).follow(); // Update UI } catch (error) { console.error('Failed to follow user:', error); } }; return <div>{/* Component JSX */}</div>; } export async function getServerSideProps(context) { const { id } = context.params; const user = await api.users(id).get(); return { props: { user: user.data } }; } ``` ### React Query Integration ```typescript import { useQuery, useMutation } from '@tanstack/react-query'; import { api } from '../lib/api'; // Custom hooks export const useUser = (id: number) => { return useQuery({ queryKey: ['user', id], queryFn: () => api.users(id).get(), staleTime: 5 * 60 * 1000, // 5 minutes }); }; export const useFollowUser = () => { return useMutation({ mutationFn: (userId: number) => api.users(userId).follow(), onSuccess: () => { // Invalidate and refetch user data queryClient.invalidateQueries({ queryKey: ['user'] }); }, }); }; ``` ## Comparison with Other Libraries ### vs. Axios | Feature | @metis-w/api-client | Axios | |---------|-------------------|-------| | Bundle Size | ~15KB | ~45KB | | Dynamic Routes | βœ… `api.users.get()` | ❌ Manual URLs | | TypeScript | βœ… Built-in | ⚠️ Community types | | Modern Fetch | βœ… Native | ❌ XMLHttpRequest | | Tree Shaking | βœ… Full support | ⚠️ Limited | | Interceptors | βœ… Async/await | βœ… Promise-based | ### vs. Native Fetch | Feature | @metis-w/api-client | Fetch API | |---------|-------------------|-----------| | Dynamic Routes | βœ… `api.users(123).follow()` | ❌ Manual | | Error Handling | βœ… Automatic | ❌ Manual | | Retries | βœ… Built-in | ❌ Manual | | Interceptors | βœ… Built-in | ❌ Manual | | File Uploads | βœ… Automatic | ❌ Manual FormData | | TypeScript | βœ… Full support | ⚠️ Basic | ## Performance Considerations ### Bundle Size Optimization ```typescript // Import only what you need import { APIClient } from '@metis-w/api-client'; import { requestLoggingInterceptor } from '@metis-w/api-client'; // Or import specific modules import { DynamicClient } from '@metis-w/api-client/core'; import { CacheInterceptor } from '@metis-w/api-client/interceptors'; // Tree-shaking will remove unused code ``` ### Memory Management ```typescript // Always destroy clients when done const api = new APIClient(config); // In cleanup (useEffect, componentWillUnmount, etc.) useEffect(() => { return () => api.destroy(); }, []); // For DynamicClient, cache is automatically cleared const dynamicApi = new DynamicClient(config); useEffect(() => { return () => dynamicApi.destroy(); // Clears both client and cache }, []); ``` ### Caching Strategy ```typescript import { CacheInterceptor } from '@metis-w/api-client'; const cache = new CacheInterceptor({ ttl: 5 * 60 * 1000, // 5 minutes maxSize: 100 // Max 100 cached responses }); // Add to client api.interceptors.addRequestInterceptor(cache.requestInterceptor); api.interceptors.addResponseInterceptor(cache.responseInterceptor); // Check cache statistics console.log(cache.getStats()); ``` ## Examples Check out the comprehensive test suite for real-world examples: - [`test/api-client.test.ts`](./test/api-client.test.ts) - Core APIClient functionality - [`test/dynamic-client.test.ts`](./test/dynamic-client.test.ts) - Dynamic routing examples - [`test/converter.test.ts`](./test/converter.test.ts) - Case conversion examples - [`test/serializer.test.ts`](./test/serializer.test.ts) - Data transformation examples - [`test/url-builder.test.ts`](./test/url-builder.test.ts) - URL construction examples ## 🀝 Contributing We welcome contributions! Here's how to get started: ### Development Setup ```bash # Clone the repository git clone https://github.com/metis-w/api-client.git cd api-client # Install dependencies npm install # Run tests npm test # Run tests in watch mode during development npm run test:watch ``` ### Contribution Guidelines 1. **Code Style** - Follow the existing TypeScript patterns 2. **Tests Required** - All new features must include tests 3. **Documentation** - Update README.md for new features 4. **Type Safety** - Maintain strict TypeScript compliance 5. **Performance** - Consider bundle size and runtime performance ### Adding New Features 1. Create feature branch: `git checkout -b feature/new-feature` 2. Add implementation in appropriate `src/` directory 3. Write comprehensive tests in `test/` directory 4. Update documentation and examples 5. Submit pull request with detailed description ### Reporting Issues When reporting bugs, please include: - Environment details (Node.js version, TypeScript version) - Minimal reproduction case - Expected vs actual behavior - Test case if possible ## πŸ“„ License MIT Β© [whiteakyloff](https://github.com/metis-w) ---