learning_ai_common_plat/services/platform-service/src/lib/declarative-loader.ts
saravanakumardb1 0f299231cc feat(platform-service): declarative YAML module loader (4.4) — 34 tests
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)
2026-03-19 21:16:58 -07:00

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;
}