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)
This commit is contained in:
saravanakumardb1 2026-03-19 21:16:58 -07:00
parent 70635ba80e
commit 0f299231cc
5 changed files with 1085 additions and 7 deletions

118
pnpm-lock.yaml generated
View File

@ -89,6 +89,9 @@ importers:
'@bytelyst/cosmos':
specifier: workspace:*
version: link:../../packages/cosmos
'@bytelyst/dashboard-components':
specifier: workspace:*
version: link:../../packages/dashboard-components
'@bytelyst/datastore':
specifier: workspace:*
version: link:../../packages/datastore
@ -188,7 +191,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
husky:
specifier: ^9.0.0
version: 9.1.7
@ -228,6 +231,9 @@ importers:
'@bytelyst/config':
specifier: workspace:*
version: link:../../packages/config
'@bytelyst/dashboard-components':
specifier: workspace:*
version: link:../../packages/dashboard-components
'@bytelyst/errors':
specifier: workspace:*
version: link:../../packages/errors
@ -282,7 +288,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
husky:
specifier: ^9.0.0
version: 9.1.7
@ -375,23 +381,71 @@ importers:
specifier: '>=4.0.0'
version: 4.9.1(@azure/core-client@1.10.1)
packages/create-app:
devDependencies:
tsx:
specifier: ^4.19.2
version: 4.21.0
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/dashboard-components:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.0.0
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.0.0
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.0.0
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.0.0
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/dashboard-shell:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/datastore:
dependencies:
@ -839,6 +893,9 @@ importers:
stripe:
specifier: ^17.5.0
version: 17.7.0
yaml:
specifier: ^2.8.2
version: 2.8.2
zod:
specifier: ^3.24.2
version: 3.25.76
@ -17733,6 +17790,15 @@ snapshots:
msw: 2.12.10(@types/node@20.19.33)(typescript@5.9.3)
vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3)
vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
@ -23590,6 +23656,46 @@ snapshots:
- tsx
- yaml
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
'@vitest/spy': 4.0.18
'@vitest/utils': 4.0.18
es-module-lexer: 1.7.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 22.19.11
happy-dom: 18.0.1
jsdom: 28.0.0(@noble/hashes@1.8.0)
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
vlq@1.0.1: {}
w3c-xmlserializer@5.0.0:

View File

@ -18,7 +18,6 @@
"@azure/cosmos": "^4.2.0",
"@bytelyst/auth": "workspace:*",
"@bytelyst/blob": "workspace:*",
"@bytelyst/storage": "workspace:*",
"@bytelyst/config": "workspace:*",
"@bytelyst/cosmos": "workspace:*",
"@bytelyst/datastore": "workspace:*",
@ -26,6 +25,7 @@
"@bytelyst/events": "workspace:*",
"@bytelyst/fastify-core": "workspace:*",
"@bytelyst/queue": "workspace:*",
"@bytelyst/storage": "workspace:*",
"@fastify/cors": "^10.0.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/swagger": "^9.4.2",
@ -37,6 +37,7 @@
"jose": "^6.0.8",
"nodemailer": "^6.10.1",
"stripe": "^17.5.0",
"yaml": "^2.8.2",
"zod": "^3.24.2",
"zod-openapi": "^5.4.6"
},

View File

