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:
time_
2026-03-29 14:36:10 +03:00
parent 79c9589658
commit 1cdd80f51b
37 changed files with 1272 additions and 247 deletions

View File

@@ -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`

View File

@@ -0,0 +1,16 @@
import { Notification, NotificationProps } from 'react-admin';
export const AppNotification = (props: NotificationProps) => (
<Notification
{...props}
sx={{
whiteSpace: 'pre-line',
'& .MuiAlert-message': {
whiteSpace: 'pre-line',
},
'& .MuiSnackbarContent-message': {
whiteSpace: 'pre-line',
},
}}
/>
);

View File

@@ -0,0 +1,184 @@
import {
ArgumentsHost,
BadRequestException,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Request, Response } from 'express';
type ErrorResponseBody = {
statusCode: number;
message: string | string[];
code: string;
details?: unknown;
path: string;
timestamp: string;
};
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ApiExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const mapped = this.mapException(exception);
const body: ErrorResponseBody = {
statusCode: mapped.statusCode,
message: mapped.message,
code: mapped.code,
...(mapped.details !== undefined ? { details: mapped.details } : {}),
path: request.url,
timestamp: new Date().toISOString(),
};
if (mapped.statusCode >= 500) {
const logDetail =
exception instanceof Error ? exception.message : String(mapped.message);
this.logger.error(
`Unhandled error on ${request.method} ${request.url}: ${logDetail}`,
exception instanceof Error ? exception.stack : undefined,
);
}
response.status(mapped.statusCode).json(body);
}
private mapException(exception: unknown): {
statusCode: number;
message: string | string[];
code: string;
details?: unknown;
} {
if (exception instanceof HttpException) {
const statusCode = exception.getStatus();
const payload = exception.getResponse() as
| string
| {
message?: string | string[];
error?: string;
code?: string;
details?: unknown;
};
if (typeof payload === 'string') {
return {
statusCode,
message: payload,
code: `HTTP_${statusCode}`,
};
}
const rawMessage = payload?.message ?? exception.message;
const message: string | string[] = Array.isArray(rawMessage)
? rawMessage.map((m) => String(m))
: typeof rawMessage === 'string' && rawMessage.length > 0
? rawMessage
: String(exception.message ?? 'Bad Request');
return {
statusCode,
message,
code: payload?.code ?? payload?.error ?? `HTTP_${statusCode}`,
details: payload?.details,
};
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
return this.mapPrismaKnownRequestError(exception);
}
if (exception instanceof Prisma.PrismaClientValidationError) {
return {
statusCode: HttpStatus.BAD_REQUEST,
message:
'Некорректные данные запроса. Проверьте обязательные поля и форматы значений.',
code: 'PRISMA_VALIDATION_ERROR',
};
}
if (exception instanceof Prisma.PrismaClientInitializationError) {
return {
statusCode: HttpStatus.SERVICE_UNAVAILABLE,
message: 'Сервис базы данных временно недоступен.',
code: 'DATABASE_UNAVAILABLE',
};
}
if (exception instanceof Prisma.PrismaClientRustPanicError) {
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Внутренняя ошибка сервера.',
code: 'DATABASE_ENGINE_PANIC',
};
}
if (exception instanceof Error) {
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Внутренняя ошибка сервера.',
code: 'INTERNAL_ERROR',
};
}
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Внутренняя ошибка сервера.',
code: 'INTERNAL_ERROR',
};
}
private mapPrismaKnownRequestError(
exception: Prisma.PrismaClientKnownRequestError,
): {
statusCode: number;
message: string;
code: string;
details?: unknown;
} {
switch (exception.code) {
case 'P2002': {
const target = Array.isArray(exception.meta?.target)
? exception.meta?.target.join(', ')
: String(exception.meta?.target ?? '');
return {
statusCode: HttpStatus.CONFLICT,
message: target
? `Запись с таким значением уже существует (${target}).`
: 'Запись с таким значением уже существует.',
code: 'UNIQUE_CONSTRAINT_VIOLATION',
details: exception.meta,
};
}
case 'P2003':
return {
statusCode: HttpStatus.CONFLICT,
message:
'Операцию нельзя выполнить из-за связанных данных или некорректной ссылки.',
code: 'FOREIGN_KEY_CONSTRAINT_VIOLATION',
details: exception.meta,
};
case 'P2025':
return {
statusCode: HttpStatus.NOT_FOUND,
message: 'Запись не найдена.',
code: 'RECORD_NOT_FOUND',
details: exception.meta,
};
default:
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Ошибка при обработке данных в базе.',
code: exception.code,
details: exception.meta,
};
}
}
}

View File

@@ -0,0 +1,185 @@
import { DataProvider, fetchUtils, HttpError } from 'react-admin';
import { getValidAccessToken } from './auth/keycloak';
import { env } from './config/env';
const apiUrl = env.apiUrl;
/** HTTP status from fetch / react-admin error objects (avoid coupling the client to Nest). */
type HttpStatusCode = number;
/** JSON body shape returned by ApiExceptionFilter (and compatible Nest errors). */
type ApiErrorBody = {
message?: string | string[];
code?: string;
details?: unknown;
};
/** Shape thrown by fetchUtils.fetchJson on non-2xx responses. */
type FetchJsonError = {
status?: HttpStatusCode;
body?: ApiErrorBody;
message?: string;
};
function userMessageFromApiBody(
body: ApiErrorBody | undefined,
fallback: string,
): string {
const raw = body?.message;
if (Array.isArray(raw)) return raw.join('\n');
if (typeof raw === 'string') return raw;
return fallback;
}
const httpClient = async (url: string, options: fetchUtils.Options = {}) => {
const token = await getValidAccessToken();
const headers = new Headers(options.headers ?? { Accept: 'application/json' });
headers.set('Authorization', `Bearer ${token}`);
try {
return await fetchUtils.fetchJson(url, {
...options,
headers,
});
} catch (error: unknown) {
const fetchError = error as FetchJsonError;
const fromPayload = userMessageFromApiBody(fetchError.body, '');
const fallbackMessage = fetchError.message || 'Request failed';
throw new HttpError(
fromPayload || fallbackMessage,
fetchError.status ?? 500,
fetchError.body,
);
}
};
function buildQueryString(query: Record<string, unknown>) {
const search = new URLSearchParams();
Object.entries(query).forEach(([key, val]) => {
if (val === undefined || val === null || val === '') return;
if (Array.isArray(val)) {
val.forEach((v) => {
if (v === undefined || v === null || v === '') return;
search.append(key, String(v));
});
return;
}
search.set(key, String(val));
});
return search.toString();
}
const dataProvider: DataProvider = {
getList: async (resource, params) => {
const { page, perPage } = params.pagination!;
const { field, order } = params.sort!;
const start = (page - 1) * perPage;
const end = page * perPage;
const query: Record<string, unknown> = {
_start: start,
_end: end,
_sort: field,
_order: order,
...(params.filter ?? {}),
};
const queryString = buildQueryString(query);
const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url);
const contentRange = headers.get('Content-Range');
const total = contentRange
? parseInt(contentRange.split('/').pop() || '0', 10)
: json.length;
return { data: json, total };
},
getOne: async (resource, params) => {
const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`);
return { data: json };
},
getMany: async (resource, params) => {
const query = params.ids.map((id) => `id=${id}`).join('&');
const { json } = await httpClient(`${apiUrl}/${resource}?${query}`);
return { data: json };
},
getManyReference: async (resource, params) => {
const { page, perPage } = params.pagination!;
const { field, order } = params.sort!;
const start = (page - 1) * perPage;
const end = page * perPage;
const query: Record<string, unknown> = {
_start: start,
_end: end,
_sort: field,
_order: order,
[params.target]: params.id,
...(params.filter ?? {}),
};
const queryString = buildQueryString(query);
const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url);
const contentRange = headers.get('Content-Range');
const total = contentRange
? parseInt(contentRange.split('/').pop() || '0', 10)
: json.length;
return { data: json, total };
},
create: async (resource, params) => {
const { json } = await httpClient(`${apiUrl}/${resource}`, {
method: 'POST',
body: JSON.stringify(params.data),
});
return { data: json };
},
update: async (resource, params) => {
const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`, {
method: 'PATCH',
body: JSON.stringify(params.data),
});
return { data: json };
},
updateMany: async (resource, params) => {
const responses = await Promise.all(
params.ids.map((id) =>
httpClient(`${apiUrl}/${resource}/${id}`, {
method: 'PATCH',
body: JSON.stringify(params.data),
})
)
);
return { data: responses.map(({ json }) => json.id) };
},
delete: async (resource, params) => {
const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`, {
method: 'DELETE',
});
return { data: json };
},
deleteMany: async (resource, params) => {
const responses = await Promise.all(
params.ids.map((id) =>
httpClient(`${apiUrl}/${resource}/${id}`, {
method: 'DELETE',
})
)
);
return { data: responses.map(({ json }) => json.id) };
},
};
export default dataProvider;

