MAESTRO: add api client abstraction

This commit is contained in:
Mariusz Banach
2026-02-17 23:08:28 +01:00
parent 8875dd4b84
commit 17e96354df
3 changed files with 417 additions and 1 deletions

View File

@@ -0,0 +1,104 @@
import { describe, expect, it, vi } from "vitest";
import { ApiError, createApiClient } from "./api-client";
describe("api client", () => {
it("uses the env base URL when not provided", async () => {
const previous = process.env.NEXT_PUBLIC_API_BASE_URL;
process.env.NEXT_PUBLIC_API_BASE_URL = "http://example.test";
const fetcher = vi.fn(async () => {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
const client = createApiClient({ fetcher });
await client.get<{ ok: boolean }>("/api/health");
expect(fetcher).toHaveBeenCalledWith("http://example.test/api/health", expect.any(Object));
if (previous) {
process.env.NEXT_PUBLIC_API_BASE_URL = previous;
} else {
delete process.env.NEXT_PUBLIC_API_BASE_URL;
}
});
it("sends JSON requests with defaults", async () => {
const fetcher = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
expect(headers.get("accept")).toBe("application/json");
expect(headers.get("content-type")).toBe("application/json");
expect(init?.method).toBe("POST");
expect(init?.body).toBe(JSON.stringify({ hello: "world" }));
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
const client = createApiClient({ fetcher, baseUrl: "http://example.test" });
const response = await client.post<{ ok: boolean }, { hello: string }>("/api/tests", {
hello: "world",
});
expect(response.ok).toBe(true);
});
it("throws ApiError with parsed payload", async () => {
const fetcher = vi.fn(async () => {
return new Response(JSON.stringify({ error: "Too many requests", retryAfter: 5 }), {
status: 429,
headers: { "Content-Type": "application/json" },
});
});
const client = createApiClient({ fetcher, baseUrl: "http://example.test" });
try {
await client.get("/api/tests");
throw new Error("Expected ApiError");
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
const apiError = error as ApiError;
expect(apiError.status).toBe(429);
expect(apiError.message).toBe("Too many requests");
expect(apiError.payload?.retryAfter).toBe(5);
}
});
it("parses SSE streams into events", async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("event: progress\n"));
controller.enqueue(encoder.encode("data: {\"step\": 1}\n\n"));
controller.enqueue(encoder.encode("event: result\n"));
controller.enqueue(encoder.encode("data: {\"done\": true}\n\n"));
controller.close();
},
});
const fetcher = vi.fn(async () => {
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
});
const client = createApiClient({ fetcher, baseUrl: "http://example.test" });
const events: Array<{ event: string; data: unknown }> = [];
await client.stream("/api/analyse", {
body: { headers: "X-Test" },
onEvent: (event) => events.push({ event: event.event, data: event.data }),
});
expect(events).toEqual([
{ event: "progress", data: { step: 1 } },
{ event: "result", data: { done: true } },
]);
});
});

View File

