Skip to content

Commit 7f96d2f

Browse files
committed
Add caching, failover caching, and user id strategy
1 parent 3f98186 commit 7f96d2f

File tree

10 files changed

+227
-25
lines changed

10 files changed

+227
-25
lines changed

ModuleConfig.cfc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ component {
1010
"environment": variables.controller.getSetting( "environment" ),
1111
"contextProvider": "DefaultContextProvider@unleashsdk",
1212
"apiURL": getSystemSetting( "UNLEASH_API_URL" ),
13-
"apiToken": getSystemSetting( "UNLEASH_API_TOKEN" )
13+
"apiToken": getSystemSetting( "UNLEASH_API_TOKEN" ),
14+
"cacheTimeout": createTimeSpan( 0, 0, 0, 1 )
1415
};
1516
}
1617

docker-compose.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ services:
99
expose:
1010
- "61442"
1111
environment:
12+
APP_DIR: /unleashsdk
1213
PORT: 61442
1314
BOX_SERVER_PROFILE: development
1415
UNLEASH_API_URL: http://unleash:4242/api
1516
volumes:
16-
- .:/app
17+
- .:/unleashsdk
1718

1819
unleash:
1920
image: unleashorg/unleash-server:4.0.10

models/DefaultContextProvider.cfc

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
component {
2+
3+
property name="environment" inject="coldbox:setting:environment";
4+
property name="event" inject="coldbox:requestContext";
5+
6+
public struct function getContext() {
7+
return {
8+
"appName": getApplicationName(),
9+
"environment": variables.environment,
10+
"userId": "",
11+
"sessionId": getSessionId(),
12+
"remoteAddress": CGI.REMOTE_ADDR
13+
};
14+
}
15+
16+
private string function getApplicationName() {
17+
try {
18+
return getApplicationSettings().name;
19+
} catch ( any e ) {
20+
return "";
21+
}
22+
}
23+
24+
private function getSessionId() {
25+
try {
26+
return session.sessionid;
27+
} catch ( any e ) {
28+
return "";
29+
}
30+
}
31+
32+
}

