feat: add application productivity foundations
This commit is contained in:
1 parent
46195462c3
commit
64ab20cc95
42 files changed
+1533
-40
No files matched your search
@@ -0,0 +1,118 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type { Db } from "@wrnexus/db";
|
||||
import { can } from "@wrnexus/authz";
|
||||
import { HttpError, defineApiRoute, json, requireJson, requireParam, requireUser } from "./http.ts";
|
||||
|
||||
const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const ident = (value: string) => {
|
||||
if (!SAFE.test(value)) throw new TypeError(`Unsafe resource identifier: ${value}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export interface ResourceDefinition<T extends Record<string, unknown>> {
|
||||
name: string;
|
||||
db: () => Db;
|
||||
table: string;
|
||||
id?: string;
|
||||
owner?: string;
|
||||
fields: readonly (keyof T & string)[];
|
||||
permissions?: { read?: string; readAny?: string; write?: string; writeAny?: string };
|
||||
validate?: (input: unknown, mode: "create" | "update") => T | Promise<T>;
|
||||
serialize?: (row: T) => unknown;
|
||||
}
|
||||
|
||||
export interface ResourceHandlers {
|
||||
list(ctx: Context): Response | Promise<Response>;
|
||||
create(ctx: Context): Response | Promise<Response>;
|
||||
get(ctx: Context): Response | Promise<Response>;
|
||||
update(ctx: Context): Response | Promise<Response>;
|
||||
remove(ctx: Context): Response | Promise<Response>;
|
||||
}
|
||||
|
||||
/** Build conventional owned CRUD handlers from one checked resource declaration. */
|
||||
export function defineResource<T extends Record<string, unknown>>(
|
||||
definition: ResourceDefinition<T>,
|
||||
): ResourceHandlers {
|
||||
const table = ident(definition.table);
|
||||
const id = ident(definition.id ?? "id");
|
||||
const owner = definition.owner ? ident(definition.owner) : undefined;
|
||||
const fields = definition.fields.map(ident);
|
||||
const shape = (row: T) => definition.serialize?.(row) ?? row;
|
||||
const authorize = async (ctx: Context, action: "read" | "write", row?: T) => {
|
||||
const subject = requireUser(ctx).id;
|
||||
const permission = definition.permissions?.[action];
|
||||
const any = definition.permissions?.[`${action}Any`];
|
||||
if (any && (await can(ctx, any))) return subject;
|
||||
if (
|
||||
permission &&
|
||||
!(await can(ctx, permission, owner ? { ownerId: row?.[owner] ?? subject } : row))
|
||||
) {
|
||||
throw new HttpError(403, "Forbidden");
|
||||
}
|
||||
if (owner && row && String(row[owner]) !== subject) throw new HttpError(403, "Forbidden");
|
||||
return subject;
|
||||
};
|
||||
const find = async (ctx: Context) => {
|
||||
const value = requireParam(ctx, id, { integer: true, positive: true });
|
||||
const row = await definition.db().one<T>(`SELECT * FROM ${table} WHERE ${id} = ?`, [value]);
|
||||
if (!row) throw new HttpError(404, `${definition.name} not found`);
|
||||
return row;
|
||||
};
|
||||
return {
|
||||
list: defineApiRoute(async (ctx) => {
|
||||
const subject = await authorize(ctx, "read");
|
||||
const seeAll =
|
||||
definition.permissions?.readAny && (await can(ctx, definition.permissions.readAny));
|
||||
const rows = await definition
|
||||
.db()
|
||||
.all<T>(
|
||||
`SELECT * FROM ${table}${owner && !seeAll ? ` WHERE ${owner} = ?` : ""} ORDER BY ${id} DESC`,
|
||||
owner && !seeAll ? [subject] : [],
|
||||
);
|
||||
return json.ok({ [definition.name]: rows.map(shape) });
|
||||
}),
|
||||
create: defineApiRoute(async (ctx) => {
|
||||
const subject = await authorize(ctx, "write");
|
||||
const input = definition.validate
|
||||
? await definition.validate(await requireJson(ctx), "create")
|
||||
: await requireJson<T>(ctx);
|
||||
const values = Object.fromEntries(
|
||||
fields.filter((key) => key in input).map((key) => [key, input[key]]),
|
||||
);
|
||||
if (owner) values[owner] = subject;
|
||||
const columns = Object.keys(values).map(ident);
|
||||
const result = await definition.db().exec(
|
||||
`INSERT INTO ${table} (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`,
|
||||
columns.map((key) => values[key]),
|
||||
);
|
||||
return json.created({ ok: true, id: result.lastInsertId });
|
||||
}),
|
||||
get: defineApiRoute(async (ctx) => {
|
||||
const row = await find(ctx);
|
||||
await authorize(ctx, "read", row);
|
||||
return json.ok({ [definition.name.replace(/s$/, "")]: shape(row) });
|
||||
}),
|
||||
update: defineApiRoute(async (ctx) => {
|
||||
const row = await find(ctx);
|
||||
await authorize(ctx, "write", row);
|
||||
const input = definition.validate
|
||||
? await definition.validate(await requireJson(ctx), "update")
|
||||
: await requireJson<T>(ctx);
|
||||
const keys = fields.filter((key) => key in input);
|
||||
if (!keys.length) throw new HttpError(400, "No writable fields supplied.");
|
||||
await definition
|
||||
.db()
|
||||
.exec(`UPDATE ${table} SET ${keys.map((key) => `${key} = ?`).join(", ")} WHERE ${id} = ?`, [
|
||||
...keys.map((key) => input[key]),
|
||||
row[id],
|
||||
]);
|
||||
return json.ok({ ok: true });
|
||||
}),
|
||||
remove: defineApiRoute(async (ctx) => {
|
||||
const row = await find(ctx);
|
||||
await authorize(ctx, "write", row);
|
||||
await definition.db().exec(`DELETE FROM ${table} WHERE ${id} = ?`, [row[id]]);
|
||||
return json.ok({ ok: true });
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user