184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
/**
|
|
* Zesdex REST API — HTTP server and router (Bun.serve).
|
|
* Mirrors `apps/interfaces/api/src/lib.rs`.
|
|
*
|
|
* Routes:
|
|
* - Public: /api/v1/auth/{login,register,refresh}, /api/v1/health
|
|
* - Protected (JWT): /api/v1/sessions..., /api/v1/chat/completions
|
|
*
|
|
* Uses Bun's native HTTP server — no external framework dependency.
|
|
*/
|
|
import type { ApiState } from "./state.ts";
|
|
import { ApiError } from "./error.ts";
|
|
import { errorBody } from "./dto.ts";
|
|
import { authenticateRequest } from "./middleware/auth.ts";
|
|
import { loginHandler, registerHandler, refreshHandler } from "./handlers/auth.ts";
|
|
import {
|
|
listSessionsHandler,
|
|
createSessionHandler,
|
|
deleteSessionHandler,
|
|
} from "./handlers/sessions.ts";
|
|
import {
|
|
getConversationHandler,
|
|
addMessageHandler,
|
|
deleteMessageHandler,
|
|
} from "./handlers/conversations.ts";
|
|
import { chatCompletionsHandler } from "./handlers/chat.ts";
|
|
|
|
/** CORS headers applied to every response (permissive for local/dev). */
|
|
const CORS_HEADERS: Record<string, string> = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
};
|
|
|
|
/** A handler's response: JSON body plus optional status code. */
|
|
interface Result {
|
|
status: number;
|
|
body: unknown;
|
|
}
|
|
|
|
/** Per-request context passed to handlers. */
|
|
interface Ctx {
|
|
state: ApiState;
|
|
params: string[];
|
|
body: unknown;
|
|
headers: Headers;
|
|
}
|
|
|
|
/** Route mapping: regex matches URL path after `/api/v1`. */
|
|
interface Route {
|
|
method: string;
|
|
pattern: RegExp;
|
|
protected: boolean;
|
|
handle: (ctx: Ctx) => Promise<Result>;
|
|
}
|
|
|
|
function result(body: unknown, status = 200): Result {
|
|
return { status, body };
|
|
}
|
|
|
|
function json(res: Result): Response {
|
|
return new Response(JSON.stringify(res.body), {
|
|
status: res.status,
|
|
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
});
|
|
}
|
|
|
|
function noContent(): Response {
|
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
}
|
|
|
|
/** Build the route table for the API. Matches against paths without the /api/v1 prefix. */
|
|
function buildRoutes(): Route[] {
|
|
return [
|
|
// Auth — public (rate-limited inside handlers)
|
|
{ method: "POST", pattern: /^\/auth\/login$/, protected: false, handle: (c) => loginHandler(c.state, c.headers, c.body as never).then((r) => result(r)) },
|
|
{ method: "POST", pattern: /^\/auth\/register$/, protected: false, handle: (c) => registerHandler(c.state, c.headers, c.body as never).then((r) => result(r)) },
|
|
{ method: "POST", pattern: /^\/auth\/refresh$/, protected: false, handle: (c) => refreshHandler(c.state, c.headers, c.body as never).then((r) => result(r)) },
|
|
|
|
// Health — public
|
|
{ method: "GET", pattern: /^\/health$/, protected: false, handle: () => Promise.resolve(result({ status: "ok" })) },
|
|
|
|
// Sessions — protected
|
|
{ method: "GET", pattern: /^\/sessions$/, protected: true, handle: (c) => listSessionsHandler(c.state).then((s) => result(s)) },
|
|
{ method: "POST", pattern: /^\/sessions$/, protected: true, handle: async (c) => {
|
|
const r = await createSessionHandler(c.state, c.body as { title: string });
|
|
return result(r.body, r.status);
|
|
} },
|
|
{ method: "DELETE", pattern: /^\/sessions\/([^/]+)$/, protected: true, handle: async (c) => {
|
|
await deleteSessionHandler(c.state, c.params[0]!);
|
|
return { status: 204, body: null };
|
|
} },
|
|
|
|
// Conversations (sub-resource of sessions) — protected
|
|
{ method: "GET", pattern: /^\/sessions\/([^/]+)\/conversations$/, protected: true, handle: async (c) => {
|
|
const conv = await getConversationHandler(c.state, c.params[0]!);
|
|
return result(conv);
|
|
} },
|
|
{ method: "POST", pattern: /^\/sessions\/([^/]+)\/conversations$/, protected: true, handle: async (c) => {
|
|
const r = await addMessageHandler(c.state, c.params[0]!, c.body as { role: string; content: string });
|
|
return result(r.body, r.status);
|
|
} },
|
|
{ method: "DELETE", pattern: /^\/sessions\/([^/]+)\/conversations\/([^/]+)$/, protected: true, handle: async (c) => {
|
|
await deleteMessageHandler(c.state, c.params[0]!, c.params[1]!);
|
|
return { status: 204, body: null };
|
|
} },
|
|
|
|
// Chat — protected
|
|
{ method: "POST", pattern: /^\/chat\/completions$/, protected: true, handle: (c) => chatCompletionsHandler(c.state, c.body as never).then((s) => result(s)) },
|
|
];
|
|
}
|
|
|
|
/** Handle a request against the router; maps ApiError → HTTP response. */
|
|
async function handleRequest(state: ApiState, routes: Route[], req: Request): Promise<Response> {
|
|
const url = new URL(req.url);
|
|
// Strip the /api/v1 version prefix so route patterns match cleanly.
|
|
const fullPath = url.pathname;
|
|
const path = fullPath.startsWith("/api/v1") ? fullPath.slice("/api/v1".length) : fullPath;
|
|
const method = req.method;
|
|
|
|
// CORS preflight
|
|
if (method === "OPTIONS") {
|
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
}
|
|
|
|
const route = routes.find((r) => r.method === method && r.pattern.test(path));
|
|
if (!route) {
|
|
return json(result(errorBody("Not found", 404), 404));
|
|
}
|
|
|
|
// JWT auth for protected routes (subject is validated but not currently exposed).
|
|
if (route.protected) {
|
|
const auth = authenticateRequest(state, req.headers);
|
|
if (!auth.ok) {
|
|
return json(result({ error: "Unauthorized", detail: auth.detail }, auth.status));
|
|
}
|
|
}
|
|
|
|
// Parse JSON body if present; read once and reuse.
|
|
let body: unknown = undefined;
|
|
const raw = await req.text();
|
|
if (raw) {
|
|
try {
|
|
body = JSON.parse(raw);
|
|
} catch {
|
|
return json(result(errorBody("Bad request: malformed JSON body", 400), 400));
|
|
}
|
|
}
|
|
|
|
const match = path.match(route.pattern);
|
|
const ctx: Ctx = {
|
|
state,
|
|
params: match ? match.slice(1) : [],
|
|
body,
|
|
headers: req.headers,
|
|
};
|
|
|
|
try {
|
|
const res = await route.handle(ctx);
|
|
if (res.status === 204) return noContent();
|
|
return json(res);
|
|
} catch (e) {
|
|
if (e instanceof ApiError) {
|
|
const msg = e.exposeDetails ? e.message : "An internal error occurred";
|
|
return json(result(errorBody(msg, e.status), e.status));
|
|
}
|
|
console.error("Unhandled error:", e);
|
|
return json(result(errorBody("An internal error occurred", 500)));
|
|
}
|
|
}
|
|
|
|
/** Start the API server on the given port. Returns a handle. */
|
|
export function startApiServer(state: ApiState, port: number): { stop: () => void; port: number } {
|
|
const routes = buildRoutes();
|
|
const server = Bun.serve({
|
|
port,
|
|
async fetch(req) {
|
|
return handleRequest(state, routes, req);
|
|
},
|
|
});
|
|
console.log(`zesdex-api listening on http://0.0.0.0:${server.port}`);
|
|
return { stop: () => server.stop(true), port: server.port ?? port };
|
|
}
|