feat: align RU validation, error contract, and generator runtime templates
Wire DSL-derived field labels, safe API error JSON (string|string[]), decimal/enum DTO fixes, and client dataProvider without comma-splitting. Add generation/templates/runtime as canonical source copied on generate; extend AID bundle, prompts, validation gate, and docs.
This commit is contained in:
@@ -7,6 +7,14 @@ import { fileURLToPath } from 'node:url';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
/** Canonical runtime files for API errors + RA dataProvider; copied into server/ and client/ on each generate --apply. */
|
||||
const RUNTIME_TEMPLATE_DIR = path.join(__dirname, 'templates', 'runtime');
|
||||
const RUNTIME_TEMPLATE_FILES = [
|
||||
['api-exception.filter.ts', 'server/src/common/filters/api-exception.filter.ts'],
|
||||
['dataProvider.ts', 'client/src/dataProvider.ts'],
|
||||
['AppNotification.tsx', 'client/src/AppNotification.tsx'],
|
||||
['main.ts', 'server/src/main.ts'],
|
||||
];
|
||||
|
||||
function readFile(p) {
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
@@ -178,6 +186,7 @@ function getReferenceDisplayExpr(foreignEntity) {
|
||||
|
||||
function getAttributeLabel(attr, allEntities) {
|
||||
if (attr.label && attr.label !== attr.name) return attr.label;
|
||||
if (attr.name === 'id' && attr.type === 'uuid') return 'Идентификатор';
|
||||
if (attr.name === 'status') return 'Статус';
|
||||
if (attr.name === 'equipmentId') return 'Оборудование';
|
||||
if (attr.name === 'equipmentTypeCode') return 'Вид оборудования';
|
||||
@@ -260,6 +269,55 @@ function generatePrismaModel(name, entity, allEntities) {
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
/** Human-readable field labels for ValidationPipe messages (derived from DSL descriptions). */
|
||||
function renderFieldLabelsGenerated(parsed) {
|
||||
const { entities } = parsed;
|
||||
const map = new Map();
|
||||
for (const ent of Object.values(entities)) {
|
||||
for (const a of ent.attributes) {
|
||||
const label = getAttributeLabel(a, entities);
|
||||
const prev = map.get(a.name);
|
||||
if (prev === undefined) {
|
||||
map.set(a.name, label);
|
||||
} else if (String(label).length > String(prev).length) {
|
||||
map.set(a.name, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
const keys = [...map.keys()].sort();
|
||||
const lines = keys.map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(map.get(k))},`);
|
||||
return `/** AUTO-GENERATED from domain DSL (generation/generate.mjs). Do not edit by hand. */\nexport const FIELD_LABELS: Record<string, string> = {\n${lines.join('\n')}\n};\n`;
|
||||
}
|
||||
|
||||
function ensureFieldLabels(parsed, apply) {
|
||||
const rel = 'server/src/common/field-labels.generated.ts';
|
||||
const content = renderFieldLabelsGenerated(parsed);
|
||||
if (apply) writeFile(path.join(ROOT, rel), content);
|
||||
return { rel, content };
|
||||
}
|
||||
|
||||
function loadRuntimeTemplateFiles() {
|
||||
const out = {};
|
||||
for (const [name, rel] of RUNTIME_TEMPLATE_FILES) {
|
||||
const abs = path.join(RUNTIME_TEMPLATE_DIR, name);
|
||||
if (!fs.existsSync(abs)) {
|
||||
throw new Error(`Missing generator runtime template: ${abs}`);
|
||||
}
|
||||
out[rel] = readFile(abs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyRuntimeTemplateFiles(apply) {
|
||||
const files = loadRuntimeTemplateFiles();
|
||||
if (apply) {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
writeFile(path.join(ROOT, rel), content);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function ensurePrismaSchema({ enums, entities }, prismaPath, apply) {
|
||||
const existing = fs.existsSync(prismaPath) ? readFile(prismaPath) : '';
|
||||
const hasGenerator = /generator\s+client\s*\{/m.test(existing);
|
||||
@@ -279,7 +337,7 @@ function ensurePrismaSchema({ enums, entities }, prismaPath, apply) {
|
||||
return { changed: true, content: next };
|
||||
}
|
||||
|
||||
function renderBackendModule(entityName, entity, resourceName, pk) {
|
||||
function renderBackendModule(entityName, entity, resourceName, pk, enums) {
|
||||
const className = entityName;
|
||||
const moduleName = `${className}Module`;
|
||||
const serviceName = `${className}Service`;
|
||||
@@ -287,15 +345,6 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
||||
const folder = toKebab(entityName);
|
||||
|
||||
// DTOs
|
||||
const enumTypes = entity.attributes
|
||||
.filter((a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type))
|
||||
.map((a) => a.type);
|
||||
|
||||
const enumUnion = (typeName) => {
|
||||
// Unknown labels in DSL aren't needed; keep as string union to avoid importing Prisma enums.
|
||||
return `'${typeName}'`;
|
||||
};
|
||||
|
||||
const dtoType = (attr) => {
|
||||
switch (attr.type) {
|
||||
case 'uuid':
|
||||
@@ -305,7 +354,7 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
||||
case 'integer':
|
||||
return 'number';
|
||||
case 'decimal':
|
||||
return 'string';
|
||||
return 'number';
|
||||
case 'date':
|
||||
return 'string';
|
||||
default:
|
||||
@@ -314,23 +363,109 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
||||
}
|
||||
};
|
||||
|
||||
const getValidationDecorators = (attr, isUpdate) => {
|
||||
const decorators = [];
|
||||
const imports = new Set();
|
||||
let needsTypeImport = false;
|
||||
const field = attr.name;
|
||||
if (isUpdate) {
|
||||
decorators.push('@IsOptional()');
|
||||
imports.add('IsOptional');
|
||||
}
|
||||
|
||||
switch (attr.type) {
|
||||
case 'uuid':
|
||||
decorators.push(`@IsUUID(undefined, { message: '${field}: должно быть UUID' })`);
|
||||
imports.add('IsUUID');
|
||||
break;
|
||||
case 'string':
|
||||
case 'text':
|
||||
decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`);
|
||||
imports.add('IsString');
|
||||
break;
|
||||
case 'integer':
|
||||
decorators.push('@Type(() => Number)');
|
||||
decorators.push(`@IsInt({ message: '${field}: должно быть целым числом' })`);
|
||||
imports.add('IsInt');
|
||||
needsTypeImport = true;
|
||||
break;
|
||||
case 'decimal':
|
||||
decorators.push('@Type(() => Number)');
|
||||
decorators.push(
|
||||
`@IsNumber({ allowNaN: false, allowInfinity: false }, { message: '${field}: должно быть числом' })`,
|
||||
);
|
||||
imports.add('IsNumber');
|
||||
needsTypeImport = true;
|
||||
break;
|
||||
case 'date':
|
||||
decorators.push(`@IsISO8601({}, { message: '${field}: должно содержать корректную дату' })`);
|
||||
imports.add('IsISO8601');
|
||||
break;
|
||||
default: {
|
||||
const vals = enums?.[attr.type]?.values;
|
||||
if (vals && vals.length) {
|
||||
const list = vals.map((v) => `'${v}'`).join(', ');
|
||||
decorators.push(
|
||||
`@IsIn([${list}], { message: '${field}: недопустимое значение' })`,
|
||||
);
|
||||
imports.add('IsIn');
|
||||
} else {
|
||||
decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`);
|
||||
imports.add('IsString');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUpdate && attr.isRequired && !(attr.isPrimary && attr.type === 'uuid')) {
|
||||
decorators.push(`@IsNotEmpty({ message: '${field}: обязательное поле' })`);
|
||||
imports.add('IsNotEmpty');
|
||||
}
|
||||
return { decorators, imports, needsTypeImport };
|
||||
};
|
||||
|
||||
const createDecorators = new Set();
|
||||
const updateDecorators = new Set(['IsOptional']);
|
||||
let createNeedsTypeImport = false;
|
||||
let updateNeedsTypeImport = false;
|
||||
|
||||
const createDtoLines = [];
|
||||
createDtoLines.push(`export class Create${className}Dto {`);
|
||||
for (const a of entity.attributes) {
|
||||
if (a.isPrimary && a.type === 'uuid') continue; // generated
|
||||
const { decorators, imports, needsTypeImport } = getValidationDecorators(a, false);
|
||||
imports.forEach((i) => createDecorators.add(i));
|
||||
if (needsTypeImport) createNeedsTypeImport = true;
|
||||
decorators.forEach((d) => createDtoLines.push(` ${d}`));
|
||||
const opt = a.isRequired && !(a.isPrimary && a.type !== 'uuid') ? '!' : '?';
|
||||
createDtoLines.push(` ${a.name}${opt}: ${dtoType(a)};`);
|
||||
}
|
||||
createDtoLines.push('}');
|
||||
|
||||
const updateDtoLines = [];
|
||||
updateDtoLines.push(`export class Update${className}Dto {`);
|
||||
if (pk !== 'id') updateDtoLines.push(` id?: string;`);
|
||||
if (pk !== 'id') {
|
||||
updateDtoLines.push(' @IsOptional()');
|
||||
updateDtoLines.push(` @IsString({ message: 'id: должно быть строкой' })`);
|
||||
updateDtoLines.push(' id?: string;');
|
||||
updateDecorators.add('IsString');
|
||||
}
|
||||
for (const a of entity.attributes) {
|
||||
if (pk !== 'id' && a.name === 'id') continue;
|
||||
const { decorators, imports, needsTypeImport } = getValidationDecorators(a, true);
|
||||
imports.forEach((i) => updateDecorators.add(i));
|
||||
if (needsTypeImport) updateNeedsTypeImport = true;
|
||||
decorators.forEach((d) => updateDtoLines.push(` ${d}`));
|
||||
updateDtoLines.push(` ${a.name}?: ${dtoType(a)};`);
|
||||
}
|
||||
updateDtoLines.push('}');
|
||||
|
||||
const createImports = Array.from(createDecorators)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(', ');
|
||||
const updateImports = Array.from(updateDecorators)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(', ');
|
||||
const createDto = `import { ${createImports} } from 'class-validator';\n${createNeedsTypeImport ? "import { Type } from 'class-transformer';\n" : ''}\nexport class Create${className}Dto {\n${createDtoLines.join('\n')}\n}\n`;
|
||||
const updateDto = `import { ${updateImports} } from 'class-validator';\n${updateNeedsTypeImport ? "import { Type } from 'class-transformer';\n" : ''}\nexport class Update${className}Dto {\n${updateDtoLines.join('\n')}\n}\n`;
|
||||
|
||||
const controller = `import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';\nimport { Response } from 'express';\nimport { Roles } from '../../auth/decorators/roles.decorator';\nimport { RealmRole } from '../../auth/roles/realm-role.enum';\nimport { ${serviceName} } from './${folder}.service';\nimport { Create${className}Dto } from './dto/create-${folder}.dto';\nimport { Update${className}Dto } from './dto/update-${folder}.dto';\n\n@Controller('${resourceName}')\nexport class ${controllerName} {\n constructor(private readonly service: ${serviceName}) {}\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get()\n async findAll(@Query() query: any, @Res() res: Response) {\n const result = await this.service.findAll(query);\n res.set('Content-Range', \`${resourceName} \${query._start || 0}-\${query._end || result.total}/\${result.total}\`);\n res.set('Access-Control-Expose-Headers', 'Content-Range');\n return res.json(result.data);\n }\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get(':${pk}')\n findOne(@Param('${pk}') id: string) {\n return this.service.findOne(id);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Post()\n create(@Body() dto: Create${className}Dto) {\n return this.service.create(dto);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Patch(':${pk}')\n update(@Param('${pk}') id: string, @Body() dto: Update${className}Dto) {\n return this.service.update(id, dto);\n }\n\n @Roles(RealmRole.Admin)\n @Delete(':${pk}')\n remove(@Param('${pk}') id: string) {\n return this.service.remove(id);\n }\n}\n`;
|
||||
|
||||
@@ -393,8 +528,8 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
||||
[`server/src/modules/${folder}/${folder}.controller.ts`]: controller,
|
||||
[`server/src/modules/${folder}/${folder}.service.ts`]: serviceContent,
|
||||
[`server/src/modules/${folder}/${folder}.module.ts`]: mod,
|
||||
[`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDtoLines.join('\n') + '\n',
|
||||
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDtoLines.join('\n') + '\n',
|
||||
[`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDto,
|
||||
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDto,
|
||||
},
|
||||
moduleName,
|
||||
importPath: `./modules/${folder}/${folder}.module`,
|
||||
@@ -666,6 +801,22 @@ function ensureClientApp(apply, frontendResources) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!out.includes("from './AppNotification'")) {
|
||||
out = out.replace(
|
||||
/import authProvider from '\.\/auth\/authProvider';/,
|
||||
`import authProvider from './auth/authProvider';\nimport { AppNotification } from './AppNotification';`,
|
||||
);
|
||||
}
|
||||
if (!out.includes('notification={AppNotification}')) {
|
||||
out = out.replace(
|
||||
/<Admin dataProvider=\{dataProvider\} authProvider=\{authProvider\} requireAuth>/,
|
||||
'<Admin\n dataProvider={dataProvider}\n authProvider={authProvider}\n notification={AppNotification}\n requireAuth\n >',
|
||||
);
|
||||
out = out.replace(
|
||||
/(<Admin\n\s*dataProvider=\{dataProvider\}\n\s*authProvider=\{authProvider\})(\n\s*requireAuth)/,
|
||||
'$1\n notification={AppNotification}$2',
|
||||
);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
@@ -677,13 +828,23 @@ function collectGeneratedBundle(parsed) {
|
||||
const pr = ensurePrismaSchema(parsed, prismaPath, false);
|
||||
files['server/prisma/schema.prisma'] = pr.content;
|
||||
|
||||
const fieldLabels = ensureFieldLabels(parsed, false);
|
||||
files[fieldLabels.rel] = fieldLabels.content;
|
||||
|
||||
const backendModules = [];
|
||||
const frontendResources = [];
|
||||
for (const [entityName, ent] of Object.entries(parsed.entities)) {
|
||||
const pk = ent.primaryKey;
|
||||
const resource = pluralize(toKebab(entityName));
|
||||
const be = renderBackendModule(entityName, ent, resource, pk);
|
||||
const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums);
|
||||
const be = renderBackendModule(entityName, ent, resource, pk, parsed.enums);
|
||||
const fe = renderFrontendResource(
|
||||
entityName,
|
||||
ent,
|
||||
resource,
|
||||
pk,
|
||||
parsed.enums,
|
||||
parsed.entities,
|
||||
);
|
||||
backendModules.push(be);
|
||||
frontendResources.push(fe);
|
||||
Object.assign(files, be.files, fe.files);
|
||||
@@ -694,6 +855,8 @@ function collectGeneratedBundle(parsed) {
|
||||
const clientApp = ensureClientApp(false, frontendResources);
|
||||
files['client/src/App.tsx'] = clientApp.content;
|
||||
|
||||
Object.assign(files, loadRuntimeTemplateFiles());
|
||||
|
||||
return {
|
||||
entityCount: Object.keys(parsed.entities).length,
|
||||
enumCount: Object.keys(parsed.enums).length,
|
||||
@@ -721,6 +884,7 @@ function main() {
|
||||
// Prisma schema
|
||||
const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma');
|
||||
ensurePrismaSchema(parsed, prismaPath, apply);
|
||||
ensureFieldLabels(parsed, apply);
|
||||
|
||||
// Backend modules + frontend resources
|
||||
const backendModules = [];
|
||||
@@ -728,7 +892,7 @@ function main() {
|
||||
for (const [entityName, ent] of Object.entries(parsed.entities)) {
|
||||
const pk = ent.primaryKey;
|
||||
const resource = pluralize(toKebab(entityName));
|
||||
const be = renderBackendModule(entityName, ent, resource, pk);
|
||||
const be = renderBackendModule(entityName, ent, resource, pk, parsed.enums);
|
||||
const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums, parsed.entities);
|
||||
backendModules.push(be);
|
||||
frontendResources.push(fe);
|
||||
@@ -741,6 +905,7 @@ function main() {
|
||||
|
||||
ensureAppModule(apply, backendModules);
|
||||
ensureClientApp(apply, frontendResources);
|
||||
applyRuntimeTemplateFiles(apply);
|
||||
|
||||
process.stdout.write(
|
||||
`${apply ? 'Generated' : 'Planned'} ${Object.keys(parsed.entities).length} entities from ${dslPath}\n`
|
||||
|
||||
Reference in New Issue
Block a user