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:
parent
70635ba80e
commit
0f299231cc
118
pnpm-lock.yaml
generated
118
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
492
services/platform-service/src/lib/declarative-loader.test.ts
Normal file
492
services/platform-service/src/lib/declarative-loader.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
312
services/platform-service/src/lib/declarative-loader.ts
Normal file
312
services/platform-service/src/lib/declarative-loader.ts
Normal 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;
|
||||
}
|
||||
167
services/platform-service/src/lib/declarative-schema.ts
Normal file
167
services/platform-service/src/lib/declarative-schema.ts
Normal 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}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user