├── .gitignore ├── lib ├── index.ts ├── utils.ts ├── constants.ts ├── Serializable.ts ├── Model.ts ├── JSONAdapter.ts └── ValueTransformer.ts ├── .npmignore ├── .travis.yml ├── gulpfile.js ├── tsconfig.json ├── LICENSE.md ├── gulpfile-base.js ├── package.json ├── __tests__ ├── Model.spec.ts ├── ExampleWithout.spec.ts ├── Example.spec.ts ├── ValueTransformer.spec.ts ├── TestModel.ts └── JSONAdapter.spec.ts ├── tslint.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .coverage/ 3 | dist/ 4 | doc/ 5 | package-lock.json 6 | .vscode -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./JSONAdapter"; 2 | export * from "./Serializable"; 3 | export * from "./Model"; 4 | export * from "./ValueTransformer"; 5 | export * from "./constants"; 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .coverage/ 3 | doc/ 4 | lib/ 5 | __tests__/ 6 | gulpfile.js 7 | gulpfile-base.js 8 | package-lock.json 9 | tsconfig.json 10 | tslint.json 11 | .travis.yml -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8.10" 4 | before_install: 5 | - npm install codecov -g 6 | install: 7 | - npm install 8 | script: 9 | - npm run build 10 | - codecov -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @hidden 3 | */ 4 | export function CreateError(msg: string, name: string): Error { 5 | const error = new Error(msg); 6 | error.name = name; 7 | 8 | return error; 9 | } 10 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorTypes { 2 | /** 3 | * classForParsingObject returned null for the given object. 4 | */ 5 | JSONAdapterNoClassFound = "JSONAdapterNoClassFoundError", 6 | 7 | /** 8 | * The provided JSON is not valid. 9 | */ 10 | JSONAdapterInvalidJSON = "JSONAdapterInvalidJSONError", 11 | 12 | /** 13 | * Used to indicate that the input valie was invalid. 14 | */ 15 | TransformerHandlingInvalidInput = "TransformerHandlingInvalidInput", 16 | } 17 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const gulp = require("gulp"); 4 | const gulp_base = require("./gulpfile-base"); 5 | const del = require("del"); 6 | 7 | gulp.task("tsc", gulp_base.tsc); 8 | 9 | gulp.task("tslint", gulp_base.tslint); 10 | 11 | gulp.task("test", gulp_base.test); 12 | 13 | gulp.task("doc", gulp_base.doc); 14 | 15 | gulp.task("clean", () => { 16 | return del([".coverage", "dist", "doc"]); 17 | }); 18 | 19 | gulp.task("default", gulp.series("clean", gulp.parallel("tsc", "tslint", "test", "doc"))); 20 | gulp.task("release", gulp.series("default")); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "lib": [ 6 | "es6" 7 | ], 8 | "moduleResolution": "node", 9 | "rootDir": "lib", 10 | "outDir": "dist", 11 | "sourceMap": true, 12 | "alwaysStrict": true, 13 | "allowJs": false, 14 | "noImplicitAny": true, 15 | "noUnusedLocals": true, 16 | "noImplicitThis": true, 17 | "strictNullChecks": true, 18 | "noImplicitReturns": true, 19 | "preserveConstEnums": true, 20 | "suppressImplicitAnyIndexErrors": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "strict": true, 23 | "declaration": true 24 | }, 25 | "include": [ 26 | "lib" 27 | ] 28 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Mihail Cristian Dumitru 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /gulpfile-base.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const path = require("path"); 4 | const child_process = require("child_process"); 5 | 6 | module.exports = { 7 | tsc: (done) => { 8 | const tscPath = path.normalize("./node_modules/.bin/tsc"); 9 | const command = `${tscPath} -p tsconfig.json`; 10 | 11 | child_process.execSync(command, { 12 | stdio: "inherit" 13 | }); 14 | done(); 15 | }, 16 | tslint: (done) => { 17 | const tslintPath = path.normalize('./node_modules/.bin/tslint'); 18 | const command = `${tslintPath} -p tsconfig.json -c tslint.json`; 19 | 20 | child_process.execSync(command, { 21 | stdio: 'inherit' 22 | }); 23 | done(); 24 | }, 25 | test: (done) => { 26 | const jestPath = path.normalize("./node_modules/.bin/jest"); 27 | const command = `${jestPath} --coverage`; 28 | 29 | child_process.execSync(command, { 30 | stdio: 'inherit' 31 | }); 32 | done(); 33 | }, 34 | doc: (done) => { 35 | const typedocPath = path.normalize("./node_modules/.bin/typedoc"); 36 | const command = `${typedocPath} --theme minimal --excludeExternals --excludePrivate --mode file --out doc lib/`; 37 | 38 | child_process.execSync(command, { 39 | stdio: "inherit" 40 | }); 41 | done(); 42 | }, 43 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easy-models", 3 | "version": "0.3.1", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "gulp", 9 | "test": "jest", 10 | "test:watch": "jest --watch", 11 | "test:coverage": "jest --coverage" 12 | }, 13 | "jest": { 14 | "testEnvironment": "node", 15 | "transform": { 16 | "^.+\\.tsx?$": "ts-jest" 17 | }, 18 | "moduleFileExtensions": [ 19 | "ts", 20 | "tsx", 21 | "js" 22 | ], 23 | "testMatch": [ 24 | "**/*.spec.ts" 25 | ], 26 | "coverageDirectory": ".coverage" 27 | }, 28 | "author": "Mihail Cristian Dumitru", 29 | "license": "MIT", 30 | "repository": "github:Xzya/easy-models", 31 | "bugs": "https://github.com/Xzya/easy-models/issues", 32 | "homepage": "https://github.com/Xzya/easy-models#readme", 33 | "dependencies": { 34 | "lodash.get": "^4.4.2", 35 | "lodash.isequal": "^4.5.0", 36 | "lodash.set": "^4.3.2" 37 | }, 38 | "devDependencies": { 39 | "@types/jest": "^23.3.1", 40 | "@types/lodash.get": "^4.4.4", 41 | "@types/lodash.isequal": "^4.5.3", 42 | "@types/lodash.set": "^4.3.4", 43 | "@types/node": "^10.5.4", 44 | "del": "^3.0.0", 45 | "gulp": "^4.0.0", 46 | "jest": "^23.4.2", 47 | "ts-jest": "^23.0.1", 48 | "tslint": "^5.11.0", 49 | "typedoc": "^0.11.1", 50 | "typescript": "^3.0.1" 51 | } 52 | } -------------------------------------------------------------------------------- /lib/Serializable.ts: -------------------------------------------------------------------------------- 1 | import { ValueTransformer } from "./ValueTransformer"; 2 | 3 | export type KeyPaths = { 4 | [K in keyof T]?: string | string[]; 5 | }; 6 | 7 | export type Newable = { new(...args: any[]): T; }; 8 | 9 | /** 10 | * This interface defines the minimal that classes need to implement to interact with the helpers. 11 | * 12 | * It is intended for scenarios where inheriting from {@link Model} is not feasible. 13 | */ 14 | export interface Serializable { 15 | /** 16 | * Specifies how to map property keys to different key paths in JSON. 17 | * 18 | * Values in the object can either be key paths in the JSON representation of the receiver or an array of such key 19 | * paths. If an array is used, the deserialized value will be an object containing all of the keys in the array. 20 | * 21 | * Any keys omitted will not participate in JSON serialization. 22 | * 23 | * @example 24 | * ```typescript 25 | * 26 | * public static JSONKeyPaths() { 27 | * return { 28 | * name: "POI.name", 29 | * point: ["latitude", "longitude"], 30 | * starred: "starred", 31 | * }; 32 | * } 33 | * ``` 34 | */ 35 | JSONKeyPaths(): { [key: string]: string | string[] }; 36 | 37 | /** 38 | * Specifies how to convert a JSON value to the given property key. If reversible, the transformer will also be used 39 | * to convert the property value back to JSON. 40 | * 41 | * If the receiver implements a `JSONTransformer` method, the result of that method will be used instead. 42 | * 43 | * @returns a value transformer, or undefined if no transformation should be performed. 44 | * 45 | * @param key the name of the property. 46 | */ 47 | JSONTransformerForKey?(key: string): ValueTransformer | undefined; 48 | 49 | /** 50 | * Overridden to parse the receiver as a different class, based on information in the provided object. 51 | * 52 | * This is mostly useful for class clusters, where the abstract base class would be passed, but a subclass should be 53 | * instantiated instead. 54 | * 55 | * @returns the class that should be parsed (which may be the receiver), or undefined to abort parsing (e.g. if the data is invalid). 56 | * 57 | * @param json object to check the class for. 58 | */ 59 | classForParsingObject?(json: any): Newable; 60 | } 61 | 62 | export class Serializable { 63 | 64 | } 65 | -------------------------------------------------------------------------------- /__tests__/Model.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestModel, SubclassTestModel, ValidationModel, TestModelNameMissingError, SelfValidatingModel } from "./TestModel"; 2 | 3 | describe("Model", () => { 4 | describe("merging", () => { 5 | it("should merge two models together", () => { 6 | const target = TestModel.create({ 7 | name: "foo", 8 | count: 5, 9 | }); 10 | 11 | const source = TestModel.create({ 12 | name: "bar", 13 | count: 3, 14 | }); 15 | 16 | target.mergeValues(source); 17 | 18 | expect(target.name).toEqual("bar"); 19 | expect(target.count).toEqual(8); 20 | }); 21 | 22 | it("should not modify values when merging null", () => { 23 | const target = TestModel.create({ 24 | name: "foo", 25 | }); 26 | 27 | target.mergeValue("name", null); 28 | 29 | expect(target.name).toEqual("foo"); 30 | 31 | target.mergeValues(null); 32 | 33 | expect(target.name).toEqual("foo"); 34 | }); 35 | 36 | describe("merging with model subclasses", () => { 37 | let superclass: TestModel; 38 | let subclass: SubclassTestModel; 39 | 40 | beforeEach(() => { 41 | superclass = TestModel.create({ 42 | name: "foo", 43 | count: 5, 44 | }); 45 | 46 | subclass = SubclassTestModel.create({ 47 | name: "bar", 48 | count: 3, 49 | generation: 1, 50 | role: "subclass", 51 | }); 52 | }); 53 | 54 | it("should merge from subclass model", () => { 55 | superclass.mergeValues(subclass); 56 | 57 | expect(superclass.name).toEqual("bar"); 58 | expect(superclass.count).toEqual(8); 59 | }); 60 | 61 | it("should merge from superclass model", () => { 62 | subclass.mergeValues(superclass); 63 | 64 | expect(subclass.name).toEqual("foo"); 65 | expect(subclass.count).toEqual(8); 66 | expect(subclass.generation).toEqual(1); 67 | expect(subclass.role).toEqual("subclass"); 68 | }); 69 | }); 70 | }); 71 | 72 | describe("validation", () => { 73 | it("should fail with incorrect values", () => { 74 | let model: ValidationModel; 75 | let error: Error; 76 | 77 | try { 78 | model = ValidationModel.create({}); 79 | } catch (err) { 80 | error = err; 81 | } 82 | 83 | expect(model).toBeUndefined(); 84 | expect(error).toBeDefined(); 85 | expect(error.name).toEqual(TestModelNameMissingError); 86 | }); 87 | 88 | it("should fail without error on invalid count", () => { 89 | let model: TestModel; 90 | let error: Error; 91 | 92 | try { 93 | model = TestModel.create({ 94 | count: 11, 95 | }); 96 | } catch (err) { 97 | error = err; 98 | } 99 | 100 | expect(error).toBeUndefined(); 101 | expect(model).toBeNull(); 102 | }); 103 | 104 | it("should succeed with correct values", () => { 105 | let model: ValidationModel; 106 | let error: Error; 107 | 108 | try { 109 | model = ValidationModel.create({ 110 | name: "valid", 111 | }); 112 | } catch (err) { 113 | error = err; 114 | } 115 | 116 | expect(error).toBeUndefined(); 117 | expect(model).toBeDefined(); 118 | expect(model.name).toEqual("valid"); 119 | }); 120 | 121 | it("should autovalidate name", () => { 122 | let model: SelfValidatingModel; 123 | let error: Error; 124 | 125 | try { 126 | model = SelfValidatingModel.create({}); 127 | } catch (err) { 128 | error = err; 129 | } 130 | 131 | expect(error).toBeUndefined(); 132 | expect(model).toBeDefined(); 133 | expect(model.name).toEqual("foobar"); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /__tests__/ExampleWithout.spec.ts: -------------------------------------------------------------------------------- 1 | describe("Example model without using the library for GitHub issues", () => { 2 | enum GHIssueState { 3 | Open = 0, 4 | Closed, 5 | } 6 | 7 | class GHUser { 8 | public readonly username: string; 9 | public readonly url: string; 10 | public readonly htmlUrl: string; 11 | 12 | constructor(json: any) { 13 | this.username = json.login; 14 | this.url = json.url; 15 | this.htmlUrl = json.html_url; 16 | } 17 | } 18 | 19 | class GHIssue { 20 | public readonly url: string; 21 | public readonly htmlUrl: string; 22 | public readonly number: number; 23 | public readonly state: GHIssueState; 24 | public readonly reporterLogin: string; 25 | public readonly assignee: GHUser; 26 | public readonly assignees: GHUser[]; 27 | public readonly updatedAt: Date; 28 | 29 | public title: string; 30 | public body: string; 31 | 32 | public retrievedAt: Date; 33 | 34 | constructor(json: any) { 35 | this.url = json.url; 36 | this.htmlUrl = json.html_url; 37 | this.number = json.number; 38 | 39 | if (json.state === "open") { 40 | this.state = GHIssueState.Open; 41 | } else if (json.state === "closed") { 42 | this.state = GHIssueState.Closed; 43 | } 44 | 45 | this.title = json.title; 46 | this.body = json.body; 47 | this.reporterLogin = json.user.login; 48 | this.updatedAt = new Date(json.updated_at); 49 | this.assignee = new GHUser(json.assignee); 50 | 51 | const assignees: GHUser[] = []; 52 | for (const assigneeJSON of json.assignees) { 53 | const assignee = new GHUser(assigneeJSON); 54 | assignees.push(assignee); 55 | } 56 | this.assignees = assignees; 57 | 58 | this.retrievedAt = new Date(); 59 | } 60 | } 61 | 62 | it("Should work", () => { 63 | const values = { 64 | "url": "https://api.github.com/repos/octocat/Hello-World/issues/1347", 65 | "html_url": "https://github.com/octocat/Hello-World/issues/1347", 66 | "number": 1347, 67 | "state": "open", 68 | "title": "Found a bug", 69 | "body": "I'm having a problem with this.", 70 | "user": { 71 | "login": "octocat", 72 | }, 73 | "assignee": { 74 | "login": "octocat", 75 | "url": "https://api.github.com/users/octocat", 76 | "html_url": "https://github.com/octocat", 77 | }, 78 | "assignees": [ 79 | { 80 | "login": "octocat", 81 | "url": "https://api.github.com/users/octocat", 82 | "html_url": "https://github.com/octocat", 83 | }, 84 | ], 85 | "updated_at": "2011-04-22T13:33:48.000Z", 86 | }; 87 | 88 | let model: GHIssue; 89 | let error: Error; 90 | 91 | try { 92 | model = new GHIssue(values); 93 | } catch (err) { 94 | error = err; 95 | } 96 | 97 | expect(error).toBeUndefined(); 98 | expect(model).toBeDefined(); 99 | 100 | expect(model.url).toEqual("https://api.github.com/repos/octocat/Hello-World/issues/1347"); 101 | expect(model.htmlUrl).toEqual("https://github.com/octocat/Hello-World/issues/1347"); 102 | expect(model.number).toEqual(1347); 103 | expect(model.state).toEqual(GHIssueState.Open); 104 | expect(model.reporterLogin).toEqual("octocat"); 105 | expect(model.assignee).toBeDefined(); 106 | expect(model.assignee.username).toEqual("octocat"); 107 | expect(model.assignee.url).toEqual("https://api.github.com/users/octocat"); 108 | expect(model.assignee.htmlUrl).toEqual("https://github.com/octocat"); 109 | expect(model.assignees).toBeDefined(); 110 | expect(model.assignees.length).toEqual(1); 111 | expect(model.assignees[0].username).toEqual("octocat"); 112 | expect(model.assignees[0].url).toEqual("https://api.github.com/users/octocat"); 113 | expect(model.assignees[0].htmlUrl).toEqual("https://github.com/octocat"); 114 | expect(model.updatedAt).toEqual(new Date("2011-04-22T13:33:48.000Z")); 115 | expect(model.title).toEqual("Found a bug"); 116 | expect(model.body).toEqual("I'm having a problem with this."); 117 | expect(model.retrievedAt).toBeDefined(); 118 | expect(model.retrievedAt).not.toBeNull(); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "linterOptions": { 3 | "exclude": [ 4 | "node_modules/**/*" 5 | ] 6 | }, 7 | "rules": { 8 | "array-type": [ 9 | true, 10 | "array-simple" 11 | ], 12 | "arrow-parens": true, 13 | "arrow-return-shorthand": true, 14 | "align": [ 15 | true, 16 | "parameters", 17 | "arguments", 18 | "statements" 19 | ], 20 | "ban-comma-operator": true, 21 | "ban-types": [ 22 | true, ["Object", "Use {} instead."], 23 | ["String", "Use string instead."] 24 | ], 25 | "binary-expression-operand-order": true, 26 | "class-name": true, 27 | "comment-format": [ 28 | true, 29 | "check-space" 30 | ], 31 | "curly": true, 32 | "eofline": true, 33 | "forin": true, 34 | "import-spacing": true, 35 | "indent": [ 36 | true, 37 | "spaces" 38 | ], 39 | "interface-over-type-literal": false, 40 | "jsdoc-format": true, 41 | "label-position": true, 42 | "max-line-length": [ 43 | true, 44 | 250 45 | ], 46 | "member-access": [ 47 | true, 48 | "check-accessor" 49 | ], 50 | "newline-before-return": true, 51 | "new-parens": true, 52 | "no-arg": true, 53 | "no-bitwise": true, 54 | "no-conditional-assignment": true, 55 | "no-consecutive-blank-lines": [ 56 | true, 57 | 1 58 | ], 59 | "no-console": [ 60 | true, 61 | "log", 62 | "error" 63 | ], 64 | "no-construct": true, 65 | "no-debugger": true, 66 | "no-default-export": true, 67 | "no-duplicate-imports": true, 68 | "no-duplicate-super": true, 69 | "no-duplicate-switch-case": true, 70 | "no-duplicate-variable": true, 71 | "no-empty": true, 72 | "no-eval": true, 73 | "no-internal-module": true, 74 | "no-invalid-template-strings": true, 75 | "no-invalid-this": [ 76 | true, 77 | "check-function-in-method" 78 | ], 79 | "no-irregular-whitespace": true, 80 | "no-namespace": true, 81 | "no-parameter-properties": true, 82 | "no-reference": true, 83 | "no-return-await": true, 84 | "no-shadowed-variable": true, 85 | "no-sparse-arrays": true, 86 | "no-string-literal": true, 87 | "no-switch-case-fall-through": true, 88 | "no-trailing-whitespace": true, 89 | "no-unsafe-finally": true, 90 | "no-unused-expression": true, 91 | "no-var-keyword": true, 92 | "no-var-requires": true, 93 | "object-literal-shorthand": true, 94 | "object-literal-sort-keys": false, 95 | "one-line": [ 96 | true, 97 | "check-catch", 98 | "check-else", 99 | "check-finally", 100 | "check-open-brace", 101 | "check-whitespace" 102 | ], 103 | "one-variable-per-declaration": [ 104 | true, 105 | "ignore-for-loop" 106 | ], 107 | "only-arrow-functions": [ 108 | true, 109 | "allow-declarations" 110 | ], 111 | "prefer-conditional-expression": true, 112 | "prefer-const": true, 113 | "prefer-for-of": true, 114 | "prefer-object-spread": true, 115 | "quotemark": [ 116 | true, 117 | "double", 118 | "avoid-escape" 119 | ], 120 | "semicolon": [ 121 | true, 122 | "always" 123 | ], 124 | "switch-default": true, 125 | "trailing-comma": [ 126 | true, 127 | { 128 | "multiline": "always", 129 | "singleline": "never" 130 | } 131 | ], 132 | "triple-equals": [ 133 | true, 134 | "allow-null-check" 135 | ], 136 | "typedef": [ 137 | true, 138 | "call-signature", 139 | "parameter", 140 | "property-declaration", 141 | "member-variable-declaration" 142 | ], 143 | "unified-signatures": true, 144 | "use-isnan": true, 145 | "variable-name": [ 146 | true, 147 | "ban-keywords", 148 | "check-format", 149 | "allow-pascal-case", 150 | "allow-leading-underscore" 151 | ], 152 | "whitespace": [ 153 | true, 154 | "check-branch", 155 | "check-decl", 156 | "check-operator", 157 | "check-separator", 158 | "check-type", 159 | "check-typecast" 160 | ] 161 | } 162 | } -------------------------------------------------------------------------------- /__tests__/Example.spec.ts: -------------------------------------------------------------------------------- 1 | import { Model, KeyPaths, ValueTransformer } from "../lib"; 2 | 3 | describe("Example model using GitHub issues", () => { 4 | enum GHIssueState { 5 | Open = 0, 6 | Closed, 7 | } 8 | 9 | class GHUser extends Model { 10 | public readonly username: string; 11 | public readonly url: string; 12 | public readonly htmlUrl: string; 13 | 14 | public static JSONKeyPaths(): KeyPaths { 15 | return { 16 | username: "login", 17 | url: "url", 18 | htmlUrl: "html_url", 19 | }; 20 | } 21 | } 22 | 23 | class GHIssue extends Model { 24 | public readonly url: string; 25 | public readonly htmlUrl: string; 26 | public readonly number: number; 27 | public readonly state: GHIssueState; 28 | public readonly reporterLogin: string; 29 | public readonly assignee: GHUser; 30 | public readonly assignees: GHUser[]; 31 | public readonly updatedAt: Date; 32 | 33 | public title: string; 34 | public body: string; 35 | 36 | public retrievedAt: Date; 37 | 38 | constructor() { 39 | super(); 40 | 41 | this.retrievedAt = new Date(); 42 | } 43 | 44 | public static JSONKeyPaths(): KeyPaths { 45 | return { 46 | url: "url", 47 | htmlUrl: "html_url", 48 | number: "number", 49 | state: "state", 50 | reporterLogin: "user.login", 51 | assignee: "assignee", 52 | assignees: "assignees", 53 | updatedAt: "updated_at", 54 | title: "title", 55 | body: "body", 56 | }; 57 | } 58 | 59 | public static updatedAtJSONTransformer(): ValueTransformer { 60 | return ValueTransformer.forwardAndReversible( 61 | (value: string) => { 62 | return new Date(value); 63 | }, 64 | (value: Date) => { 65 | return value.toISOString(); 66 | }, 67 | ); 68 | } 69 | 70 | public static stateJSONTransformer(): ValueTransformer { 71 | return ValueTransformer.valueMappingTransformer({ 72 | "open": GHIssueState.Open, 73 | "closed": GHIssueState.Closed, 74 | }); 75 | } 76 | 77 | public static assigneeJSONTransformer(): ValueTransformer { 78 | return ValueTransformer.objectTransformer(GHUser); 79 | } 80 | 81 | public static assigneesJSONTransformer(): ValueTransformer { 82 | return ValueTransformer.arrayTransformer(GHUser); 83 | } 84 | } 85 | 86 | it("Should work", () => { 87 | const values = { 88 | "url": "https://api.github.com/repos/octocat/Hello-World/issues/1347", 89 | "html_url": "https://github.com/octocat/Hello-World/issues/1347", 90 | "number": 1347, 91 | "state": "open", 92 | "title": "Found a bug", 93 | "body": "I'm having a problem with this.", 94 | "user": { 95 | "login": "octocat", 96 | }, 97 | "assignee": { 98 | "login": "octocat", 99 | "url": "https://api.github.com/users/octocat", 100 | "html_url": "https://github.com/octocat", 101 | }, 102 | "assignees": [ 103 | { 104 | "login": "octocat", 105 | "url": "https://api.github.com/users/octocat", 106 | "html_url": "https://github.com/octocat", 107 | }, 108 | ], 109 | "updated_at": "2011-04-22T13:33:48.000Z", 110 | }; 111 | 112 | let model: GHIssue; 113 | let error: Error; 114 | 115 | try { 116 | model = GHIssue.from(values); 117 | } catch (err) { 118 | error = err; 119 | } 120 | 121 | expect(error).toBeUndefined(); 122 | expect(model).toBeDefined(); 123 | 124 | expect(model.url).toEqual("https://api.github.com/repos/octocat/Hello-World/issues/1347"); 125 | expect(model.htmlUrl).toEqual("https://github.com/octocat/Hello-World/issues/1347"); 126 | expect(model.number).toEqual(1347); 127 | expect(model.state).toEqual(GHIssueState.Open); 128 | expect(model.reporterLogin).toEqual("octocat"); 129 | expect(model.assignee).toBeDefined(); 130 | expect(model.assignee.username).toEqual("octocat"); 131 | expect(model.assignee.url).toEqual("https://api.github.com/users/octocat"); 132 | expect(model.assignee.htmlUrl).toEqual("https://github.com/octocat"); 133 | expect(model.assignees).toBeDefined(); 134 | expect(model.assignees.length).toEqual(1); 135 | expect(model.assignees[0].username).toEqual("octocat"); 136 | expect(model.assignees[0].url).toEqual("https://api.github.com/users/octocat"); 137 | expect(model.assignees[0].htmlUrl).toEqual("https://github.com/octocat"); 138 | expect(model.updatedAt).toEqual(new Date("2011-04-22T13:33:48.000Z")); 139 | expect(model.title).toEqual("Found a bug"); 140 | expect(model.body).toEqual("I'm having a problem with this."); 141 | expect(model.retrievedAt).toBeDefined(); 142 | expect(model.retrievedAt).not.toBeNull(); 143 | 144 | expect(model.toJSON()).toEqual(values); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /lib/Model.ts: -------------------------------------------------------------------------------- 1 | import { Serializable, Newable } from "./Serializable"; 2 | import { ModelFromObject, ObjectFromModel, ModelsFromArray, ArrayFromModels } from "./JSONAdapter"; 3 | 4 | export class Model extends Serializable { 5 | /** 6 | * Deserializes a model from an object. 7 | * 8 | * @param json An object. 9 | */ 10 | public static from(this: Newable, json: any): T | null { 11 | return ModelFromObject(json, this); 12 | } 13 | 14 | /** 15 | * Attempts to parse an array of objects into model objects of a specific class. 16 | * 17 | * @param json An array of objects. 18 | */ 19 | public static fromArray(this: Newable, json: any[]): T[] | null { 20 | return ModelsFromArray(json, this); 21 | } 22 | 23 | /** 24 | * Converts an array of models into an object array. 25 | * 26 | * @param models An array of models to use for JSON serialization. 27 | */ 28 | public static toArray(this: Newable, models: T[]): any[] | null { 29 | return ArrayFromModels(models); 30 | } 31 | 32 | /** 33 | * Initializes a model with the given object. 34 | * 35 | * @param json An object. 36 | */ 37 | public static create(this: Newable, json: Partial): T | null { 38 | const model = new this(); 39 | 40 | if (json && typeof json === "object") { 41 | for (const key of Object.keys(json)) { 42 | const value = json[key]; 43 | 44 | model[key] = value; 45 | } 46 | } 47 | 48 | if (model.validate()) { 49 | return model; 50 | } 51 | 52 | return null; 53 | } 54 | 55 | /** 56 | * By default, this method looks for a `mergeFromModel` method on the receiver, 57 | * and invokes it if found. If not found, and `model` is not null, the value for the 58 | * given key is taken from `model`. 59 | * 60 | * @param key the name of the property to merge. 61 | * @param model the model to merge from. 62 | */ 63 | public mergeValue(key: keyof T & string, model: T): void { 64 | // construct the method name for this key 65 | const methodName = `merge${key.charAt(0).toUpperCase()}${key.slice(1)}FromModel`; 66 | 67 | // check if the object has a transformer for this property 68 | const method: (model: T) => void = this[methodName]; 69 | const isFunction = typeof method === "function"; 70 | 71 | // if we haven't found the mergeFromModel method 72 | if (!method || !isFunction || method.length !== 1) { 73 | // and we have a model 74 | if (model) { 75 | // take the value from the given model 76 | this[key as any] = model[key]; 77 | } 78 | 79 | return; 80 | } 81 | 82 | method.bind(this)(model); 83 | } 84 | 85 | /** 86 | * Merges the values of the given model into the receiver, using {@link Model.mergeValue} for each 87 | * key in {@link Serializable.JSONKeyPaths}. 88 | * 89 | * @param model the model to merge from. 90 | */ 91 | public mergeValues(model: T): void { 92 | if (!model) { return; } 93 | 94 | // get the class of the model 95 | const Class = model.constructor; 96 | 97 | // get the key paths 98 | const keyPaths = Class.prototype.constructor.JSONKeyPaths(); 99 | 100 | for (const key of Object.keys(keyPaths)) { 101 | this.mergeValue(key as any, model); 102 | } 103 | } 104 | 105 | /** 106 | * Validates the model. 107 | * 108 | * The default implementation simply invokes `validate` for all keys in {@link Serializable.JSONKeyPaths}. 109 | * 110 | * @returns `true` if the model is valid, `false` otherwise. 111 | */ 112 | public validate(): boolean { 113 | // get the class of the model 114 | const Class = this.constructor; 115 | 116 | // get the key paths 117 | const keyPaths = Class.prototype.constructor.JSONKeyPaths(); 118 | 119 | for (const key of Object.keys(keyPaths)) { 120 | // construct the method name of this property 121 | const methodName = `validate${key.charAt(0).toUpperCase()}${key.slice(1)}`; 122 | 123 | // check if the object has a validator for this property 124 | const method: () => boolean = this[methodName]; 125 | const isFunction = typeof method === "function"; 126 | 127 | // if we found the validate method 128 | if (method && isFunction && method.length === 0) { 129 | const valid = method.bind(this)() as boolean; 130 | if (!valid) { 131 | return false; 132 | } 133 | } 134 | } 135 | 136 | return true; 137 | } 138 | 139 | /** 140 | * Serializes a model into an object. 141 | */ 142 | public toObject(): any { 143 | return ObjectFromModel(this); 144 | } 145 | 146 | /** 147 | * Serializes a model into an object. 148 | * 149 | * Note: This does not throw the error if it occurs during serialization. 150 | * Check {@link Model.toObject} if you need that. 151 | */ 152 | public toJSON(): any { 153 | try { 154 | return this.toObject(); 155 | } catch (_) { 156 | // ignore this 157 | } 158 | 159 | return null; 160 | } 161 | 162 | } 163 | -------------------------------------------------------------------------------- /__tests__/ValueTransformer.spec.ts: -------------------------------------------------------------------------------- 1 | import { ValueTransformer } from "../lib"; 2 | import { ErrorTypes } from "../lib/constants"; 3 | 4 | describe("ValueTransformer", () => { 5 | it("should return a forward transformer with a block", () => { 6 | const transformer = ValueTransformer.forward((value: string) => { 7 | return value + "bar"; 8 | }); 9 | 10 | expect(transformer).toBeDefined(); 11 | expect(transformer.allowsReverseTransformation()).toBeFalsy(); 12 | 13 | expect(transformer.transformedValue("foo")).toEqual("foobar"); 14 | expect(transformer.transformedValue("bar")).toEqual("barbar"); 15 | }); 16 | 17 | it("should return a reversible transformer with a block", () => { 18 | const transformer = ValueTransformer.reversible((value: string) => { 19 | return value + "bar"; 20 | }); 21 | 22 | expect(transformer).toBeDefined(); 23 | expect(transformer.allowsReverseTransformation()).toBeTruthy(); 24 | 25 | expect(transformer.transformedValue("foo")).toEqual("foobar"); 26 | expect(transformer.reverseTransformedValue("foo")).toEqual("foobar"); 27 | }); 28 | 29 | it("should return a reversible transformer with forward and reverse blocks", () => { 30 | const transformer = ValueTransformer.forwardAndReversible( 31 | (value: string) => { 32 | return value + "bar"; 33 | }, 34 | (value: string) => { 35 | return value.substring(0, value.length - 3); 36 | }, 37 | ); 38 | 39 | expect(transformer).toBeDefined(); 40 | expect(transformer.allowsReverseTransformation()).toBeTruthy(); 41 | 42 | expect(transformer.transformedValue("foo")).toEqual("foobar"); 43 | expect(transformer.reverseTransformedValue("foobar")).toEqual("foo"); 44 | }); 45 | 46 | it("should return undefined with null transformers blocks", () => { 47 | const transformer = ValueTransformer.forwardAndReversible(null, null); 48 | 49 | expect(transformer).toBeDefined(); 50 | expect(transformer.allowsReverseTransformation()).toBeFalsy(); 51 | 52 | expect(transformer.transformedValue("foo")).not.toBeDefined(); 53 | expect(transformer.reverseTransformedValue("foo")).not.toBeDefined(); 54 | }); 55 | }); 56 | 57 | describe("value mapping transformer", () => { 58 | enum AdditionTypes { 59 | Negative = -1, 60 | Zero = 0, 61 | Positive = 1, 62 | Default = 42, 63 | } 64 | 65 | const values = { 66 | "negative": AdditionTypes.Negative, 67 | "zero": AdditionTypes.Zero, 68 | "positive": AdditionTypes.Positive, 69 | }; 70 | 71 | let transformer: ValueTransformer; 72 | 73 | beforeEach(() => { 74 | transformer = ValueTransformer.valueMappingTransformer(values); 75 | }); 76 | 77 | it("should transform strings into enum values", () => { 78 | expect(transformer.transformedValue("negative")).toEqual(AdditionTypes.Negative); 79 | expect(transformer.transformedValue("zero")).toEqual(AdditionTypes.Zero); 80 | expect(transformer.transformedValue("positive")).toEqual(AdditionTypes.Positive); 81 | }); 82 | 83 | it("should transform enum values into strings", () => { 84 | expect(transformer.allowsReverseTransformation()).toBeTruthy(); 85 | 86 | expect(transformer.reverseTransformedValue(AdditionTypes.Negative)).toEqual("negative"); 87 | expect(transformer.reverseTransformedValue(AdditionTypes.Zero)).toEqual("zero"); 88 | expect(transformer.reverseTransformedValue(AdditionTypes.Positive)).toEqual("positive"); 89 | }); 90 | 91 | describe("default values", () => { 92 | beforeEach(() => { 93 | transformer = ValueTransformer.valueMappingTransformer(values, AdditionTypes.Default, "default"); 94 | }); 95 | 96 | it("should transform unknown strings into the default enum value", () => { 97 | expect(transformer.transformedValue("unknown")).toEqual(AdditionTypes.Default); 98 | }); 99 | 100 | it("should transform the default enum value into the default string", () => { 101 | expect(transformer.reverseTransformedValue(AdditionTypes.Default)).toEqual("default"); 102 | }); 103 | }); 104 | }); 105 | 106 | describe("number transformer", () => { 107 | let transformer: ValueTransformer; 108 | 109 | beforeEach(() => { 110 | transformer = ValueTransformer.numberTransformer("en-US"); 111 | }); 112 | 113 | it("should transform strings into numbers", () => { 114 | expect(transformer.transformedValue("0.12345")).toEqual(0.12345); 115 | }); 116 | 117 | it("should transform numbers into strings", () => { 118 | expect(transformer.reverseTransformedValue(12345.678)).toEqual("12,345.678"); 119 | }); 120 | 121 | it("should throw error on invalid transform value", () => { 122 | let error: Error | undefined; 123 | 124 | try { 125 | transformer.transformedValue({}); 126 | } catch (err) { 127 | error = err; 128 | } 129 | 130 | expect(error).toBeDefined(); 131 | expect(error.name).toEqual(ErrorTypes.TransformerHandlingInvalidInput); 132 | }); 133 | 134 | it("should throw error on invalid reverse transform value", () => { 135 | let error: Error | undefined; 136 | 137 | try { 138 | transformer.reverseTransformedValue({}); 139 | } catch (err) { 140 | error = err; 141 | } 142 | 143 | expect(error).toBeDefined(); 144 | expect(error.name).toEqual(ErrorTypes.TransformerHandlingInvalidInput); 145 | }); 146 | 147 | it("should return null on null input", () => { 148 | expect(transformer.transformedValue(null)).toEqual(null); 149 | expect(transformer.reverseTransformedValue(null)).toEqual(null); 150 | }); 151 | 152 | it("should return null on NaN parsed input", () => { 153 | expect(transformer.transformedValue("foo")).toEqual(null); 154 | }); 155 | }); 156 | 157 | describe("invert transformer", () => { 158 | class TestTransformer extends ValueTransformer { 159 | public allowsReverseTransformation(): boolean { 160 | return true; 161 | } 162 | public transformedValue(): any { 163 | return "forward"; 164 | } 165 | public reverseTransformedValue(): any { 166 | return "reverse"; 167 | } 168 | } 169 | 170 | let transformer: ValueTransformer; 171 | 172 | beforeEach(() => { 173 | transformer = new TestTransformer(); 174 | }); 175 | 176 | it("should invert a transformer", () => { 177 | const inverted = transformer.invertedTransformer(); 178 | 179 | expect(inverted.transformedValue()).toEqual("reverse"); 180 | expect(inverted.reverseTransformedValue()).toEqual("forward"); 181 | }); 182 | 183 | it("should invert an inverted transformer", () => { 184 | const inverted = transformer.invertedTransformer().invertedTransformer(); 185 | 186 | expect(inverted.transformedValue()).toEqual("forward"); 187 | expect(inverted.reverseTransformedValue()).toEqual("reverse"); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /lib/JSONAdapter.ts: -------------------------------------------------------------------------------- 1 | import get = require("lodash.get"); 2 | import set = require("lodash.set"); 3 | import { Serializable, Newable } from "./Serializable"; 4 | import { ValueTransformer } from "./ValueTransformer"; 5 | import { CreateError } from "./utils"; 6 | import { ErrorTypes } from "./constants"; 7 | 8 | type ValueTransformerMap = { 9 | [key: string]: ValueTransformer; 10 | }; 11 | 12 | /** 13 | * Collect all value transformers needed for a given model. 14 | * 15 | * @param Class The class from which to parse the JSON. 16 | */ 17 | function valueTransformersForModel(Class: Newable): ValueTransformerMap { 18 | const result: ValueTransformerMap = {}; 19 | 20 | const jsonKeyPaths = Class.prototype.constructor.JSONKeyPaths(); 21 | 22 | // iterate over all key paths 23 | for (const key of Object.keys(jsonKeyPaths)) { 24 | // construct the method name for this property 25 | const methodName = `${key}JSONTransformer`; 26 | 27 | // check if the object has a transformer for this property 28 | const method: () => ValueTransformer = Class[methodName]; 29 | const isFunction = typeof method === "function"; 30 | 31 | // if we found the JSONTransformer method 32 | if (method && isFunction && method.length === 0) { 33 | const transformer = method(); 34 | 35 | if (transformer) { 36 | result[key] = transformer; 37 | } 38 | 39 | continue; 40 | } 41 | 42 | // otherwise, check if the model implements JSONTransformerForKey 43 | if (Class.prototype.constructor.JSONTransformerForKey) { 44 | const transformer = Class.prototype.constructor.JSONTransformerForKey(key); 45 | 46 | if (transformer) { 47 | result[key] = transformer; 48 | 49 | continue; 50 | } 51 | } 52 | } 53 | 54 | return result; 55 | } 56 | 57 | /** 58 | * Deserializes a model from an object. 59 | * 60 | * It will also call {@link Model.validate} on the model (if it extends {@link Model}) and consider it 61 | * an error if the validation fails. 62 | * 63 | * @param json An object. 64 | * @param Class The model to use for JSON serialization 65 | */ 66 | export function ModelFromObject(json: any, Class: Newable): T | null { 67 | if (json == null) { return null; } 68 | 69 | // if the class implements classForParsingObject 70 | if (Class.prototype.constructor.classForParsingObject && typeof Class.prototype.constructor.classForParsingObject === "function") { 71 | const classToUse = Class.prototype.constructor.classForParsingObject(json); 72 | 73 | // if the implementation didn't return any class 74 | if (!classToUse) { 75 | throw CreateError("No model class could be found to parse the JSON", ErrorTypes.JSONAdapterNoClassFound); 76 | } 77 | 78 | // if the class is different than the one given as a parameter 79 | if (classToUse !== Class) { 80 | return ModelFromObject(json, classToUse); 81 | } 82 | } 83 | 84 | const model: T = new Class.prototype.constructor(); 85 | 86 | const jsonKeyPaths = Class.prototype.constructor.JSONKeyPaths(); 87 | const transformers = valueTransformersForModel(Class); 88 | 89 | // iterate over all key paths 90 | for (const key of Object.keys(jsonKeyPaths)) { 91 | const keyPath = jsonKeyPaths[key]; 92 | 93 | let value: any = {}; 94 | 95 | // if the key path is a string 96 | if (typeof keyPath === "string") { 97 | // most keys are not nested, so we get a speedup by directly accessing the key instead 98 | // of unnecessarily calling `get` every time 99 | value = keyPath.indexOf(".") === -1 ? json[keyPath] : get(json, keyPath); 100 | } else { 101 | // else it must be an array of strings 102 | for (const path of keyPath) { 103 | if (path.indexOf(".") === -1) { 104 | value[path] = json[path]; 105 | } else { 106 | set(value, path, get(json, path)); 107 | } 108 | } 109 | } 110 | 111 | // attempt to transform the value 112 | const transformer = transformers[key]; 113 | if (transformer) { 114 | set(model, key, transformer.transformedValue(value)); 115 | 116 | continue; 117 | } 118 | 119 | // change undefined to null 120 | if (value === undefined) { 121 | value = null; 122 | } 123 | 124 | set(model, key, value); 125 | } 126 | 127 | // check if the model has a validation method (extends Model) 128 | const method: () => boolean = (model as any).validate; 129 | const isFunction = typeof method === "function"; 130 | 131 | // if we found the method 132 | if (method && isFunction && method.length === 0) { 133 | // call it and return the model if it is valid, null otherwise 134 | return method.bind(model)() ? model : null; 135 | } 136 | 137 | return model; 138 | } 139 | 140 | /** 141 | * Attempts to parse an array of objects into model objects of a specific class. 142 | * 143 | * @param json An array of objects. 144 | * @param Class The model to use for JSON serialization. 145 | */ 146 | export function ModelsFromArray(json: any[], Class: Newable): T[] | null { 147 | // make sure we have a value 148 | if (json == null) { return null; } 149 | 150 | // make sure the value is an array 151 | if (!Array.isArray(json)) { 152 | throw CreateError(`${Class} could not be created because an invalid object array was provided: ${json}.`, ErrorTypes.JSONAdapterInvalidJSON); 153 | } 154 | 155 | const models: T[] = []; 156 | 157 | for (const object of json) { 158 | const model = ModelFromObject(object, Class); 159 | 160 | if (!model) { return null; } 161 | 162 | models.push(model); 163 | } 164 | 165 | return models; 166 | } 167 | 168 | /** 169 | * Serializes a model into an object. 170 | * 171 | * @param model The model to use for JSON serialization. 172 | */ 173 | export function ObjectFromModel(model: T): any { 174 | const result: any = {}; 175 | 176 | // get the class of the model 177 | const Class = model.constructor as Newable; 178 | 179 | // get the key paths and transformers 180 | const jsonKeyPaths = Class.prototype.constructor.JSONKeyPaths(); 181 | const transformers = valueTransformersForModel(Class); 182 | 183 | for (const key of Object.keys(jsonKeyPaths)) { 184 | const keyPath = jsonKeyPaths[key]; 185 | 186 | const value = model[key]; 187 | 188 | const transformer = transformers[key]; 189 | 190 | // if the key path is a string 191 | if (typeof keyPath === "string") { 192 | if (transformer && transformer.allowsReverseTransformation()) { 193 | if (keyPath.indexOf(".") === -1) { 194 | result[keyPath] = transformer.reverseTransformedValue(value); 195 | } else { 196 | set(result, keyPath, transformer.reverseTransformedValue(value)); 197 | } 198 | } else { 199 | if (keyPath.indexOf(".") === -1) { 200 | result[keyPath] = value; 201 | } else { 202 | set(result, keyPath, value); 203 | } 204 | } 205 | } else { 206 | // else it must be an array of strings 207 | for (const path of (keyPath as string[])) { 208 | if (transformer && transformer.allowsReverseTransformation()) { 209 | if (path.indexOf(".") === -1) { 210 | result[path] = transformer.reverseTransformedValue(value)[path]; 211 | } else { 212 | set(result, path, get(transformer.reverseTransformedValue(value), path)); 213 | } 214 | } else { 215 | if (path.indexOf(".") === -1) { 216 | result[path] = value[path]; 217 | } else { 218 | set(result, path, get(value, path)); 219 | } 220 | } 221 | } 222 | } 223 | } 224 | 225 | return result; 226 | } 227 | 228 | /** 229 | * Converts an array of models into an object array. 230 | * 231 | * @param models An array of models to use for JSON serialization. 232 | */ 233 | export function ArrayFromModels(models: T[]): any[] | null { 234 | // make sure we have a value 235 | if (models == null) { return null; } 236 | 237 | // make sure the value is an array 238 | if (!Array.isArray(models)) { 239 | throw CreateError(`Could not create object array because an invalid model array was provided: ${models}.`, ErrorTypes.JSONAdapterInvalidJSON); 240 | } 241 | 242 | const objectArray: any[] = []; 243 | 244 | for (const model of models) { 245 | const object = ObjectFromModel(model); 246 | 247 | /* istanbul ignore next */ 248 | if (!object) { return null; } 249 | 250 | objectArray.push(object); 251 | } 252 | 253 | return objectArray; 254 | } 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Xzya/easy-models.svg?branch=master)](https://travis-ci.org/Xzya/easy-models) 2 | [![codecov](https://codecov.io/gh/Xzya/easy-models/branch/master/graph/badge.svg)](https://codecov.io/gh/Xzya/easy-models) 3 | 4 | # Easy models 5 | 6 | `easy-models` makes it easy to write a simple model layer for your JavaScript application. Inspired by [Mantle for iOS](https://github.com/Mantle/Mantle). 7 | 8 | ## Installation 9 | 10 | ```bash 11 | npm install --save easy-models 12 | ``` 13 | 14 | ## Documentation 15 | 16 | You can find the API reference [here](https://xzya.github.io/easy-models/). 17 | 18 | ## Usage 19 | 20 | Let's use the [GitHub API](http://developer.github.com/) for demonstration. 21 | 22 | ### The typical model object 23 | 24 | This is how you would typically represent a [GitHub issue](https://developer.github.com/v3/issues/#get-a-single-issue): 25 | 26 | ```typescript 27 | class GHIssue { 28 | public readonly url: string; 29 | public readonly htmlUrl: string; 30 | public readonly number: number; 31 | public readonly state: GHIssueState; 32 | public readonly reporterLogin: string; 33 | public readonly assignee: GHUser; 34 | public readonly assignees: GHUser[]; 35 | public readonly updatedAt: Date; 36 | 37 | public title: string; 38 | public body: string; 39 | 40 | public retrievedAt: Date; 41 | 42 | constructor(json: any) { 43 | this.url = json.url; 44 | this.htmlUrl = json.html_url; 45 | this.number = json.number; 46 | 47 | if (json.state === "open") { 48 | this.state = GHIssueState.Open; 49 | } else if (json.state === "closed") { 50 | this.state = GHIssueState.Closed; 51 | } 52 | 53 | this.title = json.title; 54 | this.body = json.body; 55 | this.reporterLogin = json.user.login; 56 | this.updatedAt = new Date(json.updated_at); 57 | this.assignee = new GHUser(json.assignee); 58 | 59 | const assignees: GHUser[] = []; 60 | for (const assigneeJSON of json.assignees) { 61 | const assignee = new GHUser(assigneeJSON); 62 | assignees.push(assignee); 63 | } 64 | this.assignees = assignees; 65 | 66 | this.retrievedAt = new Date(); 67 | } 68 | } 69 | ``` 70 | 71 | ### Same model by extending `Model` 72 | 73 | This is how you would represent the same model object by extending `Model`: 74 | 75 | ```typescript 76 | import { Model, KeyPaths, ValueTransformer } from "easy-models"; 77 | 78 | enum GHIssueState { 79 | Open = 0, 80 | Closed, 81 | } 82 | 83 | class GHIssue extends Model { 84 | readonly url: string; 85 | readonly htmlUrl: string; 86 | readonly number: number; 87 | readonly state: GHIssueState; 88 | readonly reporterLogin: string; 89 | readonly assignee: GHUser; 90 | readonly assignees: GHUser[]; 91 | readonly updatedAt: Date; 92 | 93 | title: string; 94 | body: string; 95 | 96 | retrievedAt: Date; 97 | 98 | constructor() { 99 | super(); 100 | 101 | this.retrievedAt = new Date(); 102 | } 103 | 104 | static JSONKeyPaths(): KeyPaths { 105 | return { 106 | url: "url", 107 | htmlUrl: "html_url", 108 | number: "number", 109 | state: "state", 110 | reporterLogin: "user.login", 111 | assignee: "assignee", 112 | assignees: "assignees", 113 | updatedAt: "updated_at", 114 | title: "title", 115 | body: "body", 116 | }; 117 | } 118 | 119 | static updatedAtJSONTransformer(): ValueTransformer { 120 | return ValueTransformer.forwardAndReversible( 121 | (value: string) => { 122 | return new Date(value); 123 | }, 124 | (value: Date) => { 125 | return value.toISOString(); 126 | } 127 | ); 128 | } 129 | 130 | static stateJSONTransformer(): ValueTransformer { 131 | return ValueTransformer.valueMappingTransformer({ 132 | "open": GHIssueState.Open, 133 | "closed": GHIssueState.Closed, 134 | }); 135 | } 136 | 137 | static assigneeJSONTransformer(): ValueTransformer { 138 | return ValueTransformer.objectTransformer(GHUser); 139 | } 140 | 141 | static assigneesJSONTransformer(): ValueTransformer { 142 | return ValueTransformer.arrayTransformer(GHUser); 143 | } 144 | } 145 | ``` 146 | 147 | ### Advantages of using `Model` over the typical aproach 148 | 149 | - Turn a model *back* into JSON. 150 | 151 | You can define the key mapping between your model and the JSON using the `JSONKeyPaths` method. 152 | 153 | By default, turning a model back into JSON will map the values into the original keys. If your data needs transformation, you can define custom reversible transformers for each field, which will be used when turning the data to/from JSON (as seen for the `updatedAt` field above). 154 | 155 | - Update a model with new data from the server. 156 | 157 | `Model` has an extensible `mergeValues` method, which makes it easy to specify how new model data should be integrated. 158 | 159 | - Validate models. 160 | 161 | `Model` allows you to define validation methods for each field, which will be automatically called when you deserialize a model. 162 | 163 | ## Serializable 164 | 165 | In order to serialize your model objects from or into JSON, you need to extend `Serializable` in your model class. 166 | 167 | While extending `Model` is not required, it gives you some helper functions, which makes it a bit easier to work with. 168 | 169 | Here is an example for the difference between using a model extending `Serializable` and `Model`: 170 | 171 | ```typescript 172 | // extending Serializable 173 | const model = ModelFromObject(jsonObject, MyModel); 174 | 175 | // extending Model 176 | const model = MyModel.from(jsonObject); 177 | ``` 178 | 179 | ### `static JSONKeyPaths()` 180 | 181 | The object returned by this method specifies how your model's properties map to the keys in the JSON representation, for example: 182 | 183 | ```typescript 184 | class User extends Model { 185 | name: string; 186 | createdAt: Date; 187 | 188 | retrievedAt: Date; 189 | isAdmin: boolean; 190 | 191 | constructor() { 192 | super(); 193 | 194 | this.retrievedAt = new Date(); 195 | } 196 | 197 | static JSONKeyPaths(): KeyPaths { 198 | return { 199 | createdAt: "created_at", 200 | name: "name", 201 | }; 202 | } 203 | } 204 | ``` 205 | 206 | In this example, the `User` class declares four properties that are handled in different ways: 207 | 208 | - `name` is mapped to a key of the same name in the JSON representation. 209 | - `createdAt` is converted to it's snake case equivalent. 210 | - `isAdmin` is not serialized into JSON. 211 | - `retrievedAt` is initialized when the object is being deserialized. 212 | 213 | **Note**: Adding `KeyPaths` return type annotation is not required, however, it will give you autocomplete for the object's property names, and the keys will also be renamed if you rename the property names. 214 | 215 | JSON keys that don't have an explicit mapping are ignored: 216 | 217 | ```typescript 218 | const json = { 219 | "name": "John Doe", 220 | "created_at": "2018-08-05T16:28:28.966Z", 221 | "age": 26 222 | }; 223 | 224 | const model = User.from(json); 225 | ``` 226 | 227 | Here, the `age` would be ignored since it is not mapped in `JSONKeyPaths`. 228 | 229 | ### `static JSONTransformerForKey` 230 | 231 | Implement this optional method to convert a property from a different type when deserializing from JSON. 232 | 233 | ```typescript 234 | class User { 235 | ... 236 | static JSONTransformerForKey(key: string): ValueTransformer { 237 | if (key === "createdAt") { 238 | return ValueTransformer.forwardAndReversible( 239 | (value: string) => { 240 | return new Date(value); 241 | }, 242 | (value: Date) => { 243 | return value.toISOString(); 244 | } 245 | ); 246 | } 247 | return null; 248 | } 249 | } 250 | ``` 251 | 252 | `key` is the key that applies to your model object, not the original JSON key. Keep this in mind if you transform the key names using `JSONTransformerForKey`. 253 | 254 | For added convenience, if you implement `static JSONTransformer`, the result of that method will be used instead: 255 | 256 | ```typescript 257 | class User { 258 | ... 259 | static createdAtJSONTransformer(): ValueTransformer { 260 | return ValueTransformer.forwardAndReversible( 261 | (value: string) => { 262 | return new Date(value); 263 | }, 264 | (value: Date) => { 265 | return value.toISOString(); 266 | } 267 | ); 268 | } 269 | } 270 | ``` 271 | 272 | If the transformer is reversible, it will also be used when serializing the object back into JSON. 273 | 274 | ### `static classForParsingObject` 275 | 276 | If you are implementing a class cluster, implement this optional method to determine which subclass of your base class should be used when deserializing an object from JSON. 277 | 278 | ```typescript 279 | class Message extends Model { 280 | static classForParsingObject(value: any) { 281 | if (value.image_url != null) { 282 | return PictureMessage; 283 | } 284 | 285 | if (value.body != null) { 286 | return TextMessage; 287 | } 288 | 289 | throw new Error("No matching class for the JSON"); 290 | } 291 | } 292 | 293 | class TextMessage extends Message { 294 | body: string; 295 | } 296 | 297 | class PictureMessage extends Message { 298 | imageUrl: string; 299 | } 300 | ``` 301 | 302 | The class will then be picked based on the JSON you pass in: 303 | 304 | ```typescript 305 | const textMessage = { 306 | "id": 1, 307 | "body": "Hello, world!" 308 | }; 309 | 310 | const pictureMessage = { 311 | "id": 2, 312 | "image_url": "http://foo.com/cat.gif" 313 | }; 314 | 315 | const invalidMessage = { 316 | "id": 3 317 | }; 318 | 319 | try { 320 | const messageA = Message.from(textMessage) as TextMessage; 321 | const messageB = Message.from(pictureMessage) as PictureMessage; 322 | 323 | // throws error since no class is found 324 | const messageC = Message.from(invalidMessage); 325 | } catch (err) { 326 | // handle error 327 | } 328 | ``` 329 | 330 | ## License 331 | 332 | Open sourced under the [MIT license](./LICENSE.md). -------------------------------------------------------------------------------- /lib/ValueTransformer.ts: -------------------------------------------------------------------------------- 1 | import isEqual = require("lodash.isequal"); 2 | import { CreateError } from "./utils"; 3 | import { ErrorTypes } from "./constants"; 4 | import { Serializable, Newable } from "./Serializable"; 5 | import { ModelFromObject, ObjectFromModel } from "./JSONAdapter"; 6 | 7 | /** 8 | * A function that represents a transformation. 9 | * 10 | * @returns the result of the transformation, which may be null or undefined. 11 | */ 12 | export type ValueTransformerFunction = (value: any) => any; 13 | 14 | /** 15 | * A value transformer supporting function-based transformation. 16 | */ 17 | export class ValueTransformer { 18 | protected forward?: ValueTransformerFunction; 19 | protected reverse?: ValueTransformerFunction; 20 | 21 | constructor(forward?: ValueTransformerFunction, reverse?: ValueTransformerFunction) { 22 | this.forward = forward; 23 | this.reverse = reverse; 24 | } 25 | 26 | /** 27 | * Transforms the given value. 28 | * 29 | * @param value The value to be transformed. 30 | */ 31 | public transformedValue(value?: any): any { 32 | if (this.forward) { 33 | return this.forward(value); 34 | } 35 | 36 | return undefined; 37 | } 38 | 39 | /** 40 | * Reverses the transformation of the given value. 41 | * 42 | * @param value The value to be reversed. 43 | */ 44 | public reverseTransformedValue(value?: any): any { 45 | if (this.reverse) { 46 | return this.reverse(value); 47 | } 48 | 49 | return undefined; 50 | } 51 | 52 | /** 53 | * @returns `true` if the transformer supports reverse transformations. 54 | */ 55 | public allowsReverseTransformation(): boolean { 56 | return this.reverse != null; 57 | } 58 | 59 | /** 60 | * Flips the direction of the receiver's transformation, such that `transformedValue` will 61 | * become `reverseTransformedValue`, and vice-versa. 62 | * 63 | * @returns an inverted transformer. 64 | */ 65 | public invertedTransformer(): ValueTransformer { 66 | return ValueTransformer.forwardAndReversible( 67 | (value) => { 68 | return this.reverseTransformedValue(value); 69 | }, 70 | (value) => { 71 | return this.transformedValue(value); 72 | }, 73 | ); 74 | } 75 | 76 | /** 77 | * @returns a transformer which transforms values using the given function. Reverse transformation will not be 78 | * allowed. 79 | * 80 | * @param transformation 81 | */ 82 | public static forward(transformation: ValueTransformerFunction): ValueTransformer { 83 | return new ValueTransformer(transformation); 84 | } 85 | 86 | /** 87 | * @returns a transformer which transforms values using the given function, for forward or reverse transformations. 88 | * 89 | * @param transformation 90 | */ 91 | public static reversible(transformation: ValueTransformerFunction): ValueTransformer { 92 | return new ValueTransformer(transformation, transformation); 93 | } 94 | 95 | /** 96 | * @returns a transformer which transforms values using the given functions. 97 | * 98 | * @param forward 99 | * @param reverse 100 | */ 101 | public static forwardAndReversible(forward: ValueTransformerFunction, reverse: ValueTransformerFunction): ValueTransformer { 102 | return new ValueTransformer(forward, reverse); 103 | } 104 | 105 | /** 106 | * A reversible value transformer to transform between the keys and values of an object. 107 | * 108 | * @param object The object whose keys and values should be transformed between. 109 | * @param defaultValue The result to fall back to, in case no key matching the input value was found during 110 | * the forward transformation. 111 | * @param reverseDefaultValue The result to fall back to, in case no value matching the input value was found 112 | * during a reverse transformation. 113 | */ 114 | public static valueMappingTransformer(object: { [key: string]: any }, defaultValue?: any, reverseDefaultValue?: any): ValueTransformer { 115 | return ValueTransformer.forwardAndReversible( 116 | (key) => { 117 | const value = object[key]; 118 | 119 | return value != null ? value : defaultValue; 120 | }, 121 | (value) => { 122 | for (const key of Object.keys(object)) { 123 | const reverseValue = object[key]; 124 | 125 | if (isEqual(value, reverseValue)) { 126 | return key; 127 | } 128 | } 129 | 130 | return reverseDefaultValue; 131 | }, 132 | ); 133 | } 134 | 135 | /** 136 | * A reversible value transformer to transform between a number and it's string representation. 137 | * 138 | * @returns a transformer which will map from strings to numbers for forward transformations, and 139 | * from numbers to strings for reverse transformations. 140 | * 141 | * @param locales A locale string or array of locale strings that contain one or more language or locale tags. 142 | * If you include more than one locale string, list them in descending order of priority so that the first entry 143 | * is the preferred locale. If you omit this parameter, the default locale of the JavaScript runtime is used. 144 | * @param options An object that contains one or more properties that specify comparison options. 145 | */ 146 | public static numberTransformer(locales?: string | string[], options?: Intl.NumberFormatOptions): ValueTransformer { 147 | return ValueTransformer.forwardAndReversible( 148 | (value: string) => { 149 | if (value == null) { return null; } 150 | 151 | // make sure the value is a string 152 | if (typeof value !== "string") { 153 | throw CreateError(`Could not convert string to number. Expected a string as input, got: ${value}.`, ErrorTypes.TransformerHandlingInvalidInput); 154 | } 155 | 156 | const num = parseFloat(value); 157 | 158 | if (isNaN(num)) { 159 | return null; 160 | } 161 | 162 | return num; 163 | }, 164 | (value: number) => { 165 | if (value == null) { return null; } 166 | 167 | // make sure the value is a number 168 | if (typeof value !== "number" || isNaN(value)) { 169 | throw CreateError(`Could not convert number to string. Expected a number as input, got: ${value}.`, ErrorTypes.TransformerHandlingInvalidInput); 170 | } 171 | 172 | return value.toLocaleString(locales, options); 173 | }, 174 | ); 175 | } 176 | 177 | /** 178 | * Creates a reversible transformer to convert an object into a {@link Model} object, and vice-versa. 179 | * 180 | * @param Class The Model subclass to attempt to parse from the JSON. 181 | */ 182 | public static objectTransformer(Class: Newable): ValueTransformer { 183 | return ValueTransformer.forwardAndReversible( 184 | (value?: any) => { 185 | if (value == null) { return null; } 186 | 187 | // make sure the value is an object 188 | if (typeof value !== "object") { 189 | throw CreateError(`Could not convert JSON object to model object. Expected an object, got: ${value}.`, ErrorTypes.TransformerHandlingInvalidInput); 190 | } 191 | 192 | return ModelFromObject(value, Class); 193 | }, 194 | (value?: Serializable) => { 195 | if (value == null) { return null; } 196 | 197 | return ObjectFromModel(value); 198 | }, 199 | ); 200 | } 201 | 202 | /** 203 | * Creates a reversible transformer to convert an array of objects into an array of {@link Model} 204 | * objects, and vice-versa. 205 | * 206 | * @param Class The Model subclass to attempt to parse from each JSON object. 207 | */ 208 | public static arrayTransformer(Class: Newable): ValueTransformer { 209 | return ValueTransformer.forwardAndReversible( 210 | (value) => { 211 | // make sure we have a value 212 | if (value == null) { return null; } 213 | 214 | // make sure the value is an array 215 | if (!Array.isArray(value)) { 216 | throw CreateError(`Could not convert JSON array to model array. Expected an array, got: ${value}.`, ErrorTypes.TransformerHandlingInvalidInput); 217 | } 218 | 219 | const models: any[] = []; 220 | 221 | for (const object of value) { 222 | // if the object is null, just add null 223 | if (object == null) { 224 | models.push(null); 225 | continue; 226 | } 227 | 228 | // make sure the value is an object 229 | if (typeof object !== "object") { 230 | throw CreateError(`Could not convert JSON array to model array. Expected an object or null, got: ${object}.`, ErrorTypes.TransformerHandlingInvalidInput); 231 | } 232 | 233 | // convert the model 234 | const model = ModelFromObject(object, Class); 235 | 236 | /* istanbul ignore next */ 237 | if (!model) { continue; } 238 | 239 | models.push(model); 240 | } 241 | 242 | return models; 243 | }, 244 | (value) => { 245 | if (value == null) { return null; } 246 | 247 | // make sure the value is an array 248 | if (!Array.isArray(value)) { 249 | throw CreateError(`Could not convert model array to JSON array. Expected an array, got: ${value}.`, ErrorTypes.TransformerHandlingInvalidInput); 250 | } 251 | 252 | const objects: any[] = []; 253 | 254 | for (const model of value) { 255 | // if the object is null, just add null 256 | if (model == null) { 257 | objects.push(null); 258 | continue; 259 | } 260 | 261 | // make sure the value is an object 262 | if (typeof model !== "object") { 263 | throw CreateError(`Could not convert model array to JSON array. Expected a model or null, got: ${model}.`, ErrorTypes.TransformerHandlingInvalidInput); 264 | } 265 | 266 | // convert the model 267 | const object = ObjectFromModel(model); 268 | 269 | /* istanbul ignore next */ 270 | if (!object) { continue; } 271 | 272 | objects.push(object); 273 | } 274 | 275 | return objects; 276 | }); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /__tests__/TestModel.ts: -------------------------------------------------------------------------------- 1 | import { Serializable, ValueTransformer, KeyPaths, Model, Newable } from "../lib"; 2 | 3 | export const TestModelNameTooLongError = "TestModelNameTooLongError"; 4 | export const TestModelNameMissingError = "TestModelNameMissingError"; 5 | 6 | export class TestModel extends Model { 7 | /** 8 | * Must be less than 10 characters. 9 | * 10 | * This property is associated with a "username" key in JSON. 11 | */ 12 | public name: string; 13 | 14 | /** 15 | * Defaults to 1. 16 | * 17 | * This property is a string in JSON. 18 | */ 19 | public count: number; 20 | 21 | /** 22 | * This property is associated with a "nested.name" key path in JSON. 23 | */ 24 | public nestedName: string; 25 | 26 | constructor() { 27 | super(); 28 | 29 | this.count = 1; 30 | } 31 | 32 | public static JSONKeyPaths(): KeyPaths { 33 | return { 34 | name: "username", 35 | count: "count", 36 | nestedName: "nested.name", 37 | }; 38 | } 39 | 40 | public static countJSONTransformer(): ValueTransformer { 41 | return ValueTransformer.forwardAndReversible( 42 | (value: string) => { 43 | const result = parseInt(value); 44 | if (!isNaN(result)) { 45 | return result; 46 | } 47 | 48 | return null; 49 | }, 50 | (value?: number) => { 51 | /* istanbul ignore else */ 52 | if (value != null) { 53 | return value.toString(); 54 | } 55 | 56 | /* istanbul ignore next */ 57 | return null; 58 | }, 59 | ); 60 | } 61 | 62 | /** 63 | * Merging 64 | */ 65 | 66 | public mergeCountFromModel(model: T): void { 67 | this.count += model.count; 68 | } 69 | 70 | /** 71 | * Validation 72 | */ 73 | 74 | public validateName(): boolean { 75 | if (this.name == null) { 76 | return true; 77 | } 78 | 79 | if (this.name.length < 10) { 80 | return true; 81 | } 82 | 83 | const error = new Error(`Expected name to be under 10 characters, got: ${this.name}`); 84 | error.name = TestModelNameTooLongError; 85 | 86 | throw error; 87 | } 88 | 89 | public validateCount(): boolean { 90 | if (this.count < 10) { 91 | return true; 92 | } 93 | 94 | return false; 95 | } 96 | } 97 | 98 | export class SubclassTestModel extends TestModel { 99 | public role: string; 100 | public generation: number; 101 | } 102 | 103 | interface Coordinate { 104 | latitude: number; 105 | longitude: number; 106 | } 107 | 108 | export class MultiKeypathModel extends Model { 109 | /** 110 | * This property is associated with the "latitude" and "longitude" keys in JSON. 111 | */ 112 | public location: Coordinate; 113 | 114 | /** 115 | * This property is associated with the "nested.latitude" and "nested.longitude" 116 | * keys in JSON. 117 | */ 118 | public nestedLocation: Coordinate; 119 | 120 | public static JSONKeyPaths(): KeyPaths { 121 | return { 122 | location: ["latitude", "longitude"], 123 | nestedLocation: ["nested.latitude", "nested.longitude"], 124 | }; 125 | } 126 | 127 | public static locationJSONTransformer(): ValueTransformer { 128 | return ValueTransformer.forward( 129 | (value) => { 130 | return value; 131 | }, 132 | ); 133 | } 134 | 135 | public static nestedLocationJSONTransformer(): ValueTransformer { 136 | return ValueTransformer.forwardAndReversible( 137 | (value) => { 138 | return value.nested; 139 | }, 140 | (value: Coordinate) => { 141 | return { 142 | "nested": value, 143 | }; 144 | }, 145 | ); 146 | } 147 | } 148 | 149 | export class ValidationModel extends Model { 150 | /** 151 | * Defaults to null, which is not considered valid. 152 | */ 153 | public name: string = null; 154 | 155 | public static JSONKeyPaths(): KeyPaths { 156 | return { 157 | name: "name", 158 | }; 159 | } 160 | 161 | public validateName(): boolean { 162 | if (this.name != null) { 163 | return true; 164 | } 165 | 166 | const error = new Error("Expected name to not be null"); 167 | error.name = TestModelNameMissingError; 168 | 169 | throw error; 170 | } 171 | } 172 | 173 | /** 174 | * Sets the name to "foobar" when `validateName` is invoked with null name. 175 | */ 176 | export class SelfValidatingModel extends ValidationModel { 177 | public validateName(): boolean { 178 | /* istanbul ignore if */ 179 | if (this.name != null) { 180 | return true; 181 | } 182 | 183 | this.name = "foobar"; 184 | 185 | return true; 186 | } 187 | } 188 | 189 | export class URL { 190 | public url: string; 191 | 192 | constructor(url: string) { 193 | const re = /[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?/gi; 194 | 195 | if (!re.test(url)) { 196 | throw new Error(`Invalid url: ${url}`); 197 | } 198 | 199 | this.url = url; 200 | } 201 | } 202 | 203 | export class URLModel extends Model { 204 | /** 205 | * Defaults to http://github.com. 206 | */ 207 | public url: URL; 208 | 209 | constructor() { 210 | super(); 211 | 212 | this.url = new URL("http://github.com"); 213 | } 214 | 215 | public static JSONKeyPaths(): KeyPaths { 216 | return { 217 | url: "url", 218 | }; 219 | } 220 | 221 | public static urlJSONTransformer(): ValueTransformer { 222 | return ValueTransformer.forwardAndReversible( 223 | (value: string) => { 224 | return new URL(value); 225 | }, 226 | (value: URL) => { 227 | if (value instanceof URL) { 228 | return value.url; 229 | } 230 | throw new Error("Invalid URL"); 231 | }, 232 | ); 233 | } 234 | } 235 | 236 | export class URLSubclassModel extends URLModel { 237 | /** 238 | * Defaults to http://foo.com. 239 | */ 240 | public otherUrl: URL; 241 | 242 | constructor() { 243 | super(); 244 | 245 | this.otherUrl = new URL("http://foo.com"); 246 | } 247 | 248 | public static JSONKeyPaths(): KeyPaths { 249 | return { 250 | ...super.JSONKeyPaths(), 251 | otherUrl: "otherUrl", 252 | }; 253 | } 254 | 255 | public static JSONTransformerForKey(key: string): ValueTransformer { 256 | /* istanbul ignore else */ 257 | if (key === "otherUrl") { 258 | return URLModel.urlJSONTransformer(); 259 | } 260 | } 261 | } 262 | 263 | /** 264 | * Conforms to {@link Serializable} but does not inherit from the {@link Model} class. 265 | */ 266 | export class ConformingModel extends Serializable { 267 | public name: string; 268 | 269 | public static JSONKeyPaths(): KeyPaths { 270 | return { 271 | name: "name", 272 | }; 273 | } 274 | } 275 | 276 | /** 277 | * Parses {@link TestModel} objects from JSON instead. 278 | */ 279 | export class SubstitutingTestModel extends Model { 280 | /* istanbul ignore next */ 281 | public static JSONKeyPaths(): KeyPaths { 282 | return {}; 283 | } 284 | 285 | public static classForParsingObject(json: any): Newable { 286 | if (json.username != null) { 287 | return TestModel; 288 | } 289 | 290 | return null; 291 | } 292 | } 293 | 294 | export class ClassClusterModel extends Model { 295 | public flavor: string; 296 | 297 | public static JSONKeyPaths(): KeyPaths { 298 | return { 299 | flavor: "flavor", 300 | }; 301 | } 302 | 303 | public static classForParsingObject(json: any): Newable { 304 | if (json.flavor === "chocolate") { 305 | return ChocolateClassClusterModel; 306 | } 307 | 308 | /* istanbul ignore else */ 309 | if (json.flavor === "strawberry") { 310 | return StrawberryClassClusterModel; 311 | } 312 | } 313 | } 314 | 315 | export class ChocolateClassClusterModel extends ClassClusterModel { 316 | /** 317 | * Associated with the "chocolate_bitterness" JSON key and transformed to a string. 318 | */ 319 | public bitterness: number; 320 | 321 | public get flavor(): string { 322 | return "chocolate"; 323 | } 324 | 325 | public static JSONKeyPaths(): KeyPaths { 326 | return { 327 | ...super.JSONKeyPaths(), 328 | bitterness: "chocolate_bitterness", 329 | }; 330 | } 331 | 332 | public static bitternessJSONTransformer(): ValueTransformer { 333 | return ValueTransformer.forwardAndReversible( 334 | (value: string) => { 335 | return parseInt(value); 336 | }, 337 | (value: number) => { 338 | return value.toString(); 339 | }, 340 | ); 341 | } 342 | } 343 | 344 | export class StrawberryClassClusterModel extends ClassClusterModel { 345 | /** 346 | * Associated with the "strawberry_freshness" JSON key. 347 | */ 348 | public freshness: number; 349 | 350 | public get flavor(): string { 351 | return "strawberry"; 352 | } 353 | 354 | public static JSONKeyPaths(): KeyPaths { 355 | return { 356 | ...super.JSONKeyPaths(), 357 | freshness: "strawberry_freshness", 358 | }; 359 | } 360 | } 361 | 362 | export class RecursiveUserModel extends Model { 363 | public name: string; 364 | public groups: RecursiveGroupModel[]; 365 | 366 | public static JSONKeyPaths(): KeyPaths { 367 | return { 368 | name: "name_", 369 | groups: "groups_", 370 | }; 371 | } 372 | 373 | public static groupsJSONTransformer(): ValueTransformer { 374 | return ValueTransformer.arrayTransformer(RecursiveGroupModel); 375 | } 376 | } 377 | 378 | export class RecursiveGroupModel extends Model { 379 | public owner: RecursiveUserModel; 380 | public users: RecursiveUserModel[]; 381 | 382 | public static JSONKeyPaths(): KeyPaths { 383 | return { 384 | owner: "owner_", 385 | users: "users_", 386 | }; 387 | } 388 | 389 | public static ownerJSONTransformer(): ValueTransformer { 390 | return ValueTransformer.objectTransformer(RecursiveUserModel); 391 | } 392 | 393 | public static usersJSONTransformer(): ValueTransformer { 394 | return ValueTransformer.arrayTransformer(RecursiveUserModel); 395 | } 396 | } 397 | 398 | export class HostedURLsModel extends Model { 399 | public urls: URLModel[]; 400 | 401 | public static JSONKeyPaths(): KeyPaths { 402 | return { 403 | urls: "urls", 404 | }; 405 | } 406 | 407 | public static urlsJSONTransformer(): ValueTransformer { 408 | return ValueTransformer.arrayTransformer(URLModel); 409 | } 410 | } 411 | 412 | export class DefaultValuesModel extends Model { 413 | public name: string; 414 | 415 | /** 416 | * Defaults to foo 417 | */ 418 | public foo: string; 419 | 420 | constructor() { 421 | super(); 422 | 423 | this.foo = "foo"; 424 | } 425 | 426 | public static JSONKeyPaths(): KeyPaths { 427 | return { 428 | name: "name", 429 | }; 430 | } 431 | } 432 | 433 | export class InvalidTransformersModel extends Model { 434 | public foo: string; 435 | public bar: string; 436 | 437 | public static JSONKeyPaths(): KeyPaths { 438 | return { 439 | foo: "foo", 440 | bar: "bar", 441 | }; 442 | } 443 | 444 | public static fooJSONTransformer(): ValueTransformer { 445 | return null; 446 | } 447 | 448 | public static JSONTransformerForKey(): ValueTransformer { 449 | return null; 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /__tests__/JSONAdapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { Serializable, ObjectFromModel, ModelFromObject } from "../lib"; 2 | import { 3 | TestModel, MultiKeypathModel, URLModel, SubstitutingTestModel, ChocolateClassClusterModel, StrawberryClassClusterModel, 4 | RecursiveGroupModel, URLSubclassModel, URL, HostedURLsModel, DefaultValuesModel, ClassClusterModel, InvalidTransformersModel, TestModelNameTooLongError, ValidationModel, TestModelNameMissingError, ConformingModel, 5 | } from "./TestModel"; 6 | import { ErrorTypes } from "../lib/constants"; 7 | 8 | describe("JSONAdapter", () => { 9 | describe("serialize nested key paths", () => { 10 | const values = { 11 | "username": null, 12 | "count": "5", 13 | }; 14 | 15 | const expected = { 16 | "username": null, 17 | "count": "5", 18 | "nested": { 19 | "name": null, 20 | }, 21 | }; 22 | 23 | it("should initialize nested key paths from JSON", () => { 24 | const model = TestModel.from(values); 25 | 26 | expect(model).toBeDefined(); 27 | expect(model.name).toBeNull(); 28 | expect(model.count).toEqual(5); 29 | 30 | expect(model.toJSON()).toEqual(expected); 31 | }); 32 | }); 33 | 34 | it("should return null when serializing to JSON with invalid data", () => { 35 | const model = new URLModel(); 36 | model.url = "" as any; 37 | 38 | expect(model.toJSON()).toBeNull(); 39 | }); 40 | 41 | it("should initialize nested key paths from JSON", () => { 42 | const values = { 43 | "username": "foo", 44 | "nested": { 45 | "name": "bar", 46 | }, 47 | "count": "0", 48 | }; 49 | 50 | const model = TestModel.from(values); 51 | 52 | expect(model).toBeDefined(); 53 | expect(model.name).toEqual("foo"); 54 | expect(model.count).toEqual(0); 55 | expect(model.nestedName).toEqual("bar"); 56 | 57 | expect(model.toJSON()).toEqual(values); 58 | }); 59 | 60 | it("it should initialize properties with multiple key paths from JSON", () => { 61 | const values = { 62 | "latitude": 20, 63 | "longitude": 12, 64 | "nested": { 65 | "latitude": 12, 66 | "longitude": 34, 67 | }, 68 | }; 69 | 70 | const model = MultiKeypathModel.from(values); 71 | 72 | expect(model).toBeDefined(); 73 | expect(model.location.latitude).toEqual(20); 74 | expect(model.location.longitude).toEqual(12); 75 | expect(model.nestedLocation.latitude).toEqual(12); 76 | expect(model.nestedLocation.longitude).toEqual(34); 77 | 78 | expect(model.toJSON()).toEqual(values); 79 | }); 80 | 81 | it("should initialize without returning any error when using a JSON with null as value", () => { 82 | const values = { 83 | "username": "foo", 84 | "nested": null, 85 | "count": "0", 86 | }; 87 | 88 | const model = TestModel.from(values); 89 | 90 | expect(model).toBeDefined(); 91 | expect(model.name).toEqual("foo"); 92 | expect(model.count).toEqual(0); 93 | expect(model.nestedName).toBeNull(); 94 | }); 95 | 96 | it("should ignore unrecognized JSON keys", () => { 97 | const values = { 98 | "foobar": "foo", 99 | "count": "2", 100 | "_": null, 101 | "username": "buzz", 102 | "nested": { 103 | "name": "bar", 104 | "stuffToIgnore": 5, 105 | "moreNonsense": null, 106 | }, 107 | }; 108 | 109 | const model = TestModel.from(values); 110 | 111 | expect(model).toBeDefined(); 112 | expect(model.name).toEqual("buzz"); 113 | expect(model.count).toEqual(2); 114 | expect(model.nestedName).toEqual("bar"); 115 | }); 116 | 117 | it("should fail to initialize if JSON validation fails", () => { 118 | const values = { 119 | "username": "this name is too long", 120 | }; 121 | 122 | let model: TestModel; 123 | let error: Error; 124 | 125 | try { 126 | model = TestModel.from(values); 127 | } catch (err) { 128 | error = err; 129 | } 130 | 131 | expect(model).toBeUndefined(); 132 | expect(error).toBeDefined(); 133 | expect(error.name).toEqual(TestModelNameTooLongError); 134 | }); 135 | 136 | it("should fail to initialize if JSON transformer fails", () => { 137 | const values = { 138 | "url": 666, 139 | }; 140 | 141 | let model: URLModel; 142 | let error: Error; 143 | 144 | try { 145 | model = URLModel.from(values); 146 | } catch (err) { 147 | error = err; 148 | } 149 | 150 | expect(model).toBeUndefined(); 151 | expect(error).toBeDefined(); 152 | }); 153 | 154 | it("should use JSONTransformerForKey transformer", () => { 155 | const values = { 156 | "url": "http://github.com/1", 157 | "otherUrl": "http://github.com/2", 158 | }; 159 | 160 | const model = URLSubclassModel.from(values); 161 | 162 | expect(model).toBeDefined(); 163 | expect(model.url).toEqual(new URL("http://github.com/1")); 164 | expect(model.otherUrl).toEqual(new URL("http://github.com/2")); 165 | 166 | expect(model.toJSON()).toEqual(values); 167 | }); 168 | 169 | it("should initialize default values", () => { 170 | const values = { 171 | "name": "John", 172 | }; 173 | 174 | const model = DefaultValuesModel.from(values); 175 | 176 | expect(model).toBeDefined(); 177 | expect(model.name).toEqual("John"); 178 | expect(model.foo).toEqual("foo"); 179 | }); 180 | 181 | it("should fail to serialize if a JSON transformer errors", () => { 182 | const model = new URLModel(); 183 | 184 | (model.url as any) = "totallyNotAnNSURL"; 185 | 186 | let values: any; 187 | let error: Error; 188 | 189 | try { 190 | values = model.toObject(); 191 | } catch (err) { 192 | error = err; 193 | } 194 | 195 | expect(values).toBeUndefined(); 196 | expect(error).toBeDefined(); 197 | }); 198 | 199 | it("should parse a different model class", () => { 200 | const values = { 201 | "username": "foo", 202 | "nested": { 203 | "name": "bar", 204 | }, 205 | "count": "0", 206 | }; 207 | 208 | const model = SubstitutingTestModel.from(values) as TestModel; 209 | 210 | expect(model).toBeInstanceOf(TestModel); 211 | expect(model.name).toEqual("foo"); 212 | expect(model.count).toEqual(0); 213 | expect(model.nestedName).toEqual("bar"); 214 | 215 | expect(model.toJSON()).toEqual(values); 216 | }); 217 | 218 | it("should serialize different model classes", () => { 219 | const chocolateValues = { 220 | "flavor": "chocolate", 221 | "chocolate_bitterness": "100", 222 | }; 223 | 224 | const chocolateModel = ClassClusterModel.from(chocolateValues) as ChocolateClassClusterModel; 225 | 226 | expect(chocolateModel).toBeDefined(); 227 | expect(chocolateModel.flavor).toEqual("chocolate"); 228 | expect(chocolateModel.bitterness).toEqual(100); 229 | 230 | expect(chocolateModel.toJSON()).toEqual(chocolateValues); 231 | 232 | const strawberryValues = { 233 | "flavor": "strawberry", 234 | "strawberry_freshness": 20, 235 | }; 236 | 237 | const strawberryModel = ClassClusterModel.from(strawberryValues) as StrawberryClassClusterModel; 238 | 239 | expect(strawberryModel).toBeDefined(); 240 | expect(strawberryModel.flavor).toEqual("strawberry"); 241 | expect(strawberryModel.freshness).toEqual(20); 242 | 243 | expect(strawberryModel.toJSON()).toEqual(strawberryValues); 244 | }); 245 | 246 | it("should return an error when no suitable model class is found", () => { 247 | let model: TestModel; 248 | let error: Error; 249 | 250 | try { 251 | model = SubstitutingTestModel.from({}) as TestModel; 252 | } catch (err) { 253 | error = err; 254 | } 255 | 256 | expect(model).toBeUndefined(); 257 | expect(error).toBeDefined(); 258 | expect(error.name).toEqual(ErrorTypes.JSONAdapterNoClassFound); 259 | }); 260 | 261 | it("should validate models", () => { 262 | let model: ValidationModel; 263 | let error: Error; 264 | 265 | try { 266 | model = ValidationModel.from({}); 267 | } catch (err) { 268 | error = err; 269 | } 270 | 271 | expect(model).toBeUndefined(); 272 | expect(error).toBeDefined(); 273 | expect(error.name).toEqual(TestModelNameMissingError); 274 | }); 275 | 276 | it("should ignore invalid transformers", () => { 277 | const values = { 278 | "foo": "foo", 279 | "bar": "bar", 280 | }; 281 | 282 | const model = InvalidTransformersModel.from(values); 283 | 284 | expect(model).toBeDefined(); 285 | expect(model.foo).toEqual("foo"); 286 | expect(model.bar).toEqual("bar"); 287 | }); 288 | 289 | it("should parse model classes not inheriting from Model", () => { 290 | const values = { 291 | "name": "foo", 292 | }; 293 | 294 | let model: ConformingModel; 295 | let error: Error; 296 | 297 | try { 298 | model = ModelFromObject(values, ConformingModel); 299 | } catch (err) { 300 | error = err; 301 | } 302 | 303 | expect(error).toBeUndefined(); 304 | expect(model).toBeDefined(); 305 | expect(model.name).toEqual("foo"); 306 | }); 307 | }); 308 | 309 | describe("Deserializing multiple models", () => { 310 | const values = [ 311 | { 312 | "username": "foo", 313 | }, 314 | { 315 | "username": "bar", 316 | }, 317 | ]; 318 | 319 | it("should initialize models from an array of objects", () => { 320 | const models = TestModel.fromArray(values); 321 | 322 | expect(models).toBeDefined(); 323 | expect(models.length).toEqual(2); 324 | expect(models[0].name).toEqual("foo"); 325 | expect(models[1].name).toEqual("bar"); 326 | }); 327 | 328 | it("should return null on null input", () => { 329 | const models = TestModel.fromArray(null); 330 | 331 | expect(models).toBeNull(); 332 | }); 333 | 334 | it("should return error on non-array input", () => { 335 | let models: TestModel[]; 336 | let error: Error; 337 | 338 | try { 339 | models = TestModel.fromArray({} as any[]); 340 | } catch (err) { 341 | error = err; 342 | } 343 | 344 | expect(models).toBeUndefined(); 345 | expect(error).toBeDefined(); 346 | expect(error.name).toEqual(ErrorTypes.JSONAdapterInvalidJSON); 347 | }); 348 | }); 349 | 350 | it("should return undefined and an error if it fails to initialize any model from an array", () => { 351 | const values = [ 352 | { 353 | "username": "foo", 354 | "count": "1", 355 | }, 356 | { 357 | "count": ["This won't parse"], 358 | }, 359 | ]; 360 | 361 | let models: SubstitutingTestModel[]; 362 | let error: Error; 363 | 364 | try { 365 | models = SubstitutingTestModel.fromArray(values); 366 | } catch (err) { 367 | error = err; 368 | } 369 | 370 | expect(error).toBeDefined(); 371 | expect(error.name).toEqual(ErrorTypes.JSONAdapterNoClassFound); 372 | expect(models).toBeUndefined(); 373 | }); 374 | 375 | it("should return null if it fails to parse any model from an array", () => { 376 | const values = [ 377 | { 378 | "username": "foo", 379 | "count": "1", 380 | }, 381 | null, 382 | ]; 383 | 384 | const models = SubstitutingTestModel.fromArray(values); 385 | 386 | expect(models).toBeNull(); 387 | }); 388 | 389 | describe("serialize array of objects from models", () => { 390 | const model1 = TestModel.create({ 391 | name: "foo", 392 | }); 393 | 394 | const model2 = TestModel.create({ 395 | name: "bar", 396 | }); 397 | 398 | it("should return an array of objects from models", () => { 399 | const objects = TestModel.toArray([model1, model2]); 400 | 401 | expect(objects).toBeDefined(); 402 | expect(objects.length).toEqual(2); 403 | expect(objects[0].username).toEqual("foo"); 404 | expect(objects[1].username).toEqual("bar"); 405 | }); 406 | 407 | it("should return null from null models", () => { 408 | const objects = TestModel.toArray(null); 409 | 410 | expect(objects).toBeNull(); 411 | }); 412 | 413 | it("should throw exception on non-array input", () => { 414 | let objects: any[]; 415 | let error: Error; 416 | 417 | try { 418 | objects = TestModel.toArray({} as Serializable[]); 419 | } catch (err) { 420 | error = err; 421 | } 422 | 423 | expect(objects).toBeUndefined(); 424 | expect(error).toBeDefined(); 425 | expect(error.name).toEqual(ErrorTypes.JSONAdapterInvalidJSON); 426 | }); 427 | }); 428 | 429 | describe("recursive models", () => { 430 | it("should support recursive models", () => { 431 | const values = { 432 | "owner_": { 433 | "name_": "Cameron", 434 | "groups_": [ 435 | { 436 | "owner_": { 437 | "name_": "Jane", 438 | "groups_": null, 439 | }, 440 | "users_": null, 441 | }, 442 | ], 443 | }, 444 | "users_": [ 445 | { 446 | "name_": "Dimitri", 447 | "groups_": [ 448 | { 449 | "owner_": { 450 | "name_": "Doe", 451 | "groups_": [ 452 | { 453 | "owner_": { 454 | "name_": "X", 455 | "groups_": null, 456 | }, 457 | "users_": null, 458 | }, 459 | ], 460 | }, 461 | "users_": null, 462 | }, 463 | { 464 | "owner_": null, 465 | "users_": null, 466 | }, 467 | ], 468 | }, 469 | { 470 | "name_": "John", 471 | "groups_": null, 472 | }, 473 | ], 474 | }; 475 | 476 | const model = RecursiveGroupModel.from(values); 477 | 478 | expect(model).toBeDefined(); 479 | expect(model.owner).toBeDefined(); 480 | expect(model.owner.name).toEqual("Cameron"); 481 | expect(model.owner.groups).toBeDefined(); 482 | expect(model.owner.groups.length).toEqual(1); 483 | expect(model.owner.groups[0].owner).toBeDefined(); 484 | expect(model.owner.groups[0].owner.name).toEqual("Jane"); 485 | expect(model.users).toBeDefined(); 486 | expect(model.users.length).toEqual(2); 487 | expect(model.users[0].name).toEqual("Dimitri"); 488 | expect(model.users[0].groups).toBeDefined(); 489 | expect(model.users[0].groups.length).toEqual(2); 490 | expect(model.users[0].groups[0].owner).toBeDefined(); 491 | expect(model.users[0].groups[0].owner.name).toEqual("Doe"); 492 | expect(model.users[0].groups[0].owner.groups).toBeDefined(); 493 | expect(model.users[0].groups[0].owner.groups.length).toEqual(1); 494 | expect(model.users[0].groups[0].owner.groups[0].owner).toBeDefined(); 495 | expect(model.users[0].groups[0].owner.groups[0].owner.name).toEqual("X"); 496 | expect(model.users[0].groups[1].owner).toBeNull(); 497 | expect(model.users[0].groups[1].users).toBeNull(); 498 | 499 | expect(model.toJSON()).toEqual(values); 500 | }); 501 | 502 | it("should throw error on non-object input", () => { 503 | const values = { 504 | "owner_": "foo", 505 | }; 506 | 507 | let model: RecursiveGroupModel; 508 | let error: Error; 509 | 510 | try { 511 | model = RecursiveGroupModel.from(values); 512 | } catch (err) { 513 | error = err; 514 | } 515 | 516 | expect(model).toBeUndefined(); 517 | expect(error).toBeDefined(); 518 | expect(error.name).toEqual(ErrorTypes.TransformerHandlingInvalidInput); 519 | }); 520 | 521 | it("should throw error on non-array input", () => { 522 | const values = { 523 | "users_": {}, 524 | }; 525 | 526 | let model: RecursiveGroupModel; 527 | let error: Error; 528 | 529 | try { 530 | model = RecursiveGroupModel.from(values); 531 | } catch (err) { 532 | error = err; 533 | } 534 | 535 | expect(model).toBeUndefined(); 536 | expect(error).toBeDefined(); 537 | expect(error.name).toEqual(ErrorTypes.TransformerHandlingInvalidInput); 538 | }); 539 | 540 | it("should throw error if array item is not an object", () => { 541 | const values = { 542 | "urls": [ 543 | "foo", 544 | ], 545 | }; 546 | 547 | let model: HostedURLsModel; 548 | let error: Error; 549 | 550 | try { 551 | model = HostedURLsModel.from(values); 552 | } catch (err) { 553 | error = err; 554 | } 555 | 556 | expect(model).toBeUndefined(); 557 | expect(error).toBeDefined(); 558 | expect(error.name).toEqual(ErrorTypes.TransformerHandlingInvalidInput); 559 | }); 560 | 561 | it("should add null values to parsed array", () => { 562 | const values = { 563 | "urls": [ 564 | { 565 | "url": "http://foo.com", 566 | }, 567 | null, 568 | { 569 | "url": "http://bar.com", 570 | }, 571 | ], 572 | }; 573 | 574 | const model = HostedURLsModel.from(values); 575 | 576 | expect(model).toBeDefined(); 577 | expect(model.urls.length).toEqual(3); 578 | expect(model.urls[0].url).toEqual(new URL("http://foo.com")); 579 | expect(model.urls[1]).toBeNull(); 580 | expect(model.urls[2].url).toEqual(new URL("http://bar.com")); 581 | 582 | expect(model.toJSON()).toEqual(values); 583 | }); 584 | 585 | it("should throw error when deserializing a non-array", () => { 586 | const model = new HostedURLsModel(); 587 | model.urls = {} as URLModel[]; 588 | 589 | let values: any; 590 | let error: Error; 591 | 592 | try { 593 | values = model.toObject(); 594 | } catch (err) { 595 | error = err; 596 | } 597 | 598 | expect(values).toBeUndefined(); 599 | expect(error).toBeDefined(); 600 | expect(error.name).toEqual(ErrorTypes.TransformerHandlingInvalidInput); 601 | }); 602 | 603 | it("should throw error when deserializing a non-object inside array", () => { 604 | const url = new URLModel(); 605 | url.url = new URL("http://foo.com"); 606 | 607 | const model = new HostedURLsModel(); 608 | model.urls = [ 609 | url, 610 | "foo", 611 | ] as URLModel[]; 612 | 613 | let values: any; 614 | let error: Error; 615 | 616 | try { 617 | values = model.toObject(); 618 | } catch (err) { 619 | error = err; 620 | } 621 | 622 | expect(values).toBeUndefined(); 623 | expect(error).toBeDefined(); 624 | expect(error.name).toEqual(ErrorTypes.TransformerHandlingInvalidInput); 625 | }); 626 | }); 627 | --------------------------------------------------------------------------------