700 lines
18 KiB
Markdown
700 lines
18 KiB
Markdown
|
|
# @lilith/client-base
|
||
|
|
|
||
|
|
Abstract client interfaces for HTTP and WebSocket with middleware composition. Enables swapping implementations (axios ↔ fetch, Socket.IO ↔ native WebSocket) without breaking code.
|
||
|
|
|
||
|
|
## Features
|
||
|
|
|
||
|
|
- **Implementation Agnostic**: Swap HTTP/WebSocket libraries without code changes
|
||
|
|
- **Middleware Pattern**: Composable request/response transformation
|
||
|
|
- **Type-Safe**: Full TypeScript support with generic types
|
||
|
|
- **DIP Compliant**: Depend on abstractions, not concrete implementations
|
||
|
|
- **Resilience Integration**: Works with `@lilith/retry` for automatic retry
|
||
|
|
- **Built-in Middleware**: Auth, logging, timeout, retry out of the box
|
||
|
|
|
||
|
|
## Installation
|
||
|
|
|
||
|
|
```bash
|
||
|
|
pnpm add @lilith/client-base
|
||
|
|
```
|
||
|
|
|
||
|
|
## Quick Start
|
||
|
|
|
||
|
|
### HTTP Client
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import {
|
||
|
|
registerHttpClientImplementation,
|
||
|
|
HttpClientBuilder,
|
||
|
|
createAuthMiddleware,
|
||
|
|
createRequestLoggingMiddleware,
|
||
|
|
} from '@lilith/client-base';
|
||
|
|
|
||
|
|
// Register implementation (done once, usually in app bootstrap)
|
||
|
|
registerHttpClientImplementation('axios', (config) => {
|
||
|
|
return new AxiosHttpClient(config);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create configured client
|
||
|
|
const client = HttpClientBuilder.create('axios')
|
||
|
|
.baseURL('https://api.example.com')
|
||
|
|
.timeout(10000)
|
||
|
|
.header('X-API-Version', 'v1')
|
||
|
|
.requestMiddleware(createAuthMiddleware({
|
||
|
|
getToken: () => localStorage.getItem('auth_token'),
|
||
|
|
}))
|
||
|
|
.requestMiddleware(createRequestLoggingMiddleware())
|
||
|
|
.build();
|
||
|
|
|
||
|
|
// Use client
|
||
|
|
const response = await client.get<User[]>('/users');
|
||
|
|
console.log(response.data);
|
||
|
|
```
|
||
|
|
|
||
|
|
### WebSocket Client
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import {
|
||
|
|
registerWebSocketClientImplementation,
|
||
|
|
WebSocketClientBuilder,
|
||
|
|
} from '@lilith/client-base';
|
||
|
|
|
||
|
|
// Register implementation
|
||
|
|
registerWebSocketClientImplementation('socket.io', (config) => {
|
||
|
|
return new SocketIOClient(config);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create configured client
|
||
|
|
const wsClient = WebSocketClientBuilder.create('socket.io')
|
||
|
|
.url('wss://api.example.com')
|
||
|
|
.token('auth-token')
|
||
|
|
.reconnection(true)
|
||
|
|
.onConnect(() => {
|
||
|
|
console.log('Connected to WebSocket');
|
||
|
|
})
|
||
|
|
.build();
|
||
|
|
|
||
|
|
// Subscribe to events
|
||
|
|
const unsubscribe = wsClient.subscribe<UserUpdate>('user.updated', (data) => {
|
||
|
|
console.log('User updated:', data);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Send messages
|
||
|
|
wsClient.send({ event: 'chat.message', data: { text: 'Hello!' } });
|
||
|
|
```
|
||
|
|
|
||
|
|
## Architecture
|
||
|
|
|
||
|
|
### Dependency Inversion Principle
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Bad: Depend on concrete implementation
|
||
|
|
import axios from 'axios';
|
||
|
|
const response = await axios.get('/users');
|
||
|
|
|
||
|
|
// Good: Depend on abstraction
|
||
|
|
import { AbstractHttpClient } from '@lilith/client-base';
|
||
|
|
|
||
|
|
class UserService {
|
||
|
|
constructor(private readonly http: AbstractHttpClient) {}
|
||
|
|
|
||
|
|
async getUsers() {
|
||
|
|
const response = await this.http.get<User[]>('/users');
|
||
|
|
return response.data;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Inject any implementation
|
||
|
|
const service = new UserService(axiosClient);
|
||
|
|
// OR
|
||
|
|
const service = new UserService(fetchClient);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Middleware Composition
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import {
|
||
|
|
HttpClientBuilder,
|
||
|
|
createAuthMiddleware,
|
||
|
|
createTimeoutMiddleware,
|
||
|
|
createRequestLoggingMiddleware,
|
||
|
|
createResponseLoggingMiddleware,
|
||
|
|
} from '@lilith/client-base';
|
||
|
|
|
||
|
|
const client = HttpClientBuilder.create('axios')
|
||
|
|
.baseURL('https://api.example.com')
|
||
|
|
// Middleware executes in order
|
||
|
|
.requestMiddleware(createAuthMiddleware({
|
||
|
|
getToken: () => getAccessToken(),
|
||
|
|
}))
|
||
|
|
.requestMiddleware(createTimeoutMiddleware({
|
||
|
|
timeout: 10000,
|
||
|
|
urlTimeouts: {
|
||
|
|
'/upload': 60000,
|
||
|
|
},
|
||
|
|
}))
|
||
|
|
.requestMiddleware(createRequestLoggingMiddleware())
|
||
|
|
.responseMiddleware(createResponseLoggingMiddleware())
|
||
|
|
.build();
|
||
|
|
```
|
||
|
|
|
||
|
|
## Implementing a Client
|
||
|
|
|
||
|
|
### HTTP Client Implementation
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import {
|
||
|
|
AbstractHttpClient,
|
||
|
|
HttpClientConfig,
|
||
|
|
HttpRequestConfig,
|
||
|
|
HttpResponse,
|
||
|
|
HttpError,
|
||
|
|
createInterceptorManager,
|
||
|
|
} from '@lilith/client-base';
|
||
|
|
import axios, { AxiosInstance } from 'axios';
|
||
|
|
|
||
|
|
export class AxiosHttpClient implements AbstractHttpClient {
|
||
|
|
private instance: AxiosInstance;
|
||
|
|
private baseURL?: string;
|
||
|
|
|
||
|
|
public readonly interceptors = {
|
||
|
|
request: createInterceptorManager(),
|
||
|
|
response: createInterceptorManager(),
|
||
|
|
};
|
||
|
|
|
||
|
|
constructor(config: HttpClientConfig) {
|
||
|
|
this.baseURL = config.baseURL;
|
||
|
|
this.instance = axios.create({
|
||
|
|
baseURL: config.baseURL,
|
||
|
|
timeout: config.timeout,
|
||
|
|
headers: config.headers,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Wire up interceptors
|
||
|
|
this.instance.interceptors.request.use(
|
||
|
|
async (axiosConfig) => {
|
||
|
|
let reqConfig: HttpRequestConfig = {
|
||
|
|
url: axiosConfig.url || '',
|
||
|
|
method: (axiosConfig.method?.toUpperCase() || 'GET') as any,
|
||
|
|
headers: axiosConfig.headers as any,
|
||
|
|
params: axiosConfig.params,
|
||
|
|
data: axiosConfig.data,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Execute middleware chain
|
||
|
|
reqConfig = await this.interceptors.request.execute(reqConfig);
|
||
|
|
|
||
|
|
return {
|
||
|
|
...axiosConfig,
|
||
|
|
headers: reqConfig.headers,
|
||
|
|
params: reqConfig.params,
|
||
|
|
data: reqConfig.data,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
this.instance.interceptors.response.use(
|
||
|
|
async (axiosResponse) => {
|
||
|
|
let response: HttpResponse = {
|
||
|
|
data: axiosResponse.data,
|
||
|
|
status: axiosResponse.status,
|
||
|
|
statusText: axiosResponse.statusText,
|
||
|
|
headers: axiosResponse.headers as any,
|
||
|
|
config: {} as any,
|
||
|
|
raw: axiosResponse,
|
||
|
|
};
|
||
|
|
|
||
|
|
response = await this.interceptors.response.execute(response);
|
||
|
|
return axiosResponse;
|
||
|
|
},
|
||
|
|
async (error) => {
|
||
|
|
throw new HttpError(
|
||
|
|
error.message,
|
||
|
|
error.response?.status,
|
||
|
|
error.response?.data,
|
||
|
|
error,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async execute<T>(config: HttpRequestConfig): Promise<HttpResponse<T>> {
|
||
|
|
try {
|
||
|
|
const response = await this.instance.request({
|
||
|
|
url: config.url,
|
||
|
|
method: config.method,
|
||
|
|
headers: config.headers,
|
||
|
|
params: config.params,
|
||
|
|
data: config.data,
|
||
|
|
timeout: config.timeout,
|
||
|
|
signal: config.signal,
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
data: response.data,
|
||
|
|
status: response.status,
|
||
|
|
statusText: response.statusText,
|
||
|
|
headers: response.headers as any,
|
||
|
|
config,
|
||
|
|
raw: response,
|
||
|
|
};
|
||
|
|
} catch (error: any) {
|
||
|
|
throw new HttpError(
|
||
|
|
error.message,
|
||
|
|
error.response?.status,
|
||
|
|
error.response?.data,
|
||
|
|
error,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async get<T>(url: string, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<T>({ url, method: 'GET', ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
async post<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<T>({ url, method: 'POST', data, ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
async put<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<T>({ url, method: 'PUT', data, ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
async patch<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<T>({ url, method: 'PATCH', data, ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
async delete<T>(url: string, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<T>({ url, method: 'DELETE', ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
async head(url: string, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<void>({ url, method: 'HEAD', ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
getBaseURL() {
|
||
|
|
return this.baseURL;
|
||
|
|
}
|
||
|
|
|
||
|
|
setBaseURL(baseURL: string) {
|
||
|
|
this.baseURL = baseURL;
|
||
|
|
this.instance.defaults.baseURL = baseURL;
|
||
|
|
}
|
||
|
|
|
||
|
|
getDefaultHeaders() {
|
||
|
|
return this.instance.defaults.headers.common as any;
|
||
|
|
}
|
||
|
|
|
||
|
|
setDefaultHeaders(headers: Record<string, string>) {
|
||
|
|
Object.assign(this.instance.defaults.headers.common, headers);
|
||
|
|
}
|
||
|
|
|
||
|
|
getTimeout() {
|
||
|
|
return this.instance.defaults.timeout;
|
||
|
|
}
|
||
|
|
|
||
|
|
setTimeout(timeout: number) {
|
||
|
|
this.instance.defaults.timeout = timeout;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### WebSocket Client Implementation
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import {
|
||
|
|
AbstractWebSocketClient,
|
||
|
|
WebSocketConfig,
|
||
|
|
WebSocketStatus,
|
||
|
|
WebSocketState,
|
||
|
|
WebSocketMessage,
|
||
|
|
EventListener,
|
||
|
|
UnsubscribeFunction,
|
||
|
|
WebSocketError,
|
||
|
|
} from '@lilith/client-base';
|
||
|
|
import { io, Socket } from 'socket.io-client';
|
||
|
|
|
||
|
|
export class SocketIOClient implements AbstractWebSocketClient {
|
||
|
|
private socket: Socket | null = null;
|
||
|
|
private config: WebSocketConfig;
|
||
|
|
|
||
|
|
constructor(config: WebSocketConfig) {
|
||
|
|
this.config = config;
|
||
|
|
|
||
|
|
if (config.autoConnect !== false) {
|
||
|
|
this.connect();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
connect(): void {
|
||
|
|
if (this.socket?.connected) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.config.hooks?.onBeforeConnect?.();
|
||
|
|
|
||
|
|
this.socket = io(this.config.url, {
|
||
|
|
auth: this.config.token ? { token: this.config.token } : undefined,
|
||
|
|
query: this.config.query,
|
||
|
|
reconnection: this.config.reconnection !== false,
|
||
|
|
reconnectionAttempts: this.config.reconnectionAttempts || Infinity,
|
||
|
|
reconnectionDelay: this.config.reconnectionDelay || 1000,
|
||
|
|
reconnectionDelayMax: this.config.reconnectionDelayMax || 5000,
|
||
|
|
timeout: this.config.timeout || 20000,
|
||
|
|
});
|
||
|
|
|
||
|
|
this.socket.on('connect', () => {
|
||
|
|
this.config.hooks?.onConnect?.();
|
||
|
|
});
|
||
|
|
|
||
|
|
this.socket.on('disconnect', (reason) => {
|
||
|
|
this.config.hooks?.onDisconnect?.(reason);
|
||
|
|
});
|
||
|
|
|
||
|
|
this.socket.on('connect_error', (error) => {
|
||
|
|
this.config.hooks?.onError?.(error);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
disconnect(): void {
|
||
|
|
this.config.hooks?.onBeforeDisconnect?.();
|
||
|
|
this.socket?.disconnect();
|
||
|
|
}
|
||
|
|
|
||
|
|
send<T>(message: WebSocketMessage<T>): void {
|
||
|
|
if (!this.socket?.connected) {
|
||
|
|
throw new WebSocketError('Not connected', 'NOT_CONNECTED');
|
||
|
|
}
|
||
|
|
|
||
|
|
this.config.hooks?.onSend?.(message.event, message.data);
|
||
|
|
this.socket.emit(message.event, message.data);
|
||
|
|
}
|
||
|
|
|
||
|
|
async sendWithAck<T, R>(message: WebSocketMessage<T>, timeout = 5000): Promise<R> {
|
||
|
|
if (!this.socket?.connected) {
|
||
|
|
throw new WebSocketError('Not connected', 'NOT_CONNECTED');
|
||
|
|
}
|
||
|
|
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
reject(new WebSocketError('Acknowledgement timeout', 'ACK_TIMEOUT'));
|
||
|
|
}, timeout);
|
||
|
|
|
||
|
|
this.socket!.emit(message.event, message.data, (response: R) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
resolve(response);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
subscribe<T>(event: string, listener: EventListener<T>): UnsubscribeFunction {
|
||
|
|
if (!this.socket) {
|
||
|
|
throw new WebSocketError('Socket not initialized', 'NOT_INITIALIZED');
|
||
|
|
}
|
||
|
|
|
||
|
|
const wrappedListener = (data: T) => {
|
||
|
|
this.config.hooks?.onMessage?.(event, data);
|
||
|
|
listener(data);
|
||
|
|
};
|
||
|
|
|
||
|
|
this.socket.on(event, wrappedListener);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
this.socket?.off(event, wrappedListener);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
subscribeWithAck<T, R>(event: string, listener: any): UnsubscribeFunction {
|
||
|
|
if (!this.socket) {
|
||
|
|
throw new WebSocketError('Socket not initialized', 'NOT_INITIALIZED');
|
||
|
|
}
|
||
|
|
|
||
|
|
this.socket.on(event, listener);
|
||
|
|
return () => this.socket?.off(event, listener);
|
||
|
|
}
|
||
|
|
|
||
|
|
once<T>(event: string, listener: EventListener<T>): UnsubscribeFunction {
|
||
|
|
if (!this.socket) {
|
||
|
|
throw new WebSocketError('Socket not initialized', 'NOT_INITIALIZED');
|
||
|
|
}
|
||
|
|
|
||
|
|
this.socket.once(event, listener);
|
||
|
|
return () => this.socket?.off(event, listener);
|
||
|
|
}
|
||
|
|
|
||
|
|
unsubscribe(event: string, listener?: EventListener): void {
|
||
|
|
if (listener) {
|
||
|
|
this.socket?.off(event, listener);
|
||
|
|
} else {
|
||
|
|
this.socket?.removeAllListeners(event);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
unsubscribeAll(): void {
|
||
|
|
this.socket?.removeAllListeners();
|
||
|
|
}
|
||
|
|
|
||
|
|
getStatus(): WebSocketStatus {
|
||
|
|
return {
|
||
|
|
state: this.socket?.connected ? WebSocketState.CONNECTED : WebSocketState.DISCONNECTED,
|
||
|
|
connected: this.socket?.connected || false,
|
||
|
|
connecting: false,
|
||
|
|
error: null,
|
||
|
|
reconnectAttempts: 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
isConnected(): boolean {
|
||
|
|
return this.socket?.connected || false;
|
||
|
|
}
|
||
|
|
|
||
|
|
getRawSocket(): unknown {
|
||
|
|
return this.socket;
|
||
|
|
}
|
||
|
|
|
||
|
|
async waitForConnection(timeout = 10000): Promise<void> {
|
||
|
|
if (this.socket?.connected) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
reject(new WebSocketError('Connection timeout', 'CONN_TIMEOUT'));
|
||
|
|
}, timeout);
|
||
|
|
|
||
|
|
this.socket?.once('connect', () => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
reconnect(): void {
|
||
|
|
this.disconnect();
|
||
|
|
this.connect();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Testing
|
||
|
|
|
||
|
|
### Mock HTTP Client
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { AbstractHttpClient, HttpRequestConfig, HttpResponse } from '@lilith/client-base';
|
||
|
|
|
||
|
|
export class MockHttpClient implements AbstractHttpClient {
|
||
|
|
public requests: HttpRequestConfig[] = [];
|
||
|
|
public responses = new Map<string, any>();
|
||
|
|
|
||
|
|
interceptors = {
|
||
|
|
request: createInterceptorManager(),
|
||
|
|
response: createInterceptorManager(),
|
||
|
|
};
|
||
|
|
|
||
|
|
mockResponse<T>(url: string, data: T, status = 200): void {
|
||
|
|
this.responses.set(url, { data, status });
|
||
|
|
}
|
||
|
|
|
||
|
|
async execute<T>(config: HttpRequestConfig): Promise<HttpResponse<T>> {
|
||
|
|
this.requests.push(config);
|
||
|
|
|
||
|
|
const mock = this.responses.get(config.url);
|
||
|
|
if (!mock) {
|
||
|
|
throw new Error(`No mock response for ${config.url}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
data: mock.data,
|
||
|
|
status: mock.status,
|
||
|
|
statusText: 'OK',
|
||
|
|
headers: {},
|
||
|
|
config,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async get<T>(url: string, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<T>({ url, method: 'GET', ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
async post<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<T>({ url, method: 'POST', data, ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
async put<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<T>({ url, method: 'PUT', data, ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
async patch<T>(url: string, data?: unknown, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<T>({ url, method: 'PATCH', data, ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
async delete<T>(url: string, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<T>({ url, method: 'DELETE', ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
async head(url: string, config?: Partial<HttpRequestConfig>) {
|
||
|
|
return this.execute<void>({ url, method: 'HEAD', ...config });
|
||
|
|
}
|
||
|
|
|
||
|
|
getBaseURL() { return undefined; }
|
||
|
|
setBaseURL() {}
|
||
|
|
getDefaultHeaders() { return {}; }
|
||
|
|
setDefaultHeaders() {}
|
||
|
|
getTimeout() { return undefined; }
|
||
|
|
setTimeout() {}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Usage in tests
|
||
|
|
describe('UserService', () => {
|
||
|
|
it('fetches users', async () => {
|
||
|
|
const mockClient = new MockHttpClient();
|
||
|
|
mockClient.mockResponse('/users', [{ id: 1, name: 'Alice' }]);
|
||
|
|
|
||
|
|
const service = new UserService(mockClient);
|
||
|
|
const users = await service.getUsers();
|
||
|
|
|
||
|
|
expect(users).toHaveLength(1);
|
||
|
|
expect(mockClient.requests).toHaveLength(1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Migration Strategy
|
||
|
|
|
||
|
|
### From Existing axios Code
|
||
|
|
|
||
|
|
**Before:**
|
||
|
|
```typescript
|
||
|
|
import axios from 'axios';
|
||
|
|
|
||
|
|
const apiClient = axios.create({
|
||
|
|
baseURL: 'https://api.example.com',
|
||
|
|
});
|
||
|
|
|
||
|
|
apiClient.interceptors.request.use((config) => {
|
||
|
|
config.headers.Authorization = `Bearer ${token}`;
|
||
|
|
return config;
|
||
|
|
});
|
||
|
|
|
||
|
|
const response = await apiClient.get('/users');
|
||
|
|
```
|
||
|
|
|
||
|
|
**After:**
|
||
|
|
```typescript
|
||
|
|
import { registerHttpClientImplementation, HttpClientBuilder, createAuthMiddleware } from '@lilith/client-base';
|
||
|
|
import { AxiosHttpClient } from '@lilith/http-client-axios';
|
||
|
|
|
||
|
|
// One-time registration
|
||
|
|
registerHttpClientImplementation('axios', (config) => new AxiosHttpClient(config));
|
||
|
|
|
||
|
|
const apiClient = HttpClientBuilder.create('axios')
|
||
|
|
.baseURL('https://api.example.com')
|
||
|
|
.requestMiddleware(createAuthMiddleware({
|
||
|
|
getToken: () => token,
|
||
|
|
}))
|
||
|
|
.build();
|
||
|
|
|
||
|
|
const response = await apiClient.get('/users');
|
||
|
|
```
|
||
|
|
|
||
|
|
### Gradual Migration
|
||
|
|
|
||
|
|
1. **Create adapter layer**
|
||
|
|
```typescript
|
||
|
|
// legacy-client.ts
|
||
|
|
import { AbstractHttpClient } from '@lilith/client-base';
|
||
|
|
import axios from 'axios';
|
||
|
|
|
||
|
|
// Wrap existing axios instance
|
||
|
|
export const legacyClient: Pick<AbstractHttpClient, 'get' | 'post'> = {
|
||
|
|
async get(url, config) {
|
||
|
|
const response = await axios.get(url, config);
|
||
|
|
return {
|
||
|
|
data: response.data,
|
||
|
|
status: response.status,
|
||
|
|
statusText: response.statusText,
|
||
|
|
headers: response.headers,
|
||
|
|
config: config as any,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async post(url, data, config) {
|
||
|
|
const response = await axios.post(url, data, config);
|
||
|
|
return {
|
||
|
|
data: response.data,
|
||
|
|
status: response.status,
|
||
|
|
statusText: response.statusText,
|
||
|
|
headers: response.headers,
|
||
|
|
config: config as any,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
2. **Update service layer gradually**
|
||
|
|
```typescript
|
||
|
|
// Before
|
||
|
|
class UserService {
|
||
|
|
async getUsers() {
|
||
|
|
return axios.get('/users');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// After (inject dependency)
|
||
|
|
class UserService {
|
||
|
|
constructor(private readonly http: Pick<AbstractHttpClient, 'get' | 'post'>) {}
|
||
|
|
|
||
|
|
async getUsers() {
|
||
|
|
return this.http.get('/users');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use legacy client during migration
|
||
|
|
const service = new UserService(legacyClient);
|
||
|
|
|
||
|
|
// Switch to new client when ready
|
||
|
|
const service = new UserService(modernClient);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Best Practices
|
||
|
|
|
||
|
|
1. **Depend on abstractions in services**
|
||
|
|
```typescript
|
||
|
|
class UserService {
|
||
|
|
constructor(private readonly http: AbstractHttpClient) {}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
2. **Register implementations at app bootstrap**
|
||
|
|
```typescript
|
||
|
|
// main.ts
|
||
|
|
import { registerHttpClientImplementation } from '@lilith/client-base';
|
||
|
|
import { AxiosHttpClient } from '@lilith/http-client-axios';
|
||
|
|
|
||
|
|
registerHttpClientImplementation('axios', (config) => new AxiosHttpClient(config));
|
||
|
|
```
|
||
|
|
|
||
|
|
3. **Use builders for complex configurations**
|
||
|
|
```typescript
|
||
|
|
const client = HttpClientBuilder.create('axios')
|
||
|
|
.baseURL(env.API_URL)
|
||
|
|
.timeout(10000)
|
||
|
|
.requestMiddleware(authMiddleware)
|
||
|
|
.requestMiddleware(loggingMiddleware)
|
||
|
|
.build();
|
||
|
|
```
|
||
|
|
|
||
|
|
4. **Compose middleware for cross-cutting concerns**
|
||
|
|
```typescript
|
||
|
|
const standardMiddleware = [
|
||
|
|
createAuthMiddleware({ getToken }),
|
||
|
|
createTimeoutMiddleware({ timeout: 10000 }),
|
||
|
|
createRequestLoggingMiddleware(),
|
||
|
|
];
|
||
|
|
|
||
|
|
standardMiddleware.forEach(mw => client.interceptors.request.use(mw));
|
||
|
|
```
|
||
|
|
|
||
|
|
## License
|
||
|
|
|
||
|
|
MIT
|