import type { ApiClient, HttpMethod } from './ApiClient' export interface ValidationError { path: string message: string code: string } export class ApiError extends Error { public readonly errorClass?: string | undefined public readonly status?: number | undefined public readonly validationErrors: ValidationError[] | null constructor( message: string, errorClass?: string, status?: number, validationErrors: ValidationError[] | null = null, ) { super(message) this.name = 'ApiError' this.errorClass = errorClass this.status = status this.validationErrors = validationErrors } } export class BaseApiClient implements ApiClient { protected readonly baseUrl: string protected accessToken: string | null = null constructor(baseUrl: string, accessToken?: string) { this.baseUrl = baseUrl.replace(/\/$/, '') this.accessToken = accessToken || null } setAccessToken(accessToken: string): this { this.accessToken = accessToken return this } async request( method: HttpMethod, endpoint: string, body?: TBody, headers?: Record, ): Promise { const url = `${this.baseUrl}/${endpoint.replace(/^\//, '')}` const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json', ...(this.accessToken && { Authorization: `Bearer ${this.accessToken}` }), ...headers, }, ...(body !== undefined && { body: JSON.stringify(body) }), }) return this.handleResponse(response) } protected async handleResponse(response: Response): Promise { if (!response.ok) { const contentType = response.headers.get('Content-Type') const isJson = contentType?.includes('application/json') ?? false let errorMessage = `HTTP Error ${response.status}: ${response.statusText}` let errorClass: string | undefined let validationErrors: ValidationError[] | null = null if (isJson) { try { const errorText = await response.text() if (errorText) { const errorData = JSON.parse(errorText) errorMessage = errorData.message || errorData.error || errorMessage errorClass = errorData.error validationErrors = errorData.validationErrors ?? null } } catch { // If JSON parsing fails, use the default error message } } throw new ApiError(errorMessage, errorClass, response.status, validationErrors) } // 204 No Content never has a body; short-circuit before reading the stream. if (response.status === 204) { return undefined as unknown as TResponse } // Read body as text once and treat an empty body as "no content" regardless of // Content-Type. Servers sometimes return 200 with Content-Type: application/json // and an empty body (e.g. POST endpoints whose framework keeps the default JSON // content-type even when nothing is written). `response.json()` would throw // "Unexpected end of input" in that case. const text = await response.text() if (!text) { return undefined as unknown as TResponse } const contentType = response.headers.get('Content-Type') const isJson = contentType?.includes('application/json') ?? false if (isJson) { return JSON.parse(text) as TResponse } try { return JSON.parse(text) as TResponse } catch { return text as unknown as TResponse } } get(endpoint: string, headers?: Record): Promise { return this.request('GET', endpoint, undefined, headers) } post( endpoint: string, body?: TBody, headers?: Record, ): Promise { return this.request('POST', endpoint, body, headers) } put( endpoint: string, body?: TBody, headers?: Record, ): Promise { return this.request('PUT', endpoint, body, headers) } patch( endpoint: string, body?: TBody, headers?: Record, ): Promise { return this.request('PATCH', endpoint, body, headers) } delete( endpoint: string, headers?: Record, ): Promise { return this.request('DELETE', endpoint, undefined, headers) } }