@ -0,0 +1,492 @@
/**
* Declarative YAML module loader unit tests.
*/
import { describe, it, expect } from 'vitest';
import Fastify from 'fastify';
import {
buildCreateSchema,
buildUpdateSchema,
defaultEndpoints,
fieldToZodSchema,
} from './declarative-schema.js';
import { MemoryStore, parseModuleYaml, registerModuleRoutes } from './declarative-loader.js';
// ── Sample YAML ──────────────────────────────────────────────────────────
const SAMPLE_YAML = `
name: announcements
container: announcements
partitionKey: /productId
idPrefix: ann
description: Product announcements
fields:
title:
type: string
required: true
minLength: 1
maxLength: 200
body:
type: string
maxLength: 5000
default: ""
priority:
type: enum
values: [low, medium, high]
default: medium
published:
type: boolean
default: false
viewCount:
type: number
min: 0
default: 0
`;
// ── Schema Validation ────────────────────────────────────────────────────
describe('ModuleSchema', () => {
it('parses valid module definition', () => {
const mod = parseModuleYaml(SAMPLE_YAML);
expect(mod.name).toBe('announcements');
expect(mod.container).toBe('announcements');
expect(mod.partitionKey).toBe('/productId');
expect(mod.idPrefix).toBe('ann');
expect(Object.keys(mod.fields)).toHaveLength(5);
});
it('rejects module without name', () => {
expect(() => parseModuleYaml('container: test\nfields:\n x:\n type: string')).toThrow();
});
it('rejects module without fields', () => {
expect(() => parseModuleYaml('name: test\ncontainer: test')).toThrow();
});
it('defaults partitionKey to /productId', () => {
const mod = parseModuleYaml(
'name: test\ncontainer: test_items\nfields:\n title:\n type: string'
);
expect(mod.partitionKey).toBe('/productId');
});
it('defaults idPrefix to empty string', () => {
const mod = parseModuleYaml(
'name: test\ncontainer: test_items\nfields:\n title:\n type: string'
);
expect(mod.idPrefix).toBe('');
});
});
// ── Field to Zod ─────────────────────────────────────────────────────────
describe('fieldToZodSchema', () => {
it('creates string schema with constraints', () => {
const schema = fieldToZodSchema('name', {
type: 'string',
required: true,
minLength: 1,
maxLength: 100,
});
expect(schema.parse('hello')).toBe('hello');
expect(() => schema.parse('')).toThrow();
});
it('creates number schema with min/max', () => {
const schema = fieldToZodSchema('count', { type: 'number', required: true, min: 0, max: 100 });
expect(schema.parse(50)).toBe(50);
expect(() => schema.parse(-1)).toThrow();
expect(() => schema.parse(101)).toThrow();
});
it('creates boolean schema', () => {
const schema = fieldToZodSchema('active', { type: 'boolean', required: true });
expect(schema.parse(true)).toBe(true);
expect(() => schema.parse('yes')).toThrow();
});
it('creates enum schema', () => {
const schema = fieldToZodSchema('status', {
type: 'enum',
required: true,
values: ['draft', 'published'],
});
expect(schema.parse('draft')).toBe('draft');
expect(() => schema.parse('unknown')).toThrow();
});
it('throws for enum without values', () => {
expect(() => fieldToZodSchema('status', { type: 'enum', required: true })).toThrow();
});
it('applies default value', () => {
const schema = fieldToZodSchema('count', { type: 'number', required: false, default: 0 });
expect(schema.parse(undefined)).toBe(0);
});
it('makes non-required fields optional', () => {
const schema = fieldToZodSchema('note', { type: 'string', required: false });
expect(schema.parse(undefined)).toBeUndefined();
});
});
// ── Build Schemas ────────────────────────────────────────────────────────
describe('buildCreateSchema / buildUpdateSchema', () => {
const mod = parseModuleYaml(SAMPLE_YAML);
it('buildCreateSchema validates required fields', () => {
const schema = buildCreateSchema(mod);
const result = schema.parse({ title: 'Hello' });
expect(result.title).toBe('Hello');
expect(result.body).toBe('');
expect(result.priority).toBe('medium');
expect(result.published).toBe(false);
});
it('buildCreateSchema rejects missing required field', () => {
const schema = buildCreateSchema(mod);
expect(() => schema.parse({ body: 'no title' })).toThrow();
});
it('buildUpdateSchema makes all fields optional', () => {
const schema = buildUpdateSchema(mod);
const result = schema.parse({ title: 'Updated' });
expect(result.title).toBe('Updated');
expect(result.body).toBeUndefined();
});
it('buildUpdateSchema accepts empty object', () => {
const schema = buildUpdateSchema(mod);
const result = schema.parse({});
expect(Object.keys(result).length).toBe(0);
});
});
// ── Default Endpoints ────────────────────────────────────────────────────
describe('defaultEndpoints', () => {
it('generates 5 CRUD endpoints', () => {
const mod = parseModuleYaml(SAMPLE_YAML);
const eps = defaultEndpoints(mod);
expect(eps).toHaveLength(5);
expect(eps.map(e => e.method)).toEqual(['GET', 'GET', 'POST', 'PATCH', 'DELETE']);
});
it('uses module name as base path', () => {
const mod = parseModuleYaml(SAMPLE_YAML);
const eps = defaultEndpoints(mod);
expect(eps[0].path).toBe('/announcements');
expect(eps[1].path).toBe('/announcements/:id');
});
it('sets DELETE to admin auth', () => {
const mod = parseModuleYaml(SAMPLE_YAML);
const eps = defaultEndpoints(mod);
const deleteEp = eps.find(e => e.method === 'DELETE');
expect(deleteEp?.auth).toBe('admin');
});
});
// ── MemoryStore ──────────────────────────────────────────────────────────
describe('MemoryStore', () => {
it('creates and retrieves documents', async () => {
const store = new MemoryStore();
const doc = { id: '1', productId: 'test', title: 'Hello' };
await store.create('items', doc);
const found = await store.get('items', '1', 'test');
expect(found?.title).toBe('Hello');
});
it('returns null for non-existent document', async () => {
const store = new MemoryStore();
const found = await store.get('items', 'nope', 'test');
expect(found).toBeNull();
});
it('returns null for wrong productId', async () => {
const store = new MemoryStore();
await store.create('items', { id: '1', productId: 'a' });
const found = await store.get('items', '1', 'b');
expect(found).toBeNull();
});
it('lists documents with pagination', async () => {
const store = new MemoryStore();
for (let i = 0; i < 10; i++) {
await store.create('items', {
id: String(i),
productId: 'test',
createdAt: new Date(2026, 0, i + 1).toISOString(),
});
}
const { items, total } = await store.list('items', 'test', { limit: 3, offset: 0 });
expect(items).toHaveLength(3);
expect(total).toBe(10);
});
it('lists with filters', async () => {
const store = new MemoryStore();
await store.create('items', { id: '1', productId: 'test', status: 'active' });
await store.create('items', { id: '2', productId: 'test', status: 'archived' });
const { items } = await store.list('items', 'test', { filters: { status: 'active' } });
expect(items).toHaveLength(1);
expect(items[0].id).toBe('1');
});
it('updates a document', async () => {
const store = new MemoryStore();
await store.create('items', { id: '1', productId: 'test', title: 'Old' });
const updated = await store.update('items', '1', 'test', { title: 'New' });
expect(updated?.title).toBe('New');
expect(updated?.updatedAt).toBeDefined();
});
it('returns null when updating non-existent', async () => {
const store = new MemoryStore();
const result = await store.update('items', 'nope', 'test', { title: 'x' });
expect(result).toBeNull();
});
it('deletes a document', async () => {
const store = new MemoryStore();
await store.create('items', { id: '1', productId: 'test' });
const ok = await store.delete('items', '1', 'test');
expect(ok).toBe(true);
const found = await store.get('items', '1', 'test');
expect(found).toBeNull();
});
it('returns false when deleting non-existent', async () => {
const store = new MemoryStore();
const ok = await store.delete('items', 'nope', 'test');
expect(ok).toBe(false);
});
});
// ── Route Integration ────────────────────────────────────────────────────
describe('registerModuleRoutes', () => {
async function buildTestApp() {
const app = Fastify({ logger: false });
// Minimal JWT extraction decorator
app.decorateRequest('jwtPayload', null);
app.addHook('preHandler', async req => {
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
try {
const payload = JSON.parse(Buffer.from(auth.split('.')[1], 'base64').toString());
(req as unknown as { jwtPayload: unknown }).jwtPayload = payload;
} catch {
/* no-op */
}
}
});
// Error handler that respects statusCode on thrown errors
app.setErrorHandler((err: Error & { statusCode?: number }, _req, reply) => {
const code = err.statusCode ?? 500;
reply.status(code).send({ error: err.message });
});
return app;
}
function makeJwt(sub: string, role: string = 'user'): string {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64');
const payload = Buffer.from(JSON.stringify({ sub, role })).toString('base64');
return `Bearer ${header}.${payload}.sig`;
}
const HEADERS = {
'x-product-id': 'testapp',
'content-type': 'application/json',
authorization: makeJwt('user-1'),
};
const ADMIN_HEADERS = {
...HEADERS,
authorization: makeJwt('admin-1', 'admin'),
};
it('registers CRUD routes from module definition', async () => {
const app = await buildTestApp();
const store = new MemoryStore();
const mod = parseModuleYaml(SAMPLE_YAML);
registerModuleRoutes(app, mod, store);
await app.ready();
// CREATE
const createRes = await app.inject({
method: 'POST',
url: '/announcements',
headers: HEADERS,
payload: { title: 'Launch Day' },
});
expect(createRes.statusCode).toBe(201);
const created = createRes.json();
expect(created.title).toBe('Launch Day');
expect(created.id).toMatch(/^ann_/);
expect(created.productId).toBe('testapp');
expect(created.priority).toBe('medium');
// LIST
const listRes = await app.inject({
method: 'GET',
url: '/announcements',
headers: HEADERS,
});
expect(listRes.statusCode).toBe(200);
const listed = listRes.json();
expect(listed.items).toHaveLength(1);
expect(listed.total).toBe(1);
// GET BY ID
const getRes = await app.inject({
method: 'GET',
url: `/announcements/${created.id}`,
headers: HEADERS,
});
expect(getRes.statusCode).toBe(200);
expect(getRes.json().title).toBe('Launch Day');
// UPDATE
const updateRes = await app.inject({
method: 'PATCH',
url: `/announcements/${created.id}`,
headers: HEADERS,
payload: { title: 'Updated Title', published: true },
});
expect(updateRes.statusCode).toBe(200);
expect(updateRes.json().title).toBe('Updated Title');
expect(updateRes.json().published).toBe(true);
// DELETE (requires admin) — omit content-type for DELETE
const { 'content-type': _ignoreCtUser, ...deleteUserHeaders } = HEADERS; // eslint-disable-line @typescript-eslint/no-unused-vars
const deleteUserRes = await app.inject({
method: 'DELETE',
url: `/announcements/${created.id}`,
headers: deleteUserHeaders,
});
expect(deleteUserRes.statusCode).toBe(403);
const { 'content-type': _ignoreCtAdmin, ...deleteAdminHeaders } = ADMIN_HEADERS; // eslint-disable-line @typescript-eslint/no-unused-vars
const deleteAdminRes = await app.inject({
method: 'DELETE',
url: `/announcements/${created.id}`,
headers: deleteAdminHeaders,
});
expect(deleteAdminRes.statusCode).toBe(204);
// Verify deleted
const getDeletedRes = await app.inject({
method: 'GET',
url: `/announcements/${created.id}`,
headers: HEADERS,
});
expect(getDeletedRes.statusCode).toBe(404);
await app.close();
});
it('rejects requests without auth', async () => {
const app = await buildTestApp();
const store = new MemoryStore();
const mod = parseModuleYaml(SAMPLE_YAML);
registerModuleRoutes(app, mod, store);
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/announcements',
headers: { 'x-product-id': 'testapp' }, // no auth
});
expect(res.statusCode).toBe(401);
await app.close();
});
it('rejects requests without x-product-id', async () => {
const app = await buildTestApp();
const store = new MemoryStore();
const mod = parseModuleYaml(SAMPLE_YAML);
registerModuleRoutes(app, mod, store);
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/announcements',
headers: { authorization: makeJwt('user-1') }, // no product id
});
expect(res.statusCode).toBe(400);
await app.close();
});
it('validates create input against schema', async () => {
const app = await buildTestApp();
const store = new MemoryStore();
const mod = parseModuleYaml(SAMPLE_YAML);
registerModuleRoutes(app, mod, store);
await app.ready();
// Missing required 'title'
const res = await app.inject({
method: 'POST',
url: '/announcements',
headers: HEADERS,
payload: { body: 'no title field' },
});
// Zod will throw, Fastify returns 500 by default (or 400 with proper error handler)
expect(res.statusCode).toBeGreaterThanOrEqual(400);
await app.close();
});
it('supports custom endpoints in YAML', () => {
const yaml = `
name: widgets
container: widgets
fields:
label:
type: string
required: true
endpoints:
- method: GET
path: /widgets
auth: none
- method: POST
path: /widgets/import
auth: admin
custom: true
`;
const mod = parseModuleYaml(yaml);
expect(mod.endpoints).toHaveLength(2);
expect(mod.endpoints![1].custom).toBe(true);
});
it('supports auth: none endpoints', async () => {
const yaml = `
name: public-items
container: public_items
fields:
title:
type: string
required: true
endpoints:
- method: GET
path: /public-items
auth: none
`;
const app = await buildTestApp();
const store = new MemoryStore();
const mod = parseModuleYaml(yaml);
registerModuleRoutes(app, mod, store);
await app.ready();
// No auth needed
const res = await app.inject({
method: 'GET',
url: '/public-items',
headers: { 'x-product-id': 'testapp' },
});
expect(res.statusCode).toBe(200);
await app.close();
});
});

