test
This commit is contained in:
parent
efd21a7297
commit
441676be27
97 changed files with 10764 additions and 223 deletions
102
frontend/src/lib/api/client.ts
Normal file
102
frontend/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import {
|
||||
isErrorEnvelope,
|
||||
isSuccessEnvelope,
|
||||
readRequestId,
|
||||
type ApiSuccessEnvelope,
|
||||
} from "@/lib/api/envelope";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
method?: HttpMethod;
|
||||
body?: unknown | FormData;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
const UNSAFE_METHODS = new Set<HttpMethod>(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
const API_BASE = "/api/v1";
|
||||
|
||||
let csrfTokenProvider: (() => string | null) | null = null;
|
||||
|
||||
export function setCsrfTokenProvider(provider: () => string | null): void {
|
||||
csrfTokenProvider = provider;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<ApiSuccessEnvelope<T>> {
|
||||
const method = options.method ?? "GET";
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
const hasBody = options.body !== undefined;
|
||||
const isFormData = typeof FormData !== "undefined" && options.body instanceof FormData;
|
||||
let requestBody: BodyInit | undefined;
|
||||
|
||||
if (hasBody && !isFormData && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
if (hasBody) {
|
||||
requestBody = isFormData ? (options.body as FormData) : JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
if (UNSAFE_METHODS.has(method) && csrfTokenProvider) {
|
||||
const csrfToken = csrfTokenProvider();
|
||||
if (csrfToken) {
|
||||
headers.set("X-CSRF-Token", csrfToken);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
const raw = await parseResponseBody(response);
|
||||
const requestId = readRequestId(raw) ?? response.headers.get("X-Request-ID");
|
||||
|
||||
if (!response.ok) {
|
||||
if (isErrorEnvelope(raw)) {
|
||||
throw new ApiRequestError({
|
||||
status: response.status,
|
||||
code: raw.error.code || "error",
|
||||
message: raw.error.message || "Request failed.",
|
||||
details: raw.error.details,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
throw new ApiRequestError({
|
||||
status: response.status,
|
||||
code: "http_error",
|
||||
message: `Request failed with status ${response.status}.`,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isSuccessEnvelope<T>(raw)) {
|
||||
throw new ApiRequestError({
|
||||
status: response.status,
|
||||
code: "invalid_envelope",
|
||||
message: "Server returned an unexpected response format.",
|
||||
requestId,
|
||||
details: raw,
|
||||
});
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
10
frontend/src/lib/api/csrf.ts
Normal file
10
frontend/src/lib/api/csrf.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { apiRequest } from "@/lib/api/client";
|
||||
|
||||
export interface CsrfBootstrapData {
|
||||
csrf_token: string;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
export async function fetchCsrfBootstrap() {
|
||||
return apiRequest<CsrfBootstrapData>("/auth/csrf", { method: "GET" });
|
||||
}
|
||||
43
frontend/src/lib/api/envelope.test.ts
Normal file
43
frontend/src/lib/api/envelope.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isErrorEnvelope,
|
||||
isSuccessEnvelope,
|
||||
readRequestId,
|
||||
type ApiSuccessEnvelope,
|
||||
} from "@/lib/api/envelope";
|
||||
|
||||
describe("api envelope helpers", () => {
|
||||
it("recognizes a success envelope", () => {
|
||||
const payload: ApiSuccessEnvelope<{ ok: boolean }> = {
|
||||
data: { ok: true },
|
||||
meta: { request_id: "req_123" },
|
||||
};
|
||||
|
||||
expect(isSuccessEnvelope(payload)).toBe(true);
|
||||
expect(isErrorEnvelope(payload)).toBe(false);
|
||||
expect(readRequestId(payload)).toBe("req_123");
|
||||
});
|
||||
|
||||
it("recognizes an error envelope", () => {
|
||||
const payload = {
|
||||
error: {
|
||||
code: "invalid",
|
||||
message: "Invalid request",
|
||||
details: null,
|
||||
},
|
||||
meta: { request_id: "req_456" },
|
||||
};
|
||||
|
||||
expect(isErrorEnvelope(payload)).toBe(true);
|
||||
expect(isSuccessEnvelope(payload)).toBe(false);
|
||||
expect(readRequestId(payload)).toBe("req_456");
|
||||
});
|
||||
|
||||
it("returns null when request id is missing or invalid", () => {
|
||||
expect(readRequestId(null)).toBeNull();
|
||||
expect(readRequestId({})).toBeNull();
|
||||
expect(readRequestId({ meta: {} })).toBeNull();
|
||||
expect(readRequestId({ meta: { request_id: "" } })).toBeNull();
|
||||
});
|
||||
});
|
||||
44
frontend/src/lib/api/envelope.ts
Normal file
44
frontend/src/lib/api/envelope.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { ApiErrorPayload } from "@/lib/api/errors";
|
||||
|
||||
export interface ApiMeta {
|
||||
request_id: string | null;
|
||||
}
|
||||
|
||||
export interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
meta: ApiMeta;
|
||||
}
|
||||
|
||||
export interface ApiErrorEnvelope {
|
||||
error: ApiErrorPayload;
|
||||
meta: ApiMeta;
|
||||
}
|
||||
|
||||
export function isSuccessEnvelope<T>(value: unknown): value is ApiSuccessEnvelope<T> {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
return "data" in value && "meta" in value;
|
||||
}
|
||||
|
||||
export function isErrorEnvelope(value: unknown): value is ApiErrorEnvelope {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
return "error" in value && "meta" in value;
|
||||
}
|
||||
|
||||
export function readRequestId(payload: unknown): string | null {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return null;
|
||||
}
|
||||
const meta = (payload as { meta?: unknown }).meta;
|
||||
if (typeof meta !== "object" || meta === null) {
|
||||
return null;
|
||||
}
|
||||
const requestId = (meta as { request_id?: unknown }).request_id;
|
||||
if (typeof requestId !== "string" || !requestId.trim()) {
|
||||
return null;
|
||||
}
|
||||
return requestId;
|
||||
}
|
||||
22
frontend/src/lib/api/errors.test.ts
Normal file
22
frontend/src/lib/api/errors.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
describe("ApiRequestError", () => {
|
||||
it("preserves structured metadata", () => {
|
||||
const error = new ApiRequestError({
|
||||
status: 409,
|
||||
code: "run_in_progress",
|
||||
message: "A run is already in progress",
|
||||
details: { run_id: 42 },
|
||||
requestId: "req_789",
|
||||
});
|
||||
|
||||
expect(error.name).toBe("ApiRequestError");
|
||||
expect(error.status).toBe(409);
|
||||
expect(error.code).toBe("run_in_progress");
|
||||
expect(error.message).toBe("A run is already in progress");
|
||||
expect(error.details).toEqual({ run_id: 42 });
|
||||
expect(error.requestId).toBe("req_789");
|
||||
});
|
||||
});
|
||||
27
frontend/src/lib/api/errors.ts
Normal file
27
frontend/src/lib/api/errors.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export interface ApiErrorPayload {
|
||||
code: string;
|
||||
message: string;
|
||||
details: unknown;
|
||||
}
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly details: unknown;
|
||||
readonly requestId: string | null;
|
||||
|
||||
constructor(params: {
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
requestId?: string | null;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = "ApiRequestError";
|
||||
this.status = params.status;
|
||||
this.code = params.code;
|
||||
this.details = params.details ?? null;
|
||||
this.requestId = params.requestId ?? null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue