diff --git a/packages/data-mapping/package-lock.json b/packages/data-mapping/package-lock.json index c29b6e55f..5cb02d443 100644 --- a/packages/data-mapping/package-lock.json +++ b/packages/data-mapping/package-lock.json @@ -13,6 +13,7 @@ "@pristine-ts/common": "file:../common", "@pristine-ts/logging": "file:../logging", "@pristine-ts/metadata": "~1.0.2", + "@pristine-ts/networking": "file:../networking", "class-transformer": "^0.5.1", "tsyringe": "^4.8.0" } @@ -37,6 +38,18 @@ "date-fns": "^2.30.0" } }, + "../networking": { + "version": "0.0.276", + "license": "ISC", + "dependencies": { + "@pristine-ts/common": "file:../common", + "@pristine-ts/core": "file:../core", + "@pristine-ts/metadata": "^1.0.2", + "@pristine-ts/security": "file:../security", + "@pristine-ts/telemetry": "file:../telemetry", + "lodash": "^4.17.21" + } + }, "node_modules/@babel/runtime": { "version": "7.23.8", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", @@ -76,6 +89,10 @@ "reflect-metadata": "^0.2.1" } }, + "node_modules/@pristine-ts/networking": { + "resolved": "../networking", + "link": true + }, "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", @@ -181,6 +198,17 @@ "reflect-metadata": "^0.2.1" } }, + "@pristine-ts/networking": { + "version": "file:../networking", + "requires": { + "@pristine-ts/common": "file:../common", + "@pristine-ts/core": "file:../core", + "@pristine-ts/metadata": "^1.0.2", + "@pristine-ts/security": "file:../security", + "@pristine-ts/telemetry": "file:../telemetry", + "lodash": "^4.17.21" + } + }, "class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", diff --git a/packages/data-mapping/package.json b/packages/data-mapping/package.json index 8a706e276..2c4bf5cf7 100644 --- a/packages/data-mapping/package.json +++ b/packages/data-mapping/package.json @@ -24,6 +24,8 @@ "@pristine-ts/common": "file:../common", "@pristine-ts/logging": "file:../logging", "@pristine-ts/metadata": "~1.0.2", + "@pristine-ts/networking": "file:../networking", + "@pristine-ts/class-validator": "^1.0.22", "class-transformer": "^0.5.1", "tsyringe": "^4.8.0" }, diff --git a/packages/data-mapping/src/data-mapping.module.ts b/packages/data-mapping/src/data-mapping.module.ts index faf05f0ae..0aac6dea9 100644 --- a/packages/data-mapping/src/data-mapping.module.ts +++ b/packages/data-mapping/src/data-mapping.module.ts @@ -3,6 +3,7 @@ import {LoggingModule} from "@pristine-ts/logging"; import {DataMappingModuleKeyname} from "./data-mapping.module.keyname"; export * from "./builders/builders"; +export * from "./decorators/decorators"; export * from "./enums/enums"; export * from "./errors/errors"; export * from "./interceptors/interceptors"; @@ -11,6 +12,7 @@ export * from "./mappers/mappers"; export * from "./nodes/nodes"; export * from "./normalizer-options/normalizer-options"; export * from "./normalizers/normalizers"; +export * from "./request-interceptors/request-interceptors"; export * from "./types/types"; export const DataMappingModule: ModuleInterface = { diff --git a/packages/data-mapping/src/decorators/body-mapping.decorator.spec.ts b/packages/data-mapping/src/decorators/body-mapping.decorator.spec.ts new file mode 100644 index 000000000..c086a764f --- /dev/null +++ b/packages/data-mapping/src/decorators/body-mapping.decorator.spec.ts @@ -0,0 +1,69 @@ +import {bodyMapping, bodyMappingDecoratorMetadataKeyname} from "./body-mapping.decorator"; +import {MetadataUtil} from "@pristine-ts/common"; +import { + ClassTransformerBodyMappingContextInterface, DataMappingBuilderBodyMappingContextInterface, + FunctionBodyMappingContextInterface +} from "../interfaces/body-mapping-context.interface"; +import {DataMappingBuilder} from "../builders/data-mapping.builder"; +import {LowercaseNormalizer} from "../normalizers/lowercase.normalizer"; + +class Class {} + +describe("Body Mapping Decorator", () =>{ + it("should properly set the context for a ClassType", () => { + class Test { + @bodyMapping(Class) + route() {} + } + + const context: { [bodyMappingDecoratorMetadataKeyname]: ClassTransformerBodyMappingContextInterface } = MetadataUtil.getRouteContext(Test.prototype, "route"); + + expect(context[bodyMappingDecoratorMetadataKeyname].type).toBe("classType"); + expect(context[bodyMappingDecoratorMetadataKeyname].classType).toBe(Class); + }) + + it("should properly set the context for a function", () => { + const method:((body: any) => any) = (body) => {return {...body, "a": true}}; + + class Test2 { + @bodyMapping(method) + route() {} + } + + const context: { [bodyMappingDecoratorMetadataKeyname]: FunctionBodyMappingContextInterface } = MetadataUtil.getRouteContext(Test2.prototype, "route"); + + expect(context[bodyMappingDecoratorMetadataKeyname].type).toBe("function"); + expect(context[bodyMappingDecoratorMetadataKeyname].function).toBe(method); + }) + it("should properly set the context for a function", () => { + const dataMappingBuilder = new DataMappingBuilder(); + + dataMappingBuilder + .add() + .setSourceProperty("title") + .setDestinationProperty("name") + .excludeNormalizer(LowercaseNormalizer.name) + .end() + .add() + .setSourceProperty("rank") + .setDestinationProperty("position") + .excludeNormalizer(LowercaseNormalizer.name) + .end() + .add() + .setSourceProperty("lastName") + .setDestinationProperty("familyName") + .excludeNormalizer(LowercaseNormalizer.name) + .end() + .end() + + class Test3 { + @bodyMapping({builder: dataMappingBuilder}) + route() {} + } + + const context: { [bodyMappingDecoratorMetadataKeyname]: DataMappingBuilderBodyMappingContextInterface } = MetadataUtil.getRouteContext(Test3.prototype, "route"); + + expect(context[bodyMappingDecoratorMetadataKeyname].type).toBe("DataMappingBuilder"); + expect(context[bodyMappingDecoratorMetadataKeyname].dataMappingBuilder).toBe(dataMappingBuilder); + }) +}) \ No newline at end of file diff --git a/packages/data-mapping/src/decorators/body-mapping.decorator.ts b/packages/data-mapping/src/decorators/body-mapping.decorator.ts new file mode 100644 index 000000000..aa8777f48 --- /dev/null +++ b/packages/data-mapping/src/decorators/body-mapping.decorator.ts @@ -0,0 +1,58 @@ +import {MetadataUtil} from "@pristine-ts/common"; +import {DataMappingBuilder} from "../builders/data-mapping.builder"; +import { + ClassTransformerBodyMappingContextInterface, DataMappingBuilderBodyMappingContextInterface, + FunctionBodyMappingContextInterface +} from "../interfaces/body-mapping-context.interface"; +import {ClassConstructor} from "class-transformer"; + +export const bodyMappingDecoratorMetadataKeyname = "@bodyMappingDecorator"; + +/** + * The bodyMapping decorator can be used to map the body to another object. + */ +export const bodyMapping = (argument: ClassConstructor | {builder: DataMappingBuilder, destination?: ClassConstructor} | ((body: any) => any) ) => { + return ( + /** + * The class on which the decorator is used. + */ + target: any, + + /** + * The method on which the decorator is used. + */ + propertyKey: string | symbol, + + /** + * The descriptor of the property. + */ + descriptor: PropertyDescriptor + ) => { + let context: ClassTransformerBodyMappingContextInterface | FunctionBodyMappingContextInterface | DataMappingBuilderBodyMappingContextInterface; + + + if(argument.hasOwnProperty("builder")) { + context = { + type: "DataMappingBuilder", + + // @ts-ignore It will exist if it has the property above. + dataMappingBuilder: argument.builder as DataMappingBuilder, + + // @ts-ignore If it doesn't exist, it shouldn't worry about it, but it does... + destination: argument.destination ?? undefined, + } + } else if(typeof argument === "function" && argument.hasOwnProperty("prototype") && argument.prototype.hasOwnProperty("constructor")) { + context = { + type: "classType", + classType: argument as ClassConstructor, + } + } else { + context = { + type: "function", + function: argument as ((body: any) => any), + } + } + + MetadataUtil.setToRouteContext(bodyMappingDecoratorMetadataKeyname, context, target, propertyKey); + } +} diff --git a/packages/data-mapping/src/decorators/decorators.ts b/packages/data-mapping/src/decorators/decorators.ts new file mode 100644 index 000000000..8db6ee33c --- /dev/null +++ b/packages/data-mapping/src/decorators/decorators.ts @@ -0,0 +1 @@ +export * from "./body-mapping.decorator" \ No newline at end of file diff --git a/packages/data-mapping/src/interfaces/body-mapping-context.interface.ts b/packages/data-mapping/src/interfaces/body-mapping-context.interface.ts new file mode 100644 index 000000000..a6dac719c --- /dev/null +++ b/packages/data-mapping/src/interfaces/body-mapping-context.interface.ts @@ -0,0 +1,24 @@ +import {DataMappingBuilder} from "../builders/data-mapping.builder"; +import {ClassConstructor} from "class-transformer"; + +export interface BodyMappingContextInterface { + type: "function" | "classType" | "DataMappingBuilder"; +} + +export interface FunctionBodyMappingContextInterface extends BodyMappingContextInterface { + type: "function"; + + function: ((body: any) => any); +} + +export interface ClassTransformerBodyMappingContextInterface extends BodyMappingContextInterface { + type: "classType"; + + classType: ClassConstructor; +} +export interface DataMappingBuilderBodyMappingContextInterface extends BodyMappingContextInterface { + type: "DataMappingBuilder"; + + dataMappingBuilder: DataMappingBuilder; + destination?: ClassConstructor; +} \ No newline at end of file diff --git a/packages/data-mapping/src/request-interceptors/body-mapping.request-interceptor.spec.ts b/packages/data-mapping/src/request-interceptors/body-mapping.request-interceptor.spec.ts new file mode 100644 index 000000000..db022fa18 --- /dev/null +++ b/packages/data-mapping/src/request-interceptors/body-mapping.request-interceptor.spec.ts @@ -0,0 +1,140 @@ +import {BodyMappingRequestInterceptor} from "./body-mapping.request-interceptor"; +import {LogHandlerInterface} from "@pristine-ts/logging"; +import {DataMapper} from "../mappers/data.mapper"; +import {HttpMethod, Request} from "@pristine-ts/common"; +import {Type} from "class-transformer"; +import {MethodRouterNode, Route} from "@pristine-ts/networking"; +import {bodyMappingDecoratorMetadataKeyname} from "../decorators/body-mapping.decorator"; +import { + ClassTransformerBodyMappingContextInterface, DataMappingBuilderBodyMappingContextInterface, + FunctionBodyMappingContextInterface +} from "../interfaces/body-mapping-context.interface"; +import {DataMappingBuilder} from "../builders/data-mapping.builder"; +import {LowercaseNormalizer} from "../normalizers/lowercase.normalizer"; + +const mockLogHandler: LogHandlerInterface = { + critical(message: string, extra?: any, module?: string): void { + }, debug(message: string, extra?: any, module?: string): void { + }, error(message: string, extra?: any, module?: string): void { + }, info(message: string, extra?: any, module?: string): void { + }, terminate(): void { + }, warning(message: string, extra?: any, module?: string): void { + } + +} + +describe("Body Mapping Request Interceptor", () => { + it("should map a body when a class is passed", async () => { + class Nested { + nestedProperty: string; + } + + class Test { + @Type(() => Nested) + nested: Nested; + + @Type(() => Date) + date: Date; + } + + const request: Request = new Request(HttpMethod.Get, ""); + request.body = { + "nested": { + "nestedProperty": "nested", + }, + "date": "2023-12-01", + } + + const bodyMappingRequestInterceptor = new BodyMappingRequestInterceptor(mockLogHandler, new DataMapper([], [])); + const route = new Route(null, ""); + route.context = {}; + route.context[bodyMappingDecoratorMetadataKeyname] = { + type: "classType", + classType: Test, + } as ClassTransformerBodyMappingContextInterface; + + // @ts-ignore + const methodNode = new MethodRouterNode(undefined, HttpMethod.Get, route, 0); + + const request2 = await bodyMappingRequestInterceptor.interceptRequest(request, methodNode); + expect(request2.body instanceof Test).toBeTruthy() + expect(request2.body.nested instanceof Nested).toBeTruthy() + expect(request2.body.date instanceof Date).toBeTruthy() + }) + it("should map a body when a function is passed", async () => { + const spy = jest.fn(); + + const bodyMapping = (body: any) => { + spy(); + return new Date(); + } + + const request: Request = new Request(HttpMethod.Get, ""); + request.body = { + "nested": { + "nestedProperty": "nested", + }, + "date": "2023-12-01", + } + + const bodyMappingRequestInterceptor = new BodyMappingRequestInterceptor(mockLogHandler, new DataMapper([], [])); + const route = new Route(null, ""); + route.context = {}; + route.context[bodyMappingDecoratorMetadataKeyname] = { + type: "function", + function: bodyMapping, + } as FunctionBodyMappingContextInterface; + + // @ts-ignore + const methodNode = new MethodRouterNode(undefined, HttpMethod.Get, route, 0); + + const request2 = await bodyMappingRequestInterceptor.interceptRequest(request, methodNode); + expect(request2.body instanceof Date).toBeTruthy() + expect(spy).toHaveBeenCalled() + }) + + it("should map a body when a DataMappingBuilder is passed", async () => { + class Test { + name: string; + + position: number; + } + + const request: Request = new Request(HttpMethod.Get, ""); + request.body = { + "title": "The Title", + "rank": 2, + } + + const dataMappingBuilder = new DataMappingBuilder(); + + dataMappingBuilder + .add() + .setSourceProperty("title") + .setDestinationProperty("name") + .addNormalizer(LowercaseNormalizer.name) + .end() + .add() + .setSourceProperty("rank") + .setDestinationProperty("position") + .end() + .end() + + const bodyMappingRequestInterceptor = new BodyMappingRequestInterceptor(mockLogHandler, new DataMapper([new LowercaseNormalizer()], [])); + const route = new Route(null, ""); + route.context = {}; + route.context[bodyMappingDecoratorMetadataKeyname] = { + type: "DataMappingBuilder", + dataMappingBuilder, + destination: Test, + } as DataMappingBuilderBodyMappingContextInterface; + + // @ts-ignore + const methodNode = new MethodRouterNode(undefined, HttpMethod.Get, route, 0); + + const request2 = await bodyMappingRequestInterceptor.interceptRequest(request, methodNode); + expect(request2.body instanceof Test).toBeTruthy() + expect(request2.body.name).toBe("the title") + expect(request2.body.position).toBe(2) + }) +}) \ No newline at end of file diff --git a/packages/data-mapping/src/request-interceptors/body-mapping.request-interceptor.ts b/packages/data-mapping/src/request-interceptors/body-mapping.request-interceptor.ts new file mode 100644 index 000000000..472d7b219 --- /dev/null +++ b/packages/data-mapping/src/request-interceptors/body-mapping.request-interceptor.ts @@ -0,0 +1,61 @@ +import {moduleScoped, Request, ServiceDefinitionTagEnum, tag} from "@pristine-ts/common"; +import {inject, injectable} from "tsyringe"; +import {MethodRouterNode, RequestInterceptorInterface} from "@pristine-ts/networking"; +import {LogHandlerInterface} from "@pristine-ts/logging"; +import {DataMapper} from "../mappers/data.mapper"; +import {bodyMappingDecoratorMetadataKeyname} from "../decorators/body-mapping.decorator"; +import { + ClassTransformerBodyMappingContextInterface, DataMappingBuilderBodyMappingContextInterface, + FunctionBodyMappingContextInterface +} from "../interfaces/body-mapping-context.interface"; +import {plainToInstance} from "class-transformer"; +import {DataMappingModuleKeyname} from "../data-mapping.module.keyname"; + +/** + * This class is an interceptor that maps the body of an incoming request. + * It is tagged as an RequestInterceptor so it can be automatically injected with the all the other RequestInterceptors. + * It is module scoped to the Validation module so that it is only registered if the validation module is imported. + */ +@moduleScoped(DataMappingModuleKeyname) +@tag(ServiceDefinitionTagEnum.RequestInterceptor) +@injectable() +export class BodyMappingRequestInterceptor implements RequestInterceptorInterface { + constructor(@inject("LogHandlerInterface") private readonly loghandler: LogHandlerInterface, + private readonly dataMapper: DataMapper) { + } + + /** + * Intercepts the request and maps that the body to the corresponding argument passed in the `@bodyMapping` validator + * @param request The request being intercepted. + * @param methodNode The method node. + */ + async interceptRequest(request: Request, methodNode: MethodRouterNode): Promise { + const bodyMapping: ClassTransformerBodyMappingContextInterface | FunctionBodyMappingContextInterface | DataMappingBuilderBodyMappingContextInterface = methodNode.route.context[bodyMappingDecoratorMetadataKeyname]; + + if(bodyMapping === undefined) { + return request; + } + + this.loghandler.debug("BodyMappingRequestInterceptor", { + request, + methodNode, + routeContext: methodNode.route.context, + }, DataMappingModuleKeyname) + + switch (bodyMapping.type) { + case "classType": + request.body = plainToInstance(bodyMapping.classType, request.body); + break; + + case "DataMappingBuilder": + request.body = await this.dataMapper.map(bodyMapping.dataMappingBuilder, request.body, bodyMapping.destination); + break; + + case "function": + request.body = bodyMapping.function(request.body); + break; + } + + return request; + } +} \ No newline at end of file diff --git a/packages/data-mapping/src/request-interceptors/request-interceptors.ts b/packages/data-mapping/src/request-interceptors/request-interceptors.ts new file mode 100644 index 000000000..82dae43dd --- /dev/null +++ b/packages/data-mapping/src/request-interceptors/request-interceptors.ts @@ -0,0 +1 @@ +export * from "./body-mapping.request-interceptor" \ No newline at end of file