View File

@ -0,0 +1,312 @@
/**
* 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;
}

View File

@ -0,0 +1,167 @@
/**
* Declarative module YAML schema definition and validation.
*
* Defines the structure of .module.yaml files that produce
* runtime-generated Fastify CRUD routes with no TypeScript needed.
*/
import { z } from 'zod';
// ── Field types supported in YAML definitions ─────────────────────────────
const FieldTypeEnum = z.enum(['string', 'number', 'boolean', 'date', 'array', 'enum']);
// ── Field definition ──────────────────────────────────────────────────────
export const FieldSchema = z.object({
type: FieldTypeEnum,
required: z.boolean().default(false),
default: z.any().optional(),
min: z.number().optional(),
max: z.number().optional(),
minLength: z.number().optional(),
maxLength: z.number().optional(),
values: z.array(z.string()).optional(), // for enum type
items: z.string().optional(), // for array type (item type)
description: z.string().optional(),
});
// ── Endpoint definition ───────────────────────────────────────────────────
export const EndpointSchema = z.object({
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
path: z.string().min(1),
auth: z.enum(['none', 'user', 'admin']).default('user'),
custom: z.boolean().default(false),
description: z.string().optional(),
});
// ── Module definition (top-level YAML structure) ──────────────────────────
export const ModuleSchema = z.object({
name: z.string().min(1).max(100),
container: z.string().min(1).max(100),
partitionKey: z.string().min(1).max(100).default('/productId'),
idPrefix: z.string().max(20).default(''),
fields: z.record(z.string(), FieldSchema),
endpoints: z.array(EndpointSchema).optional(),
description: z.string().optional(),
});
// ── Inferred types ────────────────────────────────────────────────────────
export type FieldDef = z.infer<typeof FieldSchema>;
export type EndpointDef = z.infer<typeof EndpointSchema>;
export type ModuleDef = z.infer<typeof ModuleSchema>;
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Convert a FieldDef to a Zod schema for runtime validation.
*/
export function fieldToZodSchema(name: string, field: FieldDef): z.ZodTypeAny {
let schema: z.ZodTypeAny;
switch (field.type) {
case 'string': {
let s = z.string();
if (field.minLength !== undefined) s = s.min(field.minLength);
if (field.maxLength !== undefined) s = s.max(field.maxLength);
schema = s;
break;
}
case 'number': {
let n = z.number();
if (field.min !== undefined) n = n.min(field.min);
if (field.max !== undefined) n = n.max(field.max);
schema = n;
break;
}
case 'boolean':
schema = z.boolean();
break;
case 'date':
schema = z.string().datetime({ offset: true }).or(z.string().datetime());
break;
case 'enum':
if (!field.values || field.values.length === 0) {
throw new Error(`Field "${name}" is enum but has no values`);
}
schema = z.enum(field.values as [string, ...string[]]);
break;
case 'array':
schema = z.array(z.any());
break;
default:
schema = z.any();
}
if (field.default !== undefined) {
// .default() already handles undefined → value, so no need for .optional()
schema = schema.default(field.default);
} else if (!field.required) {
schema = schema.optional();
}
return schema;
}
/**
* Build a Zod object schema for create operations from a ModuleDef.
*/
export function buildCreateSchema(mod: ModuleDef): z.ZodObject<Record<string, z.ZodTypeAny>> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const [name, field] of Object.entries(mod.fields)) {
shape[name] = fieldToZodSchema(name, field);
}
return z.object(shape);
}
/**
* Build a Zod object schema for update operations (all fields optional).
*/
export function buildUpdateSchema(mod: ModuleDef): z.ZodObject<Record<string, z.ZodTypeAny>> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const [name, field] of Object.entries(mod.fields)) {
// For updates, make everything optional
let schema = fieldToZodSchema(name, { ...field, required: false });
// Remove default on updates
if (field.default !== undefined) {
schema = fieldToZodSchema(name, { ...field, required: false, default: undefined });
}
shape[name] = schema;
}
return z.object(shape);
}
/**
* Generate default CRUD endpoints for a module if none are specified.
*/
export function defaultEndpoints(mod: ModuleDef): EndpointDef[] {
const base = `/${mod.name}`;
return [
{ method: 'GET', path: base, auth: 'user', custom: false, description: `List ${mod.name}` },
{
method: 'GET',
path: `${base}/:id`,
auth: 'user',
custom: false,
description: `Get ${mod.name} by ID`,
},
{ method: 'POST', path: base, auth: 'user', custom: false, description: `Create ${mod.name}` },
{
method: 'PATCH',
path: `${base}/:id`,
auth: 'user',
custom: false,
description: `Update ${mod.name}`,
},
{
method: 'DELETE',
path: `${base}/:id`,
auth: 'admin',
custom: false,
description: `Delete ${mod.name}`,
},
];
}