@@ -0,0 +1,312 @@
const DEFAULT_BASE_URL = "http://localhost:8000";
export interface ApiErrorPayload {
error?: string;
detail?: string;
retryAfter?: number;
captchaChallenge?: {
challengeToken: string;
imageBase64: string;
};
}
export class ApiError extends Error {
status: number;
payload?: ApiErrorPayload;
rawBody?: string;
constructor(message: string, status: number, payload?: ApiErrorPayload, rawBody?: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.payload = payload;
this.rawBody = rawBody;
}
}
export interface SseEvent<T = unknown> {
event: string;
data: T;
raw: string;
}
export interface StreamRequestOptions<TBody, TEvent> {
body: TBody;
headers?: HeadersInit;
signal?: AbortSignal;
onEvent: (event: SseEvent<TEvent>) => void;
method?: "POST" | "PUT";
}
export interface ApiClient {
request<TResponse>(path: string, init?: RequestInit & { body?: unknown }): Promise<TResponse>;
get<TResponse>(path: string, init?: RequestInit): Promise<TResponse>;
post<TResponse, TBody>(path: string, body: TBody, init?: RequestInit): Promise<TResponse>;
stream<TBody, TEvent>(
path: string,
options: StreamRequestOptions<TBody, TEvent>,
): Promise<void>;
}
type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export interface ApiClientOptions {
baseUrl?: string;
fetcher?: Fetcher;
defaultHeaders?: HeadersInit;
}
const JSON_ACCEPT_HEADER: HeadersInit = { Accept: "application/json" };
const JSON_CONTENT_HEADER: HeadersInit = { "Content-Type": "application/json" };
const SSE_ACCEPT_HEADER: HeadersInit = { Accept: "text/event-stream" };
const mergeHeaders = (base?: HeadersInit, overrides?: HeadersInit): Headers => {
const merged = new Headers(base);
if (overrides) {
const next = new Headers(overrides);
next.forEach((value, key) => merged.set(key, value));
}
return merged;
};
const resolveUrl = (baseUrl: string, path: string): string => {
return new URL(path, baseUrl).toString();
};
const isBodyInit = (body: unknown): body is BodyInit | null => {
if (body === null) {
return true;
}
if (typeof body === "string") {
return true;
}
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
return true;
}
if (typeof Blob !== "undefined" && body instanceof Blob) {
return true;
}
if (typeof FormData !== "undefined" && body instanceof FormData) {
return true;
}
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
return true;
}
if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
return true;
}
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body)) {
return true;
}
return false;
};
const parseErrorResponse = async (
response: Response,
): Promise<{ message: string; payload?: ApiErrorPayload; rawBody?: string }> => {
const fallbackMessage = `Request failed with status ${response.status}`;
let payload: ApiErrorPayload | undefined;
let rawBody: string | undefined;
try {
const json = (await response.clone().json()) as ApiErrorPayload;
if (json && typeof json === "object") {
payload = json;
const message = json.error ?? json.detail;
return { message: message ?? fallbackMessage, payload };
}
} catch {
// Ignore JSON parsing errors and fall back to text.
}
try {
rawBody = await response.text();
} catch {
rawBody = undefined;
}
return {
message: rawBody && rawBody.trim().length > 0 ? rawBody : fallbackMessage,
payload,
rawBody,
};
};
const parseResponse = async <TResponse>(response: Response): Promise<TResponse> => {
if (response.status === 204) {
return undefined as TResponse;
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return (await response.json()) as TResponse;
}
return (await response.text()) as unknown as TResponse;
};
const parseSseData = <T>(raw: string): T => {
if (raw.trim().length === 0) {
return "" as T;
}
try {
return JSON.parse(raw) as T;
} catch {
return raw as unknown as T;
}
};
const parseSseBlock = <T>(block: string): SseEvent<T> | null => {
const lines = block.split("\n");
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (!line || line.startsWith(":")) {
continue;
}
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim() || "message";
continue;
}
if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).replace(/^\s/, ""));
}
}
if (dataLines.length === 0) {
return null;
}
const raw = dataLines.join("\n");
return {
event,
data: parseSseData<T>(raw),
raw,
};
};
export const readSseStream = async <TEvent>(
stream: ReadableStream<Uint8Array>,
onEvent: (event: SseEvent<TEvent>) => void,
signal?: AbortSignal,
): Promise<void> => {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
if (signal?.aborted) {
await reader.cancel();
throw new DOMException("The request was aborted", "AbortError");
}
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const normalized = buffer.replace(/\r\n/g, "\n");
const parts = normalized.split("\n\n");
buffer = parts.pop() ?? "";
for (const part of parts) {
const event = parseSseBlock<TEvent>(part.trim());
if (event) {
onEvent(event);
}
}
}
} finally {
reader.releaseLock();
}
if (buffer.trim().length > 0) {
const event = parseSseBlock<TEvent>(buffer.replace(/\r\n/g, "\n").trim());
if (event) {
onEvent(event);
}
}
};
export const createApiClient = (options: ApiClientOptions = {}): ApiClient => {
const baseUrl = options.baseUrl ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? DEFAULT_BASE_URL;
const fetcher = options.fetcher ?? fetch;
const defaultHeaders = options.defaultHeaders;
const request = async <TResponse>(
path: string,
init: RequestInit & { body?: unknown } = {},
): Promise<TResponse> => {
const url = resolveUrl(baseUrl, path);
const { body, headers, ...rest } = init;
const hasJsonBody = body !== undefined && !isBodyInit(body);
const baseHeaders = mergeHeaders(JSON_ACCEPT_HEADER, defaultHeaders);
const withContent = hasJsonBody ? mergeHeaders(baseHeaders, JSON_CONTENT_HEADER) : baseHeaders;
const finalHeaders = mergeHeaders(withContent, headers);
const response = await fetcher(url, {
...rest,
headers: finalHeaders,
body:
body === undefined
? undefined
: hasJsonBody
? JSON.stringify(body)
: (body as BodyInit | null),
});
if (!response.ok) {
const { message, payload, rawBody } = await parseErrorResponse(response);
throw new ApiError(message, response.status, payload, rawBody);
}
return parseResponse<TResponse>(response);
};
const get = async <TResponse>(path: string, init: RequestInit = {}): Promise<TResponse> => {
return request<TResponse>(path, { ...init, method: "GET" });
};
const post = async <TResponse, TBody>(
path: string,
body: TBody,
init: RequestInit = {},
): Promise<TResponse> => {
return request<TResponse>(path, { ...init, method: "POST", body });
};
const stream = async <TBody, TEvent>(
path: string,
options: StreamRequestOptions<TBody, TEvent>,
): Promise<void> => {
const { body, headers, signal, onEvent, method } = options;
const url = resolveUrl(baseUrl, path);
const baseHeaders = mergeHeaders(SSE_ACCEPT_HEADER, defaultHeaders);
const withContent = mergeHeaders(baseHeaders, JSON_CONTENT_HEADER);
const finalHeaders = mergeHeaders(withContent, headers);
const response = await fetcher(url, {
method: method ?? "POST",
headers: finalHeaders,
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
const { message, payload, rawBody } = await parseErrorResponse(response);
throw new ApiError(message, response.status, payload, rawBody);
}
if (!response.body) {
throw new ApiError("Response body missing for SSE stream", response.status);
}
await readSseStream<TEvent>(response.body, onEvent, signal);
};
return {
request,
get,
post,
stream,
};
};
export const apiClient = createApiClient();