diff --git a/README.md b/README.md index 5ccf58f..23367db 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,17 @@ [github-prs-url]: https://github.com/davidecaroselli/jsonthis/pulls -Jsonthis is a versatile TypeScript library designed to effortlessly convert your models into JSON objects. +Jsonthis! is a versatile TypeScript library designed to effortlessly convert your models into JSON objects. It offers extensive support for custom property serializers, conditional property visibility, and more. -Jsonthis seamlessly integrates with the [Sequelize](https://sequelize.org/) ORM library, making it an ideal companion +Jsonthis! seamlessly integrates with the [Sequelize](https://sequelize.org/) ORM library, making it an ideal companion for your data management needs. Explore the [Sequelize support](#sequelize-support) section for detailed instructions. ## Table of Contents +- [Installation](#installation) - [Getting Started](#getting-started) + * [JSON Serialization](#json-serialization) - [Change Property Visibility](#change-property-visibility) * [Conditional Visibility](#conditional-visibility) - [Customizing Serialization](#customizing-serialization) @@ -34,15 +36,24 @@ for your data management needs. Explore the [Sequelize support](#sequelize-suppo * [Contextual Field-Specific Serializer](#contextual-field-specific-serializer) * [Limit Serialization Depth](#limit-serialization-depth) - [Circular References](#circular-references) +- [JSON Stringify compatibility](#json-stringify-compatibility) - [Sequelize support](#sequelize-support) - [Let's Contribute Together!](#lets-contribute-together) * [How You Can Help](#how-you-can-help) * [Some Tips](#some-tips) * [Got Ideas?](#got-ideas) +## Installation + +To install Jsonthis!, simply run: + +```bash +npm install jsonthis +``` + ## Getting Started -Getting started with Jsonthis is quick and straightforward. Here's a simple example to get you going: +Getting started with Jsonthis! is quick and straightforward. Here's a simple example to get you going: ```typescript import {JsonField, Jsonthis} from "jsonthis"; @@ -59,17 +70,60 @@ class User { this.email = email; this.password = password; } + + declare toJSON: () => any; } const user = new User(1, "john.doe@gmail.com", "s3cret"); -const jsonthis = new Jsonthis(); -console.log(jsonthis.toJson(user)); -// { id: 1, email: 'john.doe@gmail.com', registeredAt: 2024-04-13T15:29:35.583Z } +const jsonthis = new Jsonthis({models: [User]}); +console.log(user.toJSON()); +// { +// id: 1, +// email: 'john.doe@gmail.com', +// registeredAt: 2024-04-27T17:03:52.158Z +// } +``` + +The `@JsonField` decorator empowers you to fine-tune the serialization process of your properties +with Jsonthis!: you can define custom serializers, change property visibility, and more. + +### JSON Serialization + +Jsonthis! offer a `toJson(target, options?)` method, as well as the `toJSON()` method on your classes +via the `models` options in the constructor. The first allows for more flexibility and customization (such as +conditional-serialization - see [Conditional Visibility](#conditional-visibility) for more details), +while the latter is a more straightforward approach that makes your classes compatible with `JSON.stringify()`. + +```typescript +class User { + // (...) properties and methods + + // This prevent TypeScript from throwing an error when calling toJSON() on the User class + declare toJSON: () => any; +} + +const jsonthis = new Jsonthis({ + models: [User] // This will instruct Jsonthis! to implement the toJSON() method on the User class +}); +``` + +You can then use the `toJSON()` method on your class instances, or stringify them directly with `JSON.stringify()`: + +```typescript +const user = new User(); +console.log(user.toJSON()); // This will return a JSON-compatible object +console.log(JSON.stringify(user)); // This will return the JSON string of the object ``` -Additionally, the `@JsonField` decorator empowers you to fine-tune the serialization process of your properties -with Jsonthis. You can define custom serializers, change property visibility, and more. +Alternatively, you can use the `toJson()` method on the Jsonthis! instance, which allows for more customization: + +```typescript +const user = new User(); +const jsonUser = jsonthis.toJson(user, /* options */); +console.log(jsonUser); // The object resulting from the serialization process +console.log(JSON.stringify(jsonUser)); // This will return the JSON string of the object +``` ## Change Property Visibility @@ -88,7 +142,7 @@ class User { ### Conditional Visibility -Jsonthis supports conditional property visibility based on a user-defined context. +Jsonthis! supports conditional property visibility based on a user-defined context. This allows you to dynamically show or hide properties as needed. In the following example, the `email` property is only visible if the email owner is requesting it: @@ -108,11 +162,13 @@ class User { this.id = id; this.email = email; } + + declare toJSON: () => any; } const user = new User(1, "john.doe@gmail.com"); -const jsonthis = new Jsonthis(); +const jsonthis = new Jsonthis({models: [User]}); console.log(jsonthis.toJson(user, {context: {callerId: 1}})); // { id: 1, email: 'john.doe@gmail.com' } @@ -126,7 +182,7 @@ This also works with nested objects: const user = new User(1, "john.doe@gmail.com"); user.friend = new User(2, "jane.doe@gmail.com"); -const jsonthis = new Jsonthis(); +const jsonthis = new Jsonthis({models: [User]}); console.log(jsonthis.toJson(user, {context: {callerId: 1}})); // { id: 1, email: 'john.doe@gmail.com', friend: { id: 2 } } @@ -138,8 +194,8 @@ console.log(jsonthis.toJson(user, {context: {callerId: 2}})); ### Change Property Name Casing -Jsonthis allows you to enforce specific casing for property names in the JSON output. -By default, Jsonthis uses whatever casing you use in your TypeScript code, +Jsonthis! allows you to enforce specific casing for property names in the JSON output. +By default, Jsonthis! uses whatever casing you use in your TypeScript code, but you can change it to `camelCase`, `snake_case`, or `PascalCase`: ```typescript @@ -147,22 +203,32 @@ class User { id: number = 123; user_name: string = "john-doe"; registeredAt: Date = new Date(); + + declare toJSON: () => any; } const user = new User(); -console.log(new Jsonthis().toJson(user)); -// { id: 123, user_name: 'john-doe', registeredAt: 2024-04-13T20:42:22.121Z } -console.log(new Jsonthis({case: "camel"}).toJson(user)); -// { id: 123, userName: 'john-doe', registeredAt: 2024-04-13T20:42:22.121Z } -console.log(new Jsonthis({case: "snake"}).toJson(user)); -// { id: 123, user_name: 'john-doe', registered_at: 2024-04-13T20:42:22.121Z } -console.log(new Jsonthis({case: "pascal"}).toJson(user)); -// { Id: 123, UserName: 'john-doe', RegisteredAt: 2024-04-13T20:42:22.121Z } + +new Jsonthis({models: [User]}); +console.log(user.toJSON()); +// { id: 123, user_name: 'john-doe', registeredAt: 2024-04-27T17:03:52.158Z } + +new Jsonthis({case: "camel", models: [User]}); +console.log(user.toJSON()); +// { id: 123, userName: 'john-doe', registeredAt: 2024-04-27T17:03:52.158Z } + +new Jsonthis({case: "snake", models: [User]}); +console.log(user.toJSON()); +// { id: 123, user_name: 'john-doe', registered_at: 2024-04-27T17:03:52.158Z } + +new Jsonthis({case: "pascal", models: [User]}); +console.log(user.toJSON()); +// { Id: 123, UserName: 'john-doe', RegisteredAt: 2024-04-27T17:03:52.158Z } ``` ### Custom serializers -Jsonthis allows you to define custom serializers to transform property values during serialization. +Jsonthis! allows you to define custom serializers to transform property values during serialization. These can be either **global** or **field-specific**. #### Global Serializer @@ -177,14 +243,16 @@ function dateSerializer(value: Date): string { class User { id: number = 1; registeredAt: Date = new Date(); + + declare toJSON: () => any; } -const jsonthis = new Jsonthis(); +const jsonthis = new Jsonthis({models: [User]}); jsonthis.registerGlobalSerializer(Date, dateSerializer); const user = new User(); -console.log(jsonthis.toJson(user)); -// { id: 1, registeredAt: 'Sat, 13 Apr 2024 15:50:35 GMT' } +console.log(user.toJSON()); +// { id: 1, registeredAt: 'Sat, 27 Apr 2024 17:03:52 GMT' } ``` #### Field-Specific Serializer @@ -200,17 +268,20 @@ class User { id: number = 1; @JsonField({serializer: maskEmail}) email: string = "john.doe@gmail.com"; + + declare toJSON: () => any; } -const jsonthis = new Jsonthis(); +const jsonthis = new Jsonthis({models: [User]}); + const user = new User(); -console.log(jsonthis.toJson(user)); +console.log(user.toJSON()); // { id: 1, email: 'j******e@gmail.com' } ``` ### Contextual Field-Specific Serializer -Jsonthis serialization supports a user-defined context object that can be used to further influence the serialization +Jsonthis! serialization supports a user-defined context object that can be used to further influence the serialization process: ```typescript @@ -222,9 +293,12 @@ class User { id: number = 1; @JsonField({serializer: maskEmail}) email: string = "john.doe@gmail.com"; + + declare toJSON: () => any; } -const jsonthis = new Jsonthis(); +const jsonthis = new Jsonthis({models: [User]}); + const user = new User(); console.log(jsonthis.toJson(user, {context: {maskChar: "-"}})); // { id: 1, email: 'j------e@gmail.com' } @@ -245,33 +319,37 @@ class User { this.id = id; this.name = name; } + + declare toJSON: () => any; } const user = new User(1, "John"); user.friend = new User(2, "Jane"); user.friend.friend = new User(3, "Bob"); -const jsonthis = new Jsonthis({maxDepth: 1}); -console.log(jsonthis.toJson(user)); +const jsonthis = new Jsonthis({maxDepth: 1, models: [User]}); + +console.log(user.toJSON()); // { id: 1, name: 'John', friend: { id: 2, name: 'Jane' } } ``` You can also set the `maxDepth` option at the method level in `ToJsonOptions`: ```typescript -const jsonthis = new Jsonthis(); +const jsonthis = new Jsonthis({models: [User]}); + console.log(jsonthis.toJson(user, {maxDepth: 1})); // { id: 1, name: 'John', friend: { id: 2, name: 'Jane' } } ``` ## Circular References -Jsonthis can detect circular references out of the box. When serializing an object with circular references, the default +Jsonthis! can detect circular references out of the box. When serializing an object with circular references, the default behavior is to throw a `CircularReferenceError`. However, you can customize this behavior by providing a custom handler: ```typescript function serializeCircularReference(value: any): any { - return { $ref: `$${value.constructor.name}(${value.id})` }; + return {$ref: `$${value.constructor.name}(${value.id})`}; } class User { @@ -283,14 +361,17 @@ class User { this.id = id; this.name = name; } + + declare toJSON: () => any; } +const jsonthis = new Jsonthis({models: [User], circularReferenceSerializer: serializeCircularReference}); + const user = new User(1, "John"); user.friend = new User(2, "Jane"); user.friend.friend = user; -const jsonthis = new Jsonthis({circularReferenceSerializer: serializeCircularReference}); -console.log(jsonthis.toJson(user)); +console.log(user.toJSON()); // { // id: 1, // name: 'John', @@ -299,8 +380,8 @@ console.log(jsonthis.toJson(user)); ``` ## Sequelize support -Jsonthis seamlessly integrates with the [Sequelize](https://sequelize.org/) ORM library. -To utilize Jsonthis with Sequelize, simply specify it in the library constructor: +Jsonthis! seamlessly integrates with the [Sequelize](https://sequelize.org/) ORM library. +To utilize Jsonthis! with Sequelize, simply specify it in the library constructor: ```typescript const sequelize = new Sequelize({ ... }); @@ -310,7 +391,7 @@ const jsonthis = new Jsonthis({ }); ``` -Now, Jsonthis will seamlessly intercept the serialization process when using the `toJSON()` method +Now, Jsonthis! will seamlessly intercept the serialization process when using the `toJSON()` method with Sequelize models: ```typescript diff --git a/src/jsonthis.test.ts b/src/jsonthis.test.ts index eda8ecc..2f62e50 100644 --- a/src/jsonthis.test.ts +++ b/src/jsonthis.test.ts @@ -3,102 +3,91 @@ import {JsonField, JsonSchema, JsonTraversalState} from "./schema"; function sequelize(jsonthis: Jsonthis, ...models: any) { for (const model of models) { - // This is the same construct used to support Sequelize models. - jsonthis.registerGlobalSerializer(model, (value: any, state: JsonTraversalState, options?: ToJsonOptions) => { - const data = Object.assign({}, value); - const schema = JsonSchema.get(model); - return jsonthis.toJson(data, options, state, schema); - }); - - model.prototype.toJSON = function () { - return jsonthis.toJson(this); + // Define get() function used in sequelizeInstall() + model.prototype.get = function () { + return Object.assign({}, this); } } + (jsonthis as any).sequelizeInstall(models); +} + +type ToJsonFn = (jsonthis: Jsonthis, value: any, options?: ToJsonOptions) => any; + +const Jsonthis_toJson: ToJsonFn = function (jsonthis: Jsonthis, value: any, options?: ToJsonOptions): any { + return jsonthis.toJson(value, options); +} + +const model_toJSON: ToJsonFn = function (jsonthis: Jsonthis, value: any, options?: ToJsonOptions): any { + return value.toJSON(options); } describe("Jsonthis class", () => { - describe("registerGlobalSerializer method", () => { - function dateSerializer(value: Date): string { - return value.toISOString(); + it("should create schema for model classes", () => { + class User { } - it("should register a global serializer for a class", () => { - class User { - public registeredAt: Date = new Date(); + const jsonthis = new Jsonthis({models: [User]}); + expect(JsonSchema.isPresent(User)).toBeTruthy(); + }); + + describe.each([ + ["Jsonthis.toJson()", Jsonthis_toJson], + ["Model.toJSON()", model_toJSON] + ])("%s method", (_, toJson: ToJsonFn) => { + describe("with global serializer", () => { + function dateSerializer(value: Date): string { + return value.toISOString(); } - const user = new User(); + it("should register a global serializer for a class", () => { + class User { + public registeredAt: Date = new Date(); + } - expect(new Jsonthis().toJson(user)).toStrictEqual({ - registeredAt: user.registeredAt - }); + const user = new User(); - const jsonthis = new Jsonthis(); - jsonthis.registerGlobalSerializer(Date, dateSerializer); + expect(toJson(new Jsonthis({models: [User]}), user)).toStrictEqual({ + registeredAt: user.registeredAt + }); - expect(jsonthis.toJson(user)).toStrictEqual({ - registeredAt: user.registeredAt.toISOString() + const jsonthis = new Jsonthis({models: [User]}); + jsonthis.registerGlobalSerializer(Date, dateSerializer); + + expect(toJson(jsonthis, user)).toStrictEqual({ + registeredAt: user.registeredAt.toISOString() + }); }); - }); - it("should throw an error when trying to register a serializer for a class that already has one", () => { - const jsonthis = new Jsonthis(); - jsonthis.registerGlobalSerializer(Date, dateSerializer); + it("should throw an error when trying to register a serializer for a class that already has one", () => { + const jsonthis = new Jsonthis(); + jsonthis.registerGlobalSerializer(Date, dateSerializer); - expect(() => jsonthis.registerGlobalSerializer(Date, dateSerializer)) - .toThrow("Serializer already registered for \"Date\""); - }); + expect(() => jsonthis.registerGlobalSerializer(Date, dateSerializer)) + .toThrow("Serializer already registered for \"Date\""); + }); - it("should override a global serializer for a class if the override option is set", () => { - function overridingDateSerializer(value: Date): string { - return value.toUTCString(); - } + it("should override a global serializer for a class if the override option is set", () => { + function overridingDateSerializer(value: Date): string { + return value.toUTCString(); + } - class User { - public registeredAt: Date = new Date(); - } - const user = new User(); + class User { + public registeredAt: Date = new Date(); + } - const jsonthis = new Jsonthis(); + const user = new User(); - jsonthis.registerGlobalSerializer(Date, dateSerializer); - expect(jsonthis.toJson(user)).toStrictEqual({ - registeredAt: user.registeredAt.toISOString() - }); + const jsonthis = new Jsonthis({models: [User]}); - jsonthis.registerGlobalSerializer(Date, overridingDateSerializer, true); - expect(jsonthis.toJson(user)).toStrictEqual({ - registeredAt: user.registeredAt.toUTCString() - }); - }); - }); + jsonthis.registerGlobalSerializer(Date, dateSerializer); + expect(toJson(jsonthis, user)).toStrictEqual({ + registeredAt: user.registeredAt.toISOString() + }); - describe("toJson method", () => { - describe("with simple data types", () => { - it("should serialize a string", () => { - expect(new Jsonthis().toJson("hello word")).toBe("hello word"); - }); - it("should serialize a number", () => { - expect(new Jsonthis().toJson(123)).toBe(123); - }); - it("should serialize a BigInt (number)", () => { - expect(new Jsonthis().toJson(123n)).toBe(123); - }); - it("should serialize a BigInt (unsafe)", () => { - expect(new Jsonthis().toJson(9007199254740992n)).toBe("9007199254740992"); - }); - it("should serialize a boolean", () => { - expect(new Jsonthis().toJson(true)).toBe(true); - }); - it("should serialize a Date", () => { - const date = new Date(); - expect(new Jsonthis().toJson(date)).toBe(date); - }); - it("should serialize an object", () => { - expect(new Jsonthis().toJson({value: 123})).toStrictEqual({value: 123}); - }); - it("should serialize an array", () => { - expect(new Jsonthis().toJson([1, "hello"])).toStrictEqual([1, "hello"]); + jsonthis.registerGlobalSerializer(Date, overridingDateSerializer, true); + expect(toJson(jsonthis, user)).toStrictEqual({ + registeredAt: user.registeredAt.toUTCString() + }); }); }); @@ -121,7 +110,7 @@ describe("Jsonthis class", () => { const user = new User(); - expect(new Jsonthis().toJson(user)).toStrictEqual({ + expect(toJson(new Jsonthis({models: [User]}), user)).toStrictEqual({ id: 123, serial: "9007199254740992", age: 25, @@ -149,10 +138,10 @@ describe("Jsonthis class", () => { const user = new User(1, "John"); user.friend = new User(2, "Jane"); - const jsonthis = new Jsonthis(); + const jsonthis = new Jsonthis({models: [User]}); jsonthis.registerGlobalSerializer(Date, dateSerializer); - expect(jsonthis.toJson(user)).toStrictEqual({ + expect(toJson(jsonthis, user)).toStrictEqual({ id: 1, name: "John", registeredAt: user.registeredAt.toISOString(), @@ -174,7 +163,7 @@ describe("Jsonthis class", () => { name: string = "John"; } - expect(new Jsonthis().toJson(new User())).toStrictEqual({ + expect(toJson(new Jsonthis({models: [User]}), new User())).toStrictEqual({ id: 123, name: "John" }); @@ -187,7 +176,7 @@ describe("Jsonthis class", () => { password: string = "s3cret"; } - expect(new Jsonthis().toJson(new User())).toStrictEqual({ + expect(toJson(new Jsonthis({models: [User]}), new User())).toStrictEqual({ id: 123 }); }); @@ -203,11 +192,11 @@ describe("Jsonthis class", () => { email: string = "john.doe@gmail.com"; } - expect(new Jsonthis().toJson(new User(), {context: {callerId: 1}})).toStrictEqual({ + expect(toJson(new Jsonthis({models: [User]}), new User(), {context: {callerId: 1}})).toStrictEqual({ id: 1, email: "john.doe@gmail.com" }); - expect(new Jsonthis().toJson(new User(), {context: {callerId: 2}})).toStrictEqual({ + expect(toJson(new Jsonthis({models: [User]}), new User(), {context: {callerId: 2}})).toStrictEqual({ id: 1 }); }); @@ -225,7 +214,7 @@ describe("Jsonthis class", () => { serial: number = 435297235; } - expect(new Jsonthis().toJson(new User())).toStrictEqual({ + expect(toJson(new Jsonthis({models: [User]}), new User())).toStrictEqual({ id: 1, serial: "0x19f21bd3" }); @@ -244,7 +233,7 @@ describe("Jsonthis class", () => { aliases: string[] = ["john.doe-1@gmail.com", "john.doe-2@hotmail.com"]; } - expect(new Jsonthis().toJson(new User())).toStrictEqual({ + expect(toJson(new Jsonthis({models: [User]}), new User())).toStrictEqual({ id: 1, email: "j******e@gmail.com", aliases: ["j********1@gmail.com", "j********2@hotmail.com"] @@ -265,12 +254,12 @@ describe("Jsonthis class", () => { aliases: string[] = ["john.doe-1@gmail.com", "john.doe-2@hotmail.com"]; } - expect(new Jsonthis().toJson(new User())).toStrictEqual({ + expect(toJson(new Jsonthis({models: [User]}), new User())).toStrictEqual({ id: 1, email: "j******e@gmail.com", aliases: ["j********1@gmail.com", "j********2@hotmail.com"] }); - expect(new Jsonthis().toJson(new User(), {context: {maskChar: "-"}})).toStrictEqual({ + expect(toJson(new Jsonthis({models: [User]}), new User(), {context: {maskChar: "-"}})).toStrictEqual({ id: 1, email: "j------e@gmail.com", aliases: ["j--------1@gmail.com", "j--------2@hotmail.com"] @@ -286,13 +275,13 @@ describe("Jsonthis class", () => { } it("should serialize null values when keepNulls is true", () => { - const jsonthis = new Jsonthis({keepNulls: true}); - expect(jsonthis.toJson(new User())).toStrictEqual({id: 1, name: null}); + const jsonthis = new Jsonthis({keepNulls: true, models: [User]}); + expect(toJson(jsonthis, new User())).toStrictEqual({id: 1, name: null}); }); it("should skip null values when keepNulls is false", () => { - const jsonthis = new Jsonthis({keepNulls: false}); - expect(jsonthis.toJson(new User())).toStrictEqual({id: 1}); + const jsonthis = new Jsonthis({keepNulls: false, models: [User]}); + expect(toJson(jsonthis, new User())).toStrictEqual({id: 1}); }); }); @@ -306,7 +295,7 @@ describe("Jsonthis class", () => { const user = new User(); it("should serialize with camel casing", () => { - expect(new Jsonthis({case: "camel"}).toJson(user)).toStrictEqual({ + expect(toJson(new Jsonthis({case: "camel", models: [User]}), user)).toStrictEqual({ id: 1, userName: "john-doe", registeredAt: user.registeredAt @@ -314,7 +303,7 @@ describe("Jsonthis class", () => { }); it("should serialize with snake casing", () => { - expect(new Jsonthis({case: "snake"}).toJson(user)).toStrictEqual({ + expect(toJson(new Jsonthis({case: "snake", models: [User]}), user)).toStrictEqual({ id: 1, user_name: "john-doe", registered_at: user.registeredAt @@ -322,7 +311,7 @@ describe("Jsonthis class", () => { }); it("should serialize with pascal casing", () => { - expect(new Jsonthis({case: "pascal"}).toJson(user)).toStrictEqual({ + expect(toJson(new Jsonthis({case: "pascal", models: [User]}), user)).toStrictEqual({ Id: 1, UserName: "john-doe", RegisteredAt: user.registeredAt @@ -330,18 +319,16 @@ describe("Jsonthis class", () => { }); }); - describe("with context", () => { + describe.each([ + ["simple Objects", false], + ["Sequelize models", true] + ])("with context on %s", (_, withSequelize) => { function contextualMaskEmail(value: string, state: JsonTraversalState, opts?: ToJsonOptions): string { const maskChar = opts?.context?.maskChar || "*"; return value.replace(/(?<=.).(?=[^@]*?.@)/g, maskChar); } - const testCases = [ - ["simple Objects", false], - ["Sequelize models", true], - ]; - - it.each(testCases)("on %s should serializer using context", (_, withSequelize) => { + it("should serializer using context", () => { class User { id: number; @JsonField({serializer: contextualMaskEmail}) @@ -354,21 +341,21 @@ describe("Jsonthis class", () => { const user = new User(1); - const jsonthis = new Jsonthis(); + const jsonthis = new Jsonthis({models: [User]}); if (withSequelize) sequelize(jsonthis, User); - expect(jsonthis.toJson(user)).toStrictEqual({ + expect(toJson(jsonthis, user)).toStrictEqual({ id: 1, email: "j******e@gmail.com" }); - expect(jsonthis.toJson(user, {context: {maskChar: "-"}})).toStrictEqual({ + expect(toJson(jsonthis, user, {context: {maskChar: "-"}})).toStrictEqual({ id: 1, email: "j------e@gmail.com" }); }); - it.each(testCases)("on %s should pass context to nested objects", (_, withSequelize) => { + it("should pass context to nested objects", () => { class User { id: number; @JsonField({serializer: contextualMaskEmail}) @@ -384,10 +371,10 @@ describe("Jsonthis class", () => { const user = new User(1, "john.doe@gmail.com"); user.friend = new User(2, "bob.doe@hotmail.com"); - const jsonthis = new Jsonthis(); + const jsonthis = new Jsonthis({models: [User]}); if (withSequelize) sequelize(jsonthis, User); - expect(jsonthis.toJson(user)).toStrictEqual({ + expect(toJson(jsonthis, user)).toStrictEqual({ id: 1, email: "j******e@gmail.com", friend: { @@ -396,7 +383,7 @@ describe("Jsonthis class", () => { } }); - expect(jsonthis.toJson(user, {context: {maskChar: "-"}})).toStrictEqual({ + expect(toJson(jsonthis, user, {context: {maskChar: "-"}})).toStrictEqual({ id: 1, email: "j------e@gmail.com", friend: { @@ -407,19 +394,17 @@ describe("Jsonthis class", () => { }); }); - describe("with circular references", () => { + describe.each([ + ["simple Objects", false, false], + ["simple Objects and custom C-REF serializer", false, true], + ["Sequelize models", true, false], + ["Sequelize models and custom C-REF serializer", true, true] + ])("with circular references on %s", (_, withSequelize, withCRSerializer) => { function circularReferenceSerializer(ref: any) { return {"$ref": `$${ref.constructor.name}(${ref.value || ref.id})`} } - const testCases = [ - ["simple Objects", false, false], - ["simple Objects and custom C-REF serializer", false, true], - ["Sequelize models", true, false], - ["Sequelize models and custom C-REF serializer", true, true] - ]; - - it.each(testCases)("on %s with direct circular reference", (_, withSequelize, withCRSerializer) => { + it("with direct circular reference", () => { class Node { public value: number; public next?: Node; @@ -433,12 +418,12 @@ describe("Jsonthis class", () => { node.next = new Node(2); node.next.next = node; - const jsonthis = new Jsonthis(withCRSerializer ? {circularReferenceSerializer} : {}); + const jsonthis = new Jsonthis(Object.assign({models: [Node]}, withCRSerializer ? {circularReferenceSerializer} : {})); if (withSequelize) sequelize(jsonthis, Node); if (withCRSerializer) { - expect(() => jsonthis.toJson(node)).not.toThrow(CircularReferenceError); - expect(jsonthis.toJson(node)).toStrictEqual({ + expect(() => toJson(jsonthis, node)).not.toThrow(CircularReferenceError); + expect(toJson(jsonthis, node)).toStrictEqual({ value: 1, next: { value: 2, @@ -446,12 +431,12 @@ describe("Jsonthis class", () => { } }); } else { - expect(() => jsonthis.toJson(node)).toThrow(CircularReferenceError); + expect(() => toJson(jsonthis, node)).toThrow(CircularReferenceError); } }); - it.each(testCases)("on %s with nested circular reference", (_, withSequelize, withCRSerializer) => { + it("with nested circular reference", () => { class Node { public value: number; public next?: Node; @@ -466,12 +451,12 @@ describe("Jsonthis class", () => { node.next.next = new Node(3); node.next.next.next = node; - const jsonthis = new Jsonthis(withCRSerializer ? {circularReferenceSerializer} : {}); + const jsonthis = new Jsonthis(Object.assign({models: [Node]}, withCRSerializer ? {circularReferenceSerializer} : {})); if (withSequelize) sequelize(jsonthis, Node); if (withCRSerializer) { - expect(() => jsonthis.toJson(node)).not.toThrow(CircularReferenceError); - expect(jsonthis.toJson(node)).toStrictEqual({ + expect(() => toJson(jsonthis, node)).not.toThrow(CircularReferenceError); + expect(toJson(jsonthis, node)).toStrictEqual({ value: 1, next: { value: 2, @@ -482,11 +467,11 @@ describe("Jsonthis class", () => { } }); } else { - expect(() => jsonthis.toJson(node)).toThrow(CircularReferenceError); + expect(() => toJson(jsonthis, node)).toThrow(CircularReferenceError); } }); - it.each(testCases)("on %s should be able to serialize a direct duplicated property", (_, withSequelize, withCRSerializer) => { + it("should be able to serialize a direct duplicated property", () => { class User { public id: number; public registeredAt: Date; @@ -500,18 +485,18 @@ describe("Jsonthis class", () => { const user = new User(1); - const jsonthis = new Jsonthis(withCRSerializer ? {circularReferenceSerializer} : {}); + const jsonthis = new Jsonthis(Object.assign({models: [User]}, withCRSerializer ? {circularReferenceSerializer} : {})); if (withSequelize) sequelize(jsonthis, User); - expect(() => jsonthis.toJson(user)).not.toThrow(CircularReferenceError); - expect(jsonthis.toJson(user)).toStrictEqual({ + expect(() => toJson(jsonthis, user)).not.toThrow(CircularReferenceError); + expect(toJson(jsonthis, user)).toStrictEqual({ id: 1, registeredAt: user.registeredAt, updatedAt: user.updatedAt }); }); - it.each(testCases)("on %s should be able to serialize a nested duplicated property", (_, withSequelize, withCRSerializer) => { + it("should be able to serialize a nested duplicated property", () => { const date = new Date(); class User { @@ -528,11 +513,11 @@ describe("Jsonthis class", () => { const user = new User(1, date); user.friend = new User(2, date); - const jsonthis = new Jsonthis(withCRSerializer ? {circularReferenceSerializer} : {}); + const jsonthis = new Jsonthis(Object.assign({models: [User]}, withCRSerializer ? {circularReferenceSerializer} : {})); if (withSequelize) sequelize(jsonthis, User); - expect(() => jsonthis.toJson(user)).not.toThrow(CircularReferenceError); - expect(jsonthis.toJson(user)).toStrictEqual({ + expect(() => toJson(jsonthis, user)).not.toThrow(CircularReferenceError); + expect(toJson(jsonthis, user)).toStrictEqual({ id: 1, registeredAt: date, friend: { @@ -542,7 +527,7 @@ describe("Jsonthis class", () => { }); }); - it.each(testCases)("on %s should be able to serialize an Object referenced twice", (_, withSequelize, withCRSerializer) => { + it("should be able to serialize an Object referenced twice", () => { class User { public id: number; public roommate?: User; @@ -556,18 +541,18 @@ describe("Jsonthis class", () => { const user = new User(1); user.roommate = user.friend = new User(2); - const jsonthis = new Jsonthis(withCRSerializer ? {circularReferenceSerializer} : {}); + const jsonthis = new Jsonthis(Object.assign({models: [User]}, withCRSerializer ? {circularReferenceSerializer} : {})); if (withSequelize) sequelize(jsonthis, User); - expect(() => jsonthis.toJson(user)).not.toThrow(CircularReferenceError); - expect(jsonthis.toJson(user)).toStrictEqual({ + expect(() => toJson(jsonthis, user)).not.toThrow(CircularReferenceError); + expect(toJson(jsonthis, user)).toStrictEqual({ id: 1, roommate: {id: 2}, friend: {id: 2} }); }); - it.each(testCases)("on %s should be able to serialize an Object referenced twice in different sub-trees", (_, withSequelize, withCRSerializer) => { + it("should be able to serialize an Object referenced twice in different sub-trees", () => { class User { public id: number; public roommate?: User; @@ -583,11 +568,11 @@ describe("Jsonthis class", () => { user.friend = new User(3); user.roommate.friend = user.friend.friend = new User(4); - const jsonthis = new Jsonthis(withCRSerializer ? {circularReferenceSerializer} : {}); + const jsonthis = new Jsonthis(Object.assign({models: [User]}, withCRSerializer ? {circularReferenceSerializer} : {})); if (withSequelize) sequelize(jsonthis, User); - expect(() => jsonthis.toJson(user)).not.toThrow(CircularReferenceError); - expect(jsonthis.toJson(user)).toStrictEqual({ + expect(() => toJson(jsonthis, user)).not.toThrow(CircularReferenceError); + expect(toJson(jsonthis, user)).toStrictEqual({ id: 1, roommate: { id: 2, @@ -615,8 +600,8 @@ describe("Jsonthis class", () => { const user = new User(1, new User(2, new User(3, new User(4)))); it("should serialize to unlimited depth by default", () => { - const jsonthis = new Jsonthis(); - expect(jsonthis.toJson(user)).toStrictEqual({ + const jsonthis = new Jsonthis({models: [User]}); + expect(toJson(jsonthis, user)).toStrictEqual({ id: 1, friend: { id: 2, @@ -629,8 +614,8 @@ describe("Jsonthis class", () => { }); it("should stop serialization to global maxDepth", () => { - const jsonthis = new Jsonthis({maxDepth: 2}); - expect(jsonthis.toJson(user)).toStrictEqual({ + const jsonthis = new Jsonthis({maxDepth: 2, models: [User]}); + expect(toJson(jsonthis, user)).toStrictEqual({ id: 1, friend: { id: 2, @@ -640,20 +625,88 @@ describe("Jsonthis class", () => { }); it("should stop serialization to field's maxDepth", () => { - const jsonthis = new Jsonthis(); - expect(jsonthis.toJson(user, {maxDepth: 1})).toStrictEqual({ + const jsonthis = new Jsonthis({models: [User]}); + expect(toJson(jsonthis, user, {maxDepth: 1})).toStrictEqual({ id: 1, friend: {id: 2} }); }); it("should stop serialization to field's maxDepth over global maxDepth", () => { - const jsonthis = new Jsonthis({maxDepth: 2}); - expect(jsonthis.toJson(user, {maxDepth: 1})).toStrictEqual({ + const jsonthis = new Jsonthis({maxDepth: 2, models: [User]}); + expect(toJson(jsonthis, user, {maxDepth: 1})).toStrictEqual({ id: 1, friend: {id: 2} }); }); }); }); + + describe("Jsonthis.toJson() with simple data types", () => { + it("should serialize a string", () => { + expect(new Jsonthis().toJson("hello word")).toBe("hello word"); + }); + it("should serialize a number", () => { + expect(new Jsonthis().toJson(123)).toBe(123); + }); + it("should serialize a BigInt (number)", () => { + expect(new Jsonthis().toJson(123n)).toBe(123); + }); + it("should serialize a BigInt (unsafe)", () => { + expect(new Jsonthis().toJson(9007199254740992n)).toBe("9007199254740992"); + }); + it("should serialize a boolean", () => { + expect(new Jsonthis().toJson(true)).toBe(true); + }); + it("should serialize a Date", () => { + const date = new Date(); + expect(new Jsonthis().toJson(date)).toBe(date); + }); + it("should serialize an object", () => { + expect(new Jsonthis().toJson({value: 123})).toStrictEqual({value: 123}); + }); + it("should serialize an array", () => { + expect(new Jsonthis().toJson([1, "hello"])).toStrictEqual([1, "hello"]); + }); + }); + + describe("Jsonthis and Javascript JSON.stringify() compatibility", () => { + it("should default-serialize a non-Jsonthis model", () => { + class User { + id: number = 1; + userName: string = "john-doe"; + @JsonField(false) + password: string = "s3cret"; + } + + const jsonthis = new Jsonthis({case: "snake"}); + + const user = new User(); + expect(jsonthis.toJson(user)).toStrictEqual({"id": 1, "user_name": "john-doe"}); + expect(JSON.stringify(user)).toStrictEqual('{"id":1,"userName":"john-doe","password":"s3cret"}'); + }); + + it("should use Jsonthis serialization when a Jsonthis model is passed", () => { + class User { + id: number = 1; + userName: string = "john-doe"; + @JsonField(false) + password: string = "s3cret"; + + toJSON(): any { + throw new Error("Method not implemented."); + } + } + + const jsonthis = new Jsonthis({ + case: "snake", + models: [User] + }); + + const user = new User(); + expect(jsonthis.toJson(user)).toStrictEqual({"id": 1, "user_name": "john-doe"}); + expect(user.toJSON()).toStrictEqual({"id": 1, "user_name": "john-doe"}); + expect(JSON.stringify(user)).toStrictEqual('{"id":1,"user_name":"john-doe"}'); + }); + }); }); \ No newline at end of file diff --git a/src/jsonthis.ts b/src/jsonthis.ts index 434d3d4..a5bcc8c 100644 --- a/src/jsonthis.ts +++ b/src/jsonthis.ts @@ -23,6 +23,7 @@ export type JsonthisOptions = { sequelize?: Sequelize; // Install Jsonthis to this Sequelize instance. circularReferenceSerializer?: JsonTraversalFn; // The custom serializer function for circular references, default it to throw an error. maxDepth?: number; // The maximum depth to traverse the object, default is unlimited. + models?: Function[]; // The model classes to install Jsonthis' toJSON() method. } /** @@ -54,8 +55,19 @@ export class Jsonthis { constructor(options?: JsonthisOptions) { this.options = options || {}; + if (this.options.models) { + const self = this; + + for (const model of this.options.models) { + const schema = JsonSchema.getOrCreate(model); + model.prototype.toJSON = function (options?: ToJsonOptions) { + return self.toJson(this, options, undefined, schema); + } + } + } + if (this.options.sequelize) - this.sequelizeInstall(this.options.sequelize); + this.sequelizeInstall(this.options.sequelize.models); } /** @@ -175,7 +187,7 @@ export class Jsonthis { private serializeTrivialValue(value: any): [any, boolean] { switch (typeof value) { case "object": - if ('toJSON' in value && typeof value.toJSON === "function") + if ('toJSON' in value && typeof value.toJSON === "function" && !JsonSchema.isPresent(value.constructor)) return [value, true] else return [value, false]; @@ -196,8 +208,8 @@ export class Jsonthis { } - private sequelizeInstall(sequelize: Sequelize) { - for (const model of sequelize.models) { + private sequelizeInstall(models: Iterable) { + for (const model of models) { const schema = JsonSchema.get(model); this.registerGlobalSerializer(model, (model: Model, state: JsonTraversalState, options?: ToJsonOptions) => { @@ -205,10 +217,9 @@ export class Jsonthis { }); const jsonthis = this; - model.prototype.toJSON = function () { - return jsonthis.toJson(this); + model.prototype.toJSON = function (options?: ToJsonOptions) { + return jsonthis.toJson(this, options); } } - } } diff --git a/src/schema.test.ts b/src/schema.test.ts index dd01e03..04b53ff 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -1,7 +1,7 @@ import {JsonField, JsonifiedConstructor, JsonSchema} from "./schema"; describe("JsonSchema class", () => { - describe("getOrCreate method", () => { + describe("getOrCreate() method", () => { class User { } @@ -16,7 +16,7 @@ describe("JsonSchema class", () => { }); }); - describe("get method", () => { + describe("get() method", () => { class User { } @@ -43,6 +43,20 @@ describe("JsonSchema class", () => { expect(schema2!.definedFields.size).toBe(0); }); }); + + describe("isPresent() method", () => { + class User { + } + + it("should return false if schema is not present", () => { + expect(JsonSchema.isPresent(User)).toBeFalsy(); + }); + + it("should return true if schema is present", () => { + JsonSchema.getOrCreate(User); // Force schema creation + expect(JsonSchema.isPresent(User)).toBeTruthy(); + }); + }); }); describe("@JsonField decorator", () => { diff --git a/src/schema.ts b/src/schema.ts index f25e8ae..ccf4ad6 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -137,4 +137,8 @@ export class JsonSchema { return (target as JsonifiedConstructor)["__json_schema"]; } + static isPresent(target: unknown): boolean { + return !!JsonSchema.get(target); + } + } \ No newline at end of file