View File

@@ -0,0 +1,111 @@
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import {
BadRequestException,
ValidationError,
ValidationPipe,
} from '@nestjs/common';
import { AppModule } from './app.module';
import { RuntimeEnvironment } from './config/env.validation';
import { ApiExceptionFilter } from './common/filters/api-exception.filter';
import { FIELD_LABELS } from './common/field-labels.generated';
function prettifyFieldName(field: string): string {
if (FIELD_LABELS[field]) return FIELD_LABELS[field];
const withSpaces = field
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.trim();
if (!withSpaces) return field;
return withSpaces[0].toUpperCase() + withSpaces.slice(1);
}
function constraintToRuMessage(field: string, constraint: string): string {
const label = prettifyFieldName(field);
switch (constraint) {
case 'isNotEmpty':
return `Поле "${label}" обязательно`;
case 'isString':
return `Поле "${label}" должно быть строкой`;
case 'isInt':
return `Поле "${label}" должно быть целым числом`;
case 'isUUID':
return `Поле "${label}" должно быть UUID`;
case 'isNumberString':
return `Поле "${label}" должно быть числом`;
case 'isNumber':
return `Поле "${label}" должно быть числом`;
case 'isIso8601':
return `Поле "${label}" должно содержать корректную дату`;
case 'isEnum':
case 'isIn':
return `Поле "${label}" содержит недопустимое значение`;
default:
return `Поле "${label}" заполнено некорректно`;
}
}
function buildValidationMessages(errors: ValidationError[]): string[] {
const messages: string[] = [];
const walk = (errorList: ValidationError[]) => {
for (const error of errorList) {
if (error.constraints) {
const constraints = Object.keys(error.constraints);
// If field is empty, "required" is enough; skip type noise.
const filtered = constraints.includes('isNotEmpty')
? ['isNotEmpty']
: constraints;
filtered.forEach((constraint) =>
messages.push(constraintToRuMessage(error.property, constraint)),
);
}
if (error.children?.length) walk(error.children);
}
};
walk(errors);
return Array.from(new Set(messages));
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get<ConfigService<RuntimeEnvironment, true>>(
ConfigService,
);
const allowedOrigins = configService
.getOrThrow('CORS_ALLOWED_ORIGINS')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.enableCors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(new Error(`Origin ${origin} is not allowed by CORS`), false);
},
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type'],
exposedHeaders: ['Content-Range'],
credentials: false,
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidUnknownValues: false,
exceptionFactory: (errors) =>
new BadRequestException(buildValidationMessages(errors)),
}),
);
app.useGlobalFilters(new ApiExceptionFilter());
const port = configService.get('PORT', 3000);
await app.listen(port);
}
bootstrap();