Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created June 13, 2021 14:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bennadel/90d07faa1bb3504276f0ce312ff6c4e2 to your computer and use it in GitHub Desktop.
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
<cfscript>
// Create the components and wire them together.
include template = "./config.cfm";
launchDarklyService.addSubdomainTargeting(
key = "ben-test-flag",
subdomain = "acme"
);
</cfscript>
<cfscript>
// Create the components and wire them together.
include template = "./config.cfm";
launchDarklyService.addSubdomainTargeting(
key = "ben-test-flag",
subdomain = "cyberdyne"
);
</cfscript>
<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>
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
}
]
})
)
);
}
}
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 )
);
}
}
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 );
}
}
<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