Skip to content

Commit

Permalink
Merge pull request #646 from magieno/add-a-body-transform-decorator
Browse files Browse the repository at this point in the history
  • Loading branch information
etiennenoel authored Jan 24, 2024
2 parents 8282d52 + 9985b3f commit e5c87b1
Show file tree
Hide file tree
Showing 10 changed files with 386 additions and 0 deletions.
28 changes: 28 additions & 0 deletions packages/data-mapping/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/data-mapping/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/data-mapping/src/data-mapping.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
})
})
58 changes: 58 additions & 0 deletions packages/data-mapping/src/decorators/body-mapping.decorator.ts
Original file line number Diff line number Diff line change
@@ -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<any> | {builder: DataMappingBuilder, destination?: ClassConstructor<any>} | ((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<any>,
}
} else {
context = {
type: "function",
function: argument as ((body: any) => any),
}
}

MetadataUtil.setToRouteContext(bodyMappingDecoratorMetadataKeyname, context, target, propertyKey);
}
}
1 change: 1 addition & 0 deletions packages/data-mapping/src/decorators/decorators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./body-mapping.decorator"
Original file line number Diff line number Diff line change
@@ -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<any>;
}
export interface DataMappingBuilderBodyMappingContextInterface extends BodyMappingContextInterface {
type: "DataMappingBuilder";

dataMappingBuilder: DataMappingBuilder;
destination?: ClassConstructor<any>;
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading

0 comments on commit e5c87b1

Please sign in to comment.