Comparing version 2.2.2 to 3.0.0
@@ -12,3 +12,3 @@ module.exports = function(grunt) { | ||
}, | ||
src: ['test/fflip.js'] | ||
src: ['test/*'] | ||
}, | ||
@@ -20,3 +20,3 @@ spec: { | ||
}, | ||
src: ['test/fflip.js'] | ||
src: ['test/*'] | ||
} | ||
@@ -23,0 +23,0 @@ } |
@@ -5,2 +5,3 @@ 'use strict'; | ||
* FFlip Request Object - Express/Connect Helper | ||
* | ||
* @param {FFlip} fflip The fflip main module | ||
@@ -21,2 +22,3 @@ * @param {[Object]} flags A set of feature overrides, these flags will | ||
* Sets the features for a given user | ||
* | ||
* @param {Object} user A user object to test criteria against for each feature | ||
@@ -32,2 +34,3 @@ * @return {void} | ||
* Check if a user has a certain feature enabled/disabled | ||
* | ||
* @param {string} featureName The name of the feature to check for | ||
@@ -34,0 +37,0 @@ * @return {Boolean} True if feature is enabled, false otherwise |
265
lib/fflip.js
@@ -1,4 +0,1 @@ | ||
/** | ||
* @fileoverview FFlip - Feature Flipping Moduel | ||
*/ | ||
'use strict'; | ||
@@ -11,2 +8,3 @@ | ||
var FFlipRequestObject = require('./fflip-request'); | ||
var util = require('util'); | ||
@@ -21,3 +19,52 @@ | ||
/** | ||
* Process the `criteria` data provided by the user. Handle any bad input, | ||
* deprecated formatting, and other edge cases caused by user-data here. | ||
* | ||
* @param {*} userInput Expects an array of criteria. Also supports the | ||
* deprecated object format. | ||
* @return {Object} The criteria dictionary, indexed by criteria ID | ||
*/ | ||
function processUserCriteria(userInput) { | ||
if (typeof userInput !== 'object') { | ||
throw new Error('fflip: bad data passed for `criteria`'); | ||
} | ||
if (!Array.isArray(userInput)) { | ||
return userInput; | ||
} | ||
var returnObj = {}; | ||
userInput.forEach(function(criteriaObject) { | ||
returnObj[criteriaObject.id] = criteriaObject.check; | ||
}); | ||
return returnObj; | ||
} | ||
/** | ||
* Process the `features` data provided by the user. Handle any bad input, | ||
* deprecated formatting, and other edge cases caused by user-data here. | ||
* | ||
* @param {*} userInput Expects an array of features. Also supports the | ||
* deprecated object format. | ||
* @return {Object} The features dictionary, indexed by feature ID | ||
*/ | ||
function processUserFeatures(userInput) { | ||
if (typeof userInput !== 'object') { | ||
throw new Error('fflip: bad data passed for `features`'); | ||
} | ||
if (!Array.isArray(userInput)) { | ||
return userInput; | ||
} | ||
var returnObj = {}; | ||
userInput.forEach(function(featureObject) { | ||
returnObj[featureObject.id] = featureObject; | ||
}); | ||
return returnObj; | ||
} | ||
/** | ||
* Set the criteria to the given object. | ||
* | ||
* @param {Object} configVal | ||
@@ -28,3 +75,3 @@ * @return {void} | ||
function setCriteria(configVal) { | ||
self._criteria = configVal; | ||
self._criteria = processUserCriteria(configVal) | ||
} | ||
@@ -34,2 +81,3 @@ | ||
* Set the features. | ||
* | ||
* @param {Object} configVal | ||
@@ -40,31 +88,15 @@ * @return {void} | ||
function setFeatures(configVal) { | ||
if(typeof configVal == 'function') { | ||
if (typeof configVal === 'function') { | ||
if (configVal.length > 1) { | ||
throw new Error('FFlip: `features` function signature is invalid. Must accept zero arguments or one callback.'); | ||
} | ||
getFeatures = configVal; | ||
updateFeatures(); | ||
} else { | ||
getFeatures = undefined; | ||
self.reload(); | ||
return; | ||
} | ||
if(typeof configVal == 'object') { | ||
self._features = configVal; | ||
} | ||
} | ||
/** | ||
* Update the features by reloading them, if possible. | ||
* @return {void} | ||
* @private | ||
*/ | ||
function updateFeatures() { | ||
if(!getFeatures) { | ||
return; | ||
} | ||
if(getFeatures.length === 0) { | ||
self._features = getFeatures() || self._features; | ||
return; | ||
} | ||
if(getFeatures.length === 1) { | ||
getFeatures(getFeaturesCallback); | ||
return; | ||
} | ||
throw new Error('FFlip: params.features function signature is invalid. Must accept zero arguments or one callback.'); | ||
getFeatures = undefined; | ||
self._features = processUserFeatures(configVal); | ||
} | ||
@@ -74,2 +106,3 @@ | ||
* The callback called by the user-defined function for reloading features. | ||
* | ||
* @param {Object} data | ||
@@ -80,3 +113,3 @@ * @return {void} | ||
function getFeaturesCallback(data) { | ||
self._features = data || self._features; | ||
self._features = processUserFeatures(data) || self._features; | ||
} | ||
@@ -86,2 +119,3 @@ | ||
* Sets the reload rate for fetching new features. | ||
* @param {int} rate The interval to fetch new features on, in seconds | ||
@@ -102,6 +136,60 @@ * @return {void} | ||
/** | ||
* Evaluate a set of critera. Return true if ALL criteria is met. | ||
* | ||
* @param {Object} criteriaSet The set of criteria, where each key is a criteria ID | ||
* and each value is some data to send to that criteria function. | ||
* @param {Object} user The expected user object to check against in each criteria function. | ||
* @return {boolean} Returns true if ALL criteria are met, false otherwise. | ||
*/ | ||
function evaluateCriteriaSet(criteriaSet, user) { | ||
return Object.keys(criteriaSet).reduce(function(currentResult, cName) { | ||
if (cName === '$veto') { | ||
return currentResult; | ||
} | ||
var c_data = criteriaSet[cName]; | ||
var c_func = self._criteria[cName]; | ||
return (c_func(user, c_data) && currentResult); | ||
}, true); | ||
} | ||
/** | ||
* Evaluate a list of criteria. Return true if ANY one member evaluates to true, and no | ||
* "vetoing" members evaluate to false. | ||
* @param {Object} criteriaList A list of criteria sets or nested criteria lists. | ||
* @param {Object} user The expected user object to check against in each criteria function. | ||
* @return {boolean} Returns true if if ANY one member evaluates to true, and no "vetoing" | ||
* members evaluate to false. Returns false otherwise. | ||
*/ | ||
function evaluateCriteriaList(criteriaList, user) { | ||
var isEnabled = false; | ||
for (var i = 0, l = criteriaList.length; i < l; i++) { | ||
var listMember = criteriaList[i]; | ||
var memberResult; | ||
if (Array.isArray(listMember)) { | ||
// if array, repeat this logic on each member of array, return true if ANY return true & NO vetos return false` | ||
memberResult = evaluateCriteriaList(listMember, user); | ||
} else { | ||
// if object, evaluate all and return true if ALL return true | ||
memberResult = evaluateCriteriaSet(listMember, user); | ||
} | ||
if (listMember.$veto && !memberResult) { | ||
return false; | ||
} | ||
isEnabled = memberResult || isEnabled; | ||
} | ||
return isEnabled; | ||
} | ||
//-------------------------------------------------------------------------- | ||
// Public | ||
//-------------------------------------------------------------------------- | ||
var self = module.exports = { | ||
@@ -115,5 +203,6 @@ | ||
// The reload rate for reloading the features | ||
// The reload rate for reloading features | ||
_reloadRate: 30*1000, | ||
// The max cookie age for the express integration | ||
maxCookieAge: 900000, | ||
@@ -123,2 +212,3 @@ | ||
* Configure fflip. | ||
* | ||
* @param {Object} params | ||
@@ -136,13 +226,23 @@ * @return {void} | ||
* Reload the features, if a reload is possible. | ||
* | ||
* @return {void} | ||
*/ | ||
reload: function() { | ||
updateFeatures(); | ||
if(!getFeatures) { | ||
return; | ||
} | ||
if(getFeatures.length === 0) { | ||
self._features = processUserFeatures(getFeatures()) || self._features; | ||
return; | ||
} | ||
getFeatures(getFeaturesCallback); | ||
}, | ||
/** | ||
* Check if a user has some given feature, and returns a boolean. | ||
* Returns null if the feature does not exist. | ||
* Check if a user has some given feature, and returns a boolean. Returns null | ||
* if the feature does not exist. | ||
* | ||
* @param {string} featureName The name of the feature to check for. | ||
* @param {Object} user The User object that criterial will check against. | ||
* @param {string} featureName The name of the feature to check for. | ||
* @return {Boolean|null} | ||
@@ -152,19 +252,33 @@ */ | ||
var feature = self._features[featureName]; | ||
if(typeof feature != 'object') { | ||
// If feature does not exist, return null | ||
if (typeof feature === 'undefined') { | ||
return null; | ||
} | ||
var featureCriteria = feature.criteria || {}; | ||
var criteriaArray = Object.keys(featureCriteria); | ||
var isEnabled = true; | ||
if(criteriaArray.length == 0) { | ||
// If feature isn't an object, something has gone terribly wrong | ||
// TODO(fks) 03-10-2016: Check for this on config, not on the fly | ||
if (typeof feature !== 'object') { | ||
throw new Error('fflip: Features are formatted incorrectly.'); | ||
} | ||
// If feature.enabled is set, return its boolean form | ||
if (typeof feature.enabled !== 'undefined') { | ||
return !!feature.enabled; | ||
} | ||
// If feature.criteria is some non-object, return its boolean form | ||
if (typeof feature.criteria !== 'object') { | ||
return !!feature.criteria; | ||
} | ||
var featureCriteria; | ||
if (Array.isArray(feature.criteria)) { | ||
featureCriteria = feature.criteria; | ||
} else { | ||
featureCriteria = [feature.criteria]; | ||
} | ||
if(featureCriteria.length == 0) { | ||
return false; | ||
} | ||
criteriaArray.forEach(function(cKey) { | ||
if(isEnabled) { | ||
var c_data = featureCriteria[cKey]; | ||
var c_func = self._criteria[cKey]; | ||
isEnabled = c_func(user, c_data); | ||
} | ||
}); | ||
return isEnabled; | ||
return evaluateCriteriaList(featureCriteria, user); | ||
}, | ||
@@ -174,5 +288,5 @@ | ||
* Get the availability of all features for a given user. | ||
* | ||
* @param {Object} user The User object that criterial will check against. | ||
* @param {Object} flags A collection of overrides | ||
* [@deprecated this flag will be removed soon] | ||
* @return {Object} The collection of all features and their availability. | ||
@@ -182,11 +296,13 @@ */ | ||
flags = flags || {}; | ||
var user_features = {}; | ||
Object.keys(self._features).forEach(function(featureName) { | ||
if(flags[featureName] !== undefined) { | ||
user_features[featureName] = flags[featureName]; | ||
} else { | ||
user_features[featureName] = self.userHasFeature(user, featureName); | ||
var userFeatures = {}; | ||
for (var featureName in self._features) { | ||
if (self._features.hasOwnProperty(featureName)) { | ||
if(flags[featureName] !== undefined) { | ||
userFeatures[featureName] = flags[featureName]; | ||
} else { | ||
userFeatures[featureName] = self.userHasFeature(user, featureName); | ||
} | ||
} | ||
}); | ||
return user_features; | ||
} | ||
return userFeatures; | ||
}, | ||
@@ -198,2 +314,3 @@ | ||
* template. | ||
* | ||
* @param {Request} req | ||
@@ -204,3 +321,3 @@ * @param {Response} res | ||
*/ | ||
express_middleware: function(req, res, next) { | ||
expressMiddleware: function(req, res, next) { | ||
@@ -225,2 +342,3 @@ // Attach the fflip object to the request | ||
* Attach routes for manual feature flipping. | ||
* | ||
* @param {Request} req | ||
@@ -231,3 +349,3 @@ * @param {Response} res | ||
*/ | ||
express_route: function(req, res, next) { | ||
expressRoute: function(req, res, next) { | ||
var name = req.params.name; | ||
@@ -239,6 +357,6 @@ var action = req.params.action; | ||
if(self._features[name] === undefined) { | ||
var err = new Error('FFlip: Feature ' + name + ' not found'); | ||
err.fflip = true; | ||
err.statusCode = 404; | ||
return next(err); | ||
var notFoundError = new Error('FFlip: Feature ' + name + ' not found'); | ||
notFoundError.fflip = true; | ||
notFoundError.statusCode = 404; | ||
return next(notFoundError); | ||
} | ||
@@ -248,6 +366,6 @@ | ||
if(!req.cookies) { | ||
var err = new Error('FFlip: Cookies are not enabled.'); | ||
err.fflip = true; | ||
err.statusCode = 500; | ||
return next(err); | ||
var noCookiesError = new Error('FFlip: Cookies are not enabled.'); | ||
noCookiesError.fflip = true; | ||
noCookiesError.statusCode = 500; | ||
return next(noCookiesError); | ||
} | ||
@@ -293,2 +411,3 @@ | ||
* Attach FFlip functionality to an express app. Includes helpers & routes. | ||
* | ||
* @param {Object} app An Express Application | ||
@@ -299,7 +418,11 @@ * @return {void} | ||
// Express Middleware | ||
app.use(this.express_middleware); | ||
app.use(this.expressMiddleware); | ||
// Manual Flipping Route | ||
app.get('/fflip/:name/:action', this.express_route); | ||
}, | ||
app.get('/fflip/:name/:action', this.expressRoute); | ||
} | ||
}; | ||
/** @deprecated v2.x method names have been deprecated. These mappers will be removed in future versions. */ | ||
self.express_route = util.deprecate(self.expressRoute, 'fflip.express_route: Use fflip.expressRoute instead'); | ||
self.express_middleware = util.deprecate(self.expressMiddleware, 'fflip.express_middleware: Use fflip.expressMiddleware instead'); |
@@ -6,3 +6,3 @@ { | ||
"licence": "MIT", | ||
"version": "2.2.2", | ||
"version": "3.0.0", | ||
"main": "lib/fflip", | ||
@@ -15,6 +15,9 @@ "repository": "FredKSchott/fflip", | ||
"devDependencies": { | ||
"eslint": "^2.3.0", | ||
"eslint-config-airbnb": "^6.1.0", | ||
"eslint-plugin-react": "^4.2.1", | ||
"grunt": "~0.4.1", | ||
"grunt-mocha-test": "~0.7.0", | ||
"supertest": "~0.8.1", | ||
"sinon": "~1.7.3" | ||
"sinon": "~1.7.3", | ||
"supertest": "~0.8.1" | ||
}, | ||
@@ -21,0 +24,0 @@ "keywords": [ |
211
README.md
@@ -6,8 +6,8 @@ <a href="https://www.npmjs.com/package/fflip"> | ||
Working on an experimental new design? Starting a closed beta? Rolling out a new feature over the next few weeks? Fa-fa-fa-flip it! __fflip__ gives you complete control over releasing new functionality to your users based on their user id, join date, membership status, and whatever else you can think of. __fflip's__ goal is to be the most powerful and extensible feature flipping/toggling module on. the. planet. | ||
Working on an experimental new design? Starting a closed beta? Rolling out a new feature over the next few weeks? Fa-fa-fa-flip it! __fflip__ gives you complete control over releasing new functionality to your users based on their user id, join date, membership status, and whatever else you can think of. __fflip's__ goal is to be the most powerful and extensible feature flipping/toggling module out there. | ||
- Describes __custom criteria and features__ using easy-to-read JSON | ||
- Delivers features down to the client for additional __client-side feature flipping__ | ||
- Delivers features down to the client for __client-side feature flipping__ | ||
- Includes __Express Middleware__ for additional features like __feature flipping via cookie__ | ||
- __Everything-Agnostic:__ Supports any database, user representation or framework you can throw at it | ||
- __System-Agnostic:__ Built to support any database, user representation or web framework you can throw at it | ||
@@ -18,4 +18,7 @@ ``` | ||
##Getting Started | ||
## Getting Started | ||
Below is a simple example that uses __fflip__ to deliver a closed beta to a fraction of users: | ||
```javascript | ||
@@ -26,13 +29,14 @@ // Include fflip | ||
fflip.config({ | ||
criteria: ExampleCriteriaObject, // defined below | ||
features: ExampleFeaturesObject // defined below | ||
criteria: ExampleCriteria, // defined below | ||
features: ExampleFeatures // defined below | ||
}); | ||
// Get all of a user's enabled features | ||
var Features = fflip.userFeatures(someFreeUser); | ||
if(Features.closedBeta) { | ||
// Get all of a user's enabled features... | ||
someFreeUser.features = fflip.userFeatures(someFreeUser); | ||
if(someFreeUser.features.closedBeta === true) { | ||
console.log('Welcome to the Closed Beta!'); | ||
} | ||
// Or, just get a single one | ||
if (fflip.userHasFeature(someFreeUser, 'closedBeta')) { | ||
// ... or just check this single feature. | ||
if (fflip.userHasFeature(someFreeUser, 'closedBeta') === true) { | ||
console.log('Welcome to the Closed Beta!'); | ||
@@ -42,59 +46,95 @@ } | ||
###Criteria | ||
Criteria are the rules that features can test users against. Each rule takes a user and a data argument to test against, and returns true/false if the user matches that criteria. The data argument can be any type, as long as you handle it correctly in the function you describe. | ||
### Criteria | ||
**Criteria** are the rules that define access to different features. Each criteria takes a user object and some data as arguments, and returns true/false if the user matches that criteria. You will use these criteria to restrict/allow features for different subsets of your userbase. | ||
```javascript | ||
var ExampleCriteriaObject = { | ||
isPaidUser: function(user, isPaid) { | ||
return user.isPaid == isPaid; | ||
var ExampleCriteria = [ | ||
{ | ||
id: 'isPaidUser', // required | ||
check: function(user, isPaid) { // required | ||
return user.isPaid == isPaid; | ||
} | ||
}, | ||
percentageOfUsers: function(user, percent) { | ||
return (user.id % 100 < percent * 100); | ||
{ | ||
id: 'percentageOfUsers', | ||
check: function(user, percent) { | ||
return (user.id % 100 < percent * 100); | ||
} | ||
}, | ||
allowUserIDs: function(user, idArr) { | ||
return idArr.indexOf(user.id) > -1; | ||
{ | ||
id: 'allowUserIDs', | ||
check: function(user, allowedIDs) { | ||
return allowedIDs.indexOf(user.id) > -1; | ||
} | ||
} | ||
} | ||
]; | ||
``` | ||
###Features | ||
Features contain sets of criteria to test users against. The value associated with the criteria is passed in as the data argument of the criteria function. A user will have a featured enabled if they match all listed criteria, otherwise the feature is disabled. Features can include other optional properties for context. Features are described as follows: | ||
### Features | ||
**Features** represent some special behaviors in your application. They also define a set of criteria to test users against for each feature. When you ask fflip if a feature is enabled for some user, it will check that user against each rule/criteria, and return "true" if the user passes. | ||
Features are described in the following way: | ||
```javascript | ||
var ExampleFeaturesObject = { | ||
paidFeature: { | ||
criteria: { | ||
isPaidUser: true | ||
} | ||
var ExampleFeatures = [ | ||
{ | ||
id: 'closedBeta', // required | ||
// if `criteria` is in an object, ALL criteria in that set must evaluate to true to enable for user | ||
criteria: {isPaidUser: true, percentageOfUsers: 0.50} | ||
}, | ||
closedBeta: { | ||
name: "A Closed Beta", | ||
criteria: { | ||
allowUserIDs: [20,30,80,181] | ||
} | ||
{ | ||
id: 'newFeatureRollout', | ||
// if `criteria` is in an array, ANY ONE set of criteria must evaluate to true to enable for user | ||
criteria: [{isPaidUser: true}, {percentageOfUsers: 0.50}] | ||
}, | ||
newFeatureRollout: { | ||
name: "A New Feature Rollout", | ||
description: "Rollout of that new feature over the next month", | ||
owner: "FredKSchott", // Remember: These are all optional, only criteria is required | ||
criteria: { | ||
isPaidUser: false, | ||
percentageOfUsers: 0.50 | ||
} | ||
} | ||
} | ||
{ | ||
id: 'experimentalFeature', | ||
name: 'An Experimental Feature', // user-defined properties are optional but can be used to add important metadata | ||
description: 'Experimental feature still in development, useful for internal development', // user-defined | ||
owner: 'Fred K. Schott <fkschott@gmail.com>', // user-defined | ||
enabled: false, // sets the feature on or off for all users, required unless `criteria` is present instead | ||
}, | ||
] | ||
``` | ||
##Usage | ||
The value present for each rule is passed in as the data argument to it's criteria function. This allows you to write more general, flexible, reusable rules. | ||
Rule sets & lists can be nested and combined. It can help to think of criteria sets as a group of `AND` operators, and lists as a set of `OR` operators. | ||
#### Veto Criteria | ||
If you'd like to allow wider access to your feature while still preventing a specific group of users, you can use the `$veto` property. If the `$veto` property is present on a member of a criteria list (array), and that member evaluates to false, the entire list will evaluate to false regardless of it's other members. | ||
```javascript | ||
{ | ||
// Enabled if user is paid OR in the lucky 50% group of other users currently using a modern browser | ||
criteria: [{isPaidUser: true}, {percentageOfUsers: 0.50, usingModernBrowser: true}] | ||
// Enabled if user is paid OR in the lucky 50% group of other users, BUT ONLY if using a modern browser | ||
criteria: [{isPaidUser: true}, {percentageOfUsers: 0.50}, {usingModernBrowser: true, $veto: true}] | ||
} | ||
``` | ||
void config(options) // Configure fflip (see below) | ||
Object userFeatures(user) // Return object of true/false for all features for user | ||
Bool userHasFeature(user, featureName) // Return true/false if featureName is enabled for user | ||
void reload() // Force a reload (if loading features dynamically) | ||
void express(app) // Connect with an Express app or router (see below) | ||
``` | ||
Configure __fflip__ using any of the following options: | ||
## Usage | ||
- `.config(options) -> void`: Configure fflip (see below) | ||
- `.userHasFeature(user, featureName) -> boolean`: Return true/false if featureName is enabled for user | ||
- `.userFeatures(user) -> Object`: Return object of true/false for all features for user | ||
- `.reload() -> void`: Force a reload (if loading features dynamically) | ||
- `.express(app) -> void`: Connect with an Express app or router (see below) | ||
### Configuration | ||
Configure fflip using any of the following options: | ||
```javascript | ||
fflip.config({ | ||
criteria: {}, // Criteria Object | ||
features: {}, // Features Object | Function (see below) | ||
criteria: {}, // Criteria Array | ||
features: {}, // Features Array | Function (see below) | ||
reload: 30, // Interval for refreshing features, in seconds | ||
@@ -104,4 +144,7 @@ }); | ||
###Loading Features Dynamically | ||
__fflip__ also accepts functions for loading features. If __fflip__ is passed a function with no arguments it will call the function and accept the return value. To load asynchronously, pass a function that sends a features object to a callback. __fflip__ will receive the callback and set the data accordingly. In both cases, __fflip__ will save the function and call it again every X seconds, as set by the reload parameter. | ||
### Loading Features Dynamically | ||
fflip also accepts functions for loading features. If fflip is passed a function with no arguments it will call the function and accept the return value. To load asynchronously, pass a function that sends a features object to a callback. fflip will receive the callback and set the data accordingly. In both cases, fflip will save the function and call it again every X seconds, as set by the reload parameter. | ||
```javascript | ||
@@ -112,13 +155,13 @@ // Load Criteria Synchronously | ||
var featuresArr = collection.find().toArray(); | ||
/* Process featuresArr -> featuresObj (format described above) */ | ||
return featuresObj; | ||
/* Process/Format `featuresArr` if needed (format described above) */ | ||
return featuresArr; | ||
} | ||
// Load Features Asynchronously | ||
var getFeaturesAsync = function(fflip_callback) { | ||
var getFeaturesAsync = function(callback) { | ||
var collection = db.collection('features'); | ||
collection.find().toArray(function(err, featuresArr) { | ||
/* Handle err | ||
* Process featuresArr -> featuresObj (format described above) */ | ||
fflip_callback(featuresObj); | ||
* Process/Format `featuresArr` if needed (format described above) */ | ||
callback(featuresArr); | ||
}); | ||
@@ -135,13 +178,15 @@ } | ||
##Express Integration | ||
__fflip__ provides easy integration with the popular web framework [Express](https://github.com/visionmedia/express). | ||
Just call ``fflip.express()`` with your Express application or Express 4.0 router to enable the following: | ||
## Express Integration | ||
fflip provides two easy integrations with the popular web framework [Express](http://expressjs.com/). | ||
####A route for manually flipping on/off features | ||
If you have cookies enabled, you can visit ``/fflip/:name/:action`` to manually override a feature to always return true/false for your own session. Just replace ':name' with the Feature name and ':action' with 1 to enable, 0 to disable, or -1 to reset (remove the cookie override). This override is stored as in the user's cookie under the name `fflip`. | ||
#### fflip.expressMiddleware() | ||
####req.fflip | ||
A __fflip__ object is attached to the request, and includes the following functionality: | ||
```javascript | ||
app.use(fflip.expressMiddleware); | ||
``` | ||
**req.fflip:** A special fflip request object is attached to the request object, and includes the following functionality: | ||
``` | ||
req.fflip = { | ||
@@ -153,18 +198,28 @@ setForUser(user): Given a user, attaches the features object to the request (at req.fflip.features). Make sure you do this before calling has()! | ||
####Use fflip in your templates | ||
*NOTE: This will only be populated if you call `req.fflip.setForUser` beforehand.* | ||
The __fflip__ Express middleware includes a `Features` template variable that contains your user's enabled features. Here is an example of how to use it with Handlebars: `{{#if Features.closedBeta}} Welcome to the Beta! {{/if}}` | ||
**Use fflip in your templates:** Once `setForUser()` has been called, fflip will include a `Features` template variable that contains your user's enabled features. Here is an example of how to use it with Handlebars: `{{#if Features.closedBeta}} Welcome to the Beta! {{/if}}` | ||
####Use fflip on the client | ||
*NOTE: This will only be populated if you call `req.fflip.setForUser` beforehand.* | ||
The __fflip__ Express middleware also includes a `FeaturesJSON` template variable that is the JSON string of your user's enabled features. To deliver this down to the client, just make sure your template something like this: ``<script>var Features = {{ FeaturesJSON }}; </script>``. | ||
**Use fflip on the client:** Once `setForUser()` has been called, fflip will also include a `FeaturesJSON` template variable that is the JSON string of your user's enabled features. To deliver this down to the client, just make sure your template something like this: `<script>var Features = {{ FeaturesJSON }}; </script>`. | ||
####Low-level integration | ||
If you need a finer-grained Express integration, such as changing the URL for manual overrides, adding security middleware, or applying middleware on a subset of routes, you can use ``express_middleware`` and ``express_route`` directly. | ||
#### fflip.expressRoute() | ||
```javascript | ||
// Feel free to use any route you'd like, as long as `name` & `action` exist as route parameters. | ||
app.get('/custom_path/:name/:action', fflip.expressRoute); | ||
``` | ||
app.use(fflip.express_middleware); | ||
app.get('/custom_path/:name/:action', fflip.express_route); | ||
**A route for manually flipping on/off features:** If you have cookies enabled, you can visit this route to manually override a feature to always return true/false for your own session. Just replace ':name' with the Feature name and ':action' with 1 to enable, 0 to disable, or -1 to reset (remove the cookie override). This override is stored in the user's cookie under the name `fflip`, which is then read by `fflip.expressMiddleware()` and `req.fflip` automatically. | ||
#### fflip.express() | ||
Sets up the express middleware and route automatically. Equivilent to running: | ||
```javascript | ||
app.use(fflip.expressMiddleware); | ||
app.get('/custom_path/:name/:action', fflip.expressRoute); | ||
``` | ||
##Special Thanks | ||
## Special Thanks | ||
Original logo designed by <a href="http://thenounproject.com/Luboš Volkov" target="_blank">Luboš Volkov</a> |
@@ -21,3 +21,3 @@ 'use strict'; | ||
return true; | ||
}; | ||
} | ||
@@ -28,13 +28,22 @@ var sandbox = sinon.sandbox.create(); | ||
var configData = { | ||
criteria: { | ||
c1: function(user, bool) { | ||
return bool; | ||
criteria: [ | ||
{ | ||
id: 'c1', | ||
check: function(user, bool) { | ||
return bool; | ||
} | ||
}, | ||
c2: function(user, flag) { | ||
return user.flag == flag; | ||
{ | ||
id: 'c2', | ||
check: function(user, flag) { | ||
return user.flag == flag; | ||
} | ||
} | ||
}, | ||
features: { | ||
fEmpty: {}, | ||
fOpen: { | ||
], | ||
features: [ | ||
{ | ||
id: 'fEmpty' | ||
}, | ||
{ | ||
id: 'fOpen', | ||
name: 'fOpen', | ||
@@ -46,3 +55,4 @@ description: 'true for all users', | ||
}, | ||
fClosed: { | ||
{ | ||
id: 'fClosed', | ||
criteria: { | ||
@@ -52,8 +62,34 @@ c1: false | ||
}, | ||
fEval: { | ||
{ | ||
id: 'fEval', | ||
criteria: { | ||
c1: true, | ||
c2: 'abc' | ||
} | ||
}, | ||
{ | ||
id: 'fEvalOr', | ||
criteria: [ | ||
{c1: false}, | ||
{c2: 'abc'}, | ||
{c2: 'efg'} | ||
] | ||
}, | ||
{ | ||
id: 'fEvalComplex', | ||
criteria: [ | ||
{c1: false, c2: 'abc'}, | ||
{c1: true, c2: 'abc'}, | ||
[{c1: false, c2: 'xyz'}, {c1: true, c2: 'efg'}] | ||
] | ||
}, | ||
{ | ||
id: 'fEvalVeto', | ||
criteria: [ | ||
{c1: false}, | ||
{c2: 'abc'}, | ||
{c2: 'efg', $veto: true} | ||
] | ||
} | ||
}, | ||
], | ||
reload: 0 | ||
@@ -65,2 +101,5 @@ }; | ||
}; | ||
var userEFG = { | ||
flag: 'efg' | ||
}; | ||
var userXYZ = { | ||
@@ -82,6 +121,14 @@ flag: 'xyz' | ||
it('should set features if given static feature object', function(){ | ||
it('should set features if given static feature array', function(){ | ||
fflip._features = {}; | ||
fflip.config(configData); | ||
assert.equal(configData.features, fflip._features); | ||
assert.deepEqual(fflip._features, { | ||
fEmpty: configData.features[0], | ||
fOpen: configData.features[1], | ||
fClosed: configData.features[2], | ||
fEval: configData.features[3], | ||
fEvalOr: configData.features[4], | ||
fEvalComplex: configData.features[5], | ||
fEvalVeto: configData.features[6] | ||
}); | ||
}); | ||
@@ -93,4 +140,13 @@ | ||
}; | ||
fflip.config({features: loadSyncronously}); | ||
assert.equal(configData.features, fflip._features); | ||
fflip._features = {}; | ||
fflip.config({features: loadSyncronously, criteria: configData.criteria}); | ||
assert.deepEqual(fflip._features, { | ||
fEmpty: configData.features[0], | ||
fOpen: configData.features[1], | ||
fClosed: configData.features[2], | ||
fEval: configData.features[3], | ||
fEvalOr: configData.features[4], | ||
fEvalComplex: configData.features[5], | ||
fEvalVeto: configData.features[6] | ||
}); | ||
}); | ||
@@ -101,12 +157,23 @@ | ||
callback(configData.features); | ||
assert.equal(configData.features, fflip._features); | ||
assert.deepEqual(fflip._features, { | ||
fEmpty: configData.features[0], | ||
fOpen: configData.features[1], | ||
fClosed: configData.features[2], | ||
fEval: configData.features[3], | ||
fEvalOr: configData.features[4], | ||
fEvalComplex: configData.features[5], | ||
fEvalVeto: configData.features[6] | ||
}); | ||
done(); | ||
}; | ||
fflip.config({features: loadAsyncronously}); | ||
fflip.config({features: loadAsyncronously, criteria: configData.criteria}); | ||
}); | ||
it('should set criteria if given static criteria object', function(){ | ||
it('should set criteria if given static criteria array', function(){ | ||
fflip._criteria = {}; | ||
fflip.config(configData); | ||
assert.equal(configData.criteria, fflip._criteria); | ||
assert.deepEqual(fflip._criteria, { | ||
c1: configData.criteria[0].check, | ||
c2: configData.criteria[1].check | ||
}); | ||
}); | ||
@@ -122,3 +189,4 @@ | ||
describe('reload()', function(){ | ||
describe('reload()', function() { | ||
beforeEach(function() { | ||
@@ -134,3 +202,3 @@ | ||
}; | ||
fflip.config({features: loadAsyncronously, reload: 0.2}); | ||
fflip.config({features: loadAsyncronously, reload: 0.2, criteria: configData.criteria}); | ||
}); | ||
@@ -146,3 +214,3 @@ | ||
}; | ||
fflip.config({features: loadAsyncronously}); | ||
fflip.config({features: loadAsyncronously, criteria: configData.criteria}); | ||
testReady = true; | ||
@@ -154,3 +222,3 @@ fflip.reload(); | ||
describe('userHasFeature()', function(){ | ||
describe('userHasFeature()', function() { | ||
@@ -169,2 +237,4 @@ beforeEach(function() { | ||
// TODO(fks) 03-14-2016: (Edge Case) Test that an empty criteria object disables a feature | ||
it('should return false if all feature critieria evaluates to false', function(){ | ||
@@ -175,7 +245,31 @@ assert.equal(false, fflip.userHasFeature(userABC, 'fClosed')); | ||
it('should return true if one feature critieria evaluates to true', function(){ | ||
assert.equal(true, fflip.userHasFeature(userABC, 'fOpen')); | ||
it('should return false if one feature critieria evaluates to true and the other evaluates to false', function(){ | ||
assert.equal(false, fflip.userHasFeature(userXYZ, 'fEval')); | ||
}); | ||
it('should return true if all feature critieria evaluates to true', function(){ | ||
assert.equal(true, fflip.userHasFeature(userABC, 'fEval')); | ||
}); | ||
it('should return false if zero feature critieria evaluates to true', function(){ | ||
assert.equal(false, fflip.userHasFeature(userXYZ, 'fEvalOr')); | ||
}); | ||
it('should return true if one feature critieria evaluates to true', function(){ | ||
assert.equal(true, fflip.userHasFeature(userABC, 'fEvalOr')); | ||
}); | ||
it('should handle nested arrays', function(){ | ||
assert.equal(true, fflip.userHasFeature(userABC, 'fEvalComplex')); | ||
assert.equal(true, fflip.userHasFeature(userEFG, 'fEvalComplex')); | ||
assert.equal(false, fflip.userHasFeature(userXYZ, 'fEvalComplex')); | ||
}); | ||
it('should handle the $veto property', function(){ | ||
assert.equal(false, fflip.userHasFeature(userABC, 'fEvalVeto')); | ||
assert.equal(true, fflip.userHasFeature(userEFG, 'fEvalVeto')); | ||
assert.equal(false, fflip.userHasFeature(userXYZ, 'fEvalVeto')); | ||
}); | ||
}); | ||
@@ -191,6 +285,33 @@ | ||
var featuresABC = fflip.userFeatures(userABC); | ||
assert.equal(featuresABC.fEmpty, false); | ||
assert.equal(featuresABC.fOpen, true); | ||
assert.equal(featuresABC.fClosed, false); | ||
assert.equal(featuresABC.fEval, true); | ||
assert.deepEqual(featuresABC, { | ||
fEmpty: false, | ||
fOpen: true, | ||
fClosed: false, | ||
fEval: true, | ||
fEvalOr: true, | ||
fEvalComplex: true, | ||
fEvalVeto: false | ||
}); | ||
var featuresEFG = fflip.userFeatures(userEFG); | ||
assert.deepEqual(featuresEFG, { | ||
fEmpty: false, | ||
fOpen: true, | ||
fClosed: false, | ||
fEval: false, | ||
fEvalOr: true, | ||
fEvalComplex: true, | ||
fEvalVeto: true | ||
}); | ||
var featuresXYZ = fflip.userFeatures(userXYZ); | ||
assert.deepEqual(featuresXYZ, { | ||
fEmpty: false, | ||
fOpen: true, | ||
fClosed: false, | ||
fEval: false, | ||
fEvalOr: false, | ||
fEvalComplex: false, | ||
fEvalVeto: false | ||
}); | ||
}); | ||
@@ -229,3 +350,3 @@ | ||
var me = this; | ||
fflip.express_middleware(this.reqMock, this.resMock, function() { | ||
fflip.expressMiddleware(this.reqMock, this.resMock, function() { | ||
assert(me.reqMock.fflip); | ||
@@ -239,3 +360,3 @@ assert(me.reqMock.fflip._flags, me.reqMock.cookies.fflip); | ||
var me = this; | ||
fflip.express_middleware(this.reqMock, this.resMock, function() { | ||
fflip.expressMiddleware(this.reqMock, this.resMock, function() { | ||
assert.doesNotThrow(function() { | ||
@@ -250,3 +371,3 @@ me.resMock.render('testview'); | ||
var me = this; | ||
fflip.express_middleware(this.reqMock, this.resMock, function() { | ||
fflip.expressMiddleware(this.reqMock, this.resMock, function() { | ||
var features = {features : { fClosed: true }}; | ||
@@ -270,3 +391,3 @@ var featuresString = JSON.stringify(features); | ||
var spy = sandbox.spy(fflip, 'userFeatures'); | ||
fflip.express_middleware(this.reqMock, this.resMock, function() { | ||
fflip.expressMiddleware(this.reqMock, this.resMock, function() { | ||
me.reqMock.fflip.setForUser(userXYZ); | ||
@@ -282,3 +403,3 @@ assert(fflip.userFeatures.calledOnce); | ||
var me = this; | ||
fflip.express_middleware(this.reqMock, this.resMock, function() { | ||
fflip.expressMiddleware(this.reqMock, this.resMock, function() { | ||
me.reqMock.fflip.setForUser(userXYZ); | ||
@@ -295,3 +416,3 @@ assert.strictEqual(me.reqMock.fflip.has('fOpen'), true); | ||
assert.throws(function() { | ||
fflip.express_middleware(this.reqMock, this.resMock, function() { | ||
fflip.expressMiddleware(this.reqMock, this.resMock, function() { | ||
me.reqMock.fflip.has('fOpen'); | ||
@@ -305,3 +426,3 @@ }); | ||
var consoleErrorStub = sandbox.stub(console, 'error'); // Supress Error Output | ||
fflip.express_middleware(this.reqMock, this.resMock, function() { | ||
fflip.expressMiddleware(this.reqMock, this.resMock, function() { | ||
assert.ok(isObjectEmpty(me.reqMock.fflip.features)); | ||
@@ -315,3 +436,3 @@ done(); | ||
fflip.express(this.appMock); | ||
assert.ok(this.appMock.use.calledWith(fflip.express_middleware)); | ||
assert.ok(this.appMock.use.calledWith(fflip.expressMiddleware)); | ||
}); | ||
@@ -321,3 +442,3 @@ | ||
fflip.express(this.appMock); | ||
assert.ok(this.appMock.get.calledWith('/fflip/:name/:action', fflip.express_route)); | ||
assert.ok(this.appMock.get.calledWith('/fflip/:name/:action', fflip.expressRoute)); | ||
}); | ||
@@ -344,5 +465,4 @@ | ||
it('should propogate a 404 error if feature does not exist', function(done) { | ||
var next = sandbox.stub(); | ||
this.reqMock.params.name = 'doesnotexist'; | ||
fflip.express_route(this.reqMock, this.resMock, function(err) { | ||
fflip.expressRoute(this.reqMock, this.resMock, function(err) { | ||
assert(err); | ||
@@ -356,5 +476,4 @@ assert(err.fflip); | ||
it('should propogate a 500 error if cookies are not enabled', function(done) { | ||
var next = sandbox.stub(); | ||
this.reqMock.cookies = null; | ||
fflip.express_route(this.reqMock, this.resMock, function(err) { | ||
fflip.expressRoute(this.reqMock, this.resMock, function(err) { | ||
assert(err); | ||
@@ -368,3 +487,3 @@ assert(err.fflip); | ||
it('should set the right cookie flags', function() { | ||
fflip.express_route(this.reqMock, this.resMock); | ||
fflip.expressRoute(this.reqMock, this.resMock); | ||
assert(this.resMock.cookie.calledWithMatch('fflip', {fClosed: true}, { maxAge: 900000 })); | ||
@@ -377,3 +496,3 @@ }); | ||
fflip.maxCookieAge = oneMonthMs; | ||
fflip.express_route(this.reqMock, this.resMock); | ||
fflip.expressRoute(this.reqMock, this.resMock); | ||
fflip.maxCookieAge = oldMaxCookieAge; | ||
@@ -385,3 +504,3 @@ | ||
it('should send back 200 json response on successful call', function() { | ||
fflip.express_route(this.reqMock, this.resMock); | ||
fflip.expressRoute(this.reqMock, this.resMock); | ||
assert(this.resMock.json.calledWith(200)); | ||
@@ -413,3 +532,3 @@ }); | ||
// it('should call res.cookie() on successful request', function() { | ||
// self.express_route(this.reqMock, this.resMock); | ||
// self.expressRoute(this.reqMock, this.resMock); | ||
// assert(res.cookie.calledWith('fflip')); | ||
@@ -416,0 +535,0 @@ // }); |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
40258
13
927
218
7