/** * 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>>(); getCollection(name: string): Map> { if (!this.collections.has(name)) { this.collections.set(name, new Map()); } return this.collections.get(name)!; } async create(container: string, doc: Record): Promise> { this.getCollection(container).set(doc.id as string, { ...doc }); return doc; } async get( container: string, id: string, productId: string ): Promise | 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; } = {} ): Promise<{ items: Record[]; 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 ): Promise | 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 { 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, updateSchema: ReturnType ): (req: FastifyRequest, reply: FastifyReply) => Promise { 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; 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 = {}; 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 = { 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 ); 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 { 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; }