Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/api-client.md
Original file line number Diff line number Diff line change
@@ -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 <token>`. |
| `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<T>` | Sends a GET request and resolves with parsed JSON of type `T`, or `null` for an empty successful response. |
| `post<T, B>` | Serializes a body of type `B` as JSON and resolves with parsed JSON of type `T`, or `null`. |
| `put<T, B>` | Serializes a body of type `B` as JSON and resolves with parsed JSON of type `T`, or `null`. |
| `delete<T>` | 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.
189 changes: 189 additions & 0 deletions src/services/api/apiClient.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

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<string | null> {
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<T>(
endpoint: string,
options: RequestInit,
): Promise<T | null> {
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<T>(endpoint: string, options?: RequestOptions) {
return this.request<T>(endpoint, {
...options,
method: 'GET',
});
}

post<T, B = unknown>(endpoint: string, body: B, options?: RequestOptions) {
return this.request<T>(endpoint, {
...options,
method: 'POST',
body: JSON.stringify(body),
});
}

put<T, B = unknown>(endpoint: string, body: B, options?: RequestOptions) {
return this.request<T>(endpoint, {
...options,
method: 'PUT',
body: JSON.stringify(body),
});
}

delete<T>(endpoint: string, options?: RequestOptions) {
return this.request<T>(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,
});
20 changes: 20 additions & 0 deletions src/services/api/apiError.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
29 changes: 29 additions & 0 deletions src/types/apiClient.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export type TokenProvider = () => Promise<string | null>;

export interface ApiClientOptions {
baseUrl: string;
tokenProvider?: TokenProvider;
timeoutMs: number;
}

export interface RequestOptions {
signal?: AbortSignal;
headers?: Record<string, string>;
}

// Promise: Successful JSON -> T
// Promise: Successful empty response -> Null
export interface ApiClientInterface {
get<T>(endpoint: string, options?: RequestOptions): Promise<T | null>;
post<T, B = unknown>(
endpoint: string,
body: B,
options?: RequestOptions,
): Promise<T | null>;
put<T, B = unknown>(
endpoint: string,
body: B,
options?: RequestOptions,
): Promise<T | null>;
delete<T>(endpoint: string, options?: RequestOptions): Promise<T | null>;
}
Loading
Loading