import { Hono } from "hono"; import { describeRoute, validator } from "hono-openapi"; import type { ZodTypeAny } from "zod"; import type { SubjectRow } from "@/worker/db/d1/schema"; import { describeJson, jsonErrorResponse } from "@/worker/routes/api/openapi"; import { subjectForSession } from "@/worker/routes/api/session"; import type { AppContext, AppEnv } from "@/worker/types"; import { jsonError } from "@/worker/util/response"; export interface DavApiError { status: number; code: string; message: string; } export async function subjectOrNotFound(c: AppContext): Promise { const subject = await subjectForSession(c); return subject ?? jsonError("not_found", "Authenticated subject not found.", 404); } export function davApiError(error: DavApiError): Response { return jsonError(error.code, error.message, error.status); } export function isoDate(ms: number): string { return new Date(ms).toISOString(); } export function emptyPatchError(body: unknown, fields: string[], code: string, message: string): Response | null { const values = body as Record; return fields.every((field) => values[field] === undefined) ? jsonError(code, message, 400) : null; } type JsonErrorStatus = 400 | 401 | 403 | 404 | 405 | 409 | 413 | 423 | 500; export type CollectionCrudResult = { ok: true; value: T } | (DavApiError & { ok: false }); type SchemaOutput = NonNullable["output"]; export interface CollectionCrudRoutesConfig< TRecord, TResponse, TCreateSchema extends ZodTypeAny, TPatchSchema extends ZodTypeAny, > { tag: string; idParam: string; summaries: { list: string; create: string; get: string; update: string; delete: string; }; deleteSuccessDescription: string; responseSchema: ZodTypeAny; listResponseSchema: ZodTypeAny; createSchema: TCreateSchema; patchSchema: TPatchSchema; errorStatuses: readonly JsonErrorStatus[]; deleteErrorStatuses: readonly JsonErrorStatus[]; emptyPatch: { fields: string[]; code: string; message: string; }; toResponse(row: TRecord): TResponse; list(c: AppContext, subject: SubjectRow): Promise; create(c: AppContext, subject: SubjectRow, body: SchemaOutput): Promise>; get(c: AppContext, subject: SubjectRow, id: string): Promise>; patch( c: AppContext, subject: SubjectRow, id: string, body: SchemaOutput, ): Promise>; delete(c: AppContext, subject: SubjectRow, id: string): Promise>; } function deleteRouteDescription(config: CollectionCrudRoutesConfig) { return describeRoute({ summary: config.summaries.delete, tags: [config.tag], responses: { 204: { description: config.deleteSuccessDescription }, ...Object.fromEntries(config.deleteErrorStatuses.map((status) => [status, jsonErrorResponse])), }, }); } function routeParam(c: AppContext, name: string): string | Response { return c.req.param(name) ?? jsonError("not_found", "Route parameter not found.", 404); } export function createCollectionCrudRoutes< TRecord, TResponse, TCreateSchema extends ZodTypeAny, TPatchSchema extends ZodTypeAny, >(config: CollectionCrudRoutesConfig): Hono { const api = new Hono(); const idPath = `/:${config.idParam}`; api.get( "/", describeJson(config.summaries.list, config.tag, config.listResponseSchema, 200, config.errorStatuses), async (c) => { const subject = await subjectOrNotFound(c); if (subject instanceof Response) return subject; const rows = await config.list(c, subject); return c.json(rows.map(config.toResponse)); }, ); api.post( "/", describeJson(config.summaries.create, config.tag, config.responseSchema, 201, config.errorStatuses), validator("json", config.createSchema), async (c) => { const subject = await subjectOrNotFound(c); if (subject instanceof Response) return subject; const body = c.req.valid("json"); const result = await config.create(c, subject, body); if (!result.ok) return davApiError(result); return c.json(config.toResponse(result.value), 201); }, ); api.get( idPath, describeJson(config.summaries.get, config.tag, config.responseSchema, 200, config.errorStatuses), async (c) => { const subject = await subjectOrNotFound(c); if (subject instanceof Response) return subject; const id = routeParam(c, config.idParam); if (id instanceof Response) return id; const result = await config.get(c, subject, id); if (!result.ok) return davApiError(result); return c.json(config.toResponse(result.value)); }, ); api.patch( idPath, describeJson(config.summaries.update, config.tag, config.responseSchema, 200, config.errorStatuses), validator("json", config.patchSchema), async (c) => { const subject = await subjectOrNotFound(c); if (subject instanceof Response) return subject; const body = c.req.valid("json"); const emptyPatch = emptyPatchError( body, config.emptyPatch.fields, config.emptyPatch.code, config.emptyPatch.message, ); if (emptyPatch) return emptyPatch; const id = routeParam(c, config.idParam); if (id instanceof Response) return id; const result = await config.patch(c, subject, id, body); if (!result.ok) return davApiError(result); return c.json(config.toResponse(result.value)); }, ); api.delete(idPath, deleteRouteDescription(config), async (c) => { const subject = await subjectOrNotFound(c); if (subject instanceof Response) return subject; const id = routeParam(c, config.idParam); if (id instanceof Response) return id; const result = await config.delete(c, subject, id); if (!result.ok) return davApiError(result); return new Response(null, { status: 204 }); }); return api; }