Created
June 13, 2021 14:32
-
-
Save bennadel/90d07faa1bb3504276f0ce312ff6c4e2 to your computer and use it in GitHub Desktop.
Using The LaunchDarkly REST API To Update Rule Configuration In Lucee CFML 5.3.7.47
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
// Create the components and wire them together. | |
include template = "./config.cfm"; | |
launchDarklyService.addSubdomainTargeting( | |
key = "ben-test-flag", | |
subdomain = "acme" | |
); | |
</cfscript> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
// Create the components and wire them together. | |
include template = "./config.cfm"; | |
launchDarklyService.addSubdomainTargeting( | |
key = "ben-test-flag", | |
subdomain = "cyberdyne" | |
); | |
</cfscript> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
// I handle low-level HTTP requests. | |
launchDarklyRestHttpClient = new LaunchDarklyRestHttpClient(); | |
// I handle low-level semantic operations. | |
launchDarklyRestGateway = new LaunchDarklyRestGateway( | |
accessToken = server.system.environment.LAUNCH_DARKLY_REST_ACCESS_TOKEN, | |
launchDarklyRestHttpClient = launchDarklyRestHttpClient | |
); | |
// I provide high-level abstractions over feature flag mutations. | |
launchDarklyService = new LaunchDarklyService( | |
projectKey = server.system.environment.LAUNCH_DARKLY_REST_PROJECT_KEY, | |
environmentKey = server.system.environment.LAUNCH_DARKLY_REST_ENVIRONMENT_KEY, | |
launchDarklyRestGateway = launchDarklyRestGateway | |
); | |
</cfscript> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
component | |
output = false | |
hint = "I provide low-level semantic abstractions over LaunchDarkly's HTTP REST API." | |
{ | |
/** | |
* I initialize the LaunchDarkly REST API with the given access token. | |
*/ | |
public void function init( | |
required string accessToken, | |
required any launchDarklyRestHttpClient | |
) { | |
variables.accessToken = arguments.accessToken; | |
variables.httpClient = arguments.launchDarklyRestHttpClient; | |
variables.baseApiUrl = "https://app.launchdarkly.com/api/v2"; | |
// All requests to the HTTP REST API will need to include the authorization. | |
variables.authorizationHeader = { | |
name: "Authorization", | |
value: accessToken | |
}; | |
// The HTTP REST API supports two different types of "JSON patches" determined by | |
// the existence of a special Content-Type header. This "semantic patch" allows | |
// us to use "commands" to update the underlying JSON regardless(ish) of the | |
// current state of the JSON document. | |
variables.semanticPatchHeader = { | |
name: "Content-Type", | |
value: "application/json; domain-model=launchdarkly.semanticpatch" | |
}; | |
} | |
// --- | |
// PUBLIC METHODS. | |
// --- | |
/** | |
* I apply the semantic JSON patch operation for "addRule". Returns the updated | |
* feature flag struct. | |
*/ | |
public struct function addRule( | |
required string projectKey, | |
required string environmentKey, | |
required string key, | |
required string attribute, | |
required string op, | |
required array values, | |
required string variationId | |
) { | |
return( | |
httpClient.makeHttpRequestWithRetry( | |
requestMethod = "patch", | |
requestUrl = "#baseApiUrl#/flags/#projectKey#/#key#", | |
headerParams = [ | |
authorizationHeader, | |
semanticPatchHeader | |
], | |
body = serializeJson({ | |
environmentKey: environmentKey, | |
instructions: [ | |
{ | |
kind: "addRule", | |
clauses: [ | |
{ | |
attribute: attribute, | |
op: op, | |
values: values | |
} | |
], | |
variationId: variationId | |
} | |
] | |
}) | |
) | |
); | |
} | |
/** | |
* I apply the semantic JSON patch operation for "addValuesToClause". Returns the | |
* updated feature flag struct. | |
*/ | |
public struct function addValuesToClause( | |
required string projectKey, | |
required string environmentKey, | |
required string key, | |
required string ruleId, | |
required string clauseId, | |
required array values | |
) { | |
return( | |
httpClient.makeHttpRequestWithRetry( | |
requestMethod = "patch", | |
requestUrl = "#baseApiUrl#/flags/#projectKey#/#key#", | |
headerParams = [ | |
authorizationHeader, | |
semanticPatchHeader | |
], | |
body = serializeJson({ | |
environmentKey: environmentKey, | |
instructions: [ | |
{ | |
kind: "addValuesToClause", | |
ruleId: ruleId, | |
clauseId: clauseId, | |
values: values | |
} | |
] | |
}) | |
) | |
); | |
} | |
/** | |
* I return the feature flag data for the given key. | |
*/ | |
public struct function getFeatureFlag( | |
required string projectKey, | |
required string environmentKey, | |
required string key | |
) { | |
return( | |
httpClient.makeHttpRequestWithRetry( | |
requestMethod = "get", | |
requestUrl = "#baseApiUrl#/flags/#projectKey#/#key#", | |
headerParams = [ | |
authorizationHeader | |
], | |
urlParams = [ | |
{ | |
name: "env", | |
value: environmentKey | |
} | |
] | |
) | |
); | |
} | |
/** | |
* I apply the semantic JSON patch operation for "removeValuesFromClause". Returns the | |
* updated feature flag struct. | |
*/ | |
public struct function removeValuesFromClause( | |
required string projectKey, | |
required string environmentKey, | |
required string key, | |
required string ruleId, | |
required string clauseId, | |
required array values | |
) { | |
return( | |
httpClient.makeHttpRequestWithRetry( | |
requestMethod = "patch", | |
requestUrl = "#baseApiUrl#/flags/#projectKey#/#key#", | |
headerParams = [ | |
authorizationHeader, | |
semanticPatchHeader | |
], | |
body = serializeJson({ | |
environmentKey: environmentKey, | |
instructions: [ | |
{ | |
kind: "removeValuesFromClause", | |
ruleId: ruleId, | |
clauseId: clauseId, | |
values: values | |
} | |
] | |
}) | |
) | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
component | |
output = false | |
hint = "I provide low-level HTTP methods for calling the LaunchDarkly HTTP REST API." | |
{ | |
/** | |
* I make an HTTP request with the given configuration, returning the parsed file | |
* content on success or throwing an error on failure. | |
*/ | |
public any function makeHttpRequest( | |
required string requestMethod, | |
required string requestUrl, | |
array headerParams = [], | |
array urlParams = [], | |
any body = nullValue(), | |
numeric timeout = 5 | |
) { | |
http | |
result = "local.httpResponse" | |
method = requestMethod, | |
url = requestUrl | |
getAsBinary = "yes" | |
charset = "utf-8" | |
timeout = timeout | |
{ | |
for ( var headerParam in headerParams ) { | |
httpParam | |
type = "header" | |
name = headerParam.name | |
value = headerParam.value | |
; | |
} | |
for ( var urlParam in urlParams ) { | |
httpParam | |
type = "url" | |
name = urlParam.name | |
value = urlParam.value | |
; | |
} | |
if ( arguments.keyExists( "body" ) ) { | |
httpParam | |
type = "body" | |
value = body | |
; | |
} | |
} | |
var fileContent = getFileContentAsString( httpResponse.fileContent ); | |
if ( isErrorStatusCode( httpResponse.statusCode ) ) { | |
throwErrorResponseError( | |
requestUrl = requestUrl, | |
statusCode = httpResponse.statusCode, | |
fileContent = fileContent | |
); | |
} | |
if ( ! fileContent.len() ) { | |
return( "" ); | |
} | |
try { | |
return( deserializeJson( fileContent ) ); | |
} catch ( any error ) { | |
throwJsonParseError( | |
requestUrl = requestUrl, | |
statusCode = httpResponse.statusCode, | |
fileContent = fileContent, | |
error = error | |
); | |
} | |
} | |
/** | |
* I make an HTTP request with the given configuration, returning the parsed file | |
* content on success or throwing an error on failure. Failed requests will be retried | |
* a number of times if they fail with a status code that is considered safe to retry. | |
*/ | |
public any function makeHttpRequestWithRetry( | |
required string requestMethod, | |
required string requestUrl, | |
array headerParams = [], | |
array urlParams = [], | |
any body = nullValue(), | |
numeric timeout = 5 | |
) { | |
// Rather than relying on the maths to do exponential back-off calculations, this | |
// collection provides an explicit set of back-off values (in milliseconds). This | |
// collection also doubles as the number of attempts that we should execute | |
// against the underlying HTTP API. | |
// -- | |
// NOTE: Some randomness will be applied to these values at execution time. | |
var backoffDurations = [ | |
100, | |
200, | |
400, | |
800, | |
1600, | |
3200, | |
0 // Indicates end of retry attempts. | |
]; | |
for ( var backoffDuration in backoffDurations ) { | |
try { | |
return( makeHttpRequest( argumentCollection = arguments ) ); | |
} catch ( "LaunchDarklyRestError.ErrorStatusCode" error ) { | |
// Extract the HTTP status code from the error type - it is the last | |
// value in the dot-delimited error type list. | |
var statusCode = val( error.type.listLast( "." ) ); | |
if ( backoffDuration && isRetriableStatusCode( statusCode ) ) { | |
sleep( applyJitter( backoffDuration ) ); | |
} else { | |
rethrow; | |
} | |
} | |
} | |
} | |
// --- | |
// PRIVATE METHODS. | |
// --- | |
/** | |
* I apply a 20% jitter to a given back-off value in order to ensure some kind of | |
* randomness to the collection of requests against the target. This is a small effort | |
* to prevent the thundering heard problem for the target. | |
*/ | |
private numeric function applyJitter( required numeric value ) { | |
// Create a jitter of +/- 20%. | |
var jitter = ( randRange( 80, 120 ) / 100 ); | |
return( fix( value * jitter ) ); | |
} | |
/** | |
* I return the given fileContent value as a string. | |
* | |
* NOTE: Even though we always ask ColdFusion to return a Binary value in the HTTP | |
* response object, the Type is only guaranteed if the request comes back properly. | |
* If something goes terribly wrong (such as a "Connection Failure"), the fileContent | |
* will still be returned as a simple string. | |
*/ | |
private string function getFileContentAsString( required any fileContent ) { | |
if ( isBinary( fileContent ) ) { | |
return( charsetEncode( fileContent, "utf-8" ) ); | |
} else { | |
return( fileContent ); | |
} | |
} | |
/** | |
* I determine if the given HTTP status code is an error response. Anything outside | |
* the 2xx range is going to be considered an error response. | |
*/ | |
private boolean function isErrorStatusCode( required string statusCode ) { | |
return( ! isSuccessStatusCode( statusCode ) ); | |
} | |
/** | |
* I determine if the given HTTP status code is considered safe to retry. | |
*/ | |
private boolean function isRetriableStatusCode( required string statusCode ) { | |
return( | |
( statusCode == 0 ) || // Connection Failure. | |
( statusCode == 408 ) || // Request Timeout. | |
( statusCode == 500 ) || // Server error. | |
( statusCode == 502 ) || // Bad Gateway. | |
( statusCode == 503 ) || // Service Unavailable. | |
( statusCode == 504 ) // Gateway Timeout. | |
); | |
} | |
/** | |
* I determine if the given HTTP status code is a success response. Anything in the | |
* 2xx range is going to be considered a success response. | |
*/ | |
private boolean function isSuccessStatusCode( required string statusCode ) { | |
return( !! statusCode.reFind( "2\d\d" ) ); | |
} | |
/** | |
* I throw an error for when HTTP response comes back with a non-2xx status code. | |
*/ | |
private void function throwErrorResponseError( | |
required string requestUrl, | |
required string statusCode, | |
required string fileContent | |
) { | |
var statusCodeNumber = statusCode.findNoCase( "Connection Failure" ) | |
? 0 | |
: val( statusCode ) | |
; | |
throw( | |
type = "LaunchDarklyRestError.ErrorStatusCode.#statusCodeNumber#", | |
message = "LaunchDarkly HTTP REST API returned a non-2xx status code.", | |
extendedInfo = serializeJson( arguments ) | |
); | |
} | |
/** | |
* I throw an error for when the HTTP response payload could not be parsed as JSON. | |
*/ | |
private void function throwJsonParseError( | |
required string requestUrl, | |
required string statusCode, | |
required string fileContent, | |
required struct error | |
) { | |
throw( | |
type = "LaunchDarklyRestError.JsonParse", | |
message = "LaunchDarkly HTTP REST API returned payload that could not be parsed.", | |
extendedInfo = serializeJson( arguments ) | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
component | |
output = false | |
hint = "I provide high-level abstractions over LaunchDarkly's HTTP REST API." | |
{ | |
/** | |
* NOTE: In our application architecture, we lock the runtime down to a specific | |
* Project and Environment. As such, these values will be used for all subsequent | |
* method calls and cannot be changed on a per-request basis. | |
*/ | |
public void function init( | |
required string projectKey, | |
required string environmentKey, | |
required any launchDarklyRestGateway | |
) { | |
variables.projectKey = arguments.projectKey; | |
variables.environmentKey = arguments.environmentKey; | |
variables.gateway = launchDarklyRestGateway; | |
} | |
// --- | |
// PUBLIC METHODS. | |
// --- | |
/** | |
* I add the given subdomain to the "true" targeting for the given feature flag. | |
*/ | |
public void function addSubdomainTargeting( | |
required string key, | |
required string subdomain | |
) { | |
var feature = gateway.getFeatureFlag( | |
projectKey = projectKey, | |
environmentKey = environmentKey, | |
key = key | |
); | |
// The feature flag data structure is large and complex. And, we'll need to | |
// reference parts of it by ID. As such, let's build-up some indexes that make | |
// parts of it easier to reference. | |
var rulesIndex = buildRulesIndex( feature ); | |
// CAUTION: For the purposes of our application, we're going to assume that this | |
// feature flag only has two boolean variations: True and False. | |
var variationsIndex = buildVariationsIndex( feature ); | |
// If we already have a rule that represents a "subdomain is one of" targeting, | |
// we can add the given to the existing rule and clause. | |
if ( rulesIndex.keyExists( "subdomain.in" ) ) { | |
// CAUTION: For the purposes of our application, we are going to assume that | |
// all subdomain targeting rules have a single clause. | |
var rule = rulesIndex[ "subdomain.in" ]; | |
var clause = rule.clauses.first(); | |
gateway.addValuesToClause( | |
projectKey = projectKey, | |
environmentKey = environmentKey, | |
key = key, | |
ruleId = rule._id, | |
clauseId = clause._id, | |
values = [ subdomain ] | |
); | |
// If there is no existing rule that represents "subdomain is one of" targeting, | |
// we have to create it (using the given value as the initial value). | |
} else { | |
gateway.addRule( | |
projectKey = projectKey, | |
environmentKey = environmentKey, | |
key = key, | |
attribute = "subdomain", | |
op = "in", | |
values = [ subdomain ], | |
variationId = variationsIndex[ "true" ]._id | |
); | |
} | |
} | |
/** | |
* I remove the given subdomain from the "true" targeting for the given feature flag. | |
*/ | |
public void function removeSubdomainTargeting( | |
required string key, | |
required string subdomain | |
) { | |
var feature = gateway.getFeatureFlag( | |
projectKey = projectKey, | |
environmentKey = environmentKey, | |
key = key | |
); | |
// The feature flag data structure is large and complex. And, we'll need to | |
// reference parts of it by ID. As such, let's build-up some indexes that make | |
// parts of it easier to reference. | |
var rulesIndex = buildRulesIndex( feature ); | |
// CAUTION: For the purposes of our application, we're going to assume that this | |
// feature flag only has two boolean variations: True and False. | |
var variationsIndex = buildVariationsIndex( feature ); | |
// If there is no existing rule that represents "subdomain is one of" targeting, | |
// then there's no place to remove the given subdomain. As such, we can just | |
// ignore this request. | |
if ( ! rulesIndex.keyExists( "subdomain.in" ) ) { | |
return; | |
} | |
// CAUTION: For the purposes of our application, we are going to assume that all | |
// subdomain targeting rules have a single clause. | |
var rule = rulesIndex[ "subdomain.in" ]; | |
var clause = rule.clauses.first(); | |
gateway.removeValuesFromClause( | |
projectKey = projectKey, | |
environmentKey = environmentKey, | |
key = key, | |
ruleId = rule._id, | |
clauseId = clause._id, | |
values = [ subdomain ] | |
); | |
} | |
// --- | |
// PRIVATE METHODS. | |
// --- | |
/** | |
* I create an index that maps rule-operations to rules. | |
* | |
* NOTE: The underlying feature flag data structure can return rules across all of the | |
* environments. However, we're only going to concern ourselves with the single | |
* environment associated with this runtime. | |
*/ | |
private struct function buildRulesIndex( required struct featureFlag ) { | |
var rulesIndex = {}; | |
for ( var rule in featureFlag.environments[ environmentKey ].rules ) { | |
var keyParts = []; | |
for ( var clause in rule.clauses ) { | |
keyParts.append( clause.attribute ); | |
keyParts.append( clause.op ); | |
} | |
rulesIndex[ keyParts.toList( "." ).lcase() ] = rule; | |
} | |
return( rulesIndex ); | |
} | |
/** | |
* I create an index that maps variation values to variations. | |
*/ | |
private struct function buildVariationsIndex( required struct featureFlag ) { | |
var variationsIndex = {}; | |
for ( var variation in featureFlag.variations ) { | |
variationsIndex[ lcase( variation.value ) ] = variation; | |
} | |
return( variationsIndex ); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
// Create the components and wire them together. | |
include template = "./config.cfm"; | |
launchDarklyService.removeSubdomainTargeting( | |
key = "ben-test-flag", | |
subdomain = "acme" | |
); | |
</cfscript> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment