97 lines
3.1 KiB
TypeScript
97 lines
3.1 KiB
TypeScript
import Ajv from 'ajv';
|
|
import addFormats from 'ajv-formats';
|
|
import type { ValidateFunction } from 'ajv';
|
|
|
|
const ajv = new Ajv({ allErrors: false, coerceTypes: false });
|
|
addFormats(ajv);
|
|
|
|
export const compileValidator = <T>(schema: object): ValidateFunction<T> =>
|
|
ajv.compile<T>(schema);
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly code: string;
|
|
|
|
constructor(status: number, code: string, message: string) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
export interface ApiClientConfig {
|
|
/** Base URL for the REST API, e.g. '/api' (same-origin) or 'http://host/api'. */
|
|
baseUrl: string;
|
|
/** Supplies the current access token for the Authorization header, if any. */
|
|
getAccessToken?: () => string | null;
|
|
/**
|
|
* Refresh-token transport. 'cookie' (default) uses the httpOnly refresh cookie
|
|
* (web). 'token' (native) sends `X-Auth-Mode: token`; the backend then returns
|
|
* the refresh token in the body and accepts it from the body — cookies are
|
|
* unreliable in React Native.
|
|
*/
|
|
authMode?: 'cookie' | 'token';
|
|
/** Current stored refresh token (token mode only). */
|
|
getRefreshToken?: () => string | null;
|
|
}
|
|
|
|
const toApiError = (status: number, json: unknown): ApiError => {
|
|
if (typeof json === 'object' && json !== null && 'error' in json) {
|
|
const code = typeof json.error === 'string' ? json.error : 'error';
|
|
const message =
|
|
'message' in json && typeof json.message === 'string' ? json.message : code;
|
|
return new ApiError(status, code, message);
|
|
}
|
|
return new ApiError(status, 'error', `Request failed with status ${String(status)}`);
|
|
};
|
|
|
|
// Perform a credentialed request (cookies included) and return the parsed body
|
|
// as `unknown`. Throws ApiError on non-2xx.
|
|
export const requestJson = async (
|
|
config: ApiClientConfig,
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<unknown> => {
|
|
const tokenMode = config.authMode === 'token';
|
|
const headers: Record<string, string> = { accept: 'application/json' };
|
|
if (body !== undefined) {
|
|
headers['content-type'] = 'application/json';
|
|
}
|
|
const token = config.getAccessToken?.() ?? null;
|
|
if (token !== null) {
|
|
headers['authorization'] = `Bearer ${token}`;
|
|
}
|
|
if (tokenMode) {
|
|
headers['x-auth-mode'] = 'token';
|
|
}
|
|
|
|
const response = await fetch(`${config.baseUrl}${path}`, {
|
|
method,
|
|
headers,
|
|
// Cookie mode relies on the refresh cookie; token mode carries the refresh
|
|
// token explicitly, so no ambient credentials are needed.
|
|
credentials: tokenMode ? 'omit' : 'include',
|
|
body: body === undefined ? null : JSON.stringify(body),
|
|
});
|
|
|
|
const text = await response.text();
|
|
let json: unknown;
|
|
if (text.length > 0) {
|
|
json = JSON.parse(text);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw toApiError(response.status, json);
|
|
}
|
|
return json;
|
|
};
|
|
|
|
// Validate `data` against a compiled validator, narrowing to T (no assertions).
|
|
export const parse = <T>(validator: ValidateFunction<T>, data: unknown): T => {
|
|
if (!validator(data)) {
|
|
throw new ApiError(500, 'invalid_response', 'Server response failed schema validation');
|
|
}
|
|
return data;
|
|
};
|