From 4e9a226f042e93264e27c24a831188ed3fb1788d Mon Sep 17 00:00:00 2001 From: PlainOlSoapBar <86759315+PlainOlSoapBar@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:19:53 +0800 Subject: [PATCH 1/7] feat(api): Type definitions for ApiClient --- src/types/apiClient.types.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/types/apiClient.types.ts diff --git a/src/types/apiClient.types.ts b/src/types/apiClient.types.ts new file mode 100644 index 0000000..fb64d05 --- /dev/null +++ b/src/types/apiClient.types.ts @@ -0,0 +1,21 @@ +export type TokenProvider = () => Promise; + +export interface ApiResponse { + success: boolean; + data: T; + error?: string; +} + +export interface RequestOptions { + signal?: AbortSignal; + headers?: Record; +} + +// Promise: Successful JSON -> T +// Promise: Successful empty response -> Null +export interface ApiClientInterface { + get(endpoint: string, options?: RequestOptions): Promise; + post(endpoint: string, body: B, options?: RequestOptions): Promise; + put(endpoint: string, body: B, options?: RequestOptions): Promise; + delete(endpoint: string, options?: RequestOptions): Promise; +}; \ No newline at end of file From 8395a34a1557a254711deea989230d065645da74 Mon Sep 17 00:00:00 2001 From: PlainOlSoapBar <86759315+PlainOlSoapBar@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:20:35 +0800 Subject: [PATCH 2/7] feat(api): Basic error codes for ApiClient --- src/services/api/apiError.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/services/api/apiError.ts diff --git a/src/services/api/apiError.ts b/src/services/api/apiError.ts new file mode 100644 index 0000000..392b925 --- /dev/null +++ b/src/services/api/apiError.ts @@ -0,0 +1,20 @@ +export type AppErrorCode = + 'NETWORK_ERROR' | + 'REQUEST_TIMEOUT' | + 'REQUEST_CANCELLED' | + 'INVALID_RESPONSE' | + 'SESSION_EXPIRED' | + 'PERMISSION_DENIED' | + 'SERVER_ERROR' | + 'UNKNOWN'; + +export class AppError extends Error { + constructor( + public readonly code: AppErrorCode, + message: string, + public readonly status?: number, // Pass in response.status + ) { + super(message); + this.name = 'AppError'; + } +} \ No newline at end of file From 7e2680894993ba472d071e15fbf4b96a9341214b Mon Sep 17 00:00:00 2001 From: PlainOlSoapBar <86759315+PlainOlSoapBar@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:55:24 +0800 Subject: [PATCH 3/7] feat(api): Basic ApiClient implementation --- src/services/api/apiClient.ts | 78 +++++++++++++++++++++++++++++++++++ src/services/api/apiError.ts | 20 ++++----- src/types/apiClient.types.ts | 30 +++++++++----- 3 files changed, 107 insertions(+), 21 deletions(-) create mode 100644 src/services/api/apiClient.ts diff --git a/src/services/api/apiClient.ts b/src/services/api/apiClient.ts new file mode 100644 index 0000000..278f7dd --- /dev/null +++ b/src/services/api/apiClient.ts @@ -0,0 +1,78 @@ +import { fetch } from 'expo/fetch'; +import { env } from '@/config/env'; +import { + ApiClientInterface, + TokenProvider, + ApiClientOptions, + RequestOptions, +} from '../../types/apiClient.types'; +import { AppError } from './apiError'; + +class ApiClient implements ApiClientInterface { + private apiBaseUrl: string; + private readonly tokenProvider?: TokenProvider; + private timeoutMs: number; + private defaultHeaders: Record; + + constructor(options: ApiClientOptions) { + this.apiBaseUrl = options.baseUrl; + this.tokenProvider = options.tokenProvider; + this.timeoutMs = options.timeoutMs; + this.defaultHeaders = { 'Content-Type': 'application/json' }; + } + + private async request( + endpoint: string, + options: RequestInit, + ): Promise { + const url = `${this.apiBaseUrl}${endpoint}`; + const token = await this.tokenProvider?.(); + + const headers = new Headers(this.defaultHeaders); + new Headers(options.headers).forEach((value, key) => { + headers.set(key, value); + }); + + headers.set('Accept', 'application/json'); + + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + try { + const response = await fetch(url, { ...options, headers }); + } + } + + get(endpoint: string, options?: RequestOptions) { + return this.request(endpoint, { + ...options, + method: 'GET', + }); + } + + post(endpoint: string, body: B, options?: RequestOptions) { + return this.request(endpoint, { + ...options, + method: 'POST', + body: JSON.stringify(body), + }); + } + + put(endpoint: string, body: B, options?: RequestOptions) { + return this.request(endpoint, { + ...options, + method: 'PUT', + body: JSON.stringify(body), + }); + } + + delete(endpoint: string, options?: RequestOptions) { + return this.request(endpoint, { ...options, method: 'DELETE' }); + } +} + +export const apiClient = new ApiClient({ + baseUrl: env.apiBaseUrl, + timeoutMs: 10_000, // 10 second timeout +}); diff --git a/src/services/api/apiError.ts b/src/services/api/apiError.ts index 392b925..d588366 100644 --- a/src/services/api/apiError.ts +++ b/src/services/api/apiError.ts @@ -1,12 +1,12 @@ -export type AppErrorCode = - 'NETWORK_ERROR' | - 'REQUEST_TIMEOUT' | - 'REQUEST_CANCELLED' | - 'INVALID_RESPONSE' | - 'SESSION_EXPIRED' | - 'PERMISSION_DENIED' | - 'SERVER_ERROR' | - 'UNKNOWN'; +export type AppErrorCode = + | 'NETWORK_ERROR' + | 'REQUEST_TIMEOUT' + | 'REQUEST_CANCELLED' + | 'INVALID_RESPONSE' + | 'SESSION_EXPIRED' + | 'PERMISSION_DENIED' + | 'SERVER_ERROR' + | 'UNKNOWN'; export class AppError extends Error { constructor( @@ -17,4 +17,4 @@ export class AppError extends Error { super(message); this.name = 'AppError'; } -} \ No newline at end of file +} diff --git a/src/types/apiClient.types.ts b/src/types/apiClient.types.ts index fb64d05..85b3492 100644 --- a/src/types/apiClient.types.ts +++ b/src/types/apiClient.types.ts @@ -1,21 +1,29 @@ export type TokenProvider = () => Promise; -export interface ApiResponse { - success: boolean; - data: T; - error?: string; +export interface ApiClientOptions { + baseUrl: string; + tokenProvider?: TokenProvider; + timeoutMs: number; } export interface RequestOptions { - signal?: AbortSignal; - headers?: Record; + signal?: AbortSignal; + headers?: Record; } // Promise: Successful JSON -> T // Promise: Successful empty response -> Null export interface ApiClientInterface { - get(endpoint: string, options?: RequestOptions): Promise; - post(endpoint: string, body: B, options?: RequestOptions): Promise; - put(endpoint: string, body: B, options?: RequestOptions): Promise; - delete(endpoint: string, options?: RequestOptions): Promise; -}; \ No newline at end of file + get(endpoint: string, options?: RequestOptions): Promise; + post( + endpoint: string, + body: B, + options?: RequestOptions, + ): Promise; + put( + endpoint: string, + body: B, + options?: RequestOptions, + ): Promise; + delete(endpoint: string, options?: RequestOptions): Promise; +} From 09e64088c0780bdcd959e523347f7aab6b581e02 Mon Sep 17 00:00:00 2001 From: PlainOlSoapBar <86759315+PlainOlSoapBar@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:11:15 +0800 Subject: [PATCH 4/7] feat(api): Error handling for ApiClient --- src/services/api/apiClient.ts | 125 ++++++++++++++++++++++++++++++++-- src/services/api/apiError.ts | 2 +- 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/src/services/api/apiClient.ts b/src/services/api/apiClient.ts index 278f7dd..69911b4 100644 --- a/src/services/api/apiClient.ts +++ b/src/services/api/apiClient.ts @@ -8,6 +8,9 @@ import { } from '../../types/apiClient.types'; import { AppError } from './apiError'; +// 10 second timeout +export const REQUEST_TIMEOUT_MS = 10_000; + class ApiClient implements ApiClientInterface { private apiBaseUrl: string; private readonly tokenProvider?: TokenProvider; @@ -21,12 +24,23 @@ class ApiClient implements ApiClientInterface { this.defaultHeaders = { 'Content-Type': 'application/json' }; } + // Helper function to catch errors when fetching token + private async getToken(): Promise { + try { + return (await this.tokenProvider?.()) ?? null; + } catch { + throw new AppError('UNKNOWN', 'The request cannot be completed.'); + } + } + + // Request url assumes endpoint has a '/' + // e.g. ('/puzzles') private async request( endpoint: string, options: RequestInit, ): Promise { const url = `${this.apiBaseUrl}${endpoint}`; - const token = await this.tokenProvider?.(); + const token = await this.getToken(); const headers = new Headers(this.defaultHeaders); new Headers(options.headers).forEach((value, key) => { @@ -39,8 +53,100 @@ class ApiClient implements ApiClientInterface { headers.set('Authorization', `Bearer ${token}`); } + const controller = new AbortController(); + let abortReason: 'timeout' | 'cancel' | null = null; + + const abortRequest = (reason: 'timeout' | 'cancel') => { + if (!controller.signal.aborted) { + abortReason = reason; + controller.abort(); + } + }; + + // Create this abort as a const so it can be removed as an event listener + const handleCancellation = () => { + abortRequest('cancel'); + }; + + if (options.signal?.aborted) { + handleCancellation(); + } else { + options.signal?.addEventListener('abort', handleCancellation, { + once: true, + }); + } + + const timeoutId = setTimeout(() => { + abortRequest('timeout'); + }, this.timeoutMs); + try { - const response = await fetch(url, { ...options, headers }); + const response = await fetch(url, { + ...options, + headers, + signal: controller.signal, + }); + + // Response failed + if (!response.ok) { + switch (response.status) { + case 401: + throw new AppError( + 'SESSION_EXPIRED', + 'Your session has expired.', + response.status, + ); + case 403: + throw new AppError( + 'PERMISSION_DENIED', + 'You do not have permission to perform this action.', + response.status, + ); + default: + throw new AppError( + 'SERVER_ERROR', + 'The server could not complete the request.', + response.status, + ); + } + } + + const responseText = await response.text(); + if (responseText.trim() === '') { + return null; + } + + try { + return JSON.parse(responseText) as T; + } catch { + throw new AppError( + 'INVALID_RESPONSE', + 'The server returned an invalid response.', + response.status, + ); + } + } catch (error) { + // Errors that aren't defined in AppErrors will throw NETWORK_ERROR instead + if (error instanceof AppError) { + throw error; + } + + // Aborting fetch does not generate AppErrors, so we need to manually throw new AppErrors + if (abortReason === 'timeout') { + throw new AppError( + 'REQUEST_TIMEOUT', + 'The request took too long to complete.', + ); + } + if (abortReason === 'cancel') { + throw new AppError('REQUEST_CANCEL', 'The request was cancelled.'); + } + + // Fallback error throw since there is no HTTP status for network connection + throw new AppError('NETWORK_ERROR', 'Unable to connect to the server.'); + } finally { + clearTimeout(timeoutId); + options.signal?.removeEventListener('abort', handleCancellation); } } @@ -51,7 +157,7 @@ class ApiClient implements ApiClientInterface { }); } - post(endpoint: string, body: B, options?: RequestOptions) { + post(endpoint: string, body: B, options?: RequestOptions) { return this.request(endpoint, { ...options, method: 'POST', @@ -59,7 +165,7 @@ class ApiClient implements ApiClientInterface { }); } - put(endpoint: string, body: B, options?: RequestOptions) { + put(endpoint: string, body: B, options?: RequestOptions) { return this.request(endpoint, { ...options, method: 'PUT', @@ -72,7 +178,12 @@ class ApiClient implements ApiClientInterface { } } -export const apiClient = new ApiClient({ +// A factory to expose constructor +export function createApiClient(options: ApiClientOptions): ApiClientInterface { + return new ApiClient(options); +} + +export const apiClient = createApiClient({ baseUrl: env.apiBaseUrl, - timeoutMs: 10_000, // 10 second timeout -}); + timeoutMs: REQUEST_TIMEOUT_MS, +}); \ No newline at end of file diff --git a/src/services/api/apiError.ts b/src/services/api/apiError.ts index d588366..65e6fae 100644 --- a/src/services/api/apiError.ts +++ b/src/services/api/apiError.ts @@ -1,7 +1,7 @@ export type AppErrorCode = | 'NETWORK_ERROR' | 'REQUEST_TIMEOUT' - | 'REQUEST_CANCELLED' + | 'REQUEST_CANCEL' | 'INVALID_RESPONSE' | 'SESSION_EXPIRED' | 'PERMISSION_DENIED' From 5765c5d146fd74b91a9d720af8b9866adef4cda4 Mon Sep 17 00:00:00 2001 From: PlainOlSoapBar <86759315+PlainOlSoapBar@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:06:02 +0800 Subject: [PATCH 5/7] feat(api): Unit tests for ApiClient apiClient.ts has a single change of a new line. Whatever makes the Prettier/formatter checks happy I guess. --- src/services/api/apiClient.ts | 2 +- test/apiClient.test.ts | 234 ++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 test/apiClient.test.ts diff --git a/src/services/api/apiClient.ts b/src/services/api/apiClient.ts index 69911b4..f4e3406 100644 --- a/src/services/api/apiClient.ts +++ b/src/services/api/apiClient.ts @@ -186,4 +186,4 @@ export function createApiClient(options: ApiClientOptions): ApiClientInterface { export const apiClient = createApiClient({ baseUrl: env.apiBaseUrl, timeoutMs: REQUEST_TIMEOUT_MS, -}); \ No newline at end of file +}); diff --git a/test/apiClient.test.ts b/test/apiClient.test.ts new file mode 100644 index 0000000..b7c13e9 --- /dev/null +++ b/test/apiClient.test.ts @@ -0,0 +1,234 @@ +import { fetch } from 'expo/fetch'; +import { createApiClient } from '@/services/api/apiClient'; + +jest.mock('expo/fetch', () => ({ + fetch: jest.fn(), +})); + +const mockedFetch = jest.mocked(fetch); + +// Helper function to simulate pending fetch +function mockPendingFetchUntilAbort() { + mockedFetch.mockImplementation( + (_input, options) => + new Promise>>((_resolve, reject) => { + const signal = options?.signal; + + const rejectWhenAborted = () => { + reject(new Error('Request aborted')); + }; + + if (signal?.aborted) { + rejectWhenAborted(); + } else { + signal?.addEventListener('abort', rejectWhenAborted, { + once: true, + }); + } + }), + ); +} + +// Generic API client, some tests create and use their own API client +const client = createApiClient({ + baseUrl: 'https://api.example.com', + timeoutMs: 10_000, +}); + +describe('ApiClient', () => { + beforeEach(() => { + mockedFetch.mockReset(); + }); + + it('returns JSON from a successful GET request', async () => { + const responseData = { + id: 'get-item', + title: 'Fetching item', + }; + + mockedFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(JSON.stringify(responseData)), + } as unknown as Awaited>); + + const result = await client.get<{ + id: string; + title: string; + }>('/item/item-123'); + + expect(result).toEqual(responseData); + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledWith( + 'https://api.example.com/item/item-123', + expect.objectContaining({ + method: 'GET', + }), + ); + }); + + it('sends and returns JSON from a successful POST request', async () => { + const requestBody = { + name: 'Test item', + }; + + const responseData = { + id: 'item-123', + name: 'Example item', + }; + + mockedFetch.mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(JSON.stringify(responseData)), + } as unknown as Awaited>); + + const result = await client.post< + { id: string; name: string }, + { name: string } + >('/items', requestBody); + + expect(result).toEqual(responseData); + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledWith( + 'https://api.example.com/items', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(requestBody), + }), + ); + + const requestOptions = mockedFetch.mock.calls[0][1]; + const headers = new Headers(requestOptions?.headers); + + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('Accept')).toBe('application/json'); + }); + + it('returns null for empty successful response', async () => { + mockedFetch.mockResolvedValue({ + ok: true, + status: 204, + text: jest.fn().mockResolvedValue(' '), + } as unknown as Awaited>); + + await expect(client.delete('/items/item-123')).resolves.toBeNull(); + }); + + it('adds login token to Authorization header', async () => { + const tokenProvider = jest.fn().mockResolvedValue('test-login-token'); + + mockedFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue( + JSON.stringify({ + id: 'protected-item', + }), + ), + } as unknown as Awaited>); + + const authenticatedClient = createApiClient({ + baseUrl: 'https://api.example.com', + timeoutMs: 10_000, + tokenProvider, + }); + + await authenticatedClient.get<{ id: string }>('/protected'); + + expect(tokenProvider).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); + + const requestOptions = mockedFetch.mock.calls[0][1]; + const headers = new Headers(requestOptions?.headers); + + expect(headers.get('Authorization')).toBe('Bearer test-login-token'); + }); + + it('throws backend error', async () => { + mockedFetch.mockResolvedValue({ + ok: false, + status: 500, + text: jest.fn(), + } as unknown as Awaited>); + + await expect(client.get('/items')).rejects.toMatchObject({ + name: 'AppError', + code: 'SERVER_ERROR', + status: 500, + message: 'The server could not complete the request.', + }); + }); + + it('throws network error', async () => { + mockedFetch.mockRejectedValue(new TypeError('Network request failed')); + + await expect(client.get('/items')).rejects.toMatchObject({ + name: 'AppError', + code: 'NETWORK_ERROR', + message: 'Unable to connect to the server.', + }); + }); + + it('throws timeout error', async () => { + mockPendingFetchUntilAbort(); + + const timeoutClient = createApiClient({ + baseUrl: 'https://api.example.com', + timeoutMs: 1, + }); + + await expect(timeoutClient.get('/items')).rejects.toMatchObject({ + name: 'AppError', + code: 'REQUEST_TIMEOUT', + message: 'The request took too long to complete.', + }); + }); + + it('throws invalid response error', async () => { + mockedFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue('not valid JSON'), + } as unknown as Awaited>); + + await expect(client.get('/items')).rejects.toMatchObject({ + name: 'AppError', + code: 'INVALID_RESPONSE', + status: 200, + message: 'The server returned an invalid response.', + }); + }); + + it('throws session-expired error', async () => { + mockedFetch.mockResolvedValue({ + ok: false, + status: 401, + text: jest.fn(), + } as unknown as Awaited>); + + await expect(client.get('/protected')).rejects.toMatchObject({ + name: 'AppError', + code: 'SESSION_EXPIRED', + status: 401, + message: 'Your session has expired.', + }); + }); + + it('throws cancel error', async () => { + mockPendingFetchUntilAbort(); + + const controller = new AbortController(); + const requestExpectation = expect( + client.get('/items', { signal: controller.signal }), + ).rejects.toMatchObject({ + name: 'AppError', + code: 'REQUEST_CANCEL', + message: 'The request was cancelled.', + }); + + controller.abort(); + + await requestExpectation; + }); +}); From 63d0f33310647b7f16f617461af6873d4ca9215a Mon Sep 17 00:00:00 2001 From: PlainOlSoapBar <86759315+PlainOlSoapBar@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:21:29 +0800 Subject: [PATCH 6/7] feat(api): Documentation for ApiClient's types/interfaces and errors --- docs/api-client.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/api-client.md diff --git a/docs/api-client.md b/docs/api-client.md new file mode 100644 index 0000000..4f616d8 --- /dev/null +++ b/docs/api-client.md @@ -0,0 +1,61 @@ +# API Client + +Documentation of the shared API client's types, interfaces, and errors. + +Relevant files: +`src/services/api/apiClient.ts` +`src/types/apiClient.types.ts` +`src/services/api/apiError.ts` + +## Public Types & Interfaces + +| Type | Purpose | +| --- | --- | +| `TokenProvider` | An asynchronous function that returns the current login token, or `null` when no token is available. The client calls it for each request and does not store the token. | +| `ApiClientOptions` | Configuration used when creating an API client. | +| `RequestOptions` | Optional settings applied to one request. | +| `ApiClientInterface` | The shared GET, POST, PUT, and DELETE methods used by production code. | + +### `ApiClientOptions` + +| Field | Description | +| --- | --- | +| `baseUrl` | Backend base URL for endpoints. The app client receives this from the `.env` configuration. | +| `tokenProvider` | Optional `TokenProvider` field. When it returns a token, the client sends `Authorization: Bearer `. | +| `timeoutMs` | Maximum request duration in milliseconds before the client aborts with `REQUEST_TIMEOUT`. | + +### `RequestOptions` + +| Field | Description | +| --- | --- | +| `signal` | Optional caller-owned `AbortSignal`. Aborting it cancels the request with `REQUEST_CANCEL`. | +| `headers` | Additional request headers merged with the client's JSON headers. | + +### `ApiClientInterface` + +| Method | Description | +| --- | --- | +| `get` | Sends a GET request and resolves with parsed JSON of type `T`, or `null` for an empty successful response. | +| `post` | Serializes a body of type `B` as JSON and resolves with parsed JSON of type `T`, or `null`. | +| `put` | Serializes a body of type `B` as JSON and resolves with parsed JSON of type `T`, or `null`. | +| `delete` | Sends a DELETE request and resolves with parsed JSON of type `T`, or `null`. | + +Endpoints must begin with `/`. Failed requests reject with the normalized +`AppError` described below. The client does not automatically retry requests. + +## API Client Errors + +The shared API client rejects failed requests with an `AppError`. + +| Code | Trigger | +| --- | --- | +| `NETWORK_ERROR` | The request fails before an HTTP response is received, and it was not aborted by cancellation or a timeout. This commonly represents an unavailable network or unreachable backend. | +| `REQUEST_TIMEOUT` | The client aborts the request after its configured timeout elapses. | +| `REQUEST_CANCEL` | The caller aborts the `AbortSignal` supplied with the request. | +| `INVALID_RESPONSE` | A successful response has a non-empty body that is not valid JSON. | +| `SESSION_EXPIRED` | The backend responds with HTTP `401`. | +| `PERMISSION_DENIED` | The backend responds with HTTP `403`. | +| `SERVER_ERROR` | The backend responds with any other unsuccessful HTTP status. | +| `UNKNOWN` | The asynchronous token provider fails before the request can be sent. | + +`AppError.status` contains the HTTP status when the backend returned a response, EXCEPT for network, timeout, cancel, and token-provider errors. From f050199e67534298a2b7b7299ad14ab23f53e402 Mon Sep 17 00:00:00 2001 From: PlainOlSoapBar <86759315+PlainOlSoapBar@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:07:47 +0800 Subject: [PATCH 7/7] fix(doc): Fixed formatting of documentation page. --- docs/api-client.md | 62 +++++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/docs/api-client.md b/docs/api-client.md index 4f616d8..a4d6d7b 100644 --- a/docs/api-client.md +++ b/docs/api-client.md @@ -5,40 +5,40 @@ Documentation of the shared API client's types, interfaces, and errors. Relevant files: `src/services/api/apiClient.ts` `src/types/apiClient.types.ts` -`src/services/api/apiError.ts` +`src/services/api/apiError.ts` ## Public Types & Interfaces -| Type | Purpose | -| --- | --- | -| `TokenProvider` | An asynchronous function that returns the current login token, or `null` when no token is available. The client calls it for each request and does not store the token. | -| `ApiClientOptions` | Configuration used when creating an API client. | -| `RequestOptions` | Optional settings applied to one request. | -| `ApiClientInterface` | The shared GET, POST, PUT, and DELETE methods used by production code. | +| Type | Purpose | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TokenProvider` | An asynchronous function that returns the current login token, or `null` when no token is available. The client calls it for each request and does not store the token. | +| `ApiClientOptions` | Configuration used when creating an API client. | +| `RequestOptions` | Optional settings applied to one request. | +| `ApiClientInterface` | The shared GET, POST, PUT, and DELETE methods used by production code. | ### `ApiClientOptions` -| Field | Description | -| --- | --- | -| `baseUrl` | Backend base URL for endpoints. The app client receives this from the `.env` configuration. | +| Field | Description | +| --------------- | ---------------------------------------------------------------------------------------------------------- | +| `baseUrl` | Backend base URL for endpoints. The app client receives this from the `.env` configuration. | | `tokenProvider` | Optional `TokenProvider` field. When it returns a token, the client sends `Authorization: Bearer `. | -| `timeoutMs` | Maximum request duration in milliseconds before the client aborts with `REQUEST_TIMEOUT`. | +| `timeoutMs` | Maximum request duration in milliseconds before the client aborts with `REQUEST_TIMEOUT`. | ### `RequestOptions` -| Field | Description | -| --- | --- | -| `signal` | Optional caller-owned `AbortSignal`. Aborting it cancels the request with `REQUEST_CANCEL`. | -| `headers` | Additional request headers merged with the client's JSON headers. | +| Field | Description | +| --------- | ------------------------------------------------------------------------------------------- | +| `signal` | Optional caller-owned `AbortSignal`. Aborting it cancels the request with `REQUEST_CANCEL`. | +| `headers` | Additional request headers merged with the client's JSON headers. | ### `ApiClientInterface` -| Method | Description | -| --- | --- | -| `get` | Sends a GET request and resolves with parsed JSON of type `T`, or `null` for an empty successful response. | -| `post` | Serializes a body of type `B` as JSON and resolves with parsed JSON of type `T`, or `null`. | -| `put` | Serializes a body of type `B` as JSON and resolves with parsed JSON of type `T`, or `null`. | -| `delete` | Sends a DELETE request and resolves with parsed JSON of type `T`, or `null`. | +| Method | Description | +| ------------ | ---------------------------------------------------------------------------------------------------------- | +| `get` | Sends a GET request and resolves with parsed JSON of type `T`, or `null` for an empty successful response. | +| `post` | Serializes a body of type `B` as JSON and resolves with parsed JSON of type `T`, or `null`. | +| `put` | Serializes a body of type `B` as JSON and resolves with parsed JSON of type `T`, or `null`. | +| `delete` | Sends a DELETE request and resolves with parsed JSON of type `T`, or `null`. | Endpoints must begin with `/`. Failed requests reject with the normalized `AppError` described below. The client does not automatically retry requests. @@ -47,15 +47,15 @@ Endpoints must begin with `/`. Failed requests reject with the normalized The shared API client rejects failed requests with an `AppError`. -| Code | Trigger | -| --- | --- | -| `NETWORK_ERROR` | The request fails before an HTTP response is received, and it was not aborted by cancellation or a timeout. This commonly represents an unavailable network or unreachable backend. | -| `REQUEST_TIMEOUT` | The client aborts the request after its configured timeout elapses. | -| `REQUEST_CANCEL` | The caller aborts the `AbortSignal` supplied with the request. | -| `INVALID_RESPONSE` | A successful response has a non-empty body that is not valid JSON. | -| `SESSION_EXPIRED` | The backend responds with HTTP `401`. | -| `PERMISSION_DENIED` | The backend responds with HTTP `403`. | -| `SERVER_ERROR` | The backend responds with any other unsuccessful HTTP status. | -| `UNKNOWN` | The asynchronous token provider fails before the request can be sent. | +| Code | Trigger | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NETWORK_ERROR` | The request fails before an HTTP response is received, and it was not aborted by cancellation or a timeout. This commonly represents an unavailable network or unreachable backend. | +| `REQUEST_TIMEOUT` | The client aborts the request after its configured timeout elapses. | +| `REQUEST_CANCEL` | The caller aborts the `AbortSignal` supplied with the request. | +| `INVALID_RESPONSE` | A successful response has a non-empty body that is not valid JSON. | +| `SESSION_EXPIRED` | The backend responds with HTTP `401`. | +| `PERMISSION_DENIED` | The backend responds with HTTP `403`. | +| `SERVER_ERROR` | The backend responds with any other unsuccessful HTTP status. | +| `UNKNOWN` | The asynchronous token provider fails before the request can be sent. | `AppError.status` contains the HTTP status when the backend returned a response, EXCEPT for network, timeout, cancel, and token-provider errors.