├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── index.js └── util.js ├── test ├── index.js ├── mocha.opts └── setup.js └── testout └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "mocha": true, 5 | "es6": true 6 | }, 7 | "globals": { 8 | "__SERVER__": true, 9 | "__CLIENT__": true, 10 | "__TEST__": true, 11 | "__DEV__": true, 12 | "__PROD__": true, 13 | "__STAGING__": true 14 | }, 15 | "ecmaFeatures": { 16 | "arrowFunctions": true, 17 | "binaryLiterals": true, 18 | "blockBindings": true, 19 | "classes": true, 20 | "defaultParams": true, 21 | "destructuring": true, 22 | "forOf": true, 23 | "generators": true, 24 | "modules": true, 25 | "objectLiteralComputedProperties": true, 26 | "objectLiteralShorthandMethods": true, 27 | "objectLiteralShorthandProperties": true, 28 | "octalLiterals": true, 29 | "regexUFlag": true, 30 | "regexYFlag": true, 31 | "spread": true, 32 | "superInFunctions": true, 33 | "templateStrings": true, 34 | "unicodeCodePointEscapes": true, 35 | "jsx": true 36 | }, 37 | "parser": "babel-eslint", 38 | "rules": { 39 | "prefer-reflect": [ 40 | 0, 41 | { 42 | "exceptions": [ 43 | "apply", 44 | "call", 45 | "delete" 46 | ] 47 | } 48 | ], 49 | "babel/new-cap": 2, 50 | "no-return-assign": 2, 51 | "no-invalid-this": 0, 52 | "no-void": 2, 53 | "one-var": [2, "never"], 54 | "react/jsx-closing-bracket-location": 0, 55 | "no-undef": 2, 56 | "max-nested-callbacks": [ 57 | 2, 58 | 3 59 | ], 60 | "no-empty": 2, 61 | "no-loop-func": 2, 62 | "keyword-spacing": 2, 63 | "babel/object-shorthand": [ 64 | 2, 65 | "always" 66 | ], 67 | "wrap-iife": [ 68 | 2, 69 | "inside" 70 | ], 71 | "valid-typeof": 2, 72 | "react/jsx-no-literals": 2, 73 | "handle-callback-err": 2, 74 | "operator-linebreak": [2, "after"], 75 | "no-label-var": 2, 76 | "no-process-env": 2, 77 | "no-irregular-whitespace": 2, 78 | "block-spacing": 2, 79 | "padded-blocks": [ 80 | 2, 81 | "never" 82 | ], 83 | "react/jsx-pascal-case": 2, 84 | "no-empty-pattern": 2, 85 | "radix": 2, 86 | "no-undefined": 0, 87 | "semi-spacing": 2, 88 | "eqeqeq": [ 89 | 2, 90 | "allow-null" 91 | ], 92 | "no-negated-condition": 2, 93 | "require-yield": 2, 94 | "new-cap": 2, 95 | "no-const-assign": 2, 96 | "no-bitwise": 2, 97 | "dot-notation": 2, 98 | "camelcase": 2, 99 | "prefer-const": 2, 100 | "no-negated-in-lhs": 2, 101 | "prefer-arrow-callback": 2, 102 | "no-extra-bind": 2, 103 | "react/prefer-es6-class": 2, 104 | "no-sequences": 2, 105 | "babel/generator-star-spacing": 2, 106 | "comma-dangle": [ 107 | 2, 108 | "always-multiline" 109 | ], 110 | "no-spaced-func": 2, 111 | "react/require-extension": 2, 112 | "no-labels": 2, 113 | "no-unreachable": 2, 114 | "no-eval": 2, 115 | "react/no-did-mount-set-state": 2, 116 | "no-unneeded-ternary": 2, 117 | "no-process-exit": 2, 118 | "no-empty-character-class": 2, 119 | "constructor-super": 2, 120 | "no-dupe-class-members": 2, 121 | "strict": [ 122 | 2, 123 | "never" 124 | ], 125 | "no-case-declarations": 2, 126 | "array-bracket-spacing": 2, 127 | "react/no-set-state": 2, 128 | "block-scoped-var": 2, 129 | "arrow-body-style": 2, 130 | "space-in-parens": 2, 131 | "no-confusing-arrow": 2, 132 | "no-control-regex": 2, 133 | "consistent-return": 2, 134 | "no-console": 2, 135 | "comma-spacing": 2, 136 | "no-redeclare": 2, 137 | "computed-property-spacing": 2, 138 | "no-invalid-regexp": 2, 139 | "use-isnan": 2, 140 | "no-new-require": 2, 141 | "indent": [ 142 | 2, 143 | 2 144 | ], 145 | "react/react-in-jsx-scope": 2, 146 | "no-native-reassign": 2, 147 | "no-func-assign": 2, 148 | "max-len": [ 149 | 2, 150 | 120, 151 | 4, 152 | { 153 | "ignoreUrls": true 154 | } 155 | ], 156 | "no-shadow": [ 157 | 2, 158 | { 159 | "builtinGlobals": true 160 | } 161 | ], 162 | "no-mixed-requires": 2, 163 | "react/no-did-update-set-state": 2, 164 | "react/jsx-uses-react": 2, 165 | "max-statements": [ 166 | 2, 167 | 20 168 | ], 169 | "space-unary-ops": [ 170 | 2, 171 | { 172 | "words": true, 173 | "nonwords": false 174 | } 175 | ], 176 | "no-lone-blocks": 2, 177 | "no-debugger": 2, 178 | "arrow-parens": [ 179 | 2, 180 | "always" 181 | ], 182 | "space-before-blocks": [ 183 | 2, 184 | "always" 185 | ], 186 | "no-implied-eval": 2, 187 | "no-useless-concat": 2, 188 | "no-multi-spaces": 2, 189 | "curly": [2, "multi-line"], 190 | "no-extra-boolean-cast": 2, 191 | "space-infix-ops": 2, 192 | "babel/no-await-in-loop": 2, 193 | "react/sort-comp": 2, 194 | "react/jsx-no-undef": 2, 195 | "no-multiple-empty-lines": [ 196 | 2, 197 | { 198 | "max": 2 199 | } 200 | ], 201 | "semi": 2, 202 | "no-param-reassign": 0, 203 | "no-cond-assign": 2, 204 | "no-dupe-keys": 2, 205 | "import/named": 0, 206 | "max-params": [ 207 | 2, 208 | 4 209 | ], 210 | "linebreak-style": 2, 211 | "react/jsx-sort-props": [ 212 | 0, 213 | { 214 | "shorthandFirst": true, 215 | "callbacksLast": true 216 | } 217 | ], 218 | "no-octal-escape": 2, 219 | "no-this-before-super": 2, 220 | "no-alert": 2, 221 | "react/jsx-no-duplicate-props": [ 222 | 2, 223 | { 224 | "ignoreCase": true 225 | } 226 | ], 227 | "no-unused-expressions": 2, 228 | "react/jsx-sort-prop-types": 0, 229 | "no-class-assign": 2, 230 | "spaced-comment": 2, 231 | "no-path-concat": 2, 232 | "prefer-spread": 2, 233 | "no-self-compare": 2, 234 | "guard-for-in": 2, 235 | "no-nested-ternary": 2, 236 | "no-multi-str": 2, 237 | "react/jsx-key": 1, 238 | "import/namespace": 2, 239 | "no-warning-comments": 1, 240 | "no-delete-var": 2, 241 | "babel/arrow-parens": [ 242 | 2, 243 | "always" 244 | ], 245 | "no-with": 2, 246 | "no-extra-parens": 2, 247 | "no-trailing-spaces": 2, 248 | "import/no-unresolved": 1, 249 | "no-obj-calls": 2, 250 | "accessor-pairs": 2, 251 | "yoda": [ 252 | 2, 253 | "never", 254 | { 255 | "exceptRange": true 256 | } 257 | ], 258 | "no-continue": 1, 259 | "react/no-unknown-property": 2, 260 | "no-new": 2, 261 | "object-curly-spacing": 2, 262 | "react/jsx-curly-spacing": [ 263 | 2, 264 | "never" 265 | ], 266 | "jsx-quotes": 2, 267 | "react/no-direct-mutation-state": 2, 268 | "key-spacing": 2, 269 | "no-underscore-dangle": [ 270 | 2, 271 | { "allowAfterThis": true } 272 | ], 273 | "new-parens": 2, 274 | "no-mixed-spaces-and-tabs": 2, 275 | "no-floating-decimal": 2, 276 | "operator-assignment": [ 277 | 2, 278 | "always" 279 | ], 280 | "no-shadow-restricted-names": 2, 281 | "no-use-before-define": [ 282 | 2, 283 | "nofunc" 284 | ], 285 | "no-useless-call": 2, 286 | "no-caller": 2, 287 | "quotes": [ 288 | 2, 289 | "single", 290 | "avoid-escape" 291 | ], 292 | "react/jsx-handler-names": [ 293 | 1, 294 | { 295 | "eventHandlerPrefix": "handle", 296 | "eventHandlerPropPrefix": "on" 297 | } 298 | ], 299 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 300 | "no-unused-vars": 2, 301 | "import/default": 1, 302 | "no-lonely-if": 2, 303 | "no-extra-semi": 2, 304 | "prefer-template": 2, 305 | "react/forbid-prop-types": 1, 306 | "react/self-closing-comp": 2, 307 | "no-else-return": 2, 308 | "react/jsx-max-props-per-line": [ 309 | 2, 310 | { 311 | "maximum": 3 312 | } 313 | ], 314 | "no-dupe-args": 2, 315 | "no-new-object": 2, 316 | "callback-return": 2, 317 | "no-new-wrappers": 2, 318 | "comma-style": 2, 319 | "no-script-url": 2, 320 | "consistent-this": 2, 321 | "react/wrap-multilines": 0, 322 | "dot-location": [ 323 | 2, 324 | "property" 325 | ], 326 | "no-implicit-coercion": 2, 327 | "max-depth": [ 328 | 2, 329 | 4 330 | ], 331 | "babel/object-curly-spacing": [ 332 | 2, 333 | "never" 334 | ], 335 | "no-array-constructor": 2, 336 | "no-iterator": 2, 337 | "react/jsx-no-bind": 2, 338 | "sort-vars": 2, 339 | "no-var": 2, 340 | "no-sparse-arrays": 2, 341 | "space-before-function-paren": [ 342 | 2, 343 | "never" 344 | ], 345 | "no-throw-literal": 2, 346 | "no-proto": 2, 347 | "default-case": 2, 348 | "no-inner-declarations": 2, 349 | "react/jsx-indent-props": [ 350 | 2, 351 | 2 352 | ], 353 | "no-new-func": 2, 354 | "object-shorthand": 2, 355 | "no-ex-assign": 2, 356 | "no-unexpected-multiline": 2, 357 | "no-undef-init": 2, 358 | "no-duplicate-case": 2, 359 | "no-fallthrough": 2, 360 | "no-catch-shadow": 2, 361 | "import/export": 2, 362 | "no-constant-condition": 2, 363 | "complexity": [ 364 | 2, 365 | 25 366 | ], 367 | "react/jsx-boolean-value": [ 368 | 2, 369 | "never" 370 | ], 371 | "valid-jsdoc": 2, 372 | "no-extend-native": 2, 373 | "react/prop-types": 2, 374 | "no-regex-spaces": 2, 375 | "react/no-multi-comp": 2, 376 | "no-octal": 2, 377 | "arrow-spacing": 2, 378 | "quote-props": [ 379 | 2, 380 | "as-needed" 381 | ], 382 | "no-div-regex": 2, 383 | "react/jsx-uses-vars": 2, 384 | "react/no-danger": 1 385 | }, 386 | "settings": { 387 | "ecmascript": 6, 388 | "jsx": true, 389 | "import/parser": "babel-eslint", 390 | "import/ignore": [ 391 | "node_modules", 392 | "\\.scss$" 393 | ], 394 | "import/resolve": { 395 | "moduleDirectory": [ 396 | "node_modules" 397 | ] 398 | } 399 | }, 400 | "plugins": [ 401 | "react", 402 | "import", 403 | "babel" 404 | ] 405 | } 406 | -------------------------------------------------------------------------------- /.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 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Compiled source 40 | lib 41 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .eslintrc 3 | .gitignore 4 | .npmignore 5 | .nyc_output 6 | .travis.yml 7 | coverage 8 | src 9 | test 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | - "5.1" 5 | - "4" 6 | - "4.2" 7 | - "4.1" 8 | - "4.0" 9 | - "iojs" 10 | after_success: npm run coverage 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Joon Ho Cho 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 all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-rule 2 | [![Build Status](https://travis-ci.org/joonhocho/graphql-rule.svg?branch=master)](https://travis-ci.org/joonhocho/graphql-rule) 3 | [![Coverage Status](https://coveralls.io/repos/github/joonhocho/graphql-rule/badge.svg?branch=master)](https://coveralls.io/github/joonhocho/graphql-rule?branch=master) 4 | [![npm version](https://badge.fury.io/js/graphql-rule.svg)](https://badge.fury.io/js/graphql-rule) 5 | [![Dependency Status](https://david-dm.org/joonhocho/graphql-rule.svg)](https://david-dm.org/joonhocho/graphql-rule) 6 | [![License](http://img.shields.io/:license-mit-blue.svg)](http://doge.mit-license.org) 7 | 8 | Unopinionated rule based access / authorization / permission control for GraphQL type fields. 9 | 10 | Inspired by [RoModel](https://github.com/joonhocho/romodel) and Firebase rules. 11 | 12 | It actually has no dependencies to GraphQL. You can use it with plain js objects! 13 | 14 | Supports Node.js >= 4.0. 15 | 16 | 17 | 18 | ### Install 19 | ``` 20 | npm install --save graphql-rule 21 | ``` 22 | 23 | 24 | ### How It Works 25 | `graphql-rule` is simply an authorization layer between your data and data accessor (`resolve` functions in GraphQL). 26 | 27 | 28 | Without `graphql-rule`: 29 | +-------------+ +------------+ 30 | | | | | 31 | | | | | 32 | | Accessor | +---> | Data | 33 | | (GraphQL) | | | 34 | | | | | 35 | | | | | 36 | +-------------+ +------------+ 37 | 38 | 39 | With `graphql-rule`: 40 | +-------------+ +------------+ +------------+ 41 | | | | | | | 42 | | | | | | | 43 | | Accessor | +---> | Rules | +---> | Data | 44 | | (GraphQL) | | | | | 45 | | | | | | | 46 | | | | | | | 47 | +-------------+ +------------+ +------------+ 48 | 49 | 50 | You define access `rules` for each property of your data (object), and it will 51 | allow or disallow read to the property based on the predefined rules. 52 | (only read rules are supported for now). 53 | 54 | It is designed for access control in GraphQL, but it is not opinionated nor requires any dependencies. 55 | Thus, it can be used for all projects with or without GraphQL. 56 | 57 | 58 | 59 | ### API - Rule.create(options) 60 | ```javascript 61 | import Rule from 'graphql-rule'; 62 | 63 | Rule.create({ 64 | // [REQUIRED] Unique rule model name. 65 | // Used for child field type specification. 66 | name: string = null, 67 | 68 | // Base class for newly created and returned rule model class. 69 | // Base class must extend {Model} from 'graphql-rule'. 70 | base: ?class = class, 71 | 72 | // Define dynamic property getters. 73 | // Can be accessed via `model.$props.propName` 74 | // Each property is lazily initialized upon the first access and cached for performance. 75 | // Useful for something expensive to calculate and is accessed over and over by multiple fields. 76 | props: { 77 | [propName: string]: (model: Model) => any, 78 | } = {}, 79 | 80 | // Define default rule for fields in this model. 81 | defaultRule: { 82 | // `preRead` is checked before accessing or calling a field. 83 | // Useful for failing fast before accessing field that can be expensive to calculate such as field that initiates network calls 84 | // If `false` or `preRead(model, key)` returns falsy value, the access to field will fail immediately. 85 | // [default=true] 86 | preRead: boolean | (model: Model, key: string) => boolean | Promise, 87 | 88 | // `read` is checked after accessing or calling a field. 89 | // Useful for passing/failing based on the field value. 90 | // If `false` or `read(model, key, value)` returns falsy value, the access to field will fail immediately. 91 | // [default=true] 92 | read: boolean | (model: Model, key: string, value: any) => boolean | Promise, 93 | 94 | // `readFail` is used when either `preRead` or `read` returns falsy value. 95 | // If it is not a function, it is used as a final value for the failed field. 96 | // If it is function, the returend value is used as a final value for the failed field. 97 | // It can throw an error, if throwing an error is a desired upon unauthorized access to a field. 98 | // [default=null] 99 | readFail: any | (model: Model, key: string, value: ?any) => any, 100 | } = {preRead: true, read: true, readFail: null}, 101 | 102 | // Define access rule for fields in this model. 103 | rules: { 104 | [fieldName: string]: { 105 | // See `defaultRule.preRead` 106 | preRead, 107 | 108 | // See `defaultRule.read` 109 | read, 110 | 111 | // See `defaultRule.readFail` 112 | readFail, 113 | 114 | // If specified, field value will be wrapped as an instance of the specified Model class. 115 | type: string | class = null, 116 | 117 | // Whether the field returns a list of Model instances. 118 | // Used together with `type` above. 119 | list: boolean = false, 120 | 121 | // Filter function for list. 122 | // Used together with `list` above. 123 | readListItem: (listItem: FieldModel, model: Model, key: string, list: [FieldModel]) => boolean 124 | 125 | // Whether the field is a function method. 126 | method: boolean = false, 127 | 128 | // Whether to cached the final value for the field. 129 | // Only applied if `method: false` above. 130 | cache: boolean = true, 131 | }, 132 | }, 133 | 134 | // Interfaces to inherit static and prototype properties and methods from. 135 | // Useful if you have common props / field rules / etc. 136 | interfaces: [class] = [], 137 | }) 138 | ``` 139 | 140 | 141 | ### Basic Usage without GraphQL 142 | ```javascript 143 | import Rule from 'graphql-rule'; 144 | 145 | // create access control model for your data 146 | const Model = Rule.create({ 147 | // name for this access model 148 | name: 'Model', 149 | 150 | // define access rules 151 | rules: { 152 | // allow access to `public` property. 153 | public: true, 154 | 155 | secret: { 156 | // disallow access to `secret` property. 157 | read: false, 158 | 159 | // throw an error when read is disallowed. 160 | readFail: () => { throw new Error('Access denied'); }, 161 | }, 162 | 163 | conditional: { 164 | // access raw data via `$data`. 165 | // conditionally allow access if `conditional` <= 3. 166 | read: (model) => model.$data.conditional <= 3, 167 | 168 | readFail: (model) => { throw new Error(`${model.$data.conditional} > 3`); }, 169 | }, 170 | }, 171 | }); 172 | 173 | 174 | // create a wrapped instance of your data. 175 | const securedData = new Model({ 176 | public: 'public data', 177 | secret: 'something secret', 178 | conditional: 5, 179 | }); 180 | 181 | securedData.public // 'public data' 182 | 183 | securedData.secret // throws Error('Access denied'). 184 | 185 | securedData.conditional // throws Error('5 > 3'). 186 | 187 | 188 | // same access model for different data. 189 | const securedData2 = new Model({conditional: 1}); 190 | 191 | securedData2.conditional // 1 since 1 < 3. 192 | ``` 193 | 194 | 195 | ### User / Profile with Session 196 | ```javascript 197 | // set default `readFail` 198 | Rule.config({ 199 | readFail: () => { throw new Error('Access denied'); }, 200 | }); 201 | 202 | const UserRule = Rule.create({ 203 | name: 'User', 204 | 205 | // props are lazily initialized and cached once initialized. 206 | // accessible via `model.$props`. 207 | props: { 208 | isAdmin: (model) => model.$context.admin, 209 | 210 | isAuthenticated: (model) => Boolean(model.$context.userId), 211 | 212 | isOwner: (model) => model.$data.id === model.$context.userId, 213 | }, 214 | 215 | rules: { 216 | // Everyone can read `id`. 217 | id: true, 218 | 219 | email: { 220 | // allow access by admin or owner. 221 | read: (model) => model.$props.isAdmin || model.$props.isOwner, 222 | 223 | // returns null when read denied. 224 | readFail: null, 225 | }, 226 | 227 | // No one can read `password`. 228 | password: false, 229 | 230 | profile: { 231 | // Use `Profile` Rule for `profile`. 232 | type: 'Profile', 233 | 234 | // allow access by all authenticated users 235 | read: (model) => model.$props.isAuthenticated, 236 | 237 | readFail: () => { throw new Error('Login Required'); }, 238 | }, 239 | }, 240 | }); 241 | 242 | const ProfileRule = Rule.create({ 243 | name: 'Profile', 244 | 245 | rules: { 246 | name: true, 247 | 248 | phone: { 249 | // Access `UserRule` instance via `$parent`. 250 | read: (model) => model.$parent.$props.isAdmin || model.$parent.$props.isOwner, 251 | 252 | readFail: () => { throw new Error('Not authorized!'); }, 253 | }, 254 | }, 255 | }); 256 | 257 | 258 | const session = { 259 | userId: 'session_user_id', 260 | admin: false, 261 | }; 262 | 263 | const userData = { 264 | id: 'user_id', 265 | email: 'user@example.com', 266 | password: 'secret', 267 | profile: { 268 | name: 'John Doe', 269 | phone: '123-456-7890', 270 | }, 271 | }; 272 | 273 | // pass `session` as a second param to make it available as `$context`. 274 | const user = new UserRule(userData, session); 275 | 276 | user.id // 'user_id' 277 | 278 | user.email // `null` since not admin nor owner. 279 | 280 | user.password // throws Error('Access denied'). 281 | 282 | user.profile // `ProfileRule` instance. accessible since authenticated. 283 | 284 | user.profile.name // 'John Doe' 285 | 286 | user.profile.phone // throws Error('Not authorized!') since not admin nor owner. 287 | ``` 288 | 289 | 290 | ### Integration with GraphQL 291 | ```javascript 292 | // Use `UserRule` and `ProfileRule` from the above example. 293 | 294 | const ProfileType = new GraphQLObjectType({ 295 | name: 'Profile', 296 | fields: { 297 | name: { type: GraphQLString }, 298 | phone: { type: GraphQLString }, 299 | } 300 | }); 301 | 302 | const UserType = new GraphQLObjectType({ 303 | name: 'User', 304 | fields: { 305 | id: { type: GraphQLID }, 306 | email: { type: GraphQLString }, 307 | password: { type: GraphQLString }, 308 | profile: { type: ProfileType }, 309 | } 310 | }); 311 | 312 | const schema = new GraphQLSchema({ 313 | query: new GraphQLObjectType({ 314 | name: 'Query', 315 | fields: { 316 | user: { 317 | type: UserType, 318 | args: { 319 | id: { type: GraphQLID } 320 | }, 321 | resolve: (_, args, session) => { 322 | // `context` is passed as the third parameter of `resolve` function. 323 | // pass the `context` as the second parameter of `UserRule`. 324 | // Now your data is secured by predefined rules! 325 | return database.getUser(args.id).then((user) => new UserRule(user, session)); 326 | }, 327 | } 328 | } 329 | }) 330 | }); 331 | 332 | app.use('/graphql', graphqlHTTP((request) => ({ 333 | schema: schema, 334 | // pass session data as `context`. 335 | // becomes available as third parameter in field `resolve` functions. 336 | context: request.session, 337 | }))); 338 | ``` 339 | 340 | 341 | ### Integration with Mongoose & GraphQL 342 | ```javascript 343 | // Define Mongoose schemas. 344 | const UserModel = mongoose.model('User', new mongoose.Schema({ 345 | email: String, 346 | password: String, 347 | profile: { 348 | type: ObjectId, 349 | ref: 'Profile', 350 | }, 351 | })); 352 | 353 | const ProfileModel = mongoose.model('Profile', new mongoose.Schema({ 354 | name: String, 355 | phone: String, 356 | })); 357 | 358 | 359 | // Define access rules. 360 | const UserRule = Rule.create({ 361 | name: 'User', 362 | props: { 363 | isAdmin: (model) => model.$context.admin, 364 | isOwner: (model) => model.$data.id === model.$context.userId, 365 | }, 366 | rules: { 367 | id: true, 368 | email: { 369 | preRead: (model) => model.$props.isAdmin || model.$props.isOwner, 370 | readFail: () => { throw new Error('Unauthorized'); }, 371 | }, 372 | password: false, 373 | profile: { 374 | type: 'Profile', 375 | preRead: true, 376 | }, 377 | }, 378 | }); 379 | 380 | const ProfileRule = Rule.create({ 381 | name: 'Profile', 382 | rules: { 383 | name: true, 384 | phone: { 385 | preRead: (model) => model.$parent.$props.isAdmin || model.$parent.$props.isOwner, 386 | readFail: () => null, 387 | }, 388 | }, 389 | }); 390 | 391 | 392 | // Define GraphQL Types. 393 | const ProfileType = new GraphQLObjectType({ 394 | name: 'Profile', 395 | fields: { 396 | name: { type: GraphQLString }, 397 | phone: { type: GraphQLString }, 398 | } 399 | }); 400 | 401 | const UserType = new GraphQLObjectType({ 402 | name: 'User', 403 | fields: { 404 | id: { type: GraphQLID }, 405 | email: { type: GraphQLString }, 406 | password: { type: GraphQLString }, 407 | profile: { type: ProfileType }, 408 | } 409 | }); 410 | 411 | 412 | // Define GraphQL Queries. 413 | const schema = new GraphQLSchema({ 414 | query: new GraphQLObjectType({ 415 | name: 'Query', 416 | fields: { 417 | user: { 418 | type: UserType, 419 | args: { 420 | id: { type: GraphQLID } 421 | }, 422 | resolve: async (_, {id}, sessionContext) => { 423 | const userId = new ObjectId(id); 424 | const user = await UserModel.findById(userId).populate('profile').exec(); 425 | const securedUser = new UserRule(user, sessionContext); 426 | return securedUser; 427 | }, 428 | } 429 | } 430 | }) 431 | }); 432 | 433 | 434 | // Express GraphQL middleware. 435 | app.use('/graphql', graphqlHTTP((request) => ({ 436 | schema: schema, 437 | context: request.session, 438 | }))); 439 | ``` 440 | 441 | 442 | ### More $props and $context 443 | ```javascript 444 | 445 | const Model = Rule.create({ 446 | name: 'Model', 447 | props: { 448 | // context.admin 449 | isAdmin: (model) => Boolean(model.$context.admin), 450 | 451 | // !context.userId 452 | isGuest: (model) => !model.$context.userId, 453 | 454 | // !props.isGuest 455 | isAuthenticated: (model) => !model.$props.isGuest, 456 | 457 | // context.userId 458 | authId: (model) => model.$context.userId, 459 | 460 | // data.authorId 461 | authorId: (model) => model.$data.authorId, 462 | 463 | // props.authorId === props.authId 464 | isOwner: (model) => model.$props.authorId === model.$props.authId, 465 | }, 466 | defaultRule: { 467 | // read allowed by default 468 | read: (model) => true, 469 | 470 | // throws an error when read not allowed 471 | readFail: (model, key) => { throw new Error(`Cannot access '${key}'`); }, 472 | }, 473 | rules: { 474 | 475 | // use defaultRule settings 476 | authorId: {}, 477 | 478 | // read allowed only if `props.isAdmin` 479 | adminField: (model) => model.$props.isAdmin, 480 | 481 | // above is equivalent to: 482 | adminField: { 483 | read: (model) => model.$props.isAdmin, 484 | }, 485 | 486 | // read allowed only if `props.isAuthenticated` 487 | authField: (model) => model.$props.isAuthenticated, 488 | 489 | // read allowed only if `props.isGuest` 490 | guestField: (model) => model.$props.isGuest, 491 | 492 | // read allowed only if `props.isOwner` 493 | ownerField: (model) => model.$props.isOwner, 494 | 495 | notAllowedField: { 496 | read: (model) => false, 497 | readFail: (model, key) => { throw new Error('not allowed'); }, 498 | }, 499 | 500 | nullField: { 501 | read: (model) => false, 502 | readFail: (model) => null, 503 | }, 504 | }, 505 | }); 506 | 507 | const session = { 508 | userId: 'user_id_1', 509 | admin: true, 510 | }; 511 | 512 | const model = new Model( 513 | { 514 | authorId: 'user_id_1', 515 | adminField: 'adminFieldValue', 516 | authField: 'authFieldValue', 517 | guestField: 'guestFieldValue', 518 | ownerField: 'ownerFieldValue', 519 | notAllowedField: 'notAllowedFieldValue', 520 | nullField: 'nullFieldValue', 521 | undefinedField: 'undefinedFieldValue', 522 | }, // passed as $data 523 | session // passed as $context 524 | ); 525 | 526 | model.$props.isAdmin === true; 527 | model.$props.isGuest === false; 528 | model.$props.isAuthenticated === true; 529 | model.$props.isOwner === true; 530 | model.$props.authId === 'user_id_1'; 531 | model.$props.authorId === 'user_id_1'; 532 | 533 | // allowed to read by defaultRuledefault.read rule 534 | model.authorId === 'user_id_1'; 535 | 536 | // allowed to read since $props.isAdmin 537 | model.adminField === 'adminFieldValue'; 538 | 539 | // allowed to read since $props.isAuthenticated 540 | model.authField === 'authFieldValue'; 541 | 542 | // not allowed to read since !$props.isGuest 543 | model.guestField; // throws Error("Cannot access 'guestField'") 544 | 545 | // allowed to read since $props.isOwner 546 | model.ownerField === 'ownerFieldValue'; 547 | 548 | // not allowed to read 549 | model.notAllowedField; // throws Error('not allowed') 550 | 551 | // not allowed to read; returns null 552 | model.nullField === null; 553 | 554 | // rule is undefined 555 | model.undefinedField === undefined; 556 | ``` 557 | 558 | 559 | 560 | ### Even More Advanced Usage 561 | Take a look at [test file](https://github.com/joonhocho/graphql-rule/blob/master/test/index.js). 562 | 563 | 564 | ### LICENSE 565 | ``` 566 | The MIT License (MIT) 567 | 568 | Copyright (c) 2016 Joon Ho Cho 569 | 570 | Permission is hereby granted, free of charge, to any person obtaining a copy 571 | of this software and associated documentation files (the "Software"), to deal 572 | in the Software without restriction, including without limitation the rights 573 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 574 | copies of the Software, and to permit persons to whom the Software is 575 | furnished to do so, subject to the following conditions: 576 | 577 | The above copyright notice and this permission notice shall be included in all 578 | copies or substantial portions of the Software. 579 | 580 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 581 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 582 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 583 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 584 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 585 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 586 | SOFTWARE. 587 | ``` 588 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-rule", 3 | "version": "0.0.22", 4 | "description": "Rule based access control for GraphQL fields", 5 | "main": "lib/index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "build": "babel src --out-dir lib", 11 | "build-watch": "babel src --watch --out-dir lib", 12 | "clear": "rm -rf ./lib ./coverage ./.nyc_output", 13 | "coverage": "nyc npm test && nyc report --reporter=text-lcov | coveralls", 14 | "nyc": "nyc npm test && nyc report --reporter=lcov", 15 | "retest": "npm run clear && npm test", 16 | "pretest": "npm run build", 17 | "start": "npm test", 18 | "test": "mocha", 19 | "test-watch": "mocha --watch", 20 | "update-D": "npm install --save-dev babel-cli@latest babel-preset-es2015@latest babel-preset-stage-0@latest babel-register@latest chai@latest chai-as-promised@latest coveralls@latest es6-promise@latest mocha@latest nyc@latest", 21 | "watch": "npm run build-watch & npm run test-watch" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/joonhocho/graphql-rule.git" 26 | }, 27 | "keywords": [ 28 | "graphql", 29 | "rule", 30 | "access", 31 | "control", 32 | "firebase", 33 | "data", 34 | "model", 35 | "wrapper", 36 | "resolve", 37 | "fields" 38 | ], 39 | "author": "Joon Ho Cho", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/joonhocho/graphql-rule/issues" 43 | }, 44 | "homepage": "https://github.com/joonhocho/graphql-rule#readme", 45 | "devDependencies": { 46 | "babel-cli": "^6.9.0", 47 | "babel-preset-es2015": "^6.9.0", 48 | "babel-preset-stage-0": "^6.5.0", 49 | "babel-register": "^6.9.0", 50 | "chai": "^3.5.0", 51 | "chai-as-promised": "^5.3.0", 52 | "coveralls": "^2.11.9", 53 | "es6-promise": "^3.2.1", 54 | "mocha": "^2.5.3", 55 | "nyc": "^6.4.4" 56 | }, 57 | "dependencies": {} 58 | } 59 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | forEach, 3 | fastMap, 4 | setProperty, 5 | defineClassName, 6 | defineStatic, 7 | defineMethod, 8 | defineGetterSetter, 9 | defineLazyProperty, 10 | inheritClass, 11 | getCleanObject, 12 | isPromise, 13 | } from './util'; 14 | 15 | 16 | const SIGNATURE = {}; 17 | 18 | const createSetter = (key) => function set(value) { 19 | this._data[key] = value; 20 | // Removes cache 21 | delete this[key]; 22 | }; 23 | 24 | 25 | let models = getCleanObject(); 26 | let globalDefaultPreRead = true; 27 | let globalDefaultRead = true; 28 | let globalDefaultReadFail = null; 29 | let globalDefaultCache = true; 30 | 31 | 32 | const getModel = (model) => { 33 | if (typeof model === 'string') return models[model]; 34 | return model; 35 | }; 36 | 37 | 38 | const createChildModel = (parent, Class, data) => 39 | new Class(data, parent._context, parent, parent._root); 40 | 41 | 42 | const createModelMapFn = (Class) => function(data) { 43 | return data && createChildModel(this, Class, data); 44 | }; 45 | 46 | 47 | const listRegexp = /\[\s*(.*?)\s*\]/; 48 | 49 | 50 | const wrapGetterWithModel = ({type, list, readListItem}) => { 51 | if (!type) return null; 52 | 53 | let mapFn; 54 | if (typeof type === 'string') { 55 | const listMatch = type.match(listRegexp); 56 | if (listMatch) { 57 | list = true; 58 | type = listMatch[1]; 59 | } 60 | 61 | mapFn = function(data) { 62 | if (!models[type]) { 63 | throw new Error(`Unknown field model. model='${type}'`); 64 | } 65 | mapFn = createModelMapFn(models[type]); 66 | return mapFn.call(this, data); 67 | }; 68 | } else { 69 | mapFn = createModelMapFn(type); 70 | } 71 | 72 | if (list) { 73 | if (readListItem) { 74 | return (obj, key, value) => { 75 | if (value) { 76 | const len = value.length; 77 | const res = []; 78 | for (let i = 0; i < len; i += 1) { 79 | const item = value[i]; 80 | const v = mapFn.call(obj, item, i, value); 81 | if (readListItem(v, obj, key, value)) { 82 | res.push(v); 83 | } 84 | } 85 | return res; 86 | } 87 | return value; 88 | }; 89 | } 90 | 91 | return (obj, key, value) => value && fastMap(value, mapFn, obj); 92 | } 93 | 94 | // Non-list type field. 95 | return (obj, key, value) => mapFn.call(obj, value); 96 | }; 97 | 98 | 99 | const wrapGetterWithReadAccess = ({read, readFail}) => { 100 | if (read === true) return null; 101 | if (!read) return readFail; 102 | 103 | if (typeof read !== 'function') { 104 | throw new Error("'read' must be either a boolean or a function"); 105 | } 106 | 107 | return (obj, key, value) => { 108 | const canRead = read(obj, key, value); 109 | if (isPromise(canRead)) { 110 | return canRead.then((v) => { 111 | if (v) return value; 112 | return readFail(obj, key, value); 113 | }); 114 | } 115 | 116 | if (canRead) return value; 117 | return readFail(obj, key, value); 118 | }; 119 | }; 120 | 121 | 122 | const wrapGetterWithCache = ({cache = globalDefaultCache}) => { 123 | if (!cache) return null; 124 | 125 | return (obj, key, value) => { 126 | setProperty(obj, key, value); 127 | return value; 128 | }; 129 | }; 130 | 131 | 132 | const mapPromise = (obj, key, val, map) => { 133 | if (isPromise(val)) { 134 | return val.then((x) => map(obj, key, x)); 135 | } 136 | return map(obj, key, val); 137 | }; 138 | 139 | 140 | const createValueMapper = (fn) => (obj, key, value) => mapPromise(obj, key, value, fn); 141 | 142 | const createValueReducer = (fns) => (obj, key, value) => { 143 | let newValue = value; 144 | const len = fns.length; 145 | for (let i = 0; i < len; i += 1) { 146 | newValue = mapPromise(obj, key, newValue, fns[i]); 147 | } 148 | return newValue; 149 | }; 150 | 151 | 152 | const createSimpleGetter = (key) => function() { 153 | return this._data[key]; 154 | }; 155 | 156 | const createSimpleMethod = (key) => function() { 157 | const data = this._data; 158 | return data[key](...arguments); 159 | }; 160 | 161 | 162 | const createPromiseWrapper = (key, reducer) => function() { 163 | const val = this._data[key]; 164 | return mapPromise(this, key, val, reducer); 165 | }; 166 | 167 | const createPromiseWrapperForMethod = (key, reducer) => function() { 168 | const data = this._data; 169 | const val = data[key](...arguments); 170 | return mapPromise(this, key, val, reducer); 171 | }; 172 | 173 | 174 | const wrapGetterWithPreReadAccess = (key, getter, {preRead, readFail}) => { 175 | if (preRead === true) return getter; 176 | 177 | if (!preRead) { 178 | return function() { 179 | return readFail(this, key); 180 | }; 181 | } 182 | 183 | if (typeof preRead !== 'function') { 184 | throw new Error("'preRead' must be either a boolean or a function"); 185 | } 186 | 187 | return function() { 188 | const canRead = preRead(this, key); 189 | if (isPromise(canRead)) { 190 | return canRead.then((v) => { 191 | if (v) return getter.call(this); 192 | return readFail(this, key); 193 | }); 194 | } 195 | if (canRead) return getter.call(this); 196 | return readFail(this, key); 197 | }; 198 | }; 199 | 200 | const wrapMethodWithPreReadAccess = (key, method, {preRead, readFail}) => { 201 | if (preRead === true) return method; 202 | 203 | if (!preRead) { 204 | return function() { 205 | return readFail(this, key); 206 | }; 207 | } 208 | 209 | if (typeof preRead !== 'function') { 210 | throw new Error("'preRead' must be either a boolean or a function"); 211 | } 212 | 213 | return function() { 214 | const canRead = preRead(this, key); 215 | if (isPromise(canRead)) { 216 | return canRead.then((v) => { 217 | if (v) return method.apply(this, arguments); 218 | return readFail(this, key); 219 | }); 220 | } 221 | 222 | if (canRead) return method.apply(this, arguments); 223 | return readFail(this, key); 224 | }; 225 | }; 226 | 227 | 228 | const createGetter = (key, rule) => { 229 | const fns = [ 230 | wrapGetterWithModel(rule), 231 | wrapGetterWithReadAccess(rule), 232 | wrapGetterWithCache(rule), 233 | ].filter((x) => x); 234 | 235 | let getter; 236 | if (!fns.length) { 237 | getter = createSimpleGetter(key); 238 | getter = wrapGetterWithPreReadAccess(key, getter, rule); 239 | return getter; 240 | } 241 | 242 | const reducer = fns.length > 1 ? 243 | createValueReducer(fns) : 244 | createValueMapper(fns[0]); 245 | 246 | getter = createPromiseWrapper(key, reducer); 247 | getter = wrapGetterWithPreReadAccess(key, getter, rule); 248 | return getter; 249 | }; 250 | 251 | const createMethod = (key, rule) => { 252 | const fns = [ 253 | wrapGetterWithModel(rule), 254 | wrapGetterWithReadAccess(rule), 255 | ].filter((x) => x); 256 | 257 | let method; 258 | if (!fns.length) { 259 | method = createSimpleMethod(key); 260 | method = wrapMethodWithPreReadAccess(key, method, rule); 261 | return method; 262 | } 263 | 264 | const reducer = fns.length > 1 ? 265 | createValueReducer(fns) : 266 | createValueMapper(fns[0]); 267 | 268 | method = createPromiseWrapperForMethod(key, reducer); 269 | method = wrapMethodWithPreReadAccess(key, method, rule); 270 | return method; 271 | }; 272 | 273 | 274 | // exports 275 | 276 | export const config = (defaults) => { 277 | if ('preRead' in defaults) globalDefaultPreRead = defaults.preRead; 278 | if ('read' in defaults) globalDefaultRead = defaults.read; 279 | if ('readFail' in defaults) globalDefaultReadFail = defaults.readFail; 280 | if ('cache' in defaults) globalDefaultCache = Boolean(defaults.cache); 281 | }; 282 | 283 | const compileQueue = []; 284 | 285 | export const compile = () => { 286 | const len = compileQueue.length; 287 | for (let i = 0; i < len; i += 1) { 288 | compileQueue[i](); 289 | } 290 | compileQueue.length = 0; 291 | }; 292 | 293 | 294 | export const create = ({ 295 | name, 296 | base = Model, 297 | props = getCleanObject(), 298 | defaultRule: { 299 | preRead: defaultPreRead = globalDefaultPreRead, 300 | read: defaultRead = globalDefaultRead, 301 | readFail: defaultReadFail = globalDefaultReadFail, 302 | } = {}, 303 | rules = getCleanObject(), 304 | interfaces = [], 305 | } = {}) => { 306 | const NewModel = class extends base {}; 307 | 308 | // name 309 | if (models[name]) { 310 | throw new Error(`'${name}' model already exists!`); 311 | } 312 | models[name] = NewModel; 313 | defineClassName(NewModel, name); 314 | 315 | 316 | // signature 317 | defineStatic(NewModel, '$signature', SIGNATURE); 318 | 319 | 320 | // props 321 | class Props { 322 | constructor(model) { 323 | this.$model = model; 324 | } 325 | } 326 | 327 | defineStatic(NewModel, '$Props', Props); 328 | 329 | forEach(props, (fn, key) => 330 | defineLazyProperty(Props.prototype, key, function() { 331 | return fn.call(this, this.$model); 332 | })); 333 | 334 | defineLazyProperty(NewModel.prototype, '$props', function() { 335 | return new Props(this); 336 | }); 337 | 338 | 339 | // interfaces 340 | defineStatic(NewModel, '$interfaces', interfaces); 341 | 342 | if (base !== Model) { 343 | inheritClass(NewModel, base); 344 | if (base.$Props) inheritClass(Props, base.$Props); 345 | } 346 | 347 | interfaces.forEach((from) => { 348 | inheritClass(NewModel, from); 349 | if (from.$Props) inheritClass(Props, from.$Props); 350 | }); 351 | 352 | 353 | // rules 354 | const compileRule = (rules) => forEach(rules, (rule, key) => { 355 | const ruleType = typeof rule; 356 | if (ruleType === 'string' || rule && rule.$signature === SIGNATURE) { 357 | rule = { 358 | type: rule, 359 | }; 360 | } else if (ruleType === 'boolean') { 361 | rule = { 362 | preRead: rule, 363 | read: rule, 364 | }; 365 | } else if (ruleType === 'function') { 366 | rule = { 367 | read: rule, 368 | }; 369 | } else if (!rule || ruleType !== 'object') { 370 | rule = null; 371 | } 372 | 373 | if (!rule) { 374 | throw new Error(`Invalid rule for ${key}`); 375 | } 376 | 377 | if (rule.preRead === undefined) rule.preRead = defaultPreRead; 378 | if (rule.read === undefined) rule.read = defaultRead; 379 | if (rule.readFail === undefined) rule.readFail = defaultReadFail; 380 | const {readFail} = rule; 381 | if (typeof readFail !== 'function') { 382 | rule.readFail = () => readFail; 383 | } 384 | 385 | if (rule.method) { 386 | defineMethod( 387 | NewModel.prototype, 388 | key, 389 | createMethod(key, rule), 390 | ); 391 | } else { 392 | defineGetterSetter( 393 | NewModel.prototype, 394 | key, 395 | createGetter(key, rule), 396 | createSetter(key) 397 | ); 398 | } 399 | }); 400 | 401 | if (typeof rules === 'function') { 402 | compileQueue.push(() => compileRule(rules())); 403 | } else { 404 | compileRule(rules); 405 | } 406 | 407 | return NewModel; 408 | }; 409 | 410 | 411 | export const get = getModel; 412 | 413 | 414 | export const clear = () => { models = getCleanObject(); }; 415 | 416 | 417 | export class Model { 418 | constructor(data, context, parent, root) { 419 | this._data = data; 420 | this._parent = parent || null; 421 | this._root = root || this; 422 | this._context = context; 423 | } 424 | 425 | $destroy() { 426 | delete this._data; 427 | delete this._parent; 428 | delete this._context; 429 | } 430 | 431 | get $data() { 432 | return this._data; 433 | } 434 | 435 | $get(name) { 436 | return this._data[name]; 437 | } 438 | 439 | get $parent() { 440 | return this._parent; 441 | } 442 | 443 | get $context() { 444 | return this._context; 445 | } 446 | 447 | get $root() { 448 | return this._root; 449 | } 450 | 451 | $parentOfType(type) { 452 | const Model = getModel(type); 453 | let p = this; 454 | while (p = p._parent) { 455 | if (p instanceof Model) { 456 | return p; 457 | } 458 | } 459 | return null; 460 | } 461 | 462 | $createChild(model, data) { 463 | return createChildModel(this, getModel(model), data); 464 | } 465 | 466 | $createChildren(model, list) { 467 | const Class = getModel(model); 468 | return list && fastMap(list, (data) => createChildModel(this, Class, data)); 469 | } 470 | 471 | $implements(Type) { 472 | return this.constructor.$interfaces.indexOf(Type) >= 0; 473 | } 474 | } 475 | 476 | 477 | export default { 478 | config, 479 | create, 480 | get, 481 | clear, 482 | Model, 483 | }; 484 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export function forEach(obj, fn) { 2 | const keys = Object.keys(obj); 3 | const len = keys.length; 4 | for (let i = 0; i < len; i += 1) { 5 | const key = keys[i]; 6 | fn(obj[key], key, obj); 7 | } 8 | } 9 | 10 | export function fastMap(arr, fn, ctx) { 11 | const len = arr.length; 12 | const res = new Array(len); 13 | for (let i = 0; i < len; i += 1) { 14 | res[i] = fn.call(ctx, arr[i], i, arr); 15 | } 16 | return res; 17 | } 18 | 19 | export function setProperty(obj, key, value) { 20 | return Object.defineProperty(obj, key, { 21 | value, 22 | enumerable: true, 23 | writable: true, 24 | configurable: true, 25 | }); 26 | } 27 | 28 | export function defineStatic(Class, name, value) { 29 | return Object.defineProperty(Class, name, { 30 | value, 31 | writable: false, 32 | enumerable: false, 33 | configurable: true, 34 | }); 35 | } 36 | 37 | export function defineMethod(prototype, name, value) { 38 | Object.defineProperty(prototype, name, { 39 | value, 40 | writable: true, 41 | enumerable: false, 42 | configurable: true, 43 | }); 44 | } 45 | 46 | export function defineLazyProperty(obj, name, fn, { 47 | writable = true, 48 | enumerable = true, 49 | configurable = true, 50 | } = {}) { 51 | Object.defineProperty(obj, name, { 52 | get() { 53 | // Use 'this' instead of obj so that obj can be a prototype. 54 | const value = fn.call(this); 55 | Object.defineProperty(this, name, { 56 | value, 57 | writable, 58 | enumerable, 59 | configurable, 60 | }); 61 | return value; 62 | }, 63 | enumerable, 64 | configurable: true, 65 | }); 66 | } 67 | 68 | let defineClassName; 69 | if ((() => { 70 | const A = class {}; 71 | try { 72 | defineStatic(A, 'name', 'B'); 73 | return A.name === 'B'; 74 | } catch (e) { 75 | return false; 76 | } 77 | })()) { 78 | defineClassName = (Class, value) => defineStatic(Class, 'name', value); 79 | } else { 80 | // Old Node versions require the following options to overwrite class name. 81 | defineClassName = (Class, value) => Object.defineProperty(Class, 'name', { 82 | value, 83 | writable: false, 84 | enumerable: false, 85 | configurable: false, 86 | }); 87 | } 88 | export {defineClassName}; 89 | 90 | export function defineGetterSetter(Class, name, get, set) { 91 | return Object.defineProperty(Class, name, { 92 | get, 93 | set, 94 | enumerable: true, 95 | configurable: true, 96 | }); 97 | } 98 | 99 | export function inheritPropertyFrom(objA, objB, key, asKey) { 100 | return Object.defineProperty( 101 | objA, 102 | asKey || key, 103 | Object.getOwnPropertyDescriptor(objB, key) 104 | ); 105 | } 106 | 107 | export function inheritFrom(objA, objB, excludes) { 108 | const aKeys = Object.getOwnPropertyNames(objA); 109 | const bKeys = Object.getOwnPropertyNames(objB); 110 | 111 | let keys = bKeys.filter((key) => aKeys.indexOf(key) === -1); 112 | if (excludes) { 113 | keys = keys.filter((key) => excludes.indexOf(key) === -1); 114 | } 115 | 116 | keys.forEach((key) => inheritPropertyFrom(objA, objB, key)); 117 | return objA; 118 | } 119 | 120 | export function inheritStatic(classA, classB) { 121 | inheritFrom(classA, classB, ['length', 'name', 'prototype']); 122 | return classA; 123 | } 124 | 125 | export function inheritPrototype(classA, classB) { 126 | inheritFrom(classA.prototype, classB.prototype, ['constructor']); 127 | return classA; 128 | } 129 | 130 | export function inheritClass(classA, classB) { 131 | inheritStatic(classA, classB); 132 | inheritPrototype(classA, classB); 133 | return classA; 134 | } 135 | 136 | export function isPromise(x) { 137 | return x != null && typeof x.then === 'function'; 138 | } 139 | 140 | export function getCleanObject() { 141 | return Object.create(null); 142 | } 143 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | if (typeof Promise === 'undefined') { 2 | require('es6-promise').polyfill(); 3 | } 4 | import {expect} from 'chai'; 5 | import {config, create, clear, Model, compile} from '../src'; 6 | 7 | describe('graphql-rule', () => { 8 | beforeEach(() => { 9 | clear(); 10 | config({ 11 | read: true, 12 | readFail: null, 13 | }); 14 | }); 15 | 16 | 17 | it('no rules', () => { 18 | const Model = create({ 19 | name: 'Model', 20 | }); 21 | 22 | const m = new Model({a: 1, b: 2}); 23 | expect(m.a).to.be.undefined; 24 | expect(m.b).to.be.undefined; 25 | }); 26 | 27 | 28 | it('basic access rules', () => { 29 | const Model = create({ 30 | name: 'Model', 31 | rules: { 32 | a: true, // always allow 33 | b: false, // always disallow 34 | c: () => true, // always allow 35 | d: () => false, // always disallow 36 | e: {}, // defaults 37 | f: { 38 | read: false, // always disallow 39 | // uses default readFail 40 | }, 41 | g: { 42 | read: false, // always disallow 43 | readFail: new Error(), // return an error when failed 44 | }, 45 | h: { 46 | read: false, // always disallow 47 | readFail: () => { throw new Error(); }, // throw when fail 48 | }, 49 | i: { 50 | read: ({$data}) => $data.i, // allow based on its value 51 | }, 52 | }, 53 | }); 54 | 55 | const m = new Model({a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8, i: 1}); 56 | 57 | expect(m.a).to.equal(1); 58 | 59 | expect(m.b).to.be.null; 60 | 61 | expect(m.c).to.equal(3); 62 | 63 | expect(m.d).to.be.null; 64 | 65 | expect(m.e).to.equal(5); 66 | 67 | expect(m.f).to.be.null; 68 | 69 | expect(m.g).to.be.an.instanceof(Error); 70 | 71 | expect(() => m.h).to.throw(Error); 72 | 73 | // should return its value since its truthy 74 | expect(m.i).to.equal(1); 75 | 76 | m.$data.i = 0; // change raw value 77 | expect(m.i).to.equal(1); // cached 78 | 79 | // should return null since its falsy 80 | delete m.i; // remove cached value 81 | expect(m.i).to.be.null; // recalculate value 82 | 83 | m.i = 2; // overwrite 84 | expect(m.i).to.equal(2); 85 | }); 86 | 87 | 88 | it('cache', () => { 89 | let aCount = 0; 90 | let bCount = 0; 91 | 92 | const Model = create({ 93 | name: 'Model', 94 | rules: { 95 | a: { 96 | read: () => ++aCount, 97 | cache: true, 98 | }, 99 | b: { 100 | read: () => ++bCount, 101 | cache: false, 102 | }, 103 | }, 104 | }); 105 | 106 | const m = new Model({a: 1, b: 1}); 107 | 108 | expect(m.a).to.equal(1); 109 | expect(m.a).to.equal(1); 110 | expect(aCount).to.equal(1); 111 | 112 | expect(m.b).to.equal(1); 113 | expect(m.b).to.equal(1); 114 | expect(bCount).to.equal(2); 115 | }); 116 | 117 | 118 | it('$props', () => { 119 | let callCount = 0; 120 | 121 | const Model = create({ 122 | name: 'Model', 123 | props: { 124 | isAdmin: ({$context}) => Boolean($context.admin), 125 | isGuest: ({$context}) => !$context.id, 126 | isAuthenticated: ({$props}) => !$props.isGuest, 127 | isOwner: ({$props: {authorId, authId}}) => authorId === authId, 128 | authId: ({$context}) => $context.id, 129 | authorId: ({$data}) => $data.id, 130 | callCount: () => ++callCount, 131 | }, 132 | rules: { 133 | adminField: ({$props}) => $props.isAdmin, 134 | authField: ({$props}) => $props.isAuthenticated, 135 | guestField: ({$props}) => $props.isGuest, 136 | ownerField: ({$props}) => $props.isOwner, 137 | }, 138 | }); 139 | 140 | const auth = {id: 1, admin: true}; 141 | 142 | 143 | const m1 = new Model({ 144 | id: 1, 145 | adminField: 2, 146 | authField: 3, 147 | guestField: 4, 148 | ownerField: 5, 149 | }, auth); 150 | 151 | expect(m1.$props.isAdmin).to.be.true; 152 | expect(m1.$props.isGuest).to.be.false; 153 | expect(m1.$props.isAuthenticated).to.be.true; 154 | expect(m1.$props.isOwner).to.be.true; 155 | expect(m1.$props.authId).to.equal(1); 156 | expect(m1.$props.authorId).to.equal(1); 157 | 158 | expect(m1.$props.callCount).to.equal(1); // not cached 159 | expect(m1.$props.callCount).to.equal(1); // cached 160 | 161 | expect(m1.adminField).to.equal(2); 162 | expect(m1.authField).to.equal(3); 163 | expect(m1.guestField).to.be.null; 164 | expect(m1.ownerField).to.equal(5); 165 | 166 | 167 | const m2 = new Model({ 168 | id: 2, 169 | adminField: 2, 170 | authField: 3, 171 | guestField: 4, 172 | ownerField: 5, 173 | }, auth); 174 | 175 | expect(m2.$props.isAdmin).to.be.true; 176 | expect(m2.$props.isGuest).to.be.false; 177 | expect(m2.$props.isAuthenticated).to.be.true; 178 | expect(m2.$props.isOwner).to.be.false; 179 | expect(m2.$props.authId).to.equal(1); 180 | expect(m2.$props.authorId).to.equal(2); 181 | expect(m2.$props.callCount).to.equal(2); // not cached 182 | expect(m2.$props.callCount).to.equal(2); // cached 183 | 184 | expect(m2.adminField).to.equal(2); 185 | expect(m2.authField).to.equal(3); 186 | expect(m2.guestField).to.be.null; 187 | expect(m2.ownerField).to.be.null; 188 | 189 | 190 | const auth2 = {id: 4, admin: false}; 191 | const m3 = new Model({ 192 | id: 3, 193 | adminField: 2, 194 | authField: 3, 195 | guestField: 4, 196 | ownerField: 5, 197 | }, auth2); 198 | 199 | expect(m3.$props.isAdmin).to.be.false; 200 | expect(m3.$props.isGuest).to.be.false; 201 | expect(m3.$props.isAuthenticated).to.be.true; 202 | expect(m3.$props.isOwner).to.be.false; 203 | expect(m3.$props.authId).to.equal(4); 204 | expect(m3.$props.authorId).to.equal(3); 205 | expect(m3.$props.callCount).to.equal(3); 206 | expect(m3.$props.callCount).to.equal(3); 207 | 208 | expect(m3.adminField).to.be.null; 209 | expect(m3.authField).to.equal(3); 210 | expect(m3.guestField).to.be.null; 211 | expect(m3.ownerField).to.be.null; 212 | 213 | 214 | const m4 = new Model({ 215 | id: 4, 216 | adminField: 2, 217 | authField: 3, 218 | guestField: 4, 219 | ownerField: 5, 220 | }, {}); 221 | 222 | expect(m4.$props.isAdmin).to.be.false; 223 | expect(m4.$props.isGuest).to.be.true; 224 | expect(m4.$props.isAuthenticated).to.be.false; 225 | expect(m4.$props.isOwner).to.be.false; 226 | expect(m4.$props.authId).to.be.undefined; 227 | expect(m4.$props.authorId).to.equal(4); 228 | expect(m4.$props.callCount).to.equal(4); 229 | expect(m4.$props.callCount).to.equal(4); 230 | 231 | expect(m4.adminField).to.be.null; 232 | expect(m4.authField).to.be.null; 233 | expect(m4.guestField).to.equal(4); 234 | expect(m4.ownerField).to.be.null; 235 | }); 236 | 237 | 238 | it('Parent / Child', () => { 239 | const Parent = create({ 240 | name: 'Parent', 241 | rules: { 242 | child: 'Child', 243 | children: { 244 | type: '[Child]', 245 | // type: 'Child', list: true, 246 | readListItem: ({$data}) => $data.id <= 3, 247 | }, 248 | }, 249 | }); 250 | 251 | const Child = create({ 252 | name: 'Child', 253 | rules: { 254 | id: true, 255 | child: { 256 | type: 'GrandChild', 257 | }, 258 | }, 259 | }); 260 | 261 | const GrandChild = create({ 262 | name: 'GrandChild', 263 | rules: { 264 | id: true, 265 | }, 266 | }); 267 | 268 | const context = {}; 269 | 270 | const p = new Parent({ 271 | child: {id: 1, child: {id: 4}}, 272 | children: [{id: 2}, {id: 3}, {id: 5}], 273 | }, context); 274 | 275 | expect(p.child).to.be.an.instanceof(Child); 276 | expect(p.child.id).to.equal(1); 277 | expect(p.child).to.equal(p.child); // cached 278 | 279 | expect(p.child.child).to.be.an.instanceof(GrandChild); 280 | expect(p.child.child.id).to.equal(4); 281 | expect(p.child.child).to.equal(p.child.child); // cached 282 | 283 | expect(p.child.child.$parent).to.equal(p.child); 284 | expect(p.child.child.$parent.$parent).to.equal(p); 285 | 286 | expect(p.child.child.$root).to.equal(p); 287 | 288 | expect(p.child.child.$context).to.equal(context); 289 | 290 | expect(p.child.child.$parentOfType('Child')).to.equal(p.child); 291 | expect(p.child.child.$parentOfType('Parent')).to.equal(p); 292 | expect(p.child.child.$parentOfType('GrandChild')).to.be.null; 293 | 294 | expect(p.children.length).to.equal(2); 295 | 296 | expect(p.children[0]).to.be.an.instanceof(Child); 297 | expect(p.children[0].id).to.equal(2); 298 | 299 | expect(p.children[1]).to.be.an.instanceof(Child); 300 | expect(p.children[1].id).to.equal(3); 301 | 302 | expect(p.children).to.equal(p.children); // cached 303 | expect(p.children[0]).to.equal(p.children[0]); // cached 304 | 305 | expect(p.child.$parent).to.equal(p); 306 | expect(p.children[0].$parent).to.equal(p); 307 | expect(p.children[0].$root).to.equal(p); 308 | 309 | expect(p.$context).to.equal(context); 310 | expect(p.child.$context).to.equal(context); 311 | expect(p.children[0].$context).to.equal(context); 312 | }); 313 | 314 | 315 | it('Class extention', () => { 316 | const Base = create({ 317 | name: 'Base', 318 | props: { 319 | prop1: () => 1, 320 | prop2: () => 2, 321 | }, 322 | rules: { 323 | field1: true, 324 | field2: false, 325 | }, 326 | }); 327 | 328 | const Class = create({ 329 | name: 'Class', 330 | base: Base, 331 | props: { 332 | prop2: () => 3, 333 | prop3: () => 4, 334 | }, 335 | rules: { 336 | field2: true, 337 | field3: true, 338 | }, 339 | }); 340 | 341 | const u = new Class({ 342 | field1: 1, 343 | field2: 2, 344 | field3: 3, 345 | }); 346 | 347 | expect(u).to.be.an.instanceof(Class); 348 | expect(u).to.be.an.instanceof(Base); 349 | expect(u).to.be.an.instanceof(Model); 350 | 351 | expect(u.field1).to.equal(1); 352 | expect(u.field2).to.equal(2); // override 353 | expect(u.field3).to.equal(3); 354 | 355 | // inherited props 356 | expect(u.$props.prop1).to.equal(1); 357 | expect(u.$props.prop2).to.equal(3); 358 | expect(u.$props.prop3).to.equal(4); 359 | }); 360 | 361 | 362 | it('Interfaces', () => { 363 | const Node = create({ 364 | name: 'Node', 365 | props: { 366 | prop1: () => 1, 367 | prop2: () => 2, 368 | }, 369 | rules: { 370 | id: true, 371 | }, 372 | }); 373 | 374 | const User = create({ 375 | name: 'Child', 376 | interfaces: [Node], 377 | props: { 378 | prop2: () => 3, 379 | prop3: () => 4, 380 | }, 381 | rules: { 382 | name: true, 383 | }, 384 | }); 385 | 386 | const u = new User({ 387 | id: 1, 388 | name: 'hi', 389 | }); 390 | 391 | // inherited rules 392 | expect(u.id).to.equal(1); 393 | 394 | expect(u.name).to.equal('hi'); 395 | 396 | expect(u.$implements(Node)).to.be.true; 397 | 398 | // inherited props 399 | expect(u.$props.prop1).to.equal(1); 400 | expect(u.$props.prop2).to.equal(3); 401 | expect(u.$props.prop3).to.equal(4); 402 | }); 403 | 404 | 405 | it('supports promise in props and rules', (done) => { 406 | const Model = create({ 407 | name: 'Model', 408 | props: { 409 | promiseFalse: () => Promise.resolve(false), 410 | promiseTrue: () => Promise.resolve(true), 411 | }, 412 | rules: { 413 | readPromiseFalse: { 414 | read: (model) => model.$props.promiseFalse, 415 | }, 416 | readPromiseTrue: { 417 | read: (model) => model.$props.promiseTrue, 418 | }, 419 | readMethodPromiseFalse: { 420 | read: (model) => model.$props.promiseFalse, 421 | method: true, 422 | }, 423 | readMethodPromiseTrue: { 424 | read: (model) => model.$props.promiseTrue, 425 | method: true, 426 | }, 427 | preReadPromiseFalse: { 428 | preRead: (model) => model.$props.promiseFalse, 429 | }, 430 | preReadPromiseTrue: { 431 | preRead: (model) => model.$props.promiseTrue, 432 | }, 433 | preReadMethodPromiseFalse: { 434 | preRead: (model) => model.$props.promiseFalse, 435 | method: true, 436 | }, 437 | preReadMethodPromiseTrue: { 438 | preRead: (model) => model.$props.promiseTrue, 439 | method: true, 440 | }, 441 | }, 442 | }); 443 | 444 | const m = new Model({ 445 | readPromiseFalse: 'a', 446 | readPromiseTrue: 'b', 447 | readMethodPromiseFalse(a) { 448 | return this.readPromiseTrue + a; 449 | }, 450 | readMethodPromiseTrue(a) { 451 | return this.readPromiseTrue + a; 452 | }, 453 | preReadPromiseFalse: 'e', 454 | preReadPromiseTrue: 'f', 455 | preReadMethodPromiseFalse(a) { 456 | return this.preReadPromiseTrue + a; 457 | }, 458 | preReadMethodPromiseTrue(a) { 459 | return this.preReadPromiseTrue + a; 460 | }, 461 | }); 462 | 463 | Promise.all([ 464 | m.readPromiseFalse.then((v) => expect(v).to.equal(null)), 465 | m.readPromiseTrue.then((v) => expect(v).to.equal('b')), 466 | m.readMethodPromiseFalse('c').then((v) => expect(v).to.equal(null)), 467 | m.readMethodPromiseTrue('d').then((v) => expect(v).to.equal('bd')), 468 | m.preReadPromiseFalse.then((v) => expect(v).to.equal(null)), 469 | m.preReadPromiseTrue.then((v) => expect(v).to.equal('f')), 470 | m.preReadMethodPromiseFalse('g').then((v) => expect(v).to.equal(null)), 471 | m.preReadMethodPromiseTrue('h').then((v) => expect(v).to.equal('fh')), 472 | ]).then(() => done(), done); 473 | }); 474 | 475 | 476 | it('supports promise in model', (done) => { 477 | const Child = create({ 478 | name: 'Child', 479 | rules: { 480 | v: { 481 | read: (model, key, value) => value === true, 482 | }, 483 | }, 484 | }); 485 | 486 | const Model = create({ 487 | name: 'Model', 488 | rules: { 489 | a: { 490 | type: 'Child', 491 | read: (model, key, value) => value instanceof Child, 492 | }, 493 | }, 494 | }); 495 | 496 | const m = new Model({ 497 | a: Promise.resolve({v: Promise.resolve(true)}), 498 | }); 499 | 500 | const m2 = new Model({ 501 | a: Promise.resolve({v: false}), 502 | }); 503 | 504 | const m3 = new Model({ 505 | a: Promise.resolve(null), 506 | }); 507 | 508 | Promise.all([ 509 | m.a.then((a) => a.v).then((v) => expect(v).to.equal(true)), 510 | m2.a.then((a) => a.v).then((v) => expect(v).to.equal(null)), 511 | m3.a.then((a) => expect(a).to.equal(null)), 512 | ]).then(() => done(), done); 513 | }); 514 | 515 | 516 | it('supports method with arguments', () => { 517 | const Model = create({ 518 | name: 'Model', 519 | rules: { 520 | getA: { 521 | read: true, 522 | method: true, 523 | }, 524 | }, 525 | }); 526 | 527 | const m = new Model({ 528 | getA(add) { 529 | return this.a + add; 530 | }, 531 | a: 3, 532 | }); 533 | 534 | expect(m.getA(2)).to.equal(5); 535 | expect(m.a).to.be.undefined; 536 | }); 537 | 538 | 539 | it('supports method whose return value is child type', (done) => { 540 | const Child = create({ 541 | name: 'Child', 542 | rules: { 543 | v: true, 544 | }, 545 | }); 546 | 547 | const Model = create({ 548 | name: 'Model', 549 | rules: { 550 | getA: { 551 | read: true, 552 | method: true, 553 | type: 'Child', 554 | }, 555 | }, 556 | }); 557 | 558 | const m = new Model({ 559 | getA(add) { 560 | return Promise.resolve({v: this.a + add}); 561 | }, 562 | a: 3, 563 | }); 564 | 565 | Promise.all([ 566 | m.getA(2).then((c) => { 567 | expect(c).to.be.an.instanceof(Child); 568 | expect(c.v).to.equal(5); 569 | expect(m.getA(2)).to.not.equal(m.getA(2)); 570 | }), 571 | m.getA(4).then((c) => { 572 | expect(c.v).to.equal(7); 573 | }), 574 | ]).then(() => done(), done); 575 | }); 576 | 577 | 578 | it('supports preRead', () => { 579 | const Model = create({ 580 | name: 'Model', 581 | rules: { 582 | a: { 583 | preRead: false, 584 | cache: false, 585 | }, 586 | getB: { 587 | preRead: false, 588 | method: true, 589 | }, 590 | }, 591 | }); 592 | 593 | let a = 0; 594 | let b = 0; 595 | const m = new Model({ 596 | get a() { return ++a; }, 597 | getB() { return ++b; }, 598 | }); 599 | 600 | expect(m.a).to.be.null; 601 | expect(m.a).to.be.null; 602 | expect(a).to.equal(0); 603 | 604 | expect(m.getB()).to.be.null; 605 | expect(m.getB()).to.be.null; 606 | expect(b).to.equal(0); 607 | }); 608 | 609 | 610 | it('README without GraphQL', () => { 611 | // create access control model for your data 612 | const Model = create({ 613 | // name for this access model 614 | name: 'Model', 615 | 616 | // define access rules 617 | rules: { 618 | // allow access to `public` property. 619 | public: true, 620 | 621 | secret: { 622 | // disallow access to `secret` property. 623 | read: false, 624 | 625 | // throw an error when read is disallowed. 626 | readFail: () => { throw new Error('Access denied'); }, 627 | }, 628 | 629 | conditional: { 630 | // access raw data via `$data`. 631 | // conditionally allow access if `conditional` <= 3. 632 | read: (model) => model.$data.conditional <= 3, 633 | 634 | readFail: (model) => { throw new Error(`${model.$data.conditional} > 3`); }, 635 | }, 636 | }, 637 | }); 638 | 639 | 640 | // create a wrapped instance of your data. 641 | const securedData = new Model({ 642 | public: 'public data', 643 | secret: 'something secret', 644 | conditional: 5, 645 | }); 646 | 647 | expect(securedData.public).to.equal('public data'); 648 | 649 | expect(() => securedData.secret).to.throw('Access denied'); 650 | 651 | expect(() => securedData.conditional).to.throw('5 > 3'); 652 | 653 | 654 | // same access model for different data. 655 | const securedData2 = new Model({conditional: 1}); 656 | 657 | expect(securedData2.conditional).to.equal(1); // 1 since 1 < 3. 658 | }); 659 | 660 | 661 | it('README without GraphQL', () => { 662 | // set default `readFail` 663 | config({ 664 | readFail: () => { throw new Error('Access denied'); }, 665 | }); 666 | 667 | const User = create({ 668 | name: 'User', 669 | 670 | // props are lazily initialized and cached once initialized. 671 | // accessible via `model.$props`. 672 | props: { 673 | isAdmin: (model) => model.$context.admin, 674 | 675 | isAuthenticated: (model) => Boolean(model.$context.userId), 676 | 677 | isOwner: (model) => model.$data.id === model.$context.userId, 678 | }, 679 | 680 | rules: { 681 | id: true, 682 | 683 | email: { 684 | // allow access by admin or owner. 685 | read: (model) => model.$props.isAdmin || model.$props.isOwner, 686 | 687 | // returns null when read denied. 688 | readFail: null, 689 | }, 690 | 691 | password: false, 692 | 693 | profile: { 694 | // Use `Profile` Rule for `profile`. 695 | type: 'Profile', 696 | 697 | // allow access by all authenticated users 698 | read: (model) => model.$props.isAuthenticated, 699 | 700 | readFail: () => { throw new Error('Login Required'); }, 701 | }, 702 | }, 703 | }); 704 | 705 | const Profile = create({ 706 | name: 'Profile', 707 | 708 | rules: { 709 | name: true, 710 | 711 | phone: { 712 | // Access `User` model via `$parent`. 713 | read: (model) => model.$parent.$props.isAdmin || model.$parent.$props.isOwner, 714 | 715 | readFail: () => { throw new Error('Not authorized!'); }, 716 | }, 717 | }, 718 | }); 719 | 720 | 721 | const session = { 722 | userId: 'session_user_id', 723 | admin: false, 724 | }; 725 | 726 | const userData = { 727 | id: 'user_id', 728 | email: 'user@example.com', 729 | password: 'secret', 730 | profile: { 731 | name: 'John Doe', 732 | phone: '123-456-7890', 733 | }, 734 | }; 735 | 736 | // pass `session` as a second param to make it available as `$context`. 737 | const user = new User(userData, session); 738 | 739 | expect(user.id).to.equal('user_id'); 740 | 741 | expect(user.email).to.be.null; // `null` since not admin nor owner. 742 | 743 | expect(() => user.password).to.throw('Access denied'); 744 | 745 | // `Profile` instance. accessible since authenticated. 746 | expect(user.profile).to.be.an.instanceof(Profile); 747 | 748 | expect(user.profile.name).to.equal('John Doe'); 749 | 750 | expect(() => user.profile.phone).to.throw('Not authorized!'); 751 | }); 752 | 753 | 754 | it('README $context', () => { 755 | const Model = create({ 756 | name: 'Model', 757 | props: { 758 | // context.admin 759 | isAdmin: (model) => Boolean(model.$context.admin), 760 | 761 | // !context.userId 762 | isGuest: (model) => !model.$context.userId, 763 | 764 | // !props.isGuest 765 | isAuthenticated: (model) => !model.$props.isGuest, 766 | 767 | // context.userId 768 | authId: (model) => model.$context.userId, 769 | 770 | // data.authorId 771 | authorId: (model) => model.$data.authorId, 772 | 773 | // props.authorId === props.authId 774 | isOwner: (model) => model.$props.authorId === model.$props.authId, 775 | }, 776 | defaultRule: { 777 | // read allowed by default 778 | read: (model) => true, 779 | 780 | // throws an error when read not allowed 781 | readFail: (model, key) => { throw new Error(`Cannot access '${key}'`); }, 782 | }, 783 | rules: { 784 | 785 | // use defaultRule settings 786 | authorId: {}, 787 | 788 | // above is equivalent to: 789 | adminField: { 790 | read: (model) => model.$props.isAdmin, 791 | }, 792 | 793 | // read allowed only if `props.isAuthenticated` 794 | authField: (model) => model.$props.isAuthenticated, 795 | 796 | // read allowed only if `props.isGuest` 797 | guestField: (model) => model.$props.isGuest, 798 | 799 | // read allowed only if `props.isOwner` 800 | ownerField: (model) => model.$props.isOwner, 801 | 802 | notAllowedField: { 803 | read: (model) => false, 804 | readFail: (model, key) => { throw new Error('not allowed'); }, 805 | }, 806 | 807 | nullField: { 808 | read: (model) => false, 809 | readFail: (model) => null, 810 | }, 811 | }, 812 | }); 813 | 814 | const session = { 815 | userId: 'user_id_1', 816 | admin: true, 817 | }; 818 | 819 | const model = new Model( 820 | { 821 | authorId: 'user_id_1', 822 | adminField: 'adminFieldValue', 823 | authField: 'authFieldValue', 824 | guestField: 'guestFieldValue', 825 | ownerField: 'ownerFieldValue', 826 | notAllowedField: 'notAllowedFieldValue', 827 | nullField: 'nullFieldValue', 828 | undefinedField: 'undefinedFieldValue', 829 | }, // passed as $data 830 | session, // passed as $context 831 | ); 832 | 833 | expect(model.$props.isAdmin).to.equal(true); 834 | expect(model.$props.isGuest).to.equal(false); 835 | expect(model.$props.isAuthenticated).to.equal(true); 836 | expect(model.$props.isOwner).to.equal(true); 837 | expect(model.$props.authId).to.equal('user_id_1'); 838 | expect(model.$props.authorId).to.equal('user_id_1'); 839 | 840 | // allowed to read by defaultRule.read rule 841 | expect(model.authorId).to.equal('user_id_1'); 842 | 843 | // allowed to read since $props.isAdmin 844 | expect(model.adminField).to.equal('adminFieldValue'); 845 | 846 | // allowed to read since $props.isAuthenticated 847 | expect(model.authField).to.equal('authFieldValue'); 848 | 849 | // not allowed to read since !$props.isGuest 850 | expect(() => model.guestField).to.throw(/guestField/); // throws Error("Cannot access 'guestField'") 851 | 852 | // allowed to read since $props.isOwner 853 | expect(model.ownerField).to.equal('ownerFieldValue'); 854 | 855 | // not allowed to read 856 | expect(() => model.notAllowedField).to.throw('not allowed'); // throws Error('not allowed') 857 | 858 | // not allowed to read; returns null 859 | expect(model.nullField).to.equal(null); 860 | 861 | // rule is undefined 862 | expect(model.undefinedField).to.equal(undefined); 863 | }); 864 | 865 | it('"read" must be a function', () => { 866 | expect(() => 867 | create({ 868 | name: 'Model', 869 | rules: { 870 | a: { 871 | read: {}, 872 | }, 873 | }, 874 | }) 875 | ).to.throw('function'); 876 | }); 877 | 878 | it('"read" with method must be a function', () => { 879 | expect(() => 880 | create({ 881 | name: 'Model', 882 | rules: { 883 | a: { 884 | read: {}, 885 | method: true, 886 | }, 887 | }, 888 | }) 889 | ).to.throw('function'); 890 | }); 891 | 892 | it('"preRead" must be a function', () => { 893 | expect(() => 894 | create({ 895 | name: 'Model', 896 | rules: { 897 | a: { 898 | preRead: {}, 899 | }, 900 | }, 901 | }) 902 | ).to.throw('function'); 903 | }); 904 | 905 | it('"preRead" with method must be a function', () => { 906 | expect(() => 907 | create({ 908 | name: 'Model', 909 | rules: { 910 | a: { 911 | preRead: {}, 912 | method: true, 913 | }, 914 | }, 915 | }) 916 | ).to.throw('function'); 917 | }); 918 | 919 | it('Parent / Child with delayed compile for circular references', () => { 920 | const Parent = create({ 921 | name: 'P1', 922 | rules: () => ({ 923 | child: Child, 924 | }), 925 | }); 926 | 927 | const Child = create({ 928 | name: 'C1', 929 | rules: () => ({ 930 | parent: Parent, 931 | }), 932 | }); 933 | 934 | const p = new Parent({child: { 935 | parent: {child: {}}, 936 | }}); 937 | 938 | expect(p).to.be.an.instanceof(Parent); 939 | expect(p.child).to.not.be.an.instanceof(Child); 940 | 941 | compile(); 942 | 943 | expect(p.child).to.be.an.instanceof(Child); 944 | expect(p.child.parent).to.be.an.instanceof(Parent); 945 | expect(p.child.parent.child).to.be.an.instanceof(Child); 946 | expect(p.child.parent.child.parent).to.be.undefined; 947 | }); 948 | }); 949 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers js:babel-register 2 | --require ./test/setup.js 3 | --reporter spec 4 | --timeout 5000 5 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai'); 2 | chai.use(require('chai-as-promised')); 3 | -------------------------------------------------------------------------------- /testout/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _chai = require('chai'); 4 | 5 | var _chai2 = _interopRequireDefault(_chai); 6 | 7 | var _chaiAsPromised = require('chai-as-promised'); 8 | 9 | var _chaiAsPromised2 = _interopRequireDefault(_chaiAsPromised); 10 | 11 | var _lib = require('../lib'); 12 | 13 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 14 | 15 | if (typeof Promise === 'undefined') { 16 | require('es6-promise').polyfill(); 17 | } 18 | 19 | _chai2.default.use(_chaiAsPromised2.default); 20 | 21 | 22 | describe('graphql-rule', function () { 23 | beforeEach(function () { 24 | (0, _lib.clear)(); 25 | (0, _lib.config)({ 26 | read: true, 27 | readFail: null 28 | }); 29 | }); 30 | 31 | it('no rules', function () { 32 | var Model = (0, _lib.create)({ 33 | name: 'Model' 34 | }); 35 | 36 | var m = new Model({ a: 1, b: 2 }); 37 | (0, _chai.expect)(m.a).to.be.undefined; 38 | (0, _chai.expect)(m.b).to.be.undefined; 39 | }); 40 | 41 | it('basic access rules', function () { 42 | var Model = (0, _lib.create)({ 43 | name: 'Model', 44 | rules: { 45 | a: true, // always allow 46 | b: false, // always disallow 47 | c: function c() { 48 | return true; 49 | }, // always allow 50 | d: function d() { 51 | return false; 52 | }, // always disallow 53 | e: {}, // defaults 54 | f: { 55 | read: false }, 56 | // always disallow 57 | // uses default readFail 58 | g: { 59 | read: false, // always disallow 60 | readFail: new Error() }, 61 | // return an error when failed 62 | h: { 63 | read: false, // always disallow 64 | readFail: function readFail() { 65 | throw new Error(); 66 | } }, 67 | // throw when fail 68 | i: { 69 | read: function read(_ref) { 70 | var $data = _ref.$data; 71 | return $data.i; 72 | } } 73 | } 74 | }); 75 | 76 | // allow based on its value 77 | var m = new Model({ a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8, i: 1 }); 78 | 79 | (0, _chai.expect)(m.a).to.equal(1); 80 | 81 | (0, _chai.expect)(m.b).to.be.null; 82 | 83 | (0, _chai.expect)(m.c).to.equal(3); 84 | 85 | (0, _chai.expect)(m.d).to.be.null; 86 | 87 | (0, _chai.expect)(m.e).to.equal(5); 88 | 89 | (0, _chai.expect)(m.f).to.be.null; 90 | 91 | (0, _chai.expect)(m.g).to.be.an.instanceof(Error); 92 | 93 | (0, _chai.expect)(function () { 94 | return m.h; 95 | }).to.throw(Error); 96 | 97 | // should return its value since its truthy 98 | (0, _chai.expect)(m.i).to.equal(1); 99 | 100 | m.$data.i = 0; // change raw value 101 | (0, _chai.expect)(m.i).to.equal(1); // cached 102 | 103 | // should return null since its falsy 104 | delete m.i; // remove cached value 105 | (0, _chai.expect)(m.i).to.be.null; // recalculate value 106 | 107 | m.i = 2; // overwrite 108 | (0, _chai.expect)(m.i).to.equal(2); 109 | }); 110 | 111 | it('cache', function () { 112 | var aCount = 0; 113 | var bCount = 0; 114 | 115 | var Model = (0, _lib.create)({ 116 | name: 'Model', 117 | rules: { 118 | a: { 119 | read: function read() { 120 | return ++aCount; 121 | }, 122 | cache: true 123 | }, 124 | b: { 125 | read: function read() { 126 | return ++bCount; 127 | }, 128 | cache: false 129 | } 130 | } 131 | }); 132 | 133 | var m = new Model({ a: 1, b: 1 }); 134 | 135 | (0, _chai.expect)(m.a).to.equal(1); 136 | (0, _chai.expect)(m.a).to.equal(1); 137 | (0, _chai.expect)(aCount).to.equal(1); 138 | 139 | (0, _chai.expect)(m.b).to.equal(1); 140 | (0, _chai.expect)(m.b).to.equal(1); 141 | (0, _chai.expect)(bCount).to.equal(2); 142 | }); 143 | 144 | it('$props', function () { 145 | var _callCount = 0; 146 | 147 | var Model = (0, _lib.create)({ 148 | name: 'Model', 149 | props: { 150 | isAdmin: function isAdmin(_ref2) { 151 | var $context = _ref2.$context; 152 | return Boolean($context.admin); 153 | }, 154 | isGuest: function isGuest(_ref3) { 155 | var $context = _ref3.$context; 156 | return !$context.id; 157 | }, 158 | isAuthenticated: function isAuthenticated(_ref4) { 159 | var $props = _ref4.$props; 160 | return !$props.isGuest; 161 | }, 162 | isOwner: function isOwner(_ref5) { 163 | var _ref5$$props = _ref5.$props; 164 | var authorId = _ref5$$props.authorId; 165 | var authId = _ref5$$props.authId; 166 | return authorId === authId; 167 | }, 168 | authId: function authId(_ref6) { 169 | var $context = _ref6.$context; 170 | return $context.id; 171 | }, 172 | authorId: function authorId(_ref7) { 173 | var $data = _ref7.$data; 174 | return $data.id; 175 | }, 176 | callCount: function callCount() { 177 | return ++_callCount; 178 | } 179 | }, 180 | rules: { 181 | adminField: function adminField(_ref8) { 182 | var $props = _ref8.$props; 183 | return $props.isAdmin; 184 | }, 185 | authField: function authField(_ref9) { 186 | var $props = _ref9.$props; 187 | return $props.isAuthenticated; 188 | }, 189 | guestField: function guestField(_ref10) { 190 | var $props = _ref10.$props; 191 | return $props.isGuest; 192 | }, 193 | ownerField: function ownerField(_ref11) { 194 | var $props = _ref11.$props; 195 | return $props.isOwner; 196 | } 197 | } 198 | }); 199 | 200 | var auth = { id: 1, admin: true }; 201 | 202 | var m1 = new Model({ 203 | id: 1, 204 | adminField: 2, 205 | authField: 3, 206 | guestField: 4, 207 | ownerField: 5 208 | }, auth); 209 | 210 | (0, _chai.expect)(m1.$props.isAdmin).to.be.true; 211 | (0, _chai.expect)(m1.$props.isGuest).to.be.false; 212 | (0, _chai.expect)(m1.$props.isAuthenticated).to.be.true; 213 | (0, _chai.expect)(m1.$props.isOwner).to.be.true; 214 | (0, _chai.expect)(m1.$props.authId).to.equal(1); 215 | (0, _chai.expect)(m1.$props.authorId).to.equal(1); 216 | 217 | (0, _chai.expect)(m1.$props.callCount).to.equal(1); // not cached 218 | (0, _chai.expect)(m1.$props.callCount).to.equal(1); // cached 219 | 220 | (0, _chai.expect)(m1.adminField).to.equal(2); 221 | (0, _chai.expect)(m1.authField).to.equal(3); 222 | (0, _chai.expect)(m1.guestField).to.be.null; 223 | (0, _chai.expect)(m1.ownerField).to.equal(5); 224 | 225 | var m2 = new Model({ 226 | id: 2, 227 | adminField: 2, 228 | authField: 3, 229 | guestField: 4, 230 | ownerField: 5 231 | }, auth); 232 | 233 | (0, _chai.expect)(m2.$props.isAdmin).to.be.true; 234 | (0, _chai.expect)(m2.$props.isGuest).to.be.false; 235 | (0, _chai.expect)(m2.$props.isAuthenticated).to.be.true; 236 | (0, _chai.expect)(m2.$props.isOwner).to.be.false; 237 | (0, _chai.expect)(m2.$props.authId).to.equal(1); 238 | (0, _chai.expect)(m2.$props.authorId).to.equal(2); 239 | (0, _chai.expect)(m2.$props.callCount).to.equal(2); // not cached 240 | (0, _chai.expect)(m2.$props.callCount).to.equal(2); // cached 241 | 242 | (0, _chai.expect)(m2.adminField).to.equal(2); 243 | (0, _chai.expect)(m2.authField).to.equal(3); 244 | (0, _chai.expect)(m2.guestField).to.be.null; 245 | (0, _chai.expect)(m2.ownerField).to.be.null; 246 | 247 | var auth2 = { id: 4, admin: false }; 248 | var m3 = new Model({ 249 | id: 3, 250 | adminField: 2, 251 | authField: 3, 252 | guestField: 4, 253 | ownerField: 5 254 | }, auth2); 255 | 256 | (0, _chai.expect)(m3.$props.isAdmin).to.be.false; 257 | (0, _chai.expect)(m3.$props.isGuest).to.be.false; 258 | (0, _chai.expect)(m3.$props.isAuthenticated).to.be.true; 259 | (0, _chai.expect)(m3.$props.isOwner).to.be.false; 260 | (0, _chai.expect)(m3.$props.authId).to.equal(4); 261 | (0, _chai.expect)(m3.$props.authorId).to.equal(3); 262 | (0, _chai.expect)(m3.$props.callCount).to.equal(3); 263 | (0, _chai.expect)(m3.$props.callCount).to.equal(3); 264 | 265 | (0, _chai.expect)(m3.adminField).to.be.null; 266 | (0, _chai.expect)(m3.authField).to.equal(3); 267 | (0, _chai.expect)(m3.guestField).to.be.null; 268 | (0, _chai.expect)(m3.ownerField).to.be.null; 269 | 270 | var m4 = new Model({ 271 | id: 4, 272 | adminField: 2, 273 | authField: 3, 274 | guestField: 4, 275 | ownerField: 5 276 | }, {}); 277 | 278 | (0, _chai.expect)(m4.$props.isAdmin).to.be.false; 279 | (0, _chai.expect)(m4.$props.isGuest).to.be.true; 280 | (0, _chai.expect)(m4.$props.isAuthenticated).to.be.false; 281 | (0, _chai.expect)(m4.$props.isOwner).to.be.false; 282 | (0, _chai.expect)(m4.$props.authId).to.be.undefined; 283 | (0, _chai.expect)(m4.$props.authorId).to.equal(4); 284 | (0, _chai.expect)(m4.$props.callCount).to.equal(4); 285 | (0, _chai.expect)(m4.$props.callCount).to.equal(4); 286 | 287 | (0, _chai.expect)(m4.adminField).to.be.null; 288 | (0, _chai.expect)(m4.authField).to.be.null; 289 | (0, _chai.expect)(m4.guestField).to.equal(4); 290 | (0, _chai.expect)(m4.ownerField).to.be.null; 291 | }); 292 | 293 | it('Parent / Child', function () { 294 | var Parent = (0, _lib.create)({ 295 | name: 'Parent', 296 | rules: { 297 | child: 'Child', 298 | children: { 299 | type: '[Child]', 300 | // type: 'Child', list: true, 301 | readListItem: function readListItem(_ref12) { 302 | var $data = _ref12.$data; 303 | return $data.id <= 3; 304 | } 305 | } 306 | } 307 | }); 308 | 309 | var Child = (0, _lib.create)({ 310 | name: 'Child', 311 | rules: { 312 | id: true, 313 | child: { 314 | type: 'GrandChild' 315 | } 316 | } 317 | }); 318 | 319 | var GrandChild = (0, _lib.create)({ 320 | name: 'GrandChild', 321 | rules: { 322 | id: true 323 | } 324 | }); 325 | 326 | var context = {}; 327 | 328 | var p = new Parent({ 329 | child: { id: 1, child: { id: 4 } }, 330 | children: [{ id: 2 }, { id: 3 }, { id: 5 }] 331 | }, context); 332 | 333 | (0, _chai.expect)(p.child).to.be.an.instanceof(Child); 334 | (0, _chai.expect)(p.child.id).to.equal(1); 335 | (0, _chai.expect)(p.child).to.equal(p.child); // cached 336 | 337 | (0, _chai.expect)(p.child.child).to.be.an.instanceof(GrandChild); 338 | (0, _chai.expect)(p.child.child.id).to.equal(4); 339 | (0, _chai.expect)(p.child.child).to.equal(p.child.child); // cached 340 | 341 | (0, _chai.expect)(p.child.child.$parent).to.equal(p.child); 342 | (0, _chai.expect)(p.child.child.$parent.$parent).to.equal(p); 343 | 344 | (0, _chai.expect)(p.child.child.$root).to.equal(p); 345 | 346 | (0, _chai.expect)(p.child.child.$context).to.equal(context); 347 | 348 | (0, _chai.expect)(p.child.child.$parentOfType('Child')).to.equal(p.child); 349 | (0, _chai.expect)(p.child.child.$parentOfType('Parent')).to.equal(p); 350 | (0, _chai.expect)(p.child.child.$parentOfType('GrandChild')).to.be.null; 351 | 352 | (0, _chai.expect)(p.children.length).to.equal(2); 353 | 354 | (0, _chai.expect)(p.children[0]).to.be.an.instanceof(Child); 355 | (0, _chai.expect)(p.children[0].id).to.equal(2); 356 | 357 | (0, _chai.expect)(p.children[1]).to.be.an.instanceof(Child); 358 | (0, _chai.expect)(p.children[1].id).to.equal(3); 359 | 360 | (0, _chai.expect)(p.children).to.equal(p.children); // cached 361 | (0, _chai.expect)(p.children[0]).to.equal(p.children[0]); // cached 362 | 363 | (0, _chai.expect)(p.child.$parent).to.equal(p); 364 | (0, _chai.expect)(p.children[0].$parent).to.equal(p); 365 | (0, _chai.expect)(p.children[0].$root).to.equal(p); 366 | 367 | (0, _chai.expect)(p.$context).to.equal(context); 368 | (0, _chai.expect)(p.child.$context).to.equal(context); 369 | (0, _chai.expect)(p.children[0].$context).to.equal(context); 370 | }); 371 | 372 | it('Class extention', function () { 373 | var Base = (0, _lib.create)({ 374 | name: 'Base', 375 | props: { 376 | prop1: function prop1() { 377 | return 1; 378 | }, 379 | prop2: function prop2() { 380 | return 2; 381 | } 382 | }, 383 | rules: { 384 | field1: true, 385 | field2: false 386 | } 387 | }); 388 | 389 | var Class = (0, _lib.create)({ 390 | name: 'Class', 391 | base: Base, 392 | props: { 393 | prop2: function prop2() { 394 | return 3; 395 | }, 396 | prop3: function prop3() { 397 | return 4; 398 | } 399 | }, 400 | rules: { 401 | field2: true, 402 | field3: true 403 | } 404 | }); 405 | 406 | var u = new Class({ 407 | field1: 1, 408 | field2: 2, 409 | field3: 3 410 | }); 411 | 412 | (0, _chai.expect)(u).to.be.an.instanceof(Class); 413 | (0, _chai.expect)(u).to.be.an.instanceof(Base); 414 | (0, _chai.expect)(u).to.be.an.instanceof(_lib.Model); 415 | 416 | (0, _chai.expect)(u.field1).to.equal(1); 417 | (0, _chai.expect)(u.field2).to.equal(2); // override 418 | (0, _chai.expect)(u.field3).to.equal(3); 419 | 420 | // inherited props 421 | (0, _chai.expect)(u.$props.prop1).to.equal(1); 422 | (0, _chai.expect)(u.$props.prop2).to.equal(3); 423 | (0, _chai.expect)(u.$props.prop3).to.equal(4); 424 | }); 425 | 426 | it('Interfaces', function () { 427 | var Node = (0, _lib.create)({ 428 | name: 'Node', 429 | props: { 430 | prop1: function prop1() { 431 | return 1; 432 | }, 433 | prop2: function prop2() { 434 | return 2; 435 | } 436 | }, 437 | rules: { 438 | id: true 439 | } 440 | }); 441 | 442 | var User = (0, _lib.create)({ 443 | name: 'Child', 444 | interfaces: [Node], 445 | props: { 446 | prop2: function prop2() { 447 | return 3; 448 | }, 449 | prop3: function prop3() { 450 | return 4; 451 | } 452 | }, 453 | rules: { 454 | name: true 455 | } 456 | }); 457 | 458 | var u = new User({ 459 | id: 1, 460 | name: 'hi' 461 | }); 462 | 463 | // inherited rules 464 | (0, _chai.expect)(u.id).to.equal(1); 465 | 466 | (0, _chai.expect)(u.name).to.equal('hi'); 467 | 468 | (0, _chai.expect)(u.$implements(Node)).to.be.true; 469 | 470 | // inherited props 471 | (0, _chai.expect)(u.$props.prop1).to.equal(1); 472 | (0, _chai.expect)(u.$props.prop2).to.equal(3); 473 | (0, _chai.expect)(u.$props.prop3).to.equal(4); 474 | }); 475 | 476 | it('supports promise', function (done) { 477 | var Child = (0, _lib.create)({ 478 | name: 'Child', 479 | rules: { 480 | v: { 481 | read: function read(model, key, value) { 482 | return value === true; 483 | } 484 | } 485 | } 486 | }); 487 | 488 | var Model = (0, _lib.create)({ 489 | name: 'Model', 490 | rules: { 491 | a: { 492 | type: 'Child', 493 | read: function read(model, key, value) { 494 | return value instanceof Child; 495 | } 496 | } 497 | } 498 | }); 499 | 500 | var m = new Model({ 501 | a: Promise.resolve({ v: Promise.resolve(true) }) 502 | }); 503 | 504 | var m2 = new Model({ 505 | a: Promise.resolve({ v: false }) 506 | }); 507 | 508 | var m3 = new Model({ 509 | a: Promise.resolve(null) 510 | }); 511 | 512 | Promise.all([m.a.then(function (a) { 513 | return a.v; 514 | }).then(function (v) { 515 | return (0, _chai.expect)(v).to.equal(true); 516 | }), m2.a.then(function (a) { 517 | return a.v; 518 | }).then(function (v) { 519 | return (0, _chai.expect)(v).to.equal(null); 520 | }), m3.a.then(function (a) { 521 | return (0, _chai.expect)(a).to.equal(null); 522 | })]).then(function () { 523 | return done(); 524 | }, done); 525 | }); 526 | 527 | it('supports method with arguments', function () { 528 | var Model = (0, _lib.create)({ 529 | name: 'Model', 530 | rules: { 531 | getA: { 532 | read: true, 533 | method: true 534 | } 535 | } 536 | }); 537 | 538 | var m = new Model({ 539 | getA: function getA(add) { 540 | return this.a + add; 541 | }, 542 | 543 | a: 3 544 | }); 545 | 546 | (0, _chai.expect)(m.getA(2)).to.equal(5); 547 | (0, _chai.expect)(m.a).to.be.undefined; 548 | }); 549 | 550 | it('supports method whose return value is child type', function (done) { 551 | var Child = (0, _lib.create)({ 552 | name: 'Child', 553 | rules: { 554 | v: true 555 | } 556 | }); 557 | 558 | var Model = (0, _lib.create)({ 559 | name: 'Model', 560 | rules: { 561 | getA: { 562 | read: true, 563 | method: true, 564 | type: 'Child' 565 | } 566 | } 567 | }); 568 | 569 | var m = new Model({ 570 | getA: function getA(add) { 571 | return Promise.resolve({ v: this.a + add }); 572 | }, 573 | 574 | a: 3 575 | }); 576 | 577 | Promise.all([m.getA(2).then(function (c) { 578 | ; 579 | (0, _chai.expect)(c).to.be.an.instanceof(Child); 580 | (0, _chai.expect)(c.v).to.equal(5); 581 | (0, _chai.expect)(m.getA(2)).to.not.equal(m.getA(2)); 582 | }), m.getA(4).then(function (c) { 583 | (0, _chai.expect)(c.v).to.equal(7); 584 | })]).then(function () { 585 | return done(); 586 | }, done); 587 | }); 588 | 589 | it('supports preRead', function () { 590 | var Model = (0, _lib.create)({ 591 | name: 'Model', 592 | rules: { 593 | a: { 594 | preRead: false, 595 | cache: false 596 | }, 597 | getB: { 598 | preRead: false, 599 | method: true 600 | } 601 | } 602 | }); 603 | 604 | var a = 0; 605 | var b = 0; 606 | var m = new Model({ 607 | get a() { 608 | return ++a; 609 | }, 610 | getB: function getB() { 611 | return ++b; 612 | } 613 | }); 614 | 615 | (0, _chai.expect)(m.a).to.be.null; 616 | (0, _chai.expect)(m.a).to.be.null; 617 | (0, _chai.expect)(a).to.equal(0); 618 | 619 | (0, _chai.expect)(m.getB()).to.be.null; 620 | (0, _chai.expect)(m.getB()).to.be.null; 621 | (0, _chai.expect)(b).to.equal(0); 622 | }); 623 | 624 | it('README without GraphQL', function () { 625 | // create access control model for your data 626 | var Model = (0, _lib.create)({ 627 | // name for this access model 628 | name: 'Model', 629 | 630 | // define access rules 631 | rules: { 632 | // allow access to `public` property. 633 | public: true, 634 | 635 | secret: { 636 | // disallow access to `secret` property. 637 | read: false, 638 | 639 | // throw an error when read is disallowed. 640 | readFail: function readFail() { 641 | throw new Error('Access denied'); 642 | } 643 | }, 644 | 645 | conditional: { 646 | // access raw data via `$data`. 647 | // conditionally allow access if `conditional` <= 3. 648 | read: function read(model) { 649 | return model.$data.conditional <= 3; 650 | }, 651 | 652 | readFail: function readFail(model) { 653 | throw new Error(model.$data.conditional + ' > 3'); 654 | } 655 | } 656 | } 657 | }); 658 | 659 | // create a wrapped instance of your data. 660 | var securedData = new Model({ 661 | public: 'public data', 662 | secret: 'something secret', 663 | conditional: 5 664 | }); 665 | 666 | (0, _chai.expect)(securedData.public).to.equal('public data'); 667 | 668 | (0, _chai.expect)(function () { 669 | return securedData.secret; 670 | }).to.throw('Access denied'); 671 | 672 | (0, _chai.expect)(function () { 673 | return securedData.conditional; 674 | }).to.throw('5 > 3'); 675 | 676 | // same access model for different data. 677 | var securedData2 = new Model({ conditional: 1 }); 678 | 679 | (0, _chai.expect)(securedData2.conditional).to.equal(1); // 1 since 1 < 3. 680 | }); 681 | 682 | it('README without GraphQL', function () { 683 | // set default `readFail` 684 | (0, _lib.config)({ 685 | readFail: function readFail() { 686 | throw new Error('Access denied'); 687 | } 688 | }); 689 | 690 | var User = (0, _lib.create)({ 691 | name: 'User', 692 | 693 | // props are lazily initialized and cached once initialized. 694 | // accessible via `model.$props`. 695 | props: { 696 | isAdmin: function isAdmin(model) { 697 | return model.$context.admin; 698 | }, 699 | 700 | isAuthenticated: function isAuthenticated(model) { 701 | return Boolean(model.$context.userId); 702 | }, 703 | 704 | isOwner: function isOwner(model) { 705 | return model.$data.id === model.$context.userId; 706 | } 707 | }, 708 | 709 | rules: { 710 | id: true, 711 | 712 | email: { 713 | // allow access by admin or owner. 714 | read: function read(model) { 715 | return model.$props.isAdmin || model.$props.isOwner; 716 | }, 717 | 718 | // returns null when read denied. 719 | readFail: null 720 | }, 721 | 722 | password: false, 723 | 724 | profile: { 725 | // Use `Profile` Rule for `profile`. 726 | type: 'Profile', 727 | 728 | // allow access by all authenticated users 729 | read: function read(model) { 730 | return model.$props.isAuthenticated; 731 | }, 732 | 733 | readFail: function readFail() { 734 | throw new Error('Login Required'); 735 | } 736 | } 737 | } 738 | }); 739 | 740 | var Profile = (0, _lib.create)({ 741 | name: 'Profile', 742 | 743 | rules: { 744 | name: true, 745 | 746 | phone: { 747 | // Access `User` model via `$parent`. 748 | read: function read(model) { 749 | return model.$parent.$props.isAdmin || model.$parent.$props.isOwner; 750 | }, 751 | 752 | readFail: function readFail() { 753 | throw new Error('Not authorized!'); 754 | } 755 | } 756 | } 757 | }); 758 | 759 | var session = { 760 | userId: 'session_user_id', 761 | admin: false 762 | }; 763 | 764 | var userData = { 765 | id: 'user_id', 766 | email: 'user@example.com', 767 | password: 'secret', 768 | profile: { 769 | name: 'John Doe', 770 | phone: '123-456-7890' 771 | } 772 | }; 773 | 774 | // pass `session` as a second param to make it available as `$context`. 775 | var user = new User(userData, session); 776 | 777 | (0, _chai.expect)(user.id).to.equal('user_id'); 778 | 779 | (0, _chai.expect)(user.email).to.be.null; // `null` since not admin nor owner. 780 | 781 | (0, _chai.expect)(function () { 782 | return user.password; 783 | }).to.throw('Access denied'); 784 | 785 | // `Profile` instance. accessible since authenticated. 786 | (0, _chai.expect)(user.profile).to.be.an.instanceof(Profile); 787 | 788 | (0, _chai.expect)(user.profile.name).to.equal('John Doe'); 789 | 790 | (0, _chai.expect)(function () { 791 | return user.profile.phone; 792 | }).to.throw('Not authorized!'); 793 | }); 794 | 795 | it('README $context', function () { 796 | var Model = (0, _lib.create)({ 797 | name: 'Model', 798 | props: { 799 | // context.admin 800 | isAdmin: function isAdmin(model) { 801 | return Boolean(model.$context.admin); 802 | }, 803 | 804 | // !context.userId 805 | isGuest: function isGuest(model) { 806 | return !model.$context.userId; 807 | }, 808 | 809 | // !props.isGuest 810 | isAuthenticated: function isAuthenticated(model) { 811 | return !model.$props.isGuest; 812 | }, 813 | 814 | // context.userId 815 | authId: function authId(model) { 816 | return model.$context.userId; 817 | }, 818 | 819 | // data.authorId 820 | authorId: function authorId(model) { 821 | return model.$data.authorId; 822 | }, 823 | 824 | // props.authorId === props.authId 825 | isOwner: function isOwner(model) { 826 | return model.$props.authorId === model.$props.authId; 827 | } 828 | }, 829 | defaultRule: { 830 | // read allowed by default 831 | read: function read(model) { 832 | return true; 833 | }, 834 | 835 | // throws an error when read not allowed 836 | readFail: function readFail(model, key) { 837 | throw new Error('Cannot access \'' + key + '\''); 838 | } 839 | }, 840 | rules: { 841 | 842 | // use defaultRule settings 843 | authorId: {}, 844 | 845 | // above is equivalent to: 846 | adminField: { 847 | read: function read(model) { 848 | return model.$props.isAdmin; 849 | } 850 | }, 851 | 852 | // read allowed only if `props.isAuthenticated` 853 | authField: function authField(model) { 854 | return model.$props.isAuthenticated; 855 | }, 856 | 857 | // read allowed only if `props.isGuest` 858 | guestField: function guestField(model) { 859 | return model.$props.isGuest; 860 | }, 861 | 862 | // read allowed only if `props.isOwner` 863 | ownerField: function ownerField(model) { 864 | return model.$props.isOwner; 865 | }, 866 | 867 | notAllowedField: { 868 | read: function read(model) { 869 | return false; 870 | }, 871 | readFail: function readFail(model, key) { 872 | throw new Error('not allowed'); 873 | } 874 | }, 875 | 876 | nullField: { 877 | read: function read(model) { 878 | return false; 879 | }, 880 | readFail: function readFail(model) { 881 | return null; 882 | } 883 | } 884 | } 885 | }); 886 | 887 | var session = { 888 | userId: 'user_id_1', 889 | admin: true 890 | }; 891 | 892 | var model = new Model({ 893 | authorId: 'user_id_1', 894 | adminField: 'adminFieldValue', 895 | authField: 'authFieldValue', 896 | guestField: 'guestFieldValue', 897 | ownerField: 'ownerFieldValue', 898 | notAllowedField: 'notAllowedFieldValue', 899 | nullField: 'nullFieldValue', 900 | undefinedField: 'undefinedFieldValue' 901 | }, // passed as $data 902 | session); 903 | 904 | // passed as $context 905 | (0, _chai.expect)(model.$props.isAdmin).to.equal(true); 906 | (0, _chai.expect)(model.$props.isGuest).to.equal(false); 907 | (0, _chai.expect)(model.$props.isAuthenticated).to.equal(true); 908 | (0, _chai.expect)(model.$props.isOwner).to.equal(true); 909 | (0, _chai.expect)(model.$props.authId).to.equal('user_id_1'); 910 | (0, _chai.expect)(model.$props.authorId).to.equal('user_id_1'); 911 | 912 | // allowed to read by defaultRule.read rule 913 | (0, _chai.expect)(model.authorId).to.equal('user_id_1'); 914 | 915 | // allowed to read since $props.isAdmin 916 | (0, _chai.expect)(model.adminField).to.equal('adminFieldValue'); 917 | 918 | // allowed to read since $props.isAuthenticated 919 | (0, _chai.expect)(model.authField).to.equal('authFieldValue'); 920 | 921 | // not allowed to read since !$props.isGuest 922 | (0, _chai.expect)(function () { 923 | return model.guestField; 924 | }).to.throw(/guestField/); // throws Error("Cannot access 'guestField'") 925 | 926 | // allowed to read since $props.isOwner 927 | (0, _chai.expect)(model.ownerField).to.equal('ownerFieldValue'); 928 | 929 | // not allowed to read 930 | (0, _chai.expect)(function () { 931 | return model.notAllowedField; 932 | }).to.throw('not allowed'); // throws Error('not allowed') 933 | 934 | // not allowed to read; returns null 935 | (0, _chai.expect)(model.nullField).to.equal(null); 936 | 937 | // rule is undefined 938 | (0, _chai.expect)(model.undefinedField).to.equal(undefined); 939 | }); 940 | }); --------------------------------------------------------------------------------