Skip to content

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

License

Notifications You must be signed in to change notification settings

metis-w/api-client

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

19 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Metis API Client

NPM

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

npm install @metis-w/api-client

Quick Start

Basic Usage

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 (optional)

import { DynamicClient } from '@metis-w/api-client/core';
import { CacheInterceptor } from '@metis-w/api-client/interceptors';

Dynamic Routes

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:

// 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)

// 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

// 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:

// 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:

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:

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

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.

// 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

// 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

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

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

# 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:

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

// 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

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

// 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

// 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

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:

🀝 Contributing

We welcome contributions! Here's how to get started:

Development Setup

# 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


About

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

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published