feat(spa): typed api client wrapping /wp/api/*

This commit is contained in:
DumiFlex
2026-04-26 19:43:33 +03:00
parent d80fd24254
commit 7a7b632f06
3 changed files with 380 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api, ApiError } from "../api/client";
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockReset();
});
afterEach(() => vi.unstubAllGlobals());
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("api.modules", () => {
it("list passes type + q query params", async () => {
fetchMock.mockResolvedValue(jsonResponse({ items: [], total: 0 }));
await api.modules.list({ type: "wildcard", q: "foo" });
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("/wp/api/modules?type=wildcard&q=foo");
expect(init).toMatchObject({ method: "GET" });
});
it("list omits empty params", async () => {
fetchMock.mockResolvedValue(jsonResponse({ items: [], total: 0 }));
await api.modules.list({});
expect(fetchMock.mock.calls[0]![0]).toBe("/wp/api/modules");
});
it("list maps favorites=true → '1'", async () => {
fetchMock.mockResolvedValue(jsonResponse({ items: [], total: 0 }));
await api.modules.list({ favorites: true });
expect(fetchMock.mock.calls[0]![0]).toBe("/wp/api/modules?favorites=1");
});
it("create posts JSON body", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ id: "wc_x_1234abcd", type: "wildcard", name: "x" }, 201),
);
const row = await api.modules.create({ type: "wildcard", name: "x", payload: {} });
expect(row.id).toBe("wc_x_1234abcd");
const init = fetchMock.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ type: "wildcard", name: "x", payload: {} });
});
it("404 throws ApiError with status + message", async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: "module not found" }, 404));
await expect(api.modules.get("ghost")).rejects.toMatchObject({
status: 404,
message: expect.stringContaining("module not found"),
});
});
it("204 returns undefined", async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
const result = await api.modules.delete("wc_a");
expect(result).toBeUndefined();
});
it("snapshot calls POST", async () => {
fetchMock.mockResolvedValue(jsonResponse({ library_id: "x" }));
await api.modules.snapshot("wc_a_111");
expect(fetchMock).toHaveBeenCalledWith(
"/wp/api/modules/wc_a_111/snapshot",
expect.objectContaining({ method: "POST" }),
);
});
it("duplicate calls POST", async () => {
fetchMock.mockResolvedValue(jsonResponse({ id: "wc_a_111_copy" }, 201));
await api.modules.duplicate("wc_a_111");
expect(fetchMock).toHaveBeenCalledWith(
"/wp/api/modules/wc_a_111/duplicate",
expect.objectContaining({ method: "POST" }),
);
});
it("favorite calls POST", async () => {
fetchMock.mockResolvedValue(jsonResponse({ id: "wc_a_111", is_favorite: true }));
await api.modules.favorite("wc_a_111");
expect(fetchMock).toHaveBeenCalledWith(
"/wp/api/modules/wc_a_111/favorite",
expect.objectContaining({ method: "POST" }),
);
});
it("match posts body", async () => {
fetchMock.mockResolvedValue(jsonResponse({ matched: false }));
await api.modules.match({ type: "wildcard", name: "x", payload_hash: "abc" });
const init = fetchMock.mock.calls[0]![1] as RequestInit;
expect(JSON.parse(init.body as string)).toEqual({
type: "wildcard", name: "x", payload_hash: "abc",
});
});
});
describe("api.categories", () => {
it("create posts body", async () => {
fetchMock.mockResolvedValue(jsonResponse({ id: "style", name: "Style" }, 201));
await api.categories.create({ name: "Style", color: "#a970ff" });
const init = fetchMock.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string).name).toBe("Style");
});
it("409 throws ApiError", async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: "category name not unique: 'Style'" }, 409));
await expect(api.categories.create({ name: "Style" })).rejects.toMatchObject({
status: 409,
});
});
});
describe("api.test + api.exportBundle + api.importBundle", () => {
it("test endpoint forwards samples", async () => {
fetchMock.mockResolvedValue(jsonResponse({ results: [], histogram: {} }));
await api.test({ type: "wildcard", payload: {}, instance: {}, samples: 3 });
const init = fetchMock.mock.calls[0]![1] as RequestInit;
expect(JSON.parse(init.body as string).samples).toBe(3);
});
it("exportBundle returns JSON", async () => {
fetchMock.mockResolvedValue(jsonResponse({ version: 1, modules: [], categories: [] }));
const bundle = await api.exportBundle();
expect(bundle.version).toBe(1);
});
it("importBundle posts body", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ modules_imported: 1, categories_imported: 0, skipped: [] }),
);
await api.importBundle({ version: 1, modules: [], categories: [] });
const init = fetchMock.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("POST");
});
});
describe("ApiError", () => {
it("captures status code", () => {
const e = new ApiError(500, "boom");
expect(e.status).toBe(500);
expect(e.message).toBe("boom");
expect(e.name).toBe("ApiError");
});
});
+126
View File
@@ -0,0 +1,126 @@
import type {
CategoryCreateInput, CategoryRow,
ImportBundle, ImportResult,
MatchRequest, MatchResponse,
ModuleCreateInput, ModuleListResponse, ModuleRow, ModuleUpdateInput,
SnapshotShape, TestRequest, TestResponse,
} from "./types";
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
...(init.headers as Record<string, string> | undefined ?? {}),
};
if (init.body !== undefined) headers["content-type"] = "application/json";
const resp = await fetch(path, { ...init, headers });
if (resp.status === 204) return undefined as T;
let body: unknown;
try { body = await resp.json(); } catch { body = null; }
if (!resp.ok) {
const message = (body as { error?: string } | null)?.error
?? `request failed (${resp.status})`;
throw new ApiError(resp.status, message);
}
return body as T;
}
function qs(params: Record<string, string | number | boolean | undefined>): string {
const usp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") usp.append(k, String(v));
}
const s = usp.toString();
return s ? `?${s}` : "";
}
interface ListParams {
type?: string;
category?: string;
q?: string;
favorites?: boolean;
limit?: number;
offset?: number;
}
export const api = {
modules: {
list(params: ListParams = {}) {
const query = qs({
type: params.type,
category: params.category,
q: params.q,
favorites: params.favorites ? "1" : undefined,
limit: params.limit,
offset: params.offset,
});
return request<ModuleListResponse>(`/wp/api/modules${query}`, { method: "GET" });
},
get(id: string) {
return request<ModuleRow>(`/wp/api/modules/${id}`, { method: "GET" });
},
create(body: ModuleCreateInput) {
return request<ModuleRow>("/wp/api/modules", {
method: "POST", body: JSON.stringify(body),
});
},
update(id: string, body: ModuleUpdateInput) {
return request<ModuleRow>(`/wp/api/modules/${id}`, {
method: "PUT", body: JSON.stringify(body),
});
},
delete(id: string) {
return request<void>(`/wp/api/modules/${id}`, { method: "DELETE" });
},
snapshot(id: string) {
return request<SnapshotShape>(`/wp/api/modules/${id}/snapshot`, { method: "POST" });
},
duplicate(id: string) {
return request<ModuleRow>(`/wp/api/modules/${id}/duplicate`, { method: "POST" });
},
favorite(id: string) {
return request<ModuleRow>(`/wp/api/modules/${id}/favorite`, { method: "POST" });
},
match(body: MatchRequest) {
return request<MatchResponse>("/wp/api/modules/match", {
method: "POST", body: JSON.stringify(body),
});
},
},
categories: {
list() {
return request<{ items: CategoryRow[] }>("/wp/api/categories", { method: "GET" });
},
create(body: CategoryCreateInput) {
return request<CategoryRow>("/wp/api/categories", {
method: "POST", body: JSON.stringify(body),
});
},
update(id: string, body: Partial<CategoryCreateInput>) {
return request<CategoryRow>(`/wp/api/categories/${id}`, {
method: "PUT", body: JSON.stringify(body),
});
},
delete(id: string) {
return request<void>(`/wp/api/categories/${id}`, { method: "DELETE" });
},
},
test(body: TestRequest) {
return request<TestResponse>("/wp/api/test", {
method: "POST", body: JSON.stringify(body),
});
},
exportBundle() {
return request<ImportBundle>("/wp/api/export", { method: "GET" });
},
importBundle(bundle: ImportBundle) {
return request<ImportResult>("/wp/api/import", {
method: "POST", body: JSON.stringify(bundle),
});
},
};
+104
View File
@@ -0,0 +1,104 @@
export type ModuleType = "wildcard" | "fixed_values";
export interface ModuleRow {
id: string;
type: ModuleType;
name: string;
description: string;
category_id: string | null;
tags: string[];
is_favorite: boolean;
payload: Record<string, unknown>;
version: number;
created_at: string;
updated_at: string;
}
export interface ModuleListResponse {
items: ModuleRow[];
total: number;
}
export interface ModuleCreateInput {
type: ModuleType;
name: string;
description?: string;
category_id?: string | null;
tags?: string[];
payload: Record<string, unknown>;
is_favorite?: boolean;
}
export interface ModuleUpdateInput {
name?: string;
description?: string;
category_id?: string | null;
tags?: string[];
payload?: Record<string, unknown>;
is_favorite?: boolean;
}
export interface CategoryRow {
id: string;
name: string;
color: string | null;
icon: string | null;
sort_order: number;
}
export interface CategoryCreateInput {
name: string;
color?: string | null;
icon?: string | null;
sort_order?: number;
}
export interface SnapshotShape {
library_id: string;
library_snapshot_at: string;
library_version_at_snapshot: number;
type: ModuleType;
name: string;
category_id: string | null;
payload: Record<string, unknown>;
instance: {
variable_binding: string;
enabled_options: string[] | null;
category_filter: string | null;
};
}
export interface MatchRequest {
type: ModuleType;
name: string;
payload_hash: string;
}
export type MatchResponse =
| { matched: false }
| { matched: true; id: string; version: number };
export interface TestRequest {
type: ModuleType;
payload: Record<string, unknown>;
instance: Record<string, unknown>;
samples: number;
}
export interface TestResponse {
results: Record<string, string>[];
histogram: Record<string, number>;
}
export interface ImportBundle {
version: 1;
exported_at?: string;
modules: ModuleRow[];
categories: CategoryRow[];
}
export interface ImportResult {
modules_imported: number;
categories_imported: number;
skipped: string[];
}