diff --git a/docs/api-client.md b/docs/api-client.md new file mode 100644 index 0000000..a4d6d7b --- /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. diff --git a/src/services/api/apiClient.ts b/src/services/api/apiClient.ts new file mode 100644 index 0000000..f4e3406 --- /dev/null +++ b/src/services/api/apiClient.ts @@ -0,0 +1,189 @@ +import { fetch } from 'expo/fetch'; +import { env } from '@/config/env'; +import { + ApiClientInterface, + TokenProvider, + ApiClientOptions, + RequestOptions, +} 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; + 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' }; + } + + // 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.getToken(); + + 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}`); + } + + 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, + 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); + } + } + + 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' }); + } +} + +// A factory to expose constructor +export function createApiClient(options: ApiClientOptions): ApiClientInterface { + return new ApiClient(options); +} + +export const apiClient = createApiClient({ + baseUrl: env.apiBaseUrl, + timeoutMs: REQUEST_TIMEOUT_MS, +}); diff --git a/src/services/api/apiError.ts b/src/services/api/apiError.ts new file mode 100644 index 0000000..65e6fae --- /dev/null +++ b/src/services/api/apiError.ts @@ -0,0 +1,20 @@ +export type AppErrorCode = + | 'NETWORK_ERROR' + | 'REQUEST_TIMEOUT' + | 'REQUEST_CANCEL' + | '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'; + } +} diff --git a/src/types/apiClient.types.ts b/src/types/apiClient.types.ts new file mode 100644 index 0000000..85b3492 --- /dev/null +++ b/src/types/apiClient.types.ts @@ -0,0 +1,29 @@ +export type TokenProvider = () => Promise; + +export interface ApiClientOptions { + baseUrl: string; + tokenProvider?: TokenProvider; + timeoutMs: number; +} + +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; +} 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; + }); +});