models/UnleashSDK.cfc

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
component singleton accessors="true" {
22

3+
property name="settings" inject="coldbox:moduleSettings:unleashsdk";
34
property name="client" inject="UnleashHyperClient@unleashsdk";
4-
property name="cache" inject="cachebox:default";
55
property name="log" inject="logbox:logger:{this}";
6+
property name="cache" inject="cachebox:default";
7+
property name="wirebox" inject="wirebox";
68

79
variables.strategies = {
8-
"default": "DefaultStrategy",
9-
"userWithId": "UserWithIdStrategy",
10-
"flexibleRollout": "FlexibleRolloutStrategy",
11-
"remoteAddress": "RemoteAddressStrategy",
12-
"applicationHostname": "ApplicationHostnameStrategy"
10+
"default": "DefaultStrategy@unleashsdk",
11+
"userWithId": "UserWithIdStrategy@unleashsdk",
12+
"flexibleRollout": "FlexibleRolloutStrategy@unleashsdk",
13+
"remoteAddress": "RemoteAddressStrategy@unleashsdk",
14+
"applicationHostname": "ApplicationHostnameStrategy@unleashsdk"
1315
};
1416

1517
public boolean function isEnabled( required string name, boolean defaultValue = false ) {
16-
var feature = findFeature( arguments.name );
18+
var feature = getFeature( arguments.name );
1719
if ( isNull( feature ) ) {
1820
return arguments.defaultValue;
1921
}
@@ -29,27 +31,31 @@ component singleton accessors="true" {
2931
}
3032

3133
param strategyData.constraints = [];
32-
if ( !strategy.satisfiesConstraints( strategyData.constraints ) ) {
34+
if ( !strategy.satisfiesConstraints( strategyData.constraints, getContext() ) ) {
3335
continue;
3436
}
3537

3638
param strategyData.parameters = {};
37-
if ( !strategy.isEnabled( strategyData.parameters ) ) {
39+
if ( !strategy.isEnabled( strategyData.parameters, getContext() ) ) {
3840
return false;
3941
}
4042
}
4143

4244
return true;
4345
}
4446

47+
public boolean function isDisabled( required string name, boolean defaultValue = false ) {
48+
return !isEnabled( argumentCollection = arguments );
49+
}
50+
4551
private any function getStrategy( required string name ) {
4652
if ( !variables.strategies.keyExists( arguments.name ) ) {
4753
log.warn( "No Unleash strategy found for [#arguments.name#]" );
4854
return javacast( "null", "" );
4955
}
5056

5157
if ( isSimpleValue( variables.strategies[ arguments.name ] ) ) {
52-
variables.strategies[ arguments.name ] = new "unleashsdk.models.strategies.#variables.strategies[ arguments.name ]#"();
58+
variables.strategies[ arguments.name ] = wirebox.getInstance( variables.strategies[ arguments.name ] );
5359
}
5460

5561
return variables.strategies[ arguments.name ];
@@ -81,29 +87,52 @@ component singleton accessors="true" {
8187
boolean enabled = true,
8288
array strategies = []
8389
) {
84-
var feature = findFeature( arguments.name );
90+
var feature = getFeature( arguments.name );
8591
if ( !isNull( feature ) ) {
8692
return feature;
8793
}
8894
return createFeature( argumentCollection = arguments );
8995
}
9096

91-
private any function findFeature( required string name ) {
92-
return arrayFindFirst( fetchFeatures(), function( feature ) {
97+
public any function getFeature( required string name ) {
98+
return arrayFindFirst( getFeatures(), function( feature ) {
9399
return feature.name == name;
94100
} );
95101
}
96102

97103
public array function getFeatures() {
98-
return cache.getOrSet( "unleashsdk-features", function() {
99-
return fetchFeatures();
100-
}, createTimespan( 0, 0, 10, 0 ) );
104+
try {
105+
return cache.getOrSet( "unleashsdk-features", function() {
106+
var features = fetchFeatures();
107+
cache.set( "unleashsdk-failover", features, 0 );
108+
return features;
109+
}, variables.settings.cacheTimeout );
110+
} catch ( any e ) {
111+
if ( log.canError() ) {
112+
log.error( "Exception occurred while retrieving Unleash features. Using failover", e );
113+
}
114+
var features = cache.get( "unleashsdk-failover" );
115+
if ( isNull( features ) ) {
116+
return [];
117+
}
118+
return features;
119+
}
101120
}
102121

103122
private array function fetchFeatures() {
104123
return variables.client.get( "/client/features" ).json().features;
105124
}
106125

126+
private struct function getContext() {
127+
param request.unleashContext = generateContext();
128+
return request.unleashContext;
129+
}
130+
131+
private struct function generateContext() {
132+
var contextProvider = wirebox.getInstance( variables.settings.contextProvider );
133+
return contextProvider.getContext();
134+
}
135+
107136
private any function arrayFindFirst( required array items, required function predicate ) {
108137
for ( var item in arguments.items ) {
109138
if ( arguments.predicate( item ) ) {

models/strategies/DefaultStrategy.cfc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
component implements="IStrategy" {
1+
component implements="IStrategy" singleton {
22

3-
public boolean function satisfiesConstraints( array constraints ) {
3+
public boolean function satisfiesConstraints( array constraints, struct context ) {
44
return true; // spike
55
}
66

7-
public boolean function isEnabled( struct parameters ) {
7+
public boolean function isEnabled( struct parameters, struct context ) {
88
return true;
99
}
1010

models/strategies/IStrategy.cfc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
interface {
22

3-
public boolean function satisfiesConstraints( array constraints );
3+
public boolean function satisfiesConstraints( array constraints, struct context );
44

5-
public boolean function isEnabled( struct parameters );
5+
public boolean function isEnabled( struct parameters, struct context );
66

77
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
component implements="IStrategy" singleton {
2+
3+
public boolean function satisfiesConstraints( array constraints, struct context ) {
4+
return true; // spike
5+
}
6+
7+
public boolean function isEnabled( struct parameters, struct context ) {
8+
if ( !arguments.parameters.keyExists( "userIds" ) ) {
9+
return true;
10+
}
11+
return listContains( arguments.parameters.userIds, arguments.context.userId, ", " );
12+
}
13+
14+
}

tests/resources/ModuleIntegrationSpec.cfc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/app" {
44
super.beforeAll();
55

66
getController().getModuleService()
7-
.registerAndActivateModule( "app", "testingModuleRoot" );
7+
.registerAndActivateModule( "unleashsdk", "testingModuleRoot" );
88

99
getWireBox().autowire( this );
1010
}

tests/specs/integration/UnleashSpec.cfc

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
component extends="tests.resources.ModuleIntegrationSpec" {
22

3-
property name="cache" inject="cachebox:default"
3+
property name="cache" inject="cachebox:default";
44

55
function beforeAll() {
66
super.beforeAll();
@@ -44,6 +44,37 @@ component extends="tests.resources.ModuleIntegrationSpec" {
4444
expect( unleash.isEnabled( "feature-2" ) ).toBeFalse();
4545
} );
4646

47+
it( "can check if a feature is disabled", function() {
48+
unleash.ensureFeatureExists(
49+
name = "feature-1",
50+
description = "A test feature flag that is enabled",
51+
type = "release",
52+
enabled = true,
53+
strategies = [
54+
{
55+
"name": "default",
56+
"parameters": {}
57+
}
58+
]
59+
);
60+
61+
unleash.ensureFeatureExists(
62+
name = "feature-2",
63+
description = "A test feature flag that is disabled",
64+
type = "release",
65+
enabled = false,
66+
strategies = [
67+
{
68+
"name": "default",
69+
"parameters": {}
70+
}
71+
]
72+
);
73+
74+
expect( unleash.isDisabled( "feature-1" ) ).toBeFalse();
75+
expect( unleash.isDisabled( "feature-2" ) ).toBeTrue();
76+
} );
77+
4778
it( "can return all features", function() {
4879
unleash.ensureFeatureExists(
4980
name = "feature-1",
@@ -70,6 +101,46 @@ component extends="tests.resources.ModuleIntegrationSpec" {
70101
unleash.getFeatures();
71102
expect( hyper.$count( "new" ) ).toBe( 1, "The UnleashHyperClient should only be used once." );
72103
} );
104+
105+
it( "stores a failover cache", function() {
106+
cache.clear( "unleashsdk-features" );
107+
cache.clear( "unleashsdk-failover" );
108+
expect( cache.get( "unleashsdk-failover" ) ).toBeNull();
109+
var features = unleash.getFeatures();
110+
expect( cache.get( "unleashsdk-failover" ) ).notToBeNull().toBe( features );
111+
} );
112+
113+
it( "uses the failover cache if there are any issues retrieving the features", function() {
114+
cache.clear( "unleashsdk-features" );
115+
cache.clear( "unleashsdk-failover" );
116+
unleash.getFeatures();
117+
var fallbackFeatures = cache.get( "unleashsdk-failover" );
118+
expect( fallbackFeatures ).notToBeNull();
119+
120+
cache.clear( "unleashsdk-features" );
121+
var hyper = prepareMock( getInstance( "UnleashHyperClient@unleashsdk" ) );
122+
hyper.$( method = "new", callback = function() {
123+
throw( "Something went wrong with the HTTP request to Unleash for the test!" );
124+
} );
125+
126+
var features = unleash.getFeatures();
127+
expect( features ).toBe( fallbackFeatures );
128+
} );
129+
130+
// this test currently doesn't work with CacheBox's default settings
131+
// because it only reaps the cache every 5 minutes
132+
// and it doesn't clear timed out items on a `get`.
133+
xit( "caches the features within a timeout", function() {
134+
cache.clear( "unleashsdk-features" );
135+
var hyper = prepareMock( getInstance( "UnleashHyperClient@unleashsdk" ) );
136+
var newRequest = hyper.new();
137+
hyper.$( "new", newRequest );
138+
unleash.getFeatures();
139+
unleash.getFeatures();
140+
sleep( 5 * 1000 ); // default timeout is 11 seconds
141+
unleash.getFeatures();
142+
expect( hyper.$count( "new" ) ).toBe( 2, "The UnleashHyperClient should be used twice." );
143+
} );
73144
} );
74145
}
75146

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
component extends="tests.resources.ModuleIntegrationSpec" {
2+
3+
property name="cache" inject="cachebox:default";
4+
5+
function beforeAll() {
6+
super.beforeAll();
7+
variables.unleash = getInstance( "UnleashSDK@unleashsdk" );
8+
variables.strategy = getInstance( "UserWithIdStrategy@unleashsdk" );
9+
}
10+
11+
function run() {
12+
describe( "UserWithIdStrategy", function() {
13+
it( "returns true for valid user ids", function() {
14+
var params = { "userIds": "123, 456" };
15+
var context = getTestContext( { "userId": "456" } );
16+
var result = variables.strategy.isEnabled( params, context );
17+
expect( result ).toBeTrue();
18+
} );
19+
20+
it( "returns false for invalid user ids", function() {
21+
var params = { "userIds": "123,456" };
22+
var context = getTestContext( { "userId": "789" } );
23+
var result = variables.strategy.isEnabled( params, context );
24+
expect( result ).toBeFalse();
25+
} );
26+
27+
it( "returns false for no user id in context", function() {
28+
var params = { "userIds": "123,456" };
29+
var context = getTestContext( { "userId": "" } );
30+
var result = variables.strategy.isEnabled( params, context );
31+
expect( result ).toBeFalse();
32+
} );
33+
34+
it( "returns true for no user id in params", function() {
35+
var params = {};
36+
var context = getTestContext( { "userId": "456" } );
37+
var result = variables.strategy.isEnabled( params, context );
38+
expect( result ).toBeTrue();
39+
} );
40+
} );
41+
}
42+
43+
function getTestContext( struct overrides = {} ) {
44+
structAppend( arguments.overrides, {
45+
"appName": "unleashsdk-tests",
46+
"environment": "testing",
47+
"userId": "1",
48+
"sessionId": "1",
49+
"remoteAddress": CGI.REMOTE_ADDR
50+
}, false );
51+
return arguments.overrides;
52+
}
53+
54+
}

0 commit comments

Comments
 (0)