├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.MD ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── _config.yml ├── docs └── images │ ├── mophism-function-full-example.png │ ├── morphism.png │ └── schema.png ├── images ├── inferred-field-type.png ├── schema-required-fields.png ├── schema.png └── ts-action-function.png ├── jest.config.js ├── now.json ├── package.json ├── src ├── MorphismDecorator.ts ├── MorphismRegistry.spec.ts ├── MorphismRegistry.ts ├── MorphismTree.spec.ts ├── MorphismTree.ts ├── classObjects.spec.ts ├── helpers.ts ├── jsObjects.spec.ts ├── morphism.spec.ts ├── morphism.ts ├── types.ts ├── typescript.spec.ts ├── utils-test.ts └── validation │ ├── Validation.spec.ts │ ├── Validation.ts │ ├── reporter.spec.ts │ ├── reporter.ts │ └── validators │ ├── BooleanValidator.ts │ ├── LinkedList.ts │ ├── NumberValidator.ts │ ├── StringValidator.ts │ ├── ValidatorError.ts │ ├── index.ts │ └── types.ts ├── tsconfig.json ├── tsconfig.prod.json ├── tsconfig.webpack.json ├── tslint.json ├── typedoc.js ├── webpack.config.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "node": "current", 9 | "esmodules": true 10 | } 11 | } 12 | ] 13 | ], 14 | "plugins": [ 15 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 16 | ["@babel/proposal-class-properties", { "loose": true }], 17 | "@babel/proposal-object-rest-spread" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | test-job: 8 | docker: 9 | - image: circleci/node:10 10 | working_directory: ~/repo 11 | steps: 12 | - checkout 13 | # Download and cache dependencies 14 | - restore_cache: 15 | keys: 16 | - v1-dependencies-{{ checksum "yarn.lock" }} 17 | - run: yarn install 18 | - save_cache: 19 | paths: 20 | - node_modules 21 | key: v1-dependencies-{{ checksum "yarn.lock" }} 22 | # run tests! 23 | - run: 24 | name: Run Tests 25 | command: yarn run test:coverage 26 | - run: 27 | name: Build Tests Types 28 | command: yarn run test:types 29 | 30 | test-docs: 31 | docker: 32 | - image: circleci/node:10 33 | working_directory: ~/repo 34 | steps: 35 | - checkout 36 | - restore_cache: 37 | keys: 38 | - v1-dependencies-{{ checksum "yarn.lock" }} 39 | - run: yarn install 40 | - run: 41 | name: Build Docs 42 | command: yarn run docs 43 | 44 | deploy-docs: 45 | docker: 46 | - image: circleci/node:10 47 | working_directory: ~/repo 48 | steps: 49 | - checkout 50 | - restore_cache: 51 | keys: 52 | - v1-dependencies-{{ checksum "yarn.lock" }} 53 | - run: 54 | name: Build Docs 55 | command: yarn run docs 56 | - run: 57 | name: Deploy Docs to Zeit 58 | command: yarn run docs:deploy 59 | 60 | publish-job: 61 | docker: 62 | - image: circleci/node:10 63 | working_directory: ~/repo 64 | steps: 65 | - checkout 66 | - restore_cache: 67 | keys: 68 | - v1-dependencies-{{ checksum "yarn.lock" }} 69 | - run: yarn install 70 | - run: 71 | name: Build 72 | command: yarn run build 73 | - run: 74 | name: Publish Package 75 | command: cd dist && yarn run semantic-release 76 | 77 | workflows: 78 | version: 2 79 | test: 80 | jobs: 81 | - test-job 82 | - test-docs 83 | - publish-job: 84 | requires: 85 | - test-job 86 | - test-docs 87 | filters: 88 | branches: 89 | only: 90 | - next 91 | - master 92 | - beta 93 | - deploy-docs: 94 | requires: 95 | - publish-job 96 | filters: 97 | branches: 98 | only: 99 | - master 100 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true, 7 | "mocha": true 8 | }, 9 | "extends": "eslint:recommended", 10 | "parserOptions": { 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "indent": ["error", 4], 15 | "linebreak-style": [ 16 | "error", 17 | "unix" 18 | ], 19 | "quotes": [ 20 | "error", 21 | "single" 22 | ], 23 | "semi": [ 24 | "error", 25 | "always" 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.MD: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ## Description 6 | 7 | 8 | 9 | ## Related Issue 10 | 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | docs/typedoc 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 140 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "prettier.singleQuote": true, 4 | "prettier.tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at renaudin.yann@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Yann Renaudin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Morphism 2 | 3 | 6 | 7 | [![Financial Contributors on Open Collective](https://opencollective.com/morphism/all/badge.svg?label=financial+contributors)](https://opencollective.com/morphism) [![npm](https://img.shields.io/npm/v/morphism.svg?style=for-the-badge)][npm-url] 8 | [![npm bundle size (minified)](https://img.shields.io/bundlephobia/min/morphism.svg?style=for-the-badge)](https://github.com/nobrainr/morphism) 9 | [![npm](https://img.shields.io/npm/dy/morphism.svg?style=for-the-badge)][trends-url] 10 | [![CircleCI (all branches)](https://img.shields.io/circleci/project/github/nobrainr/morphism/master.svg?style=for-the-badge)][circleci-url] 11 | [![Deps](https://img.shields.io/david/nobrainr/morphism.svg?style=for-the-badge)][deps-url] 12 | 13 | > In many fields of mathematics, morphism refers to a structure-preserving map from one mathematical structure to another. A morphism **f** with source **X** and target **Y** is written **f : X → Y**. Thus a morphism is represented by an arrow from its **source** to its **target**. 14 | 15 | _https://en.wikipedia.org/wiki/Morphism_ 16 | 17 | - ⚛️ Write your schema once, Transform your data everywhere 18 | - 0️⃣ Zero dependencies 19 | - 💪🏽 Typescript Support 20 | 21 | --- 22 | 23 | - [Morphism](#Morphism) 24 | - [Getting started](#Getting-started) 25 | - [Installation](#Installation) 26 | - [Usage](#Usage) 27 | - [Example (TypeScript)](#Example-TypeScript) 28 | - [Motivation](#Motivation) 29 | - [TypeScript integration](#TypeScript-integration) 30 | - [Docs](#Docs) 31 | - [1. The Schema](#1-The-Schema) 32 | - [Schema actions](#Schema-actions) 33 | - [Schema Example](#Schema-Example) 34 | - [1.1 Using a strict Schema](#11-Using-a-strict-Schema) 35 | - [2. Morphism as Currying Function](#2-Morphism-as-Currying-Function) 36 | - [API](#API) 37 | - [Currying Function Example](#Currying-Function-Example) 38 | - [3. Morphism Function as Decorators](#3-Morphism-Function-as-Decorators) 39 | - [`toJsObject` Decorator](#toJsObject-Decorator) 40 | - [`toClassObject` Decorator](#toClassObject-Decorator) 41 | - [`morph` Decorator](#morph-Decorator) 42 | - [4. Default export: Morphism object](#4-Default-export-Morphism-object) 43 | - [More Schema examples](#More-Schema-examples) 44 | - [Flattening or Projection](#Flattening-or-Projection) 45 | - [Function over a source property's value](#Function-over-a-source-propertys-value) 46 | - [Function over a source property](#Function-over-a-source-property) 47 | - [Properties Aggregation](#Properties-Aggregation) 48 | - [Registry API](#Registry-API) 49 | - [Register](#Register) 50 | - [Map](#Map) 51 | - [Get or Set an existing mapper configuration](#Get-or-Set-an-existing-mapper-configuration) 52 | - [Delete a registered mapper](#Delete-a-registered-mapper) 53 | - [List registered mappers](#List-registered-mappers) 54 | - [Contribution](#Contribution) 55 | - [Similar Projects](#Similar-Projects) 56 | - [License](#License) 57 | 58 | ## Getting started 59 | 60 | ### Installation 61 | 62 | ```sh 63 | npm install --save morphism 64 | ``` 65 | 66 | or in the browser 67 | 68 | ```html 69 | 70 | 73 | ``` 74 | 75 | ### Usage 76 | 77 | The entry point of a **morphism** is the **schema**. The `keys` represent the shape of your **target** object, and the `values` represents one of the several ways to access the properties of the incoming source. 78 | 79 | ```typescript 80 | const schema = { 81 | targetProperty: 'sourceProperty' 82 | }; 83 | ``` 84 | 85 | Then use the `morphism` function along with the **schema** to transform any **source** to your desired **target** 86 | 87 | ```typescript 88 | import { morphism } from 'morphism'; 89 | 90 | const source = { 91 | _firstName: 'Mirza' 92 | }; 93 | 94 | const schema = { 95 | name: '_firstName' 96 | }; 97 | 98 | morphism(schema, source); 99 | ➡ 100 | { 101 | "name": "Mirza" 102 | } 103 | ``` 104 | 105 | You may specify properties deep within the source object to be copied to your desired target by using dot notation in the mapping `value`. 106 | This is [one of the actions available](#schema-actions) to transform the source data 107 | 108 | ```typescript 109 | const schema = { 110 | foo: 'deep.foo', 111 | bar: { 112 | baz: 'deep.foo' 113 | } 114 | }; 115 | 116 | const source = { 117 | deep: { 118 | foo: 'value' 119 | } 120 | }; 121 | 122 | morphism(schema, source); 123 | ➡ 124 | { 125 | "foo": "value", 126 | "bar": { 127 | "baz": "value" 128 | } 129 | } 130 | ``` 131 | 132 | One important rule of `Morphism` is that **it will always return a result respecting the dimension of the source data.** If the source data is an `array`, morphism will outputs an `array`, if the source data is an `object` you'll have an `object` 133 | 134 | ```typescript 135 | const schema = { 136 | foo: 'bar' 137 | }; 138 | 139 | // The source is a single object 140 | const object = { 141 | bar: 'value' 142 | }; 143 | 144 | morphism(schema, object); 145 | ➡ 146 | { 147 | "foo": "value" 148 | } 149 | 150 | // The source is a collection of objects 151 | const multipleObjects = [{ 152 | bar: 'value' 153 | }]; 154 | 155 | morphism(schema, multipleObjects); 156 | ➡ 157 | [{ 158 | "foo": "value" 159 | }] 160 | ``` 161 | 162 | ### Example (TypeScript) 163 | 164 | ```typescript 165 | import { morphism, StrictSchema } from 'morphism'; 166 | 167 | // What we have 168 | interface Source { 169 | ugly_field: string; 170 | } 171 | 172 | // What we want 173 | interface Destination { 174 | field: string; 175 | } 176 | 177 | const source: Source = { 178 | ugly_field: 'field value' 179 | }; 180 | 181 | // Destination and Source types are optional 182 | morphism>({ field: 'ugly_field' }, source); 183 | // => {field: "field value"} 184 | 185 | // Or 186 | const sources = [source]; 187 | const schema: StrictSchema = { field: 'ugly_field' }; 188 | morphism(schema, sources); 189 | // => [{field: "field value"}] 190 | ``` 191 | 192 | ▶️ [Test with Repl.it](https://repl.it/@yrnd1/Morphism-Full-Example) 193 | 194 | ## Motivation 195 | 196 | We live in a era where we deal with mutiple data contracts coming from several sources (Rest API, Services, Raw JSON...). When it comes to transform multiple data contracts to match with your domain objects, it's common to create your objects with `Object.assign`, `new Object(sourceProperty1, sourceProperty2)` or by simply assigning each source properties to your destination. This can result in your business logic being spread all over the place. 197 | 198 | `Morphism` allows you to keep this business logic centralized and brings you a top-down view of your data transformation. When a contract change occurs, it helps to track the bug since you just need to refer to your `schema` 199 | 200 | ## TypeScript integration 201 | 202 | When you type your schema, this library will require you to specify each transformation for your required fields. 203 | 204 | ![schema](https://raw.githubusercontent.com/nobrainr/morphism/master/images/schema.png) 205 | 206 | ![schema-required-fields](https://raw.githubusercontent.com/nobrainr/morphism/master/images/schema-required-fields.png) 207 | 208 | This library uses TypeScript extensively. The target type will be inferred from the defined schema. 209 | 210 | ![inferred field type](https://raw.githubusercontent.com/nobrainr/morphism/master/images/inferred-field-type.png) 211 | 212 | When using an [`ActionFunction`](https://morphism.now.sh/modules/morphism#actionfunction) the input type is also inferred to enforce your transformations 213 | 214 | ![typed action function](https://raw.githubusercontent.com/nobrainr/morphism/master/images/ts-action-function.png) 215 | 216 | See below the different options you have for the schema. 217 | 218 | ## Docs 219 | 220 | 📚 **[API documentation](https://morphism.now.sh)** 221 | 222 | **`Morphism` comes with 3 artifacts to achieve your transformations:** 223 | 224 | ### 1. The Schema 225 | 226 | A schema is an object-preserving map from one data structure to another. 227 | 228 | The keys of the schema match the desired destination structure. Each value corresponds to an Action applied by Morphism when iterating over the input data. 229 | 230 | #### Schema actions 231 | 232 | You can use **4 kind of values** for the keys of your schema: 233 | 234 | - [`ActionString`](https://morphism.now.sh/modules/_types_#actionstring): A string that allows to perform a projection from a property 235 | - [`ActionSelector`](https://morphism.now.sh/interfaces/_types_.actionselector): An Object that allows to perform a function over a source property's value 236 | - [`ActionFunction`](https://morphism.now.sh/interfaces/_types_.actionfunction): A Function that allows to perform a function over source property 237 | - [`ActionAggregator`](https://morphism.now.sh/modules/_types_#actionaggregator): An Array of Strings that allows to perform a function over source property 238 | 239 | #### Schema Example 240 | 241 | ```ts 242 | import { morphism } from 'morphism'; 243 | 244 | const input = { 245 | foo: { 246 | baz: 'value1' 247 | } 248 | }; 249 | 250 | const schema = { 251 | bar: 'foo', // ActionString: Allows to perform a projection from a property 252 | qux: ['foo', 'foo.baz'], // ActionAggregator: Allows to aggregate multiple properties 253 | quux: (iteratee, source, destination) => { 254 | // ActionFunction: Allows to perform a function over source property 255 | return iteratee.foo; 256 | }, 257 | corge: { 258 | // ActionSelector: Allows to perform a function over a source property's value 259 | path: 'foo.baz', 260 | fn: (propertyValue, source) => { 261 | return propertyValue; 262 | } 263 | } 264 | }; 265 | 266 | morphism(schema, input); 267 | // { 268 | // "bar": { 269 | // "baz": "value1" 270 | // }, 271 | // "qux": { 272 | // "foo": { 273 | // "baz": "value1" 274 | // } 275 | // }, 276 | // "quux": { 277 | // "baz": "value1" 278 | // }, 279 | // "corge": "value1" 280 | // } 281 | ``` 282 | 283 | ▶️ [Test with Repl.it](https://repl.it/@yrnd1/Morphism-Schema-Options) 284 | 285 | ⏩ [More Schema examples](#more-schema-examples) 286 | 287 | 📚 [Schema Docs](https://morphism.now.sh/classes/_morphismtree_.morphismschematree) 288 | 289 | #### 1.1 Using a strict Schema 290 | 291 | You might want to enforce the keys provided in your schema using `Typescript`. This is possible using a `StrictSchema`. Doing so will require to map every field of the `Target` type provided. 292 | 293 | ```ts 294 | interface IFoo { 295 | foo: string; 296 | bar: number; 297 | } 298 | const schema: StrictSchema = { foo: 'qux', bar: () => 'test' }; 299 | const source = { qux: 'foo' }; 300 | const target = morphism(schema, source); 301 | // { 302 | // "foo": "qux", 303 | // "bar": "test" 304 | // } 305 | ``` 306 | 307 | ### 2. Morphism as Currying Function 308 | 309 | The simplest way to use morphism is to import the currying function: 310 | 311 | ```ts 312 | import { morphism } from 'morphism'; 313 | ``` 314 | 315 | `morphism` either outputs a mapping function or the transformed data depending on the usage: 316 | 317 | #### API 318 | 319 | ```ts 320 | morphism(schema: Schema, items?: any, type?: any): any 321 | ``` 322 | 323 | 324 | 📚 [Currying Function Docs](https://morphism.now.sh/modules/morphism#morphism-1) 325 | 326 | #### Currying Function Example 327 | 328 | ```ts 329 | // Outputs a function when only a schema is provided 330 | const fn = morphism(schema); 331 | const result = fn(data); 332 | 333 | // Outputs the transformed data when a schema and the source data are provided 334 | const result = morphism(schema, data); 335 | 336 | // Outputs the transformed data as an ES6 Class Object when a schema, the source data and an ES6 Class are provided 337 | const result = morphism(schema, data, Foo); 338 | // => Items in result are instance of Foo 339 | ``` 340 | 341 | ### 3. Morphism Function as Decorators 342 | 343 | You can also use Function Decorators on your method or functions to transform the return value using `Morphism`: 344 | 345 | #### `toJsObject` Decorator 346 | 347 | ```ts 348 | import { toJSObject } from 'morphism'; 349 | 350 | class Service { 351 | @toJSObject({ 352 | foo: currentItem => currentItem.foo, 353 | baz: 'bar.baz' 354 | }) 355 | async fetch() { 356 | const response = await fetch('https://api.com'); 357 | return response.json(); 358 | // => 359 | // { 360 | // foo: 'fooValue' 361 | // bar: { 362 | // baz: 'bazValue' 363 | // } 364 | // }; 365 | } 366 | } 367 | 368 | // await service.fetch() will return 369 | // => 370 | // { 371 | // foo: 'fooValue', 372 | // baz: 'bazValue' 373 | // } 374 | 375 | -------------------------------- 376 | 377 | // Using Typescript will enforce the key from the target to be required 378 | class Target { 379 | a: string = null; 380 | b: string = null; 381 | } 382 | class Service { 383 | // By Using , Mapping for Properties `a` and `b` will be required 384 | @toJSObject({ 385 | a: currentItem => currentItem.foo, 386 | b: 'bar.baz' 387 | }) 388 | fetch(); 389 | } 390 | ``` 391 | 392 | #### `toClassObject` Decorator 393 | 394 | ```ts 395 | import { toClassObject } from 'morphism'; 396 | 397 | class Target { 398 | foo = null; 399 | bar = null; 400 | } 401 | const schema = { 402 | foo: currentItem => currentItem.foo, 403 | baz: 'bar.baz' 404 | }; 405 | class Service { 406 | @toClassObject(schema, Target) 407 | async fetch() { 408 | const response = await fetch('https://api.com'); 409 | return response.json(); 410 | // => 411 | // { 412 | // foo: 'fooValue' 413 | // bar: { 414 | // baz: 'bazValue' 415 | // } 416 | // }; 417 | } 418 | } 419 | 420 | // await service.fetch() will be instanceof Target 421 | // => 422 | // Target { 423 | // foo: 'fooValue', 424 | // baz: 'bazValue' 425 | // } 426 | ``` 427 | 428 | #### `morph` Decorator 429 | 430 | Utility decorator wrapping `toClassObject` and `toJSObject` decorators 431 | 432 | ```ts 433 | import { toClassObject } from 'morphism'; 434 | 435 | class Target { 436 | foo = null; 437 | bar = null; 438 | } 439 | const schema = { 440 | foo: currentItem => currentItem.foo, 441 | baz: 'bar.baz' 442 | }; 443 | class Service { 444 | @morph(schema) 445 | async fetch() { 446 | const response = await fetch('https://api.com'); 447 | return response.json(); 448 | // => 449 | // { 450 | // foo: 'fooValue' 451 | // bar: { 452 | // baz: 'bazValue' 453 | // } 454 | // }; 455 | } 456 | @morph(schema, Target) 457 | async fetch2() { 458 | const response = await fetch('https://api.com'); 459 | return response.json(); 460 | } 461 | } 462 | // await service.fetch() will be 463 | // => 464 | // { 465 | // foo: 'fooValue', 466 | // baz: 'bazValue' 467 | // } 468 | 469 | // await service.fetch() will be instanceof Target 470 | // => 471 | // Target { 472 | // foo: 'fooValue', 473 | // baz: 'bazValue' 474 | // } 475 | ``` 476 | 477 | 📚 [Morphism Function as Decorators Docs](https://morphism.now.sh/modules/_morphism_) 478 | 479 | ### 4. Default export: Morphism object 480 | 481 | Morphism comes along with an internal registry you can use to save your schema attached to a specific **ES6 Class**. 482 | 483 | In order to use the registry, you might want to use the default export: 484 | 485 | ```ts 486 | import Morphism from 'morphism'; 487 | ``` 488 | 489 | All features available with the currying function are also available when using the plain object plus the internal registry: 490 | 491 | ```typescript 492 | // Currying Function 493 | Morphism(schema: Schema, items?: any, type?: any): any 494 | 495 | // Registry API 496 | Morphism.register(type: any, schema?: Schema); 497 | Morphism.map(type: any, data?: any); 498 | Morphism.setMapper(type: any, schema: Schema); 499 | Morphism.getMapper(type); 500 | Morphism.deleteMapper(type); 501 | Morphism.mappers 502 | ``` 503 | 504 | 🔗 [Registry API Documentation](#registry-api) 505 | 506 | ## More Schema examples 507 | 508 | ### Flattening or Projection 509 | 510 | ```ts 511 | import { morphism } from 'morphism'; 512 | // Source data coming from an API. 513 | const source = { 514 | foo: 'baz', 515 | bar: ['bar', 'foo'], 516 | baz: { 517 | qux: 'bazqux' 518 | } 519 | }; 520 | const schema = { 521 | foo: 'foo', // Simple Projection 522 | bazqux: 'baz.qux' // Grab a value from a deep path 523 | }; 524 | 525 | morphism(schema, source); 526 | //=> { foo: 'baz', bazqux: 'bazqux' } 527 | ``` 528 | 529 | ▶️ [Test with Repl.it](https://repl.it/@yrnd1/Morphism-Flattening-Projection) 530 | 531 | ### Function over a source property's value 532 | 533 | ```ts 534 | import { morphism } from 'morphism'; 535 | // Source data coming from an API. 536 | const source = { 537 | foo: { 538 | bar: 'bar' 539 | } 540 | }; 541 | let schema = { 542 | barqux: { 543 | path: 'foo.bar', 544 | fn: value => `${value}qux` // Apply a function over the source property's value 545 | } 546 | }; 547 | 548 | morphism(schema, source); 549 | //=> { barqux: 'barqux' } 550 | ``` 551 | 552 | ▶️ [Test with Repl.it](https://repl.it/@yrnd1/Morphism-Function-over-a-source-propertys-value) 553 | 554 | ### Function over a source property 555 | 556 | ```ts 557 | import { morphism } from 'morphism'; 558 | // Source data coming from an API. 559 | const source = { 560 | foo: { 561 | bar: 'bar' 562 | } 563 | }; 564 | let schema = { 565 | bar: iteratee => { 566 | // Apply a function over the source propery 567 | return iteratee.foo.bar; 568 | } 569 | }; 570 | 571 | morphism(schema, source); 572 | //=> { bar: 'bar' } 573 | ``` 574 | 575 | ▶️ [Test with Repl.it](https://repl.it/@yrnd1/Function-over-a-source-property) 576 | 577 | ### Properties Aggregation 578 | 579 | ```ts 580 | import { morphism } from 'morphism'; 581 | // Source data coming from an API. 582 | const source = { 583 | foo: 'foo', 584 | bar: 'bar' 585 | }; 586 | let schema = { 587 | fooAndBar: ['foo', 'bar'] // Grab these properties into fooAndBar 588 | }; 589 | 590 | morphism(schema, source); 591 | //=> { fooAndBar: { foo: 'foo', bar: 'bar' } } 592 | ``` 593 | 594 | ▶️ [Test with Repl.it](https://repl.it/@yrnd1/Morphism-Properties-Aggregation) 595 | 596 | ## Registry API 597 | 598 | 📚 [Registry API Documentation](https://morphism.now.sh/classes/_morphismregistry_.morphismregistry) 599 | 600 | #### Register 601 | 602 | Register a mapper for a specific type. The schema is optional. 603 | 604 | ```js 605 | Morphism.register(type: any, schema?: Schema); 606 | ``` 607 | 608 | #### Map 609 | 610 | Map a collection of objects to the specified type 611 | 612 | ```ts 613 | Morphism.map(type: any, data?: any); 614 | ``` 615 | 616 | #### Get or Set an existing mapper configuration 617 | 618 | ```ts 619 | Morphism.setMapper(type: any, schema: Schema); 620 | Morphism.getMapper(type); 621 | ``` 622 | 623 | #### Delete a registered mapper 624 | 625 | ```js 626 | Morphism.deleteMapper(type); 627 | ``` 628 | 629 | #### List registered mappers 630 | 631 | ```js 632 | Morphism.mappers; 633 | ``` 634 | 635 | ## Contribution 636 | 637 | - Twitter: [@renaudin_yann][twitter-account] 638 | - Pull requests and stars are always welcome 🙏🏽 For bugs and feature requests, [please create an issue](https://github.com/emyann/morphism/issues) 639 | 640 | ## Similar Projects 641 | 642 | - [`io-ts`](https://github.com/gcanti/io-ts) 643 | - [`joi`](https://github.com/hapijs/joi/) 644 | - [`object-mapper`](https://www.npmjs.com/package/object-mapper) 645 | - [`autoMapper-ts`](https://www.npmjs.com/package/automapper-ts) 646 | - [`C# AutoMapper`](https://github.com/AutoMapper/AutoMapper) 647 | - [`node-data-transform`](https://github.com/bozzltron/node-json-transform) 648 | 649 | ## Contributors 650 | 651 | ### Code Contributors 652 | 653 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. 654 | 655 | 656 | ### Financial Contributors 657 | 658 | Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/morphism/contribute)] 659 | 660 | #### Individuals 661 | 662 | 663 | 664 | #### Organizations 665 | 666 | Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/morphism/contribute)] 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | ## License 680 | 681 | MIT © [Yann Renaudin][twitter-account] 682 | 683 | [twitter-account]: https://twitter.com/YannRenaudin 684 | [npm-image]: https://badge.fury.io/js/morphism.svg?style=flat-square 685 | [npm-url]: https://npmjs.org/package/morphism 686 | [deps-url]: https://www.npmjs.com/package/morphism?activeTab=dependencies 687 | [circleci-url]: https://circleci.com/gh/nobrainr/morphism 688 | [trends-url]: https://www.npmtrends.com/morphism 689 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /docs/images/mophism-function-full-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nobrainr/morphism/981c30c8f88d377d1c6c36e1440bee9437cc606e/docs/images/mophism-function-full-example.png -------------------------------------------------------------------------------- /docs/images/morphism.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nobrainr/morphism/981c30c8f88d377d1c6c36e1440bee9437cc606e/docs/images/morphism.png -------------------------------------------------------------------------------- /docs/images/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nobrainr/morphism/981c30c8f88d377d1c6c36e1440bee9437cc606e/docs/images/schema.png -------------------------------------------------------------------------------- /images/inferred-field-type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nobrainr/morphism/981c30c8f88d377d1c6c36e1440bee9437cc606e/images/inferred-field-type.png -------------------------------------------------------------------------------- /images/schema-required-fields.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nobrainr/morphism/981c30c8f88d377d1c6c36e1440bee9437cc606e/images/schema-required-fields.png -------------------------------------------------------------------------------- /images/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nobrainr/morphism/981c30c8f88d377d1c6c36e1440bee9437cc606e/images/schema.png -------------------------------------------------------------------------------- /images/ts-action-function.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nobrainr/morphism/981c30c8f88d377d1c6c36e1440bee9437cc606e/images/ts-action-function.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'], 3 | coverageReporters: ['json', 'lcov', 'text'], 4 | verbose: true, 5 | coverageThreshold: { 6 | global: { 7 | statements: 96, 8 | branches: 90, 9 | functions: 100, 10 | lines: 96 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "morphism", 3 | "alias": "morphism", 4 | "type": "static" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "morphism", 3 | "version": "1.0.0", 4 | "description": "Type-safe object transformer for JavaScript, TypeScript & Node.js. ", 5 | "homepage": "https://github.com/nobrainr/morphism", 6 | "main": "./dist/morphism.js", 7 | "types": "./dist/types/morphism.d.ts", 8 | "scripts": { 9 | "build": "run-p build:js build:types", 10 | "start": "run-p watch:*", 11 | "build:js": "TS_NODE_PROJECT=\"tsconfig.webpack.json\" webpack --mode=production", 12 | "watch:js": "yarn run build:js -w --display \"errors-only\"", 13 | "build:types": "tsc -p tsconfig.prod.json --emitDeclarationOnly", 14 | "watch:types": "yarn run build:types -w >/dev/null", 15 | "test": "jest", 16 | "test:types": "tsc --emitDeclarationOnly", 17 | "test:coverage": "yarn run test --coverage", 18 | "semantic-release": "semantic-release", 19 | "docs": "typedoc", 20 | "docs:deploy": "now ./docs/typedoc -A ../../now.json --public --team $TEAM_NAME --token $NOW_TOKEN && now alias --team $TEAM_NAME --token $NOW_TOKEN", 21 | "analyze-bundle:dev": "WEBPACK_ANALYZE=true TS_NODE_PROJECT=\"tsconfig.webpack.json\" webpack", 22 | "analyze-bundle:prod": "WEBPACK_ANALYZE=true TS_NODE_PROJECT=\"tsconfig.webpack.json\" webpack --mode=production" 23 | }, 24 | "release": { 25 | "branches": [ 26 | "master", 27 | "next", 28 | { 29 | "name": "beta", 30 | "prerelease": true 31 | } 32 | ] 33 | }, 34 | "author": { 35 | "name": "Yann Renaudin", 36 | "email": "renaudin.yann@gmail.com", 37 | "url": "https://twitter.com/renaudin_yann" 38 | }, 39 | "files": [ 40 | "dist" 41 | ], 42 | "keywords": [ 43 | "data", 44 | "functional", 45 | "parser", 46 | "typescript", 47 | "object", 48 | "array", 49 | "flow", 50 | "mapper", 51 | "automapper", 52 | "morphism", 53 | "fp", 54 | "js", 55 | "javascript" 56 | ], 57 | "dependencies": {}, 58 | "devDependencies": { 59 | "@babel/core": "7.8.7", 60 | "@babel/plugin-proposal-class-properties": "^7.8.3", 61 | "@babel/plugin-proposal-decorators": "^7.8.3", 62 | "@babel/plugin-proposal-object-rest-spread": "^7.8.3", 63 | "@babel/preset-env": "7.8.7", 64 | "@babel/preset-typescript": "^7.8.3", 65 | "@hapi/joi": "^16.1.7", 66 | "@types/hapi__joi": "^16.0.1", 67 | "@types/jest": "25.1.4", 68 | "@types/node": "^13.1.0", 69 | "@types/validator": "^10.11.3", 70 | "@types/webpack": "^4.4.27", 71 | "@types/webpack-bundle-analyzer": "^2.13.3", 72 | "babel-loader": "^8.0.5", 73 | "fork-ts-checker-webpack-plugin": "^4.0.1", 74 | "jest": "24.7.1", 75 | "lint-staged": "^10.0.3", 76 | "nodemon": "^2.0.0", 77 | "nodemon-webpack-plugin": "^4.2.2", 78 | "now": "^17.0.0", 79 | "npm-run-all": "^4.1.5", 80 | "prettier": "1.19.1", 81 | "semantic-release": "^17.0.0", 82 | "source-map-loader": "^0.2.4", 83 | "ts-node": "^8.0.3", 84 | "tslint": "^5.15.0", 85 | "tslint-loader": "^3.6.0", 86 | "typedoc": "^0.16.0", 87 | "typedoc-plugin-external-module-name": "^3.0.0", 88 | "typedoc-plugin-internal-external": "^2.0.1", 89 | "typescript": "^3.7.2", 90 | "validator": "^11.1.0", 91 | "webpack": "4.42.0", 92 | "webpack-bundle-analyzer": "^3.5.2", 93 | "webpack-cli": "^3.3.0" 94 | }, 95 | "eslintConfig": { 96 | "extends": "xo-space", 97 | "env": { 98 | "mocha": true 99 | } 100 | }, 101 | "repository": "nobrainr/morphism", 102 | "license": "MIT", 103 | "husky": { 104 | "hooks": { 105 | "pre-commit": "lint-staged" 106 | } 107 | }, 108 | "lint-staged": { 109 | "*.{ts,js,css,json,md}": [ 110 | "prettier --write", 111 | "git add" 112 | ] 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/MorphismDecorator.ts: -------------------------------------------------------------------------------- 1 | import { Mapper } from './types'; 2 | import { isPromise } from './helpers'; 3 | 4 | export function decorator(mapper: Mapper) { 5 | return (_target: any, _name: string, descriptor: PropertyDescriptor) => { 6 | const fn = descriptor.value; 7 | if (typeof fn === 'function') { 8 | descriptor.value = function(...args: any[]) { 9 | const output = fn.apply(this, args); 10 | if (isPromise(output)) { 11 | return Promise.resolve(output).then(res => mapper(res)); 12 | } 13 | return mapper(output); 14 | }; 15 | } 16 | 17 | return descriptor; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/MorphismRegistry.spec.ts: -------------------------------------------------------------------------------- 1 | import Morphism from './morphism'; 2 | import { User, MockData } from './utils-test'; 3 | 4 | describe('Mappers Registry', function() { 5 | const dataToCrunch: MockData[] = [ 6 | { 7 | firstName: 'John', 8 | lastName: 'Smith', 9 | age: 25, 10 | address: { 11 | streetAddress: '21 2nd Street', 12 | city: 'New York', 13 | state: 'NY', 14 | postalCode: '10021', 15 | }, 16 | phoneNumber: [ 17 | { 18 | type: 'home', 19 | number: '212 555-1234', 20 | }, 21 | { 22 | type: 'fax', 23 | number: '646 555-4567', 24 | }, 25 | ], 26 | }, 27 | ]; 28 | beforeEach(() => { 29 | Morphism.deleteMapper(User); 30 | Morphism.register(User); 31 | }); 32 | it('should throw an exception when using Registration function without parameters', function() { 33 | expect(() => Morphism.register(null as any, null)).toThrow(); 34 | }); 35 | 36 | it('should throw an exception when trying to register a mapper type more than once', function() { 37 | expect(() => { 38 | Morphism.register(User, {}); 39 | }).toThrow(); 40 | }); 41 | 42 | it('should return the stored mapper after a registration', function() { 43 | let schema = { 44 | phoneNumber: 'phoneNumber[0].number', 45 | }; 46 | let mapper = Morphism.setMapper(User, schema); 47 | let mapperSaved = Morphism.getMapper(User); 48 | expect(typeof mapper).toEqual('function'); 49 | expect(typeof mapperSaved).toEqual('function'); 50 | expect(mapperSaved).toEqual(mapper); 51 | }); 52 | 53 | it('should get a stored mapper after a registration', function() { 54 | Morphism.setMapper(User, {}); 55 | expect(typeof Morphism.getMapper(User)).toEqual('function'); 56 | }); 57 | 58 | it('should allow to map data using a registered mapper', function() { 59 | let schema = { 60 | phoneNumber: 'phoneNumber[0].number', 61 | }; 62 | Morphism.setMapper(User, schema); 63 | let desiredResult = new User('John', 'Smith', '212 555-1234'); 64 | expect(Morphism.map(User, dataToCrunch)).toBeTruthy(); 65 | expect(Morphism.map(User, dataToCrunch)[0]).toEqual(desiredResult); 66 | }); 67 | 68 | it('should allow to map data using a mapper updated schema', function() { 69 | let schema = { 70 | phoneNumber: 'phoneNumber[0].number', 71 | }; 72 | let mapper = Morphism.setMapper(User, schema); 73 | let desiredResult = new User('John', 'Smith', '212 555-1234'); 74 | expect(mapper(dataToCrunch)[0]).toEqual(desiredResult); 75 | }); 76 | 77 | it('should throw an exception when trying to set an non-registered type', function() { 78 | Morphism.deleteMapper(User); 79 | expect(() => { 80 | Morphism.setMapper(User, {}); 81 | }).toThrow(); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/MorphismRegistry.ts: -------------------------------------------------------------------------------- 1 | import { Constructable, Schema, Mapper } from './types'; 2 | import { morphism } from './morphism'; 3 | 4 | export interface IMorphismRegistry { 5 | /** 6 | * Register a mapping schema for a Class. 7 | * 8 | * @param type Class Type to be registered 9 | * @param schema Structure-preserving object from a source data towards a target data. 10 | * 11 | */ 12 | register(type: Constructable): Mapper, Target>; 13 | register(type: Constructable, schema?: TSchema): Mapper; 14 | 15 | /** 16 | * Transform any input in the specified Class 17 | * 18 | * @param {Type} type Class Type of the ouput Data 19 | * @param {Object} data Input data to transform 20 | * 21 | */ 22 | map(type: Constructable): Mapper; 23 | map(type: Constructable, data: Source[]): Target[]; 24 | map(type: Constructable, data: Source): Target; 25 | /** 26 | * Get a specific mapping function for the provided Class 27 | * 28 | * @param {Type} type Class Type of the ouput Data 29 | * 30 | */ 31 | getMapper(type: Constructable): Mapper; 32 | /** 33 | * Set a schema for a specific Class Type 34 | * 35 | * @param {Type} type Class Type of the ouput Data 36 | * @param {Schema} schema Class Type of the ouput Data 37 | * 38 | */ 39 | setMapper>(type: Constructable, schema: TSchema): Mapper; 40 | /** 41 | * Delete a registered schema associated to a Class 42 | * 43 | * @param type ES6 Class Type of the ouput Data 44 | * 45 | */ 46 | deleteMapper(type: Constructable): any; 47 | /** 48 | * Check if a schema has already been registered for this type 49 | * 50 | * @param {*} type 51 | */ 52 | exists(type: Target): boolean; 53 | /** 54 | * Get the list of the mapping functions registered 55 | * 56 | * @param {Type} type Class Type of the ouput Data 57 | * 58 | */ 59 | mappers: Map; 60 | } 61 | 62 | export class MorphismRegistry implements IMorphismRegistry { 63 | private _registry: any = null; 64 | /** 65 | *Creates an instance of MorphismRegistry. 66 | * @param {Map} cache Cache implementation to store the mapping functions. 67 | */ 68 | constructor() { 69 | this._registry = { cache: new Map() }; 70 | } 71 | 72 | /** 73 | * Register a mapping schema for a Class. 74 | * 75 | * @param type Class Type to be registered 76 | * @param schema Structure-preserving object from a source data towards a target data. 77 | * 78 | */ 79 | register(type: Constructable, schema?: TSchema) { 80 | if (!type && !schema) { 81 | throw new Error('type paramater is required when you register a mapping'); 82 | } else if (this.exists(type)) { 83 | throw new Error(`A mapper for ${type.name} has already been registered`); 84 | } 85 | let mapper; 86 | if (schema) { 87 | mapper = morphism(schema, null, type); 88 | } else { 89 | mapper = morphism({}, null, type); 90 | } 91 | this._registry.cache.set(type, mapper); 92 | return mapper; 93 | } 94 | /** 95 | * Transform any input in the specified Class 96 | * 97 | * @param {Type} type Class Type of the ouput Data 98 | * @param {Object} data Input data to transform 99 | * 100 | */ 101 | map(type: any, data?: any) { 102 | if (!this.exists(type)) { 103 | const mapper = this.register(type); 104 | if (data === undefined) { 105 | return mapper; 106 | } 107 | } 108 | return this.getMapper(type)(data); 109 | } 110 | /** 111 | * Get a specific mapping function for the provided Class 112 | * 113 | * @param {Type} type Class Type of the ouput Data 114 | * 115 | */ 116 | getMapper(type: Constructable) { 117 | return this._registry.cache.get(type); 118 | } 119 | /** 120 | * Set a schema for a specific Class Type 121 | * 122 | * @param {Type} type Class Type of the ouput Data 123 | * @param {Schema} schema Class Type of the ouput Data 124 | * 125 | */ 126 | setMapper(type: Constructable, schema: Schema) { 127 | if (!schema) { 128 | throw new Error(`The schema must be an Object. Found ${schema}`); 129 | } else if (!this.exists(type)) { 130 | throw new Error(`The type ${type.name} is not registered. Register it using \`Mophism.register(${type.name}, schema)\``); 131 | } else { 132 | let fn = morphism(schema, null, type); 133 | this._registry.cache.set(type, fn); 134 | return fn; 135 | } 136 | } 137 | 138 | /** 139 | * Delete a registered schema associated to a Class 140 | * 141 | * @param type ES6 Class Type of the ouput Data 142 | * 143 | */ 144 | deleteMapper(type: any) { 145 | return this._registry.cache.delete(type); 146 | } 147 | 148 | /** 149 | * Check if a schema has already been registered for this type 150 | * 151 | * @param {*} type 152 | */ 153 | exists(type: any) { 154 | return this._registry.cache.has(type); 155 | } 156 | /** 157 | * Get the list of the mapping functions registered 158 | * 159 | * @param {Type} type Class Type of the ouput Data 160 | * 161 | */ 162 | get mappers() { 163 | return this._registry.cache as Map; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/MorphismTree.spec.ts: -------------------------------------------------------------------------------- 1 | import { MorphismSchemaTree, SchemaNode, NodeKind } from './MorphismTree'; 2 | import Morphism, { StrictSchema } from './morphism'; 3 | 4 | describe('Tree', () => { 5 | describe('Add', () => { 6 | it('should add a node under the root', () => { 7 | interface Target { 8 | keyA: string; 9 | } 10 | const tree = new MorphismSchemaTree({}); 11 | tree.add({ action: 'keyA', propertyName: 'keyA' }); 12 | 13 | const root: SchemaNode = { 14 | data: { targetPropertyPath: '', propertyName: 'MorphismTreeRoot', action: null, kind: NodeKind.Root }, 15 | parent: null, 16 | children: [], 17 | }; 18 | const result: SchemaNode = { 19 | data: { targetPropertyPath: 'keyA', propertyName: 'keyA', action: 'keyA', kind: NodeKind.ActionString }, 20 | parent: root, 21 | children: [], 22 | }; 23 | 24 | expect(tree.root.data).toEqual(root.data); 25 | expect(tree.root.parent).toEqual(root.parent); 26 | 27 | expect(tree.root.children[0].data.action).toEqual(result.data.action); 28 | expect(tree.root.children[0].data.targetPropertyPath).toEqual(result.data.targetPropertyPath); 29 | expect(tree.root.children[0].data.propertyName).toEqual(result.data.propertyName); 30 | expect(tree.root.children[0].data.kind).toEqual(result.data.kind); 31 | 32 | expect(tree.root.children[0].parent!.data.propertyName).toEqual(root.data.propertyName); 33 | }); 34 | 35 | it('should add a node under another node', () => { 36 | interface Target { 37 | keyA: string; 38 | } 39 | const tree = new MorphismSchemaTree({}); 40 | const parentTargetPropertyPath = 'keyA'; 41 | tree.add({ action: {}, propertyName: 'keyA', targetPropertyPath: parentTargetPropertyPath }); 42 | tree.add({ action: 'keyA', propertyName: 'keyA1' }, parentTargetPropertyPath); 43 | 44 | const nodeKeyA: SchemaNode = { 45 | data: { targetPropertyPath: 'keyA', propertyName: 'keyA', action: {}, kind: NodeKind.Property }, 46 | parent: null, 47 | children: [], 48 | }; 49 | 50 | const nodeKeyA1: SchemaNode = { 51 | data: { targetPropertyPath: 'keyA.keyA1', propertyName: 'keyA1', action: 'keyA', kind: NodeKind.ActionString }, 52 | parent: null, 53 | children: [], 54 | }; 55 | 56 | // KeyA 57 | expect(tree.root.children[0].data.action).toEqual(nodeKeyA.data.action); 58 | expect(tree.root.children[0].data.targetPropertyPath).toEqual(nodeKeyA.data.targetPropertyPath); 59 | expect(tree.root.children[0].data.propertyName).toEqual(nodeKeyA.data.propertyName); 60 | expect(tree.root.children[0].data.kind).toEqual(nodeKeyA.data.kind); 61 | 62 | // KeyA1 63 | const keyA1 = tree.root.children[0].children[0]; 64 | expect(keyA1.data.action).toEqual(nodeKeyA1.data.action); 65 | expect(keyA1.data.targetPropertyPath).toEqual(nodeKeyA1.data.targetPropertyPath); 66 | expect(keyA1.data.propertyName).toEqual(nodeKeyA1.data.propertyName); 67 | expect(keyA1.data.kind).toEqual(nodeKeyA1.data.kind); 68 | }); 69 | }); 70 | 71 | describe('Parser', () => { 72 | it('should parse a simple morphism schema to a MorphismTree', () => { 73 | interface Target { 74 | keyA: string; 75 | } 76 | const schema: StrictSchema = { keyA: 'test' }; 77 | const tree = new MorphismSchemaTree(schema); 78 | 79 | const expected = { 80 | propertyName: 'keyA', 81 | targetPropertyPath: 'keyA', 82 | kind: NodeKind.ActionString, 83 | action: 'test', 84 | }; 85 | 86 | let result; 87 | for (const node of tree.traverseBFS()) { 88 | const { 89 | data: { propertyName, targetPropertyPath, kind, action }, 90 | } = node; 91 | result = { propertyName, targetPropertyPath, kind, action }; 92 | } 93 | 94 | expect(result).toEqual(expected); 95 | }); 96 | 97 | it('should parse a complex morphism schema to a MorphismTree', () => { 98 | interface Target { 99 | keyA: { 100 | keyA1: string; 101 | }; 102 | keyB: { 103 | keyB1: { 104 | keyB11: string; 105 | }; 106 | }; 107 | } 108 | const mockAction = 'action-string'; 109 | const schema: StrictSchema = { 110 | keyA: { keyA1: mockAction }, 111 | keyB: { keyB1: { keyB11: mockAction } }, 112 | }; 113 | const tree = new MorphismSchemaTree(schema); 114 | 115 | const expected = [ 116 | { 117 | propertyName: 'keyA', 118 | targetPropertyPath: 'keyA', 119 | kind: NodeKind.Property, 120 | action: { keyA1: mockAction }, 121 | }, 122 | { 123 | propertyName: 'keyB', 124 | targetPropertyPath: 'keyB', 125 | kind: NodeKind.Property, 126 | action: { keyB1: { keyB11: mockAction } }, 127 | }, 128 | { 129 | propertyName: 'keyA1', 130 | targetPropertyPath: 'keyA.keyA1', 131 | kind: NodeKind.ActionString, 132 | action: mockAction, 133 | }, 134 | { 135 | propertyName: 'keyB1', 136 | targetPropertyPath: 'keyB.keyB1', 137 | kind: NodeKind.Property, 138 | action: { keyB11: mockAction }, 139 | }, 140 | { 141 | propertyName: 'keyB11', 142 | targetPropertyPath: 'keyB.keyB1.keyB11', 143 | kind: NodeKind.ActionString, 144 | action: mockAction, 145 | }, 146 | ]; 147 | 148 | let results = []; 149 | for (const node of tree.traverseBFS()) { 150 | const { 151 | data: { propertyName, targetPropertyPath, kind, action }, 152 | } = node; 153 | results.push({ propertyName, targetPropertyPath, kind, action }); 154 | } 155 | 156 | expect(results).toEqual(expected); 157 | }); 158 | 159 | it('should parse an array of morphism actions in schema to a MorphismTree', () => { 160 | interface Target { 161 | keyA: [ 162 | { 163 | keyA1: string; 164 | keyA2: string; 165 | }, 166 | { 167 | keyB1: string; 168 | keyB2: string; 169 | } 170 | ]; 171 | } 172 | 173 | const mockAction = 'action-string'; 174 | const schema: StrictSchema = { 175 | keyA: [ 176 | { keyA1: mockAction, keyA2: mockAction }, 177 | { keyB1: mockAction, keyB2: mockAction }, 178 | ], 179 | }; 180 | const tree = new MorphismSchemaTree(schema); 181 | 182 | const expected = [ 183 | { 184 | action: [ 185 | { 186 | keyA1: mockAction, 187 | keyA2: mockAction, 188 | }, 189 | { 190 | keyB1: mockAction, 191 | keyB2: mockAction, 192 | }, 193 | ], 194 | kind: 'Property', 195 | propertyName: 'keyA', 196 | targetPropertyPath: 'keyA', 197 | }, 198 | { 199 | action: { keyA1: mockAction, keyA2: mockAction }, 200 | kind: 'Property', 201 | propertyName: '0', 202 | targetPropertyPath: 'keyA.0', 203 | }, 204 | { 205 | action: { keyB1: mockAction, keyB2: mockAction }, 206 | kind: 'Property', 207 | propertyName: '1', 208 | targetPropertyPath: 'keyA.1', 209 | }, 210 | { 211 | action: 'action-string', 212 | kind: 'ActionString', 213 | propertyName: 'keyA1', 214 | targetPropertyPath: 'keyA.0.keyA1', 215 | }, 216 | { 217 | action: 'action-string', 218 | kind: 'ActionString', 219 | propertyName: 'keyA2', 220 | targetPropertyPath: 'keyA.0.keyA2', 221 | }, 222 | { 223 | action: 'action-string', 224 | kind: 'ActionString', 225 | propertyName: 'keyB1', 226 | targetPropertyPath: 'keyA.1.keyB1', 227 | }, 228 | { 229 | action: 'action-string', 230 | kind: 'ActionString', 231 | propertyName: 'keyB2', 232 | targetPropertyPath: 'keyA.1.keyB2', 233 | }, 234 | ]; 235 | 236 | let results = []; 237 | for (const node of tree.traverseBFS()) { 238 | const { 239 | data: { propertyName, targetPropertyPath, kind, action }, 240 | } = node; 241 | results.push({ propertyName, targetPropertyPath, kind, action }); 242 | } 243 | 244 | expect(results).toEqual(expected); 245 | }); 246 | }); 247 | }); 248 | -------------------------------------------------------------------------------- /src/MorphismTree.ts: -------------------------------------------------------------------------------- 1 | import { Actions, Schema, StrictSchema, ValidatorValidateResult } from './types'; 2 | import { 3 | aggregator, 4 | get, 5 | isValidAction, 6 | isActionString, 7 | isActionSelector, 8 | isActionAggregator, 9 | isActionFunction, 10 | isFunction, 11 | isString, 12 | isObject, 13 | SCHEMA_OPTIONS_SYMBOL, 14 | isEmptyObject, 15 | } from './helpers'; 16 | import { ValidationError, ERRORS, targetHasErrors, ValidationErrors, reporter, Reporter } from './validation/reporter'; 17 | import { ValidatorError } from './validation/validators/ValidatorError'; 18 | import { isArray } from 'util'; 19 | 20 | export enum NodeKind { 21 | Root = 'Root', 22 | Property = 'Property', 23 | ActionFunction = 'ActionFunction', 24 | ActionAggregator = 'ActionAggregator', 25 | ActionString = 'ActionString', 26 | ActionSelector = 'ActionSelector', 27 | } 28 | 29 | type PreparedAction = (params: { object: any; items: any; objectToCompute: any }) => any; 30 | interface SchemaNodeData { 31 | propertyName: string; 32 | action: Actions | null; 33 | preparedAction?: PreparedAction | null; 34 | kind: NodeKind; 35 | targetPropertyPath: string; 36 | } 37 | export interface SchemaNode { 38 | data: SchemaNodeData; 39 | parent: SchemaNode | null; 40 | children: SchemaNode[]; 41 | } 42 | 43 | type Overwrite = { [P in Exclude]: T1[P] } & T2; 44 | 45 | type AddNode = Overwrite< 46 | SchemaNodeData, 47 | { 48 | kind?: NodeKind; 49 | targetPropertyPath?: string; 50 | preparedAction?: (...args: any) => any; 51 | } 52 | >; 53 | 54 | /** 55 | * Options attached to a `Schema` or `StrictSchema` 56 | */ 57 | export interface SchemaOptions { 58 | /** 59 | * Specify how to handle ES6 Class 60 | * @memberof SchemaOptions 61 | */ 62 | class?: { 63 | /** 64 | * Specify wether ES6 Class fields should be automapped if names on source and target match 65 | * @default true 66 | * @type {boolean} 67 | */ 68 | automapping: boolean; 69 | }; 70 | /** 71 | * Specify how to handle undefined values mapped during the transformations 72 | * @memberof SchemaOptions 73 | */ 74 | undefinedValues?: { 75 | /** 76 | * Undefined values should be removed from the target 77 | * @default false 78 | * @type {boolean} 79 | */ 80 | strip: boolean; 81 | /** 82 | * Optional callback to be executed for every undefined property on the Target 83 | * @function default 84 | */ 85 | default?: (target: Target, propertyPath: string) => any; 86 | }; 87 | /** 88 | * Schema validation options 89 | * @memberof SchemaOptions 90 | */ 91 | validation?: { 92 | /** 93 | * Should throw when property validation fails 94 | * @default false 95 | * @type {boolean} 96 | */ 97 | throw: boolean; 98 | /** 99 | * Custom reporter to use when throw option is set to true 100 | * @default false 101 | * @type {boolean} 102 | */ 103 | reporter?: Reporter; 104 | }; 105 | } 106 | 107 | /** 108 | * A utility function that allows defining a `StrictSchema` with extra-options e.g: how to handle `undefinedValues` 109 | * 110 | * @param {StrictSchema} schema 111 | * @param {SchemaOptions} [options] 112 | */ 113 | export function createSchema(schema: StrictSchema, options?: SchemaOptions) { 114 | if (options && !isEmptyObject(options)) (schema as any)[SCHEMA_OPTIONS_SYMBOL] = options; 115 | return schema; 116 | } 117 | 118 | export class MorphismSchemaTree { 119 | schemaOptions: SchemaOptions; 120 | 121 | root: SchemaNode; 122 | schema: Schema | StrictSchema | null; 123 | 124 | constructor(schema: Schema | StrictSchema | null) { 125 | this.schema = schema; 126 | this.schemaOptions = MorphismSchemaTree.getSchemaOptions(this.schema); 127 | 128 | this.root = { 129 | data: { 130 | targetPropertyPath: '', 131 | propertyName: 'MorphismTreeRoot', 132 | action: null, 133 | kind: NodeKind.Root, 134 | }, 135 | parent: null, 136 | children: [], 137 | }; 138 | if (schema) { 139 | this.parseSchema(schema); 140 | } 141 | } 142 | 143 | static getSchemaOptions(schema: Schema | StrictSchema | null): SchemaOptions { 144 | const defaultSchemaOptions: SchemaOptions = { 145 | class: { automapping: true }, 146 | undefinedValues: { strip: false }, 147 | }; 148 | const options = schema ? (schema as any)[SCHEMA_OPTIONS_SYMBOL] : undefined; 149 | 150 | return { ...defaultSchemaOptions, ...options }; 151 | } 152 | 153 | private parseSchema(partialSchema: Partial | string | number, actionKey?: string, parentKeyPath?: string): void { 154 | if (isValidAction(partialSchema) && actionKey) { 155 | this.add( 156 | { 157 | propertyName: actionKey, 158 | action: partialSchema as Actions, 159 | }, 160 | parentKeyPath 161 | ); 162 | parentKeyPath = parentKeyPath ? `${parentKeyPath}.${actionKey}` : actionKey; 163 | } else { 164 | if (actionKey) { 165 | if (isObject(partialSchema) && isEmptyObject(partialSchema as any)) 166 | throw new Error( 167 | `A value of a schema property can't be an empty object. Value ${JSON.stringify(partialSchema)} found for property ${actionKey}` 168 | ); 169 | // check if actionKey exists to verify if not root node 170 | this.add( 171 | { 172 | propertyName: actionKey, 173 | action: partialSchema as Actions, 174 | }, 175 | parentKeyPath 176 | ); 177 | parentKeyPath = parentKeyPath ? `${parentKeyPath}.${actionKey}` : actionKey; 178 | } 179 | 180 | if (Array.isArray(partialSchema)) { 181 | partialSchema.forEach((subSchema, index) => { 182 | this.parseSchema(subSchema, index.toString(), parentKeyPath); 183 | }); 184 | } else if (isObject(partialSchema)) { 185 | Object.keys(partialSchema).forEach(key => { 186 | this.parseSchema((partialSchema as any)[key], key, parentKeyPath); 187 | }); 188 | } 189 | } 190 | } 191 | 192 | *traverseBFS() { 193 | const queue: SchemaNode[] = []; 194 | queue.push(this.root); 195 | while (queue.length > 0) { 196 | let node = queue.shift(); 197 | if (node) { 198 | for (let i = 0, length = node.children.length; i < length; i++) { 199 | queue.push(node.children[i]); 200 | } 201 | if (node.data.kind !== NodeKind.Root) { 202 | yield node; 203 | } 204 | } 205 | } 206 | } 207 | 208 | add(data: AddNode, targetPropertyPath?: string) { 209 | const kind = this.getActionKind(data); 210 | 211 | const nodeToAdd: SchemaNode = { 212 | data: { ...data, kind, targetPropertyPath: '' }, 213 | parent: null, 214 | children: [], 215 | }; 216 | nodeToAdd.data.preparedAction = this.getPreparedAction(nodeToAdd.data); 217 | 218 | if (!targetPropertyPath) { 219 | nodeToAdd.parent = this.root; 220 | nodeToAdd.data.targetPropertyPath = nodeToAdd.data.propertyName; 221 | this.root.children.push(nodeToAdd); 222 | } else { 223 | for (const node of this.traverseBFS()) { 224 | if (node.data.targetPropertyPath === targetPropertyPath) { 225 | nodeToAdd.parent = node; 226 | nodeToAdd.data.targetPropertyPath = `${node.data.targetPropertyPath}.${nodeToAdd.data.propertyName}`; 227 | node.children.push(nodeToAdd); 228 | } 229 | } 230 | } 231 | } 232 | 233 | getActionKind(data: AddNode) { 234 | if (isActionString(data.action)) return NodeKind.ActionString; 235 | if (isFunction(data.action)) return NodeKind.ActionFunction; 236 | if (isActionSelector(data.action)) return NodeKind.ActionSelector; 237 | if (isActionAggregator(data.action)) return NodeKind.ActionAggregator; 238 | if (isObject(data.action)) return NodeKind.Property; 239 | throw new Error(`The action specified for ${data.propertyName} is not supported.`); 240 | } 241 | 242 | getPreparedAction(nodeData: SchemaNodeData): PreparedAction | null | undefined { 243 | const { propertyName: targetProperty, action, kind } = nodeData; 244 | // iterate on every action of the schema 245 | if (isActionString(action)) { 246 | // Action: string path => [ target: 'source' ] 247 | return ({ object }) => get(object, action); 248 | } else if (isActionFunction(action)) { 249 | // Action: Free Computin - a callback called with the current object and collection [ destination: (object) => {...} ] 250 | return ({ object, items, objectToCompute }) => action.call(undefined, object, items, objectToCompute); 251 | } else if (isActionAggregator(action)) { 252 | // Action: Aggregator - string paths => : [ destination: ['source1', 'source2', 'source3'] ] 253 | return ({ object }) => aggregator(action, object); 254 | } else if (isActionSelector(action)) { 255 | // Action: a path and a function: [ destination : { path: 'source', fn:(fieldValue, items) }] 256 | return ({ object, items, objectToCompute }) => { 257 | let targetValue: any; 258 | if (action.path) { 259 | if (Array.isArray(action.path)) { 260 | targetValue = aggregator(action.path, object); 261 | } else if (isString(action.path)) { 262 | targetValue = get(object, action.path); 263 | } 264 | } else { 265 | targetValue = object; 266 | } 267 | 268 | if (action.fn) { 269 | try { 270 | targetValue = action.fn.call(undefined, targetValue, object, items, objectToCompute); 271 | } catch (e) { 272 | e.message = `Unable to set target property [${targetProperty}]. 273 | \n An error occured when applying [${action.fn.name}] on property [${action.path}] 274 | \n Internal error: ${e.message}`; 275 | throw e; 276 | } 277 | } 278 | 279 | if (action.validation) { 280 | const validationResult = action.validation({ value: targetValue }); 281 | 282 | this.processValidationResult(validationResult, targetProperty, objectToCompute); 283 | targetValue = validationResult.value; 284 | } 285 | return targetValue; 286 | }; 287 | } else if (kind === NodeKind.Property) { 288 | return null; 289 | } 290 | } 291 | private processValidationResult(validationResult: ValidatorValidateResult, targetProperty: string, objectToCompute: any) { 292 | if (validationResult.error) { 293 | const error = validationResult.error; 294 | if (error instanceof ValidatorError) { 295 | this.addErrorToTarget(targetProperty, error, objectToCompute); 296 | } else { 297 | throw error; 298 | } 299 | } 300 | } 301 | 302 | private addErrorToTarget(targetProperty: string, error: ValidatorError, objectToCompute: any) { 303 | const validationError = new ValidationError({ 304 | targetProperty, 305 | innerError: error, 306 | }); 307 | 308 | if (targetHasErrors(objectToCompute)) { 309 | objectToCompute[ERRORS].addError(validationError); 310 | } else { 311 | if (this.schemaOptions.validation && this.schemaOptions.validation.reporter) { 312 | objectToCompute[ERRORS] = new ValidationErrors(this.schemaOptions.validation.reporter, objectToCompute); 313 | } else { 314 | objectToCompute[ERRORS] = new ValidationErrors(reporter, objectToCompute); 315 | } 316 | objectToCompute[ERRORS].addError(validationError); 317 | } 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/classObjects.spec.ts: -------------------------------------------------------------------------------- 1 | import Morphism, { toClassObject, morph, morphism, SCHEMA_OPTIONS_SYMBOL, Schema } from './morphism'; 2 | import { User } from './utils-test'; 3 | import { createSchema } from './MorphismTree'; 4 | 5 | describe('Class Objects', () => { 6 | describe('Class Type Mapping', function() { 7 | beforeEach(() => { 8 | Morphism.deleteMapper(User); 9 | }); 10 | 11 | it('should throw an exception when setting a mapper with a falsy schema', function() { 12 | expect(() => { 13 | Morphism.setMapper(User, null as any); 14 | }).toThrow(); 15 | }); 16 | 17 | it('should throw an exception when trying to map with a falsy type', function() { 18 | expect(() => { 19 | morphism({}, {}, undefined as any); 20 | }).toThrow(); 21 | }); 22 | 23 | it('should use the constructor default value if source value is undefined', function() { 24 | let sourceData: any = { 25 | firstName: 'John', 26 | lastName: 'Smith', 27 | type: undefined, // <== this field should fallback to the type constructor default value 28 | }; 29 | let desiredResult = new User('John', 'Smith'); 30 | let mapper = Morphism.register(User); 31 | expect(desiredResult.type).toEqual('User'); 32 | expect(mapper([sourceData])[0]).toEqual(desiredResult); 33 | }); 34 | 35 | it('should allow straight mapping from a Type without a schema', () => { 36 | let userName = 'user-name'; 37 | let user = Morphism(null as any, { firstName: userName }, User); 38 | expect(user).toEqual(new User(userName)); 39 | }); 40 | 41 | it('should allow straight mapping from a Type with a schema', () => { 42 | let dataSource = { 43 | userName: 'a-user-name', 44 | }; 45 | let schema = { 46 | firstName: 'userName', 47 | }; 48 | let user = Morphism(schema, dataSource, User); 49 | expect(user).toEqual(new User(dataSource.userName)); 50 | }); 51 | 52 | it('should pass created object context for complex interractions within object', function() { 53 | let dataSource = { 54 | groups: ['main', 'test'], 55 | }; 56 | 57 | let triggered = false; 58 | let trigger = (_user: User, _group: any) => { 59 | triggered = true; 60 | }; 61 | 62 | let schema = { 63 | groups: (object: any, _items: any, constructed: User) => { 64 | if (object.groups) { 65 | for (let group of object.groups) { 66 | constructed.addToGroup(group, trigger); 67 | } 68 | } 69 | }, 70 | }; 71 | let user = Morphism(schema, dataSource, User); 72 | 73 | let expectedUser = new User(); 74 | expectedUser.groups = dataSource.groups; 75 | expect(user).toEqual(expectedUser); 76 | expect(user.firstName).toEqual(expectedUser.firstName); 77 | 78 | expect(triggered).toEqual(true); 79 | }); 80 | 81 | it('should return undefined if undefined is given to map without doing any processing', function() { 82 | Morphism.register(User, { a: 'firstName' }); 83 | expect(Morphism.map(User, undefined)).toEqual(undefined); 84 | }); 85 | 86 | it('should override the default value if source value is defined', function() { 87 | let sourceData = { 88 | phoneNumber: null, 89 | }; 90 | 91 | let mapper = Morphism.register(User, {}); 92 | 93 | let result = mapper([sourceData])[0]; 94 | expect(new User().phoneNumber).toEqual(undefined); 95 | expect(result.phoneNumber).toEqual(null); 96 | }); 97 | 98 | it('should provide an Object as result when Morphism is applied on a typed Object', function() { 99 | let mock = { 100 | number: '12345', 101 | }; 102 | 103 | let mapper = Morphism.register(User, { phoneNumber: 'number' }); 104 | let result = mapper(mock); 105 | expect(result.phoneNumber).toEqual(mock.number); 106 | expect(result instanceof User).toEqual(true); 107 | }); 108 | 109 | it('should provide an Object as result when Morphism is applied on a typed Object usin .map', function() { 110 | let mock = { 111 | number: '12345', 112 | }; 113 | 114 | Morphism.register(User, { phoneNumber: 'number' }); 115 | let result = Morphism.map(User, mock); 116 | expect(result.phoneNumber).toEqual(mock.number); 117 | expect(result instanceof User).toEqual(true); 118 | }); 119 | 120 | it('should provide a List of Objects as result when Morphism is applied on a list', function() { 121 | let mock = { 122 | number: '12345', 123 | }; 124 | 125 | Morphism.register(User, { phoneNumber: 'number' }); 126 | let result = Morphism.map(User, [mock]); 127 | expect(result[0].phoneNumber).toEqual(mock.number); 128 | expect(result[0] instanceof User).toBe(true); 129 | }); 130 | 131 | it('should fallback to constructor default value and ignore function when path value is undefined', function() { 132 | let mock = { 133 | lastname: 'user-lastname', 134 | }; 135 | let schema = { 136 | type: { 137 | path: 'unreachable.path', 138 | fn: (value: any) => value, 139 | }, 140 | }; 141 | 142 | Morphism.register(User, schema); 143 | expect(new User().type).toEqual('User'); 144 | 145 | let result = Morphism.map(User, mock); 146 | expect(result.type).toEqual('User'); 147 | }); 148 | 149 | it('should automatically map class fields when source fields match the target', () => { 150 | class Target { 151 | a: string; 152 | b: number; 153 | c: string; 154 | } 155 | 156 | const source = { a: 'auto', b: 1, c: 'normal' }; 157 | 158 | const schema = { c: 'c' }; 159 | const result = morphism(schema, source, Target); 160 | 161 | expect(result).toEqual(source); 162 | }); 163 | 164 | it('should not automatically map class fields when automapping is turned off', () => { 165 | class Target { 166 | a: string; 167 | b: number; 168 | c: string; 169 | } 170 | 171 | const source = { a: 'auto', b: 1, c: 'normal' }; 172 | 173 | const schema: Schema = { c: 'c', [SCHEMA_OPTIONS_SYMBOL]: { class: { automapping: false } } }; 174 | const result = morphism(schema, source, Target); 175 | 176 | expect(result).toEqual({ c: 'normal' }); 177 | }); 178 | 179 | it('(Registry API) should not automatically map class fields when automapping is turned off', () => { 180 | class Target { 181 | a: string; 182 | b: number; 183 | c: string; 184 | } 185 | 186 | const source = { a: 'auto', b: 1, c: 'normal' }; 187 | 188 | const schema = createSchema({ c: 'c' }, { class: { automapping: false } }); 189 | Morphism.register(Target, schema); 190 | const result = Morphism.map(Target, source); 191 | 192 | expect(result).toEqual({ c: 'normal' }); 193 | }); 194 | }); 195 | describe('Projection', () => { 196 | it('should allow to map property one to one when using `Morphism.map(Type,object)` without registration', function() { 197 | let mock = { field: 'value' }; 198 | class Target { 199 | field: any; 200 | constructor(field: any) { 201 | this.field = field; 202 | } 203 | } 204 | const result = Morphism.map(Target, mock); 205 | expect(result).toEqual(new Target('value')); 206 | }); 207 | 208 | it('should allow to map property one to one when using `Morphism.map(Type,data)` without registration', function() { 209 | let mocks = [{ field: 'value' }, { field: 'value' }, { field: 'value' }]; 210 | class Target { 211 | field: any; 212 | constructor(field: any) { 213 | this.field = field; 214 | } 215 | } 216 | const results = Morphism.map(Target, mocks); 217 | results.forEach((res: any) => { 218 | expect(res).toEqual(new Target('value')); 219 | }); 220 | }); 221 | 222 | it('should allow to use Morphism.map as an iteratee first function', function() { 223 | let mocks = [{ field: 'value' }, { field: 'value' }, { field: 'value' }]; 224 | class Target { 225 | field: any; 226 | constructor(field: any) { 227 | this.field = field; 228 | } 229 | } 230 | const results = mocks.map(Morphism.map(Target)); 231 | results.forEach(res => { 232 | expect(res).toEqual(new Target('value')); 233 | }); 234 | }); 235 | 236 | it('should allow to use mapper from `Morphism.map(Type, undefined)` as an iteratee first function', function() { 237 | let mocks = [{ field: 'value' }, { field: 'value' }, { field: 'value' }]; 238 | class Target { 239 | field: any; 240 | constructor(field: any) { 241 | this.field = field; 242 | } 243 | } 244 | const mapper = Morphism.map(Target); 245 | const results = mocks.map(mapper); 246 | results.forEach(res => { 247 | expect(res).toEqual(new Target('value')); 248 | expect(res.field).toBeDefined(); 249 | }); 250 | }); 251 | }); 252 | describe('Class Decorators', () => { 253 | const schema = { foo: 'bar' }; 254 | interface ITarget { 255 | foo: string | null; 256 | } 257 | class Target implements ITarget { 258 | foo = null; 259 | } 260 | 261 | class Service { 262 | @toClassObject(schema, Target) 263 | fetch(source: any) { 264 | return Promise.resolve(source); 265 | } 266 | @toClassObject(schema, Target) 267 | fetch2(source: any) { 268 | return source; 269 | } 270 | @toClassObject(schema, Target) 271 | async fetch3(source: any) { 272 | return await (async () => source)(); 273 | } 274 | @toClassObject(schema, Target) 275 | fetchMultiple(source: any) { 276 | return [source, source]; 277 | } 278 | @morph(schema, Target) 279 | withMorphDecorator(source: any) { 280 | return source; 281 | } 282 | } 283 | 284 | const service = new Service(); 285 | interface ISource { 286 | bar: string; 287 | } 288 | 289 | const source: ISource = { 290 | bar: 'value', 291 | }; 292 | 293 | it('should create a Class Object when a Promise is used', function() { 294 | service.fetch(source).then((result: any) => expect(result instanceof Target).toBe(true)); 295 | }); 296 | it('should support a function returning an array of Class Object or a Class Object', () => { 297 | expect(service.fetch2(source) instanceof Target).toBe(true); 298 | }); 299 | it('should support an aync function', async function() { 300 | const res = await service.fetch3(source); 301 | expect(res instanceof Target).toBe(true); 302 | }); 303 | it('should support a function returning an array of Class Objects', function() { 304 | service.fetchMultiple(source).forEach(item => expect(item instanceof Target).toBe(true)); 305 | }); 306 | it('should allow to morph with Morph decorator to Class Object', function() { 307 | expect(service.withMorphDecorator(source) instanceof Target).toBe(true); 308 | }); 309 | }); 310 | }); 311 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ActionSelector, ActionAggregator, ActionFunction } from './types'; 2 | 3 | /** 4 | * Symbol identifier used to store options on a Morphism schema. Using the `createSchema` helper to avoid using the symbol directly. 5 | * 6 | * @example 7 | * ```typescript 8 | * import { SCHEMA_OPTIONS_SYMBOL } from 'morphism'; 9 | * 10 | * const options: SchemaOptions = { class: { automapping: true }, undefinedValues: { strip: true } }; 11 | * const schema: Schema = { targetProperty: 'sourceProperty', [SCHEMA_OPTIONS_SYMBOL]: options } 12 | 13 | * ``` 14 | */ 15 | export const SCHEMA_OPTIONS_SYMBOL = Symbol('SchemaOptions'); 16 | 17 | export function isActionSelector(value: any): value is ActionSelector { 18 | return isObject(value) && (value.hasOwnProperty('fn') || value.hasOwnProperty('path')); 19 | } 20 | export function isActionString(value: any): value is string { 21 | return isString(value); 22 | } 23 | export function isActionAggregator(value: any): value is ActionAggregator { 24 | return Array.isArray(value) && value.every(isActionString); 25 | } 26 | export function isActionFunction(value: any): value is ActionFunction { 27 | return isFunction(value); 28 | } 29 | 30 | export function isValidAction(action: any) { 31 | return isString(action) || isFunction(action) || isActionSelector(action) || isActionAggregator(action); 32 | } 33 | 34 | export const aggregator = (paths: string[], object: any) => { 35 | return paths.reduce((delta, path) => { 36 | set(delta, path, get(object, path)); // TODO: ensure set will return the mutated object 37 | return delta; 38 | }, {}); 39 | }; 40 | 41 | export function isUndefined(value: any) { 42 | return value === undefined; 43 | } 44 | 45 | export function isObject(value: any): value is object { 46 | const type = typeof value; 47 | return value != null && (type === 'object' || type === 'function'); 48 | } 49 | 50 | export function isString(value: any): value is string { 51 | return typeof value === 'string' || value instanceof String; 52 | } 53 | 54 | export function isFunction(value: any): value is (...args: any[]) => any { 55 | return typeof value === 'function'; 56 | } 57 | 58 | export function isPromise(object: any) { 59 | if (Promise && Promise.resolve) { 60 | // tslint:disable-next-line:triple-equals 61 | return Promise.resolve(object) == object; 62 | } else { 63 | throw new Error('Promise not supported in your environment'); 64 | } 65 | } 66 | export function get(object: any, path: string) { 67 | path = path.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties 68 | path = path.replace(/^\./, ''); // strip a leading dot 69 | const a = path.split('.'); 70 | for (let i = 0, n = a.length; i < n; ++i) { 71 | const k = a[i]; 72 | if (isObject(object) && k in object) { 73 | object = (object as any)[k]; 74 | } else { 75 | return; 76 | } 77 | } 78 | return object; 79 | } 80 | 81 | export function zipObject(props: string[], values: any[]) { 82 | return props.reduce((prev, prop, i) => { 83 | return { ...prev, [prop]: values[i] }; 84 | }, {}); 85 | } 86 | 87 | // https://github.com/mariocasciaro/object-path/blob/master/index.js 88 | function hasOwnProperty(obj: any, prop: any) { 89 | if (obj == null) { 90 | return false; 91 | } 92 | // to handle objects with null prototypes (too edge case?) 93 | return Object.prototype.hasOwnProperty.call(obj, prop); 94 | } 95 | function hasShallowProperty(obj: any, prop: any) { 96 | return (typeof prop === 'number' && Array.isArray(obj)) || hasOwnProperty(obj, prop); 97 | } 98 | function getShallowProperty(obj: any, prop: any) { 99 | if (hasShallowProperty(obj, prop)) { 100 | return obj[prop]; 101 | } 102 | } 103 | export function set(obj: any, path: any, value: any, doNotReplace?: boolean): any { 104 | if (typeof path === 'number') { 105 | path = [path]; 106 | } 107 | if (!path || path.length === 0) { 108 | return obj; 109 | } 110 | if (typeof path === 'string') { 111 | return set(obj, path.split('.').map(getKey), value, doNotReplace); 112 | } 113 | const currentPath = path[0]; 114 | const currentValue = getShallowProperty(obj, currentPath); 115 | if (path.length === 1) { 116 | if (currentValue === void 0 || !doNotReplace) { 117 | obj[currentPath] = value; 118 | } 119 | return currentValue; 120 | } 121 | 122 | if (currentValue === void 0) { 123 | // check if we assume an array 124 | if (typeof path[1] === 'number') { 125 | obj[currentPath] = []; 126 | } else { 127 | obj[currentPath] = {}; 128 | } 129 | } 130 | 131 | return set(obj[currentPath], path.slice(1), value, doNotReplace); 132 | } 133 | 134 | function getKey(key: any) { 135 | const intKey = parseInt(key); 136 | if (intKey.toString() === key) { 137 | return intKey; 138 | } 139 | return key; 140 | } 141 | 142 | export function isEmptyObject(obj: object) { 143 | for (const prop in obj) { 144 | if (Object.prototype.hasOwnProperty.call(obj, prop)) { 145 | return false; 146 | } 147 | } 148 | return true; 149 | } 150 | -------------------------------------------------------------------------------- /src/jsObjects.spec.ts: -------------------------------------------------------------------------------- 1 | import Morphism, { toJSObject, morph } from './morphism'; 2 | 3 | describe('Javascript Objects', () => { 4 | describe('Base', () => { 5 | it('should morph an empty Object to an empty Object || m({}, {}) => {}', function() { 6 | expect(Morphism({}, {})).toEqual({}); 7 | }); 8 | 9 | it('should allow to use a mapper as an iteratee first function', function() { 10 | let mocks = [{ source: 'value' }, { source: 'value' }, { source: 'value' }]; 11 | let schema = { 12 | target: 'source', 13 | }; 14 | const mapper = Morphism(schema); 15 | 16 | let results = mocks.map(mapper); 17 | results.forEach(res => { 18 | expect(res).toEqual({ target: 'value' }); 19 | }); 20 | }); 21 | 22 | it('should allow to use a mapper declaration as an iteratee first function', function() { 23 | let mocks = [{ source: 'value' }, { source: 'value' }, { source: 'value' }]; 24 | let schema = { 25 | target: 'source', 26 | }; 27 | 28 | let results = mocks.map(Morphism(schema)); 29 | results.forEach(res => { 30 | expect(res).toEqual({ target: 'value' }); 31 | }); 32 | }); 33 | }); 34 | 35 | describe('Decorators - Function Decorator', () => { 36 | const schema = { foo: 'bar' }; 37 | 38 | class Service { 39 | @toJSObject(schema) 40 | fetch(source: any) { 41 | return Promise.resolve(source); 42 | } 43 | @toJSObject(schema) 44 | fetch2(source: any) { 45 | return source; 46 | } 47 | @toJSObject(schema) 48 | async fetch3(source: any) { 49 | return await (async () => source)(); 50 | } 51 | @morph(schema) 52 | withMorphDecorator(source: any) { 53 | return source; 54 | } 55 | @toJSObject(schema) 56 | fetchFail(source: any) { 57 | return Promise.reject(source); 58 | } 59 | } 60 | 61 | const service = new Service(); 62 | interface ISource { 63 | bar: string; 64 | } 65 | interface ITarget { 66 | foo: string; 67 | } 68 | const source: ISource = { 69 | bar: 'value', 70 | }; 71 | const expected: ITarget = { 72 | foo: 'value', 73 | }; 74 | 75 | it('should support a function returning a promise', () => { 76 | return service.fetch(source).then((result: any) => expect(result).toEqual(expected)); 77 | }); 78 | it('should support a function returning an array or an object', () => { 79 | expect(service.fetch2(source)).toEqual(expected); 80 | }); 81 | it('should support an aync function', async function() { 82 | const res = await service.fetch3(source); 83 | expect(res).toEqual(expected); 84 | }); 85 | it('should allow to morph with Morph decorator to JS Object', function() { 86 | expect(service.withMorphDecorator(source)).toEqual(expected); 87 | }); 88 | it('should not swallow error when promise fails', () => { 89 | return service.fetchFail(source).catch(data => { 90 | expect(data).toEqual(source); 91 | }); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/morphism.spec.ts: -------------------------------------------------------------------------------- 1 | import Morphism, { 2 | StrictSchema, 3 | morphism, 4 | Schema, 5 | createSchema, 6 | SchemaOptions, 7 | SCHEMA_OPTIONS_SYMBOL, 8 | reporter, 9 | Validation, 10 | } from './morphism'; 11 | import { User, MockData } from './utils-test'; 12 | import { ActionSelector, ActionAggregator } from './types'; 13 | import { defaultFormatter, ValidationError } from './validation/reporter'; 14 | import { ValidatorError } from './validation/validators/ValidatorError'; 15 | 16 | describe('Morphism', () => { 17 | const dataToCrunch: MockData[] = [ 18 | { 19 | firstName: 'John', 20 | lastName: 'Smith', 21 | age: 25, 22 | address: { 23 | streetAddress: '21 2nd Street', 24 | city: 'New York', 25 | state: 'NY', 26 | postalCode: '10021', 27 | }, 28 | phoneNumber: [ 29 | { 30 | type: 'home', 31 | number: '212 555-1234', 32 | }, 33 | { 34 | type: 'fax', 35 | number: '646 555-4567', 36 | }, 37 | ], 38 | }, 39 | ]; 40 | beforeEach(() => { 41 | Morphism.deleteMapper(User); 42 | Morphism.register(User); 43 | }); 44 | 45 | describe('Currying Function overload', () => { 46 | it('Should return a collection of objects when an array is provided as source', () => { 47 | const schema = { foo: 'bar' }; 48 | const res = morphism(schema, [{ bar: 'test' }]); 49 | expect(res.map).toBeDefined(); 50 | expect(res[0].foo).toEqual('test'); 51 | }); 52 | it('Should return a single object matching the schema structure when an object is provided as source', () => { 53 | const schema = { foo: 'bar' }; 54 | const res = morphism(schema, { bar: 'test' }); 55 | 56 | expect(res.foo).toEqual('test'); 57 | }); 58 | 59 | it('Should return a Mapper which outputs a Class Object when a Class Type is specified and no items', () => { 60 | class Foo { 61 | foo: string; 62 | } 63 | const schema = { foo: 'bar' }; 64 | const source = { bar: 'value' }; 65 | const mapper = morphism(schema, null, Foo); 66 | expect(mapper(source).foo).toEqual('value'); 67 | expect(mapper([source][0]).foo).toEqual('value'); 68 | }); 69 | 70 | it('Should return a Mapper which outputs a Typed Object from the generic provided', () => { 71 | interface IFoo { 72 | foo: string; 73 | } 74 | const schema: Schema = { foo: 'bar' }; 75 | const source = { bar: 'value' }; 76 | const mapper = morphism(schema); 77 | 78 | expect(mapper(source).foo).toEqual('value'); 79 | expect(mapper([source][0]).foo).toEqual('value'); 80 | }); 81 | 82 | it('Should do a straight mapping with an Interface provided', () => { 83 | interface Destination { 84 | foo: string; 85 | bar: string; 86 | qux: string; 87 | } 88 | interface Source { 89 | bar: string; 90 | } 91 | const schema: StrictSchema = { 92 | foo: 'bar', 93 | bar: 'bar', 94 | qux: elem => elem.bar, 95 | }; 96 | const source = { bar: 'value' }; 97 | 98 | const target = morphism(schema, source); 99 | const targets = morphism(schema, [source]); 100 | const singleTarget = targets.shift(); 101 | 102 | expect(target.foo).toEqual('value'); 103 | expect(singleTarget).toBeDefined(); 104 | if (singleTarget) { 105 | expect(singleTarget.foo).toEqual('value'); 106 | } 107 | }); 108 | 109 | it('should return undefined when property with function action acts with when nested', () => { 110 | const source = { 111 | foo: 'value', 112 | bar: 'bar', 113 | }; 114 | const schemaB = { 115 | some: 'test', 116 | }; 117 | let schemaA = { 118 | f: 'foo', 119 | b: (data: any) => morphism(schemaB, data.undefined), 120 | }; 121 | 122 | const res = morphism(schemaA, source); 123 | expect(res).toEqual({ f: 'value' }); 124 | expect(['f', 'b']).toEqual(Object.keys(res)); 125 | }); 126 | 127 | it('should provide a mapper outputting class objects', () => { 128 | const source = { 129 | name: 'value', 130 | }; 131 | const schema = { 132 | firstName: 'name', 133 | }; 134 | 135 | const mapper = morphism(schema, null, User); 136 | const res = mapper(source); 137 | 138 | expect(res instanceof User).toBe(true); 139 | expect(res).toEqual(new User(source.name)); 140 | }); 141 | }); 142 | 143 | describe('Plain Objects', function() { 144 | it('should export Morphism function curried function', function() { 145 | expect(typeof Morphism).toEqual('function'); 146 | }); 147 | 148 | it('should provide a mapper function from the partial application', function() { 149 | let fn = Morphism({}); 150 | expect(typeof fn).toEqual('function'); 151 | }); 152 | 153 | it('should provide an Object as result when Morphism is applied on an Object', function() { 154 | expect(Morphism({}, {})).toEqual({}); 155 | }); 156 | 157 | it('should throw an exception when trying to access a path from an undefined object', function() { 158 | Morphism.setMapper(User, { 159 | fieldWillThrow: { 160 | path: 'fieldWillThrow.becauseNotReachable', 161 | fn: (object: any) => { 162 | let failHere = object.value; 163 | return failHere; 164 | }, 165 | }, 166 | }); 167 | let applyMapping = () => 168 | Morphism.map(User, { 169 | fieldWillThrow: 'value', 170 | }); 171 | expect(applyMapping).toThrow(); 172 | }); 173 | 174 | it('should rethrow an exception when applying a function on path throws an error', function() { 175 | const err = new TypeError('an internal error'); 176 | Morphism.setMapper(User, { 177 | fieldWillThrow: { 178 | path: 'fieldWillThrow', 179 | fn: () => { 180 | throw err; 181 | }, 182 | }, 183 | }); 184 | let applyMapping = () => 185 | Morphism.map(User, { 186 | fieldWillThrow: 'value', 187 | }); 188 | expect(applyMapping).toThrow(err); 189 | }); 190 | }); 191 | 192 | describe('Collection of Objects', function() { 193 | it('should morph an empty array to an empty array || m({}, []) => []', function() { 194 | expect(Morphism({}, [])).toEqual([]); 195 | }); 196 | 197 | it('should morph a collection of objects with a stored function || mapObject([{}]) => [Object{}]', function() { 198 | const input = [ 199 | { 200 | firstName: 'John', 201 | lastName: 'Smith', 202 | number: '212 555-1234', 203 | }, 204 | { 205 | firstName: 'James', 206 | lastName: 'Bond', 207 | number: '212 555-5678', 208 | }, 209 | ]; 210 | 211 | const output = [ 212 | { 213 | firstName: 'John', 214 | lastName: 'Smith', 215 | phoneNumber: '212 555-1234', 216 | }, 217 | { 218 | firstName: 'James', 219 | lastName: 'Bond', 220 | phoneNumber: '212 555-5678', 221 | }, 222 | ]; 223 | 224 | const schema = { 225 | phoneNumber: (object: any) => object.number, 226 | }; 227 | 228 | Morphism.deleteMapper(User); 229 | const mapUser = Morphism.register(User, schema); 230 | const results = mapUser(input); 231 | results.forEach((res, index) => { 232 | expect(res).toEqual(jasmine.objectContaining(output[index])); 233 | }); 234 | 235 | const mapUser2 = Morphism(schema, null, User); 236 | const results2 = mapUser2(input); 237 | 238 | results2.forEach((res, index) => { 239 | expect(res).toEqual(jasmine.objectContaining(output[index])); 240 | }); 241 | 242 | const results3 = Morphism.map(User, input); 243 | results3.forEach((res, index) => { 244 | expect(res).toEqual(jasmine.objectContaining(output[index])); 245 | }); 246 | 247 | const results4 = input.map(userInput => Morphism.map(User, userInput)); 248 | results4.forEach((res, index) => { 249 | expect(res).toEqual(jasmine.objectContaining(output[index])); 250 | }); 251 | }); 252 | }); 253 | 254 | describe('Mapper Instance', function() { 255 | it('should provide a pure idempotent mapper function from the partial application', function() { 256 | let schema = { 257 | user: ['firstName', 'lastName'], 258 | city: 'address.city', 259 | }; 260 | let desiredResult = { 261 | user: { 262 | firstName: 'John', 263 | lastName: 'Smith', 264 | }, 265 | city: 'New York', 266 | }; 267 | let mapper = Morphism(schema); 268 | let results = mapper(dataToCrunch); 269 | 270 | expect(results[0]).toEqual(desiredResult); 271 | expect(results[0]).toEqual(mapper(dataToCrunch)[0]); 272 | expect(results[0].city).toEqual(desiredResult.city); 273 | }); 274 | }); 275 | 276 | describe('Schema', function() { 277 | describe('Action Selector', () => { 278 | it('should accept a selector action in deep nested schema property', () => { 279 | interface Source { 280 | keySource: string; 281 | keySource1: string; 282 | } 283 | const sample: Source = { 284 | keySource: 'value', 285 | keySource1: 'value1', 286 | }; 287 | 288 | interface Target { 289 | keyA: { 290 | keyA1: [ 291 | { 292 | keyA11: string; 293 | keyA12: number; 294 | } 295 | ]; 296 | keyA2: string; 297 | }; 298 | } 299 | const selector: ActionSelector = { 300 | path: 'keySource', 301 | fn: () => 'value-test', 302 | }; 303 | const aggregator: ActionAggregator = ['keySource', 'keySource1']; 304 | const schema: StrictSchema = { 305 | keyA: { 306 | keyA1: [{ keyA11: aggregator, keyA12: selector }], 307 | keyA2: 'keySource', 308 | }, 309 | }; 310 | 311 | const target = morphism(schema, sample); 312 | 313 | expect(target).toEqual({ 314 | keyA: { 315 | keyA1: [ 316 | { 317 | keyA11: { 318 | keySource: 'value', 319 | keySource1: 'value1', 320 | }, 321 | keyA12: 'value-test', 322 | }, 323 | ], 324 | keyA2: 'value', 325 | }, 326 | }); 327 | }); 328 | it('should compute function on data from specified path', function() { 329 | let schema = { 330 | state: { 331 | path: 'address.state', 332 | fn: (s: any) => s.toLowerCase(), 333 | }, 334 | }; 335 | 336 | let desiredResult = { 337 | state: 'ny', // from NY to ny 338 | }; 339 | let results = Morphism(schema, dataToCrunch); 340 | expect(results[0]).toEqual(desiredResult); 341 | }); 342 | it('should allow to use an action selector without a `fn` specified', () => { 343 | interface Source { 344 | s1: string; 345 | } 346 | interface Target { 347 | t1: string; 348 | } 349 | const schema = createSchema({ t1: { path: 's1' } }); 350 | const result = morphism(schema, { s1: 'value' }); 351 | expect(result.t1).toEqual('value'); 352 | }); 353 | 354 | it('should allow to use an action selector without a `fn` specified along with validation options', () => { 355 | interface Target { 356 | t1: string; 357 | } 358 | const schema = createSchema({ 359 | t1: { path: 's1', validation: Validation.string() }, 360 | }); 361 | const result = morphism(schema, { s1: 1234 }); 362 | const errors = reporter.report(result); 363 | expect(errors).not.toBeNull(); 364 | if (errors) { 365 | expect(errors.length).toBe(1); 366 | } 367 | }); 368 | 369 | it('should allow to use an action selector with a `fn` callback only', () => { 370 | interface Target { 371 | t1: string; 372 | } 373 | const schema = createSchema({ t1: { fn: value => value.s1 } }); 374 | const result = morphism(schema, { s1: 'value' }); 375 | expect(result.t1).toEqual('value'); 376 | }); 377 | 378 | it('should allow to use an action selector with a `fn` callback only along with validation options', () => { 379 | interface Target { 380 | t1: string; 381 | } 382 | const schema = createSchema({ 383 | t1: { fn: value => value.s1, validation: Validation.string() }, 384 | }); 385 | const result = morphism(schema, { s1: 1234 }); 386 | const errors = reporter.report(result); 387 | expect(errors).not.toBeNull(); 388 | if (errors) { 389 | expect(errors.length).toBe(1); 390 | } 391 | }); 392 | 393 | it('should throw an exception when a schema property is an empty object', () => { 394 | const schema = createSchema({ prop: {} }); 395 | expect(() => { 396 | morphism(schema, {}); 397 | }).toThrow(`A value of a schema property can't be an empty object. Value {} found for property prop`); 398 | }); 399 | 400 | it('should throw an exception when a schema property is not supported', () => { 401 | const schema = createSchema({ prop: 1234 }); 402 | expect(() => { 403 | morphism(schema, {}); 404 | }).toThrow(`The action specified for prop is not supported.`); 405 | }); 406 | }); 407 | describe('Function Predicate', function() { 408 | it('should support es6 destructuring as function predicate', function() { 409 | let schema = { 410 | target: ({ source }: { source: string }) => source, 411 | }; 412 | let mock = { 413 | source: 'value', 414 | }; 415 | let expected = { 416 | target: 'value', 417 | }; 418 | let result = Morphism(schema, mock); 419 | expect(result).toEqual(expected); 420 | expect(result.target).toEqual(expected.target); 421 | expect(result.target.replace).toBeDefined(); 422 | }); 423 | 424 | it('should support nesting mapping', function() { 425 | let nestedSchema = { 426 | target1: 'source', 427 | target2: ({ nestedSource }: any) => nestedSource.source, 428 | }; 429 | let schema = { 430 | complexTarget: ({ complexSource }: any) => Morphism(nestedSchema, complexSource), 431 | }; 432 | let mock = { 433 | complexSource: { 434 | source: 'value1', 435 | nestedSource: { 436 | source: 'value2', 437 | }, 438 | }, 439 | }; 440 | let expected = { 441 | complexTarget: { 442 | target1: 'value1', 443 | target2: 'value2', 444 | }, 445 | }; 446 | let result = Morphism(schema, mock); 447 | expect(result).toEqual(expected); 448 | }); 449 | 450 | it('should be resilient when doing nesting mapping and using destructuration on array', function() { 451 | let nestedSchema = { 452 | target: 'source', 453 | nestedTargets: ({ nestedSources }: any) => Morphism({ nestedTarget: ({ nestedSource }: any) => nestedSource }, nestedSources), 454 | }; 455 | let schema = { 456 | complexTarget: ({ complexSource }: any) => Morphism(nestedSchema, complexSource), 457 | }; 458 | let mock: any = { 459 | complexSource: { 460 | source: 'value1', 461 | nestedSources: [], 462 | }, 463 | }; 464 | let expected: any = { 465 | complexTarget: { 466 | target: 'value1', 467 | nestedTargets: [], 468 | }, 469 | }; 470 | let result = Morphism(schema, mock); 471 | expect(result).toEqual(expected); 472 | }); 473 | }); 474 | describe('createSchema', () => { 475 | it('should return a schema', () => { 476 | interface Target { 477 | keyA: string; 478 | } 479 | interface Source { 480 | s1: string; 481 | } 482 | const schema = createSchema({ keyA: 's1' }); 483 | expect(schema).toEqual({ keyA: 's1' }); 484 | const res = morphism(schema, { s1: 'value' }); 485 | expect(res).toEqual({ keyA: 'value' }); 486 | }); 487 | 488 | it('should return a schema with options', () => { 489 | interface Target { 490 | keyA: string; 491 | } 492 | interface Source { 493 | s1: string; 494 | } 495 | const options: SchemaOptions = { 496 | class: { automapping: true }, 497 | undefinedValues: { strip: true }, 498 | }; 499 | const schema = createSchema({ keyA: 's1' }, options); 500 | const res = morphism(schema, { s1: 'value' }); 501 | 502 | expect(schema).toEqual({ 503 | keyA: 's1', 504 | [SCHEMA_OPTIONS_SYMBOL]: options, 505 | }); 506 | expect(res).toEqual({ keyA: 'value' }); 507 | }); 508 | 509 | it('should allow schema options using symbol', () => { 510 | const source = { 511 | foo: 'value', 512 | bar: 'bar', 513 | }; 514 | const schemaB = { 515 | some: 'test', 516 | }; 517 | const options: SchemaOptions = { undefinedValues: { strip: true } }; 518 | let schemaA = { 519 | f: 'foo', 520 | b: (data: any) => morphism(schemaB, data.undefined), 521 | [SCHEMA_OPTIONS_SYMBOL]: options, 522 | }; 523 | 524 | const res = morphism(schemaA, source); 525 | expect(res).toEqual({ f: 'value' }); 526 | expect(['f']).toEqual(Object.keys(res)); 527 | }); 528 | 529 | it('should strip undefined values from target when option is provided', () => { 530 | const source = { 531 | foo: 'value', 532 | bar: 'bar', 533 | }; 534 | const schemaB = { 535 | some: 'test', 536 | }; 537 | let schemaA = createSchema( 538 | { 539 | f: 'foo', 540 | b: (data: any) => morphism(schemaB, data.undefined), 541 | }, 542 | { undefinedValues: { strip: true } } 543 | ); 544 | 545 | const res = morphism(schemaA, source); 546 | expect(res).toEqual({ f: 'value' }); 547 | expect(['f']).toEqual(Object.keys(res)); 548 | }); 549 | 550 | it('should fallback to default when undefined value on target', () => { 551 | const source = {}; 552 | const schema = createSchema( 553 | { key: 'foo' }, 554 | { 555 | undefinedValues: { 556 | strip: true, 557 | default: () => null, 558 | }, 559 | } 560 | ); 561 | 562 | expect(morphism(schema, source)).toEqual({ key: null }); 563 | }); 564 | 565 | it('should throw when validation.throw option is set to true', () => { 566 | interface Source { 567 | s1: string; 568 | } 569 | interface Target { 570 | t1: string; 571 | t2: string; 572 | } 573 | const schema = createSchema( 574 | { 575 | t1: { fn: value => value.s1, validation: Validation.string() }, 576 | t2: { fn: value => value.s1, validation: Validation.string() }, 577 | }, 578 | { validation: { throw: true } } 579 | ); 580 | const error1 = new ValidationError({ 581 | targetProperty: 't1', 582 | innerError: new ValidatorError({ 583 | expect: `Expected value to be a but received <${undefined}>`, 584 | value: undefined, 585 | }), 586 | }); 587 | const error2 = new ValidationError({ 588 | targetProperty: 't2', 589 | innerError: new ValidatorError({ 590 | expect: `Expected value to be a but received <${undefined}>`, 591 | value: undefined, 592 | }), 593 | }); 594 | 595 | const message1 = defaultFormatter(error1); 596 | const message2 = defaultFormatter(error2); 597 | 598 | expect(() => { 599 | morphism(schema, JSON.parse('{}')); 600 | }).toThrow(`${message1}\n${message2}`); 601 | }); 602 | }); 603 | }); 604 | 605 | describe('Paths Aggregation', function() { 606 | it('should return a object of aggregated values given a array of paths', function() { 607 | let schema = { 608 | user: ['firstName', 'lastName'], 609 | }; 610 | 611 | let desiredResult = { 612 | user: { 613 | firstName: 'John', 614 | lastName: 'Smith', 615 | }, 616 | }; 617 | let results = Morphism(schema, dataToCrunch); 618 | expect(results[0]).toEqual(desiredResult); 619 | }); 620 | 621 | it('should return a object of aggregated values given a array of paths (nested path case)', function() { 622 | let schema = { 623 | user: ['firstName', 'address.city'], 624 | }; 625 | 626 | let desiredResult = { 627 | user: { 628 | firstName: 'John', 629 | address: { 630 | city: 'New York', 631 | }, 632 | }, 633 | }; 634 | let results = Morphism(schema, dataToCrunch); 635 | expect(results[0]).toEqual(desiredResult); 636 | }); 637 | 638 | it('should provide an aggregate as a result from an array of paths when applying a function', () => { 639 | let data = { a: 1, b: { c: 2 } }; 640 | let rules = { 641 | ac: { 642 | path: ['a', 'b.c'], 643 | fn: (aggregate: any) => { 644 | expect(aggregate).toEqual({ a: 1, b: { c: 2 } }); 645 | return aggregate; 646 | }, 647 | }, 648 | }; 649 | let res = Morphism(rules, data); 650 | 651 | expect(res).toEqual({ ac: { a: 1, b: { c: 2 } } }); 652 | }); 653 | }); 654 | 655 | describe('Flattening and Projection', function() { 656 | it('should flatten data from specified path', function() { 657 | interface Source { 658 | firstName: string; 659 | lastName: string; 660 | address: { city: string }; 661 | } 662 | interface Target { 663 | firstName: string; 664 | lastName: string; 665 | city: string; 666 | } 667 | let schema: StrictSchema = { 668 | firstName: 'firstName', 669 | lastName: 'lastName', 670 | city: 'address.city', 671 | }; 672 | 673 | let desiredResult = { 674 | firstName: 'John', 675 | lastName: 'Smith', 676 | city: 'New York', 677 | }; 678 | let results = Morphism(schema, dataToCrunch); 679 | expect(results[0]).toEqual(desiredResult); 680 | }); 681 | 682 | it('should pass the object value to the function when no path is specified', function() { 683 | interface D { 684 | firstName: string; 685 | lastName: string; 686 | city: string; 687 | status: string; 688 | } 689 | 690 | let schema: StrictSchema = { 691 | firstName: 'firstName', 692 | lastName: 'lastName', 693 | city: { path: 'address.city', fn: prop => prop }, 694 | status: o => o.phoneNumber[0].type, 695 | }; 696 | 697 | let desiredResult = { 698 | firstName: 'John', 699 | lastName: 'Smith', 700 | city: 'New York', 701 | status: 'home', 702 | }; 703 | let results = Morphism(schema, dataToCrunch); 704 | expect(results[0]).toEqual(desiredResult); 705 | }); 706 | 707 | it('should accept deep nested actions', () => { 708 | interface Source { 709 | keyA: string; 710 | } 711 | const sample: Source = { 712 | keyA: 'value', 713 | }; 714 | 715 | interface Target { 716 | keyA: { keyA1: string }; 717 | } 718 | 719 | const schema: StrictSchema = { 720 | keyA: { keyA1: source => source.keyA }, 721 | }; 722 | 723 | const target = morphism(schema, sample); 724 | expect(target).toEqual({ keyA: { keyA1: 'value' } }); 725 | }); 726 | 727 | it('should accept deep nested actions into array', () => { 728 | interface Source { 729 | keySource: string; 730 | } 731 | const sample: Source = { 732 | keySource: 'value', 733 | }; 734 | 735 | interface Target { 736 | keyA: { 737 | keyA1: [ 738 | { 739 | keyA11: string; 740 | keyA12: number; 741 | } 742 | ]; 743 | keyA2: string; 744 | }; 745 | } 746 | const schema: StrictSchema = { 747 | keyA: { 748 | keyA1: [{ keyA11: 'keySource', keyA12: 'keySource' }], 749 | keyA2: 'keySource', 750 | }, 751 | }; 752 | 753 | const target = morphism(schema, sample); 754 | 755 | expect(target).toEqual({ 756 | keyA: { keyA1: [{ keyA11: 'value', keyA12: 'value' }], keyA2: 'value' }, 757 | }); 758 | }); 759 | }); 760 | }); 761 | -------------------------------------------------------------------------------- /src/morphism.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module morphism 3 | */ 4 | import { zipObject, isUndefined, get, set, SCHEMA_OPTIONS_SYMBOL } from './helpers'; 5 | import { Schema, StrictSchema, Constructable, SourceFromSchema, Mapper, DestinationFromSchema } from './types'; 6 | import { MorphismSchemaTree, createSchema, SchemaOptions } from './MorphismTree'; 7 | import { MorphismRegistry, IMorphismRegistry } from './MorphismRegistry'; 8 | import { decorator } from './MorphismDecorator'; 9 | import { Reporter, reporter as defaultReporter, Formatter, targetHasErrors, ValidationErrors } from './validation/reporter'; 10 | import { Validation, IValidation } from './validation/Validation'; 11 | import { ValidatorError } from './validation/validators/ValidatorError'; 12 | import { Rule } from './validation/validators/types'; 13 | 14 | /** 15 | * Low Level transformer function. 16 | * Take a plain object as input and transform its values using a specified schema. 17 | * @param {Object} object 18 | * @param {Map | Map} schema Transformation schema 19 | * @param {Array} items Items to be forwarded to Actions 20 | * @param {} objectToCompute Created tranformed object of a given type 21 | */ 22 | function transformValuesFromObject( 23 | object: Source, 24 | tree: MorphismSchemaTree, 25 | items: Source[], 26 | objectToCompute: Target 27 | ) { 28 | const options = tree.schemaOptions; 29 | const transformChunks = []; 30 | 31 | for (const node of tree.traverseBFS()) { 32 | const { preparedAction, targetPropertyPath } = node.data; 33 | if (preparedAction) 34 | transformChunks.push({ 35 | targetPropertyPath, 36 | preparedAction: preparedAction({ object, objectToCompute, items }), 37 | }); 38 | } 39 | 40 | return transformChunks.reduce((finalObject, chunk) => { 41 | const undefinedValueCheck = (destination: any, source: any) => { 42 | // Take the Object class value property if the incoming property is undefined 43 | if (isUndefined(source)) { 44 | if (!isUndefined(destination)) { 45 | return destination; 46 | } else { 47 | return; // No Black Magic Fuckery here, if the source and the destination are undefined, we don't do anything 48 | } 49 | } else { 50 | return source; 51 | } 52 | }; 53 | 54 | const finalValue = undefinedValueCheck(get(finalObject, chunk.targetPropertyPath), chunk.preparedAction); 55 | if (finalValue === undefined) { 56 | // strip undefined values 57 | if (options && options.undefinedValues && options.undefinedValues.strip) { 58 | if (options.undefinedValues.default) { 59 | set(finalObject, chunk.targetPropertyPath, options.undefinedValues.default(finalObject, chunk.targetPropertyPath)); 60 | } 61 | } else { 62 | // do not strip undefined values 63 | set(finalObject, chunk.targetPropertyPath, finalValue); 64 | } 65 | checkIfValidationShouldThrow(options, finalObject); 66 | return finalObject; 67 | } else { 68 | set(finalObject, chunk.targetPropertyPath, finalValue); 69 | checkIfValidationShouldThrow(options, finalObject); 70 | return finalObject; 71 | } 72 | }, objectToCompute); 73 | } 74 | 75 | function checkIfValidationShouldThrow(options: SchemaOptions, finalObject: Target) { 76 | if (options && options.validation && options.validation.throw) { 77 | if (targetHasErrors(finalObject)) { 78 | let errors: ValidationErrors | null; 79 | if (options.validation.reporter) { 80 | const reporter = options.validation.reporter; 81 | errors = reporter.extractErrors(finalObject); 82 | } else { 83 | errors = defaultReporter.extractErrors(finalObject); 84 | } 85 | if (errors) { 86 | throw errors; 87 | } 88 | } 89 | } 90 | } 91 | 92 | function transformItems>(schema: TSchema, type?: Constructable) { 93 | const options = MorphismSchemaTree.getSchemaOptions(schema); 94 | let tree: MorphismSchemaTree; 95 | if (type && options.class && options.class.automapping) { 96 | const finalSchema = getSchemaForClass(type, schema); 97 | tree = new MorphismSchemaTree(finalSchema); 98 | } else { 99 | tree = new MorphismSchemaTree(schema); 100 | } 101 | 102 | const mapper: Mapper = (source: any) => { 103 | if (!source) { 104 | return source; 105 | } 106 | if (Array.isArray(source)) { 107 | return source.map(obj => { 108 | if (type) { 109 | const classObject = new type(); 110 | return transformValuesFromObject(obj, tree, source, classObject); 111 | } else { 112 | const jsObject = {}; 113 | return transformValuesFromObject(obj, tree, source, jsObject); 114 | } 115 | }); 116 | } else { 117 | const object = source; 118 | if (type) { 119 | const classObject = new type(); 120 | return transformValuesFromObject(object, tree, [object], classObject); 121 | } else { 122 | const jsObject = {}; 123 | return transformValuesFromObject(object, tree, [object], jsObject); 124 | } 125 | } 126 | }; 127 | 128 | return mapper; 129 | } 130 | 131 | function getSchemaForClass(type: Constructable, baseSchema: Schema): Schema { 132 | let typeFields = Object.keys(new type()); 133 | let defaultSchema = zipObject(typeFields, typeFields); 134 | let finalSchema = Object.assign(defaultSchema, baseSchema); 135 | return finalSchema; 136 | } 137 | 138 | /** 139 | * Currying function that either outputs a mapping function or the transformed data. 140 | * 141 | * @example 142 | * ```js 143 | * 144 | // => Outputs a function when only a schema is provided 145 | const fn = morphism(schema); 146 | const result = fn(data); 147 | 148 | // => Outputs the transformed data when a schema and the input data is provided 149 | const result = morphism(schema, data); 150 | 151 | // => Outputs the transformed data as an ES6 Class Object when a schema, the input data and an ES6 Class are provided 152 | const result = morphism(schema, data, Foo); 153 | // result is type of Foo 154 | * ``` 155 | * @param {Schema} schema Structure-preserving object from a source data towards a target data 156 | * @param {} items Object or Collection to be mapped 157 | * @param {} type 158 | * 159 | */ 160 | function morphism< 161 | TSchema = Schema, SourceFromSchema>, 162 | Source extends SourceFromSchema = SourceFromSchema 163 | >(schema: TSchema, data: Source[]): DestinationFromSchema[]; 164 | 165 | function morphism< 166 | TSchema = Schema, SourceFromSchema>, 167 | Source extends SourceFromSchema = SourceFromSchema 168 | >(schema: TSchema, data: Source): DestinationFromSchema; 169 | 170 | function morphism, SourceFromSchema>>(schema: TSchema): Mapper; // morphism({}) => mapper(S) => T 171 | 172 | function morphism( 173 | schema: TSchema, 174 | items: null, 175 | type: Constructable 176 | ): Mapper; // morphism({}, null, T) => mapper(S) => T 177 | 178 | function morphism< 179 | TSchema = Schema, SourceFromSchema>, 180 | Target = never, 181 | Source extends SourceFromSchema = SourceFromSchema 182 | >(schema: TSchema, items: Source, type: Constructable): Target; // morphism({}, {}, T) => T 183 | 184 | function morphism< 185 | TSchema = Schema, SourceFromSchema>, 186 | Target = never, 187 | Source extends SourceFromSchema = SourceFromSchema 188 | >(schema: TSchema, items: Source[], type: Constructable): Target[]; // morphism({}, [], T) => T[] 189 | 190 | function morphism>( 191 | schema: TSchema, 192 | items?: SourceFromSchema | null, 193 | type?: Constructable 194 | ) { 195 | switch (arguments.length) { 196 | case 1: { 197 | return transformItems(schema); 198 | } 199 | case 2: { 200 | return transformItems(schema)(items); 201 | } 202 | case 3: { 203 | if (type) { 204 | if (items !== null) return transformItems(schema, type)(items); // TODO: deprecate this option morphism(schema,null,Type) in favor of createSchema({},options={class: Type}) 205 | return transformItems(schema, type); 206 | } else { 207 | throw new Error(`When using morphism(schema, items, type), type should be defined but value received is ${type}`); 208 | } 209 | } 210 | } 211 | } 212 | 213 | // Decorators 214 | /** 215 | * Function Decorator transforming the return value of the targeted Function using the provided Schema and/or Type 216 | * 217 | * @param {Schema} schema Structure-preserving object from a source data towards a target data 218 | * @param {Constructable} [type] Target Class Type 219 | */ 220 | export function morph(schema: Schema, type?: Constructable) { 221 | const mapper = transformItems(schema, type); 222 | return decorator(mapper); 223 | } 224 | /** 225 | * Function Decorator transforming the return value of the targeted Function to JS Object(s) using the provided Schema 226 | * 227 | * @param {StrictSchema} schema Structure-preserving object from a source data towards a target data 228 | */ 229 | export function toJSObject(schema: StrictSchema) { 230 | const mapper = transformItems(schema); 231 | return decorator(mapper); 232 | } 233 | /** 234 | * Function Decorator transforming the return value of the targeted Function using the provided Schema and Class Type 235 | * 236 | * @param {Schema} schema Structure-preserving object from a source data towards a target data 237 | * @param {Constructable} [type] Target Class Type 238 | */ 239 | export function toClassObject(schema: Schema, type: Constructable) { 240 | const mapper = transformItems(schema, type); 241 | return decorator(mapper); 242 | } 243 | 244 | // Registry 245 | const morphismRegistry = new MorphismRegistry(); 246 | const morphismMixin: typeof morphism & any = morphism; 247 | morphismMixin.register = (t: any, s: any) => morphismRegistry.register(t, s); 248 | morphismMixin.map = (t: any, d: any) => morphismRegistry.map(t, d); 249 | morphismMixin.getMapper = (t: any) => morphismRegistry.getMapper(t); 250 | morphismMixin.setMapper = (t: any, s: any) => morphismRegistry.setMapper(t, s); 251 | morphismMixin.deleteMapper = (t: any) => morphismRegistry.deleteMapper(t); 252 | morphismMixin.mappers = morphismRegistry.mappers; 253 | 254 | const Morphism: typeof morphism & IMorphismRegistry = morphismMixin; 255 | 256 | export { 257 | morphism, 258 | createSchema, 259 | Schema, 260 | StrictSchema, 261 | SchemaOptions, 262 | Mapper, 263 | SCHEMA_OPTIONS_SYMBOL, 264 | Reporter, 265 | defaultReporter as reporter, 266 | Formatter, 267 | Validation, 268 | Rule, 269 | ValidatorError, 270 | IValidation, 271 | }; 272 | export default Morphism; 273 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { SCHEMA_OPTIONS_SYMBOL, SchemaOptions, ValidatorError } from './morphism'; 2 | 3 | /** 4 | * A structure-preserving object from a source data towards a target data. 5 | * 6 | * The keys of the schema match the desired destination structure. 7 | * Each value corresponds to an Action applied by Morphism when iterating over the input data 8 | * @example 9 | * ```typescript 10 | * 11 | * const input = { 12 | * foo: { 13 | * baz: 'value1' 14 | * } 15 | * }; 16 | * 17 | * const schema: Schema = { 18 | * bar: 'foo', // ActionString 19 | * qux: ['foo', 'foo.baz'], // ActionAggregator 20 | * quux: (iteratee, source, destination) => { // ActionFunction 21 | * return iteratee.foo; 22 | * }, 23 | * corge: { // ActionSelector 24 | * path: 'foo.baz', 25 | * fn: (propertyValue, source) => { 26 | * return propertyValue; 27 | * } 28 | * } 29 | * }; 30 | * 31 | * morphism(schema, input); 32 | * ``` 33 | */ 34 | export type StrictSchema = { 35 | /** `destinationProperty` is the name of the property of the target object you want to produce */ 36 | [destinationProperty in keyof Target]: 37 | | ActionString 38 | | { 39 | (iteratee: Source, source: Source[], target: Target[destinationProperty]): Target[destinationProperty]; 40 | } 41 | | ActionAggregator 42 | | ActionSelector 43 | | StrictSchema; 44 | } & { [SCHEMA_OPTIONS_SYMBOL]?: SchemaOptions }; 45 | export type Schema = { 46 | /** `destinationProperty` is the name of the property of the target object you want to produce */ 47 | [destinationProperty in keyof Target]?: 48 | | ActionString 49 | | { 50 | (iteratee: Source, source: Source[], target: Target[destinationProperty]): Target[destinationProperty]; 51 | } 52 | | ActionAggregator 53 | | ActionSelector 54 | | Schema; 55 | } & { [SCHEMA_OPTIONS_SYMBOL]?: SchemaOptions }; 56 | 57 | export type Actions = ActionFunction | ActionAggregator | ActionString | ActionSelector; 58 | 59 | /** 60 | * @interface ActionFunction 61 | * @description A Function invoked per iteration 62 | * @param {S} iteratee The current element to transform 63 | * @param {S|S[]} source The source input to transform 64 | * @param {D} target The current element transformed 65 | * @typeparam D Destination / Target type 66 | * @typeparam S Source / Input type 67 | * @typeparam R Inferred result type 68 | * @example 69 | * ```typescript 70 | * 71 | * const source = { 72 | * foo: { 73 | * bar: 'bar' 74 | * } 75 | * }; 76 | * let schema = { 77 | * bar: iteratee => { 78 | * // Apply a function over the source propery 79 | * return iteratee.foo.bar; 80 | * } 81 | * }; 82 | * 83 | * morphism(schema, source); 84 | * //=> { bar: 'bar' } 85 | * ``` 86 | * 87 | */ 88 | export interface ActionFunction { 89 | (iteratee: S, source: S[], target: D): R; 90 | } 91 | 92 | /** 93 | * @description A String path that indicates where to find the property in the source input 94 | * 95 | * @example 96 | * ```typescript 97 | * 98 | * const source = { 99 | * foo: 'baz', 100 | * bar: ['bar', 'foo'], 101 | * baz: { 102 | * qux: 'bazqux' 103 | * } 104 | * }; 105 | * const schema = { 106 | * foo: 'foo', // Simple Projection 107 | * bar: 'bar[0]', // Grab a value from an array 108 | * bazqux: 'baz.qux' // Grab a value from a nested property, 109 | * }; 110 | * 111 | * morphism(schema, source); 112 | * //=> { foo: 'baz', bar: 'bar', bazqux: 'bazqux' } 113 | * ``` 114 | * 115 | */ 116 | export type ActionString = string | keyof Source; // TODO: ActionString should support string and string[] for deep properties 117 | 118 | /** 119 | * An Array of String that allows to perform a function over source property 120 | * 121 | * @example 122 | * ```typescript 123 | * 124 | * const source = { 125 | * foo: 'foo', 126 | * bar: 'bar' 127 | * }; 128 | * let schema = { 129 | * fooAndBar: ['foo', 'bar'] // Grab these properties into fooAndBar 130 | * }; 131 | * 132 | * morphism(schema, source); 133 | * //=> { fooAndBar: { foo: 'foo', bar: 'bar' } } 134 | * ``` 135 | */ 136 | export type ActionAggregator = T extends object ? (keyof T)[] | string[] : string[]; 137 | /** 138 | * @interface ActionSelector 139 | * @typeparam Source Source/Input Type 140 | * @typeparam R Result Type 141 | * 142 | * @description An Object that allows to perform a function over a source property's value 143 | * 144 | * @example 145 | * ```typescript 146 | * 147 | * const source = { 148 | * foo: { 149 | * bar: 'bar' 150 | * } 151 | * }; 152 | * let schema = { 153 | * barqux: { 154 | * path: 'foo.bar', 155 | * fn: value => `${value}qux` // Apply a function over the source property's value 156 | * } 157 | * }; 158 | * 159 | * morphism(schema, source); 160 | * //=> { barqux: 'barqux' } 161 | *``` 162 | * 163 | */ 164 | export interface ActionSelector { 165 | path?: ActionString | ActionAggregator; 166 | fn?: (fieldValue: any, object: Source, items: Source, objectToCompute: Target) => Target[TargetProperty]; 167 | validation?: ValidateFunction; 168 | } 169 | 170 | export interface ValidatorValidateResult { 171 | value: any; 172 | error?: ValidatorError; 173 | } 174 | export type ValidateFunction = (input: { value: any }) => ValidatorValidateResult; 175 | 176 | export interface Constructable { 177 | new (...args: any[]): T; 178 | } 179 | 180 | export type SourceFromSchema = T extends StrictSchema | Schema ? U : never; 181 | export type DestinationFromSchema = T extends StrictSchema | Schema ? U : never; 182 | 183 | export type ResultItem = DestinationFromSchema; 184 | 185 | /** 186 | * Function to map an Input source towards a Target. Input can be a single object, or a collection of objects 187 | * @function 188 | * @typeparam TSchema Schema 189 | * @typeparam TResult Result Type 190 | */ 191 | export interface Mapper> { 192 | (data?: SourceFromSchema[] | null): TResult[]; 193 | (data?: SourceFromSchema | null): TResult; 194 | } 195 | -------------------------------------------------------------------------------- /src/typescript.spec.ts: -------------------------------------------------------------------------------- 1 | import Morphism, { morphism, StrictSchema, Schema, createSchema } from './morphism'; 2 | 3 | describe('Typescript', () => { 4 | describe('Registry Type Checking', () => { 5 | it('Should return a Mapper when using Register', () => { 6 | class Foo { 7 | foo: string; 8 | } 9 | const schema = { foo: 'bar' }; 10 | const source = { bar: 'value' }; 11 | const mapper = Morphism.register(Foo, schema); 12 | 13 | expect(mapper(source).foo).toEqual('value'); 14 | expect(mapper([source][0]).foo).toEqual('value'); 15 | }); 16 | }); 17 | 18 | describe('Schema Type Checking', () => { 19 | it('Should allow to type the Schema', () => { 20 | interface IFoo { 21 | foo: string; 22 | bar: number; 23 | } 24 | const schema: Schema = { foo: 'qux' }; 25 | const source = { qux: 'foo' }; 26 | const target = morphism(schema, source); 27 | 28 | expect(target.foo).toEqual(source.qux); 29 | }); 30 | 31 | it('Should allow to use a strict Schema', () => { 32 | interface IFoo { 33 | foo: string; 34 | bar: number; 35 | } 36 | const schema: StrictSchema = { foo: 'qux', bar: () => 1 }; 37 | const source = { qux: 'foo' }; 38 | const target = morphism(schema, source); 39 | 40 | expect(target.foo).toEqual(source.qux); 41 | expect(target.bar).toEqual(1); 42 | }); 43 | 44 | it('should accept 2 generic parameters on StrictSchema', () => { 45 | interface Source { 46 | inputA: string; 47 | inputB: string; 48 | inputC: string; 49 | } 50 | interface Destination { 51 | fooA: string; 52 | fooB: string; 53 | fooC: string; 54 | } 55 | const schema: StrictSchema = { 56 | fooA: 'inputA', 57 | fooB: ({ inputB }) => inputB, 58 | fooC: 'inputC', 59 | }; 60 | 61 | const mapper = morphism(schema); 62 | 63 | expect(mapper({ inputA: 'test', inputB: 'test2', inputC: 'test3' })).toEqual({ 64 | fooA: 'test', 65 | fooB: 'test2', 66 | fooC: 'test3', 67 | }); 68 | }); 69 | 70 | it('should accept 2 generic parameters on Schema', () => { 71 | interface Source2 { 72 | inputA: string; 73 | } 74 | const schema: Schema<{ foo: string }, Source2> = { 75 | foo: 'inputA', 76 | }; 77 | morphism(schema, { inputA: 'test' }); 78 | morphism(schema, [{ inputA: '' }]); 79 | }); 80 | 81 | it('should accept 2 generic parameters on Schema', () => { 82 | interface S { 83 | s1: string; 84 | } 85 | interface D { 86 | d1: string; 87 | } 88 | const schema: StrictSchema = { 89 | d1: 's1', 90 | }; 91 | const a = morphism(schema)([{ s1: 'test' }]); 92 | const itemA = a.shift(); 93 | expect(itemA).toBeDefined(); 94 | if (itemA) { 95 | itemA.d1; 96 | } 97 | morphism(schema, { s1: 'teest' }).d1.toString(); 98 | const b = morphism(schema, [{ s1: 'teest' }]); 99 | const itemB = b.shift(); 100 | expect(itemB).toBeDefined(); 101 | if (itemB) { 102 | itemB.d1; 103 | } 104 | morphism(schema, [{ s1: 'teest' }]); 105 | morphism(schema, [{ s1: 'test' }]); 106 | }); 107 | 108 | it('should not fail with typescript', () => { 109 | interface S { 110 | s1: string; 111 | } 112 | interface D { 113 | d1: string; 114 | } 115 | 116 | interface Source { 117 | boring_api_field: number; 118 | } 119 | const source: Source[] = [{ boring_api_field: 2 }]; 120 | 121 | interface Destination { 122 | namingIsHard: string; 123 | } 124 | 125 | const a = morphism>({ namingIsHard: 'boring_api_field' }, [{ boring_api_field: 2 }]); 126 | const itemA = a.pop(); 127 | expect(itemA).toBeDefined(); 128 | if (itemA) { 129 | itemA.namingIsHard; 130 | } 131 | 132 | const b = morphism>({ namingIsHard: 'boring_api_field' }, { boring_api_field: 2 }); 133 | b.namingIsHard; 134 | 135 | const c = morphism>({ namingIsHard: 'boring_api_field' }, [{ boring_api_field: 2 }]); 136 | const itemC = c.pop(); 137 | expect(itemC).toBeDefined(); 138 | if (itemC) { 139 | itemC.namingIsHard; 140 | } 141 | 142 | const d = morphism({ namingIsHard: 'boring_api_field' }, { boring_api_field: 2 }); 143 | d.namingIsHard; 144 | 145 | morphism({ namingIsHard: 'boring_api_field' }); 146 | morphism>({ 147 | namingIsHard: 'boring_api_field', 148 | })({ boring_api_field: 2 }); 149 | const e = morphism>({ 150 | namingIsHard: 'boring_api_field', 151 | })([{ boring_api_field: 2 }]); 152 | const itemE = e.pop(); 153 | expect(itemE).toBeDefined(); 154 | if (itemE) { 155 | itemE.namingIsHard; 156 | } 157 | 158 | interface S1 { 159 | _a: string; 160 | } 161 | interface D1 { 162 | a: string; 163 | } 164 | 165 | morphism>({ a: ({ _a }) => _a.toString() }); 166 | morphism>({ a: ({ _a }) => _a.toString() }); 167 | }); 168 | 169 | it('shoud infer result type from source when a class is provided', () => { 170 | class Source { 171 | constructor(public id: number, public ugly_field: string) {} 172 | } 173 | 174 | class Destination { 175 | constructor(public id: number, public field: string) {} 176 | } 177 | 178 | const source = [new Source(1, 'abc'), new Source(1, 'def')]; 179 | 180 | const schema: StrictSchema = { 181 | id: 'id', 182 | field: 'ugly_field', 183 | }; 184 | const expected = [new Destination(1, 'abc'), new Destination(1, 'def')]; 185 | 186 | const result = morphism(schema, source, Destination); 187 | result.forEach((item, idx) => { 188 | expect(item).toEqual(expected[idx]); 189 | }); 190 | }); 191 | 192 | it('should accept union types as Target', () => { 193 | const schema = createSchema<{ a: string } | { a: string; b: string }, { c: string }>({ 194 | a: ({ c }) => c, 195 | }); 196 | 197 | expect(morphism(schema, { c: 'result' }).a).toEqual('result'); 198 | }); 199 | }); 200 | 201 | describe('Morphism Function Type Checking', () => { 202 | it('should infer target type from array input', () => { 203 | interface Source { 204 | ID: number; 205 | } 206 | 207 | interface Destination { 208 | id: number; 209 | } 210 | 211 | const rows: Array = [{ ID: 1234 }]; 212 | 213 | const schema: StrictSchema = { id: 'ID' }; 214 | expect(morphism(schema, rows)).toBeDefined(); 215 | expect(morphism(schema, rows)[0].id).toEqual(1234); 216 | }); 217 | }); 218 | 219 | describe('Selector Action', () => { 220 | it('should match return type of fn with target property', () => { 221 | interface Source { 222 | foo: string; 223 | } 224 | 225 | interface Target { 226 | foo: number; 227 | } 228 | 229 | const schema: StrictSchema = { 230 | foo: { 231 | path: 'foo', 232 | fn: val => { 233 | return Number(val); 234 | }, 235 | }, 236 | }; 237 | const source: Source = { foo: '1' }; 238 | expect(morphism(schema, source)).toEqual({ foo: 1 }); 239 | }); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /src/utils-test.ts: -------------------------------------------------------------------------------- 1 | export class User { 2 | firstName?: string; 3 | lastName?: string; 4 | phoneNumber?: string; 5 | type?: string; 6 | 7 | groups: Array = new Array(); 8 | 9 | constructor(firstName?: string, lastName?: string, phoneNumber?: string) { 10 | this.firstName = firstName; 11 | this.lastName = lastName; 12 | this.phoneNumber = phoneNumber; 13 | 14 | this.type = 'User'; // Use to test default value scenario 15 | } 16 | 17 | /** 18 | * Use to test runtime access to the created object context 19 | * @param {} group 20 | * @param {} externalTrigger 21 | */ 22 | addToGroup(group: any, externalTrigger: any) { 23 | this.groups.push(group); 24 | externalTrigger(this, group); 25 | } 26 | } 27 | 28 | export interface MockData { 29 | firstName: string; 30 | lastName: string; 31 | age: number; 32 | address: { 33 | streetAddress: string; 34 | city: string; 35 | state: string; 36 | postalCode: string; 37 | }; 38 | phoneNumber: [ 39 | { 40 | type: string; 41 | number: string; 42 | }, 43 | { 44 | type: string; 45 | number: string; 46 | } 47 | ]; 48 | } 49 | -------------------------------------------------------------------------------- /src/validation/Validation.spec.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from './Validation'; 2 | import { createSchema } from '../MorphismTree'; 3 | import { morphism, reporter, ValidatorError } from '../morphism'; 4 | import { ValidationError, defaultFormatter } from './reporter'; 5 | import { isEmail } from 'validator'; 6 | import { isFunction, isString } from '../helpers'; 7 | import { Rule } from './validators/types'; 8 | import { LinkedList } from './validators/LinkedList'; 9 | import { ValidatorValidateResult, ValidateFunction } from '../types'; 10 | 11 | declare module './Validation' { 12 | interface IValidation { 13 | test: typeof TestValidator; 14 | } 15 | } 16 | function TestValidator() { 17 | let list = new LinkedList>({ 18 | name: 'array', 19 | expect: input => `Expected value to be an but received <${input.value}>`, 20 | validate: input => Array.isArray(input.value), 21 | }); 22 | 23 | const validate: ValidateFunction = input => { 24 | const result: ValidatorValidateResult = input; 25 | const iterator = list.values(); 26 | let current = iterator.next(); 27 | while (!result.error && !current.done) { 28 | const rule = current.value; 29 | if (!rule.validate(result)) { 30 | result.error = new ValidatorError({ 31 | expect: isString(rule.expect) ? rule.expect : rule.expect(result), 32 | value: result.value, 33 | }); 34 | } 35 | current = iterator.next(); 36 | } 37 | return result; 38 | }; 39 | 40 | const rules = { 41 | max: (value: any) => { 42 | list.append({ 43 | name: 'max', 44 | expect: input => `Expected length of array to be less or equal than <${value}> but received <${input.value}>`, 45 | validate: input => input.value <= value, 46 | }); 47 | return api; 48 | }, 49 | }; 50 | const api = Object.assign(validate, rules); 51 | return api; 52 | } 53 | Validation.addValidator('test', TestValidator); 54 | 55 | describe('Validation', () => { 56 | it('should allow to add a custom validator', () => { 57 | interface S { 58 | s1: string[]; 59 | } 60 | interface T { 61 | t1: string[]; 62 | } 63 | 64 | const length = 2; 65 | const source: S = { s1: ['a', 'b', 'c'] }; 66 | const schema = createSchema({ 67 | t1: { path: 's1', validation: Validation.test().max(length) }, 68 | }); 69 | 70 | const result = morphism(schema, source); 71 | const error = new ValidationError({ 72 | targetProperty: 't1', 73 | innerError: new ValidatorError({ 74 | expect: `Expected length of array to be less or equal than <${length}> but received <${source.s1}>`, 75 | value: source.s1, 76 | }), 77 | }); 78 | const message = defaultFormatter(error); 79 | 80 | const errors = reporter.report(result); 81 | expect(errors).not.toBeNull(); 82 | if (errors) { 83 | expect(errors.length).toEqual(1); 84 | expect(errors[0]).toBe(message); 85 | } 86 | }); 87 | 88 | it('should allow to use validator.js as a third party validation library', () => { 89 | interface S { 90 | s1: string; 91 | } 92 | interface T { 93 | t1: string; 94 | } 95 | 96 | const source: S = { s1: 'email@gmail.com' }; 97 | const schema = createSchema({ 98 | t1: { 99 | path: 's1', 100 | validation: input => { 101 | if (isEmail(input.value)) { 102 | return { value: input.value }; 103 | } else { 104 | return { 105 | value: input.value, 106 | error: new ValidatorError({ 107 | value: input.value, 108 | expect: `Expected value to be an but received <${input.value}>`, 109 | }), 110 | }; 111 | } 112 | }, 113 | }, 114 | }); 115 | const expected: T = { t1: source.s1 }; 116 | const result = morphism(schema, source); 117 | expect(result).toEqual(expected); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/validation/Validation.ts: -------------------------------------------------------------------------------- 1 | import { StringValidator, BooleanValidator, NumberValidator } from './validators'; 2 | 3 | type ValidatorsMap = Omit; 4 | type Validators = IValidation[keyof ValidatorsMap]; 5 | type ValidatorsKeys = keyof ValidatorsMap; 6 | 7 | export interface IValidation { 8 | string: typeof StringValidator; 9 | number: typeof NumberValidator; 10 | boolean: typeof BooleanValidator; 11 | addValidator(name: T, validator: U): void; 12 | } 13 | 14 | const handler: ProxyHandler = { 15 | get: (object, prop: ValidatorsKeys & keyof IValidation) => { 16 | if (prop in object) { 17 | return object[prop]; 18 | } else if (validators.has(prop)) { 19 | return validators.get(prop); 20 | } else { 21 | throw new Error(`The validator ${prop}() does not exist. Did you forget to call Validation.addValidator(name, validator)`); 22 | } 23 | }, 24 | }; 25 | 26 | const validators = new Map(); 27 | const Validation = new Proxy( 28 | { 29 | addValidator: (name, validator) => { 30 | validators.set(name, validator); 31 | }, 32 | } as IValidation, 33 | handler 34 | ); 35 | Validation.addValidator('string', StringValidator); 36 | Validation.addValidator('number', NumberValidator); 37 | Validation.addValidator('boolean', BooleanValidator); 38 | 39 | export { Validation }; 40 | -------------------------------------------------------------------------------- /src/validation/reporter.spec.ts: -------------------------------------------------------------------------------- 1 | import { morphism, createSchema, Reporter } from '../morphism'; 2 | import { defaultFormatter, reporter, ValidationError, Formatter } from './reporter'; 3 | import { Validation } from './Validation'; 4 | import { ValidatorError } from './validators/ValidatorError'; 5 | 6 | describe('Reporter', () => { 7 | describe('Formatter', () => { 8 | it('should format a ValidationError to human readable message', () => { 9 | const targetProperty = 'targetProperty'; 10 | const value = undefined; 11 | const error = new ValidationError({ 12 | targetProperty, 13 | innerError: new ValidatorError({ 14 | expect: 'message', 15 | value, 16 | }), 17 | }); 18 | const message = defaultFormatter(error); 19 | expect(message).toEqual(`Invalid value supplied at property <${targetProperty}>. Reason: ${error.innerError.expect}`); 20 | }); 21 | }); 22 | describe('Validation', () => { 23 | it('should add multiple errors', () => { 24 | interface S { 25 | s1: boolean; 26 | s2: number; 27 | } 28 | interface T { 29 | t1: boolean; 30 | t2: number; 31 | } 32 | 33 | const schema = createSchema({ 34 | t1: { path: 's1', fn: val => val, validation: Validation.boolean() }, 35 | t2: { path: 's2', fn: val => val, validation: Validation.number() }, 36 | }); 37 | const result = morphism(schema, JSON.parse('{}')); 38 | const errors = reporter.report(result); 39 | const error1 = new ValidationError({ 40 | targetProperty: 't1', 41 | innerError: new ValidatorError({ 42 | expect: `Expected value to be a but received <${undefined}>`, 43 | value: undefined, 44 | }), 45 | }); 46 | const error2 = new ValidationError({ 47 | targetProperty: 't2', 48 | innerError: new ValidatorError({ 49 | expect: `Expected value to be a but received <${undefined}>`, 50 | value: undefined, 51 | }), 52 | }); 53 | const message1 = defaultFormatter(error1); 54 | const message2 = defaultFormatter(error2); 55 | expect(errors).not.toBeNull(); 56 | if (errors) { 57 | expect(errors[0]).toBe(message1); 58 | expect(errors[1]).toBe(message2); 59 | } 60 | }); 61 | 62 | it('should throw an exception when trying to use a rule more than once', () => { 63 | expect(() => { 64 | Validation.string() 65 | .max(1) 66 | .max(1)({ value: 'a' }); 67 | }).toThrow('Rule max has already been used'); 68 | }); 69 | 70 | it('should allow to use a reporter with a custom formatter', () => { 71 | interface Target { 72 | t1: string; 73 | } 74 | const formatter: Formatter = error => { 75 | const { innerError, targetProperty } = error; 76 | return `${innerError.expect} for property ${targetProperty}`; 77 | }; 78 | const reporter = new Reporter(formatter); 79 | 80 | const schema = createSchema({ 81 | t1: { path: 's1', validation: Validation.string() }, 82 | }); 83 | const result = morphism(schema, { s1: 1234 }); 84 | const error = new ValidationError({ 85 | targetProperty: 't1', 86 | innerError: new ValidatorError({ 87 | value: 1234, 88 | expect: `Expected value to be a but received <${1234}>`, 89 | }), 90 | }); 91 | 92 | const message = formatter(error); 93 | const errors = reporter.report(result); 94 | expect(errors).not.toBeNull(); 95 | if (errors) { 96 | expect(errors[0]).toBe(message); 97 | } 98 | }); 99 | 100 | it('should allow to use a reporter with a custom formatter via schema options', () => { 101 | interface Target { 102 | t1: string; 103 | } 104 | const formatter: Formatter = error => { 105 | const { innerError, targetProperty } = error; 106 | return `${innerError.expect} for property ${targetProperty}`; 107 | }; 108 | const reporter = new Reporter(formatter); 109 | 110 | const schema = createSchema( 111 | { t1: { path: 's1', validation: Validation.string() } }, 112 | { validation: { throw: true, reporter } } 113 | ); 114 | 115 | const error = new ValidationError({ 116 | targetProperty: 't1', 117 | innerError: new ValidatorError({ 118 | value: 1234, 119 | expect: `Expected value to be a but received <${1234}>`, 120 | }), 121 | }); 122 | const message = formatter(error); 123 | expect(() => { 124 | morphism(schema, { s1: 1234 }); 125 | }).toThrow(message); 126 | }); 127 | 128 | describe('string', () => { 129 | it('should report error on string undefined', () => { 130 | interface S { 131 | s1: string; 132 | } 133 | interface T { 134 | t1: string; 135 | } 136 | 137 | const schema = createSchema({ 138 | t1: { path: 's1', fn: val => val, validation: Validation.string() }, 139 | }); 140 | const result = morphism(schema, JSON.parse('{}')); 141 | const error = new ValidationError({ 142 | targetProperty: 't1', 143 | innerError: new ValidatorError({ 144 | expect: `Expected value to be a but received <${undefined}>`, 145 | value: undefined, 146 | }), 147 | }); 148 | const message = defaultFormatter(error); 149 | const errors = reporter.report(result); 150 | expect(errors).not.toBeNull(); 151 | if (errors) { 152 | expect(errors.length).toEqual(1); 153 | expect(errors[0]).toBe(message); 154 | } 155 | }); 156 | 157 | it('should report error when string max length is not met', () => { 158 | interface S { 159 | s1: string; 160 | } 161 | interface T { 162 | t1: string; 163 | } 164 | 165 | const schema = createSchema({ 166 | t1: { fn: value => value.s1, validation: Validation.string().max(3) }, 167 | }); 168 | const result = morphism(schema, { s1: 'value' }); 169 | const error = new ValidationError({ 170 | targetProperty: 't1', 171 | innerError: new ValidatorError({ 172 | expect: `Expected value to be less or equal than <3> but received `, 173 | value: 'value', 174 | }), 175 | }); 176 | const message = defaultFormatter(error); 177 | const errors = reporter.report(result); 178 | expect(errors).not.toBeNull(); 179 | if (errors) { 180 | expect(errors.length).toEqual(1); 181 | expect(errors[0]).toBe(message); 182 | } 183 | }); 184 | 185 | it('should report error when string min length is not met', () => { 186 | interface S { 187 | s1: string; 188 | } 189 | interface T { 190 | t1: string; 191 | } 192 | 193 | const schema = createSchema({ 194 | t1: { fn: value => value.s1, validation: Validation.string().min(3) }, 195 | }); 196 | const result = morphism(schema, { s1: 'a' }); 197 | const error = new ValidationError({ 198 | targetProperty: 't1', 199 | innerError: new ValidatorError({ 200 | expect: `Expected value to be greater or equal than <3> but received `, 201 | value: 'a', 202 | }), 203 | }); 204 | const message = defaultFormatter(error); 205 | const errors = reporter.report(result); 206 | expect(errors).not.toBeNull(); 207 | if (errors) { 208 | expect(errors.length).toEqual(1); 209 | expect(errors[0]).toBe(message); 210 | } 211 | }); 212 | 213 | it('should return the value when the validation pass', () => { 214 | interface S { 215 | s1: string; 216 | } 217 | interface T { 218 | t1: string; 219 | } 220 | 221 | const schema = createSchema({ 222 | t1: { 223 | fn: value => value.s1, 224 | validation: Validation.string() 225 | .min(1) 226 | .max(3), 227 | }, 228 | }); 229 | const result = morphism(schema, { s1: 'aaa' }); 230 | const errors = reporter.report(result); 231 | expect(errors).toBeNull(); 232 | expect(result.t1).toBe('aaa'); 233 | }); 234 | 235 | it('should report an error when string length is not met', () => { 236 | interface S { 237 | s1: string; 238 | } 239 | interface T { 240 | t1: string; 241 | } 242 | 243 | const LENGTH = 1; 244 | const schema = createSchema({ 245 | t1: { 246 | fn: value => value.s1, 247 | validation: Validation.string().size(LENGTH), 248 | }, 249 | }); 250 | const result = morphism(schema, { s1: 'aaa' }); 251 | const error = new ValidationError({ 252 | targetProperty: 't1', 253 | innerError: new ValidatorError({ 254 | expect: `Expected value to be length of <${LENGTH}> but received `, 255 | value: 'aaa', 256 | }), 257 | }); 258 | const message = defaultFormatter(error); 259 | const errors = reporter.report(result); 260 | expect(errors).not.toBeNull(); 261 | if (errors) { 262 | expect(errors.length).toEqual(1); 263 | expect(errors[0]).toBe(message); 264 | } 265 | }); 266 | 267 | it('should report an error when string does not match specified regex', () => { 268 | interface S { 269 | s1: string; 270 | } 271 | interface T { 272 | t1: string; 273 | } 274 | 275 | const REGEX = /^[0-9]+$/; 276 | const VALUE = 'aaa'; 277 | const schema = createSchema({ 278 | t1: { 279 | fn: value => value.s1, 280 | validation: Validation.string().regex(REGEX), 281 | }, 282 | }); 283 | const result = morphism(schema, { s1: VALUE }); 284 | const error = new ValidationError({ 285 | targetProperty: 't1', 286 | innerError: new ValidatorError({ 287 | expect: `Expected value to match pattern: ${REGEX} but received <${VALUE}>`, 288 | value: VALUE, 289 | }), 290 | }); 291 | const message = defaultFormatter(error); 292 | const errors = reporter.report(result); 293 | expect(errors).not.toBeNull(); 294 | if (errors) { 295 | expect(errors.length).toEqual(1); 296 | expect(errors[0]).toBe(message); 297 | } 298 | }); 299 | 300 | it('should report an error when string does not match alphanum rule', () => { 301 | interface S { 302 | s1: string; 303 | } 304 | interface T { 305 | t1: string; 306 | } 307 | 308 | const VALUE = '(*&@#$)'; 309 | const schema = createSchema({ 310 | t1: { 311 | fn: value => value.s1, 312 | validation: Validation.string().alphanum(), 313 | }, 314 | }); 315 | const result = morphism(schema, { s1: VALUE }); 316 | const error = new ValidationError({ 317 | targetProperty: 't1', 318 | innerError: new ValidatorError({ 319 | expect: `Expected value to contain only alphanumeric characters but received <${VALUE}>`, 320 | value: VALUE, 321 | }), 322 | }); 323 | const message = defaultFormatter(error); 324 | const errors = reporter.report(result); 325 | expect(errors).not.toBeNull(); 326 | if (errors) { 327 | expect(errors.length).toEqual(1); 328 | expect(errors[0]).toBe(message); 329 | } 330 | }); 331 | }); 332 | 333 | describe('number', () => { 334 | it('should report error on number undefined', () => { 335 | interface S { 336 | s1: string; 337 | } 338 | interface T { 339 | t1: number; 340 | } 341 | 342 | const schema = createSchema({ 343 | t1: { path: 's1', fn: val => val, validation: Validation.number() }, 344 | }); 345 | const result = morphism(schema, JSON.parse('{}')); 346 | const error = new ValidationError({ 347 | targetProperty: 't1', 348 | innerError: new ValidatorError({ 349 | expect: `Expected value to be a but received <${undefined}>`, 350 | value: undefined, 351 | }), 352 | }); 353 | const message = defaultFormatter(error); 354 | const errors = reporter.report(result); 355 | expect(errors).not.toBeNull(); 356 | if (errors) { 357 | expect(errors.length).toEqual(1); 358 | expect(errors[0]).toBe(message); 359 | } 360 | }); 361 | it('should parse number from string', () => { 362 | interface S { 363 | s1: string; 364 | } 365 | interface T { 366 | t1: number; 367 | } 368 | 369 | const schema = createSchema({ 370 | t1: { 371 | path: 's1', 372 | fn: val => val, 373 | validation: Validation.number({ convert: true }), 374 | }, 375 | }); 376 | const result = morphism(schema, JSON.parse('{ "s1": "1234" }')); 377 | const errors = reporter.report(result); 378 | expect(errors).toBeNull(); 379 | expect(result).toEqual({ t1: 1234 }); 380 | }); 381 | 382 | it('should report an error when number is greater than max rule', () => { 383 | interface S { 384 | s1: number; 385 | } 386 | interface T { 387 | t1: number; 388 | } 389 | 390 | const VALUE = 10; 391 | const MAX = 5; 392 | const schema = createSchema({ 393 | t1: { 394 | fn: value => value.s1, 395 | validation: Validation.number().max(MAX), 396 | }, 397 | }); 398 | const result = morphism(schema, { s1: VALUE }); 399 | const error = new ValidationError({ 400 | targetProperty: 't1', 401 | innerError: new ValidatorError({ 402 | expect: `value to be less or equal than ${MAX}`, 403 | value: VALUE, 404 | }), 405 | }); 406 | const message = defaultFormatter(error); 407 | const errors = reporter.report(result); 408 | expect(errors).not.toBeNull(); 409 | if (errors) { 410 | expect(errors.length).toEqual(1); 411 | expect(errors[0]).toBe(message); 412 | } 413 | }); 414 | 415 | it('should report an error when number is less than min rule', () => { 416 | interface S { 417 | s1: number; 418 | } 419 | interface T { 420 | t1: number; 421 | } 422 | 423 | const VALUE = 2; 424 | const MIN = 5; 425 | const schema = createSchema({ 426 | t1: { 427 | fn: value => value.s1, 428 | validation: Validation.number().min(MIN), 429 | }, 430 | }); 431 | const result = morphism(schema, { s1: VALUE }); 432 | const error = new ValidationError({ 433 | targetProperty: 't1', 434 | innerError: new ValidatorError({ 435 | expect: `value to be greater or equal than ${MIN}`, 436 | value: VALUE, 437 | }), 438 | }); 439 | const message = defaultFormatter(error); 440 | const errors = reporter.report(result); 441 | expect(errors).not.toBeNull(); 442 | if (errors) { 443 | expect(errors.length).toEqual(1); 444 | expect(errors[0]).toBe(message); 445 | } 446 | }); 447 | }); 448 | 449 | describe('boolean', () => { 450 | it('should return a boolean if a boolean has been provided', () => { 451 | interface S { 452 | s1: boolean; 453 | } 454 | interface T { 455 | t1: boolean; 456 | } 457 | 458 | const schema = createSchema({ 459 | t1: { path: 's1', fn: val => val, validation: Validation.boolean() }, 460 | }); 461 | const result = morphism(schema, JSON.parse('{ "s1": true }')); 462 | const errors = reporter.report(result); 463 | expect(errors).toBeNull(); 464 | expect(result).toEqual({ t1: true }); 465 | }); 466 | 467 | it('should return a boolean true from a string', () => { 468 | interface S { 469 | s1: boolean; 470 | } 471 | interface T { 472 | t1: boolean; 473 | } 474 | 475 | const schema = createSchema({ 476 | t1: { 477 | path: 's1', 478 | fn: val => val, 479 | validation: Validation.boolean({ convert: true }), 480 | }, 481 | }); 482 | const result = morphism(schema, JSON.parse('{ "s1": "true" }')); 483 | const errors = reporter.report(result); 484 | expect(errors).toBeNull(); 485 | expect(result).toEqual({ t1: true }); 486 | }); 487 | 488 | it('should return a boolean false from a string', () => { 489 | interface S { 490 | s1: boolean; 491 | } 492 | interface T { 493 | t1: boolean; 494 | } 495 | 496 | const schema = createSchema({ 497 | t1: { 498 | path: 's1', 499 | fn: val => val, 500 | validation: Validation.boolean({ convert: true }), 501 | }, 502 | }); 503 | const result = morphism(schema, JSON.parse('{ "s1": "false" }')); 504 | const errors = reporter.report(result); 505 | expect(errors).toBeNull(); 506 | expect(result).toEqual({ t1: false }); 507 | }); 508 | 509 | it('should return an error', () => { 510 | interface S { 511 | s1: boolean; 512 | } 513 | interface T { 514 | t1: boolean; 515 | } 516 | 517 | const schema = createSchema({ 518 | t1: { path: 's1', fn: val => val, validation: Validation.boolean() }, 519 | }); 520 | const result = morphism(schema, JSON.parse('{ "s1": "a value" }')); 521 | const error = new ValidationError({ 522 | targetProperty: 't1', 523 | innerError: new ValidatorError({ 524 | expect: `Expected value to be a but received `, 525 | value: 'a value', 526 | }), 527 | }); 528 | const message = defaultFormatter(error); 529 | 530 | const errors = reporter.report(result); 531 | 532 | expect(result.t1).toEqual('a value'); 533 | expect(errors).not.toBeNull(); 534 | if (errors) { 535 | expect(errors.length).toEqual(1); 536 | expect(errors[0]).toBe(message); 537 | } 538 | }); 539 | }); 540 | }); 541 | }); 542 | -------------------------------------------------------------------------------- /src/validation/reporter.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorError } from '../morphism'; 2 | 3 | export const ERRORS = Symbol('errors'); 4 | 5 | export class ValidationError extends Error { 6 | targetProperty: string; 7 | innerError: ValidatorError; 8 | constructor(infos: { targetProperty: string; innerError: ValidatorError }) { 9 | super(`Invalid value supplied at property <${infos.targetProperty}>.`); 10 | this.innerError = infos.innerError; 11 | } 12 | } 13 | 14 | export class ValidationErrors extends Error { 15 | errors: Set; 16 | reporter: Reporter; 17 | target: unknown; 18 | constructor(reporter: Reporter, target: unknown) { 19 | super(); 20 | this.errors = new Set(); 21 | this.reporter = reporter; 22 | this.target = target; 23 | } 24 | addError(error: ValidationError) { 25 | this.errors.add(error); 26 | const errors = this.reporter.report(this.target); 27 | if (errors) { 28 | this.message = errors.join('\n'); 29 | } 30 | } 31 | } 32 | 33 | export interface Validation { 34 | [ERRORS]: ValidationErrors; 35 | } 36 | 37 | export function targetHasErrors(target: any): target is Validation { 38 | return target && target[ERRORS] && target[ERRORS].errors.size > 0; 39 | } 40 | export function defaultFormatter(error: ValidationError) { 41 | const { message, innerError } = error; 42 | return `${message} Reason: ${innerError.message}`; 43 | } 44 | 45 | /** 46 | * Formatting function called by the reporter for each errors found during the mapping towards a target. 47 | * 48 | * @interface Formatter 49 | */ 50 | export interface Formatter { 51 | (error: ValidationError): string; 52 | } 53 | 54 | /** 55 | * Class to handle reporting of errors found on a target when executing a mapping. 56 | * 57 | * @class Reporter 58 | */ 59 | export class Reporter { 60 | constructor(private formatter: Formatter = defaultFormatter) {} 61 | 62 | /** 63 | * Report a list of messages corresponding to the errors found during the transformations. Returns null when no errors has been found. 64 | * 65 | * @param {*} target 66 | * @returns {string[] | null} 67 | * @memberof Reporter 68 | */ 69 | report(target: any): string[] | null { 70 | const validationErrors = this.extractErrors(target); 71 | 72 | if (!validationErrors) return null; 73 | return [...validationErrors.errors.values()].map(this.formatter); 74 | } 75 | 76 | extractErrors(target: any) { 77 | if (!targetHasErrors(target)) return null; 78 | return target[ERRORS]; 79 | } 80 | } 81 | 82 | /** 83 | * Singleton instance of a Reporter class. 84 | * 85 | */ 86 | export const reporter = new Reporter(); 87 | -------------------------------------------------------------------------------- /src/validation/validators/BooleanValidator.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorError } from './ValidatorError'; 2 | import { isFunction, isString } from '../../helpers'; 3 | import { ValidatorOptions, Rule } from './types'; 4 | import { LinkedList } from './LinkedList'; 5 | import { ValidatorValidateResult, ValidateFunction } from '../../types'; 6 | 7 | export function BooleanValidator(options: ValidatorOptions = {}) { 8 | let list = new LinkedList>({ 9 | name: 'boolean', 10 | expect: input => `Expected value to be a but received <${input.value}>`, 11 | validate: input => { 12 | if (!options.convert) { 13 | return typeof input.value === 'boolean'; 14 | } else { 15 | if (typeof input.value === 'boolean') { 16 | return input.value; 17 | } else { 18 | if (/true/i.test(input.value)) { 19 | input.value = true; 20 | return true; 21 | } else if (/false/i.test(input.value)) { 22 | input.value = false; 23 | return true; 24 | } else { 25 | return false; 26 | } 27 | } 28 | } 29 | }, 30 | }); 31 | 32 | const validate: ValidateFunction = input => { 33 | const result: ValidatorValidateResult = input; 34 | const iterator = list.values(); 35 | let current = iterator.next(); 36 | const usedRules: { [id: string]: Rule } = {}; 37 | 38 | while (!result.error && !current.done) { 39 | const rule = current.value; 40 | if (rule.name in usedRules) { 41 | throw new Error(`Rule ${rule.name} has already been used`); 42 | } else { 43 | usedRules[rule.name] = rule; 44 | } 45 | if (!rule.validate(result)) { 46 | result.error = new ValidatorError({ 47 | expect: isString(rule.expect) ? rule.expect : rule.expect(result), 48 | value: result.value, 49 | }); 50 | } 51 | current = iterator.next(); 52 | } 53 | return result; 54 | }; 55 | 56 | const rules = {}; 57 | const api = Object.assign(validate, rules); 58 | return api; 59 | } 60 | -------------------------------------------------------------------------------- /src/validation/validators/LinkedList.ts: -------------------------------------------------------------------------------- 1 | export class LinkedList { 2 | next: LinkedList | null; 3 | head: LinkedList | null; 4 | tail: LinkedList; 5 | constructor(private data: T) { 6 | this.next = null; 7 | this.head = this; 8 | this.tail = this; 9 | } 10 | 11 | append(data: T) { 12 | this.tail.next = new LinkedList(data); 13 | this.tail = this.tail.next; 14 | } 15 | 16 | *values() { 17 | while (this.head !== null) { 18 | yield this.head.data; 19 | this.head = this.head.next; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/validation/validators/NumberValidator.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorValidateResult, ValidateFunction } from '../../types'; 2 | import { LinkedList } from './LinkedList'; 3 | import { ValidatorOptions, Rule } from './types'; 4 | import { ValidatorError } from './ValidatorError'; 5 | import { isString } from '../../helpers'; 6 | 7 | export function NumberValidator(options: ValidatorOptions = {}) { 8 | let list = new LinkedList>({ 9 | name: 'number', 10 | expect: input => `Expected value to be a but received <${input.value}>`, 11 | validate: input => { 12 | if (!options.convert) { 13 | return typeof input.value === 'number'; 14 | } else { 15 | input.value = +input.value; 16 | return !isNaN(input.value); 17 | } 18 | }, 19 | }); 20 | 21 | const validate: ValidateFunction = input => { 22 | const result: ValidatorValidateResult = input; 23 | const iterator = list.values(); 24 | let current = iterator.next(); 25 | const usedRules: { [id: string]: Rule } = {}; 26 | 27 | while (!result.error && !current.done) { 28 | const rule = current.value; 29 | if (rule.name in usedRules) { 30 | throw new Error(`Rule ${rule.name} has already been used`); 31 | } else { 32 | usedRules[rule.name] = rule; 33 | } 34 | if (!rule.validate(result)) { 35 | result.error = new ValidatorError({ 36 | expect: isString(rule.expect) ? rule.expect : rule.expect(result), 37 | value: result.value, 38 | }); 39 | } 40 | current = iterator.next(); 41 | } 42 | return result; 43 | }; 44 | 45 | const rules = { 46 | min: (value: any) => { 47 | list.append({ 48 | name: 'min', 49 | expect: `value to be greater or equal than ${value}`, 50 | validate: input => input.value >= value, 51 | }); 52 | return api; 53 | }, 54 | max: (value: any) => { 55 | list.append({ 56 | name: 'max', 57 | expect: `value to be less or equal than ${value}`, 58 | validate: input => input.value <= value, 59 | }); 60 | return api; 61 | }, 62 | }; 63 | const api = Object.assign(validate, rules); 64 | return api; 65 | } 66 | -------------------------------------------------------------------------------- /src/validation/validators/StringValidator.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorError } from './ValidatorError'; 2 | import { isString } from '../../helpers'; 3 | import { ValidatorValidateResult, ValidateFunction } from '../../types'; 4 | import { ValidatorOptions, Rule } from './types'; 5 | import { LinkedList } from './LinkedList'; 6 | 7 | export function StringValidator(options: ValidatorOptions = {}) { 8 | let list = new LinkedList>({ 9 | name: 'string', 10 | expect: input => `Expected value to be a but received <${input.value}>`, 11 | validate: input => { 12 | if (isString(input.value)) { 13 | return true; 14 | } else { 15 | if (options.convert) { 16 | input.value = String(input.value); 17 | return true; 18 | } else { 19 | return false; 20 | } 21 | } 22 | }, 23 | }); 24 | 25 | const validate: ValidateFunction = input => { 26 | const result: ValidatorValidateResult = input; 27 | const iterator = list.values(); 28 | let current = iterator.next(); 29 | const usedRules: { [id: string]: Rule } = {}; 30 | 31 | while (!result.error && !current.done) { 32 | const rule = current.value; 33 | if (rule.name in usedRules) { 34 | throw new Error(`Rule ${rule.name} has already been used`); 35 | } else { 36 | usedRules[rule.name] = rule; 37 | } 38 | if (!rule.validate(result)) { 39 | result.error = new ValidatorError({ 40 | expect: isString(rule.expect) ? rule.expect : rule.expect(result), 41 | value: result.value, 42 | }); 43 | } 44 | current = iterator.next(); 45 | } 46 | return result; 47 | }; 48 | 49 | const rules = { 50 | min: (value: number) => { 51 | list.append({ 52 | name: 'min', 53 | expect: input => `Expected value to be greater or equal than <${value}> but received <${input.value}>`, 54 | validate: input => input.value.length >= value, 55 | }); 56 | return api; 57 | }, 58 | max: (value: number) => { 59 | list.append({ 60 | name: 'max', 61 | expect: input => `Expected value to be less or equal than <${value}> but received <${input.value}>`, 62 | validate: input => input.value.length <= value, 63 | }); 64 | return api; 65 | }, 66 | size: (value: number) => { 67 | list.append({ 68 | name: 'length', 69 | expect: input => `Expected value to be length of <${value}> but received <${input.value}>`, 70 | validate: input => input.value.length === value, 71 | }); 72 | return api; 73 | }, 74 | regex: (regex: RegExp) => { 75 | const rule = createRegexRule('regex', input => `Expected value to match pattern: ${regex} but received <${input.value}>`, regex); 76 | list.append(rule); 77 | return api; 78 | }, 79 | alphanum: () => { 80 | const rule = createRegexRule( 81 | 'regex', 82 | input => `Expected value to contain only alphanumeric characters but received <${input.value}>`, 83 | /^[a-z0-9]+$/i 84 | ); 85 | list.append(rule); 86 | return api; 87 | }, 88 | }; 89 | const api = Object.assign(validate, rules); 90 | return api; 91 | } 92 | 93 | function createRegexRule(name: Rule['name'], expect: Rule['expect'], regex: RegExp): Rule { 94 | const validate: Rule['validate'] = input => regex.test(input.value); 95 | return { name, expect, validate }; 96 | } 97 | -------------------------------------------------------------------------------- /src/validation/validators/ValidatorError.ts: -------------------------------------------------------------------------------- 1 | export interface ValidatorErrorInfos { 2 | value: unknown; 3 | expect: string; 4 | } 5 | export class ValidatorError extends Error { 6 | value: unknown; 7 | expect: string; 8 | constructor(infos: ValidatorErrorInfos) { 9 | super(infos.expect); 10 | this.value = infos.value; 11 | this.expect = infos.expect; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/validation/validators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './StringValidator'; 2 | export * from './BooleanValidator'; 3 | export * from './NumberValidator'; 4 | -------------------------------------------------------------------------------- /src/validation/validators/types.ts: -------------------------------------------------------------------------------- 1 | export interface ValidatorOptions { 2 | convert?: boolean; 3 | } 4 | 5 | export interface Rule { 6 | name: string; 7 | expect: string | ((input: { value: T }) => string); 8 | validate: (input: { value: T }) => boolean; 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "sourceMap": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "moduleResolution": "node", 9 | "lib": ["esnext", "dom"], 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "declarationDir": "dist/types", 13 | "declaration": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "strictPropertyInitialization": false, 17 | "downlevelIteration": true 18 | }, 19 | "include": ["src/**/*.ts"], 20 | "exclude": ["dist"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "sourceMap": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "moduleResolution": "node", 9 | "lib": ["esnext", "dom"], 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "declarationDir": "dist/types", 13 | "declaration": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "strictPropertyInitialization": false, 17 | "downlevelIteration": true 18 | }, 19 | "include": ["src/**/*.ts"], 20 | "exclude": ["dist", "**/*/*.spec.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.webpack.json: -------------------------------------------------------------------------------- 1 | // https://webpack.js.org/configuration/configuration-languages/#typescript 2 | 3 | { 4 | "compilerOptions": { 5 | "module": "commonjs", 6 | "target": "es5", 7 | "esModuleInterop": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [true, "check-space"], 5 | "indent": [true, "spaces"], 6 | "no-duplicate-variable": true, 7 | "no-eval": true, 8 | "no-internal-module": true, 9 | "no-trailing-whitespace": true, 10 | "no-var-keyword": true, 11 | "one-line": [true, "check-open-brace", "check-whitespace"], 12 | "semicolon": false, 13 | "triple-equals": [true, "allow-null-check"], 14 | "typedef-whitespace": [ 15 | true, 16 | { 17 | "call-signature": "nospace", 18 | "index-signature": "nospace", 19 | "parameter": "nospace", 20 | "property-declaration": "nospace", 21 | "variable-declaration": "nospace" 22 | } 23 | ], 24 | "variable-name": [true, "ban-keywords"], 25 | "whitespace": [true, "check-branch", "check-decl", "check-operator", "check-separator", "check-type"] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'Morphism API', 3 | out: './docs/typedoc', 4 | 5 | // readme: 'none', 6 | exclude: ['/**/*.spec.ts'], 7 | 8 | mode: 'modules', 9 | externalPattern: '**/helpers.ts', 10 | excludeExternals: true, 11 | excludeNotExported: true, 12 | excludePrivate: true, 13 | excludeProtected: true, 14 | includeDeclarations: true 15 | }; 16 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 4 | import NodemonPlugin from 'nodemon-webpack-plugin'; 5 | import ModuleDependencyWarning from 'webpack/lib/ModuleDependencyWarning'; 6 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 7 | 8 | const nodeEnv = process.env.NODE_ENV || 'development'; 9 | const isProd = nodeEnv === 'production'; 10 | const shouldAnalyzeBundle = process.env.WEBPACK_ANALYZE; 11 | class IgnoreNotFoundExportPlugin { 12 | apply(compiler) { 13 | const messageRegExp = /export '.*'( \(reexported as '.*'\))? was not found in/; 14 | function doneHook(stats) { 15 | stats.compilation.warnings = stats.compilation.warnings.filter(function(warn) { 16 | if (warn instanceof ModuleDependencyWarning && messageRegExp.test(warn.message)) { 17 | return false; 18 | } 19 | return true; 20 | }); 21 | } 22 | if (compiler.hooks) { 23 | compiler.hooks.done.tap('IgnoreNotFoundExportPlugin', doneHook); 24 | } else { 25 | compiler.plugin('done', doneHook); 26 | } 27 | } 28 | } 29 | 30 | const webpackconfiguration: webpack.Configuration = { 31 | mode: 'development', 32 | entry: path.resolve(__dirname, 'src', 'morphism.ts'), 33 | devtool: isProd ? 'hidden-source-map' : 'source-map', 34 | output: { 35 | filename: 'morphism.js', 36 | path: path.resolve(__dirname, 'dist'), 37 | libraryTarget: 'umd', 38 | globalObject: 'this', 39 | sourceMapFilename: 'morphism.map', 40 | library: 'Morphism', 41 | }, 42 | resolve: { 43 | extensions: ['.ts', '.tsx', '.js', '.json'], 44 | }, 45 | module: { 46 | rules: [{ test: /\.(ts|js)x?$/, use: ['babel-loader', 'source-map-loader'], exclude: /node_modules/ }], 47 | }, 48 | plugins: [ 49 | new ForkTsCheckerWebpackPlugin({ 50 | async: false, 51 | checkSyntacticErrors: true, 52 | reportFiles: ['**', '!**/*.json', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], 53 | silent: true, 54 | }), 55 | new NodemonPlugin(), 56 | new IgnoreNotFoundExportPlugin(), 57 | shouldAnalyzeBundle ? new BundleAnalyzerPlugin({ generateStatsFile: true }) : null, 58 | ].filter(plugin => plugin), 59 | }; 60 | 61 | export default webpackconfiguration; 62 | --------------------------------------------------------------------------------