├── .circleci └── config.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.js ├── package.json ├── src ├── defaults.js ├── get.js ├── helpers │ ├── getOwnProperty.js │ ├── isPOJO.js │ └── matchType.js ├── index.js ├── path.js ├── required.js ├── specialProperties.js ├── symbols.js ├── to.js ├── type.js ├── unmarshal │ ├── error.js │ ├── index.js │ └── util.js └── util.js └── test ├── defaults.test.js ├── helpers.test.js ├── to.test.js ├── unit.test.js └── util.test.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | setup_environment: &setup_environment 2 | docker: 3 | - image: circleci/node:6.11.1 4 | environment: 5 | TZ: "/usr/share/zoneinfo/America/Los_Angeles" 6 | working_directory: ~/project 7 | 8 | version: 2 9 | jobs: 10 | install: 11 | <<: *setup_environment 12 | steps: 13 | - checkout 14 | - restore_cache: 15 | key: dependency-cache-{{ checksum "package.json" }} 16 | - run: 17 | name: Install dependencies 18 | command: npm install 19 | - save_cache: 20 | key: dependency-cache-{{ checksum "package.json" }} 21 | paths: 22 | - node_modules 23 | - persist_to_workspace: 24 | root: ~/project 25 | paths: 26 | - node_modules 27 | 28 | test: 29 | <<: *setup_environment 30 | steps: 31 | - checkout 32 | - attach_workspace: 33 | at: ~/project 34 | - run: 35 | name: Run tests 36 | command: npm i && npm test 37 | 38 | workflows: 39 | version: 2 40 | install_test: 41 | jobs: 42 | - install 43 | - test: 44 | requires: 45 | - install 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | package-lock.json 36 | 37 | #IDES 38 | .idea 39 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | .circleci/ 3 | package-lock.json 4 | test/ 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [0.11.3](https://github.com/boosterfuels/archetype/compare/v0.11.2...v0.11.3) (2020-05-18) 3 | 4 | 5 | ### Bug Fixes 6 | 7 | * **import:** Remove unused import of index.js in src/unmarshal/index.js ([71eac99](https://github.com/boosterfuels/archetype/commit/71eac99)) 8 | 9 | 10 | 11 | 12 | ## [0.11.1](https://github.com/boosterfuels/archetype/compare/v0.11.0...v0.11.1) (2020-02-27) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * throw non-empty object $default error if passing in a date or other non-POJO ([76e7bfa](https://github.com/boosterfuels/archetype/commit/76e7bfa)) 18 | 19 | 20 | 21 | 22 | # [0.11.0](https://github.com/boosterfuels/archetype/compare/v0.10.2...v0.11.0) (2020-02-27) 23 | 24 | 25 | ### Features 26 | 27 | * make `new Type()` clone by default, but allow passing in an option to disable cloning for perf ([3638b57](https://github.com/boosterfuels/archetype/commit/3638b57)) 28 | 29 | 30 | 31 | 32 | ## [0.10.2](https://github.com/boosterfuels/archetype/compare/v0.10.0...v0.10.2) (2020-02-25) 33 | 34 | 35 | ### Bug Fixes 36 | 37 | * clone empty arrays / objects if set as `$default`, throw if not an empty object ([312aa14](https://github.com/boosterfuels/archetype/commit/312aa14)) 38 | 39 | 40 | 41 | 42 | ## [0.10.1](https://github.com/boosterfuels/archetype/compare/v0.10.0...v0.10.1) (2020-02-25) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * clone empty arrays / objects if set as `$default`, throw if not an empty object ([312aa14](https://github.com/boosterfuels/archetype/commit/312aa14)) 48 | 49 | 50 | 51 | 52 | # [0.10.0](https://github.com/boosterfuels/archetype/compare/v0.9.1...v0.10.0) (2019-08-09) 53 | 54 | 55 | ### Performance Improvements 56 | 57 | * remove `cloneDeep()` to reduce memory usage for huge objects ([117c083](https://github.com/boosterfuels/archetype/commit/117c083)) 58 | 59 | 60 | 61 | 62 | ## [0.9.1](https://github.com/boosterfuels/archetype/compare/v0.9.0...v0.9.1) (2019-06-10) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * export core Type class ([3e1bdf2](https://github.com/boosterfuels/archetype/commit/3e1bdf2)) 68 | 69 | 70 | 71 | 72 | # [0.9.0](https://github.com/boosterfuels/archetype/compare/v0.8.8...v0.9.0) (2019-02-12) 73 | 74 | 75 | ### Bug Fixes 76 | 77 | * ignore special properties ([7b99d33](https://github.com/boosterfuels/archetype/commit/7b99d33)) 78 | * **to:** disallow casting POJOs to numbers ([5548fc2](https://github.com/boosterfuels/archetype/commit/5548fc2)) 79 | 80 | 81 | ### Features 82 | 83 | * make `compile()` return a full ES6 class for improved inheritance ([d3e873e](https://github.com/boosterfuels/archetype/commit/d3e873e)), closes [#9](https://github.com/boosterfuels/archetype/issues/9) 84 | 85 | 86 | 87 | 88 | ## [0.8.8](https://github.com/boosterfuels/archetype/compare/v0.8.7...v0.8.8) (2019-01-30) 89 | 90 | 91 | ### Bug Fixes 92 | 93 | * remove leftover reference to lodash ([34c6519](https://github.com/boosterfuels/archetype/commit/34c6519)) 94 | 95 | 96 | 97 | 98 | ## [0.8.7](https://github.com/boosterfuels/archetype/compare/v0.8.6...v0.8.7) (2019-01-29) 99 | 100 | 101 | ### Bug Fixes 102 | 103 | * clean up unnecessary _.each() usage ([ab32c15](https://github.com/boosterfuels/archetype/commit/ab32c15)) 104 | 105 | 106 | 107 | 108 | ## [0.8.6](https://github.com/boosterfuels/archetype/compare/v0.8.5...v0.8.6) (2019-01-28) 109 | 110 | 111 | 112 | 113 | ## [0.8.4](https://github.com/boosterfuels/archetype/compare/v0.8.3...v0.8.4) (2019-01-27) 114 | 115 | 116 | ### Bug Fixes 117 | 118 | * **defaults:** handle nested defaults correctly ([c02b89b](https://github.com/boosterfuels/archetype/commit/c02b89b)), closes [#16](https://github.com/boosterfuels/archetype/issues/16) 119 | * **to:** dont convert null -> undefined when casting strings ([92faef2](https://github.com/boosterfuels/archetype/commit/92faef2)) 120 | 121 | 122 | 123 | 124 | ## [0.8.3](https://github.com/boosterfuels/archetype/compare/v0.8.2...v0.8.3) (2018-03-21) 125 | 126 | 127 | ### Features 128 | 129 | * support inPlace updates for recursive schemas ([8f61d32](https://github.com/boosterfuels/archetype/commit/8f61d32)), closes [#11](https://github.com/boosterfuels/archetype/issues/11) 130 | 131 | 132 | 133 | 134 | ## [0.8.2](https://github.com/boosterfuels/archetype/compare/v0.8.1...v0.8.2) (2017-12-03) 135 | 136 | 137 | ### Bug Fixes 138 | 139 | * **unmarshal:** don't use $transform on array if nested in child element ([78bc272](https://github.com/boosterfuels/archetype/commit/78bc272)), closes [#13](https://github.com/boosterfuels/archetype/issues/13) 140 | 141 | 142 | 143 | 144 | ## [0.8.1](https://github.com/boosterfuels/archetype/compare/v0.8.0...v0.8.1) (2017-10-19) 145 | 146 | 147 | ### Bug Fixes 148 | 149 | * handle passing array of paths to `Type.omit()` ([0cfc96f](https://github.com/boosterfuels/archetype/commit/0cfc96f)) 150 | 151 | 152 | 153 | 154 | # [0.8.0](https://github.com/boosterfuels/archetype/compare/v0.7.0...v0.8.0) (2017-09-21) 155 | 156 | 157 | ### Features 158 | 159 | * export CastError type ([1c3a971](https://github.com/boosterfuels/archetype/commit/1c3a971)), closes [#7](https://github.com/boosterfuels/archetype/issues/7) 160 | * **helpers:** add matchType ([7f5e1e8](https://github.com/boosterfuels/archetype/commit/7f5e1e8)) 161 | * **unmarshal:** add rudimentary support for $transform ([8bdf8db](https://github.com/boosterfuels/archetype/commit/8bdf8db)) 162 | * **unmarshal:** report errors from $transform ([a8c9e3d](https://github.com/boosterfuels/archetype/commit/a8c9e3d)) 163 | * **unmarshal:** support $transform in arrays ([2d11cf5](https://github.com/boosterfuels/archetype/commit/2d11cf5)) 164 | 165 | 166 | 167 | 168 | ## [0.6.1](https://github.com/vkarpov15/archetype-js/compare/v0.6.0...v0.6.1) (2017-03-17) 169 | 170 | 171 | ### Bug Fixes 172 | 173 | * **unmarshal:** handle $type: Array with non-arrays ([f65c063](https://github.com/vkarpov15/archetype-js/commit/f65c063)) 174 | 175 | 176 | 177 | 178 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # archetype 2 | 3 | 4 | 5 | Archetype is a library for casting and validating objects. It has exceptional support for deeply nested objects, type composition, custom types, and geoJSON. 6 | 7 | [![CircleCI](https://circleci.com/gh/boosterfuels/archetype.svg?style=svg)](https://circleci.com/gh/boosterfuels/archetype) 8 | 9 | ```javascript 10 | const { ObjectId } = require('mongodb'); 11 | const moment = require('moment'); 12 | 13 | // `Person` is now a constructor 14 | const Person = new Archetype({ 15 | name: 'string', 16 | bandId: { 17 | $type: ObjectId, 18 | $required: true 19 | }, 20 | createdAt: { 21 | $type: moment, 22 | $default: () => moment() 23 | } 24 | }).compile('Person'); 25 | 26 | const test = new Person({ 27 | name: 'test', 28 | bandId: '507f191e810c19729de860ea' 29 | }); 30 | 31 | test.bandId; // Now a mongodb ObjectId 32 | test.createdAt; // moment representing now 33 | ``` 34 | 35 | If casting fails, archetype throws a nice clean exception: 36 | 37 | ```javascript 38 | try { 39 | new Person({ 40 | name: 'test', 41 | bandId: 'ImNotAValidObjectId' 42 | }); 43 | } catch(error) { 44 | error.errors['bandId'].message; // Mongodb ObjectId error 45 | } 46 | ``` 47 | 48 | [Archetypes are composable, inspectable, and extendable via `extends`.](http://thecodebarbarian.com/casting-mongodb-queries-with-archetype.html) 49 | 50 | ## Connect 51 | 52 | Follow [archetype on Twitter](https://twitter.com/archetype_js) for updates, including our gists of the week. Here's some older gists of the week: 53 | 54 | * 20180112: [Virtual Types with Archetype](https://gist.github.com/vkarpov15/32abd9f72dfb2ba1558e9e6a1060c768) 55 | * 20180105: [Transform an Archetype So All Properties are Not Required](https://gist.github.com/vkarpov15/6c49c0bc5ed0eab42633645bf03e8fa7) 56 | * 20171124: [Embedding Only Certain Fields in an Embedded Archetype](https://gist.github.com/vkarpov15/37622608b33eb144acfda5d3ad936be6) 57 | * 20171117: [Async/Await with Archetype](https://gist.github.com/vkarpov15/f10b560468f49166bcc14c6e41bb755e) 58 | * 20171110: [Configuring Custom Validators for Individual Paths in Archetype](https://gist.github.com/vkarpov15/dcf7c490c69e625764c0dc1453555524) 59 | * 20171103: [Conditionally Required Properties with Archetype](https://gist.github.com/vkarpov15/b4a9cb225699b3bf852c3fa8ca2c56e2) 60 | * 20171027: [Custom Types with Archetype](https://gist.github.com/vkarpov15/d4cbd7941b40346741cf791d379001e5) 61 | * 20171020: [Embedding a Subset of One Archetype's Properties in another Archetype](https://gist.github.com/vkarpov15/0dc21e98acfb96e72d0bb9b602adb3ad) 62 | * 20171013: [Embedded Objects vs Embedded Types in Archetype](https://gist.github.com/vkarpov15/1520cfb604972e81198db028e4606809) 63 | * 20171006: [Consistent Arrays from Query Params with Express and Archetype](https://gist.github.com/vkarpov15/e03dafb2ac478cb38ff3fbe4c36139d6) 64 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src').Archetype; 2 | 3 | module.exports.Any = require('./src/symbols').Any; 4 | module.exports.CastError = require('./src').CastError; 5 | module.exports.Type = require('./src/type'); 6 | module.exports.matchType = require('./src/helpers/matchType'); 7 | module.exports.to = require('./src/to'); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "archetype", 3 | "version": "0.13.1", 4 | "author": "Valeri Karpov ", 5 | "dependencies": { 6 | "lodash": "4.x", 7 | "mpath": "0.8.x" 8 | }, 9 | "devDependencies": { 10 | "acquit": "1.x", 11 | "acquit-ignore": "0.1.x", 12 | "acquit-markdown": "0.1.x", 13 | "co": "4.6.0", 14 | "mocha": "5.x", 15 | "mongodb": "3.1.13", 16 | "standard-changelog": "1.0.1" 17 | }, 18 | "engines": { 19 | "node": ">= 4.0.0" 20 | }, 21 | "license": "Apache 2.0", 22 | "main": "./index.js", 23 | "repository": { 24 | "type": "git", 25 | "url": "git://github.com/boosterfuels/archetype.git" 26 | }, 27 | "scripts": { 28 | "changelog": "standard-changelog -i CHANGELOG.md -w", 29 | "docs": "node docs.js", 30 | "test": "mocha test/*.test.js" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/defaults.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = applyDefaults; 4 | 5 | const get = require('./get'); 6 | const getOwnProperty = require('./helpers/getOwnProperty'); 7 | const join = require('./unmarshal/util').join; 8 | const realPathToSchemaPath = require('./unmarshal/util').realPathToSchemaPath; 9 | const shouldSkipPath = require('./util').shouldSkipPath; 10 | const util = require('util'); 11 | 12 | function applyDefaults(obj, schema, projection) { 13 | const keys = Object.keys(schema._obj); 14 | for (const key of keys) { 15 | const def = defaults(obj, obj[key], schema, key, projection); 16 | if (def !== void 0 && obj[key] == null) { 17 | obj[key] = def; 18 | } 19 | } 20 | } 21 | 22 | function defaults(root, v, schema, path, projection) { 23 | if (shouldSkipPath(projection, path) || projection.$noDefaults) { 24 | return; 25 | } 26 | 27 | const fakePath = realPathToSchemaPath(path); 28 | const schemaPath = schema._paths[fakePath]; 29 | 30 | if (!schemaPath) { 31 | return; 32 | } 33 | 34 | if ('$default' in schemaPath) { 35 | return handleDefault(schemaPath.$default, root, path); 36 | } 37 | 38 | if (schemaPath.$type === Object && getOwnProperty(schemaPath, '$schema') != null) { 39 | for (const key of Object.keys(schemaPath.$schema)) { 40 | const fullPath = join(fakePath, key); 41 | const value = get(v, key); 42 | // Might have nested defaults even if this level isn't nullish 43 | const def = defaults(root, value, schema, fullPath, projection); 44 | if (def !== void 0 && value == null) { 45 | if (v == null) { 46 | v = {}; 47 | } 48 | v[key] = def; 49 | } 50 | } 51 | 52 | return v; 53 | } 54 | if (schemaPath.$type === Array) { 55 | const arr = v || []; 56 | for (let index = 0; index < arr.length; ++index) { 57 | const value = v[index]; 58 | const def = defaults(root, value, schema, 59 | join(fakePath, index.toString()), projection); 60 | if (def !== void 0 && value == null) { 61 | v[index] = def; 62 | } 63 | } 64 | 65 | return v; 66 | } 67 | } 68 | 69 | function handleDefault(obj, ctx, path) { 70 | if (typeof obj === 'function') { 71 | return obj(ctx); 72 | } 73 | // If default is an object type, be very careful - 74 | // returning an object default would be by reference, 75 | // which means user data could modify the default. 76 | if (typeof obj === 'object' && obj != null) { 77 | if (Array.isArray(obj) && obj.length === 0) { 78 | return []; 79 | } else if (!Array.isArray(obj) && 80 | Object.keys(obj).length === 0 && 81 | [void 0, Object].indexOf(obj.constructor) !== -1) { 82 | return {}; 83 | } 84 | throw new Error('Default at path `' + path + '` is a non-empty ' + 85 | 'object `' + util.inspect(obj) + '`. Please make `$default` ' + 86 | 'a function that returns an object instead'); 87 | } 88 | return obj; 89 | } 90 | -------------------------------------------------------------------------------- /src/get.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /*! 4 | * Simplified lodash.get to work around the annoying null quirk. See: 5 | * https://github.com/lodash/lodash/issues/3659 6 | */ 7 | 8 | module.exports = function get(obj, path, def) { 9 | const parts = path.split('.'); 10 | let rest = path; 11 | let cur = obj; 12 | for (const part of parts) { 13 | if (cur == null) { 14 | return def; 15 | } 16 | 17 | cur = getProperty(cur, part); 18 | 19 | rest = rest.substr(part.length + 1); 20 | } 21 | 22 | return cur == null ? def : cur; 23 | }; 24 | 25 | function getProperty(obj, prop) { 26 | if (obj == null) { 27 | return obj; 28 | } 29 | if (obj instanceof Map) { 30 | return obj.get(prop); 31 | } 32 | return obj[prop]; 33 | } 34 | -------------------------------------------------------------------------------- /src/helpers/getOwnProperty.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function getOwnProperty(obj, prop) { 4 | if (obj == null) { 5 | return null; 6 | } 7 | if (typeof obj !== 'object') { 8 | return null; 9 | } 10 | if (!obj.hasOwnProperty(prop)) { 11 | return null; 12 | } 13 | return obj[prop]; 14 | }; -------------------------------------------------------------------------------- /src/helpers/isPOJO.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function isPOJO(arg) { 4 | if (arg == null || typeof arg !== 'object') { 5 | return false; 6 | } 7 | const proto = Object.getPrototypeOf(arg); 8 | // Prototype may be null if you used `Object.create(null)` 9 | // Checking `proto`'s constructor is safe because `getPrototypeOf()` 10 | // explicitly crosses the boundary from object data to object metadata 11 | return !proto || proto.constructor.name === 'Object'; 12 | }; -------------------------------------------------------------------------------- /src/helpers/matchType.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Given a mapping from types ('object', 'string', 'boolean', 'undefined', 5 | * 'number', 'symbol', or 'function') to functions, returns a function that 6 | * takes a `value` and replaces it with the return value from the function 7 | * mapped to by `typeof value`. 8 | * 9 | * Example: `matchType({ 'string': JSON.parse })` will return a function that 10 | * takes a value and only calls `JSON.parse()` if that value is a string. 11 | * 12 | * @param {Object} obj Maps from `typeof` value to function 13 | * @return {Function} transforms value based on `typeof` 14 | */ 15 | 16 | module.exports = function(obj) { 17 | return function(v) { 18 | const type = typeof v; 19 | if (typeof obj[type] === 'function') { 20 | return obj[type](v); 21 | } 22 | return v; 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Path = require('./path'); 4 | const Type = require('./type'); 5 | const { cloneDeep } = require('lodash'); 6 | const get = require('./get'); 7 | const { set } = require('lodash'); 8 | const specialProperties = require('./specialProperties'); 9 | const unmarshal = require('./unmarshal'); 10 | 11 | class Archetype { 12 | constructor(obj) { 13 | this._obj = cloneDeep(obj); 14 | this._paths = {}; 15 | } 16 | 17 | compile(name) { 18 | const _this = this; 19 | this._paths = visitor(this._obj); 20 | 21 | class _Type extends Type { 22 | static paths() { 23 | return _this.paths(); 24 | } 25 | 26 | static path(path, props, opts) { 27 | return _this.path(path, props, opts); 28 | } 29 | 30 | static omit(paths) { 31 | return _this.omit(paths); 32 | } 33 | 34 | static pick(paths) { 35 | return _this.pick(paths); 36 | } 37 | 38 | static transform(fn) { 39 | return _this.transform(fn); 40 | } 41 | 42 | static eachPath(fn) { 43 | return _this.eachPath(fn); 44 | } 45 | } 46 | 47 | _Type.schema = this; 48 | if (name != null) { 49 | _Type.toString = () => name; 50 | Object.defineProperty(_Type, 'name', { value: name }); 51 | } 52 | 53 | return _Type; 54 | } 55 | 56 | json() { 57 | return this._obj; 58 | } 59 | 60 | path(path, props, options) { 61 | if (!props) { 62 | return get(this._obj, path); 63 | } 64 | if (get(options, 'inPlace')) { 65 | set(this._obj, path, props); 66 | return this; 67 | } 68 | const newSchema = new Archetype(this._obj); 69 | set(newSchema._obj, path, props); 70 | return newSchema; 71 | } 72 | 73 | omit(paths) { 74 | const newSchema = new Archetype(this._obj); 75 | if (Array.isArray(paths)) { 76 | for (const path of paths) { 77 | unset(newSchema._obj, path); 78 | } 79 | } else { 80 | unset(newSchema._obj, paths); 81 | } 82 | return newSchema; 83 | } 84 | 85 | pick(paths) { 86 | const obj = {}; 87 | paths = Array.isArray(paths) ? paths : [paths]; 88 | for (const path of paths) { 89 | obj[path] = this._obj[path]; 90 | } 91 | const newSchema = new Archetype(obj); 92 | return newSchema; 93 | } 94 | 95 | transform(fn) { 96 | const newSchema = new Archetype(this._obj); 97 | newSchema._transform(fn, newSchema._obj, []); 98 | return newSchema; 99 | } 100 | 101 | _transform(fn, obj, path) { 102 | Object.keys(obj).forEach(key => { 103 | obj[key] = fn(path.concat([key]).join('.'), obj[key]); 104 | if (typeof obj[key] === 'object' && obj[key] && !('$type' in obj[key])) { 105 | this._transform(fn, obj[key], path.concat([key])); 106 | } 107 | }); 108 | } 109 | 110 | eachPath(fn) { 111 | this._eachPath(fn, this._obj, []); 112 | } 113 | 114 | _eachPath(fn, obj, path) { 115 | Object.keys(obj).forEach(key => { 116 | fn(path.concat([key]).join('.'), obj[key]); 117 | if (typeof obj[key] === 'object' && obj[key] && !('$type' in obj[key])) { 118 | this._eachPath(fn, obj[key], path.concat([key])); 119 | } 120 | }); 121 | } 122 | 123 | paths() { 124 | return Object.keys(this._paths). 125 | map(path => Object.assign({}, this._paths[path], { path })); 126 | } 127 | 128 | unmarshal(obj, projection) { 129 | return unmarshal(obj, this, projection); 130 | } 131 | } 132 | 133 | function visitor(obj) { 134 | var paths = paths || {}; 135 | 136 | visitObject(obj, '', paths); 137 | return paths; 138 | } 139 | 140 | function visitArray(arr, path, paths) { 141 | paths[path] = new Path({ $type: Array }); 142 | if (arr.length > 0) { 143 | if (Array.isArray(arr[0])) { 144 | visitArray(arr[0], path + '.$', paths); 145 | } else if (typeof arr[0] === 'object') { 146 | visitObject(arr[0], path + '.$', paths); 147 | } else { 148 | paths[path + '.$'] = new Path({ $type: arr[0] }); 149 | } 150 | } else { 151 | paths[path + '.$'] = new Path({ $type: null }); 152 | } 153 | return paths[path]; 154 | } 155 | 156 | function visitObject(obj, path, paths) { 157 | if ('$type' in obj) { 158 | if (Array.isArray(obj.$type)) { 159 | visitArray(obj.$type, path, paths); 160 | const withoutType = Object.keys(obj). 161 | filter(key => key !== '$type'). 162 | reduce((clone, key) => { 163 | clone[key] = obj[key]; 164 | return clone; 165 | }, {}); 166 | paths[path] = new Path(Object.assign(paths[path], withoutType)); 167 | return; 168 | } 169 | 170 | if (obj.$type == null) { 171 | throw new Error(`Path ${path} has nullish $type. Use Archetype.Any to skip casting`); 172 | } 173 | 174 | paths[path] = new Path(obj); 175 | return; 176 | } 177 | 178 | if (path) { 179 | paths[path] = new Path({ $type: Object, $schema: obj }); 180 | } 181 | Object.keys(obj).forEach(function(key) { 182 | const value = obj[key]; 183 | if (Array.isArray(value)) { 184 | visitArray(value, join(path, key), paths); 185 | } else if (typeof value === 'object') { 186 | visitObject(value, join(path, key), paths); 187 | } else { 188 | paths[join(path, key)] = new Path({ $type: value }); 189 | } 190 | }); 191 | } 192 | 193 | function join(path, key) { 194 | if (path) { 195 | return path + '.' + key; 196 | } 197 | return key; 198 | } 199 | 200 | function unset(obj, path) { 201 | const pieces = path.split('.'); 202 | let i = 0; 203 | for (i = 0; i < pieces.length - 1; ++i) { 204 | if (specialProperties.has(pieces[i])) { 205 | continue; 206 | } 207 | if (obj == null) { 208 | return; 209 | } 210 | obj = obj[pieces[i]]; 211 | } 212 | delete obj[pieces[pieces.length - 1]]; 213 | } 214 | 215 | exports.Archetype = Archetype; 216 | exports.CastError = require('./unmarshal/error'); 217 | -------------------------------------------------------------------------------- /src/path.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const util = require('util'); 4 | 5 | class Path { 6 | constructor(obj) { 7 | if (obj != null && obj.$default != null && typeof obj.$default === 'object') { 8 | const $default = obj.$default; 9 | const numKeys = Array.isArray($default) ? 10 | $default.length : 11 | Object.keys($default).length; 12 | const isInvalidType = Array.isArray($default) ? 13 | false : 14 | [void 0, Object].indexOf($default.constructor) === -1; 15 | if (numKeys > 0 || isInvalidType) { 16 | throw new Error('Default is a non-empty object `' + 17 | util.inspect($default) + '`. Please make `$default` a function ' + 18 | 'that returns an object instead'); 19 | } 20 | } 21 | Object.assign(this, obj); 22 | } 23 | } 24 | 25 | module.exports = Path; -------------------------------------------------------------------------------- /src/required.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = checkRequired; 4 | 5 | const ValidateError = require('./unmarshal/error'); 6 | const get = require('./get'); 7 | const join = require('./unmarshal/util').join; 8 | const realPathToSchemaPath = require('./unmarshal/util').realPathToSchemaPath; 9 | const shouldSkipPath = require('./util').shouldSkipPath; 10 | 11 | function check(root, v, schema, path, error, projection) { 12 | if (shouldSkipPath(projection, path) || projection.$noRequired) { 13 | return; 14 | } 15 | 16 | const fakePath = realPathToSchemaPath(path); 17 | const schemaPath = schema._paths[fakePath]; 18 | if (isRequired(root, schemaPath) && v == null) { 19 | return error.markError(path, new Error(`Path "${path}" is required`)); 20 | } 21 | 22 | if (!path) { 23 | for (const key of Object.keys(schema._obj)) { 24 | check(root, v[key], schema, join(fakePath, key), error, projection); 25 | } 26 | } else if (schemaPath) { 27 | if (schemaPath.$type === Object && schemaPath.$schema) { 28 | for (const key of Object.keys(schemaPath.$schema)) { 29 | check(root, get(v, key), schema, join(fakePath, key), error, projection); 30 | } 31 | } else if (schemaPath.$type === Array) { 32 | const arr = v || []; 33 | for (let index = 0; index < arr.length; ++index) { 34 | check(root, arr[index], schema, join(fakePath, index.toString()), 35 | error, projection); 36 | } 37 | } 38 | } 39 | } 40 | 41 | function isRequired(root, schemaPath) { 42 | if (!schemaPath) { 43 | return false; 44 | } 45 | if (typeof schemaPath.$required === 'function') { 46 | return schemaPath.$required(root); 47 | } 48 | return schemaPath.$required; 49 | } 50 | 51 | function checkRequired(obj, schema, projection) { 52 | const error = new ValidateError(); 53 | check(obj, obj, schema, '', error, projection); 54 | return error; 55 | } 56 | -------------------------------------------------------------------------------- /src/specialProperties.js: -------------------------------------------------------------------------------- 1 | module.exports = new Set(['__proto__', 'constructor', 'prototype']); 2 | -------------------------------------------------------------------------------- /src/symbols.js: -------------------------------------------------------------------------------- 1 | exports.Any = Symbol.for('Archetype.Any'); -------------------------------------------------------------------------------- /src/to.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Any = require('./symbols').Any; 4 | const inspect = require('util').inspect; 5 | const isPOJO = require('./helpers/isPOJO'); 6 | 7 | module.exports = to; 8 | 9 | const CAST_PRIMITIVES = { 10 | number: v => { 11 | // Nasty edge case: Number converts '', ' ', and `{ toString: () => '' }` to 0 12 | if (typeof v !== 'number' && v.toString().trim() === '') { 13 | throw new Error(`Could not cast "${inspect(v)}" to number`); 14 | } 15 | 16 | if (isPOJO(v)) { 17 | throw new Error(`Could not cast "${inspect(v)}" to number`); 18 | } 19 | 20 | const res = Number(v).valueOf(); 21 | if (Number.isNaN(res)) { 22 | throw new Error(`Could not cast "${inspect(v)}" to number`); 23 | } 24 | return res; 25 | }, 26 | string: v => { 27 | if (isPOJO(v)) { 28 | throw new Error(`Could not cast "${inspect(v)}" to string`); 29 | } 30 | return String(v); 31 | }, 32 | boolean: v => { 33 | const str = String(v); 34 | if (str === '1' || str === 'true' || str === 'yes') { 35 | return true; 36 | } 37 | if (str === '0' || str === 'false' || str === 'no') { 38 | return false; 39 | } 40 | throw new Error(`Could not cast "${inspect(v)}" to boolean`); 41 | } 42 | } 43 | 44 | function to(v, type) { 45 | if (v == null) { 46 | return v; 47 | } 48 | 49 | if (type === Any) { 50 | return v; 51 | } 52 | 53 | if (typeof type === 'string') { 54 | if (!CAST_PRIMITIVES[type]) { 55 | throw new Error(`"${type}" is not a valid primitive type`); 56 | } 57 | 58 | if (type === 'number' && Number.isNaN(v)) { 59 | return CAST_PRIMITIVES[type](v); 60 | } 61 | if (typeof v === type) { 62 | return v; 63 | } 64 | return CAST_PRIMITIVES[type](v); 65 | } 66 | 67 | if (!(v instanceof type)) { 68 | return new type(v); 69 | } 70 | return v; 71 | } 72 | -------------------------------------------------------------------------------- /src/type.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { cloneDeep } = require('lodash'); 4 | const unmarshal = require('./unmarshal'); 5 | 6 | class Type { 7 | constructor(obj, projection, options) { 8 | options = options || {}; 9 | if (options.clone !== false) { 10 | obj = cloneDeep(obj); 11 | } 12 | 13 | Object.assign(this, unmarshal(obj, this.constructor.schema, projection)); 14 | } 15 | } 16 | 17 | module.exports = Type; 18 | -------------------------------------------------------------------------------- /src/unmarshal/error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class CastError extends Error { 4 | constructor() { 5 | super(); 6 | this.errors = {}; 7 | this.hasError = false; 8 | this._isArchetypeError = true; 9 | } 10 | 11 | markError(path, error) { 12 | const standardized = new ValidatorError(error.message); 13 | standardized.stack = error.stack; 14 | this.errors[path] = standardized; 15 | this.hasError = true; 16 | this.message = this.toString(); 17 | return this; 18 | } 19 | 20 | merge(error) { 21 | if (!error) { 22 | return this; 23 | } 24 | for (const key of Object.keys(error.errors || {})) { 25 | this.errors[key] = error.errors[key]; 26 | } 27 | this.hasError = Object.keys(this.errors).length > 0; 28 | this.message = this.toString(); 29 | return this; 30 | } 31 | 32 | toString() { 33 | let str = []; 34 | for (const key of Object.keys(this.errors)) { 35 | str.push(`${key}: ${this.errors[key].message || value}`); 36 | } 37 | return str.join(', '); 38 | } 39 | } 40 | 41 | class ValidatorError extends Error { 42 | toJSON() { 43 | return { message: this.message }; 44 | } 45 | } 46 | 47 | module.exports = CastError; 48 | -------------------------------------------------------------------------------- /src/unmarshal/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ValidateError = require('./error'); 4 | const applyDefaults = require('../defaults'); 5 | const checkRequired = require('../required'); 6 | const getOwnProperty = require('../helpers/getOwnProperty'); 7 | const handleCast = require('./util').handleCast; 8 | const inspect = require('util').inspect; 9 | const join = require('./util').join; 10 | const mpath = require('mpath'); 11 | const realPathToSchemaPath = require('./util').realPathToSchemaPath; 12 | const shouldSkipPath = require('../util').shouldSkipPath; 13 | 14 | module.exports = castDocument; 15 | 16 | function markSubpaths(projection, path, val) { 17 | const pieces = path.split('.'); 18 | let cur = pieces[0]; 19 | projection[cur] = val; 20 | for (let i = 1; i < pieces.length; ++i) { 21 | cur += `.${pieces[i]}`; 22 | projection[cur] = val; 23 | } 24 | } 25 | 26 | function handleProjection(projection) { 27 | if (!projection) { 28 | return { $inclusive: true }; 29 | } 30 | projection.$hasExclusiveChild = {}; 31 | let inclusive = null; 32 | for (const key of Object.keys(projection)) { 33 | if (key.startsWith('$')) { 34 | continue; 35 | } 36 | if (projection[key] > 0) { 37 | if (inclusive === true) { 38 | throw new Error("Can't mix inclusive and exclusive in projection"); 39 | } 40 | markSubpaths(projection.$hasExclusiveChild, key, projection[key]); 41 | inclusive = false; 42 | } else { 43 | if (inclusive === false) { 44 | throw new Error("Can't mix inclusive and exclusive in projection"); 45 | } 46 | inclusive = true; 47 | } 48 | } 49 | 50 | if (inclusive === null) { 51 | inclusive = true; 52 | } 53 | projection.$inclusive = inclusive; 54 | 55 | return projection; 56 | } 57 | 58 | function castDocument(obj, schema, projection) { 59 | projection = handleProjection(projection); 60 | if (obj == null) { 61 | throw new Error(`Can't cast null or undefined`); 62 | } 63 | applyDefaults(obj, schema, projection); 64 | const error = new ValidateError(); 65 | error.merge(visitObject(obj, schema, projection, '').error); 66 | error.merge(checkRequired(obj, schema, projection)); 67 | error.merge(runValidation(obj, schema, projection)); 68 | if (error.hasError) { 69 | throw error; 70 | } 71 | return obj; 72 | } 73 | 74 | function visitArray(arr, schema, projection, path) { 75 | let error = new ValidateError(); 76 | let curPath = realPathToSchemaPath(path); 77 | let newPath = join(curPath, '$'); 78 | 79 | if (arr == null) { 80 | return { 81 | value: arr, 82 | error: null 83 | }; 84 | } 85 | 86 | if (!Array.isArray(arr)) { 87 | arr = [arr]; 88 | } 89 | 90 | if (!schema._paths[newPath] || !schema._paths[newPath].$type) { 91 | return { 92 | value: arr, 93 | error: null 94 | }; 95 | } 96 | 97 | const pathOptions = schema._paths[newPath]; 98 | arr.forEach(function(value, index) { 99 | if (getOwnProperty(pathOptions, '$transform') != null) { 100 | try { 101 | arr[index] = pathOptions.$transform(arr[index]); 102 | } catch (err) { 103 | error.markError(`newPath.${index}`, err); 104 | return; 105 | } 106 | value = arr[index]; 107 | } 108 | if (pathOptions.$type === Array || 109 | Array.isArray(schema._paths[newPath].$type)) { 110 | let res = visitArray(value, schema, projection, join(path, index, true)); 111 | if (res.error) { 112 | error.merge(res.error); 113 | } 114 | arr[index] = res.value; 115 | return; 116 | } else if (pathOptions.$type === Object) { 117 | let res = visitObject(value, schema, projection, join(path, index, true)); 118 | if (res.error) { 119 | error.merge(res.error); 120 | } 121 | arr[index] = res.value; 122 | return; 123 | } 124 | 125 | try { 126 | handleCast(arr, index, pathOptions.$type); 127 | } catch(err) { 128 | error.markError(join(path, index, true), err); 129 | } 130 | }); 131 | 132 | return { 133 | value: arr, 134 | error: (error.hasError ? error : null) 135 | }; 136 | } 137 | 138 | function visitObject(obj, schema, projection, path) { 139 | let error = new ValidateError(); 140 | if (typeof obj !== 'object' || Array.isArray(obj)) { 141 | let err = new Error('Could not cast ' + require('util').inspect(obj) + 142 | ' to Object'); 143 | error.markError(path, err); 144 | return { 145 | value: null, 146 | error: error 147 | }; 148 | } 149 | 150 | let fakePath = realPathToSchemaPath(path); 151 | const curSchema = schema._paths[fakePath]; 152 | if (fakePath && !curSchema.$schema) { 153 | return { 154 | value: obj, 155 | error: (error.hasError ? error : null) 156 | }; 157 | } 158 | 159 | Object.keys(obj).forEach(function(key) { 160 | let value = obj[key]; 161 | let newPath = join(fakePath, key); 162 | if (!schema._paths.hasOwnProperty(newPath) || shouldSkipPath(projection, newPath)) { 163 | delete obj[key]; 164 | return; 165 | } 166 | const newSchema = schema._paths[newPath]; 167 | const pathOptions = schema._paths[newPath]; 168 | if (getOwnProperty(pathOptions, '$transform') != null) { 169 | try { 170 | obj[key] = pathOptions.$transform(obj[key]); 171 | } catch (err) { 172 | error.markError(newPath, err); 173 | return; 174 | } 175 | value = obj[key]; 176 | } 177 | if (newSchema.$type == null) { 178 | // If type not specified, no type casting 179 | return; 180 | } 181 | 182 | if (pathOptions.$type === Array || 183 | Array.isArray(schema._paths[newPath].$type)) { 184 | let res = visitArray(value, schema, projection, newPath); 185 | if (res.error) { 186 | error.merge(res.error); 187 | } 188 | obj[key] = res.value; 189 | return; 190 | } else if (pathOptions.$type === Object) { 191 | if (value == null) { 192 | delete obj[key]; 193 | return; 194 | } 195 | let res = visitObject(value, schema, projection, newPath); 196 | if (res.error) { 197 | error.merge(res.error); 198 | } 199 | obj[key] = res.value; 200 | return; 201 | } 202 | 203 | try { 204 | handleCast(obj, key, pathOptions.$type, pathOptions.$transform); 205 | } catch(err) { 206 | error.markError(join(path, key, true), err); 207 | } 208 | }); 209 | 210 | return { 211 | value: obj, 212 | error: (error.hasError ? error : null) 213 | }; 214 | } 215 | 216 | function runValidation(obj, schema, projection) { 217 | const error = new ValidateError(); 218 | for (const path of Object.keys(schema._paths)) { 219 | if (shouldSkipPath(projection, path)) { 220 | continue; 221 | } 222 | 223 | const _path = path.replace(/\.\$\./g, '.').replace(/\.\$$/g, ''); 224 | const val = mpath.get(_path, obj); 225 | if (!schema._paths[path].$validate && !schema._paths[path].$enum) { 226 | continue; 227 | } 228 | 229 | if (val == null) { 230 | continue; 231 | } 232 | 233 | if (Array.isArray(schema._paths[path].$enum)) { 234 | if (Array.isArray(val)) { 235 | val.forEach((val, index) => { 236 | if (schema._paths[path].$enum.indexOf(val) === -1) { 237 | const msg = `Value "${val}" invalid, allowed values are ` + 238 | `"${inspect(schema._paths[path].$enum)}"`; 239 | error.markError([path, index].join('.'), new Error(msg)); 240 | } 241 | }); 242 | } else if (schema._paths[path].$enum.indexOf(val) === -1) { 243 | const msg = `Value "${val}" invalid, allowed values are ` + 244 | `"${inspect(schema._paths[path].$enum)}"`; 245 | error.markError(path, new Error(msg)); 246 | continue; 247 | } 248 | } 249 | 250 | if (schema._paths[path].$validate) { 251 | if (Array.isArray(val)) { 252 | if (path.indexOf('$') === -1) { 253 | try { 254 | schema._paths[path].$validate(val, schema._paths[path], obj); 255 | } catch(_error) { 256 | error.markError(path, _error); 257 | } 258 | } else { 259 | val.forEach((val, index) => { 260 | try { 261 | schema._paths[path].$validate(val, schema._paths[path], obj); 262 | } catch(_error) { 263 | error.markError(`${path}.${index}`, _error); 264 | } 265 | }); 266 | } 267 | } else { 268 | try { 269 | schema._paths[path].$validate(val, schema._paths[path], obj); 270 | } catch(_error) { 271 | error.markError(path, _error); 272 | } 273 | } 274 | } 275 | } 276 | return error; 277 | } 278 | -------------------------------------------------------------------------------- /src/unmarshal/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const to = require('../to'); 4 | 5 | const noop = x => x; 6 | 7 | exports.handleCast = function(obj, key, type, transform) { 8 | transform = transform == null ? noop : transform; 9 | obj[key] = to(transform(obj[key]), type); 10 | }; 11 | 12 | exports.realPathToSchemaPath = function(path) { 13 | return path.replace(/\.\d+\./g, '.$.').replace(/\.\d+$/, '.$'); 14 | }; 15 | 16 | exports.join = function(path, key, real) { 17 | if (!real && typeof key === 'number') { 18 | key = '$'; 19 | } 20 | if (path) { 21 | return path + '.' + key; 22 | } 23 | return key; 24 | }; 25 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getOwnProperty = require('./helpers/getOwnProperty'); 4 | 5 | exports.shouldSkipPath = function(projection, path) { 6 | if (projection.$inclusive) { 7 | return getOwnProperty(projection, path) != null; 8 | } else { 9 | const parts = path.split('.'); 10 | let cur = parts[0]; 11 | for (let i = 0; i < parts.length - 1; ++i) { 12 | if (getOwnProperty(projection, cur) != null) { 13 | return false; 14 | } 15 | cur += '.' + parts[i + 1]; 16 | } 17 | return projection[path] == null && projection.$hasExclusiveChild[path] == null; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/defaults.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Archetype = require('../'); 4 | const assert = require('assert'); 5 | 6 | describe('defaults', function() { 7 | it('basic $default', function() { 8 | const Breakfast = new Archetype({ 9 | name: { $type: 'string', $required: true, $default: 'bacon' }, 10 | names: [{ $type: 'string', $required: true, $default: 'eggs' }], 11 | title: { $type: 'string', $default: 'N/A' } 12 | }).compile(); 13 | 14 | const val = new Breakfast({ names: [null, 'avocado'], title: 'test' }); 15 | assert.deepEqual(val, { 16 | name: 'bacon', 17 | names: ['eggs', 'avocado'], 18 | title: 'test' 19 | }); 20 | }); 21 | 22 | it('default function', function() { 23 | const now = Date.now(); 24 | const Model = new Archetype({ 25 | createdAt: { $type: Date, $required: true, $default: Date.now } 26 | }).compile(); 27 | 28 | const val = new Model({}); 29 | assert.ok(val.createdAt.getTime() >= now, `${val.createdAt}, ${now}`); 30 | }); 31 | 32 | it('deep defaults', function() { 33 | const C = new Archetype({ 34 | firstName: { 35 | $type: 'string', 36 | $default: () => 'test' 37 | }, 38 | name: { 39 | first: { 40 | $type: 'string', 41 | $default: () => 'test' 42 | } 43 | }, 44 | multiple: { 45 | a: { 46 | $type: 'string', 47 | $default: () => 'test' 48 | }, 49 | b: { 50 | $type: 'string' 51 | } 52 | } 53 | }).compile('c'); 54 | 55 | let v = new C({ multiple: { b: 'foo' } }); 56 | assert.equal(v.firstName, 'test'); 57 | assert.equal(v.name.first, 'test'); 58 | assert.equal(v.multiple.a, 'test'); 59 | assert.equal(v.multiple.b, 'foo'); 60 | }); 61 | 62 | it('clones empty default array and object', function() { 63 | const T = new Archetype({ 64 | myArr: { 65 | $type: ['string'], 66 | $default: [] 67 | }, 68 | myObj: { 69 | $type: Object, 70 | $default: {} 71 | } 72 | }).compile('T'); 73 | 74 | const obj1 = new T({}); 75 | 76 | obj1.myArr.push('test'); 77 | obj1.myObj.hello = 'world'; 78 | 79 | assert.equal(obj1.myArr[0], 'test'); 80 | assert.equal(obj1.myObj.hello, 'world'); 81 | 82 | const obj2 = new T({}); 83 | assert.deepEqual(obj2.myArr, []); 84 | assert.deepEqual(obj2.myObj, {}); 85 | }); 86 | 87 | it('throws if default is non-empty object', function() { 88 | assert.throws(() => { 89 | new Archetype({ 90 | myArr: { 91 | $type: ['string'], 92 | $default: ['test'] 93 | } 94 | }).compile('T'); 95 | }, /Default is a non-empty object/); 96 | 97 | assert.throws(() => { 98 | new Archetype({ 99 | myArr: { 100 | $type: {}, 101 | $default: { test: 42 } 102 | } 103 | }).compile('T'); 104 | }, /Default is a non-empty object/); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /test/helpers.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const matchType = require('../').matchType; 5 | 6 | describe('matchType', function() { 7 | it('works with JSON.parse()', function() { 8 | const parse = matchType({ string: JSON.parse }); 9 | 10 | const obj = { hello: 'world' }; 11 | 12 | // If given a string, will parse it 13 | assert.deepEqual(parse(JSON.stringify(obj)), obj); 14 | 15 | // If not, will do nothing 16 | assert.strictEqual(parse(obj), obj); 17 | assert.strictEqual(parse(null), null); 18 | assert.strictEqual(parse(undefined), undefined); 19 | }); 20 | 21 | it('works with trimming strings', function() { 22 | const trim = matchType({ string: str => str.trim() }); 23 | 24 | // If given a string, will trim it 25 | assert.equal(trim(' abc '), 'abc'); 26 | assert.equal(trim('abc'), 'abc'); 27 | 28 | // If not, will do nothing 29 | const obj = {}; 30 | assert.strictEqual(trim(obj), obj); 31 | assert.strictEqual(trim(null), null); 32 | assert.strictEqual(trim(undefined), undefined); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/to.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const to = require('../src/to'); 5 | 6 | describe('to()', function() { 7 | it('handles strings', function() { 8 | assert.strictEqual(to('foo', 'string'), 'foo'); 9 | assert.strictEqual(to(42, 'string'), '42'); 10 | assert.strictEqual(to(void 0, 'string'), void 0); 11 | assert.strictEqual(to(null, 'string'), null); 12 | }); 13 | 14 | it('handles numbers', function() { 15 | assert.strictEqual(to('42 ', 'number'), 42); 16 | assert.strictEqual(to(' 42', 'number'), 42); 17 | assert.strictEqual(to(new Date(1), 'number'), 1); 18 | assert.throws(() => to({ valueOf: () => '' }, 'number'), /Could not cast/i); 19 | assert.throws(() => to({ valueOf: () => null }, 'number'), /Could not cast/i); 20 | assert.strictEqual(to(void 0, 'string'), void 0); 21 | assert.strictEqual(to(null, 'string'), null); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/unit.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Archetype = require('../'); 4 | const Path = require('../src/path'); 5 | const assert = require('assert'); 6 | const mongodb = require('mongodb'); 7 | 8 | describe('schema', function() { 9 | it('compiles paths', function() { 10 | let schema = new Archetype({ 11 | test: 'number', 12 | nested: { 13 | a: { 14 | $type: 'number' 15 | } 16 | } 17 | }); 18 | 19 | schema.compile(); 20 | 21 | assert.deepEqual(schema._paths, { 22 | test: { $type: 'number' }, 23 | nested: { $type: Object, $schema: { a: { $type: 'number' } } }, 24 | 'nested.a': { $type: 'number' } 25 | }); 26 | }); 27 | 28 | it('handles arrays', function() { 29 | let schema = new Archetype({ 30 | test: 'number', 31 | arrMixed: [], 32 | arrPlain: ['number'], 33 | arrNested: [['number']] 34 | }); 35 | 36 | schema.compile(); 37 | 38 | assert.deepEqual(schema._paths, { 39 | 'test': { $type: 'number' }, 40 | 'arrMixed': { $type: Array }, 41 | 'arrMixed.$': { $type: null }, 42 | 'arrPlain': { $type: Array }, 43 | 'arrPlain.$': { $type: 'number' }, 44 | 'arrNested': { $type: Array }, 45 | 'arrNested.$': { $type: Array }, 46 | 'arrNested.$.$': { $type: 'number' } 47 | }); 48 | 49 | assert.ok(schema._paths['test'] instanceof Path); 50 | }); 51 | 52 | it('handles nested document arrays', function() { 53 | let schema = new Archetype({ 54 | docs: [{ _id: 'number' }] 55 | }); 56 | 57 | schema.compile(); 58 | 59 | assert.deepEqual(schema._paths, { 60 | 'docs': { $type: Array }, 61 | 'docs.$': { $type: Object, $schema: { _id: 'number' } }, 62 | 'docs.$._id': { $type: 'number' } 63 | }); 64 | }); 65 | 66 | it('treats keys that contain $type as a terminus', function() { 67 | let schema = new Archetype({ 68 | test: { 69 | $type: 1 70 | } 71 | }); 72 | 73 | schema.compile(); 74 | 75 | assert.deepEqual(schema._paths, { 76 | 'test': { $type: 1 } 77 | }); 78 | }); 79 | 80 | it('supports $ keys', function() { 81 | let schema = new Archetype({ 82 | $lt: 'number', 83 | $gt: 'number' 84 | }); 85 | 86 | schema.compile(); 87 | 88 | assert.deepEqual(schema._paths, { 89 | '$lt': { $type: 'number' }, 90 | '$gt': { $type: 'number' } 91 | }); 92 | }); 93 | 94 | it('adding paths with .path()', function() { 95 | let schema = new Archetype({ 96 | docs: [{ _id: 'number' }] 97 | }); 98 | 99 | assert.ok(!schema.path('_id')); 100 | const newSchema = schema.path('_id', { $type: 'number' }); 101 | assert.deepEqual(newSchema.path('_id'), { $type: 'number' }); 102 | }); 103 | 104 | it('arrays with $type', function() { 105 | const schema = new Archetype({ 106 | docs: { $type: [{ _id: 'number' }] } 107 | }); 108 | 109 | schema.compile(); 110 | 111 | assert.deepEqual(schema._paths, { 112 | 'docs': { $type: Array }, 113 | 'docs.$': { $type: Object, $schema: { _id: 'number' } }, 114 | 'docs.$._id': { $type: 'number' } 115 | }); 116 | }); 117 | }); 118 | 119 | describe('unmarshal()', function() { 120 | it('ignores paths not defined in the schema', function() { 121 | const Person = new Archetype({ 122 | name: { $type: 'string' } 123 | }).compile(); 124 | 125 | const axl = { name: 'Axl Rose', role: 'Lead Singer' }; 126 | const res = new Person(axl); 127 | assert.deepEqual(res, { name: 'Axl Rose' }); 128 | }); 129 | 130 | it('casts values to specified types', function() { 131 | const Person = new Archetype({ 132 | _id: { $type: mongodb.ObjectId }, 133 | name: { $type: 'string' }, 134 | born: { $type: 'number' } 135 | }).compile(); 136 | 137 | const axl = { 138 | _id: '000000000000000000000001', 139 | name: 'Axl Rose', 140 | born: '1962' 141 | }; 142 | 143 | const res = new Person(axl); 144 | 145 | assert.deepEqual(res, { 146 | _id: mongodb.ObjectId('000000000000000000000001'), 147 | name: 'Axl Rose', 148 | born: 1962 149 | }); 150 | assert.ok(res instanceof Person); 151 | }); 152 | 153 | it('only casts if necessary', function() { 154 | const Person = new Archetype({ 155 | _id: { $type: mongodb.ObjectId }, 156 | name: { $type: 'string' }, 157 | born: { $type: 'number' } 158 | }).compile(); 159 | 160 | const axl = { 161 | _id: new mongodb.ObjectId('000000000000000000000001'), 162 | name: 'Axl Rose', 163 | born: 1962 164 | }; 165 | 166 | const res = new Person(axl); 167 | 168 | assert.deepEqual(res, { 169 | _id: new mongodb.ObjectId('000000000000000000000001'), 170 | name: 'Axl Rose', 171 | born: 1962 172 | }); 173 | assert.ok(res instanceof Person); 174 | assert.ok(res._id instanceof mongodb.ObjectId); 175 | assert.equal(res._id.toHexString(), '000000000000000000000001'); 176 | }); 177 | 178 | it('casts into arrays', function() { 179 | let Band = new Archetype({ 180 | members: [{ $type: mongodb.ObjectId }] 181 | }).compile(); 182 | 183 | const band = { 184 | members: '000000000000000000000001' 185 | }; 186 | 187 | const res = new Band(band); 188 | 189 | assert.deepEqual(res, { 190 | members: [mongodb.ObjectId('000000000000000000000001')] 191 | }); 192 | }); 193 | 194 | it('boolean to array', function() { 195 | let Band = new Archetype({ 196 | test: { $type: Array } 197 | }).compile(); 198 | 199 | const res = new Band({ test: true }); 200 | assert.deepEqual(res.test, [true]); 201 | }); 202 | 203 | it('casts deeply nested arrays', function() { 204 | const Graph = new Archetype({ 205 | points: [[{ $type: 'number' }]] 206 | }).compile(); 207 | 208 | const obj = { points: 1 }; 209 | const res = new Graph(obj); 210 | 211 | assert.deepEqual(res, { 212 | points: [[1]] 213 | }); 214 | }); 215 | 216 | it('does not cast $type: Object', function() { 217 | const Test = new Archetype({ 218 | nested: { $type: Object } 219 | }).compile(); 220 | 221 | const obj = { nested: { hello: 'world' }, removed: 'field' }; 222 | const res = new Test(obj); 223 | 224 | assert.deepEqual(res, { 225 | nested: { hello: 'world' } 226 | }); 227 | }); 228 | 229 | it('error if you cast an object to a primitive', function() { 230 | const Person = new Archetype({ 231 | name: { 232 | first: { $type: 'string' }, 233 | last: { $type: 'string' } 234 | } 235 | }).compile(); 236 | 237 | let user = { name: 'Axl Rose' }; 238 | let errored = false; 239 | try { 240 | new Person(user); 241 | } catch(error) { 242 | errored = true; 243 | assert.deepEqual(error.errors, { 244 | name: new Error("Could not cast 'Axl Rose' to Object") 245 | }); 246 | 247 | const stringified = JSON.parse(JSON.stringify(error)); 248 | assert.deepEqual(stringified.errors, { 249 | name: { message: "Could not cast 'Axl Rose' to Object" } 250 | }); 251 | } 252 | assert.ok(errored); 253 | }); 254 | 255 | it('ignores if $type not specified', function() { 256 | const Band = new Archetype({ 257 | members: { $lookUp: { ref: 'Test' }, $type: Archetype.Any }, 258 | tags: { $type: Array } 259 | }).compile(); 260 | 261 | const band = { members: { x: 1 } }; 262 | const res = new Band(band); 263 | assert.deepEqual(res, { members: { x: 1 } }) 264 | }); 265 | 266 | it('array of objects to primitive', function() { 267 | const Band = new Archetype({ 268 | names: [{ 269 | first: { $type: 'string' }, 270 | last: { $type: 'string' } 271 | }] 272 | }).compile(); 273 | 274 | const user = { names: ['Axl Rose'] }; 275 | let errored = false; 276 | try { 277 | new Band(user); 278 | } catch(error) { 279 | errored = true; 280 | assert.deepEqual(error.errors, { 281 | 'names.0': new Error("Could not cast 'Axl Rose' to Object") 282 | }); 283 | } 284 | assert.ok(errored); 285 | }); 286 | 287 | it('array of objects', function() { 288 | const Band = new Archetype({ 289 | people: [{name: { $type: 'string', $required: true } }] 290 | }).compile(); 291 | 292 | const v = { people: [{ name: 'Axl Rose', other: 'field' }] }; 293 | const res = new Band(v); 294 | assert.deepEqual(res, { 295 | people: [{ name: 'Axl Rose' }] 296 | }); 297 | }); 298 | 299 | it('disallows __proto__, constructor', function() { 300 | const Test = new Archetype({ 301 | test: { $type: 'string' } 302 | }).compile(); 303 | 304 | let res = new Test(JSON.parse('{"__proto__":"foo","test":"bar"}')); 305 | assert.deepEqual(res, { 306 | test: 'bar' 307 | }); 308 | assert.equal(res.__proto__, Test.prototype); 309 | 310 | res = new Test(JSON.parse('{"constructor":"foo","test":"bar"}')); 311 | assert.deepEqual(res, { 312 | test: 'bar' 313 | }); 314 | assert.equal(res.constructor, Test); 315 | }); 316 | 317 | it('required', function() { 318 | const Person = new Archetype({ 319 | name: { $type: 'string', $required: true } 320 | }).compile(); 321 | 322 | let errored = false; 323 | try { 324 | new Person({}); 325 | } catch(error) { 326 | errored = true; 327 | assert.deepEqual(error.errors, { 328 | name: new Error('Path "name" is required') 329 | }); 330 | } 331 | assert.ok(errored); 332 | 333 | errored = false; 334 | try { 335 | new Person({ name: undefined }); 336 | } catch(error) { 337 | errored = true; 338 | assert.deepEqual(error.errors, { 339 | name: new Error('Path "name" is required') 340 | }); 341 | 342 | console.log(JSON.stringify(error, null, ' ')) 343 | assert.deepEqual(JSON.parse(JSON.stringify(error)).errors, { 344 | name: { message: 'Path "name" is required' } 345 | }); 346 | } 347 | assert.ok(errored); 348 | 349 | new Person({}, { $noRequired: 1 }); 350 | }); 351 | 352 | it('required with ObjectIds', function() { 353 | const Person = new Archetype({ 354 | name: { $type: mongodb.ObjectId, $required: true } 355 | }).compile(); 356 | 357 | let errored = false; 358 | try { 359 | new Person({ name: undefined }); 360 | } catch(error) { 361 | errored = true; 362 | assert.deepEqual(error.errors, { 363 | name: new Error('Path "name" is required') 364 | }); 365 | } 366 | assert.ok(errored); 367 | }); 368 | 369 | it('recursive', function() { 370 | let NodeType = new Archetype({ 371 | value: { $type: 'string' } 372 | }).compile('NodeType'); 373 | NodeType. 374 | path('left', { $type: NodeType }, { inPlace: true }). 375 | path('right', { $type: NodeType }, { inPlace: true }). 376 | compile('NodeType'); 377 | 378 | const raw = { 379 | value: 'root', 380 | left: { 381 | value: 'left' 382 | }, 383 | right: { 384 | left: { 385 | value: 'right->left' 386 | }, 387 | value: 'right' 388 | } 389 | }; 390 | assert.deepEqual(raw, new NodeType(raw)); 391 | assert.equal(raw.right.left.value, 'right->left'); 392 | }); 393 | 394 | it('required function', function() { 395 | const Person = new Archetype({ 396 | requireName: 'boolean', 397 | name: { $type: 'string', $required: doc => doc.requireName } 398 | }).compile(); 399 | 400 | // works 401 | new Person({}); 402 | 403 | let errored = false; 404 | try { 405 | new Person({ requireName: true }); 406 | } catch(error) { 407 | errored = true; 408 | assert.deepEqual(error.errors, { 409 | name: new Error('Path "name" is required') 410 | }); 411 | } 412 | assert.ok(errored); 413 | 414 | // Works 415 | new Person({ requireName: true }, { $noRequired: 1 }); 416 | }); 417 | 418 | it('required in array', function() { 419 | const Person = new Archetype({ 420 | names: [{ $type: 'string', $required: true }] 421 | }).compile(); 422 | 423 | let errored = false; 424 | try { 425 | new Person({ names: ['test', null] }); 426 | } catch(error) { 427 | errored = true; 428 | assert.deepEqual(error.errors, { 429 | 'names.1': new Error('Path "names.1" is required') 430 | }); 431 | } 432 | assert.ok(errored); 433 | }); 434 | 435 | it('no defaults for projecton', function() { 436 | const now = Date.now(); 437 | const Model = new Archetype({ 438 | name: { $type: 'string', $default: 'test' }, 439 | createdAt: { $type: Date, $required: true, $default: Date.now } 440 | }).compile(); 441 | 442 | const val = new Model({}, { createdAt: false }); 443 | assert.deepEqual(val, { name: 'test' }); 444 | }); 445 | 446 | it('no defaults for projecton', function() { 447 | const now = Date.now(); 448 | const Model = new Archetype({ 449 | name: { $type: 'string', $default: 'test' }, 450 | createdAt: { $type: Date, $required: true, $default: Date.now } 451 | }).compile(); 452 | 453 | const val = new Model({}, { $noDefaults: true, $noRequired: true }); 454 | assert.deepEqual(val, {}); 455 | }); 456 | 457 | it('projections', function() { 458 | const Person = new Archetype({ 459 | name: { 460 | first: { $type: 'string' }, 461 | last: { $type: 'string' } 462 | } 463 | }).compile(); 464 | 465 | const justFirst = new Person({ name: { first: 'Axl', last: 'Rose' } }, 466 | { 'name.first': 1 }); 467 | assert.deepEqual(justFirst, { name: { first: 'Axl' } }); 468 | const justLast = new Person({ name: { first: 'Axl', last: 'Rose' } }, 469 | { 'name.first': 0 }); 470 | assert.deepEqual(justLast, { name: { last: 'Rose' } }); 471 | }); 472 | 473 | it('validation', function() { 474 | const Breakfast = new Archetype({ 475 | bacon: { 476 | $type: 'number', 477 | $required: true, 478 | $validate: v => { 479 | if (v < 3) { 480 | throw new Error('Need more bacon'); 481 | } 482 | } 483 | } 484 | }).compile(); 485 | 486 | assert.throws(function() { 487 | new Breakfast({ bacon: 2 }); 488 | }, /Need more bacon/); 489 | }); 490 | 491 | it('enum', function() { 492 | const Breakfast = new Archetype({ 493 | type: { 494 | $type: 'string', 495 | $enum: ['steak and eggs', 'bacon and eggs'] 496 | }, 497 | addOns: [ 498 | { name: { $type: 'string', $enum: ['cheese', 'sour cream'] } } 499 | ] 500 | }).compile(); 501 | 502 | assert.throws(function() { 503 | new Breakfast({ type: 'waffles' }); 504 | }, /Value "waffles" invalid/); 505 | 506 | assert.throws(function() { 507 | new Breakfast({ addOns: [{ name: 'maple syrup' }] }); 508 | }, /Value "maple syrup" invalid/); 509 | 510 | // works 511 | new Breakfast({ type: 'steak and eggs' }); 512 | new Breakfast({}); 513 | new Breakfast({ addOns: [{ name: 'cheese' }] }); 514 | }); 515 | 516 | it('validation with arrays', function() { 517 | const Band = new Archetype({ 518 | name: 'string', 519 | members: { 520 | $type: ['string'], 521 | $validate: v => { 522 | if (v.length !== 5) { 523 | throw new Error('Must have 5 members'); 524 | } 525 | } 526 | } 527 | }).compile(); 528 | 529 | assert.throws(function() { 530 | new Band({ name: "Guns N' Roses", members: ['Axl Rose'] }); 531 | }, /Must have 5 members/); 532 | 533 | new Band({ 534 | name: "Guns N' Roses", 535 | members: ['Axl Rose', 'Slash', 'Izzy', 'Duff', 'Adler'] 536 | }); 537 | }); 538 | 539 | it('supports nested types', function() { 540 | const Person = new Archetype({ 541 | name: 'string' 542 | }).compile(); 543 | const Band = new Archetype({ 544 | name: 'string', 545 | singer: { 546 | $type: Person 547 | } 548 | }).compile(); 549 | 550 | const gnr = new Band({ 551 | name: "Guns N' Roses", 552 | singer: { 553 | name: 'Axl Rose' 554 | } 555 | }); 556 | assert.deepEqual(gnr, { 557 | name: "Guns N' Roses", 558 | singer: { 559 | name: 'Axl Rose' 560 | } 561 | }); 562 | }); 563 | 564 | it('compile takes a name param', function() { 565 | const Person = new Archetype({ 566 | name: 'string' 567 | }).compile('PersonModel'); 568 | assert.equal(Person.toString(), 'PersonModel'); 569 | assert.ok(new Person({}) instanceof Person); 570 | assert.equal(new Person({}).constructor.name, 'PersonModel'); 571 | }); 572 | 573 | it('handles inheritance correctly with path(), etc.', function() { 574 | const ABase = new Archetype({ x: { $type: 'string' } }).compile('ABase'); 575 | 576 | class A extends ABase {} 577 | 578 | const B = A.path('y', { $type: 'string' }).compile('B'); 579 | 580 | assert.deepEqual(new B({ x: 1, y: 2 }), { x: '1', y: '2' }); 581 | }); 582 | 583 | it('validation with arrays and nested objects', function() { 584 | const Band = new Archetype({ 585 | name: 'string', 586 | members: [{ 587 | name: { 588 | $type: 'string', 589 | $validate: v => { 590 | if (['Axl Rose', 'Slash'].indexOf(v) === -1) { 591 | throw new Error('Invalid name!'); 592 | } 593 | } 594 | } 595 | }] 596 | }).compile(); 597 | 598 | assert.throws(function() { 599 | new Band({ 600 | name: "Guns N' Roses", 601 | members: [{ name: 'Vince Neil' }] 602 | }); 603 | }, /Invalid name!/); 604 | 605 | new Band({ 606 | name: "Guns N' Roses", 607 | members: [{ name: 'Axl Rose' }] 608 | }); 609 | }); 610 | 611 | it('arrays with null', function() { 612 | const Nest = new Archetype({ name: { $type: 'string', $required: true } }).compile(); 613 | const Test = new Archetype({ 614 | members: { $type: [Nest] } 615 | }).compile(); 616 | 617 | new Test({ members: null }); 618 | }); 619 | 620 | it('get paths as array', function() { 621 | const Test = new Archetype({ 622 | str: 'string', 623 | num: { 624 | $type: 'number', 625 | $description: 'this is a number' 626 | } 627 | }).compile(); 628 | 629 | assert.deepEqual(Test.paths().filter(v => !v.$description), [ 630 | { path: 'str', $type: 'string' } 631 | ]); 632 | assert.deepEqual(Test.paths().filter(v => !!v.$description), [ 633 | { 634 | path: 'num', 635 | $type: 'number', 636 | $description: 'this is a number' 637 | } 638 | ]); 639 | }); 640 | 641 | it('$transform', function() { 642 | const Test = new Archetype({ 643 | str: { 644 | $type: Object, 645 | $transform: JSON.parse 646 | }, 647 | nums: { 648 | $type: ['number'], 649 | $transform: JSON.parse 650 | }, 651 | objs: { 652 | $type: [{ $type: Object, $transform: JSON.parse }] 653 | } 654 | }).compile(); 655 | 656 | const doc = new Test({ 657 | str: JSON.stringify({ hello: 'world' }), 658 | nums: JSON.stringify([1, 2, 3]), 659 | objs: [JSON.stringify({ a: 1 }), JSON.stringify({ b: 2 })] 660 | }); 661 | assert.deepEqual(doc, { 662 | str: { hello: 'world' }, 663 | nums: [1, 2, 3], 664 | objs: [{ a: 1 }, { b: 2 }] 665 | }); 666 | }); 667 | 668 | it('$transform', function() { 669 | const Name = new Archetype({ 670 | first: { $type: 'string' }, 671 | last: { $type: 'string' } 672 | }).compile('Name'); 673 | const Test = new Archetype({ 674 | names: [{ $type: Name, $transform: JSON.parse }] 675 | }).compile(); 676 | 677 | const doc = new Test({ 678 | names: [ 679 | JSON.stringify({ first: 'James', last: 'Kirk' }), 680 | JSON.stringify({ first: 'Leonard', last: 'McCoy' }) 681 | ] 682 | }); 683 | assert.deepEqual(doc, { 684 | names: [ 685 | { first: 'James', last: 'Kirk' }, 686 | { first: 'Leonard', last: 'McCoy' } 687 | ] 688 | }); 689 | }); 690 | 691 | it('$transform errors', function() { 692 | const Test = new Archetype({ 693 | str: { 694 | $type: Object, 695 | $transform: JSON.parse 696 | } 697 | }).compile(); 698 | 699 | let threw = false; 700 | try { 701 | new Test({ 702 | str: { already: 'object' } 703 | }); 704 | } catch (error) { 705 | assert.ok(error.errors['str']); 706 | threw = true; 707 | } 708 | assert.ok(threw); 709 | }); 710 | 711 | it('to()', function() { 712 | const n = Archetype.to('2', 'number'); 713 | assert.strictEqual(n, 2) 714 | }); 715 | 716 | it('handles NaN', function() { 717 | const Test = new Archetype({ num: 'number' }).compile('Test'); 718 | assert.throws(function() { 719 | new Test({ num: 'a' * 2 }); 720 | }, /to number/); 721 | }); 722 | 723 | it('handles casting whitespace to number', function() { 724 | const Test = new Archetype({ num: 'number' }).compile('Test'); 725 | assert.throws(function() { 726 | new Test({ num: ' ' }); 727 | }, /to number/); 728 | }); 729 | 730 | it('required underneath array', function() { 731 | const Test = new Archetype({ 732 | products: [{ name: { $type: 'string', $required: true } }] 733 | }).compile('Test'); 734 | assert.throws(function() { 735 | new Test({ products: [{ name: null }] }); 736 | }, /required/); 737 | }); 738 | 739 | it('object array under projection', function() { 740 | const Test = new Archetype({ 741 | name: 'string', 742 | arr: { 743 | $type: [{ el: { $type: 'string' } }], 744 | $required: true 745 | } 746 | }).compile('Test'); 747 | 748 | assert.deepEqual(new Test({ name: '1', arr: [{ el: '2' }]}, { arr: 1 }), { 749 | arr: [{ el: '2' }] 750 | }); 751 | }); 752 | 753 | it('can optionally skip cloning', function() { 754 | const Test = new Archetype({ 755 | name: 'string' 756 | }).compile('Test'); 757 | 758 | const obj = { name: 'test', otherProp: 'foo' }; 759 | new Test(obj); 760 | assert.equal(obj.otherProp, 'foo'); 761 | 762 | const casted = new Test(obj, null, { clone: false }); 763 | assert.equal(casted.name, 'test'); 764 | assert.strictEqual(casted.otherProp, void 0); 765 | assert.strictEqual(obj.otherProp, void 0); 766 | }); 767 | 768 | it('throws if $default is a date', function() { 769 | assert.throws(() => { 770 | const Test = new Archetype({ 771 | name: { 772 | $type: Date, 773 | $default: new Date() 774 | } 775 | }).compile('Test'); 776 | }, /non-empty object/); 777 | }); 778 | }); 779 | 780 | describe('schema modifications', function() { 781 | it('path() adds new paths', function() { 782 | const Test = new Archetype({ 783 | str: 'string' 784 | }).compile(); 785 | 786 | const Test2 = Test.path('num', { $type: 'number' }).compile('Test2'); 787 | assert.deepEqual(new Test2({ str: 123, num: '123' }), { 788 | str: '123', 789 | num: 123 790 | }); 791 | }); 792 | 793 | it('omit() removes paths', function() { 794 | const Test = new Archetype({ 795 | str: 'string', 796 | num: 'number' 797 | }).compile(); 798 | 799 | const Test2 = Test.omit('num').compile('Test2'); 800 | assert.deepEqual(new Test2({ str: 123, num: '123' }), { 801 | str: '123' 802 | }); 803 | }); 804 | 805 | it('omit() multiple paths', function() { 806 | const Test = new Archetype({ 807 | str: 'string', 808 | num: 'number', 809 | bool: 'boolean' 810 | }).compile(); 811 | 812 | const Test2 = Test.omit(['num', 'str']).compile('Test2'); 813 | assert.deepEqual(new Test2({ str: 123, num: '123', bool: 'yes' }), { 814 | bool: true 815 | }); 816 | }); 817 | 818 | it('pick() creates a new schema with a subset of paths', function() { 819 | const Test = new Archetype({ 820 | str: 'string', 821 | num: 'number' 822 | }).compile(); 823 | 824 | const Test2 = Test.pick('num').compile('Test2'); 825 | assert.deepEqual(new Test2({ str: 123, num: '123' }), { 826 | num: 123 827 | }); 828 | }); 829 | 830 | it('transform() loops over top-level paths and transforms them', function () { 831 | const Test = new Archetype({ 832 | str: { $type: 'string', $required: true }, 833 | num: { $type: 'number', $required: true } 834 | }).compile(); 835 | 836 | const Test2 = Test.transform((path, props) => { 837 | if (path === 'num') { 838 | delete props.$required; 839 | } 840 | return props; 841 | }).compile('Test2'); 842 | 843 | // Should work 844 | new Test2({ str: '123' }); 845 | }); 846 | 847 | it('transform() loops over nested paths and transforms them', function () { 848 | const Test = new Archetype({ 849 | nested: { 850 | str: { $type: 'string', $required: true }, 851 | num: { $type: 'number', $required: true } 852 | } 853 | }).compile(); 854 | 855 | const Test2 = Test.transform((path, props) => { 856 | if (path === 'nested.num') { 857 | delete props.$required; 858 | } 859 | return props; 860 | }).compile('Test2'); 861 | 862 | // Should work 863 | new Test2({ nested: { str: '123' } }); 864 | }); 865 | 866 | it('pick() and transform() create a new schema with a subset of paths with altered props', function() { 867 | const Test = new Archetype({ 868 | str: 'string', 869 | num: 'number', 870 | didRun: { 871 | $type : 'boolean', 872 | $required: true, 873 | $default: false 874 | } 875 | }).compile(); 876 | 877 | const Test2 = Test 878 | .pick(['num', 'didRun']) 879 | .transform((path, props) => { 880 | assert.ok(props, 'transform() props should be present.') 881 | if (path === 'didRun') { 882 | delete props.$required; 883 | delete props.$default; 884 | } 885 | return props; 886 | }) 887 | .compile('Test2'); 888 | 889 | assert.deepEqual(new Test2({ str: 123, num: '123' }), { 890 | num: 123 891 | }); 892 | }); 893 | 894 | it('eachPath() loops over nested paths', function () { 895 | const Test = new Archetype({ 896 | nested: { 897 | str: { $type: 'string', $required: true }, 898 | num: { $type: 'number', $required: true } 899 | } 900 | }).compile(); 901 | 902 | const arr = []; 903 | Test.eachPath(path => { 904 | arr.push(path); 905 | }); 906 | 907 | assert.deepEqual(arr, ['nested', 'nested.str', 'nested.num']); 908 | }); 909 | }); 910 | -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const shouldSkipPath = require('../src/util').shouldSkipPath; 5 | 6 | describe('shouldSkipPath', function() { 7 | it('returns false for __proto__', function() { 8 | assert.ok(!shouldSkipPath({}, '__proto__')); 9 | }); 10 | }); --------------------------------------------------------------------------------