diff --git a/packages/core/src/domain/Hooks.ts b/packages/core/src/domain/Hooks.ts index 4abd696e8b5..f3eadd3e453 100644 --- a/packages/core/src/domain/Hooks.ts +++ b/packages/core/src/domain/Hooks.ts @@ -1,6 +1,9 @@ export class Hooks { #listeners: Record = {}; + has(event: string) { + return !!this.#listeners[event]; + } /** * Listen a hook event * @param event diff --git a/packages/specs/json-mapper/src/domain/JsonMapperCompiler.ts b/packages/specs/json-mapper/src/domain/JsonMapperCompiler.ts new file mode 100644 index 00000000000..b6d26cf64ab --- /dev/null +++ b/packages/specs/json-mapper/src/domain/JsonMapperCompiler.ts @@ -0,0 +1,102 @@ +import {isCollection, nameOf, objectKeys, Type} from "@tsed/core"; +import {alterIgnore} from "@tsed/schema"; +import {getRandomComponentId} from "../utils/getRandomComponentId"; +import {getJsonMapperTypes} from "./JsonMapperTypesContainer"; + +export type JsonMapperCallback = (input: any, options?: Options) => any; +export type CachedJsonMapper = { + id: string; + fn: JsonMapperCallback; + source: string; +}; + +export abstract class JsonMapperCompiler { + cache = new Map, CachedJsonMapper>(); + mappers: Record> = {}; + schemes: Record = {}; + + abstract alterValue(schemaId: string, value: any, options: Options): any; + abstract createMapper(model: Type, id: string): string; + + set(model: Type, mapper: CachedJsonMapper) { + this.cache.set(model, mapper); + this.mappers[mapper.id] = mapper.fn; + } + + get(model: Type) { + return this.cache.get(model); + } + + has(model: Type) { + return this.cache.has(model); + } + + addTypeMapper(model: Type, fn: any) { + this.cache.set(model, { + id: nameOf(model), + source: "", + fn + }); + this.mappers[nameOf(model)] = fn; + + return this; + } + + eval(id: string, model: Type, mapper: string) { + const {cache} = this; + eval(`cache.set(model, { fn: ${mapper} })`); + + const serializer = this.cache.get(model)!; + serializer.source = mapper; + serializer.id = id; + + this.mappers[id] = serializer.fn; + + return serializer.fn; + } + + createContext(options: Options) { + return { + ...options, + alterIgnore: (id: string, options: Options) => { + return alterIgnore(this.schemes[id], options); + }, + alterValue: (id: string, value: any, options: Options) => { + return this.alterValue(id, value, options); + }, + objectKeys, + cache: this.cache, + mappers: this.mappers + }; + } + + compile(model: Type): CachedJsonMapper { + if (!this.has(model)) { + const types = getJsonMapperTypes(); + + if (types.has(model) && !isCollection(model)) { + const mapper = types.get(model); + + if (mapper) { + this.addTypeMapper(model, mapper.serialize.bind(mapper)); + } + + return this.get(model)!; + } + + const id = `${nameOf(model)}:${getRandomComponentId()}`; + this.cache.set(model, {id} as any); + + const mapper = this.createMapper(model, id); + + try { + this.eval(id, model, mapper); + } catch (err) { + console.log(mapper); + throw new Error(`Fail to compile mapper for ${nameOf(model)}. See the error above: ${err.message}.\n${mapper}`); + } + } + + return this.get(model)!; + } +} diff --git a/packages/specs/json-mapper/src/domain/JsonSerializer.spec.ts b/packages/specs/json-mapper/src/domain/JsonSerializer.spec.ts new file mode 100644 index 00000000000..4dfe0284c50 --- /dev/null +++ b/packages/specs/json-mapper/src/domain/JsonSerializer.spec.ts @@ -0,0 +1,345 @@ +import {AdditionalProperties, CollectionOf, Ignore, JsonHookContext, Name, Property} from "@tsed/schema"; +import "../components/PrimitiveMapper"; +import {OnSerialize} from "../decorators/onSerialize"; +import {serializeV2} from "./JsonSerializer"; + +describe("JsonSerializer", () => { + describe("ignore hook is configured on props", () => { + it("should serialize model (api = true)", () => { + class Role { + @Property() + label: string; + + constructor({label}: any = {}) { + this.label = label; + } + } + + class Model { + @Property() + id: string; + + @Ignore((ignored, ctx: JsonHookContext) => ctx.api) + password: string; + + @OnSerialize((value) => String(value) + "test") + @Name("mapped_prop") + mappedProp: string; + + @CollectionOf(Role) + roles: Role[] = []; + + @CollectionOf(Role) + mapRoles: Map = new Map(); + + @CollectionOf(String) + setRoleNames: Set = new Set(); + } + + const model = new Model(); + model.id = "id"; + model.password = "string"; + model.mappedProp = "mappedProp"; + model.roles = [new Role({label: "ADMIN"})]; + + model.mapRoles = new Map([["ADMIN", new Role({label: "ADMIN"})]]); + + model.setRoleNames = new Set(); + model.setRoleNames.add("ADMIN"); + + expect( + serializeV2(model, { + useAlias: false, + api: true + }) + ).toEqual({ + id: "id", + mapRoles: { + ADMIN: { + label: "ADMIN" + } + }, + mappedProp: "mappedProptest", + roles: [ + { + label: "ADMIN" + } + ], + setRoleNames: ["ADMIN"] + }); + }); + it("should serialize model (api = false)", () => { + class Role { + @Property() + label: string; + + constructor({label}: any = {}) { + this.label = label; + } + } + + class Model { + @Property() + id: string; + + @Ignore((ignored, ctx: JsonHookContext) => ctx.api) + password: string; + + @OnSerialize((value) => String(value) + "test") + @Name("mapped_prop") + mappedProp: string; + + @CollectionOf(Role) + roles: Role[] = []; + + @CollectionOf(Role) + mapRoles: Map = new Map(); + + @CollectionOf(String) + setRoleNames: Set = new Set(); + } + + const model = new Model(); + model.id = "id"; + model.password = "string"; + model.mappedProp = "mappedProp"; + model.roles = [new Role({label: "ADMIN"})]; + + model.mapRoles = new Map([["ADMIN", new Role({label: "ADMIN"})]]); + + model.setRoleNames = new Set(); + model.setRoleNames.add("ADMIN"); + + expect( + serializeV2(model, { + useAlias: false, + api: false + }) + ).toEqual({ + id: "id", + mapRoles: { + ADMIN: { + label: "ADMIN" + } + }, + password: "string", + mappedProp: "mappedProptest", + roles: [ + { + label: "ADMIN" + } + ], + setRoleNames: ["ADMIN"] + }); + }); + }); + it("should serialize model with alias property", () => { + class Role { + @Property() + label: string; + + constructor({label}: any = {}) { + this.label = label; + } + } + + class Model { + @Property() + id: string; + + @Ignore((ignored, ctx: JsonHookContext) => ctx.api) + password: string; + + @OnSerialize((value) => String(value) + "test") + @Name("mapped_prop") + mappedProp: string; + + @CollectionOf(Role) + roles: Role[] = []; + + @CollectionOf(Role) + mapRoles: Map = new Map(); + + @CollectionOf(String) + setRoleNames: Set = new Set(); + } + + const model = new Model(); + model.id = "id"; + model.password = "string"; + model.mappedProp = "mappedProp"; + model.roles = [new Role({label: "ADMIN"})]; + + model.mapRoles = new Map([["ADMIN", new Role({label: "ADMIN"})]]); + + model.setRoleNames = new Set(); + model.setRoleNames.add("ADMIN"); + + expect( + serializeV2(model, { + useAlias: true, + api: false + }) + ).toEqual({ + id: "id", + mapRoles: { + ADMIN: { + label: "ADMIN" + } + }, + mapped_prop: "mappedProptest", + password: "string", + roles: [ + { + label: "ADMIN" + } + ], + setRoleNames: ["ADMIN"] + }); + }); + it("should serialize model with nested object", () => { + class Nested { + @Property() + label: string; + + @Name("additional_description") + additionalDescription: string; + + constructor({label, additionalDescription}: any = {}) { + this.label = label; + this.additionalDescription = additionalDescription; + } + } + + class Model { + @Property() + id: string; + + @Property() + nested: any; + + @Property() + nestedTyped: Nested; + } + + const model = new Model(); + model.id = "id"; + model.nested = { + other: "other", + test: new Nested({ + additionalDescription: "additionalDescription", + label: "label" + }) + }; + + model.nestedTyped = new Nested({ + additionalDescription: "additionalDescription", + label: "label" + }); + + expect( + serializeV2(model, { + useAlias: true, + api: false + }) + ).toEqual({ + id: "id", + nested: { + other: "other", + test: { + additional_description: "additionalDescription", + label: "label" + } + }, + nestedTyped: { + additional_description: "additionalDescription", + label: "label" + } + }); + }); + it("should serialize model with additional properties", () => { + @AdditionalProperties(true) + class Model { + @Property() + id: string; + + @OnSerialize((value) => String(value) + "test") + @Name("mapped_prop") + mappedProp: string; + } + + expect( + serializeV2( + { + id: "id", + mappedProp: "mappedProp", + additionalProperty: true + }, + { + type: Model, + useAlias: false + } + ) + ).toEqual({ + additionalProperty: true, + id: "id", + mappedProp: "mappedProptest" + }); + }); + it("should serialize model (recursive class)", () => { + class User { + @Property() + name: string; + + @CollectionOf(() => Post) + posts: any[]; + + @Property(() => Post) + @Name("main_post") + mainPost: any; + } + + class Post { + @Property() + id: string; + + @Property() + owner: User; + + @Name("title") + initializedTitle: string; + } + + const post = new Post(); + post.id = "id"; + + post.owner = new User(); + post.owner.name = "name"; + post.owner.posts = [new Post()]; + post.owner.posts[0].id = "id"; + post.owner.posts[0].initializedTitle = "initializedTitle"; + + post.owner.mainPost = new Post(); + post.owner.mainPost.id = "idMain"; + post.owner.mainPost.initializedTitle = "initializedTitle"; + + const result = serializeV2(post, {useAlias: true}); + + expect(result).toEqual({ + id: "id", + owner: { + name: "name", + main_post: { + id: "idMain", + title: "initializedTitle" + }, + posts: [ + { + id: "id", + title: "initializedTitle" + } + ] + } + }); + }); +}); diff --git a/packages/specs/json-mapper/src/domain/JsonSerializer.ts b/packages/specs/json-mapper/src/domain/JsonSerializer.ts new file mode 100644 index 00000000000..853eb13d379 --- /dev/null +++ b/packages/specs/json-mapper/src/domain/JsonSerializer.ts @@ -0,0 +1,141 @@ +import {classOf, nameOf, Type} from "@tsed/core"; +import {getPropertiesStores, JsonEntityStore, JsonPropertyStore} from "@tsed/schema"; +import {alterOnSerialize} from "../hooks/alterOnSerialize"; +import {getObjectProperties} from "../utils/getObjectProperties"; +import {JsonSerializerOptions} from "../utils/serialize"; +import {JsonMapperCompiler} from "./JsonMapperCompiler"; +import {Writer} from "./Writer"; + +export class JsonSerializer extends JsonMapperCompiler { + constructor() { + super(); + + this.addTypeMapper(Object, (input: any, {type, ...options}: JsonSerializerOptions) => { + return getObjectProperties(input) + .filter(([, value]) => value !== undefined) + .reduce((obj, [key, value]) => { + const mapper = this.compile(classOf(value)); + + return { + ...obj, + [key]: mapper.fn(value, options) + }; + }, {}); + }); + + this.addTypeMapper(Array, (input: any, {id, ...options}: JsonSerializerOptions) => { + return [].concat(input).map((item) => this.mappers[id](item, options)); + }); + + this.addTypeMapper(Map, (input: any, {id, ...options}: JsonSerializerOptions) => { + return [...input.entries()].reduce((obj, [key, item]) => { + return { + ...obj, + [key]: this.mappers[id](item, options) + }; + }, {}); + }); + + this.addTypeMapper(Set, (input: any, {id, ...options}: JsonSerializerOptions) => { + return [...input.values()].map((item) => { + return this.mappers[id](item, options); + }); + }); + } + + alterValue(schemaId: string, value: any, options: JsonSerializerOptions): any { + return alterOnSerialize(this.schemes[schemaId], value, options as any); + } + + createMapper(model: Type, id: string): string { + const entity = JsonEntityStore.from(model); + const properties = new Set(); + const schemaProperties = [...getPropertiesStores(entity).values()]; + + // properties mapping + const writer = new Writer().arrow("input", "options"); + + writer.const("obj", "{}"); + writer.set("options", "{...options, self: input}"); + + const propsWriters = schemaProperties.flatMap((propertyStore) => { + properties.add(propertyStore.propertyKey as string); + return this.createPropertyMapper(propertyStore, id); + }); + + writer.add(...propsWriters); + + // additional properties + const additionalProperties = !!entity.schema.get("additionalProperties"); + + if (additionalProperties) { + const exclude = [...properties.values()].map((key) => `'${key}'`).join(", "); + + writer.add("// add additional properties"); + writer.each("options.objectKeys(input)", ["key"]).if(`![${exclude}].includes(key)`).set("obj[key]", "input[key]"); + } + + writer.return("obj"); + + return writer.root().toString(); + } + + createPropertyMapper(propertyStore: JsonPropertyStore, id: string) { + const key = String(propertyStore.propertyKey); + const aliasKey: string = String(propertyStore.parent.schema.getAliasOf(key) || key); + const schemaId = `${id}:${key}`; + + let builder = new Writer().add(`// Map ${key}`); + + // ignore hook + if (propertyStore.schema?.$hooks?.has("ignore")) { + this.schemes[schemaId] = propertyStore.schema; + + builder = builder.if(`!options.alterIgnore('${schemaId}', {...options, self: input})`); + } + + builder = builder.add(`let ${key} = input.${key};`).if(`${key} !== undefined`); + + // pre hook + if (propertyStore.schema?.$hooks?.has("onSerialize")) { + this.schemes[schemaId] = propertyStore.schema; + + builder.set(key, `options.alterValue('${schemaId}', ${key}, options)`); + } + + const format = propertyStore.itemSchema.get("format"); + + if (propertyStore.isCollection) { + const type = propertyStore.getBestType(); + + const nestedMapper = this.compile(type); + + builder.callMapper(nameOf(propertyStore.collectionType), key, `id: '${nestedMapper.id}'`, format && `format: '${format}'`); + } else { + const type = propertyStore.getBestType(); + const nestedMapper = this.compile(type); + + builder.callMapper(nestedMapper.id, key, format && `format: '${format}'`); + } + + if (aliasKey !== key) { + builder.if(`options.useAlias`).set(`obj.${aliasKey}`, key).else().set(`obj.${key}`, key); + } else { + builder.set(`obj.${key}`, key); + } + + return builder.root(); + } + + serialize(input: any, options: JsonSerializerOptions = {}) { + const mapper = this.compile(options.type || classOf(input)); + + return mapper.fn(input, serializer.createContext(options)); + } +} + +const serializer = new JsonSerializer(); + +export function serializeV2(input: any, options: JsonSerializerOptions = {}) { + return serializer.serialize(input, options); +} diff --git a/packages/specs/json-mapper/src/domain/Writer.ts b/packages/specs/json-mapper/src/domain/Writer.ts new file mode 100644 index 00000000000..75fe0350b7a --- /dev/null +++ b/packages/specs/json-mapper/src/domain/Writer.ts @@ -0,0 +1,137 @@ +export class Writer { + static object = { + assign(...args: string[]) { + return `{ ${args.filter(Boolean).join(", ")} }`; + } + }; + + protected _root?: Writer; + protected body: (string | Writer)[] = []; + private _indent: boolean; + + constructor(root?: Writer) { + this._root = root; + } + + static indent(lines: string[]) { + return lines.map((p) => `\t${p}`); + } + + static mapper(mapperId: string, key: string, options: string) { + return `options.mappers['${mapperId}'](${key}, ${options})`; + } + + static options(...args: string[]) { + args = args.filter(Boolean); + return !args.length ? "options" : Writer.object.assign("...options", ...args); + } + + add(...lines: (string | Writer)[]) { + this.body.push(...lines); + return this; + } + + arrow(...args: string[]) { + this.add(`(${args}) => {`); + + const writer = this.new(); + + this.add("}"); + + return writer; + } + + build(): string[] { + const result = this.body.flatMap((line) => { + return line instanceof Writer ? line.build() : line; + }); + + return this._indent ? result.map((item) => "\t" + item) : result; + } + + callMapper(id: string, key: string, ...options: string[]) { + return this.set(key, Writer.mapper(id, key, Writer.options(...options))); + } + + const(name: string, line: string) { + this.add(`const ${name} = ${line};`); + return this; + } + + each(iterable: string, args: string[] = []) { + const writer = this.add(iterable + ".forEach((" + args.join(", ") + ") => {").new(); + + this.add("});"); + + return writer; + } + + if(condition: string, line?: string) { + const writer = new IfWriter(condition, this); + + this.add(writer); + + if (line) { + writer.add(line); + } + + return writer; + } + + indent(indent: boolean) { + this._indent = indent; + return this; + } + + new(indent = true) { + const writer = new Writer(this.root()); + writer.indent(indent); + this.add(writer); + + return writer; + } + + return(line: string) { + this.add(`return ${line};`); + return this.root(); + } + + root() { + return this._root || this; + } + + set(left: string, right: string) { + this.add(`${left} = ${right};`); + return this; + } + + toString() { + return this.build().join("\n"); + } +} + +class IfWriter extends Writer { + protected elseWriter?: Writer; + + constructor(protected condition: string, root: Writer) { + super(); + this._root = root; + } + + else() { + const writer = new Writer(this._root); + + this.elseWriter = writer; + + return writer; + } + + build() { + return [ + `if (${this.condition}) {`, + ...Writer.indent(super.build()), + "}", + this.elseWriter ? [`else {`, ...Writer.indent(this.elseWriter.build()), "}"] : [] + ].flat(); + } +} diff --git a/packages/specs/json-mapper/src/hooks/alterOnSerialize.ts b/packages/specs/json-mapper/src/hooks/alterOnSerialize.ts new file mode 100644 index 00000000000..f42db447906 --- /dev/null +++ b/packages/specs/json-mapper/src/hooks/alterOnSerialize.ts @@ -0,0 +1,5 @@ +import {JsonHookContext, JsonSchema} from "@tsed/schema"; + +export function alterOnSerialize(schema: JsonSchema, value: any, options: JsonHookContext) { + return schema.$hooks.alter("onSerialize", value, [options]); +} diff --git a/packages/specs/json-mapper/src/utils/getObjectProperties.ts b/packages/specs/json-mapper/src/utils/getObjectProperties.ts new file mode 100644 index 00000000000..2e967869cbc --- /dev/null +++ b/packages/specs/json-mapper/src/utils/getObjectProperties.ts @@ -0,0 +1,5 @@ +import {isFunction} from "@tsed/core"; + +export function getObjectProperties(obj: any): [string, any][] { + return Object.entries(obj).filter(([, value]) => !isFunction(value)); +} diff --git a/packages/specs/json-mapper/src/utils/getRandomComponentId.ts b/packages/specs/json-mapper/src/utils/getRandomComponentId.ts new file mode 100644 index 00000000000..b76490a0207 --- /dev/null +++ b/packages/specs/json-mapper/src/utils/getRandomComponentId.ts @@ -0,0 +1,3 @@ +export function getRandomComponentId() { + return `e${Math.random().toString(36).substring(7)}`; +} diff --git a/packages/specs/json-mapper/src/utils/getSchemaProperties.ts b/packages/specs/json-mapper/src/utils/getSchemaProperties.ts new file mode 100644 index 00000000000..cabbd557ef1 --- /dev/null +++ b/packages/specs/json-mapper/src/utils/getSchemaProperties.ts @@ -0,0 +1,16 @@ +import {objectKeys} from "@tsed/core"; +import {getPropertiesStores, JsonEntityStore} from "@tsed/schema"; + +export function getSchemaProperties(storedJson: JsonEntityStore, obj: any) { + const stores = Array.from(getPropertiesStores(storedJson).entries()); + + if (!stores.length) { + // fallback to auto discovering field from obj + objectKeys(obj).forEach((key) => { + const propStore = JsonEntityStore.from(storedJson.target, key); + stores.push([key, propStore]); + }); + } + + return stores; +} diff --git a/packages/specs/schema/src/decorators/common/property.ts b/packages/specs/schema/src/decorators/common/property.ts index 2f4783800c7..046165ffa60 100644 --- a/packages/specs/schema/src/decorators/common/property.ts +++ b/packages/specs/schema/src/decorators/common/property.ts @@ -1,4 +1,4 @@ -import {isClass} from "@tsed/core"; +import {isArrowFn, isClass} from "@tsed/core"; import {JsonEntityFn} from "./jsonEntityFn"; /** @@ -14,7 +14,7 @@ import {JsonEntityFn} from "./jsonEntityFn"; export function Property(type?: any) { return JsonEntityFn((store) => { if (type) { - if (isClass(type)) { + if (isClass(type) || isArrowFn(type)) { store.type = type; } store.itemSchema.type(type); diff --git a/packages/specs/schema/src/domain/JsonEntityStore.ts b/packages/specs/schema/src/domain/JsonEntityStore.ts index 40ea224ee4b..ffde7e81d52 100644 --- a/packages/specs/schema/src/domain/JsonEntityStore.ts +++ b/packages/specs/schema/src/domain/JsonEntityStore.ts @@ -5,6 +5,7 @@ import { DecoratorTypes, descriptorOf, isArrayOrArrayClass, + isArrowFn, isClass, isClassObject, isCollection, @@ -280,6 +281,8 @@ export abstract class JsonEntityStore implements JsonEntityStoreOptions { ? this.itemSchema.discriminator() : isClassObject(this.type) ? this.itemSchema.getTarget() + : isArrowFn(this.type) + ? this.type() : this.type; } }