Messenger/packages/core/src/api/http.ts
Заид Омар Медхат | Zaid Omar Medhat f653b083aa f
2026-07-13 12:32:00 +05:00

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;
};