New files: - src/lib/declarative-schema.ts — YAML schema + Zod validation - ModuleSchema, FieldSchema, EndpointSchema - fieldToZodSchema, buildCreateSchema, buildUpdateSchema, defaultEndpoints - src/lib/declarative-loader.ts — runtime route generator - parseModuleYaml, registerModuleRoutes, loadDeclarativeModules - MemoryStore with filtering, sorting, pagination - Auth enforcement (none/user/admin), custom endpoint support - src/lib/declarative-loader.test.ts — 34 tests - Schema validation, field conversion, endpoint generation - MemoryStore CRUD, route integration with full lifecycle Dependency: yaml (npm)
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
/**
|
|
* Declarative YAML module loader.
|
|
*
|
|
* Reads .module.yaml files and generates Fastify CRUD routes at runtime.
|
|
* No code generation — routes are created on the fly at startup.
|
|
*
|
|
* Usage:
|
|
* await loadDeclarativeModules(app, path.join(__dirname, '../modules/declarative'));
|
|
*/
|
|
|
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
import { parse as parseYaml } from 'yaml';
|
|
import { readdir, readFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import {
|
|
ModuleSchema,
|
|
buildCreateSchema,
|
|
buildUpdateSchema,
|
|
defaultEndpoints,
|
|
type ModuleDef,
|
|
type EndpointDef,
|
|
} from './declarative-schema.js';
|
|
|
|
// ── Auth helpers ──────────────────────────────────────────────────────────
|
|
|
|
interface JwtPayload {
|
|
sub: string;
|
|
role?: string;
|
|
}
|
|
|
|
function getUserId(req: FastifyRequest): string {
|
|
const jwt = (req as unknown as { jwtPayload?: JwtPayload }).jwtPayload;
|
|
if (!jwt?.sub) throw Object.assign(new Error('Authentication required'), { statusCode: 401 });
|
|
return jwt.sub;
|
|
}
|
|
|
|
function requireAdmin(req: FastifyRequest): string {
|
|
const userId = getUserId(req);
|
|
const jwt = (req as unknown as { jwtPayload?: JwtPayload }).jwtPayload;
|
|
if (jwt?.role !== 'admin')
|
|
throw Object.assign(new Error('Admin access required'), { statusCode: 403 });
|
|
return userId;
|
|
}
|
|
|
|
function getProductId(req: FastifyRequest): string {
|
|
const pid = req.headers['x-product-id'] as string | undefined;
|
|
if (!pid) throw Object.assign(new Error('x-product-id header required'), { statusCode: 400 });
|
|
return pid;
|
|
}
|
|
|
|
// ── In-memory store (for testing / no-DB mode) ───────────────────────────
|
|
|
|
export class MemoryStore {
|
|
private collections = new Map<string, Map<string, Record<string, unknown>>>();
|
|
|
|
getCollection(name: string): Map<string, Record<string, unknown>> {
|
|
if (!this.collections.has(name)) {
|
|
this.collections.set(name, new Map());
|
|
}
|
|
return this.collections.get(name)!;
|
|
}
|
|
|
|
async create(container: string, doc: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
this.getCollection(container).set(doc.id as string, { ...doc });
|
|
return doc;
|
|
}
|
|
|
|
async get(
|
|
container: string,
|
|
id: string,
|
|
productId: string
|
|
): Promise<Record<string, unknown> | null> {
|
|
const doc = this.getCollection(container).get(id);
|
|
if (!doc || doc.productId !== productId) return null;
|
|
return doc;
|
|
}
|
|
|
|
async list(
|
|
container: string,
|
|
productId: string,
|
|
opts: {
|
|
sortBy?: string;
|
|
sortDir?: 'asc' | 'desc';
|
|
limit?: number;
|
|
offset?: number;
|
|
filters?: Record<string, unknown>;
|
|
} = {}
|
|
): Promise<{ items: Record<string, unknown>[]; total: number }> {
|
|
let items = Array.from(this.getCollection(container).values()).filter(
|
|
d => d.productId === productId
|
|
);
|
|
|
|
// Apply filters
|
|
if (opts.filters) {
|
|
for (const [key, value] of Object.entries(opts.filters)) {
|
|
items = items.filter(d => d[key] === value);
|
|
}
|
|
}
|
|
|
|
const total = items.length;
|
|
|
|
// Sort
|
|
if (opts.sortBy) {
|
|
const dir = opts.sortDir === 'desc' ? -1 : 1;
|
|
items.sort((a, b) => {
|
|
const va = String(a[opts.sortBy!] ?? '');
|
|
const vb = String(b[opts.sortBy!] ?? '');
|
|
return va.localeCompare(vb) * dir;
|
|
});
|
|
}
|
|
|
|
// Paginate
|
|
const offset = opts.offset ?? 0;
|
|
const limit = opts.limit ?? 50;
|
|
items = items.slice(offset, offset + limit);
|
|
|
|
return { items, total };
|
|
}
|
|
|
|
async update(
|
|
container: string,
|
|
id: string,
|
|
productId: string,
|
|
updates: Record<string, unknown>
|
|
): Promise<Record<string, unknown> | null> {
|
|
const existing = await this.get(container, id, productId);
|
|
if (!existing) return null;
|
|
const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
|
this.getCollection(container).set(id, updated);
|
|
return updated;
|
|
}
|
|
|
|
async delete(container: string, id: string, productId: string): Promise<boolean> {
|
|
const existing = await this.get(container, id, productId);
|
|
if (!existing) return false;
|
|
this.getCollection(container).delete(id);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// ── Route generator ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Register CRUD routes for a single declarative module definition.
|
|
*/
|
|
export function registerModuleRoutes(
|
|
app: FastifyInstance,
|
|
mod: ModuleDef,
|
|
store: MemoryStore
|
|
): void {
|
|
const createSchema = buildCreateSchema(mod);
|
|
const updateSchema = buildUpdateSchema(mod);
|
|
const endpoints =
|
|
mod.endpoints && mod.endpoints.length > 0 ? mod.endpoints : defaultEndpoints(mod);
|
|
|
|
for (const ep of endpoints) {
|
|
if (ep.custom) continue; // Skip custom — those need hand-written handlers
|
|
|
|
const handler = buildHandler(ep, mod, store, createSchema, updateSchema);
|
|
const method = ep.method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
app[method](ep.path, handler);
|
|
}
|
|
}
|
|
|
|
function buildHandler(
|
|
ep: EndpointDef,
|
|
mod: ModuleDef,
|
|
store: MemoryStore,
|
|
createSchema: ReturnType<typeof buildCreateSchema>,
|
|
updateSchema: ReturnType<typeof buildUpdateSchema>
|
|
): (req: FastifyRequest, reply: FastifyReply) => Promise<unknown> {
|
|
const { method, path, auth } = ep;
|
|
const isById = path.includes(':id');
|
|
|
|
// Determine auth function
|
|
const checkAuth = auth === 'admin' ? requireAdmin : auth === 'user' ? getUserId : null;
|
|
|
|
// ── LIST ────────────────────────────────────────────────────────
|
|
if (method === 'GET' && !isById) {
|
|
return async (req, _reply) => {
|
|
if (checkAuth) checkAuth(req);
|
|
const productId = getProductId(req);
|
|
const query = req.query as Record<string, string>;
|
|
const sortBy = query.sortBy || 'createdAt';
|
|
const sortDir = query.sortDir === 'asc' ? 'asc' : 'desc';
|
|
const limit = Math.min(parseInt(query.limit || '50', 10), 100);
|
|
const offset = parseInt(query.offset || '0', 10);
|
|
|
|
// Extract field-based filters
|
|
const filters: Record<string, unknown> = {};
|
|
for (const key of Object.keys(mod.fields)) {
|
|
if (query[key] !== undefined) {
|
|
filters[key] = query[key];
|
|
}
|
|
}
|
|
|
|
return store.list(mod.container, productId, { sortBy, sortDir, limit, offset, filters });
|
|
};
|
|
}
|
|
|
|
// ── GET BY ID ───────────────────────────────────────────────────
|
|
if (method === 'GET' && isById) {
|
|
return async (req, _reply) => {
|
|
if (checkAuth) checkAuth(req);
|
|
const productId = getProductId(req);
|
|
const { id } = req.params as { id: string };
|
|
const doc = await store.get(mod.container, id, productId);
|
|
if (!doc) throw Object.assign(new Error(`${mod.name} not found`), { statusCode: 404 });
|
|
return doc;
|
|
};
|
|
}
|
|
|
|
// ── CREATE ──────────────────────────────────────────────────────
|
|
if (method === 'POST') {
|
|
return async (req, reply) => {
|
|
let userId: string | undefined;
|
|
if (checkAuth) userId = checkAuth(req);
|
|
const productId = getProductId(req);
|
|
const input = createSchema.parse(req.body);
|
|
const now = new Date().toISOString();
|
|
const prefix = mod.idPrefix ? `${mod.idPrefix}_` : '';
|
|
const doc: Record<string, unknown> = {
|
|
id: `${prefix}${crypto.randomUUID()}`,
|
|
productId,
|
|
...input,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
if (userId) doc.createdBy = userId;
|
|
const created = await store.create(mod.container, doc);
|
|
reply.status(201);
|
|
return created;
|
|
};
|
|
}
|
|
|
|
// ── UPDATE ──────────────────────────────────────────────────────
|
|
if (method === 'PATCH' || method === 'PUT') {
|
|
return async (req, _reply) => {
|
|
if (checkAuth) checkAuth(req);
|
|
const productId = getProductId(req);
|
|
const { id } = req.params as { id: string };
|
|
const updates = updateSchema.parse(req.body);
|
|
const updated = await store.update(
|
|
mod.container,
|
|
id,
|
|
productId,
|
|
updates as Record<string, unknown>
|
|
);
|
|
if (!updated) throw Object.assign(new Error(`${mod.name} not found`), { statusCode: 404 });
|
|
return updated;
|
|
};
|
|
}
|
|
|
|
// ── DELETE ──────────────────────────────────────────────────────
|
|
if (method === 'DELETE') {
|
|
return async (req, reply) => {
|
|
if (checkAuth) checkAuth(req);
|
|
const productId = getProductId(req);
|
|
const { id } = req.params as { id: string };
|
|
const ok = await store.delete(mod.container, id, productId);
|
|
if (!ok) throw Object.assign(new Error(`${mod.name} not found`), { statusCode: 404 });
|
|
reply.status(204);
|
|
};
|
|
}
|
|
|
|
// Fallback — shouldn't reach here
|
|
return async () => ({ error: 'Unhandled endpoint' });
|
|
}
|
|
|
|
// ── YAML file loader ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse a YAML string into a validated ModuleDef.
|
|
*/
|
|
export function parseModuleYaml(yamlContent: string): ModuleDef {
|
|
const raw = parseYaml(yamlContent);
|
|
return ModuleSchema.parse(raw);
|
|
}
|
|
|
|
/**
|
|
* Load all .module.yaml files from a directory and register their routes.
|
|
* Returns the list of loaded module definitions.
|
|
*/
|
|
export async function loadDeclarativeModules(
|
|
app: FastifyInstance,
|
|
dir: string,
|
|
store?: MemoryStore
|
|
): Promise<ModuleDef[]> {
|
|
const memStore = store ?? new MemoryStore();
|
|
const modules: ModuleDef[] = [];
|
|
|
|
let files: string[];
|
|
try {
|
|
files = await readdir(dir);
|
|
} catch {
|
|
// Directory doesn't exist — no declarative modules
|
|
return modules;
|
|
}
|
|
|
|
const yamlFiles = files.filter(f => f.endsWith('.module.yaml'));
|
|
|
|
for (const file of yamlFiles) {
|
|
const content = await readFile(join(dir, file), 'utf-8');
|
|
const mod = parseModuleYaml(content);
|
|
registerModuleRoutes(app, mod, memStore);
|
|
modules.push(mod);
|
|
app.log.info(`Loaded declarative module: ${mod.name} (${file})`);
|
|
}
|
|
|
|
return modules;
|
|
}
|