├── go ├── variants │ ├── go.mod │ ├── testdata │ │ ├── broken_nomods.json │ │ ├── custom.json │ │ ├── testdata_reloaded.json │ │ ├── broken_nooperator.json │ │ └── testdata.json │ ├── types.go │ ├── registry_test.go │ └── registry.go └── README.md ├── .gitignore ├── nodejs ├── tests │ ├── broken_nocondition.json │ ├── testdata_reloaded.json │ ├── custom.json │ ├── broken_nooperator.json │ ├── altdata.json │ ├── testdata.json │ └── variants_test.js ├── lib │ ├── operators.js │ ├── condition.js │ ├── mod.js │ ├── flag.js │ ├── variant.js │ └── variants.js ├── package.json └── README.md ├── README.md └── LICENSE.txt /go/variants/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Medium/variants/go/variants 2 | 3 | go 1.13 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | lib-cov 3 | *.seed 4 | *.log 5 | *.csv 6 | *.dat 7 | *.out 8 | *.pid 9 | *.gz 10 | 11 | pids 12 | logs 13 | results 14 | 15 | node_modules 16 | npm-debug.log 17 | 18 | -------------------------------------------------------------------------------- /go/variants/testdata/broken_nomods.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [{ 3 | "flag": "broken", 4 | "base_value": false 5 | }], 6 | 7 | "variants": [{ 8 | "id": "BrokenNoMods", 9 | "conditions": [{ 10 | "type": "RANDOM", 11 | "value": 0.0 12 | }] 13 | }] 14 | } 15 | -------------------------------------------------------------------------------- /go/variants/testdata/custom.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [{ 3 | "flag": "custom_value", 4 | "base_value": 0 5 | }], 6 | 7 | "variants": [{ 8 | "id": "CustomTest", 9 | "conditions": [{ 10 | "type": "CUSTOM", 11 | "value": "secret" 12 | }], 13 | "mods": [{ 14 | "flag": "custom_value", 15 | "value": 42 16 | }] 17 | }] 18 | } 19 | -------------------------------------------------------------------------------- /go/variants/testdata/testdata_reloaded.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [{ 3 | "flag": "always_passes", 4 | "desc": "Always passes", 5 | "base_value": true 6 | }, { 7 | "flag": "always_fails", 8 | "desc": "Always fails", 9 | "base_value": true 10 | }, { 11 | "flag": "coin_flip", 12 | "base_value": true 13 | }, { 14 | "flag": "mod_range", 15 | "base_value": true 16 | }] 17 | } 18 | -------------------------------------------------------------------------------- /nodejs/tests/broken_nocondition.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [ 3 | { 4 | "flag": "broken" 5 | , "base_value": false 6 | } 7 | ], 8 | 9 | "variants": [ 10 | { 11 | "id": "BrokenNoCondition" 12 | , "condition_operator": "AND" 13 | , "mods": [ 14 | { 15 | "flag": "broken" 16 | , "value": true 17 | } 18 | ] 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /go/variants/testdata/broken_nooperator.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [{ 3 | "flag": "broken", 4 | "base_value": false 5 | }], 6 | 7 | "variants": [{ 8 | "id": "BrokenNoOperator", 9 | "conditions": [{ 10 | "type": "RANDOM", 11 | "value": 0.0 12 | }, { 13 | "type": "RANDOM", 14 | "value": 1.0 15 | }], 16 | "mods": [{ 17 | "flag": "broken", 18 | "value": true 19 | }] 20 | }] 21 | } 22 | -------------------------------------------------------------------------------- /nodejs/lib/operators.js: -------------------------------------------------------------------------------- 1 | // Copyright (c)2012 The Obvious Corporation 2 | 3 | /** 4 | * @fileoverview Defines the Operators enum for conditional evaluation. Variants determine when they are 5 | * active with respect to their list of conditions and the operator to apply to their results. 6 | */ 7 | 8 | 9 | /** 10 | * Condition operators for conditional list evaluation. 11 | * @enum {string} 12 | */ 13 | module.exports = { 14 | AND: "AND" 15 | , OR: "OR" 16 | } 17 | -------------------------------------------------------------------------------- /nodejs/tests/testdata_reloaded.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [ 3 | { 4 | "flag": "always_passes" 5 | , "desc": "Always passes" 6 | , "base_value": true 7 | }, 8 | { 9 | "flag": "always_fails" 10 | , "desc": "Always fails" 11 | , "base_value": true 12 | }, 13 | { 14 | "flag": "coin_flip" 15 | , "base_value": true 16 | }, 17 | { 18 | "flag": "mod_range" 19 | , "base_value": true 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /nodejs/tests/custom.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [ 3 | { 4 | "flag": "custom_value" 5 | , "base_value": 0 6 | } 7 | ], 8 | 9 | "variants": [ 10 | { 11 | "id": "CustomTest" 12 | , "conditions": [ 13 | { 14 | "type": "CUSTOM" 15 | , "value": "secret" 16 | } 17 | ] 18 | , "mods": [ 19 | { 20 | "flag": "custom_value" 21 | , "value": 42 22 | } 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /nodejs/tests/broken_nooperator.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [ 3 | { 4 | "flag": "broken" 5 | , "base_value": false 6 | } 7 | ], 8 | 9 | "variants": [ 10 | { 11 | "id": "BrokenNoOperator" 12 | , "conditions": [ 13 | { 14 | "type": "RANDOM" 15 | , "value": 0.0 16 | }, 17 | { 18 | "type": "RANDOM" 19 | , "value": 1.0 20 | } 21 | ] 22 | , "mods": [ 23 | { 24 | "flag": "broken" 25 | , "value": true 26 | } 27 | ] 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /nodejs/lib/condition.js: -------------------------------------------------------------------------------- 1 | // Copyright (c)2012 The Obvious Corporation 2 | 3 | /** 4 | * @fileoverview Defines the Condition class. Conditions wrap user defined methods that 5 | * evaluate with user-defined context object. A condition must evaluate to true or false and is 6 | * used by variants to determine whether or not the variant is "Active" 7 | */ 8 | 9 | 10 | module.exports = Condition 11 | 12 | 13 | /** 14 | * Condition wrapper. 15 | * @constructor 16 | */ 17 | function Condition (fn) { 18 | this.fn = fn 19 | } 20 | 21 | 22 | /** 23 | * Evalutes this condition in the context. 24 | * @param {Object} context 25 | * @return {boolean} 26 | */ 27 | Condition.prototype.evaluate = function(context) { 28 | return !!this.fn.call(null, context) 29 | } 30 | -------------------------------------------------------------------------------- /nodejs/lib/mod.js: -------------------------------------------------------------------------------- 1 | // Copyright (c)2012 The Obvious Corporation 2 | 3 | /** 4 | * @fileoverview Defines the Mod class. A mod defines how a flag changes. Variants contain mods 5 | * that take affect when they are active. 6 | */ 7 | 8 | 9 | module.exports = Mod 10 | 11 | 12 | /** 13 | * Defines a modification to a variant flag. 14 | * @param {string} flagName 15 | * @param {*} value 16 | * @constructor 17 | */ 18 | function Mod(flagName, value) { 19 | this.flagName = flagName 20 | this.value = value 21 | } 22 | 23 | 24 | /** 25 | * Returns the mod's flag name. 26 | * @return {string} 27 | */ 28 | Mod.prototype.getFlagName = function () { 29 | return this.flagName 30 | } 31 | 32 | 33 | /** 34 | * Returns the mod's override value. 35 | * @return {*} 36 | */ 37 | Mod.prototype.getValue = function () { 38 | return this.value 39 | } 40 | -------------------------------------------------------------------------------- /nodejs/lib/flag.js: -------------------------------------------------------------------------------- 1 | // Copyright (c)2012 The Obvious Corporation 2 | 3 | /** 4 | * @fileoverview Defines the variant Flag class and its base value. A variant flag is a 5 | * global flag that may changes on a contextual basis based on the variants that refer to it. 6 | */ 7 | 8 | 9 | module.exports = Flag 10 | 11 | 12 | /** 13 | * Defines a variant flag. 14 | * @param {string} name 15 | * @param {*} value 16 | * @constructor 17 | */ 18 | function Flag(name, baseValue) { 19 | this.name = name 20 | this.baseValue = baseValue 21 | } 22 | 23 | 24 | /** 25 | * Returns the flag's name 26 | * @return {string} 27 | */ 28 | Flag.prototype.getName = function () { 29 | return this.name 30 | } 31 | 32 | 33 | /** 34 | * Returns the flag's base value 35 | * @return {*} 36 | */ 37 | Flag.prototype.getBaseValue = function () { 38 | return this.baseValue 39 | } 40 | -------------------------------------------------------------------------------- /nodejs/tests/altdata.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [ 3 | { 4 | "flag": "always_passes" 5 | , "desc": "Always passes (but not really)" 6 | , "base_value": false 7 | }, 8 | { 9 | "flag": "always_fails" 10 | , "desc": "Always fails (but not really)" 11 | , "base_value": false 12 | } 13 | ], 14 | 15 | "variants": [ 16 | { 17 | "id": "BackwardsAlwaysFailsTest" 18 | , "conditions": [ 19 | { 20 | "type": "RANDOM" 21 | , "value": 1.0 22 | } 23 | ] 24 | , "mods": [ 25 | { 26 | "flag": "always_fails" 27 | , "value": true 28 | } 29 | ] 30 | } 31 | , { 32 | "id": "BackwardsAlwaysPassesTest" 33 | , "conditions": [ 34 | { 35 | "type": "RANDOM" 36 | , "value": 0.0 37 | } 38 | ] 39 | , "mods": [ 40 | { 41 | "flag": "always_passes" 42 | , "value": true 43 | } 44 | ] 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "variants", 3 | "version" : "0.3.3", 4 | "description" : "Framework for declaring dynamic flags based on pluggable conditions.", 5 | "keywords" : ["node", "variants", "experiments", "mods"], 6 | "repository" : { 7 | "type" : "git", 8 | "url" : "http://github.com/Obvious/variants.git" 9 | }, 10 | "homepage" : "https://github.com/Obvious/variants", 11 | "author" : { 12 | "name" : "David Byttow", 13 | "email" : "david@obvious.com", 14 | "url" : "https://github.com/guitardave24" 15 | }, 16 | "licenses": [{ 17 | "type": "Apache 2.0", 18 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 19 | }], 20 | "author": { 21 | "name": "David Byttow", 22 | "email": "guitardave@gmail.com", 23 | "url": "http://twitter.com/guitardave24" 24 | }, 25 | "main" : "./lib/variants.js", 26 | "directories": { 27 | "lib": "./lib" 28 | }, 29 | "scripts": { 30 | "test": "./node_modules/.bin/nodeunit ./tests/variants_test.js" 31 | }, 32 | "devDependencies": { 33 | "nodeunit": "*" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /go/variants/types.go: -------------------------------------------------------------------------------- 1 | package variants 2 | 3 | // A Flag defines a value that may change on a contextual basis 4 | // based on the Variants that refer to it. 5 | type Flag struct { 6 | Name string `json:"flag"` 7 | Description string `json:"desc,omit_empty"` 8 | BaseValue interface{} `json:"base_value"` 9 | } 10 | 11 | // A Mod defines how a flag changes. Variants contain Mods that 12 | // take effect when the Variant is “active.” 13 | type Mod struct { 14 | FlagName string `json:"flag"` 15 | Value interface{} 16 | } 17 | 18 | // A Condition wraps a user-defined method used to evaluate 19 | // whether the owning Variant is “active.” 20 | type Condition struct { 21 | Type string 22 | Value interface{} 23 | Values []interface{} 24 | Evaluator func(context interface{}) bool 25 | } 26 | 27 | // Evaluate returns whether the condition has been met with 28 | // the given context. 29 | func (c *Condition) Evaluate(context interface{}) bool { 30 | if c.Evaluator == nil { 31 | return false 32 | } 33 | return c.Evaluator(context) 34 | } 35 | 36 | // A Variant contains a list of conditions and a set of mods. 37 | // When all conditions are met, the mods take effect. 38 | // A variant must contain at least one mod to be valid. 39 | type Variant struct { 40 | ID string 41 | Description string `json:"desc"` 42 | Mods []Mod 43 | ConditionalOperator string `json:"condition_operator"` 44 | Conditions []Condition 45 | } 46 | 47 | // FlagValue returns the value of a modified flag for the receiver. 48 | func (v *Variant) FlagValue(name string) interface{} { 49 | for _, m := range v.Mods { 50 | if m.FlagName == name { 51 | return m.Value 52 | } 53 | } 54 | return nil 55 | } 56 | 57 | const ( 58 | conditionalOperatorAnd = "AND" 59 | conditionalOperatorOr = "OR" 60 | ) 61 | 62 | // Evaluate returns the result of evaluating each condition of the 63 | // receiver given a context. 64 | func (v *Variant) Evaluate(context interface{}) bool { 65 | if len(v.Conditions) <= 1 || v.ConditionalOperator == conditionalOperatorAnd { 66 | for _, c := range v.Conditions { 67 | if !c.Evaluate(context) { 68 | return false 69 | } 70 | } 71 | return true 72 | } else if v.ConditionalOperator == conditionalOperatorOr { 73 | for _, c := range v.Conditions { 74 | if c.Evaluate(context) { 75 | return true 76 | } 77 | } 78 | } 79 | return false 80 | } 81 | -------------------------------------------------------------------------------- /go/variants/testdata/testdata.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [{ 3 | "flag": "always_passes", 4 | "desc": "Always passes", 5 | "base_value": false 6 | }, { 7 | "flag": "always_fails", 8 | "desc": "Always fails", 9 | "base_value": false 10 | }, { 11 | "flag": "coin_flip", 12 | "base_value": false 13 | }, { 14 | "flag": "mod_range", 15 | "base_value": false 16 | }, { 17 | "flag": "or_result", 18 | "base_value": false 19 | }, { 20 | "flag": "and_result", 21 | "base_value": false 22 | }, { 23 | "flag": "no_conditions", 24 | "base_value": false 25 | }], 26 | 27 | "variants": [{ 28 | "id": "AlwaysFailsTest", 29 | "conditions": [{ 30 | "type": "RANDOM", 31 | "value": 0.0 32 | }], 33 | "mods": [{ 34 | "flag": "always_fails", 35 | "value": true 36 | }] 37 | }, { 38 | "id": "AlwaysPassesTest", 39 | "conditions": [{ 40 | "type": "RANDOM", 41 | "value": 1.0 42 | }], 43 | "mods": [{ 44 | "flag": "always_passes", 45 | "value": true 46 | }] 47 | }, { 48 | "id": "CoinFlipTest", 49 | "conditions": [{ 50 | "type": "RANDOM", 51 | "value": 0.5 52 | }], 53 | "mods": [{ 54 | "flag": "coin_flip", 55 | "value": true 56 | }] 57 | }, { 58 | "id": "ModRangeTest", 59 | "conditions": [{ 60 | "type": "MOD_RANGE", 61 | "values": ["user_id", 0, 9] 62 | }], 63 | "mods": [{ 64 | "flag": "mod_range", 65 | "value": true 66 | }] 67 | }, { 68 | "id": "OrTest", 69 | "condition_operator": "OR", 70 | "conditions": [{ 71 | "type": "RANDOM", 72 | "value": 0.0 73 | }, { 74 | "type": "RANDOM", 75 | "value": 1.0 76 | }], 77 | "mods": [{ 78 | "flag": "or_result", 79 | "value": true 80 | }] 81 | }, { 82 | "id": "AndTest", 83 | "condition_operator": "AND", 84 | "conditions": [{ 85 | "type": "RANDOM", 86 | "value": 0.0 87 | }, { 88 | "type": "RANDOM", 89 | "value": 1.0 90 | }], 91 | "mods": [{ 92 | "flag": "and_result", 93 | "value": true 94 | }] 95 | }, { 96 | "id": "UnconditionalTest", 97 | "conditions": [], 98 | "mods": [{ 99 | "flag": "no_conditions", 100 | "value": true 101 | }] 102 | }] 103 | } 104 | -------------------------------------------------------------------------------- /nodejs/lib/variant.js: -------------------------------------------------------------------------------- 1 | // Copyright (c)2012 The Obvious Corporation 2 | 3 | /** 4 | * @fileoverview Defines the Variant class. A variant contains a list of conditions and a set of mods. 5 | * when all conditions are met, the mods take effect. 6 | */ 7 | 8 | 9 | var Operators = require('./operators') 10 | 11 | module.exports = Variant 12 | 13 | 14 | /** 15 | * Fully defines a variant. 16 | * @param {string} id 17 | * @param {Operator} 18 | * @param {Array.} conditions 19 | * @param {Array.} mods 20 | * @constructor 21 | */ 22 | function Variant(id, operator, conditions, mods) { 23 | // We should have an operator iff we have 2 or more conditions. 24 | if (!!operator !== (conditions && conditions.length >= 2)) { 25 | throw new Error( 26 | operator ? 27 | 'Cannot have a variant operator without multiple conditions' : 28 | 'Cannot have multiple variant conditions without an operator') 29 | } 30 | 31 | if (operator && operator !== Operators.OR && operator !== Operators.AND) throw new Error('Expected operator to be "AND" or "OR", but got ' + this.operator + '.') 32 | 33 | this.id = id 34 | this.operator = operator 35 | this.conditions = conditions 36 | this.mods = mods 37 | } 38 | 39 | 40 | /** 41 | * Returns the variant id. 42 | * @return {string} 43 | */ 44 | Variant.prototype.getId = function () { 45 | return this.id 46 | } 47 | 48 | 49 | /** 50 | * Evaluates the variant in the given context. 51 | * @param {Object} context Context to evaluate the variant in 52 | * @return {boolean} evaluation result 53 | */ 54 | Variant.prototype.evaluate = function (context) { 55 | if (this.operator == Operators.OR) { 56 | for (var i = 0; i < this.conditions.length; ++i) { 57 | if (this.conditions[i].evaluate(context)) { 58 | return true 59 | } 60 | } 61 | return false 62 | } else if (this.conditions.length <= 1 || this.operator == Operators.AND) { 63 | for (var i = 0; i < this.conditions.length; ++i) { 64 | if (!this.conditions[i].evaluate(context)) { 65 | return false 66 | } 67 | } 68 | return true 69 | } 70 | else { 71 | throw new Error('Operator not understood: ' + this.operator) 72 | } 73 | } 74 | 75 | 76 | /** 77 | * Returns the value of a modified flag for this variant. 78 | * @param {string} flagName name of the flag 79 | * @return {*} override value 80 | */ 81 | Variant.prototype.getFlagValue = function (flagName) { 82 | // TODO(david): Use a map instead of searching. 83 | for (var i = 0; i < this.mods.length; ++i) { 84 | var m = this.mods[i] 85 | if (m.flagName === flagName) { 86 | return m.value 87 | } 88 | } 89 | 90 | throw new Error('Flag not found: ' + flagName) 91 | } 92 | -------------------------------------------------------------------------------- /nodejs/tests/testdata.json: -------------------------------------------------------------------------------- 1 | { 2 | "flag_defs": [ 3 | { 4 | "flag": "always_passes" 5 | , "desc": "Always passes" 6 | , "base_value": false 7 | }, 8 | { 9 | "flag": "always_fails" 10 | , "desc": "Always fails" 11 | , "base_value": false 12 | }, 13 | { 14 | "flag": "coin_flip" 15 | , "base_value": false 16 | }, 17 | { 18 | "flag": "mod_range" 19 | , "base_value": false 20 | }, 21 | { 22 | "flag": "or_result" 23 | , "base_value": false 24 | }, 25 | { 26 | "flag": "and_result" 27 | , "base_value": false 28 | } 29 | ], 30 | 31 | "variants": [ 32 | { 33 | "id": "AlwaysFailsTest" 34 | , "conditions": [ 35 | { 36 | "type": "RANDOM" 37 | , "value": 0.0 38 | } 39 | ] 40 | , "mods": [ 41 | { 42 | "flag": "always_fails" 43 | , "value": true 44 | } 45 | ] 46 | } 47 | , { 48 | "id": "AlwaysPassesTest" 49 | , "conditions": [ 50 | { 51 | "type": "RANDOM" 52 | , "value": 1.0 53 | } 54 | ] 55 | , "mods": [ 56 | { 57 | "flag": "always_passes" 58 | , "value": true 59 | } 60 | ] 61 | } , { 62 | "id": "CoinFlipTest" 63 | , "conditions": [ 64 | { 65 | "type": "RANDOM" 66 | , "value": 0.5 67 | } 68 | ] 69 | , "mods": [ 70 | { 71 | "flag": "coin_flip" 72 | , "value": true 73 | } 74 | ] 75 | } , { 76 | "id": "ModRangeTest" 77 | , "conditions": [ 78 | { 79 | "type": "MOD_RANGE" 80 | , "values": ["user_id", 0, 9] 81 | } 82 | ] 83 | , "mods": [ 84 | { 85 | "flag": "mod_range" 86 | , "value": true 87 | } 88 | ] 89 | } , { 90 | "id": "OrTest" 91 | , "condition_operator": "OR" 92 | , "conditions": [ 93 | { 94 | "type": "RANDOM" 95 | , "value": 0.0 96 | }, 97 | { 98 | "type": "RANDOM" 99 | , "value": 1.0 100 | } 101 | ] 102 | , "mods": [ 103 | { 104 | "flag": "or_result" 105 | , "value": true 106 | } 107 | ] 108 | } , { 109 | "id": "AndTest" 110 | , "condition_operator": "AND" 111 | , "conditions": [ 112 | { 113 | "type": "RANDOM" 114 | , "value": 0.0 115 | }, 116 | { 117 | "type": "RANDOM" 118 | , "value": 1.0 119 | } 120 | ] 121 | , "mods": [ 122 | { 123 | "flag": "and_result" 124 | , "value": true 125 | } 126 | ] 127 | } 128 | ] 129 | } 130 | -------------------------------------------------------------------------------- /go/README.md: -------------------------------------------------------------------------------- 1 | # Variants 2 | 3 | This README details the Go implementation of Variants. For general background, see [the general README](https://github.com/Medium/variants/). 4 | 5 | ## Detailed Design 6 | 7 | Flag and Variant definitions can be defined in a JSON file that can be loaded by a Registry object that manages the sanity and evaluation of each condition. 8 | 9 | Example 10 | ```json 11 | { 12 | "flag_defs": [{ 13 | "flag": "ab_test", 14 | "base_value": false 15 | }], 16 | 17 | "variants": [{ 18 | "id": "FeatureABTest", 19 | "conditions": [{ 20 | "type": "RANDOM", 21 | "value": 0.5 22 | }], 23 | 24 | "mods": [{ 25 | "flag": "ab_test", 26 | "value": true 27 | }] 28 | }] 29 | } 30 | ``` 31 | 32 | In the above example, a flag called "ab_test" is defined, and behavior surrounding how that flag will be evaluated is defined by the variant definition below it. If the condition defined by the variant is met, then the associated mods will be realized (the flag "ab_test" will evaluate to true). The variant is using the built-in RANDOM condition type that will evaluate its result by checking whether a random number between 0.0 and 1.0 is less than or equal to the given value (0.5 in this case). So, in practice, a call to `FlagValue("ab_test")` will return true 50% of the time. 33 | 34 | But say you don't want to use the built-in condition types... 35 | 36 | Another example 37 | ```json 38 | { 39 | "flag_defs": [{ 40 | "flag": "enable_new_hotness_feature", 41 | "base_value": false 42 | }], 43 | 44 | "variants": [{ 45 | "id": "EnableNewHotnessFeature", 46 | "conditions": [{ 47 | "type": "CUSTOM", 48 | "values": [ 49 | "andybons", 50 | "pupius", 51 | "guitardave24" 52 | ] 53 | }], 54 | 55 | "mods": [{ 56 | "flag": "enable_new_hotness_feature", 57 | "value": true 58 | }] 59 | }] 60 | } 61 | ``` 62 | 63 | Now, there is no built-in condition type called CUSTOM, so when the above config is loaded, bad things will happen. We need to define how a CUSTOM condition should be evaluated _before_ the above config is loaded. 64 | 65 | ```go 66 | RegisterConditionType("CUSTOM", func(values ...interface{}) func(interface{}) bool { 67 | usernames := []string{} 68 | for _, v := range values { 69 | usernames = append(usernames, v.(string)) 70 | } 71 | 72 | return func(context interface{}) bool { 73 | c := context.(map[string]string) 74 | for _, u := range usernames { 75 | if c["username"] == u { 76 | return true 77 | } 78 | } 79 | return false 80 | } 81 | }) 82 | ``` 83 | 84 | The above code evaluates the CUSTOM condition by checking to see if the value of the "username" key in the passed in context object is present in the values passed when the variant is constructed. Here are a couple examples of getting the flag value: 85 | 86 | ```go 87 | ctx := map[string]string{"username": "andybons"} 88 | hasAccess := FlagValueWithContext("enable_new_hotness_feature", ctx) // true 89 | 90 | ctx = map[string]string{"username": "tessr"} 91 | hasAccess := FlagValueWithContext("enable_new_hotness_feature", ctx) // false 92 | ``` 93 | 94 | Take a look at the unit tests for a working example. 95 | 96 | # Using Variants 97 | 98 | ## Installation 99 | 100 | Install variants by using the "go get" command: 101 | 102 | ```shell 103 | go get github.com/medium/variants/go/variants 104 | ``` 105 | 106 | ```go 107 | import "github.com/medium/variants/go/variants" 108 | ``` 109 | 110 | ## Testing 111 | 112 | ```shell 113 | go test 114 | ``` 115 | 116 | # Appendix 117 | 118 | ## Contributing 119 | 120 | Questions, comments, bug reports, and pull requests are all welcome. 121 | Submit them at [the project on GitHub](https://github.com/Medium/variants/). 122 | 123 | Bug reports that include steps-to-reproduce (including code) are the 124 | best. Even better, make them in the form of pull requests that update 125 | the test suite. Thanks! 126 | 127 | 128 | ## Author 129 | 130 | [Andrew Bonventre](https://github.com/andybons) 131 | supported by [Poptip](http://poptip.com) and [The Obvious Corporation](http://obvious.com/). 132 | 133 | 134 | ## License 135 | 136 | Copyright 2012 [The Obvious Corporation](http://obvious.com/). 137 | 138 | Licensed under the Apache License, Version 2.0. 139 | See the top-level file `LICENSE.txt` and 140 | (http://www.apache.org/licenses/LICENSE-2.0). 141 | -------------------------------------------------------------------------------- /nodejs/README.md: -------------------------------------------------------------------------------- 1 | # Variants 2 | 3 | This README details the Node.js implementation of Variants. For general background, see [the general README](https://github.com/Obvious/variants/). 4 | 5 | ## Detailed Design 6 | 7 | The frontend server will load all defined variants to modify variant flags for individual requests. 8 | 9 | The variants are provided in a JSON file that is loaded at startup by the server and watched for changes in development. 10 | 11 | Example 12 | ``` 13 | { 14 | "variants": [ 15 | { 16 | "id": "ProductAccess" 17 | , "conditions": [ 18 | { 19 | "type": "USER_ID" 20 | , "values": [ 21 | "somedude74" 22 | , "anotherdude323" 23 | , "hax0r1337" 24 | ] 25 | } 26 | ] 27 | , "mods": [ 28 | { 29 | "enable_access": true 30 | } 31 | ] 32 | } 33 | , { 34 | "id": "ShinyNewFeature" 35 | , "conditions": [ 36 | { 37 | "type": "USER_ID_MOD" 38 | , "values": [ 0, 9 ] 39 | , "cookie_type": "NSID" 40 | } 41 | ] 42 | , "mods": [ 43 | { 44 | "enable_shiny_new_feature": true 45 | } 46 | ] 47 | } 48 | ] 49 | } 50 | ``` 51 | 52 | In the above example, there are two variants: ProductAccess and ShinyNewFeature. For some set of users, we declare that the global variable "enable_access" is set to true. Similarily, for ShinyNewFeature, a different condition is used to modify a different value, and so on. 53 | 54 | # Using Variants 55 | 56 | ## Building and Installing 57 | 58 | ```shell 59 | npm install variants 60 | ``` 61 | 62 | Or grab the source and 63 | 64 | ```shell 65 | npm install 66 | ``` 67 | 68 | ## Testing 69 | 70 | ```shell 71 | npm install nodeunit -g 72 | nodeunit tests/variants_test.js 73 | ``` 74 | 75 | # API Overview 76 | 77 | Below is a list of the "exposed" interfaces. 78 | 79 | Assuming the following: 80 | 81 | ```js 82 | var variants = require('variants') 83 | ``` 84 | 85 | ### variants.getFlagValue({string} flagName, {Object=} opt_context, {Object.=} opt_forced) => {*} 86 | Evaluates the flag value based on the given context object. 87 | * flagName: Name of the variant flag to get the value for 88 | * opt_context: Optional context object that contains fields relevant to evaluating conditions 89 | * opt_forced: Optional map of variant ids that are forced to either true or false. 90 | * Returns: Value specified in the variants JSON file or undefined if no conditions were met 91 | 92 | ### variants.loadFile({string} filepath, {function(Error, Object)} callback) 93 | Asynchronously oads a JSON file that can declare variants and variant flags. Uses node `fs` API. Accepts a callback path that returns either an error object or a callback signaling completion. 94 | * filepath: JSON file to load 95 | * callback: Optional callback to handle errors 96 | 97 | ### variants.loadJson({Object} obj, {function(Error, Object)} callback) 98 | Asynchronously oads a given raw JSON object that contains variants and variant flags. Accepts a callback path that returns either an error object or a callback signaling completion. 99 | * obj: JSON object to parse 100 | * callback: Optional callback to handle errors 101 | 102 | ### variants.registerConditionType({string} id, {function(*, Array.<*>)}) 103 | Registers a new condition type and handler associated with it. 104 | * id: Case-insensitive identifier for the condition 105 | * fn: Conditional function generator which takes the the parsed value and list of values respectively and must return a function that optionally takes a context Object and returns true or false. 106 | 107 | Any context passed into the method that evaluates a flag will be passed into the condition handler, allowing you to define custom conditions based on context within your application. 108 | 109 | E.g. 110 | ```js 111 | registerConditionType('RANDOM', function (value) { 112 | if (value < 0 || value > 1) { 113 | throw new Error('Fractional value from 0-1 required') 114 | } 115 | 116 | return function() { 117 | if (!value) { 118 | return false 119 | } 120 | return Math.random() <= value 121 | } 122 | }) 123 | ``` 124 | 125 | ### variants.registerFlag({string} flag) 126 | Programmatic way to register a global flag value. This must come before parsing any files that use the flag. 127 | * flag: the unique flag value 128 | 129 | ### variants.getAllVariants => {!Array.} 130 | Returns a list of all registered variants. 131 | 132 | ### variants.getAllFlags => {Array.} 133 | Returns a list of all registered flags. 134 | 135 | ## Variant 136 | 137 | ### Variant.getFlagValue({string} flagName) => {*} 138 | Returns the value for the specific flag name. 139 | 140 | # Appendix 141 | 142 | ## Contributing 143 | 144 | Questions, comments, bug reports, and pull requests are all welcome. 145 | Submit them at [the project on GitHub](https://github.com/Obvious/variants/nodejs/). 146 | 147 | Bug reports that include steps-to-reproduce (including code) are the 148 | best. Even better, make them in the form of pull requests that update 149 | the test suite. Thanks! 150 | 151 | 152 | ## Author 153 | 154 | [David Byttow](https://github.com/guitardave24) 155 | supported by [The Obvious Corporation](http://obvious.com/). 156 | 157 | 158 | ## License 159 | 160 | Copyright 2012 [The Obvious Corporation](http://obvious.com/). 161 | 162 | Licensed under the Apache License, Version 2.0. 163 | See the top-level file `LICENSE.txt` and 164 | (http://www.apache.org/licenses/LICENSE-2.0). 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Variants 2 | 3 | # Background 4 | In web applications it is common to provide varying experiences to unique sets of users. A flexible design should allow implementations of common patterns in web development like: 5 | 6 | * A/B testing 7 | * Experimental features 8 | * Trusted tester groups 9 | * Gradual feature rollouts 10 | 11 | # Overview 12 | 13 | Variants provide an expressive way to define and conditionally modify experimental features, which can also be forcefully adjusted (for development). 14 | 15 | Note that the following README only provides a general overview of variants, and is language independent. Currently, there are ports written for Node.js and Go. See the implementation-specific READMEs for more information. 16 | 17 | To conditionally gate certain features, they must be protected by variant flags. Variant flags are globally unique strings that can point to a language primitive, array or object. Most commonly, variant flags are simple boolean values so that the below code is possible: 18 | 19 | ```js 20 | if (variants.getFlagValue('enable_product_access')) { 21 | throw Error('Authenticated failed.') 22 | } 23 | ``` 24 | 25 | # Design 26 | 27 | * Each service contains variants 28 | * Variants contain 0 or more conditions and 1 or more mods 29 | * Conditions evaluate the current request based on the condition type and values 30 | * Mods modify variant flags 31 | * Variant flags are checked in code to gate control flow 32 | 33 | ## Variant 34 | 35 | Variants are globally defined objects that may optionally modify values based on some conditions. All variants are evaluated on a per request basis, which means that they are scoped to request-based values such as: user ip, specific users, groups of users, query parameters, etc. 36 | 37 | Variants must have an id and a list of conditions and mods. A variant must contain at least one mod to be valid. 38 | 39 | ``` 40 | variant: { 41 | required string id 42 | optional string conditional_operator 43 | optional condition[] conditions 44 | required mod[] mods 45 | } 46 | ``` 47 | 48 | ## Condition 49 | 50 | Conditions return true or false based on the current request object. If more than one condition is supplied, then the conditional_operator (either "OR" or "AND") must be supplied. 51 | 52 | Below is a list of condition types: 53 | 54 | ### USER_ID 55 | 56 | User id is a condition that evaluates the given condition based on a list of usernames in the "values" field. 57 | 58 | E.g. 59 | ```json 60 | { 61 | "type": "USER_ID", 62 | "values": [ 63 | "somedude74", 64 | "anotherdude323", 65 | "hax0r1337" 66 | ] 67 | } 68 | ``` 69 | 70 | ### USER_ID_MOD 71 | 72 | User id mods use a hashed value of the current user’s username mapped onto a range from 0-99. It allows the properties "range_start" and "range_end", which contain values between 0-99 and range_end must be greater than range_start. 73 | 74 | By default, this uses the unique user id of an authenticated user. However, the "cookie_type" field can be set to "NSID" to refer to unauthenticated users. 75 | 76 | E.g. 77 | ```json 78 | { 79 | "type": "USER_ID_MOD", 80 | "values": [ 0, 9 ] 81 | } 82 | ``` 83 | 84 | Note: This is useful for rolling out new features, such as to 1% -> 10% -> 50% -> 100% of users. 85 | 86 | ### RANDOM 87 | 88 | Random will randomly determine whether or not a given request is eligible for the variant. 89 | 90 | E.g. 91 | ```json 92 | { 93 | "type": "RANDOM", 94 | "value": 0.25 95 | } 96 | ``` 97 | 98 | ### Mod 99 | 100 | Mods are triggered when the conditions are met on the given variant. The format of a mod is simply a key and a value. The key must refer to a global identifier for the variant flag. 101 | 102 | Full spec 103 | 104 | Spec in pseudo-protobuf format: 105 | 106 | ``` 107 | message Variants { 108 | repeated Variant variants; 109 | } 110 | 111 | message Variant { 112 | 113 | enum Operator { 114 | AND, // "AND" 115 | OR // "OR" 116 | } 117 | 118 | // Unique identifier. 119 | required string id; 120 | 121 | // Readable description of the feature. 122 | optional string description; 123 | 124 | // Optional operator to evaluate the conditions. 125 | optional Operator conditional_operator; 126 | 127 | // List of conditions to evaluate. 128 | repeated Condition conditions; 129 | 130 | // List of mods to be triggered. 131 | repeated Mod mods; 132 | } 133 | 134 | message Condition { 135 | 136 | enum Type { 137 | RANDOM, 138 | USER_ID, 139 | USER_ID_MOD, 140 | USER_IP 141 | }; 142 | 143 | // Type of condition. 144 | required Type type; 145 | 146 | // Single value. 147 | optional * value; 148 | 149 | // List of values. 150 | repeated * values; 151 | } 152 | 153 | message Mod { 154 | // Name of the variant flag to modify. 155 | required string flag; 156 | 157 | // Value to set. 158 | required * value; 159 | } 160 | ``` 161 | 162 | # Appendix 163 | 164 | ## Contributing 165 | 166 | Questions, comments, bug reports, and pull requests are all welcome. 167 | Submit them at [the project on GitHub](https://github.com/Obvious/variants/). 168 | 169 | Bug reports that include steps-to-reproduce (including code) are the 170 | best. Even better, make them in the form of pull requests that update 171 | the test suite. Thanks! 172 | 173 | 174 | ## Author 175 | 176 | [David Byttow](https://github.com/guitardave24) 177 | supported by [The Obvious Corporation](http://obvious.com/). 178 | 179 | 180 | ## License 181 | 182 | Copyright 2012 [The Obvious Corporation](http://obvious.com/). 183 | 184 | Licensed under the Apache License, Version 2.0. 185 | See the top-level file `LICENSE.txt` and 186 | (http://www.apache.org/licenses/LICENSE-2.0). 187 | -------------------------------------------------------------------------------- /nodejs/tests/variants_test.js: -------------------------------------------------------------------------------- 1 | // Copyright 2012 The Obvious Corporation. 2 | 3 | /** 4 | * @fileoverview Unit tests for variants lib. Run with `nodeunit variants_test.js` 5 | */ 6 | 7 | 8 | var nodeunit = require('nodeunit') 9 | , fs = require('fs') 10 | , testCase = nodeunit.testCase 11 | , variants = require('../lib/variants.js') 12 | 13 | 14 | module.exports = testCase({ 15 | 16 | setUp: function (done) { 17 | variants.clearAll() 18 | done() 19 | }, 20 | 21 | testErrorConditions: function (test) { 22 | var json = { 23 | variants: [{ 24 | id: 'Fail', 25 | condition_operator: 'AND', 26 | conditions: [{ 27 | type: 'RANDOM' 28 | , value: 'foo' 29 | , values: ['foo'] 30 | }], 31 | mods: [{ 32 | flag: 'foo' 33 | , value: 'bar' 34 | }] 35 | }] 36 | } 37 | var parseError 38 | variants.loadJson(json, function (err) { 39 | parseError = !!err 40 | test.ok(parseError) 41 | test.done() 42 | }) 43 | }, 44 | 45 | testRandom: function (test) { 46 | variants.loadFile('tests/testdata.json', function (err) { 47 | test.ok(!err) 48 | test.ok(variants.getFlagValue('always_passes')) 49 | test.equals(variants.getFlagValue('always_fails'), false) 50 | test.done() 51 | }) 52 | }, 53 | 54 | testModRange: function (test) { 55 | variants.loadFile('tests/testdata.json', function (err) { 56 | test.ok(!err) 57 | test.ok(variants.getFlagValue('mod_range', { user_id: 0 })) 58 | test.ok(variants.getFlagValue('mod_range', { user_id: 3 })) 59 | test.ok(variants.getFlagValue('mod_range', { user_id: 9 })) 60 | test.equal(variants.getFlagValue('mod_range', { user_id: 50 }), false) 61 | test.done() 62 | }) 63 | }, 64 | 65 | testOperators: function (test) { 66 | variants.loadFile('tests/testdata.json', function (err) { 67 | test.ok(!err) 68 | test.equals(variants.getFlagValue('or_result'), true) 69 | test.equals(variants.getFlagValue('and_result'), false) 70 | test.done() 71 | }) 72 | }, 73 | 74 | testNoOperator: function (test) { 75 | var thrown = false 76 | variants.loadFile('tests/broken_nooperator.json', function (err) { 77 | // An error is expected here. 78 | test.equal('Cannot have multiple variant conditions without an operator', err.message) 79 | test.done() 80 | }) 81 | }, 82 | 83 | testNoCondition: function (test) { 84 | var thrown = false 85 | variants.loadFile('tests/broken_nocondition.json', function (err) { 86 | // An error is expected here. 87 | test.equal('Cannot have a variant operator without multiple conditions', err.message) 88 | test.done() 89 | }) 90 | }, 91 | 92 | testCustomCondition: function (test) { 93 | variants.registerConditionType('CUSTOM', function(value) { 94 | return function(context) { 95 | return context['password'] === value 96 | } 97 | }) 98 | variants.loadFile('tests/custom.json', function (err) { 99 | test.ok(!err) 100 | test.equal(variants.getFlagValue('custom_value', {}), 0) 101 | test.equal(variants.getFlagValue('custom_value', { password: 'wrong' }), 0) 102 | test.equal(variants.getFlagValue('custom_value', { password: 'secret'}), 42) 103 | test.done() 104 | }) 105 | }, 106 | 107 | testGetFlags: function (test) { 108 | variants.loadFile('tests/testdata.json', function (err) { 109 | test.ok(!err) 110 | var flags = variants.getAllFlags() 111 | test.ok(contains(flags, 'always_passes')) 112 | test.ok(contains(flags, 'always_fails')) 113 | test.ok(contains(flags, 'coin_flip')) 114 | test.ok(contains(flags, 'mod_range')) 115 | test.done() 116 | }) 117 | }, 118 | 119 | testGetVariants: function (test) { 120 | variants.loadFile('tests/testdata.json', function (err) { 121 | test.ok(!err) 122 | var list = variants.getAllVariants() 123 | var variant; 124 | for (var i = 0; i < list.length; ++i) { 125 | if (list[i].getId() == 'CoinFlipTest') { 126 | variant = list[i] 127 | break 128 | } 129 | } 130 | test.ok(!!variant) 131 | test.done() 132 | }) 133 | }, 134 | 135 | testForcing: function (test) { 136 | variants.loadFile('tests/testdata.json', function (err) { 137 | test.ok(!err) 138 | test.ok(variants.getFlagValue('always_passes')) 139 | test.equals(variants.getFlagValue('always_fails'), false) 140 | var forced = { 141 | AlwaysPassesTest: false 142 | , AlwaysFailsTest: true 143 | } 144 | test.ok(variants.getFlagValue('always_fails', undefined, forced)) 145 | test.equals(variants.getFlagValue('always_passes', undefined, forced), false) 146 | test.done() 147 | }) 148 | }, 149 | 150 | testReloadVariants: function (test) { 151 | variants.loadFile('tests/testdata.json', function (err) { 152 | test.ok(!err) 153 | test.ok(variants.getFlagValue('always_passes')) 154 | test.ok(!variants.getFlagValue('always_fails')) 155 | 156 | // Reload the new test data with changed values. 157 | variants.reloadFile('tests/testdata_reloaded.json', function (err) { 158 | test.ok(!err) 159 | test.ok(variants.getFlagValue('always_passes')) 160 | test.ok(variants.getFlagValue('always_fails')) 161 | test.ok(variants.getFlagValue('coin_flip')) 162 | test.ok(variants.getFlagValue('mod_range')) 163 | test.done() 164 | }) 165 | }) 166 | }, 167 | 168 | testSwitchRegistries: function (test) { 169 | variants.loadFile('tests/testdata.json', function (err) { 170 | test.ok(!err) 171 | variants.setCurrentRegistry('alt') 172 | variants.loadFile('tests/altdata.json', function (err) { 173 | test.ok(!err) 174 | 175 | // In the alt data file, pass and fail are flipped. 176 | test.equals(variants.getFlagValue('always_passes'), false) 177 | test.equals(variants.getFlagValue('always_fails'), true) 178 | 179 | // Switch back to the main file 180 | variants.setCurrentRegistry('main') 181 | test.equals(variants.getFlagValue('always_passes'), true) 182 | test.equals(variants.getFlagValue('always_fails'), false) 183 | 184 | test.done() 185 | }) 186 | }) 187 | } 188 | }) 189 | 190 | 191 | function contains(list, value) { 192 | for (var i = 0; i < list.length; ++i) { 193 | if (list[i] === value) { 194 | return true 195 | } 196 | } 197 | return false 198 | } 199 | -------------------------------------------------------------------------------- /go/variants/registry_test.go: -------------------------------------------------------------------------------- 1 | package variants 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | ) 7 | 8 | func resetAndLoadFile(filename string, t *testing.T) { 9 | Reset() 10 | if err := LoadConfig(filename); err != nil { 11 | t.Fatalf("LoadConfig: Expected no error, but got %q", err.Error()) 12 | } 13 | } 14 | 15 | func TestDuplicateFlags(t *testing.T) { 16 | Reset() 17 | if err := AddFlag(Flag{Name: "GOOB"}); err != nil { 18 | t.Errorf("AddFlag: Expected no error but got %q", err.Error()) 19 | } 20 | if err := AddFlag(Flag{Name: "GOOB"}); err == nil { 21 | t.Error("AddFlag: Expected duplicate flag error, but got nil.") 22 | } 23 | } 24 | 25 | func TestDuplicateVariant(t *testing.T) { 26 | Reset() 27 | if err := AddVariant(Variant{ID: "GOOB"}); err != nil { 28 | t.Errorf("AddVariant: Expected no error but got %q", err.Error()) 29 | } 30 | if err := AddVariant(Variant{ID: "GOOB"}); err == nil { 31 | t.Error("AddVariant: Expected duplicate variant error, but got nil.") 32 | } 33 | } 34 | 35 | func TestRandom(t *testing.T) { 36 | resetAndLoadFile("testdata/testdata.json", t) 37 | testCases := map[string]bool{ 38 | "always_passes": true, 39 | "always_fails": false, 40 | } 41 | for flagName, expected := range testCases { 42 | v := FlagValue(flagName) 43 | if v != expected { 44 | t.Errorf("FlagValue: expected %q to return %t, got %t.", flagName, expected, v) 45 | } 46 | } 47 | } 48 | 49 | func TestConditionals(t *testing.T) { 50 | resetAndLoadFile("testdata/testdata.json", t) 51 | testCases := map[string]bool{ 52 | "or_result": true, 53 | "and_result": false, 54 | } 55 | for flagName, expected := range testCases { 56 | v := FlagValue(flagName) 57 | if v != expected { 58 | t.Errorf("FlagValue: expected %q to return %t, got %t.", flagName, expected, v) 59 | } 60 | } 61 | } 62 | 63 | func TestNoConditionsShouldWork(t *testing.T) { 64 | resetAndLoadFile("testdata/testdata.json", t) 65 | if FlagValue("no_conditions") == false { 66 | t.Error("FlagValue: expected mod without condition to be applied but it was not") 67 | } 68 | } 69 | 70 | func TestModRange(t *testing.T) { 71 | resetAndLoadFile("testdata/testdata.json", t) 72 | testCases := map[int]bool{ 73 | 0: true, 74 | 3: true, 75 | 9: true, 76 | 50: false, 77 | } 78 | for userID, expected := range testCases { 79 | v := FlagValueWithContext("mod_range", map[string]int{"user_id": userID}) 80 | if v != expected { 81 | t.Errorf("FlagValueWithContext: expected mod_range to return %t, got %t.", expected, v) 82 | } 83 | } 84 | } 85 | 86 | func TestLoadJSON(t *testing.T) { 87 | Reset() 88 | RegisterConditionType("CUSTOM", func(values ...interface{}) func(interface{}) bool { 89 | usernames := []string{} 90 | for _, v := range values { 91 | usernames = append(usernames, v.(string)) 92 | } 93 | 94 | return func(context interface{}) bool { 95 | c := context.(map[string]string) 96 | for _, u := range usernames { 97 | if c["username"] == u { 98 | return true 99 | } 100 | } 101 | return false 102 | } 103 | }) 104 | json := `{ 105 | "flag_defs": [{ 106 | "flag": "enable_new_hotness_feature", 107 | "base_value": false 108 | }], 109 | 110 | "variants": [{ 111 | "id": "EnableNewHotnessFeature", 112 | "conditions": [{ 113 | "type": "CUSTOM", 114 | "values": [ 115 | "andybons", 116 | "pupius", 117 | "guitardave24" 118 | ] 119 | }], 120 | 121 | "mods": [{ 122 | "flag": "enable_new_hotness_feature", 123 | "value": true 124 | }] 125 | }] 126 | }` 127 | if err := LoadJSON([]byte(json)); err != nil { 128 | t.Errorf("LoadJSON: expected no error, but got %q.", err.Error()) 129 | } 130 | testCases := map[string]bool{ 131 | "andybons": true, 132 | "sjkaliski": false, 133 | } 134 | for uname, expected := range testCases { 135 | ctx := map[string]string{"username": uname} 136 | actual := FlagValueWithContext("enable_new_hotness_feature", ctx) 137 | if expected != actual { 138 | t.Errorf("FlagValueWithContext: Expected enable_new_hotness_feature to be %t, got %t.", expected, actual) 139 | } 140 | } 141 | } 142 | 143 | func TestCustomCondition(t *testing.T) { 144 | Reset() 145 | RegisterConditionType("CUSTOM", func(values ...interface{}) func(interface{}) bool { 146 | value := values[0].(string) 147 | 148 | return func(context interface{}) bool { 149 | c := context.(map[string]string) 150 | return c["password"] == value 151 | } 152 | }) 153 | if err := LoadConfig("testdata/custom.json"); err != nil { 154 | t.Fatalf("LoadConfig: Expected no error, but got %q", err.Error()) 155 | } 156 | 157 | type testCase struct { 158 | Context map[string]string 159 | Expected float64 160 | } 161 | testCases := []testCase{ 162 | testCase{ 163 | Context: map[string]string{"password": "wrong"}, 164 | Expected: 0, 165 | }, 166 | testCase{ 167 | Context: map[string]string{"password": "secret"}, 168 | Expected: 42, 169 | }, 170 | testCase{ 171 | Context: map[string]string{}, 172 | Expected: 0, 173 | }, 174 | } 175 | for _, tc := range testCases { 176 | v := FlagValueWithContext("custom_value", tc.Context) 177 | if v != tc.Expected { 178 | t.Errorf("FlagValueWithContext: expected custom_value to return %f, got %f.", tc.Expected, v) 179 | } 180 | } 181 | } 182 | 183 | func TestForceVariant(t *testing.T) { 184 | Reset() 185 | RegisterConditionType("CUSTOM", func(values ...interface{}) func(interface{}) bool { 186 | value := values[0].(string) 187 | 188 | return func(context interface{}) bool { 189 | c := context.(map[string]string) 190 | return c["password"] == value 191 | } 192 | }) 193 | if err := LoadConfig("testdata/custom.json"); err != nil { 194 | t.Fatalf("LoadConfig: Expected no error, but got %q", err.Error()) 195 | } 196 | 197 | type testCase struct { 198 | Context map[string]string 199 | Expected float64 200 | ForcedVariants map[string]bool 201 | } 202 | testCases := []testCase{ 203 | testCase{ 204 | Context: map[string]string{"password": "wrong"}, 205 | Expected: 42, 206 | ForcedVariants: map[string]bool{"CustomTest": true}, 207 | }, 208 | testCase{ 209 | Context: map[string]string{"password": "secret"}, 210 | Expected: 0, 211 | ForcedVariants: map[string]bool{"CustomTest": false}, 212 | }, 213 | } 214 | for _, tc := range testCases { 215 | v := FlagValueWithContextWithForcedVariants("custom_value", tc.Context, tc.ForcedVariants) 216 | if v != tc.Expected { 217 | t.Errorf("FlagValueWithContext: expected custom_value to return %f, got %f.", tc.Expected, v) 218 | } 219 | } 220 | } 221 | 222 | func TestGetFlags(t *testing.T) { 223 | resetAndLoadFile("testdata/testdata.json", t) 224 | 225 | testCases := []string{ 226 | "always_passes", 227 | "always_fails", 228 | "coin_flip", 229 | "mod_range", 230 | } 231 | names := []string{} 232 | for _, f := range Flags() { 233 | names = append(names, f.Name) 234 | } 235 | for _, n := range testCases { 236 | if !contains(names, n) { 237 | t.Errorf("Flags: expected %q to be present.", n) 238 | } 239 | } 240 | } 241 | 242 | func TestGetVariants(t *testing.T) { 243 | resetAndLoadFile("testdata/testdata.json", t) 244 | testCases := []string{ 245 | "AlwaysFailsTest", 246 | "AlwaysPassesTest", 247 | "CoinFlipTest", 248 | "ModRangeTest", 249 | "OrTest", 250 | "AndTest", 251 | } 252 | ids := []string{} 253 | for _, v := range Variants() { 254 | ids = append(ids, v.ID) 255 | } 256 | for _, id := range testCases { 257 | if !contains(ids, id) { 258 | t.Errorf("Variants: Expected Variant with ID %q to be present.", id) 259 | } 260 | } 261 | } 262 | 263 | func contains(arr []string, s string) bool { 264 | for _, v := range arr { 265 | if v == s { 266 | return true 267 | } 268 | } 269 | return false 270 | } 271 | 272 | func TestReloadConfig(t *testing.T) { 273 | resetAndLoadFile("testdata/testdata.json", t) 274 | testCases := map[string]bool{ 275 | "always_passes": true, 276 | "always_fails": false, 277 | } 278 | for flagName, expected := range testCases { 279 | v := FlagValue(flagName) 280 | if v != expected { 281 | t.Errorf("FlagValue: expected %q to return %t, got %t.", flagName, expected, v) 282 | } 283 | } 284 | 285 | if err := ReloadConfig("testdata/testdata_reloaded.json"); err != nil { 286 | t.Errorf("ReloadConfig: expected no error but got %q.", err.Error()) 287 | } 288 | testCases = map[string]bool{ 289 | "always_passes": true, 290 | "always_fails": true, 291 | "coin_flip": true, 292 | "mod_range": true, 293 | } 294 | for flagName, expected := range testCases { 295 | v := FlagValue(flagName) 296 | if v != expected { 297 | t.Errorf("FlagValue: expected %q to return %t, got %t.", flagName, expected, v) 298 | } 299 | } 300 | } 301 | 302 | func TestNoMods(t *testing.T) { 303 | Reset() 304 | if err := LoadConfig("testdata/broken_nomods.json"); err == nil { 305 | t.Error("LoadConfig: Expected error for not having at least one mod in the variant.") 306 | } 307 | } 308 | 309 | func TestNoOperator(t *testing.T) { 310 | Reset() 311 | if err := LoadConfig("testdata/broken_nooperator.json"); err == nil { 312 | t.Error("LoadConfig: Expected error for not specifying an operator with more than one condition.") 313 | } 314 | } 315 | 316 | func TestRegistryDataRace(t *testing.T) { 317 | var wg sync.WaitGroup 318 | wg.Add(2) 319 | go func() { 320 | ReloadConfig("testdata/testdata.json") 321 | Reset() 322 | wg.Done() 323 | }() 324 | go func() { 325 | ReloadConfig("testdata/testdata_reloaded.json") 326 | Reset() 327 | wg.Done() 328 | }() 329 | wg.Wait() 330 | } 331 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2012 The Obvious Corporation. 2 | http://obvious.com/ 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | 16 | 17 | ------------------------------------------------------------------------- 18 | Apache License 19 | Version 2.0, January 2004 20 | http://www.apache.org/licenses/ 21 | 22 | 23 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 24 | 25 | 1. Definitions. 26 | 27 | "License" shall mean the terms and conditions for use, reproduction, 28 | and distribution as defined by Sections 1 through 9 of this document. 29 | 30 | "Licensor" shall mean the copyright owner or entity authorized by 31 | the copyright owner that is granting the License. 32 | 33 | "Legal Entity" shall mean the union of the acting entity and all 34 | other entities that control, are controlled by, or are under common 35 | control with that entity. For the purposes of this definition, 36 | "control" means (i) the power, direct or indirect, to cause the 37 | direction or management of such entity, whether by contract or 38 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 39 | outstanding shares, or (iii) beneficial ownership of such entity. 40 | 41 | "You" (or "Your") shall mean an individual or Legal Entity 42 | exercising permissions granted by this License. 43 | 44 | "Source" form shall mean the preferred form for making modifications, 45 | including but not limited to software source code, documentation 46 | source, and configuration files. 47 | 48 | "Object" form shall mean any form resulting from mechanical 49 | transformation or translation of a Source form, including but 50 | not limited to compiled object code, generated documentation, 51 | and conversions to other media types. 52 | 53 | "Work" shall mean the work of authorship, whether in Source or 54 | Object form, made available under the License, as indicated by a 55 | copyright notice that is included in or attached to the work 56 | (an example is provided in the Appendix below). 57 | 58 | "Derivative Works" shall mean any work, whether in Source or Object 59 | form, that is based on (or derived from) the Work and for which the 60 | editorial revisions, annotations, elaborations, or other modifications 61 | represent, as a whole, an original work of authorship. For the purposes 62 | of this License, Derivative Works shall not include works that remain 63 | separable from, or merely link (or bind by name) to the interfaces of, 64 | the Work and Derivative Works thereof. 65 | 66 | "Contribution" shall mean any work of authorship, including 67 | the original version of the Work and any modifications or additions 68 | to that Work or Derivative Works thereof, that is intentionally 69 | submitted to Licensor for inclusion in the Work by the copyright owner 70 | or by an individual or Legal Entity authorized to submit on behalf of 71 | the copyright owner. For the purposes of this definition, "submitted" 72 | means any form of electronic, verbal, or written communication sent 73 | to the Licensor or its representatives, including but not limited to 74 | communication on electronic mailing lists, source code control systems, 75 | and issue tracking systems that are managed by, or on behalf of, the 76 | Licensor for the purpose of discussing and improving the Work, but 77 | excluding communication that is conspicuously marked or otherwise 78 | designated in writing by the copyright owner as "Not a Contribution." 79 | 80 | "Contributor" shall mean Licensor and any individual or Legal Entity 81 | on behalf of whom a Contribution has been received by Licensor and 82 | subsequently incorporated within the Work. 83 | 84 | 2. Grant of Copyright License. Subject to the terms and conditions of 85 | this License, each Contributor hereby grants to You a perpetual, 86 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 87 | copyright license to reproduce, prepare Derivative Works of, 88 | publicly display, publicly perform, sublicense, and distribute the 89 | Work and such Derivative Works in Source or Object form. 90 | 91 | 3. Grant of Patent License. Subject to the terms and conditions of 92 | this License, each Contributor hereby grants to You a perpetual, 93 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 94 | (except as stated in this section) patent license to make, have made, 95 | use, offer to sell, sell, import, and otherwise transfer the Work, 96 | where such license applies only to those patent claims licensable 97 | by such Contributor that are necessarily infringed by their 98 | Contribution(s) alone or by combination of their Contribution(s) 99 | with the Work to which such Contribution(s) was submitted. If You 100 | institute patent litigation against any entity (including a 101 | cross-claim or counterclaim in a lawsuit) alleging that the Work 102 | or a Contribution incorporated within the Work constitutes direct 103 | or contributory patent infringement, then any patent licenses 104 | granted to You under this License for that Work shall terminate 105 | as of the date such litigation is filed. 106 | 107 | 4. Redistribution. You may reproduce and distribute copies of the 108 | Work or Derivative Works thereof in any medium, with or without 109 | modifications, and in Source or Object form, provided that You 110 | meet the following conditions: 111 | 112 | (a) You must give any other recipients of the Work or 113 | Derivative Works a copy of this License; and 114 | 115 | (b) You must cause any modified files to carry prominent notices 116 | stating that You changed the files; and 117 | 118 | (c) You must retain, in the Source form of any Derivative Works 119 | that You distribute, all copyright, patent, trademark, and 120 | attribution notices from the Source form of the Work, 121 | excluding those notices that do not pertain to any part of 122 | the Derivative Works; and 123 | 124 | (d) If the Work includes a "NOTICE" text file as part of its 125 | distribution, then any Derivative Works that You distribute must 126 | include a readable copy of the attribution notices contained 127 | within such NOTICE file, excluding those notices that do not 128 | pertain to any part of the Derivative Works, in at least one 129 | of the following places: within a NOTICE text file distributed 130 | as part of the Derivative Works; within the Source form or 131 | documentation, if provided along with the Derivative Works; or, 132 | within a display generated by the Derivative Works, if and 133 | wherever such third-party notices normally appear. The contents 134 | of the NOTICE file are for informational purposes only and 135 | do not modify the License. You may add Your own attribution 136 | notices within Derivative Works that You distribute, alongside 137 | or as an addendum to the NOTICE text from the Work, provided 138 | that such additional attribution notices cannot be construed 139 | as modifying the License. 140 | 141 | You may add Your own copyright statement to Your modifications and 142 | may provide additional or different license terms and conditions 143 | for use, reproduction, or distribution of Your modifications, or 144 | for any such Derivative Works as a whole, provided Your use, 145 | reproduction, and distribution of the Work otherwise complies with 146 | the conditions stated in this License. 147 | 148 | 5. Submission of Contributions. Unless You explicitly state otherwise, 149 | any Contribution intentionally submitted for inclusion in the Work 150 | by You to the Licensor shall be under the terms and conditions of 151 | this License, without any additional terms or conditions. 152 | Notwithstanding the above, nothing herein shall supersede or modify 153 | the terms of any separate license agreement you may have executed 154 | with Licensor regarding such Contributions. 155 | 156 | 6. Trademarks. This License does not grant permission to use the trade 157 | names, trademarks, service marks, or product names of the Licensor, 158 | except as required for reasonable and customary use in describing the 159 | origin of the Work and reproducing the content of the NOTICE file. 160 | 161 | 7. Disclaimer of Warranty. Unless required by applicable law or 162 | agreed to in writing, Licensor provides the Work (and each 163 | Contributor provides its Contributions) on an "AS IS" BASIS, 164 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 165 | implied, including, without limitation, any warranties or conditions 166 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 167 | PARTICULAR PURPOSE. You are solely responsible for determining the 168 | appropriateness of using or redistributing the Work and assume any 169 | risks associated with Your exercise of permissions under this License. 170 | 171 | 8. Limitation of Liability. In no event and under no legal theory, 172 | whether in tort (including negligence), contract, or otherwise, 173 | unless required by applicable law (such as deliberate and grossly 174 | negligent acts) or agreed to in writing, shall any Contributor be 175 | liable to You for damages, including any direct, indirect, special, 176 | incidental, or consequential damages of any character arising as a 177 | result of this License or out of the use or inability to use the 178 | Work (including but not limited to damages for loss of goodwill, 179 | work stoppage, computer failure or malfunction, or any and all 180 | other commercial damages or losses), even if such Contributor 181 | has been advised of the possibility of such damages. 182 | 183 | 9. Accepting Warranty or Additional Liability. While redistributing 184 | the Work or Derivative Works thereof, You may choose to offer, 185 | and charge a fee for, acceptance of support, warranty, indemnity, 186 | or other liability obligations and/or rights consistent with this 187 | License. However, in accepting such obligations, You may act only 188 | on Your own behalf and on Your sole responsibility, not on behalf 189 | of any other Contributor, and only if You agree to indemnify, 190 | defend, and hold each Contributor harmless for any liability 191 | incurred by, or claims asserted against, such Contributor by reason 192 | of your accepting any such warranty or additional liability. 193 | 194 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /go/variants/registry.go: -------------------------------------------------------------------------------- 1 | package variants 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "math/rand" 8 | "strings" 9 | "sync" 10 | ) 11 | 12 | // A Registry keeps track of all Flags, Conditions, and Variants. 13 | type Registry struct { 14 | // This mutex protects the fields below. 15 | sync.RWMutex 16 | 17 | // Currently registered variants mapped by ID. 18 | variants map[string]Variant 19 | 20 | // Registered condition specs mapped on type. Specs create condition functions. 21 | conditionSpecs map[string]func(...interface{}) func(interface{}) bool 22 | 23 | // Registered variant flags mapped by name. 24 | flags map[string]Flag 25 | 26 | // Maps flag names to a set of variant IDs. Used to evaluate flag values. 27 | flagToVariantIDMap map[string]map[string]struct{} 28 | } 29 | 30 | // NewRegistry allocates and returns a new Registry. 31 | func NewRegistry() *Registry { 32 | r := &Registry{ 33 | variants: map[string]Variant{}, 34 | conditionSpecs: map[string]func(...interface{}) func(interface{}) bool{}, 35 | flags: map[string]Flag{}, 36 | flagToVariantIDMap: map[string]map[string]struct{}{}, 37 | } 38 | r.registerBuiltInConditionTypes() 39 | return r 40 | } 41 | 42 | var ( 43 | // DefaultRegistry is the default Registry used by Variants. 44 | DefaultRegistry = NewRegistry() 45 | defaultRegistryMu sync.RWMutex 46 | ) 47 | 48 | // Reset clears any registered objects within the DefaultRegistry. 49 | func Reset() { 50 | defaultRegistryMu.Lock() 51 | DefaultRegistry = NewRegistry() 52 | defaultRegistryMu.Unlock() 53 | } 54 | 55 | // AddFlag adds f to the DefaultRegistry. 56 | func AddFlag(f Flag) error { 57 | defaultRegistryMu.RLock() 58 | defer defaultRegistryMu.RUnlock() 59 | return DefaultRegistry.AddFlag(f) 60 | } 61 | 62 | // FlagValue returns the value of a flag with the given name from the DefaultRegistry. 63 | func FlagValue(name string) interface{} { 64 | defaultRegistryMu.RLock() 65 | defer defaultRegistryMu.RUnlock() 66 | return DefaultRegistry.FlagValue(name) 67 | } 68 | 69 | // FlagValueWithContext returns the value of the flag with the given name and 70 | // context from the DefaultRegistry. 71 | func FlagValueWithContext(name string, context interface{}) interface{} { 72 | defaultRegistryMu.RLock() 73 | defer defaultRegistryMu.RUnlock() 74 | return DefaultRegistry.FlagValueWithContext(name, context) 75 | } 76 | 77 | // FlagValueWithContextWithForcedVariants returns the value of the flag with the given name and 78 | // context from the DefaultRegistry. Potentially forcing a variant on or off. 79 | func FlagValueWithContextWithForcedVariants( 80 | name string, 81 | context interface{}, 82 | forcedVariants map[string]bool, 83 | ) interface{} { 84 | defaultRegistryMu.RLock() 85 | defer defaultRegistryMu.RUnlock() 86 | return DefaultRegistry.FlagValueWithContextWithForcedVariants(name, context, forcedVariants) 87 | } 88 | 89 | // Flags returns all Flags registered with the DefaultRegistry. 90 | func Flags() []Flag { 91 | defaultRegistryMu.RLock() 92 | defer defaultRegistryMu.RUnlock() 93 | return DefaultRegistry.Flags() 94 | } 95 | 96 | // AddVariant adds v to the DefaultRegistry. 97 | func AddVariant(v Variant) error { 98 | defaultRegistryMu.RLock() 99 | defer defaultRegistryMu.RUnlock() 100 | return DefaultRegistry.AddVariant(v) 101 | } 102 | 103 | // Variants returns all variants registered within the DefaultRegistry. 104 | func Variants() []Variant { 105 | defaultRegistryMu.RLock() 106 | defer defaultRegistryMu.RUnlock() 107 | return DefaultRegistry.Variants() 108 | } 109 | 110 | // RegisterConditionType registers a Condition type with the given ID 111 | // and evaluating function with the DefaultRegistry. 112 | func RegisterConditionType(id string, fn func(...interface{}) func(interface{}) bool) error { 113 | defaultRegistryMu.RLock() 114 | defer defaultRegistryMu.RUnlock() 115 | return DefaultRegistry.RegisterConditionType(id, fn) 116 | } 117 | 118 | // LoadConfig loads filename, a JSON-encoded set of Mods, Conditions, and Variants, 119 | // with the DefaultRegistry. 120 | func LoadConfig(filename string) error { 121 | defaultRegistryMu.RLock() 122 | defer defaultRegistryMu.RUnlock() 123 | return DefaultRegistry.LoadConfig(filename) 124 | } 125 | 126 | // LoadJSON loads data, a JSON-encoded set of Mods, Conditions, and Variants, 127 | // with the DefaultRegistry. 128 | func LoadJSON(data []byte) error { 129 | defaultRegistryMu.RLock() 130 | defer defaultRegistryMu.RUnlock() 131 | return DefaultRegistry.LoadJSON(data) 132 | } 133 | 134 | // ReloadConfig reloads the given filename config into the DefaultRegistry. 135 | func ReloadConfig(filename string) error { 136 | defaultRegistryMu.RLock() 137 | defer defaultRegistryMu.RUnlock() 138 | return DefaultRegistry.ReloadConfig(filename) 139 | } 140 | 141 | // ReloadJSON reloads the given JSON-encoded byte slice into the DefaultRegistry. 142 | func ReloadJSON(data []byte) error { 143 | defaultRegistryMu.RLock() 144 | defer defaultRegistryMu.RUnlock() 145 | return DefaultRegistry.ReloadJSON(data) 146 | } 147 | 148 | // AddFlag registers a new flag, returning an error if a flag already 149 | // exists with the same name. 150 | func (r *Registry) AddFlag(f Flag) error { 151 | r.Lock() 152 | defer r.Unlock() 153 | if _, present := r.flags[f.Name]; present { 154 | return fmt.Errorf("Variant flag with the name %q is already registered.", f.Name) 155 | } 156 | r.flags[f.Name] = f 157 | r.flagToVariantIDMap[f.Name] = map[string]struct{}{} 158 | return nil 159 | } 160 | 161 | // FlagValue returns the value of a flag based on a nil context. 162 | func (r *Registry) FlagValue(name string) interface{} { 163 | return r.FlagValueWithContext(name, nil) 164 | } 165 | 166 | // FlagValueWithContext returns the value of a flag based on a given context object. 167 | // The first variant that is satisfied and has a mod associated with the given flag name 168 | // will be evaluated. The order of variant evaluation is nondeterministic. 169 | func (r *Registry) FlagValueWithContext(name string, context interface{}) interface{} { 170 | return r.FlagValueWithContextWithForcedVariants(name, context, nil) 171 | } 172 | 173 | // FlagValueWithContextWithForcedVariants returns the value of a flag based on a given context object. 174 | // The first variant that is satisfied and has a mod associated with the given flag name 175 | // will be evaluated. The order of variant evaluation is nondeterministic. A forced variant 176 | // can "force" the value of the flag to returned or ignored. 177 | // TODO(andybons): Deterministic behavior through rule ordering. 178 | func (r *Registry) FlagValueWithContextWithForcedVariants( 179 | name string, 180 | context interface{}, 181 | forcedVariants map[string]bool, 182 | ) interface{} { 183 | r.RLock() 184 | defer r.RUnlock() 185 | val := r.flags[name].BaseValue 186 | for variantID := range r.flagToVariantIDMap[name] { 187 | variant := r.variants[variantID] 188 | 189 | forcedVal, found := forcedVariants[variantID] 190 | forcedOn := found && forcedVal == true 191 | forcedOff := found && forcedVal == false 192 | 193 | if !forcedOff && (forcedOn || variant.Evaluate(context)) { 194 | val = variant.FlagValue(name) 195 | } 196 | } 197 | return val 198 | } 199 | 200 | // Flags returns all flags registered with the receiver. 201 | func (r *Registry) Flags() []Flag { 202 | r.RLock() 203 | defer r.RUnlock() 204 | result := make([]Flag, len(r.flags)) 205 | i := 0 206 | for _, f := range r.flags { 207 | result[i] = f 208 | i++ 209 | } 210 | return result 211 | } 212 | 213 | // AddVariant registers a new variant, returning an error if the flag 214 | // already exists with the same Id or the flag name within any of the variant's 215 | // mods is not registered. 216 | func (r *Registry) AddVariant(v Variant) error { 217 | r.Lock() 218 | defer r.Unlock() 219 | if _, found := r.variants[v.ID]; found { 220 | return fmt.Errorf("Variant already registered with the ID %q", v.ID) 221 | } 222 | 223 | for _, m := range v.Mods { 224 | if _, found := r.flags[m.FlagName]; !found { 225 | return fmt.Errorf("Flag with the name %q has not been registered.", m.FlagName) 226 | } 227 | r.flagToVariantIDMap[m.FlagName][v.ID] = struct{}{} 228 | } 229 | r.variants[v.ID] = v 230 | return nil 231 | } 232 | 233 | // Variants returns a slice of all variants registered with the receiver. 234 | func (r *Registry) Variants() []Variant { 235 | r.RLock() 236 | defer r.RUnlock() 237 | result := make([]Variant, len(r.variants)) 238 | i := 0 239 | for _, v := range r.variants { 240 | result[i] = v 241 | i++ 242 | } 243 | return result 244 | } 245 | 246 | // RegisterConditionType registers the condition type with an ID unique to the 247 | // set of registered condition types with a function that determines how the 248 | // condition will be evaluated. 249 | func (r *Registry) RegisterConditionType(id string, fn func(...interface{}) func(interface{}) bool) error { 250 | r.Lock() 251 | defer r.Unlock() 252 | id = strings.ToUpper(id) 253 | if _, found := r.conditionSpecs[id]; found { 254 | return fmt.Errorf("Condition with id %q already registered.", id) 255 | } 256 | // TODO(andybons): Input checking/sanitization is left to the user to muddle around with. 257 | // Determine a better way of handling bad input. 258 | r.conditionSpecs[id] = fn 259 | return nil 260 | } 261 | 262 | const ( 263 | conditionTypeRandom = "RANDOM" 264 | conditionTypeModRange = "MOD_RANGE" 265 | ) 266 | 267 | func (r *Registry) registerBuiltInConditionTypes() { 268 | // Register the RANDOM condition type. 269 | r.RegisterConditionType(conditionTypeRandom, func(values ...interface{}) func(interface{}) bool { 270 | v, ok := values[0].(float64) 271 | if !ok || v < 0 || v > 1 { 272 | return nil 273 | } 274 | return func(_ interface{}) bool { 275 | return rand.Float64() <= v 276 | } 277 | }) 278 | 279 | // Register the MOD_RANGE condition type. 280 | r.RegisterConditionType(conditionTypeModRange, func(values ...interface{}) func(interface{}) bool { 281 | if len(values) != 3 { 282 | return nil 283 | } 284 | 285 | // TODO(andybons): These will panic if the type assertion fails. 286 | key := values[0].(string) 287 | rangeBegin := int(values[1].(float64)) 288 | rangeEnd := int(values[2].(float64)) 289 | if rangeBegin > rangeEnd { 290 | return nil 291 | } 292 | 293 | return func(context interface{}) bool { 294 | ctx, ok := context.(map[string]int) 295 | if !ok { 296 | return false 297 | } 298 | mod := ctx[key] % 100 299 | return mod >= rangeBegin && mod <= rangeEnd 300 | } 301 | }) 302 | } 303 | 304 | type configFile struct { 305 | Flags []Flag `json:"flag_defs"` 306 | Variants []Variant `json:"variants"` 307 | } 308 | 309 | // ReloadJSON constructs a union of the registry created by the given 310 | // JSON byte array and the receiver, overriding any flag or variant 311 | // definitions present in the new config but leaving all others alone. 312 | func (r *Registry) ReloadJSON(data []byte) error { 313 | registry := NewRegistry() 314 | if err := registry.LoadJSON(data); err != nil { 315 | return err 316 | } 317 | return r.mergeRegistry(registry) 318 | } 319 | 320 | // ReloadConfig constructs a union of the registry created by the given 321 | // config filename and the receiver, overriding any flag or variant 322 | // definitions present in the new config but leaving all others alone. 323 | func (r *Registry) ReloadConfig(filename string) error { 324 | other := NewRegistry() 325 | if err := other.LoadConfig(filename); err != nil { 326 | return err 327 | } 328 | return r.mergeRegistry(other) 329 | } 330 | 331 | func (r *Registry) mergeRegistry(registry *Registry) error { 332 | for _, flag := range registry.Flags() { 333 | delete(r.flags, flag.Name) 334 | r.AddFlag(flag) 335 | } 336 | for _, variant := range registry.Variants() { 337 | delete(r.variants, variant.ID) 338 | r.AddVariant(variant) 339 | } 340 | return nil 341 | } 342 | 343 | // LoadJSON reads a byte array of JSON containing flags and variants 344 | // and registers them with the receiver. 345 | func (r *Registry) LoadJSON(data []byte) error { 346 | config := configFile{} 347 | if err := json.Unmarshal(data, &config); err != nil { 348 | return err 349 | } 350 | for _, f := range config.Flags { 351 | if err := r.AddFlag(f); err != nil { 352 | return err 353 | } 354 | } 355 | for _, v := range config.Variants { 356 | if len(v.Mods) == 0 { 357 | return fmt.Errorf("Variant with ID %q must have at least one mod.", v.ID) 358 | } 359 | if len(v.Conditions) > 1 && len(v.ConditionalOperator) == 0 { 360 | return fmt.Errorf("Variant with ID %q has %d conditions but no conditional operator specified.", v.ID, len(v.Conditions)) 361 | } 362 | for i, c := range v.Conditions { 363 | if len(c.Values) == 0 { 364 | c.Values = []interface{}{c.Value} 365 | } 366 | r.Lock() 367 | if fn, ok := r.conditionSpecs[c.Type]; ok { 368 | v.Conditions[i].Evaluator = fn(c.Values...) 369 | } 370 | r.Unlock() 371 | } 372 | if err := r.AddVariant(v); err != nil { 373 | return err 374 | } 375 | } 376 | return nil 377 | } 378 | 379 | // LoadConfig reads a JSON-encoded file containing flags and variants 380 | // and registers them with the receiver. 381 | func (r *Registry) LoadConfig(filename string) error { 382 | data, err := ioutil.ReadFile(filename) 383 | if err != nil { 384 | return err 385 | } 386 | return r.LoadJSON(data) 387 | } 388 | -------------------------------------------------------------------------------- /nodejs/lib/variants.js: -------------------------------------------------------------------------------- 1 | // Copyright 2012 The Obvious Corporation. 2 | 3 | /** 4 | * @fileoverview Public interface exposed to users of 'variants' 5 | */ 6 | 7 | var fs = require('fs') 8 | , path = require('path') 9 | , Variant = require('./variant') 10 | , Condition = require('./condition') 11 | , Mod = require('./mod') 12 | , Flag = require('./flag') 13 | , Operators = require('./operators') 14 | 15 | 16 | /** 17 | * Public API. See function declarations for JSDoc. 18 | */ 19 | module.exports = { 20 | clearAll: clearAll 21 | , getFlagValue: getFlagValue 22 | , getAllVariants: getAllVariants 23 | , getAllFlags: getAllFlags 24 | , loadFile: loadFile 25 | , loadFileSync: loadFileSync 26 | , loadJson: loadJson 27 | , reloadFile: reloadFile 28 | , reloadJson: reloadJson 29 | , registerConditionType: registerConditionType 30 | , registerFlag: registerUserFlag 31 | , getRegistryNames: getRegistryNames 32 | , currentRegistry: currentRegistry 33 | , setCurrentRegistry: setCurrentRegistry 34 | } 35 | 36 | /** 37 | * A map that contains all registries that have been created. 38 | * @type {Object.} 39 | */ 40 | var registryList = {} 41 | 42 | /** 43 | * Global registry object that contains the current set of flags, conditions and variants. 44 | * @type {Registry} 45 | */ 46 | var globalRegistry = null 47 | 48 | 49 | // Call 'clearAll' in order to set ourselves to the initial state, with a single main registry. 50 | clearAll() 51 | 52 | 53 | /** 54 | * Registry class that contains a set of registered flags, variants and conditions. 55 | */ 56 | function Registry(name) { 57 | if (!!registryList[name]) throw new Error('A registry with the name ' + name + ' already exists.') 58 | 59 | /** 60 | * Map of currently registered variants. 61 | * @type {Object.} 62 | */ 63 | this.variants = {} 64 | 65 | 66 | /** 67 | * Registered condition specs based on type. Specs create condition functions. 68 | * @type {Object.} 69 | */ 70 | this.conditionSpecs = {} 71 | 72 | 73 | /** 74 | * Registered variant flags. 75 | * @type {Object.} 76 | */ 77 | this.flags = {} 78 | 79 | 80 | /** 81 | * Maps flags to a set of variant ids. Used to evaluate flag values. 82 | * @type {Object.} 83 | */ 84 | this.flagToVariantIdsMap = {} 85 | 86 | /** 87 | * @type {string} 88 | */ 89 | this.name = name 90 | 91 | 92 | registryList[name] = this 93 | } 94 | 95 | 96 | /** 97 | * Registers a new flag. 98 | * @param {!Flag} flag 99 | */ 100 | Registry.prototype.addFlag = function(flag) { 101 | var name = flag.getName() 102 | if (name in this.flags) { 103 | throw new Error('Variant flag already registered: ' + name) 104 | } 105 | this.flags[name] = flag 106 | this.flagToVariantIdsMap[name] = {} 107 | } 108 | 109 | 110 | /** 111 | * Registers a new variant. 112 | * @param {!Variant} variant 113 | */ 114 | Registry.prototype.addVariant = function(variant) { 115 | if (!!this.variants[variant.id]) { 116 | throw new Error('Variant already registered with id: ' + variant.id) 117 | } 118 | 119 | Registry._mapVariantFlags(variant, this.flags, this.flagToVariantIdsMap) 120 | this.variants[variant.id] = variant 121 | } 122 | 123 | 124 | /** 125 | * Maps flags to a map of variant ids. Useful for quickly looking up which variants 126 | * belong to a particular flag. 127 | * @param {!Variant} variant variant to map flags for 128 | * @param {!Object.} flags map of flags 129 | * @param {!Object.>} flagToVariantIdsMap 130 | */ 131 | Registry._mapVariantFlags = function(variant, flags, flagToVariantIdsMap) { 132 | for (var i = 0; i < variant.mods.length; ++i) { 133 | var flagName = variant.mods[i].flagName 134 | 135 | // Simply place a marker indicating that this flag name maps to the given variant. 136 | if (!(flagName in flags)) { 137 | throw new Error('Flag has not been registered: ' + flagName) 138 | } 139 | if (!flagToVariantIdsMap[flagName]) { 140 | flagToVariantIdsMap[flagName] = {} 141 | } 142 | flagToVariantIdsMap[flagName][variant.id] = true 143 | } 144 | } 145 | 146 | 147 | /** 148 | * Overrides the registry with the given registry. Will not stomp old variants or flags unless 149 | * specified in the new registry. If there is a failure, the registry will not be changed and 150 | * the old values will persist. 151 | * @param {!Registry} registry overrides 152 | */ 153 | Registry.prototype.overrideFlagsAndVariants = function (registry) { 154 | // Copy old and new into temporaries to make sure there are no errors. 155 | var newFlags = shallowExtend(this.flags, registry.flags) 156 | var newVariants = shallowExtend(this.variants, registry.variants) 157 | 158 | var newFlagToVariantIdsMap = {} 159 | for (var k in newVariants) { 160 | var v = newVariants[k] 161 | Registry._mapVariantFlags(v, newFlags, newFlagToVariantIdsMap) 162 | } 163 | 164 | // By this point there was no error, so make the changes. 165 | this.flags = newFlags 166 | this.variants = newVariants 167 | this.flagToVariantIdsMap = newFlagToVariantIdsMap 168 | } 169 | 170 | 171 | /** 172 | * Clears all variants and flags. 173 | */ 174 | function clearAll() { 175 | registryList = {} 176 | globalRegistry = null 177 | setCurrentRegistry('main') 178 | } 179 | 180 | 181 | /** 182 | * Returns all of the registered variants. 183 | * @return {Array.} 184 | */ 185 | function getAllVariants() { 186 | var variants = [] 187 | for (var k in globalRegistry.variants) { 188 | variants.push(globalRegistry.variants[k]) 189 | } 190 | return variants 191 | } 192 | 193 | 194 | /** 195 | * Returns all of the registered flags. 196 | * @return {Array.} 197 | */ 198 | function getAllFlags() { 199 | var flags = [] 200 | for (var flag in globalRegistry.flagToVariantIdsMap) { 201 | flags.push(flag) 202 | } 203 | return flags 204 | } 205 | 206 | 207 | /** 208 | * Evaluates the flag value based on the given context object. 209 | * @param {string} flagName Name of the variant flag to get the value for 210 | * @param {Object=} opt_context Optional context object that contains fields relevant to 211 | * evaluating conditions 212 | * @param {Object.=} opt_forced Optional map of variant ids that are forced 213 | * to either true or false. 214 | * @return {*} Value specified in the variants JSON file or undefined if no conditions were met 215 | */ 216 | function getFlagValue(flagName, context, opt_forced) { 217 | var variantIds = globalRegistry.flagToVariantIdsMap[flagName] 218 | if (!variantIds) { 219 | throw new Error('Variant flag not defined: ' + flagName) 220 | } 221 | 222 | context = context || {} 223 | var forced = opt_forced || {} 224 | var value = globalRegistry.flags[flagName].getBaseValue() 225 | 226 | // TODO(david): Partial ordering 227 | for (var id in variantIds) { 228 | var v = globalRegistry.variants[id] 229 | if (!v) { 230 | throw new Error('Missing registered variant: ' + id) 231 | } 232 | 233 | var forcedOn = forced[id] === true 234 | var forcedOff = forced[id] === false 235 | if (!forcedOff && (forcedOn || v.evaluate(context))) { 236 | value = v.getFlagValue(flagName) 237 | } 238 | } 239 | return value 240 | } 241 | 242 | 243 | /** 244 | * Loads the JSON file and registers its variants. 245 | * @param {string} filepath JSON file to load 246 | * @param {function (Error=)} callback invoked when done 247 | * @param {Registry=} opt_registry optional registry 248 | */ 249 | function loadFile(filepath, callback, opt_registry) { 250 | // Keep a copy of the registry that was active when the call was 251 | // initiated, so that we load the JSON into the correct registry 252 | // even if the registry gets switched out before the readFile completes. 253 | var registry = opt_registry || globalRegistry 254 | 255 | fs.readFile(filepath, function (err, text) { 256 | if (err) return callback(err) 257 | 258 | loadJson(JSON.parse(text), function (err) { 259 | callback(err) 260 | }, registry) 261 | }) 262 | } 263 | 264 | 265 | /** 266 | * Loads the JSON file synchronously and registers its variants. 267 | * @param {string} filepath JSON file to load 268 | * @param {Registry=} opt_registry optional registry 269 | */ 270 | function loadFileSync(filepath, opt_registry) { 271 | var registry = opt_registry || globalRegistry 272 | var text = fs.readFileSync(filepath, 'utf8') 273 | return loadJson(JSON.parse(text), function (err) { 274 | if (err) throw err 275 | }, registry) 276 | } 277 | 278 | 279 | /** 280 | * Parses the given JSON object and registers its variants. 281 | * @param {Object} obj JSON object to parse 282 | * @param {function (Error=)} callback invoked when done. 283 | * @param {Registry=} opt_registry optional registry 284 | */ 285 | function loadJson(obj, callback, opt_registry) { 286 | var registry = opt_registry || globalRegistry 287 | var err 288 | try { 289 | var flags = obj['flag_defs'] ? parseFlags(obj['flag_defs']) : [] 290 | for (var i = 0; i < flags.length; ++i) { 291 | registry.addFlag(flags[i]) 292 | } 293 | 294 | var variants = obj['variants'] ? parseVariants(obj['variants']) : [] 295 | for (var i = 0; i < variants.length; ++i) { 296 | registry.addVariant(variants[i]) 297 | } 298 | } catch (e) { 299 | err = e 300 | } 301 | callback(err) 302 | } 303 | 304 | 305 | /** 306 | * Reloads the JSON file and overrides currently registered variants. 307 | * @param {string} filepath JSON file to load 308 | * @param {function (Error=, Object=)} callback optional callback to handle errors 309 | */ 310 | function reloadFile(filepath, callback) { 311 | var reloaded = new Registry() 312 | loadFile(filepath, function (err) { 313 | if (err) return callback(err) 314 | globalRegistry.overrideFlagsAndVariants(reloaded) 315 | callback() 316 | }, reloaded) 317 | } 318 | 319 | 320 | /** 321 | * Reloads the given JSON object and registers its variants. 322 | * @param {Object} obj JSON object to parse 323 | * @param {function (Error=, Object=)} callback optional callback to handle errors 324 | */ 325 | function reloadJson(obj, callback) { 326 | var reloaded = new Registry() 327 | loadJson(obj, callback, reloaded) 328 | globalRegistry.overrideFlagsAndVariants(reloaded) 329 | } 330 | 331 | 332 | /** 333 | * Creates a new flag object and registers it. 334 | * @param {string} flagName Name of the variant flag 335 | * @param {*} defaultValue 336 | */ 337 | function registerUserFlag(flagName, defaultValue) { 338 | return globalRegistry.addFlag(new Flag(flagName, defaultValue)) 339 | } 340 | 341 | 342 | /** 343 | * Registers the condition type to be used when evaluating variants. 344 | * @param {string} id Case-insensitive identifier for the condition 345 | * @param {function(*, Array.<*>)} fn Conditional function generator which takes the 346 | * the parsed value and list of values respectively and must return a function that optionally 347 | * takes a context Object and returns true or false. 348 | * See #registerBuiltInConditionTypes as an example. 349 | */ 350 | function registerConditionType(id, fn) { 351 | id = id.toUpperCase() 352 | if (globalRegistry.conditionSpecs[id]) { 353 | throw new Error('Condition already registered: ' + id) 354 | } 355 | globalRegistry.conditionSpecs[id] = fn 356 | } 357 | 358 | 359 | /** 360 | * Gets an array of all the names of the registries that have already been created. 361 | * @return {Array.} 362 | */ 363 | function getRegistryNames() { 364 | return Object.keys(registryList) 365 | } 366 | 367 | 368 | /** 369 | * Gets the name of the registry that is currently being used. 370 | * @return {string} 371 | */ 372 | function currentRegistry() { 373 | return globalRegistry.name 374 | } 375 | 376 | 377 | /** 378 | * Switches to the named registry. If a registry by that name does not exist, it 379 | * is created. 380 | * @return {string} 381 | */ 382 | function setCurrentRegistry(name) { 383 | if (!globalRegistry || globalRegistry.name != name) { 384 | if (registryList[name]) { 385 | globalRegistry = registryList[name] 386 | } else { 387 | globalRegistry = new Registry(name) 388 | registerBuiltInConditionTypes() 389 | } 390 | } 391 | } 392 | 393 | 394 | /** 395 | * Parses a JSON array into an array of Flags. 396 | * @param {Array.} array 397 | * @return {!Array.} 398 | */ 399 | function parseFlags(array) { 400 | var flags = [] 401 | for (var i = 0; i < array.length; ++i) { 402 | var f = parseFlag(array[i]) 403 | flags.push(f) 404 | } 405 | return flags 406 | } 407 | 408 | 409 | /** 410 | * Parses a JSON object into a Flag. 411 | * @param {Object} obj 412 | * @return {!Variant} 413 | */ 414 | function parseFlag(obj) { 415 | var id = getRequired(obj, 'flag') 416 | var baseValue = getRequired(obj, 'base_value') 417 | return new Flag(id, baseValue) 418 | } 419 | 420 | 421 | /** 422 | * Parses a JSON array into an array of Variants. 423 | * @param {Array.} array 424 | * @return {!Array.} 425 | */ 426 | function parseVariants(array) { 427 | var variants = [] 428 | for (var i = 0; i < array.length; ++i) { 429 | variants.push(parseVariant(array[i])) 430 | } 431 | return variants 432 | } 433 | 434 | 435 | /** 436 | * Parses a JSON object into a Variant. 437 | * @param {Object} obj 438 | * @return {!Variant} 439 | */ 440 | function parseVariant(obj) { 441 | var variantId = getRequired(obj, 'id') 442 | var operator = getOrDefault(obj, 'condition_operator', null) 443 | var conditions = !!obj['conditions'] ? parseConditions(obj['conditions']) : [] 444 | var mods = parseMods(obj['mods']) 445 | return new Variant(variantId, operator, conditions, mods) 446 | } 447 | 448 | 449 | /** 450 | * Parses a JSON array into an array of Conditions. 451 | * @param {Array.} array 452 | * @return {!Array.} 453 | */ 454 | function parseConditions(array) { 455 | var conditions = [] 456 | for (var i = 0; i < array.length; ++i) { 457 | conditions.push(parseCondition(array[i])) 458 | } 459 | return conditions 460 | } 461 | 462 | 463 | /** 464 | * Parses a JSON object into a Condition. 465 | * @param {Object} obj 466 | * @param {!Condition} 467 | */ 468 | function parseCondition(obj) { 469 | var type = getRequired(obj, 'type').toUpperCase() 470 | 471 | if (!globalRegistry.conditionSpecs[type]) { 472 | throw new Error('Unknown condition type: ' + type) 473 | } 474 | 475 | var value = getOrDefault(obj, 'value', null) 476 | var values = getOrDefault(obj, 'values', null) 477 | if (value != null && values != null) { 478 | throw new Error('Cannot specify both a value and array of values for: ' + type) 479 | } 480 | 481 | // Only pass in either value, values or null. 482 | if (value == null && values == null) { 483 | value = null 484 | } 485 | var input = (values != null) ? values : value 486 | var fn = globalRegistry.conditionSpecs[type](input) 487 | if (typeof fn !== 'function') { 488 | throw new Error('Condition function must return a function') 489 | } 490 | return new Condition(fn) 491 | } 492 | 493 | 494 | /** 495 | * Parses a JSON array into an array of Mods. 496 | * @param {Array.} array 497 | * @return {!Array.} 498 | */ 499 | function parseMods(array) { 500 | var mods = [] 501 | for (var i = 0; i < array.length; ++i) { 502 | var obj = array[i] 503 | var flag = getRequired(obj, 'flag') 504 | var value = getRequired(obj, 'value') 505 | mods.push(new Mod(flag, value)) 506 | } 507 | return mods 508 | } 509 | 510 | 511 | /** 512 | * Returns the value from the map if it exists or throw an error. 513 | * @param {Object} obj 514 | * @param {string} key 515 | * @return {*} the value if it exists 516 | */ 517 | function getRequired(obj, key) { 518 | if (key in obj) { 519 | return obj[key] 520 | } 521 | throw new Error('Missing required key "' + key + '" in object: ' + JSON.stringify(obj)) 522 | } 523 | 524 | 525 | 526 | /** 527 | * Returns the value from the map if it exists or the default. 528 | * @param {Object} obj 529 | * @param {string} key 530 | * @param {*} def Default to return if the key doesn't exist in the object. 531 | */ 532 | function getOrDefault(obj, key, def) { 533 | if (key in obj) { 534 | return obj[key] 535 | } 536 | return def 537 | } 538 | 539 | 540 | /** 541 | * Creates a superset of all of the passed in objects, overriding individual key/value pairs 542 | * for each subsequent duplicate (therefore order dependent). Returns the new object. 543 | * @param {Object...} arguments 544 | * @return {!Object} 545 | */ 546 | function shallowExtend() { 547 | var to = {} 548 | for (var i = 0; i < arguments.length; ++i) { 549 | var from = arguments[i] 550 | for (var k in from) { 551 | to[k] = from[k] 552 | } 553 | } 554 | return to 555 | } 556 | 557 | 558 | // Registers built-in condition types. 559 | function registerBuiltInConditionTypes() { 560 | // Register the RANDOM condition type. 561 | registerConditionType('RANDOM', function (value) { 562 | if (value < 0 || value > 1) { 563 | throw new Error('Fractional value from 0-1 required') 564 | } 565 | 566 | return function() { 567 | if (!value) { 568 | return false 569 | } 570 | return Math.random() <= value 571 | } 572 | }) 573 | 574 | // Register the MOD_RANGE condition type. 575 | registerConditionType('MOD_RANGE', function (values) { 576 | if (values.length != 3) { 577 | throw new Error('Expected two integer range values in "values" array') 578 | } 579 | 580 | var key = values[0] 581 | if (typeof key !== 'string') { 582 | throw new Error('Expected values[0] to be of type string') 583 | } 584 | var rangeBegin = values[1] 585 | var rangeEnd = values[2] 586 | if (rangeBegin > rangeEnd) { 587 | throw new Error('Start range must be less than end range') 588 | } 589 | 590 | return function(context) { 591 | var v = context[key] 592 | if (typeof v != 'number') { 593 | return false 594 | } 595 | var mod = v % 100 596 | return (mod >= rangeBegin && mod <= rangeEnd) 597 | } 598 | }) 599 | } 600 | --------------------------------------------------------------------------------