feat(spa): typed api client wrapping /wp/api/*
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user