Added http service to tools

This commit is contained in:
Waleed Latif
2025-01-14 18:25:14 -08:00
parent 4ea867ac7e
commit abd52dd6c6
3 changed files with 254 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import { describe, expect, test, jest, beforeEach } from '@jest/globals';
import { HttpService } from '../index';
import { HttpRequestConfig } from '../types/http';
// Setup fetch mock
const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
global.fetch = mockFetch;
describe('HttpService', () => {
let service: HttpService;
beforeEach(() => {
jest.clearAllMocks();
service = HttpService.getInstance();
});
test('should make successful GET request', async () => {
const mockResponse = { message: 'Success' };
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(mockResponse)
} as Response);
const response = await service.get('https://api.example.com/data');
expect(response.data).toEqual(mockResponse);
expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
method: 'GET'
})
);
});
test('should make successful POST request with JSON body', async () => {
const requestBody = { key: 'value' };
const mockResponse = { id: 1 };
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
statusText: 'Created',
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(mockResponse)
} as Response);
const response = await service.post('https://api.example.com/data', requestBody);
expect(response.data).toEqual(mockResponse);
expect(response.status).toBe(201);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(requestBody)
})
);
});
test('should handle request with authentication', async () => {
const mockResponse = { message: 'Authenticated' };
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(mockResponse)
} as Response);
const config: Omit<HttpRequestConfig, 'url' | 'method'> = {
auth: {
type: 'bearer',
token: 'test-token'
}
};
const response = await service.get('https://api.example.com/protected', config);
expect(response.data).toEqual(mockResponse);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/protected',
expect.objectContaining({
headers: expect.any(Headers)
})
);
const headers = mockFetch.mock.calls[0][1]?.headers as Headers;
expect(headers.get('Authorization')).toBe('Bearer test-token');
});
test('should handle request timeout', async () => {
mockFetch.mockImplementationOnce(() =>
new Promise((_, reject) => {
setTimeout(() => reject(new Error('The operation was aborted')), 50);
})
);
await expect(
service.get('https://api.example.com/data', { timeout: 10 })
).rejects.toThrow('The operation was aborted');
});
test('should handle API errors', async () => {
const errorResponse = { error: 'Not Found' };
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(errorResponse)
} as Response);
const promise = service.get('https://api.example.com/nonexistent');
await expect(promise).rejects.toThrow('Not Found');
await expect(promise).rejects.toMatchObject({
status: 404,
data: errorResponse
});
});
});

108
tools/http-service/index.ts Normal file
View File

@@ -0,0 +1,108 @@
import { HttpRequestConfig, HttpResponse, HttpError } from './types/http';
export class HttpService {
private static instance: HttpService;
private constructor() {}
public static getInstance(): HttpService {
if (!HttpService.instance) {
HttpService.instance = new HttpService();
}
return HttpService.instance;
}
private getHeaders(config: HttpRequestConfig): Headers {
const headers = new Headers(config.headers);
if (!headers.has('Content-Type') && config.body) {
headers.set('Content-Type', 'application/json');
}
if (config.auth) {
switch (config.auth.type) {
case 'bearer':
headers.set('Authorization', `Bearer ${config.auth.token}`);
break;
case 'basic':
const credentials = btoa(`${config.auth.username}:${config.auth.password}`);
headers.set('Authorization', `Basic ${credentials}`);
break;
}
}
return headers;
}
private async handleResponse<T>(response: Response): Promise<HttpResponse<T>> {
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
if (!response.ok) {
const error = new Error(response.statusText) as HttpError;
error.status = response.status;
error.statusText = response.statusText;
try {
error.data = await response.json();
} catch {
error.data = await response.text();
}
throw error;
}
let data: T;
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
data = await response.json();
} else {
data = await response.text() as T;
}
return {
data,
status: response.status,
statusText: response.statusText,
headers
};
}
public async request<T = any>(config: HttpRequestConfig): Promise<HttpResponse<T>> {
const controller = new AbortController();
const timeoutId = config.timeout ? setTimeout(() => controller.abort(), config.timeout) : null;
try {
const response = await fetch(config.url, {
method: config.method,
headers: this.getHeaders(config),
body: config.body ? JSON.stringify(config.body) : undefined,
signal: controller.signal
});
return await this.handleResponse<T>(response);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
public async get<T = any>(url: string, config: Omit<HttpRequestConfig, 'url' | 'method'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'GET' });
}
public async post<T = any>(url: string, data?: any, config: Omit<HttpRequestConfig, 'url' | 'method' | 'body'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'POST', body: data });
}
public async put<T = any>(url: string, data?: any, config: Omit<HttpRequestConfig, 'url' | 'method' | 'body'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'PUT', body: data });
}
public async delete<T = any>(url: string, config: Omit<HttpRequestConfig, 'url' | 'method'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'DELETE' });
}
public async patch<T = any>(url: string, data?: any, config: Omit<HttpRequestConfig, 'url' | 'method' | 'body'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'PATCH', body: data });
}
}

View File

@@ -0,0 +1,26 @@
export interface HttpRequestConfig {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers?: Record<string, string>;
body?: any;
timeout?: number;
auth?: {
type: 'basic' | 'bearer';
token?: string;
username?: string;
password?: string;
};
}
export interface HttpResponse<T = any> {
data: T;
status: number;
headers: Record<string, string>;
statusText: string;
}
export interface HttpError extends Error {
status?: number;
statusText?: string;
data?: any;
}