Commit 6d10a5eb authored by Robert Knight's avatar Robert Knight

Consolidate /app and /app/features endpoints

Experience has taught us that the client needs the session
data (current user, list of groups) and set of enabled feature
flags at the same time, and also needs to invalidate them
in the same scenarios (eg. account switching).

Fetching this data via two separate requests made it more complicated
to ensure the client had a consistent view of session and feature-flag
data. To simplify things and also same a network request:

 * On the server, put the feature flag data into the session data payload.

 * On the client, use the existing central storage and cache management
   for the session data payload to manage feature flags as well.

   The features service now becomes a thin wrapper around part of
   the session state.
parent 86256af9
...@@ -6,9 +6,6 @@ require('angular-jwt') ...@@ -6,9 +6,6 @@ require('angular-jwt')
streamer = require('./streamer') streamer = require('./streamer')
resolve = resolve =
# Ensure that we have feature flags available before we load the main
# view as features such as groups affect which annotations are loaded
featuresLoaded: ['features', (features) -> features.fetch()]
# Ensure that we have available a) the current authenticated userid, and b) # Ensure that we have available a) the current authenticated userid, and b)
# the list of user groups. # the list of user groups.
sessionState: ['session', (session) -> session.load().$promise] sessionState: ['session', (session) -> session.load().$promise]
...@@ -81,8 +78,6 @@ setupHttp = ['$http', ($http) -> ...@@ -81,8 +78,6 @@ setupHttp = ['$http', ($http) ->
setupHost = ['host', (host) -> ] setupHost = ['host', (host) -> ]
setupFeatures = ['features', (features) -> features.fetch()]
module.exports = angular.module('h', [ module.exports = angular.module('h', [
'angulartics' 'angulartics'
'angulartics.google.analytics' 'angulartics.google.analytics'
...@@ -170,7 +165,6 @@ module.exports = angular.module('h', [ ...@@ -170,7 +165,6 @@ module.exports = angular.module('h', [
.config(configureRoutes) .config(configureRoutes)
.config(configureTemplates) .config(configureTemplates)
.run(setupFeatures)
.run(setupCrossFrame) .run(setupCrossFrame)
.run(setupHttp) .run(setupHttp)
.run(setupHost) .run(setupHost)
/** /**
* Feature flag client. * Provides access to feature flag states for the current
* Hypothesis user.
* *
* This is a small utility which will periodically retrieve the application * This service is a thin wrapper around the feature flag data in
* feature flags from a JSON endpoint in order to expose these to the * the session state.
* client-side application.
*
* All feature flags implicitly start toggled off. When `flagEnabled` is first
* called (or alternatively when `fetch` is called explicitly) an XMLHTTPRequest
* will be made to retrieve the current feature flag values from the server.
* Once these are retrieved, `flagEnabled` will return current values.
*
* If `flagEnabled` is called and the cache is more than `CACHE_TTL`
* milliseconds old, then it will trigger a new fetch of the feature flag
* values. Note that this is again done asynchronously, so it is only later
* calls to `flagEnabled` that will return the updated values.
* *
* Users of this service should assume that the value of any given flag can * Users of this service should assume that the value of any given flag can
* change at any time and should write code accordingly. Feature flags should * change at any time and should write code accordingly. Feature flags should
...@@ -21,70 +11,35 @@ ...@@ -21,70 +11,35 @@
*/ */
'use strict'; 'use strict';
var assign = require('core-js/modules/$.object-assign');
var events = require('./events');
var CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// @ngInject // @ngInject
function features ($document, $http, $log, $rootScope) { function features($log, session) {
var cache = null; /**
var featuresUrl = new URL('/app/features', $document.prop('baseURI')).href; * Returns true if the flag with the given name is enabled for the current
var fetchOperation; * user.
*
$rootScope.$on(events.USER_CHANGED, function () { * Returns false if session data has not been fetched for the current
cache = null; * user yet or if the feature flag name is unknown.
}); */
function flagEnabled(flag) {
function fetch() { // trigger a refresh of session data, if it has not been
if (fetchOperation) { // refetched within a cache timeout managed by the session service
// fetch already in progress // (see CACHE_TTL in session.js)
return fetchOperation; session.load();
}
if (!session.state.features) {
fetchOperation = $http.get(featuresUrl).then(function (response) { // features data has not yet been fetched
cache = {
updated: Date.now(),
flags: response.data,
};
}).catch(function (err) {
// if for any reason fetching features fails, we behave as
// if all flags are turned off
$log.warn('failed to fetch feature data', err);
cache = assign({}, cache, {
updated: Date.now(),
});
}).finally(function () {
fetchOperation = null;
});
return fetchOperation;
}
function flagEnabled(name) {
// Trigger a fetch if the cache is more than CACHE_TTL milliseconds old.
// We don't wait for the fetch to complete, so it's not this call that
// will see new data.
if (!cache || (Date.now() - cache.updated) > CACHE_TTL) {
fetch();
}
if (!cache || !cache.flags) {
// a fetch is either in progress or fetching the feature flags
// failed
return false; return false;
} }
if (!cache.flags.hasOwnProperty(name)) { var features = session.state.features;
$log.warn('features service: looked up unknown feature:', name); if (!(flag in features)) {
$log.warn('looked up unknown feature', flag);
return false; return false;
} }
return cache.flags[name]; return features[flag];
} }
return { return {
fetch: fetch,
flagEnabled: flagEnabled flagEnabled: flagEnabled
}; };
} }
......
'use strict'; 'use strict';
var mock = angular.mock; var features = require('../features');
var events = require('../events');
describe('h:features', function () { describe('h:features', function () {
var $httpBackend;
var $rootScope;
var features;
var sandbox;
before(function () {
angular.module('h', [])
.service('features', require('../features'));
});
beforeEach(mock.module('h'));
beforeEach(mock.module(function ($provide) { var fakeLog;
sandbox = sinon.sandbox.create(); var fakeSession;
var fakeDocument = { beforeEach(function () {
prop: sandbox.stub() fakeLog = {
warn: sinon.stub(),
};
fakeSession = {
load: sinon.stub(),
state: {
features: {
'feature_on': true,
'feature_off': false,
},
},
}; };
fakeDocument.prop.withArgs('baseURI').returns('http://foo.com/');
$provide.value('$document', fakeDocument);
}));
beforeEach(mock.inject(function ($injector) {
$httpBackend = $injector.get('$httpBackend');
$rootScope = $injector.get('$rootScope');
features = $injector.get('features');
}));
afterEach(function () {
$httpBackend.verifyNoOutstandingExpectation();
$httpBackend.verifyNoOutstandingRequest();
sandbox.restore();
});
function defaultHandler() {
var handler = $httpBackend.expect('GET', 'http://foo.com/app/features');
handler.respond(200, {foo: true, bar: false});
return handler;
}
describe('fetch', function() {
it('should retrieve features data', function () {
defaultHandler();
features.fetch();
$httpBackend.flush();
assert.equal(features.flagEnabled('foo'), true);
});
it('should return a promise', function () {
defaultHandler();
features.fetch().then(function () {
assert.equal(features.flagEnabled('foo'), true);
});
$httpBackend.flush();
});
it('should not explode for errors fetching features data', function () {
defaultHandler().respond(500, "ASPLODE!");
var handler = sinon.stub();
features.fetch().then(handler);
$httpBackend.flush();
assert.calledOnce(handler);
});
it('should only send one request at a time', function () {
defaultHandler();
features.fetch();
features.fetch();
$httpBackend.flush();
});
}); });
describe('flagEnabled', function () { describe('flagEnabled', function () {
it('should retrieve features data', function () { it('should retrieve features data', function () {
defaultHandler(); var features_ = features(fakeLog, fakeSession);
features.flagEnabled('foo'); assert.equal(features_.flagEnabled('feature_on'), true);
$httpBackend.flush(); assert.equal(features_.flagEnabled('feature_off'), false);
}); });
it('should return false initially', function () { it('should return false if features have not been loaded', function () {
defaultHandler(); var features_ = features(fakeLog, fakeSession);
var result = features.flagEnabled('foo'); // simulate feature data not having been loaded yet
$httpBackend.flush(); fakeSession.state = {};
assert.equal(features_.flagEnabled('feature_on'), false);
assert.isFalse(result);
}); });
it('should return flag values when data is loaded', function () { it('should trigger a refresh of session data', function () {
defaultHandler(); var features_ = features(fakeLog, fakeSession);
features.fetch(); features_.flagEnabled('feature_on');
$httpBackend.flush(); assert.calledOnce(fakeSession.load);
var foo = features.flagEnabled('foo');
assert.isTrue(foo);
var bar = features.flagEnabled('bar');
assert.isFalse(bar);
}); });
it('should return false for unknown flags', function () { it('should return false for unknown flags', function () {
defaultHandler(); var features_ = features(fakeLog, fakeSession);
features.fetch(); assert.isFalse(features_.flagEnabled('unknown_feature'));
$httpBackend.flush();
var baz = features.flagEnabled('baz');
assert.isFalse(baz);
});
it('should trigger a new fetch after cache expiry', function () {
var clock = sandbox.useFakeTimers();
defaultHandler();
features.flagEnabled('foo');
$httpBackend.flush();
clock.tick(301 * 1000);
defaultHandler();
features.flagEnabled('foo');
$httpBackend.flush();
});
it('should clear the features data when the user changes', function () {
// fetch features and check that the flag is set
defaultHandler();
features.fetch();
$httpBackend.flush();
assert.isTrue(features.flagEnabled('foo'));
// simulate a change of logged-in user which should clear
// the features cache
$rootScope.$broadcast(events.USER_CHANGED, {});
defaultHandler();
assert.isFalse(features.flagEnabled('foo'));
$httpBackend.flush();
}); });
}); });
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment