feat(api): port Fase 5 REST API — Bun.serve router + JWT middleware + auth/session/conversation/chat handlers + composition root

This commit is contained in:
asepharyana
2026-09-02 22:50:38 +07:00
parent 1b130e69b3
commit 49e79322b4
14 changed files with 1007 additions and 0 deletions
+183
View File
@@ -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 };
}