feat(api): port Fase 5 REST API — Bun.serve router + JWT middleware + auth/session/conversation/chat handlers + composition root
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user