From 580357a130b134297d097368ec04642a3e98fa84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 12 Aug 2026 20:51:36 +0200 Subject: [PATCH] Add core Endpoint/ApiClient base classes --- src/core/http/ApiClient.ts | 47 ++++++++++++++ src/core/http/Endpoint.ts | 18 ++++++ test/core/http/ApiClient.test.ts | 104 +++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/core/http/ApiClient.ts create mode 100644 src/core/http/Endpoint.ts create mode 100644 test/core/http/ApiClient.test.ts diff --git a/src/core/http/ApiClient.ts b/src/core/http/ApiClient.ts new file mode 100644 index 0000000..99b7fa4 --- /dev/null +++ b/src/core/http/ApiClient.ts @@ -0,0 +1,47 @@ +import { Endpoint } from './Endpoint'; + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly body: string + ) { + super(message); + this.name = 'ApiError'; + } +} + +export abstract class ApiClient { + protected constructor(protected readonly baseUrl: string) {} + + protected buildHeaders(): Record { + return { Accept: 'application/json' }; + } + + protected async request( + endpoint: Endpoint, + params: TParams + ): Promise { + const query = endpoint.query(params); + const qs = query ? `?${new URLSearchParams(query).toString()}` : ''; + const url = `${this.baseUrl}${endpoint.path(params)}${qs}`; + const body = endpoint.body(params); + const headers = this.buildHeaders(); + + const init: RequestInit = { method: endpoint.method, headers }; + if (body !== undefined) { + init.body = JSON.stringify(body); + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(url, init); + const text = await res.text(); + const ok = res.ok || endpoint.acceptableStatuses.includes(res.status); + if (!ok) { + throw new ApiError(`${endpoint.method} ${url} returned ${res.status}`, res.status, text); + } + + const json = text.length > 0 ? JSON.parse(text) : undefined; + return endpoint.parseResponse(json); + } +} diff --git a/src/core/http/Endpoint.ts b/src/core/http/Endpoint.ts new file mode 100644 index 0000000..064042a --- /dev/null +++ b/src/core/http/Endpoint.ts @@ -0,0 +1,18 @@ +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + +export abstract class Endpoint { + abstract readonly method: HttpMethod; + readonly acceptableStatuses: number[] = []; + + abstract path(params: TParams): string; + + query(_params: TParams): Record | undefined { + return undefined; + } + + body(_params: TParams): unknown { + return undefined; + } + + abstract parseResponse(json: unknown): TResponse; +} diff --git a/test/core/http/ApiClient.test.ts b/test/core/http/ApiClient.test.ts new file mode 100644 index 0000000..030a957 --- /dev/null +++ b/test/core/http/ApiClient.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiClient, ApiError } from '../../../src/core/http/ApiClient'; +import { Endpoint, HttpMethod } from '../../../src/core/http/Endpoint'; + +interface GetParams { + id: number; +} + +class GetThingEndpoint extends Endpoint<{ name: string }, GetParams> { + readonly method: HttpMethod = 'GET'; + path(params: GetParams): string { + return `/things/${params.id}`; + } + query(): Record { + return { verbose: 'true' }; + } + parseResponse(json: unknown): { name: string } { + return json as { name: string }; + } +} + +class PostThingEndpoint extends Endpoint { + readonly method: HttpMethod = 'POST'; + readonly acceptableStatuses = [400]; + path(): string { + return '/things'; + } + body(params: { name: string }): unknown { + return params; + } + parseResponse(): void { + return undefined; + } +} + +class TestClient extends ApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + protected buildHeaders(): Record { + return { ...super.buildHeaders(), 'X-Test': '1' }; + } + getThing(id: number) { + return this.request(new GetThingEndpoint(), { id }); + } + postThing(name: string) { + return this.request(new PostThingEndpoint(), { name }); + } +} + +describe('ApiClient', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('builds the URL with query params and headers, and parses the response', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ name: 'widget' }), + }); + + const client = new TestClient('https://api.example.com'); + const result = await client.getThing(42); + + expect(result).toEqual({ name: 'widget' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.example.com/things/42?verbose=true'); + expect(init.method).toBe('GET'); + expect(init.headers['X-Test']).toBe('1'); + }); + + it('sends a JSON body and Content-Type header for POST', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '' }); + + const client = new TestClient('https://api.example.com'); + await client.postThing('widget'); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.body).toBe(JSON.stringify({ name: 'widget' })); + expect(init.headers['Content-Type']).toBe('application/json'); + }); + + it('treats an endpoint-declared acceptable status as success', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 400, text: async () => '' }); + + const client = new TestClient('https://api.example.com'); + await expect(client.postThing('widget')).resolves.toBeUndefined(); + }); + + it('throws ApiError for a non-ok, non-acceptable status', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' }); + + const client = new TestClient('https://api.example.com'); + await expect(client.getThing(1)).rejects.toBeInstanceOf(ApiError); + }); +});