All checks were successful
continuous-integration/drone/push Build is passing
135 lines
3.6 KiB
TypeScript
135 lines
3.6 KiB
TypeScript
export type RequestOptions = {
|
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
token?: string | null;
|
|
body?: unknown;
|
|
};
|
|
|
|
export type ResolveErrorInput = {
|
|
code?: string;
|
|
status?: number;
|
|
fallbackMessage?: string;
|
|
};
|
|
|
|
export type CreateApiClientConfig = {
|
|
baseUrl: string;
|
|
resolveError?: (input: ResolveErrorInput) => string;
|
|
inferErrorCodeFromStatus?: (status?: number | null) => string | undefined;
|
|
fetchImpl?: typeof fetch;
|
|
};
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
code?: string;
|
|
requestId?: string;
|
|
details?: unknown;
|
|
rawMessage?: string;
|
|
|
|
constructor({
|
|
message,
|
|
status,
|
|
code,
|
|
requestId,
|
|
details,
|
|
rawMessage,
|
|
}: {
|
|
message: string;
|
|
status: number;
|
|
code?: string;
|
|
requestId?: string;
|
|
details?: unknown;
|
|
rawMessage?: string;
|
|
}) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
this.code = code;
|
|
this.requestId = requestId;
|
|
this.details = details;
|
|
this.rawMessage = rawMessage;
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value != null;
|
|
}
|
|
|
|
function parseErrorPayload(data: unknown) {
|
|
if (!isRecord(data)) {
|
|
return {
|
|
code: undefined as string | undefined,
|
|
rawMessage: undefined as string | undefined,
|
|
requestId: undefined as string | undefined,
|
|
details: undefined as unknown,
|
|
};
|
|
}
|
|
|
|
const code = typeof data.code === 'string' ? data.code : undefined;
|
|
const rawMessage = typeof data.error === 'string' ? data.error : undefined;
|
|
const requestId = typeof data.requestId === 'string' ? data.requestId : undefined;
|
|
const details = data.details;
|
|
|
|
return { code, rawMessage, requestId, details };
|
|
}
|
|
|
|
function defaultResolveError({ status, fallbackMessage }: ResolveErrorInput): string {
|
|
if (fallbackMessage) {
|
|
return fallbackMessage;
|
|
}
|
|
|
|
if (status != null) {
|
|
return `Request failed (${status}).`;
|
|
}
|
|
|
|
return 'Request failed. Please try again.';
|
|
}
|
|
|
|
export function createApiClient(config: CreateApiClientConfig) {
|
|
const {
|
|
baseUrl,
|
|
resolveError = defaultResolveError,
|
|
inferErrorCodeFromStatus,
|
|
fetchImpl,
|
|
} = config;
|
|
|
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
const { method = 'GET', token, body } = options;
|
|
const runFetch = fetchImpl ?? fetch;
|
|
|
|
const response = await runFetch(`${baseUrl}${path}`, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
const data = await response.json().catch(() => null);
|
|
|
|
if (!response.ok) {
|
|
const parsed = parseErrorPayload(data);
|
|
const code = parsed.code ?? inferErrorCodeFromStatus?.(response.status);
|
|
const message = resolveError({
|
|
code,
|
|
status: response.status,
|
|
fallbackMessage: parsed.rawMessage,
|
|
});
|
|
|
|
throw new ApiError({
|
|
message,
|
|
status: response.status,
|
|
code,
|
|
requestId: parsed.requestId,
|
|
details: parsed.details,
|
|
rawMessage: parsed.rawMessage,
|
|
});
|
|
}
|
|
|
|
return data as T;
|
|
}
|
|
|
|
return {
|
|
request,
|
|
};
|
|
}
|