99 lines
3.0 KiB
TypeScript
99 lines
3.0 KiB
TypeScript
import {
|
|
errorResponseSchema,
|
|
routes,
|
|
type ErrorCode,
|
|
type RouteName,
|
|
type RouteRequest,
|
|
type RouteResponse,
|
|
} from "@deadhead/schema";
|
|
|
|
/** The server answered with its documented error format. */
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly code: ErrorCode,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
}
|
|
}
|
|
|
|
/** The server's response did not match the route catalog. */
|
|
export class ApiValidationError extends Error {
|
|
constructor(
|
|
readonly route: RouteName,
|
|
message: string,
|
|
) {
|
|
super(`Unexpected response from ${route}: ${message}`);
|
|
this.name = "ApiValidationError";
|
|
}
|
|
}
|
|
|
|
export interface ApiClientOptions {
|
|
/** Defaults to the global `fetch`. */
|
|
fetch?: typeof fetch;
|
|
/** Defaults to "" (same origin, decision 0.5.20). */
|
|
baseUrl?: string;
|
|
}
|
|
|
|
export interface ApiClient {
|
|
call<N extends RouteName>(name: N, input: RouteRequest<N>): Promise<RouteResponse<N>>;
|
|
}
|
|
|
|
/** Builds a request URL and body from a route and its input. */
|
|
function buildRequest(path: string, method: "GET" | "POST", input: Record<string, unknown>) {
|
|
const rest = { ...input };
|
|
const url = path.replace(/:(\w+)/g, (_match, param: string) => {
|
|
const value = rest[param];
|
|
delete rest[param];
|
|
return encodeURIComponent(String(value));
|
|
});
|
|
if (method === "GET") {
|
|
const query = new URLSearchParams(
|
|
Object.entries(rest).map(([key, value]) => [key, String(value)]),
|
|
).toString();
|
|
return { url: query ? `${url}?${query}` : url, body: undefined };
|
|
}
|
|
return { url, body: Object.keys(rest).length > 0 ? JSON.stringify(rest) : undefined };
|
|
}
|
|
|
|
/** A client for every catalog route: typed inputs and validated, typed outputs. */
|
|
export function createApiClient(options: ApiClientOptions = {}): ApiClient {
|
|
const doFetch = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
|
|
const baseUrl = options.baseUrl ?? "";
|
|
|
|
return {
|
|
async call(name, input) {
|
|
const route = routes[name];
|
|
const { url, body } = buildRequest(
|
|
route.path,
|
|
route.method,
|
|
input as Record<string, unknown>,
|
|
);
|
|
const response = await doFetch(baseUrl + url, {
|
|
method: route.method,
|
|
credentials: "include",
|
|
headers: body ? { "content-type": "application/json" } : {},
|
|
body,
|
|
});
|
|
|
|
const json: unknown = await response.json().catch(() => undefined);
|
|
if (!response.ok) {
|
|
const parsed = errorResponseSchema.safeParse(json);
|
|
if (parsed.success) {
|
|
throw new ApiError(response.status, parsed.data.error.code, parsed.data.error.message);
|
|
}
|
|
throw new ApiError(
|
|
response.status,
|
|
"internal-error",
|
|
`Request failed (${response.status}).`,
|
|
);
|
|
}
|
|
const parsed = route.response.safeParse(json);
|
|
if (!parsed.success) throw new ApiValidationError(name, parsed.error.message);
|
|
return parsed.data as RouteResponse<typeof name>;
|
|
},
|
|
};
|
|
}
|