deadhead-space-freight-tycoon/apps/web/src/api/client.ts

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