Предыдущая статья: Получение серверного времени через WebSockets и отображение его в Angular-приложении
В этом посте я добавлю в NestJS
-приложении валидацию REST
-запросов и их отображение в формах Angular
-приложения.
1. Устанавливаем новый генератор кода
Устанавливаем новый генератор DTO
из Prisma
-схемы и удаляем старый, так как в старом нет добавления декораторов валидации с помощью class-validator
.
Команды
npm install --save-dev @brakebein/prisma-generator-nestjs-dto@1.24.0-beta5 npm uninstall --save-dev prisma-class-generator
Удаляем старый сгенерированный код и создаем новый.
Команды
rm -rf libs/feature/webhook/src/lib/generated rm -rf apps/server/src/app/generated npm run prisma:generate
2. Создаем NestJS-модуль для хранения кода необходимого при валидации
Команды
./node_modules/.bin/nx g @nestjs-mod/schematics:library validation --buildable --publishable --directory=libs/core/validation --simpleName=true --projectNameAndRootFormat=as-provided --strict=true
Вывод консоли
$ ./node_modules/.bin/nx g @nestjs-mod/schematics:library validation --buildable --publishable --directory=libs/core/validation --simpleName=true --projectNameAndRootFormat=as-provided --strict=true NX Generating @nestjs-mod/schematics:library CREATE libs/core/validation/tsconfig.json CREATE libs/core/validation/src/index.ts CREATE libs/core/validation/tsconfig.lib.json CREATE libs/core/validation/README.md CREATE libs/core/validation/package.json CREATE libs/core/validation/project.json CREATE libs/core/validation/.eslintrc.json CREATE libs/core/validation/jest.config.ts CREATE libs/core/validation/tsconfig.spec.json UPDATE tsconfig.base.json CREATE libs/core/validation/src/lib/validation.configuration.ts CREATE libs/core/validation/src/lib/validation.constants.ts CREATE libs/core/validation/src/lib/validation.environments.ts CREATE libs/core/validation/src/lib/validation.module.ts
Переменные окружения модуля
Добавляем переменную окружения для включения и выключения глобальной проверки входных данных.
Так как поля в базе данных имеют собственную валидацию и если мы хотим проверить корректность валидации на уровне базы данных, то проверка входных в бэкенд данных не даст нам это сделать, для такой проверки при разработке и тестировании функционала нужно уметь временно отключать валидацию на входе в бэкенд.
Модуль идет вместе с встроенным фильтром для корректного отображения ошибок, если вам нужно кастомизировать его, вы можете создать свой вариант и при этом отключить встроенный в модуль фильтр.
Обновляем файл libs/core/validation/src/lib/validation.environments.ts
import { BooleanTransformer, EnvModel, EnvModelProperty } from '@nestjs-mod/common'; @EnvModel() export class ValidationEnvironments { @EnvModelProperty({ description: 'Use pipes.', transform: new BooleanTransformer(), default: true, hidden: true, }) usePipes?: boolean; @EnvModelProperty({ description: 'Use filters.', transform: new BooleanTransformer(), default: true, hidden: true, }) useFilters?: boolean; }
Пример переменных окружения:
Key |
Description |
Sources |
Constraints |
Default |
Value |
---|---|---|---|---|---|
|
Use pipes. |
|
optional |
|
|
|
Use filters. |
|
optional |
|
|
Конфигурация модуля
В данный момент тут только один параметр, это конфигурация для создания ValidationPipe
.
Обновляем файл libs/core/validation/src/lib/validation.configuration.ts
import { ConfigModel, ConfigModelProperty } from '@nestjs-mod/common'; import { ValidationPipeOptions } from '@nestjs/common'; @ConfigModel() export class ValidationConfiguration { @ConfigModelProperty({ description: 'Validation pipe options', }) pipeOptions?: ValidationPipeOptions; }
Класс с ошибками модуля
Так как на данном этапе проект разрабатывается в видеREST
-бэкенда, который доступен на фронтенде в виде OpenApi
-библиотеки, то класс с ошибками также публикуется в Swagger
-схему.
Для того чтобы описание ошибки было более подробным в нем используется декораторы добавляющие мета информацию которая будет выведена в Swagger
-схему.
Создаем файл libs/core/validation/src/lib/validation.errors.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ValidationError as CvValidationError } from 'class-validator'; export enum ValidationErrorEnum { COMMON = 'VALIDATION-000', } export const VALIDATION_ERROR_ENUM_TITLES: Record<ValidationErrorEnum, string> = { [ValidationErrorEnum.COMMON]: 'Validation error', }; export class ValidationErrorMetadataConstraint { @ApiProperty({ type: String, }) name!: string; @ApiProperty({ type: String, }) description!: string; constructor(options?: ValidationErrorMetadataConstraint) { Object.assign(this, options); } } export class ValidationErrorMetadata { @ApiProperty({ type: String, }) property!: string; @ApiProperty({ type: () => ValidationErrorMetadataConstraint, isArray: true, }) constraints!: ValidationErrorMetadataConstraint[]; @ApiPropertyOptional({ type: () => ValidationErrorMetadata, isArray: true, }) children?: ValidationErrorMetadata[]; constructor(options?: ValidationErrorMetadata) { Object.assign(this, options); } static fromClassValidatorValidationErrors(errors?: CvValidationError[]): ValidationErrorMetadata[] | undefined { return errors?.map( (error) => new ValidationErrorMetadata({ property: error.property, constraints: Object.entries(error.constraints || {}).map( ([key, value]) => new ValidationErrorMetadataConstraint({ name: key, description: value, }) ), ...(error.children?.length ? { children: this.fromClassValidatorValidationErrors(error.children), } : {}), }) ); } } export class ValidationError extends Error { @ApiProperty({ type: String, description: Object.entries(VALIDATION_ERROR_ENUM_TITLES) .map(([key, value]) => `${value} (${key})`) .join(', '), example: VALIDATION_ERROR_ENUM_TITLES[ValidationErrorEnum.COMMON], }) override message: string; @ApiProperty({ enum: ValidationErrorEnum, enumName: 'ValidationErrorEnum', example: ValidationErrorEnum.COMMON, }) code = ValidationErrorEnum.COMMON; @ApiPropertyOptional({ type: ValidationErrorMetadata, isArray: true }) metadata?: ValidationErrorMetadata[]; constructor(message?: string | ValidationErrorEnum, code?: ValidationErrorEnum, metadata?: CvValidationError[]) { const messageAsCode = Boolean(message && Object.values(ValidationErrorEnum).includes(message as ValidationErrorEnum)); const preparedCode = messageAsCode ? (message as ValidationErrorEnum) : code; const preparedMessage = preparedCode ? VALIDATION_ERROR_ENUM_TITLES[preparedCode] : message; code = preparedCode || ValidationErrorEnum.COMMON; message = preparedMessage || VALIDATION_ERROR_ENUM_TITLES[code]; super(message); this.code = code; this.message = message; this.metadata = ValidationErrorMetadata.fromClassValidatorValidationErrors(metadata); } }
Фильтр для ошибок модуля
Для преобразования ошибок модуля в Http
-ошибку создаем ValidationExceptionsFilter
.
Создаем файл libs/core/validation/src/lib/validation.filter.ts
import { ArgumentsHost, Catch, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { BaseExceptionFilter } from '@nestjs/core'; import { ValidationError } from './validation.errors'; @Catch(ValidationError) export class ValidationExceptionsFilter extends BaseExceptionFilter { private logger = new Logger(ValidationExceptionsFilter.name); override catch(exception: ValidationError, host: ArgumentsHost) { if (exception instanceof ValidationError) { this.logger.error(exception, exception.stack); super.catch( new HttpException( { code: exception.code, message: exception.message, metadata: exception.metadata, }, HttpStatus.BAD_REQUEST ), host ); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any this.logger.error(exception, (exception as any)?.stack); super.catch(exception, host); } } }
NestJS-mod модуль
Создаем файл libs/core/validation/src/lib/validation.module.ts
import { createNestModule, getFeatureDotEnvPropertyNameFormatter, NestModuleCategory } from '@nestjs-mod/common'; import { Provider, ValidationPipe } from '@nestjs/common'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { ValidationConfiguration } from './validation.configuration'; import { VALIDATION_FEATURE, VALIDATION_MODULE } from './validation.constants'; import { ValidationEnvironments } from './validation.environments'; import { ValidationExceptionsFilter } from './validation.filter'; import { ValidationError, ValidationErrorEnum } from './validation.errors'; export const { ValidationModule } = createNestModule({ moduleName: VALIDATION_MODULE, moduleCategory: NestModuleCategory.feature, configurationModel: ValidationConfiguration, staticEnvironmentsModel: ValidationEnvironments, providers: ({ staticEnvironments }) => { const providers: Provider[] = []; if (staticEnvironments.usePipes) { providers.push({ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true, validationError: { target: false, value: false, }, exceptionFactory: (errors) => new ValidationError(ValidationErrorEnum.COMMON, undefined, errors), }), }); } if (staticEnvironments.useFilters) { providers.push({ provide: APP_FILTER, useClass: ValidationExceptionsFilter, }); } return providers; }, wrapForRootAsync: (asyncModuleOptions) => { if (!asyncModuleOptions) { asyncModuleOptions = {}; } const FomatterClass = getFeatureDotEnvPropertyNameFormatter(VALIDATION_FEATURE); Object.assign(asyncModuleOptions, { environmentsOptions: { propertyNameFormatters: [new FomatterClass()], name: VALIDATION_FEATURE, }, }); return { asyncModuleOptions }; }, });
3. По всему проекту исправляем типы, так как новый генератор создает типы с другими названиями и содержимым
Запускаем перегенерацию всех СДК и другого дополнительного кода, при генерации будут отображаться ошибки типов, необходимо все исправить и повторно запустить перегенерацию и так до тех пор пока все ошибки не будут исправлены.
Команды
npm run manual:prepare
4. В форме создания и редактирования веб-хуков отображаем серверные ошибки
Для отображения ошибок в Formly
-форме используем динамическое создание валидаторов которые всегда возвращают ошибку и в текст ошибки пишем то что мы получили с бэкенда.
Пример серверного ответа с ошибками:
{ "code": "VALIDATION-000", "message": "Validation error", "metadata": [ { "property": "eventName", "constraints": [ { "name": "isNotEmpty", "description": "eventName should not be empty" } ] }, { "property": "endpoint", "constraints": [ { "name": "isNotEmpty", "description": "endpoint should not be empty" } ] } ] }
Обновляем файл libs/feature/webhook-angular/src/lib/forms/webhook-form/webhook-form.component.ts
import { //... ValidationErrorEnumInterface, ValidationErrorInterface, ValidationErrorMetadataInterface, } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; //... @UntilDestroy() @Component({ standalone: true, imports: [FormlyModule, NzFormModule, NzInputModule, NzButtonModule, FormsModule, ReactiveFormsModule, AsyncPipe], selector: 'webhook-form', templateUrl: './webhook-form.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class WebhookFormComponent implements OnInit { //... constructor( @Optional() @Inject(NZ_MODAL_DATA) private readonly nzModalData: WebhookFormComponent, private readonly webhookService: WebhookService, private readonly webhookEventsService: WebhookEventsService, private readonly nzMessageService: NzMessageService ) {} //... createOne() { return this.webhookService.createOne(this.toJson(this.form.value)).pipe(catchError((err) => this.catchAndProcessServerError(err))); } updateOne() { if (!this.id) { throw new Error('id not set'); } return this.webhookService.updateOne(this.id, this.toJson(this.form.value)).pipe(catchError((err) => this.catchAndProcessServerError(err))); } private setFormlyFields(errors?: ValidationErrorMetadataInterface[]) { this.formlyFields$.next( this.appendServerErrorsAsValidatorsToFields( [ //... { key: 'requestTimeout', type: 'input', validation: { show: true, }, props: { type: 'number', label: `webhook.form.requestTimeout`, placeholder: 'requestTimeout', required: false, }, }, ], errors ) ); } private appendServerErrorsAsValidatorsToFields(fields: FormlyFieldConfig[], errors?: ValidationErrorMetadataInterface[]) { return (fields || []).map((f: FormlyFieldConfig) => { const error = errors?.find((e) => e.property === f.key); if (error) { f.validators = Object.fromEntries( error.constraints.map((c) => { return [ c.name === 'isNotEmpty' ? 'required' : c.name, { expression: () => false, message: () => c.description, }, ]; }) ); } return f; }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any private catchAndProcessServerError(err: any) { const error = err.error as ValidationErrorInterface; if (error.code.includes(ValidationErrorEnumInterface.VALIDATION_000)) { this.setFormlyFields(error.metadata); return of(null); } return throwError(() => err); } //... }
5. Создаем серверный E2E-тест для проверки работы валидации
Создаем файл apps/server-e2e/src/server/validation.spec.ts
import { RestClientHelper } from '@nestjs-mod-fullstack/testing'; import { AxiosError } from 'axios'; describe('Validation', () => { jest.setTimeout(60000); const user1 = new RestClientHelper(); beforeAll(async () => { await user1.createAndLoginAsUser(); }); it('should catch error on create new webhook as user1', async () => { try { await user1.getWebhookApi().webhookControllerCreateOne({ enabled: false, endpoint: '', eventName: '', }); } catch (err) { expect((err as AxiosError).response?.data).toEqual({ code: 'VALIDATION-000', message: 'Validation error', metadata: [ { property: 'eventName', constraints: [ { name: 'isNotEmpty', description: 'eventName should not be empty', }, ], }, { property: 'endpoint', constraints: [ { name: 'isNotEmpty', description: 'endpoint should not be empty', }, ], }, ], }); } }); });
6. Создаем клиентский E2E-тест для проверки работы валидации
Создаем файл apps/client-e2e/src/validation.spec.ts
import { faker } from '@faker-js/faker'; import { expect, Page, test } from '@playwright/test'; import { get } from 'env-var'; import { join } from 'path'; import { setTimeout } from 'timers/promises'; test.describe('Validation', () => { test.describe.configure({ mode: 'serial' }); const user = { email: faker.internet.email({ provider: 'example.fakerjs.dev', }), password: faker.internet.password({ length: 8 }), site: `http://${faker.internet.domainName()}`, }; let page: Page; test.beforeAll(async ({ browser }) => { page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, recordVideo: { dir: join(__dirname, 'video'), size: { width: 1920, height: 1080 }, }, }); await page.goto('/', { timeout: 7000, }); await page.evaluate((authorizerURL) => localStorage.setItem('authorizerURL', authorizerURL), get('SERVER_AUTHORIZER_URL').required().asString()); await page.evaluate((minioURL) => localStorage.setItem('minioURL', minioURL), get('SERVER_MINIO_URL').required().asString()); }); test.afterAll(async () => { await setTimeout(1000); await page.close(); }); test('sign up as user', async () => { await page.goto('/sign-up', { timeout: 7000, }); await page.locator('auth-sign-up-form').locator('[placeholder=email]').click(); await page.keyboard.type(user.email.toLowerCase(), { delay: 50, }); await expect(page.locator('auth-sign-up-form').locator('[placeholder=email]')).toHaveValue(user.email.toLowerCase()); await page.locator('auth-sign-up-form').locator('[placeholder=password]').click(); await page.keyboard.type(user.password, { delay: 50, }); await expect(page.locator('auth-sign-up-form').locator('[placeholder=password]')).toHaveValue(user.password); await page.locator('auth-sign-up-form').locator('[placeholder=confirm_password]').click(); await page.keyboard.type(user.password, { delay: 50, }); await expect(page.locator('auth-sign-up-form').locator('[placeholder=confirm_password]')).toHaveValue(user.password); await expect(page.locator('auth-sign-up-form').locator('button[type=submit]')).toHaveText('Sign-up'); await page.locator('auth-sign-up-form').locator('button[type=submit]').click(); await setTimeout(5000); await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`You are logged in as ${user.email.toLowerCase()}`); }); test('should catch error on create new webhook', async () => { await page.locator('webhook-grid').locator('button').first().click(); await setTimeout(7000); await page.locator('[nz-modal-footer]').locator('button').last().click(); await setTimeout(4000); await expect(page.locator('webhook-form').locator('formly-validation-message').first()).toContainText('endpoint should not be empty'); await expect(page.locator('webhook-form').locator('formly-validation-message').last()).toContainText('eventName should not be empty'); }); });
7. Запускаем инфраструктуру с приложениями в режиме разработки и проверяем работу через E2E-тесты
Команды
npm run pm2-full:dev:start npm run pm2-full:dev:test:e2e
Заключение
В текущем посте я добавил модуль в бэкенд для включения сериализации и валидации входных REST
-данных.
Код на фронте не унифицирован и внедрен конкретно в одной форме, при большом количестве форм с такими обработками, необходимо будет вынести общий код в отдельный файл.
Выбранный мною способ создания динамических ошибок для Formly
-форм на основе ответов с бэкенда, может показаться не очень красивым решением, но я не смог придумать более простого и рабочего решения, возможно по мере развития проекта появится иной способ.
Планы
В следующем посте я добавлю поддержку нескольких языков для бэкенд и фронтенд приложений…
Ссылки
-
https://nestjs.com — официальный сайт фреймворка
-
https://nestjs-mod.com — официальный сайт дополнительных утилит
-
https://fullstack.nestjs-mod.com — сайт из поста
-
https://github.com/nestjs-mod/nestjs-mod-fullstack — проект из поста
ссылка на оригинал статьи https://habr.com/ru/articles/863396/
Добавить комментарий