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)
168 lines
5.7 KiB
TypeScript
168 lines
5.7 KiB
TypeScript
/**
|
|
* 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}`,
|
|
},
|
|
];
|
|
}
|