mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 13:33:30 +01:00
MAESTRO: add api client abstraction
This commit is contained in:
@@ -65,7 +65,7 @@ The hacker-themed dark colour palette (from spec FR-14):
|
|||||||
- [x] T002 Initialise NextJS + TypeScript frontend project in `frontend/`. Configure `frontend/tsconfig.json` with strict mode. Install and configure Tailwind CSS in `frontend/tailwind.config.ts` with hacker-themed colour palette (#1e1e2e backgrounds, red spam, amber suspicious, green clean). Install FontAwesome packages. Configure eslint and prettier in `frontend/.eslintrc.json` and `frontend/.prettierrc`. Install Playwright (`npm init playwright@latest`) with `frontend/playwright.config.ts` and `@axe-core/playwright`. Configure `webServer` array for both uvicorn (port 8000) and NextJS (port 3000), `testDir: './e2e'`, `fullyParallel: true`. Exclude `e2e/` from vitest config
|
- [x] T002 Initialise NextJS + TypeScript frontend project in `frontend/`. Configure `frontend/tsconfig.json` with strict mode. Install and configure Tailwind CSS in `frontend/tailwind.config.ts` with hacker-themed colour palette (#1e1e2e backgrounds, red spam, amber suspicious, green clean). Install FontAwesome packages. Configure eslint and prettier in `frontend/.eslintrc.json` and `frontend/.prettierrc`. Install Playwright (`npm init playwright@latest`) with `frontend/playwright.config.ts` and `@axe-core/playwright`. Configure `webServer` array for both uvicorn (port 8000) and NextJS (port 3000), `testDir: './e2e'`, `fullyParallel: true`. Exclude `e2e/` from vitest config
|
||||||
- [x] T003 [P] Create `backend/app/core/config.py` with Pydantic BaseSettings for CORS origins, rate limit thresholds, analysis timeout (30s), and debug flag — all configurable via environment variables
|
- [x] T003 [P] Create `backend/app/core/config.py` with Pydantic BaseSettings for CORS origins, rate limit thresholds, analysis timeout (30s), and debug flag — all configurable via environment variables
|
||||||
- [x] T004 [P] Create `frontend/src/types/analysis.ts` with TypeScript interfaces: `HeaderInput`, `AnalysisConfig` (test IDs, resolve flag, decode-all flag), `TestResult` (id, name, header, value, analysis, description, severity, status), `AnalysisReport` (results, hopChain, securityAppliances, metadata), `AnalysisProgress` (current test, total, percentage, elapsed). Refer to `.specify/specs/1-web-header-analyzer/data-model.md` for the complete entity definitions
|
- [x] T004 [P] Create `frontend/src/types/analysis.ts` with TypeScript interfaces: `HeaderInput`, `AnalysisConfig` (test IDs, resolve flag, decode-all flag), `TestResult` (id, name, header, value, analysis, description, severity, status), `AnalysisReport` (results, hopChain, securityAppliances, metadata), `AnalysisProgress` (current test, total, percentage, elapsed). Refer to `.specify/specs/1-web-header-analyzer/data-model.md` for the complete entity definitions
|
||||||
- [ ] T005 [P] Create `frontend/src/lib/api-client.ts` — API client abstraction wrapping fetch with base URL from environment, JSON defaults, error handling, and typed response generics. Must support SSE streaming via ReadableStream for the analysis endpoint
|
- [x] T005 [P] Create `frontend/src/lib/api-client.ts` — API client abstraction wrapping fetch with base URL from environment, JSON defaults, error handling, and typed response generics. Must support SSE streaming via ReadableStream for the analysis endpoint
|
||||||
- [ ] T006 [P] Create `frontend/src/styles/design-tokens.ts` — centralised design tokens: colours (#1e1e2e background, #282a36 surface, #f8f8f2 text, #ff5555 spam, #ffb86c suspicious, #50fa7b clean, #bd93f9 accent), font families (mono/sans), spacing scale, and border-radius values
|
- [ ] T006 [P] Create `frontend/src/styles/design-tokens.ts` — centralised design tokens: colours (#1e1e2e background, #282a36 surface, #f8f8f2 text, #ff5555 spam, #ffb86c suspicious, #50fa7b clean, #bd93f9 accent), font families (mono/sans), spacing scale, and border-radius values
|
||||||
|
|
||||||
## Completion
|
## Completion
|
||||||
|
|||||||
104
frontend/src/lib/api-client.test.ts
Normal file
104
frontend/src/lib/api-client.test.ts
Normal 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 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
312
frontend/src/lib/api-client.ts
Normal file
312
frontend/src/lib/api-client.ts
Normal 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();
|
||||||
Reference in New Issue
Block a user