renamed http-client to http and fixed tests
This commit is contained in:
parent
e87acb5e18
commit
3d9afd711e
26 changed files with 472 additions and 496 deletions
283
libs/http/README.md
Normal file
283
libs/http/README.md
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
# HTTP Client Library
|
||||
|
||||
A comprehensive HTTP client library for the Stock Bot platform with built-in support for:
|
||||
|
||||
- ✅ **Fetch API** - Modern, promise-based HTTP requests
|
||||
- ✅ **Proxy Support** - HTTP, HTTPS, SOCKS4, and SOCKS5 proxies
|
||||
- ✅ **Rate Limiting** - Configurable request rate limiting
|
||||
- ✅ **Timeout Handling** - Request timeouts with abort controllers
|
||||
- ✅ **Retry Logic** - Automatic retries with exponential backoff
|
||||
- ✅ **TypeScript** - Full TypeScript support with type safety
|
||||
- ✅ **Logging Integration** - Optional logger integration
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @stock-bot/http
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { HttpClient } from '@stock-bot/http';
|
||||
|
||||
// Create a client with default configuration
|
||||
const client = new HttpClient();
|
||||
|
||||
// Make a GET request
|
||||
const response = await client.get('https://api.example.com/data');
|
||||
console.log(response.data);
|
||||
|
||||
// Make a POST request
|
||||
const postResponse = await client.post('https://api.example.com/users', {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com'
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
```typescript
|
||||
import { HttpClient } from '@stock-bot/http';
|
||||
import { logger } from '@stock-bot/logger';
|
||||
|
||||
const client = new HttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
timeout: 10000, // 10 seconds
|
||||
retries: 3,
|
||||
retryDelay: 1000, // 1 second base delay
|
||||
defaultHeaders: {
|
||||
'Authorization': 'Bearer token',
|
||||
'User-Agent': 'Stock-Bot/1.0'
|
||||
},
|
||||
validateStatus: (status) => status < 400
|
||||
}, logger);
|
||||
```
|
||||
|
||||
## Proxy Support
|
||||
|
||||
### HTTP/HTTPS Proxy
|
||||
|
||||
```typescript
|
||||
const client = new HttpClient({
|
||||
proxy: {
|
||||
type: 'http',
|
||||
host: 'proxy.example.com',
|
||||
port: 8080,
|
||||
username: 'user', // optional
|
||||
password: 'pass' // optional
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### SOCKS Proxy
|
||||
|
||||
```typescript
|
||||
const client = new HttpClient({
|
||||
proxy: {
|
||||
type: 'socks5',
|
||||
host: 'socks-proxy.example.com',
|
||||
port: 1080,
|
||||
username: 'user', // optional
|
||||
password: 'pass' // optional
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
```typescript
|
||||
const client = new HttpClient({
|
||||
rateLimit: {
|
||||
maxRequests: 100, // Max 100 requests
|
||||
windowMs: 60 * 1000, // Per 1 minute
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: true // Don't count failed requests
|
||||
}
|
||||
});
|
||||
|
||||
// Check rate limit status
|
||||
const status = client.getRateLimitStatus();
|
||||
console.log(`${status.currentCount}/${status.maxRequests} requests used`);
|
||||
```
|
||||
|
||||
## Request Methods
|
||||
|
||||
```typescript
|
||||
// GET request
|
||||
const getData = await client.get('/api/data');
|
||||
|
||||
// POST request with body
|
||||
const postData = await client.post('/api/users', {
|
||||
name: 'John',
|
||||
email: 'john@example.com'
|
||||
});
|
||||
|
||||
// PUT request
|
||||
const putData = await client.put('/api/users/1', updatedUser);
|
||||
|
||||
// DELETE request
|
||||
const deleteData = await client.delete('/api/users/1');
|
||||
|
||||
// PATCH request
|
||||
const patchData = await client.patch('/api/users/1', { name: 'Jane' });
|
||||
|
||||
// Custom request
|
||||
const customResponse = await client.request({
|
||||
method: 'POST',
|
||||
url: '/api/custom',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
body: { data: 'custom' },
|
||||
timeout: 5000
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { HttpError, TimeoutError, RateLimitError } from '@stock-bot/http';
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/data');
|
||||
} catch (error) {
|
||||
if (error instanceof TimeoutError) {
|
||||
console.log('Request timed out');
|
||||
} else if (error instanceof RateLimitError) {
|
||||
console.log(`Rate limited: retry after ${error.retryAfter}ms`);
|
||||
} else if (error instanceof HttpError) {
|
||||
console.log(`HTTP error ${error.status}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Retry Configuration
|
||||
|
||||
```typescript
|
||||
const client = new HttpClient({
|
||||
retries: 3, // Retry up to 3 times
|
||||
retryDelay: 1000, // Base delay of 1 second
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
});
|
||||
|
||||
// Or per-request retry configuration
|
||||
const response = await client.get('/api/data', {
|
||||
retries: 5,
|
||||
retryDelay: 500
|
||||
});
|
||||
```
|
||||
|
||||
## Timeout Handling
|
||||
|
||||
```typescript
|
||||
// Global timeout
|
||||
const client = new HttpClient({
|
||||
timeout: 30000 // 30 seconds
|
||||
});
|
||||
|
||||
// Per-request timeout
|
||||
const response = await client.get('/api/data', {
|
||||
timeout: 5000 // 5 seconds for this request
|
||||
});
|
||||
```
|
||||
|
||||
## Custom Status Validation
|
||||
|
||||
```typescript
|
||||
const client = new HttpClient({
|
||||
validateStatus: (status) => {
|
||||
// Accept 2xx and 3xx status codes
|
||||
return status >= 200 && status < 400;
|
||||
}
|
||||
});
|
||||
|
||||
// Or per-request validation
|
||||
const response = await client.get('/api/data', {
|
||||
validateStatus: (status) => status === 200 || status === 404
|
||||
});
|
||||
```
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
The library is fully typed with TypeScript:
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Response data is properly typed
|
||||
const response = await client.get<User[]>('/api/users');
|
||||
const users: User[] = response.data;
|
||||
|
||||
// Request configuration is validated
|
||||
const config: RequestConfig = {
|
||||
method: 'POST',
|
||||
url: '/api/users',
|
||||
body: { name: 'John' },
|
||||
timeout: 5000
|
||||
};
|
||||
```
|
||||
|
||||
## Integration with Logger
|
||||
|
||||
```typescript
|
||||
import { logger } from '@stock-bot/logger';
|
||||
import { HttpClient } from '@stock-bot/http';
|
||||
|
||||
const client = new HttpClient({
|
||||
baseURL: 'https://api.example.com'
|
||||
}, logger);
|
||||
|
||||
// All requests will be logged with debug/warn/error levels
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
bun test
|
||||
|
||||
# Run with coverage
|
||||
bun test --coverage
|
||||
|
||||
# Watch mode
|
||||
bun test --watch
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Proxy Support
|
||||
- HTTP and HTTPS proxies
|
||||
- SOCKS4 and SOCKS5 proxies
|
||||
- Authentication support
|
||||
- Automatic agent creation
|
||||
|
||||
### Rate Limiting
|
||||
- Token bucket algorithm
|
||||
- Configurable window and request limits
|
||||
- Skip successful/failed requests options
|
||||
- Real-time status monitoring
|
||||
|
||||
### Retry Logic
|
||||
- Exponential backoff
|
||||
- Configurable retry attempts
|
||||
- Smart retry conditions (5xx errors only)
|
||||
- Per-request retry override
|
||||
|
||||
### Error Handling
|
||||
- Typed error classes
|
||||
- Detailed error information
|
||||
- Request/response context
|
||||
- Timeout detection
|
||||
|
||||
### Performance
|
||||
- Built on modern Fetch API
|
||||
- Minimal dependencies
|
||||
- Tree-shakeable exports
|
||||
- TypeScript optimization
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
35
libs/http/bun.lock
Normal file
35
libs/http/bun.lock
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"zod": "^3.25.51",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"ip-address": ["ip-address@9.0.5", "", { "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" } }, "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g=="],
|
||||
|
||||
"jsbn": ["jsbn@1.1.0", "", {}, "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||
|
||||
"socks": ["socks@2.8.4", "", { "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" } }, "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ=="],
|
||||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||
|
||||
"zod": ["zod@3.25.51", "", {}, "sha512-TQSnBldh+XSGL+opiSIq0575wvDPqu09AqWe1F7JhUMKY+M91/aGlK4MhpVNO7MgYfHcVCB1ffwAUTJzllKJqg=="],
|
||||
}
|
||||
}
|
||||
0
libs/http/bunfig.toml
Normal file
0
libs/http/bunfig.toml
Normal file
47
libs/http/package.json
Normal file
47
libs/http/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@stock-bot/http",
|
||||
"version": "1.0.0",
|
||||
"description": "HTTP client library with proxy support, rate limiting, and timeout for Stock Bot platform",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "bun test",
|
||||
"test:watch": "bun test --watch",
|
||||
"test:coverage": "bun test --coverage",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/logger": "*",
|
||||
"@stock-bot/types": "*",
|
||||
"got": "^14.4.7",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"socks-proxy-agent": "^8.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"bun-types": "^1.2.15"
|
||||
},
|
||||
"keywords": [
|
||||
"http",
|
||||
"client",
|
||||
"fetch",
|
||||
"proxy",
|
||||
"rate-limiting",
|
||||
"timeout",
|
||||
"stock-bot"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
266
libs/http/src/client.ts
Normal file
266
libs/http/src/client.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import type { Logger } from '@stock-bot/logger';
|
||||
import type {
|
||||
HttpClientConfig,
|
||||
RequestConfig,
|
||||
HttpResponse,
|
||||
} from './types.js';
|
||||
import { HttpError } from './types.js';
|
||||
import { ProxyManager } from './proxy-manager.js';
|
||||
import got from 'got';
|
||||
|
||||
export class HttpClient {
|
||||
private readonly config: HttpClientConfig;
|
||||
private readonly logger?: Logger;
|
||||
|
||||
constructor(config: HttpClientConfig = {}, logger?: Logger) {
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async get<T = any>(url: string, config: Omit<RequestConfig, 'method' | 'url'> = {}): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'GET', url });
|
||||
}
|
||||
|
||||
async post<T = any>(url: string, body?: any, config: Omit<RequestConfig, 'method' | 'url' | 'body'> = {}): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'POST', url, body });
|
||||
}
|
||||
|
||||
async put<T = any>(url: string, body?: any, config: Omit<RequestConfig, 'method' | 'url' | 'body'> = {}): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'PUT', url, body });
|
||||
}
|
||||
|
||||
async del<T = any>(url: string, config: Omit<RequestConfig, 'method' | 'url'> = {}): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'DELETE', url });
|
||||
}
|
||||
|
||||
async patch<T = any>(url: string, body?: any, config: Omit<RequestConfig, 'method' | 'url' | 'body'> = {}): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'PATCH', url, body });
|
||||
} /**
|
||||
* Main request method - unified and simplified
|
||||
*/
|
||||
async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
|
||||
const finalConfig = this.mergeConfig(config);
|
||||
|
||||
this.logger?.debug('Making HTTP request', {
|
||||
method: finalConfig.method,
|
||||
url: finalConfig.url,
|
||||
hasProxy: !!finalConfig.proxy
|
||||
});
|
||||
|
||||
try {
|
||||
// Single decision point for proxy type - only request-level proxy
|
||||
const proxy = finalConfig.proxy;
|
||||
const useBunFetch = !proxy || ProxyManager.shouldUseBunFetch(proxy);
|
||||
|
||||
const response = await this.makeRequest<T>(finalConfig, useBunFetch);
|
||||
|
||||
this.logger?.debug('HTTP request successful', {
|
||||
method: finalConfig.method,
|
||||
url: finalConfig.url,
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.logger?.warn('HTTP request failed', {
|
||||
method: finalConfig.method,
|
||||
url: finalConfig.url,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified request method with consolidated timeout handling
|
||||
*/
|
||||
private async makeRequest<T>(config: RequestConfig, useBunFetch: boolean): Promise<HttpResponse<T>> {
|
||||
const timeout = config.timeout ?? this.config.timeout ?? 30000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = useBunFetch
|
||||
? await this.fetchRequest<T>(config, controller.signal)
|
||||
: await this.gotRequest<T>(config, controller.signal);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Unified timeout error handling
|
||||
if (controller.signal.aborted) {
|
||||
throw new HttpError(`Request timeout after ${timeout}ms`);
|
||||
}
|
||||
if ((error as any).name === 'TimeoutError') {
|
||||
throw new HttpError(`Request timeout after ${timeout}ms`);
|
||||
}
|
||||
|
||||
throw error; // Re-throw other errors as-is
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Bun fetch implementation (simplified)
|
||||
*/
|
||||
private async fetchRequest<T>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> {
|
||||
try {
|
||||
const options = this.buildFetchOptions(config, signal);
|
||||
const response = await fetch(config.url, options);
|
||||
|
||||
return this.parseFetchResponse<T>(response);
|
||||
} catch (error) {
|
||||
throw signal.aborted
|
||||
? new HttpError(`Request timeout`)
|
||||
: new HttpError(`Request failed: ${(error as Error).message}`);
|
||||
}
|
||||
} /**
|
||||
* Got implementation (simplified for SOCKS proxies)
|
||||
*/
|
||||
private async gotRequest<T>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> {
|
||||
try {
|
||||
const options = this.buildGotOptions(config, signal);
|
||||
const response = await got(config.url, options);
|
||||
|
||||
return this.parseGotResponse<T>(response);
|
||||
} catch (error) {
|
||||
// Handle both AbortSignal timeout and Got-specific timeout errors
|
||||
if (signal.aborted) {
|
||||
throw new HttpError(`Request timeout`);
|
||||
}
|
||||
if ((error as any).name === 'TimeoutError') {
|
||||
throw new HttpError(`Request timeout`);
|
||||
}
|
||||
|
||||
throw new HttpError(`Request failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Build fetch options (extracted for clarity)
|
||||
*/
|
||||
private buildFetchOptions(config: RequestConfig, signal: AbortSignal): RequestInit {
|
||||
const options: RequestInit = {
|
||||
method: config.method || 'GET',
|
||||
headers: config.headers || {},
|
||||
signal,
|
||||
};
|
||||
|
||||
// Add body
|
||||
if (config.body && config.method !== 'GET') {
|
||||
if (typeof config.body === 'object') {
|
||||
options.body = JSON.stringify(config.body);
|
||||
options.headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
} else {
|
||||
options.body = config.body;
|
||||
}
|
||||
}
|
||||
|
||||
// Add proxy (HTTP/HTTPS only) - request level only
|
||||
if (config.proxy && ProxyManager.shouldUseBunFetch(config.proxy)) {
|
||||
(options as any).proxy = ProxyManager.createBunProxyUrl(config.proxy);
|
||||
}
|
||||
|
||||
return options;
|
||||
} /**
|
||||
* Build Got options (extracted for clarity)
|
||||
*/
|
||||
private buildGotOptions(config: RequestConfig, signal: AbortSignal): any {
|
||||
const options: any = {
|
||||
method: config.method || 'GET',
|
||||
headers: config.headers || {},
|
||||
signal, // Use AbortSignal instead of Got's timeout
|
||||
retry: {
|
||||
limit: 3,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
|
||||
},
|
||||
throwHttpErrors: false,
|
||||
responseType: 'json'
|
||||
};
|
||||
|
||||
// Add body
|
||||
if (config.body && config.method !== 'GET') {
|
||||
if (typeof config.body === 'object') {
|
||||
options.json = config.body;
|
||||
} else {
|
||||
options.body = config.body;
|
||||
options.headers = { 'Content-Type': 'text/plain', ...options.headers };
|
||||
}
|
||||
}
|
||||
|
||||
// Add SOCKS proxy via agent - request level only
|
||||
if (config.proxy && !ProxyManager.shouldUseBunFetch(config.proxy)) {
|
||||
ProxyManager.validateConfig(config.proxy);
|
||||
const agent = ProxyManager.createGotAgent(config.proxy);
|
||||
options.agent = {
|
||||
http: agent,
|
||||
https: agent
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse fetch response (simplified)
|
||||
*/
|
||||
private async parseFetchResponse<T>(response: Response): Promise<HttpResponse<T>> {
|
||||
const data = await this.parseResponseBody<T>(response);
|
||||
const headers = Object.fromEntries(response.headers.entries());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpError(
|
||||
`Request failed with status ${response.status}`,
|
||||
response.status,
|
||||
{ data, status: response.status, headers, ok: response.ok }
|
||||
);
|
||||
}
|
||||
|
||||
return { data, status: response.status, headers, ok: response.ok };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Got response (simplified)
|
||||
*/
|
||||
private parseGotResponse<T>(response: any): HttpResponse<T> {
|
||||
const headers = response.headers as Record<string, string>;
|
||||
const status = response.statusCode;
|
||||
const ok = status >= 200 && status < 300;
|
||||
const data = response.body as T;
|
||||
|
||||
if (!ok) {
|
||||
throw new HttpError(
|
||||
`Request failed with status ${status}`,
|
||||
status,
|
||||
{ data, status, headers, ok }
|
||||
);
|
||||
}
|
||||
|
||||
return { data, status, headers, ok };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified body parsing (works for fetch response)
|
||||
*/
|
||||
private async parseResponseBody<T>(response: Response): Promise<T> {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
} else if (contentType.includes('text/')) {
|
||||
return response.text() as any;
|
||||
} else {
|
||||
return response.arrayBuffer() as any;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Merge configs - request-level proxy only
|
||||
*/
|
||||
private mergeConfig(config: RequestConfig): RequestConfig {
|
||||
return {
|
||||
...config,
|
||||
headers: { ...this.config.headers, ...config.headers },
|
||||
timeout: config.timeout ?? this.config.timeout,
|
||||
};
|
||||
}
|
||||
}
|
||||
7
libs/http/src/index.ts
Normal file
7
libs/http/src/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Re-export all types and classes
|
||||
export * from './types.js';
|
||||
export * from './client.js';
|
||||
export * from './proxy-manager.js';
|
||||
|
||||
// Default export
|
||||
export { HttpClient as default } from './client.js';
|
||||
91
libs/http/src/proxy-manager.ts
Normal file
91
libs/http/src/proxy-manager.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import got from 'got';
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import type { ProxyConfig } from './types.js';
|
||||
|
||||
export class ProxyManager {
|
||||
/**
|
||||
* Determine if we should use Bun fetch (HTTP/HTTPS) or Got (SOCKS)
|
||||
*/
|
||||
static shouldUseBunFetch(proxy: ProxyConfig): boolean {
|
||||
return proxy.protocol === 'http' || proxy.protocol === 'https';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Bun fetch proxy URL for HTTP/HTTPS proxies
|
||||
*/
|
||||
static createBunProxyUrl(proxy: ProxyConfig): string {
|
||||
const { protocol, host, port, username, password } = proxy;
|
||||
|
||||
if (username && password) {
|
||||
return `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
|
||||
}
|
||||
return `${protocol}://${host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create appropriate agent for Got based on proxy type
|
||||
*/
|
||||
static createGotAgent(proxy: ProxyConfig) {
|
||||
this.validateConfig(proxy);
|
||||
|
||||
const proxyUrl = this.buildProxyUrl(proxy);
|
||||
|
||||
switch (proxy.protocol) {
|
||||
case 'socks4':
|
||||
case 'socks5':
|
||||
return new SocksProxyAgent(proxyUrl);
|
||||
case 'http':
|
||||
return new HttpProxyAgent(proxyUrl);
|
||||
case 'https':
|
||||
return new HttpsProxyAgent(proxyUrl);
|
||||
default:
|
||||
throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Got instance with proxy configuration
|
||||
*/
|
||||
static createGotInstance(proxy: ProxyConfig) {
|
||||
const agent = this.createGotAgent(proxy);
|
||||
|
||||
return got.extend({
|
||||
agent: {
|
||||
http: agent,
|
||||
https: agent
|
||||
},
|
||||
timeout: {
|
||||
request: 30000,
|
||||
connect: 10000
|
||||
},
|
||||
retry: {
|
||||
limit: 3,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
|
||||
},
|
||||
throwHttpErrors: false // We'll handle errors ourselves
|
||||
});
|
||||
}
|
||||
|
||||
private static buildProxyUrl(proxy: ProxyConfig): string {
|
||||
const { protocol, host, port, username, password } = proxy;
|
||||
|
||||
if (username && password) {
|
||||
return `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
|
||||
}
|
||||
return `${protocol}://${host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple proxy config validation
|
||||
*/
|
||||
static validateConfig(proxy: ProxyConfig): void {
|
||||
if (!proxy.host || !proxy.port) {
|
||||
throw new Error('Proxy host and port are required');
|
||||
}
|
||||
if (!['http', 'https', 'socks4', 'socks5'].includes(proxy.protocol)) {
|
||||
throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
libs/http/src/types.ts
Normal file
42
libs/http/src/types.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Minimal types for fast HTTP client
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
|
||||
export interface ProxyConfig {
|
||||
protocol: 'http' | 'https' | 'socks4' | 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface HttpClientConfig {
|
||||
timeout?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RequestConfig {
|
||||
method?: HttpMethod;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
timeout?: number;
|
||||
proxy?: ProxyConfig;
|
||||
}
|
||||
|
||||
export interface HttpResponse<T = any> {
|
||||
data: T;
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status?: number,
|
||||
public response?: HttpResponse
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
156
libs/http/test/http-integration.test.ts
Normal file
156
libs/http/test/http-integration.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { HttpClient, HttpError } from '../src/index.js';
|
||||
import { MockServer } from './mock-server.js';
|
||||
|
||||
/**
|
||||
* Integration tests for HTTP client with real network scenarios
|
||||
* These tests use external services and may be affected by network conditions
|
||||
*/
|
||||
|
||||
let mockServer: MockServer;
|
||||
let mockServerBaseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockServer = new MockServer();
|
||||
await mockServer.start();
|
||||
mockServerBaseUrl = mockServer.getBaseUrl();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mockServer.stop();
|
||||
});
|
||||
|
||||
describe('HTTP Integration Tests', () => {
|
||||
let client: HttpClient;
|
||||
|
||||
beforeAll(() => {
|
||||
client = new HttpClient({
|
||||
timeout: 10000,
|
||||
retries: 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world scenarios', () => {
|
||||
test('should handle JSON API responses', async () => {
|
||||
try {
|
||||
const response = await client.get('https://jsonplaceholder.typicode.com/posts/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data).toHaveProperty('id');
|
||||
expect(response.data).toHaveProperty('title');
|
||||
expect(response.data).toHaveProperty('body');
|
||||
} catch (error) {
|
||||
console.warn('External API test skipped due to network issues:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle large responses', async () => {
|
||||
try {
|
||||
const response = await client.get('https://jsonplaceholder.typicode.com/posts');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.data)).toBe(true);
|
||||
expect(response.data.length).toBeGreaterThan(0);
|
||||
} catch (error) {
|
||||
console.warn('Large response test skipped due to network issues:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle POST with JSON data', async () => {
|
||||
try {
|
||||
const postData = {
|
||||
title: 'Integration Test Post',
|
||||
body: 'This is a test post from integration tests',
|
||||
userId: 1
|
||||
};
|
||||
|
||||
const response = await client.post('https://jsonplaceholder.typicode.com/posts', postData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.data).toHaveProperty('id');
|
||||
expect(response.data.title).toBe(postData.title);
|
||||
} catch (error) {
|
||||
console.warn('POST integration test skipped due to network issues:', error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error scenarios with mock server', () => { test('should handle various HTTP status codes', async () => {
|
||||
const successCodes = [200, 201];
|
||||
const errorCodes = [400, 401, 403, 404, 500, 503];
|
||||
|
||||
// Test success codes
|
||||
for (const statusCode of successCodes) {
|
||||
const response = await client.get(`${mockServerBaseUrl}/status/${statusCode}`);
|
||||
expect(response.status).toBe(statusCode);
|
||||
}
|
||||
|
||||
// Test error codes (should throw HttpError)
|
||||
for (const statusCode of errorCodes) {
|
||||
await expect(
|
||||
client.get(`${mockServerBaseUrl}/status/${statusCode}`)
|
||||
).rejects.toThrow(HttpError);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle malformed responses gracefully', async () => {
|
||||
// Mock server returns valid JSON, so this test verifies our client handles it properly
|
||||
const response = await client.get(`${mockServerBaseUrl}/`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(typeof response.data).toBe('object');
|
||||
});
|
||||
|
||||
test('should handle concurrent requests', async () => {
|
||||
const requests = Array.from({ length: 5 }, (_, i) =>
|
||||
client.get(`${mockServerBaseUrl}/`, {
|
||||
headers: { 'X-Request-ID': `req-${i}` }
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
responses.forEach((response, index) => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.headers).toHaveProperty('x-request-id', `req-${index}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance and reliability', () => {
|
||||
test('should handle rapid sequential requests', async () => {
|
||||
const startTime = Date.now();
|
||||
const requests = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
requests.push(client.get(`${mockServerBaseUrl}/`));
|
||||
}
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
const endTime = Date.now();
|
||||
|
||||
expect(responses).toHaveLength(10);
|
||||
responses.forEach(response => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
console.log(`Completed 10 requests in ${endTime - startTime}ms`);
|
||||
});
|
||||
|
||||
test('should maintain connection efficiency', async () => {
|
||||
const clientWithKeepAlive = new HttpClient({
|
||||
timeout: 5000,
|
||||
keepAlive: true
|
||||
});
|
||||
|
||||
const requests = Array.from({ length: 3 }, () =>
|
||||
clientWithKeepAlive.get(`${mockServerBaseUrl}/`)
|
||||
);
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
responses.forEach(response => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
161
libs/http/test/http.test.ts
Normal file
161
libs/http/test/http.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { describe, test, expect, beforeEach, beforeAll, afterAll } from 'bun:test';
|
||||
import { HttpClient, HttpError, ProxyManager } from '../src/index.js';
|
||||
import type { ProxyConfig } from '../src/types.js';
|
||||
import { MockServer } from './mock-server.js';
|
||||
|
||||
// Global mock server instance
|
||||
let mockServer: MockServer;
|
||||
let mockServerBaseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Start mock server for all tests
|
||||
mockServer = new MockServer();
|
||||
await mockServer.start();
|
||||
mockServerBaseUrl = mockServer.getBaseUrl();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Stop mock server
|
||||
await mockServer.stop();
|
||||
});
|
||||
|
||||
describe('HttpClient', () => {
|
||||
let client: HttpClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new HttpClient();
|
||||
});
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
test('should create client with default config', () => {
|
||||
expect(client).toBeInstanceOf(HttpClient);
|
||||
});
|
||||
|
||||
test('should make GET request', async () => {
|
||||
const response = await client.get(`${mockServerBaseUrl}/`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data).toHaveProperty('url');
|
||||
expect(response.data).toHaveProperty('method', 'GET');
|
||||
});
|
||||
|
||||
test('should make POST request with body', async () => {
|
||||
const testData = {
|
||||
title: 'Test Post',
|
||||
body: 'Test body',
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
const response = await client.post(`${mockServerBaseUrl}/post`, testData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data).toHaveProperty('data');
|
||||
expect(response.data.data).toEqual(testData);
|
||||
});
|
||||
|
||||
test('should handle custom headers', async () => {
|
||||
const customHeaders = {
|
||||
'X-Custom-Header': 'test-value',
|
||||
'User-Agent': 'StockBot-HTTP-Client/1.0'
|
||||
};
|
||||
|
||||
const response = await client.get(`${mockServerBaseUrl}/headers`, {
|
||||
headers: customHeaders
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.headers).toHaveProperty('x-custom-header', 'test-value');
|
||||
expect(response.data.headers).toHaveProperty('user-agent', 'StockBot-HTTP-Client/1.0');
|
||||
});
|
||||
|
||||
test('should handle timeout', async () => {
|
||||
const clientWithTimeout = new HttpClient({ timeout: 1 }); // 1ms timeout
|
||||
|
||||
await expect(
|
||||
clientWithTimeout.get('https://httpbin.org/delay/1')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
describe('Error handling', () => {
|
||||
test('should handle HTTP errors', async () => {
|
||||
await expect(
|
||||
client.get(`${mockServerBaseUrl}/status/404`)
|
||||
).rejects.toThrow(HttpError);
|
||||
});
|
||||
|
||||
test('should handle network errors gracefully', async () => {
|
||||
await expect(
|
||||
client.get('https://nonexistent-domain-that-will-fail-12345.test')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('should handle invalid URLs', async () => {
|
||||
await expect(
|
||||
client.get('not:/a:valid/url')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP methods', () => {
|
||||
test('should make PUT request', async () => {
|
||||
const testData = { id: 1, name: 'Updated' };
|
||||
const response = await client.put(`${mockServerBaseUrl}/post`, testData);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('should make DELETE request', async () => {
|
||||
const response = await client.del(`${mockServerBaseUrl}/`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
test('should make PATCH request', async () => {
|
||||
const testData = { name: 'Patched' };
|
||||
const response = await client.patch(`${mockServerBaseUrl}/post`, testData);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxyManager', () => {
|
||||
test('should determine when to use Bun fetch', () => {
|
||||
const httpProxy: ProxyConfig = {
|
||||
protocol: 'http',
|
||||
host: 'proxy.example.com',
|
||||
port: 8080
|
||||
};
|
||||
|
||||
const socksProxy: ProxyConfig = {
|
||||
protocol: 'socks5',
|
||||
host: 'proxy.example.com',
|
||||
port: 1080
|
||||
};
|
||||
|
||||
expect(ProxyManager.shouldUseBunFetch(httpProxy)).toBe(true);
|
||||
expect(ProxyManager.shouldUseBunFetch(socksProxy)).toBe(false);
|
||||
});
|
||||
|
||||
test('should create proxy URL for Bun fetch', () => {
|
||||
const proxy: ProxyConfig = {
|
||||
protocol: 'http',
|
||||
host: 'proxy.example.com',
|
||||
port: 8080,
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
};
|
||||
|
||||
const proxyUrl = ProxyManager.createBunProxyUrl(proxy);
|
||||
expect(proxyUrl).toBe('http://user:pass@proxy.example.com:8080');
|
||||
});
|
||||
|
||||
test('should create proxy URL without credentials', () => {
|
||||
const proxy: ProxyConfig = {
|
||||
protocol: 'https',
|
||||
host: 'proxy.example.com',
|
||||
port: 8080
|
||||
};
|
||||
|
||||
const proxyUrl = ProxyManager.createBunProxyUrl(proxy);
|
||||
expect(proxyUrl).toBe('https://proxy.example.com:8080');
|
||||
});
|
||||
});
|
||||
131
libs/http/test/mock-server.test.ts
Normal file
131
libs/http/test/mock-server.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { MockServer } from './mock-server.js';
|
||||
|
||||
/**
|
||||
* Tests for the MockServer utility
|
||||
* Ensures our test infrastructure works correctly
|
||||
*/
|
||||
|
||||
describe('MockServer', () => {
|
||||
let mockServer: MockServer;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockServer = new MockServer();
|
||||
await mockServer.start();
|
||||
baseUrl = mockServer.getBaseUrl();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mockServer.stop();
|
||||
});
|
||||
|
||||
describe('Server lifecycle', () => {
|
||||
test('should start and provide base URL', () => {
|
||||
expect(baseUrl).toMatch(/^http:\/\/localhost:\d+$/);
|
||||
expect(mockServer.getBaseUrl()).toBe(baseUrl);
|
||||
});
|
||||
|
||||
test('should be reachable', async () => {
|
||||
const response = await fetch(`${baseUrl}/`);
|
||||
expect(response.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status endpoints', () => {
|
||||
test('should return correct status codes', async () => {
|
||||
const statusCodes = [200, 201, 400, 401, 403, 404, 500, 503];
|
||||
|
||||
for (const status of statusCodes) {
|
||||
const response = await fetch(`${baseUrl}/status/${status}`);
|
||||
expect(response.status).toBe(status);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Headers endpoint', () => {
|
||||
test('should echo request headers', async () => {
|
||||
const response = await fetch(`${baseUrl}/headers`, {
|
||||
headers: {
|
||||
'X-Test-Header': 'test-value',
|
||||
'User-Agent': 'MockServer-Test'
|
||||
} });
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const data = await response.json();
|
||||
expect(data.headers).toHaveProperty('x-test-header', 'test-value');
|
||||
expect(data.headers).toHaveProperty('user-agent', 'MockServer-Test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic auth endpoint', () => {
|
||||
test('should authenticate valid credentials', async () => {
|
||||
const username = 'testuser';
|
||||
const password = 'testpass';
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
|
||||
const response = await fetch(`${baseUrl}/basic-auth/${username}/${password}`, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${credentials}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const data = await response.json();
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.user).toBe(username);
|
||||
});
|
||||
|
||||
test('should reject invalid credentials', async () => {
|
||||
const credentials = btoa('wrong:credentials');
|
||||
|
||||
const response = await fetch(`${baseUrl}/basic-auth/user/pass`, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${credentials}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject missing auth header', async () => {
|
||||
const response = await fetch(`${baseUrl}/basic-auth/user/pass`);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST endpoint', () => {
|
||||
test('should echo POST data', async () => {
|
||||
const testData = {
|
||||
message: 'Hello, MockServer!',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/post`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(testData)
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const data = await response.json();
|
||||
expect(data.data).toEqual(testData);
|
||||
expect(data.method).toBe('POST');
|
||||
expect(data.headers).toHaveProperty('content-type', 'application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default endpoint', () => {
|
||||
test('should return request information', async () => {
|
||||
const response = await fetch(`${baseUrl}/unknown-endpoint`);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const data = await response.json();
|
||||
expect(data.url).toBe(`${baseUrl}/unknown-endpoint`);
|
||||
expect(data.method).toBe('GET');
|
||||
expect(data.headers).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
114
libs/http/test/mock-server.ts
Normal file
114
libs/http/test/mock-server.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Mock HTTP server for testing the HTTP client
|
||||
* Replaces external dependency on httpbin.org with a local server
|
||||
*/
|
||||
export class MockServer {
|
||||
private server: ReturnType<typeof Bun.serve> | null = null;
|
||||
private port: number = 0;
|
||||
|
||||
/**
|
||||
* Start the mock server on a random port
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
this.server = Bun.serve({
|
||||
port: 1, // Use any available port
|
||||
fetch: this.handleRequest.bind(this),
|
||||
error: this.handleError.bind(this),
|
||||
});
|
||||
|
||||
this.port = this.server.port || 1;
|
||||
console.log(`Mock server started on port ${this.port}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the mock server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.server) {
|
||||
this.server.stop(true);
|
||||
this.server = null;
|
||||
this.port = 0;
|
||||
console.log('Mock server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URL of the mock server
|
||||
*/
|
||||
getBaseUrl(): string {
|
||||
if (!this.server) {
|
||||
throw new Error('Server not started');
|
||||
}
|
||||
return `http://localhost:${this.port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming requests
|
||||
*/ private async handleRequest(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
|
||||
console.log(`Mock server handling request: ${req.method} ${path}`);
|
||||
|
||||
// Status endpoints
|
||||
if (path.startsWith('/status/')) {
|
||||
const status = parseInt(path.replace('/status/', ''), 10);
|
||||
console.log(`Returning status: ${status}`);
|
||||
return new Response(null, { status });
|
||||
} // Headers endpoint
|
||||
if (path === '/headers') {
|
||||
const headers = Object.fromEntries([...req.headers.entries()]);
|
||||
console.log('Headers endpoint called, received headers:', headers);
|
||||
return Response.json({ headers });
|
||||
} // Basic auth endpoint
|
||||
if (path.startsWith('/basic-auth/')) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
const expectedUsername = parts[1];
|
||||
const expectedPassword = parts[2];
|
||||
console.log(`Basic auth endpoint called: expected user=${expectedUsername}, pass=${expectedPassword}`);
|
||||
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||||
console.log('Missing or invalid Authorization header');
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const base64Credentials = authHeader.split(' ')[1];
|
||||
const credentials = atob(base64Credentials);
|
||||
const [username, password] = credentials.split(':');
|
||||
|
||||
if (username === expectedUsername && password === expectedPassword) {
|
||||
return Response.json({
|
||||
authenticated: true,
|
||||
user: username
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// Echo request body
|
||||
if (path === '/post' && req.method === 'POST') {
|
||||
const data = await req.json();
|
||||
return Response.json({
|
||||
data,
|
||||
headers: Object.fromEntries([...req.headers.entries()]),
|
||||
method: req.method
|
||||
});
|
||||
}
|
||||
|
||||
// Default response
|
||||
return Response.json({
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
headers: Object.fromEntries([...req.headers.entries()])
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle errors
|
||||
*/
|
||||
private handleError(error: Error): Response {
|
||||
return new Response('Server error', { status: 500 });
|
||||
}
|
||||
}
|
||||
21
libs/http/tsconfig.json
Normal file
21
libs/http/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../logger" },
|
||||
{ "path": "../types" }
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue