From e453a032355ad85253dba4110c190c025247478d Mon Sep 17 00:00:00 2001 From: Mikhail Yarmaliuk Date: Thu, 16 Mar 2023 12:48:11 +0100 Subject: [PATCH] feat: endpoint service support validation nested object --- __helpers__/root-hooks.ts | 6 +++- __mocks__/entities/test-entity.ts | 16 ++++++++- __tests__/services/endpoint-test.ts | 51 ++++++++++++++++++++++++++++- package-lock.json | 15 ++++----- package.json | 2 +- src/services/endpoint.ts | 16 ++++++--- 6 files changed, 89 insertions(+), 17 deletions(-) diff --git a/__helpers__/root-hooks.ts b/__helpers__/root-hooks.ts index 9de4bc4..fe38f16 100644 --- a/__helpers__/root-hooks.ts +++ b/__helpers__/root-hooks.ts @@ -11,11 +11,15 @@ import RemoteConfig from '@services/remote-config'; */ export const mochaHooks = { beforeAll(): void { - sinon.stub(console, 'info'); Log.configure({ silent: true }); Log.transports.find((transport) => Log.remove(transport)); RemoteConfig.init(Microservice.create(), { isOffline: true, msConfigName: '', msName: '' }); }, + beforeEach(): void { + if (!console.info?.['resetHistory']) { + sinon.stub(console, 'info'); + } + }, afterAll(): void { sinon.restore(); }, diff --git a/__mocks__/entities/test-entity.ts b/__mocks__/entities/test-entity.ts index 720084e..3ebffdd 100644 --- a/__mocks__/entities/test-entity.ts +++ b/__mocks__/entities/test-entity.ts @@ -1,5 +1,13 @@ -import { Allow, Length } from 'class-validator'; +import { Type } from 'class-transformer'; +import { Allow, IsObject, Length, ValidateNested } from 'class-validator'; import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import IsUndefinable from '@validators/is-undefinable'; + +class NestedEntity { + @Length(2, 5) + @IsUndefinable() + hello?: string; +} @Entity() class TestEntity { @@ -10,6 +18,12 @@ class TestEntity { @Column() @Length(1, 50) param: string; + + @Type(() => NestedEntity) + @IsObject() + @ValidateNested() + @IsUndefinable() + nested?: NestedEntity; } export default TestEntity; diff --git a/__tests__/services/endpoint-test.ts b/__tests__/services/endpoint-test.ts index 838dc5f..332692d 100644 --- a/__tests__/services/endpoint-test.ts +++ b/__tests__/services/endpoint-test.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/unbound-method,sonarjs/no-duplicate-string */ import TypeormJsonQuery from '@lomray/typeorm-json-query'; import { expect } from 'chai'; import sinon from 'sinon'; @@ -339,6 +339,25 @@ describe('services/endpoint', () => { } }); + it('handler - should throw error if nested entity not valid: validation failed', async () => { + try { + await Endpoint.defaultHandler.create({ + fields: { + param: 'param', + nested: { + hello: '1', + }, + }, + repository, + }); + + expect(shouldNotCall).to.be.undefined; + } catch (e) { + expect(e.payload.length).to.equal(1); + expect(e.payload[0].property).to.equal('nested'); + } + }); + it('handler - should success create entity', async () => { const fields = { param: 'test' }; @@ -690,6 +709,23 @@ describe('services/endpoint', () => { } }); + it('handler - should throw error: nested validation failed', async () => { + TypeormMock.queryBuilder.getMany.resolves([repository.create(entity)]); + + try { + await Endpoint.defaultHandler.update( + repository.createQueryBuilder().where('id = 1'), + { param: 'param', nested: { hello: '1' } }, + repository, + ); + + expect(shouldNotCall).to.be.undefined; + } catch (e) { + expect(e.payload.length).to.equal(1); + expect(e.payload[0].property).to.equal('nested'); + } + }); + it('handler - should throw error: unknown error', async () => { TypeormMock.entityManager.save.rejects(new Error('Unknown')); TypeormMock.queryBuilder.getMany.resolves([repository.create(entity)]); @@ -1115,6 +1151,19 @@ describe('services/endpoint', () => { expect(await waitResult(result)).to.throw('Invalid request params'); }); + it('should throw error: nested invalid params', async () => { + const params = { id: 1, param: 'param', nested: { hello: '1' } }; + const defaultHandler = sandbox.stub(); + const customHandler = Endpoint.custom( + () => ({ output: {}, input: TestEntity }), + defaultHandler, + ); + + const result = customHandler(params, endpointOptions); + + expect(await waitResult(result)).to.throw('Invalid request params'); + }); + it('should return custom handler metadata', () => { const description = 'custom description for endpoint'; const customHandler = Endpoint.custom( diff --git a/package-lock.json b/package-lock.json index 4b20744..bd8cc2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@opentelemetry/resources": "^1.9.1", "@opentelemetry/sdk-node": "^0.35.1", "@opentelemetry/semantic-conventions": "^1.9.1", - "class-transformer": "0.4.1", + "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "class-validator-jsonschema": "^5.0.0", "klona": "^2.0.6", @@ -4178,10 +4178,9 @@ } }, "node_modules/class-transformer": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.1.tgz", - "integrity": "sha512-mbBtth1BFa+pN2fmx6/NmMNxxyu9Mw9rx3rzKWBH7yoG+bfSoJOnEJ3qmB6yEKvoO502zUxSV2AqN7EUypC2Tg==", - "deprecated": "This version contains an important fix for a security vulnerability but accidentally was released with an unrelated API breaking change. To respect SemVer it is deprecated in favor of 0.5.0. Please update as soon as possible." + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "node_modules/class-validator": { "version": "0.14.0", @@ -19765,9 +19764,9 @@ } }, "class-transformer": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.1.tgz", - "integrity": "sha512-mbBtth1BFa+pN2fmx6/NmMNxxyu9Mw9rx3rzKWBH7yoG+bfSoJOnEJ3qmB6yEKvoO502zUxSV2AqN7EUypC2Tg==" + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "class-validator": { "version": "0.14.0", diff --git a/package.json b/package.json index 387042e..636ba64 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@opentelemetry/resources": "^1.9.1", "@opentelemetry/sdk-node": "^0.35.1", "@opentelemetry/semantic-conventions": "^1.9.1", - "class-transformer": "0.4.1", + "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "class-validator-jsonschema": "^5.0.0", "klona": "^2.0.6", diff --git a/src/services/endpoint.ts b/src/services/endpoint.ts index 0f0dc9e..69c98af 100644 --- a/src/services/endpoint.ts +++ b/src/services/endpoint.ts @@ -4,7 +4,7 @@ import { BaseException } from '@lomray/microservice-nodejs-lib'; import type { ObjectLiteral, IJsonQuery } from '@lomray/microservices-types'; import type { ITypeormJsonQueryOptions } from '@lomray/typeorm-json-query'; import TypeormJsonQuery from '@lomray/typeorm-json-query'; -import { Type } from 'class-transformer'; +import { Type, plainToInstance } from 'class-transformer'; import { IsArray, IsBoolean, IsNumber, IsObject, validate } from 'class-validator'; import { JSONSchema } from 'class-validator-jsonschema'; import { DeleteResult } from 'typeorm'; @@ -658,7 +658,7 @@ const createDefaultHandler = async ({ } const entities: (TEntity & Partial)[] = entitiesAttributes.map((attributes) => - Object.assign(repository.create(), attributes), + plainToInstance(repository.target as Constructable, attributes), ); const errors = await Promise.all( entities.map((entity) => @@ -769,7 +769,13 @@ const updateDefaultHandler = async ( } const { entity } = await viewDefaultHandler(query); - const result = Object.assign(entity, fields); + const result = plainToInstance( + (entity as ObjectLiteral).constructor as Constructable>, + { + ...entity, + ...fields, + }, + ); const errors = await validate(result, { whitelist: true, forbidNonWhitelisted: true, @@ -1495,7 +1501,7 @@ class Endpoint { if (typeof input === 'function') { const errors = await validate( - Object.assign(new (input as Constructable)() as Record, params), + plainToInstance(input as Constructable>, params), { whitelist: true, validationError: { target: false }, @@ -1533,7 +1539,7 @@ class Endpoint { if (typeof input === 'function') { const errors = await validate( - Object.assign(new (input as Constructable)() as Record, params), + plainToInstance(input as Constructable>, params), { whitelist: true, validationError: { target: false },