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:
16
generation/templates/runtime/AppNotification.tsx
Normal file
16
generation/templates/runtime/AppNotification.tsx
Normal 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',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
184
generation/templates/runtime/api-exception.filter.ts
Normal file
184
generation/templates/runtime/api-exception.filter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
185
generation/templates/runtime/dataProvider.ts
Normal file
185
generation/templates/runtime/dataProvider.ts
Normal 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;
|
||||
111
generation/templates/runtime/main.ts
Normal file
111
generation/templates/runtime/main.ts
Normal 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();
|
||||
Reference in New Issue
Block a user