feat(frontend): contract types + REST client (MSW-tested)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 12:13:50 +02:00
co-authored by Claude Sonnet 4.6
parent 7e42ac11ef
commit 913ac30137
10 changed files with 147 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
const BASE = import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8787";
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { "content-type": "application/json" },
...init,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => "")}`);
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
}
export { BASE };
+3
View File
@@ -0,0 +1,3 @@
import { apiFetch } from "./client";
export const listModels = () => apiFetch<{ models: unknown[] }>("/models");
+13
View File
@@ -0,0 +1,13 @@
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import { createSession, listSessions } from "./sessions";
test("createSession POSTs and returns the id", async () => {
server.use(http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })));
expect(await createSession({ workspace: "w", question: "q" })).toEqual({ id: "s1" });
});
test("listSessions GETs the array", async () => {
server.use(http.get("http://localhost:8787/sessions", () => HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }])));
const rows = await listSessions();
expect(rows[0].id).toBe("s1");
});
+34
View File
@@ -0,0 +1,34 @@
import { apiFetch } from "./client";
import type { SessionSummary, UiResponse } from "./types";
export const createSession = (i: {
workspace: string;
question: string;
provider?: string;
model?: string;
thinking?: string;
name?: string;
}) => apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) });
export const listSessions = () => apiFetch<SessionSummary[]>("/sessions");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getSession = (id: string) => apiFetch<any>(`/sessions/${id}`);
export const postResponse = (id: string, uiResponse: UiResponse) =>
apiFetch<void>(`/sessions/${id}/response`, {
method: "POST",
body: JSON.stringify({ ui_response: uiResponse }),
});
export const postSteer = (id: string, text: string) =>
apiFetch<void>(`/sessions/${id}/steer`, {
method: "POST",
body: JSON.stringify({ text }),
});
export const closeSession = (id: string) =>
apiFetch<void>(`/sessions/${id}/close`, { method: "POST" });
export const resumeSession = (id: string) =>
apiFetch<void>(`/sessions/${id}/resume`, { method: "POST" });
+14
View File
@@ -0,0 +1,14 @@
import { apiFetch } from "./client";
import type { PreviewResult } from "./types";
export const sqlPreview = (
id: string,
opts: { limit?: number; offset?: number } = {}
) =>
apiFetch<PreviewResult>(`/sessions/${id}/sql/preview`, {
method: "POST",
body: JSON.stringify(opts),
});
export const sqlExport = (id: string) =>
apiFetch<{ path: string }>(`/sessions/${id}/sql/export`, { method: "POST" });
+59
View File
@@ -0,0 +1,59 @@
export interface WidgetOption {
id: string;
label: string;
meta?: Record<string, unknown>;
selected?: boolean;
recommended?: boolean;
opens?: WidgetDescriptor;
}
export interface WidgetDescriptor {
id: string;
schema_version?: number;
session_id?: string;
phase?: string;
title?: string;
intro?: string;
widget: "info" | "select" | "multiselect" | "freetext" | "artifact-gate" | "artifact" | string;
options?: WidgetOption[];
reserved?: string[];
allow_empty?: boolean;
artifact?: { kind: string; content?: string; [k: string]: unknown };
level?: "info" | "warning" | "error";
text?: string;
[k: string]: unknown;
}
export interface UiResponse {
id: string;
kind?: string;
choices?: string[];
text?: string;
decision?: { type: string };
control?: string;
}
export type StreamEvent =
| { type: "ui_request"; ui_request: WidgetDescriptor }
| { type: "text_delta"; text: string }
| { type: "info"; level?: "info" | "warning" | "error"; text: string }
| { type: "system_event"; event: string; [k: string]: unknown };
export interface SessionSummary {
id: string;
status: string;
question: string;
summary: string | null;
created_at: string;
updated_at: string | null;
author: string | null;
}
export interface PreviewResult {
columns: string[];
rows: unknown[][];
execution_ms: number;
truncated: boolean;
limit: number;
offset: number;
}
+4
View File
@@ -0,0 +1,4 @@
import { apiFetch } from "./client";
export const listWorkspaces = () =>
apiFetch<{ name: string; file: string }[]>("/workspaces");
+2
View File
@@ -0,0 +1,2 @@
import { setupServer } from "msw/node";
export const server = setupServer();
+5
View File
@@ -1 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { server } from "./msw";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
+1 -1
View File
@@ -10,7 +10,7 @@
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["vitest/globals", "@testing-library/jest-dom"],
"types": ["vitest/globals", "@testing-library/jest-dom", "vite/client"],
"paths": {
"@/*": ["./src/*"]
}