diff --git a/tools/http-service/__tests__/http.test.ts b/tools/http-service/__tests__/http.test.ts new file mode 100644 index 000000000..5cb535b69 --- /dev/null +++ b/tools/http-service/__tests__/http.test.ts @@ -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; +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 = { + 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 + }); + }); +}); \ No newline at end of file diff --git a/tools/http-service/index.ts b/tools/http-service/index.ts new file mode 100644 index 000000000..683c80b88 --- /dev/null +++ b/tools/http-service/index.ts @@ -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(response: Response): Promise> { + const headers: Record = {}; + 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(config: HttpRequestConfig): Promise> { + 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(response); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + } + + public async get(url: string, config: Omit = {}): Promise> { + return this.request({ ...config, url, method: 'GET' }); + } + + public async post(url: string, data?: any, config: Omit = {}): Promise> { + return this.request({ ...config, url, method: 'POST', body: data }); + } + + public async put(url: string, data?: any, config: Omit = {}): Promise> { + return this.request({ ...config, url, method: 'PUT', body: data }); + } + + public async delete(url: string, config: Omit = {}): Promise> { + return this.request({ ...config, url, method: 'DELETE' }); + } + + public async patch(url: string, data?: any, config: Omit = {}): Promise> { + return this.request({ ...config, url, method: 'PATCH', body: data }); + } +} \ No newline at end of file diff --git a/tools/http-service/types/http.ts b/tools/http-service/types/http.ts new file mode 100644 index 000000000..866cf3a2e --- /dev/null +++ b/tools/http-service/types/http.ts @@ -0,0 +1,26 @@ +export interface HttpRequestConfig { + url: string; + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + headers?: Record; + body?: any; + timeout?: number; + auth?: { + type: 'basic' | 'bearer'; + token?: string; + username?: string; + password?: string; + }; +} + +export interface HttpResponse { + data: T; + status: number; + headers: Record; + statusText: string; +} + +export interface HttpError extends Error { + status?: number; + statusText?: string; + data?: any; +} \ No newline at end of file