import { PaginationShape } from "./types/PaginationShape"; export type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; export interface BackendApiEndpoint< M extends Method, PP extends string[], T, R, PG extends boolean > { method: M; endpoint: string; pathParams: PP; request?: T; response?: R & (PG extends true ? any[] : any); paginated: PG; } function nestedUrlSearchParams(data: any, topLevel: boolean = true): string { switch (typeof data) { case "string": case "bigint": case "boolean": case "number": case "symbol": if (topLevel) return encodeURIComponent(data.toString()); return data.toString(); case "object": if (data === null) return "null"; if (Array.isArray(data)) return data .map((d) => nestedUrlSearchParams(d, true)) .map(encodeURIComponent) .join("&"); const inner = Object.entries(data).map(([k, v]) => [ k, nestedUrlSearchParams(v, false), ]); return new URLSearchParams(inner).toString(); default: return ""; } } export type MagApiErrorCode = "Client:GenericApiError" | string; export interface MagApiError { status: number; code: MagApiErrorCode; message: string; } export interface PaginatedResult { prev?: URL; data: T & any[]; next?: URL; } function extractHeaderRel( headers: Headers, rel: "prev" | "next" ): URL | undefined { for (const [k, v] of headers) { if (k.toLowerCase() !== "link") { continue; } for (const links of v.split(",").map((s) => s.trim())) { const [url, relPar] = links.split(";").map((s) => s.trim()); if (!url || !relPar) { continue; } const urlMatch = url.match(/^<(.+?)>$/)?.[1]; const relMatch = relPar.match(/rel="(.+?)"/)?.[1]; if (relMatch == rel && urlMatch) { try { return new URL(urlMatch); } catch (e) { console.error(e); return undefined; } } } } return undefined; } export class MagApiClient { private readonly baseUrl: string; constructor(baseUrl: string) { this.baseUrl = baseUrl; } async call< T extends BackendApiEndpoint< T["method"] & Method, T["pathParams"] & string[], T["request"], T["response"], T["paginated"] & boolean > >( { endpoint, method, paginated }: T, data: T["request"] & (T["paginated"] extends true ? Partial : any), pathParams: { [K in keyof T["pathParams"] as T["pathParams"][K] & string]: | string | number; }, token?: string | null | undefined ): Promise< T["paginated"] extends true ? PaginatedResult : T["response"] > { type Response = T["response"]; const authorizationToken = token ?? undefined; const authorization = authorizationToken ? `Bearer ${authorizationToken}` : undefined; for (const name in pathParams) { endpoint = endpoint.replace(`:${name}`, `${pathParams[name]}`); } const baseUrl = this.baseUrl.replace(/\/+$/, ""); let url = `${baseUrl}${endpoint}`; if (method === "GET") { const query = nestedUrlSearchParams(data as any); if (query) { url += `?${query}`; } } return (await fetch(url, { method, body: method !== "GET" ? JSON.stringify(data) : undefined, credentials: "omit", cache: "no-cache", headers: authorization ? { authorization } : {}, }) .then(async (res) => { const body = res.status === 204 ? null : await res.json(); if (res.status === 200) { if (paginated) { return { prev: extractHeaderRel(res.headers, "prev"), data: body as Response, next: extractHeaderRel(res.headers, "next"), }; } else { return body as Response; } } else if (res.status === 204) { if (paginated) { return { prev: extractHeaderRel(res.headers, "prev"), data: null as any as Response, next: extractHeaderRel(res.headers, "next"), }; } else { return null as any as Response; } } else { throw body as MagApiError; } }) .catch((e) => { console.error( "Generic API error. This should not usually happen!", e ); throw { status: -1, code: "Client:GenericApiError", message: e, } as MagApiError; })) as T["paginated"] extends true ? PaginatedResult : Response; } }