Skip to content
File

Blob: src/worker/routes/api/collection-crud.ts

typescript178 lines
1import { Hono } from "hono";
2import { describeRoute, validator } from "hono-openapi";
3import type { ZodTypeAny } from "zod";
4 
5import type { SubjectRow } from "@/worker/db/d1/schema";
6import { describeJson, jsonErrorResponse } from "@/worker/routes/api/openapi";
7import { subjectForSession } from "@/worker/routes/api/session";
8import type { AppContext, AppEnv } from "@/worker/types";
9import { jsonError } from "@/worker/util/response";
10 
11export interface DavApiError {
12 status: number;
13 code: string;
14 message: string;
15}
16 
17export async function subjectOrNotFound(c: AppContext): Promise<SubjectRow | Response> {
18 const subject = await subjectForSession(c);
19 return subject ?? jsonError("not_found", "Authenticated subject not found.", 404);
20}
21 
22export function davApiError(error: DavApiError): Response {
23 return jsonError(error.code, error.message, error.status);
24}
25 
26export function isoDate(ms: number): string {
27 return new Date(ms).toISOString();
28}
29 
30export function emptyPatchError(body: unknown, fields: string[], code: string, message: string): Response | null {
31 const values = body as Record<string, unknown>;
32 return fields.every((field) => values[field] === undefined) ? jsonError(code, message, 400) : null;
33}
34 
35type JsonErrorStatus = 400 | 401 | 403 | 404 | 405 | 409 | 413 | 423 | 500;
36 
37export type CollectionCrudResult<T> = { ok: true; value: T } | (DavApiError & { ok: false });
38type SchemaOutput<TSchema extends ZodTypeAny> = NonNullable<TSchema["~standard"]["types"]>["output"];
39 
40export interface CollectionCrudRoutesConfig<
41 TRecord,
42 TResponse,
43 TCreateSchema extends ZodTypeAny,
44 TPatchSchema extends ZodTypeAny,
45> {
46 tag: string;
47 idParam: string;
48 summaries: {
49 list: string;
50 create: string;
51 get: string;
52 update: string;
53 delete: string;
54 };
55 deleteSuccessDescription: string;
56 responseSchema: ZodTypeAny;
57 listResponseSchema: ZodTypeAny;
58 createSchema: TCreateSchema;
59 patchSchema: TPatchSchema;
60 errorStatuses: readonly JsonErrorStatus[];
61 deleteErrorStatuses: readonly JsonErrorStatus[];
62 emptyPatch: {
63 fields: string[];
64 code: string;
65 message: string;
66 };
67 toResponse(row: TRecord): TResponse;
68 list(c: AppContext, subject: SubjectRow): Promise<TRecord[]>;
69 create(c: AppContext, subject: SubjectRow, body: SchemaOutput<TCreateSchema>): Promise<CollectionCrudResult<TRecord>>;
70 get(c: AppContext, subject: SubjectRow, id: string): Promise<CollectionCrudResult<TRecord>>;
71 patch(
72 c: AppContext,
73 subject: SubjectRow,
74 id: string,
75 body: SchemaOutput<TPatchSchema>,
76 ): Promise<CollectionCrudResult<TRecord>>;
77 delete(c: AppContext, subject: SubjectRow, id: string): Promise<CollectionCrudResult<null>>;
78}
79 
80function deleteRouteDescription(config: CollectionCrudRoutesConfig<unknown, unknown, ZodTypeAny, ZodTypeAny>) {
81 return describeRoute({
82 summary: config.summaries.delete,
83 tags: [config.tag],
84 responses: {
85 204: { description: config.deleteSuccessDescription },
86 ...Object.fromEntries(config.deleteErrorStatuses.map((status) => [status, jsonErrorResponse])),
87 },
88 });
89}
90 
91function routeParam(c: AppContext, name: string): string | Response {
92 return c.req.param(name) ?? jsonError("not_found", "Route parameter not found.", 404);
93}
94 
95export function createCollectionCrudRoutes<
96 TRecord,
97 TResponse,
98 TCreateSchema extends ZodTypeAny,
99 TPatchSchema extends ZodTypeAny,
100>(config: CollectionCrudRoutesConfig<TRecord, TResponse, TCreateSchema, TPatchSchema>): Hono<AppEnv> {
101 const api = new Hono<AppEnv>();
102 const idPath = `/:${config.idParam}`;
103 
104 api.get(
105 "/",
106 describeJson(config.summaries.list, config.tag, config.listResponseSchema, 200, config.errorStatuses),
107 async (c) => {
108 const subject = await subjectOrNotFound(c);
109 if (subject instanceof Response) return subject;
110 const rows = await config.list(c, subject);
111 return c.json(rows.map(config.toResponse));
112 },
113 );
114 
115 api.post(
116 "/",
117 describeJson(config.summaries.create, config.tag, config.responseSchema, 201, config.errorStatuses),
118 validator("json", config.createSchema),
119 async (c) => {
120 const subject = await subjectOrNotFound(c);
121 if (subject instanceof Response) return subject;
122 const body = c.req.valid("json");
123 const result = await config.create(c, subject, body);
124 if (!result.ok) return davApiError(result);
125 return c.json(config.toResponse(result.value), 201);
126 },
127 );
128 
129 api.get(
130 idPath,
131 describeJson(config.summaries.get, config.tag, config.responseSchema, 200, config.errorStatuses),
132 async (c) => {
133 const subject = await subjectOrNotFound(c);
134 if (subject instanceof Response) return subject;
135 const id = routeParam(c, config.idParam);
136 if (id instanceof Response) return id;
137 const result = await config.get(c, subject, id);
138 if (!result.ok) return davApiError(result);
139 return c.json(config.toResponse(result.value));
140 },
141 );
142 
143 api.patch(
144 idPath,
145 describeJson(config.summaries.update, config.tag, config.responseSchema, 200, config.errorStatuses),
146 validator("json", config.patchSchema),
147 async (c) => {
148 const subject = await subjectOrNotFound(c);
149 if (subject instanceof Response) return subject;
150 const body = c.req.valid("json");
151 const emptyPatch = emptyPatchError(
152 body,
153 config.emptyPatch.fields,
154 config.emptyPatch.code,
155 config.emptyPatch.message,
156 );
157 if (emptyPatch) return emptyPatch;
158 const id = routeParam(c, config.idParam);
159 if (id instanceof Response) return id;
160 const result = await config.patch(c, subject, id, body);
161 if (!result.ok) return davApiError(result);
162 return c.json(config.toResponse(result.value));
163 },
164 );
165 
166 api.delete(idPath, deleteRouteDescription(config), async (c) => {
167 const subject = await subjectOrNotFound(c);
168 if (subject instanceof Response) return subject;
169 const id = routeParam(c, config.idParam);
170 if (id instanceof Response) return id;
171 const result = await config.delete(c, subject, id);
172 if (!result.ok) return davApiError(result);
173 return new Response(null, { status: 204 });
174 });
175 
176 return api;
177}