Skip to main content
Ben Nadel at Scotch On The Rocks (SOTR) 2011 (Edinburgh) with: Stéphane Vantroyen
Ben Nadel at Scotch On The Rocks (SOTR) 2011 (Edinburgh) with: Stéphane Vantroyen@23yen )

Validating Complex, Nested, Dynamic Data Structures In ColdFusion

By on
Tags:

Last week, I looked at building-up complex objects using form POSTs in ColdFusion. I then took that same technique and used it to create a multi-step form wizard that doesn't require any data persistence. All of that was in service of creating a feature flag system for my blog (see my Tweet). But, building-up a complex object is one thing - validating and persisting that complex object is whole other beast. And, to be honest, I don't have a go-to method for validating complex, nested structures in ColdFusion. As such, I've spent the last week playing around with an approach that I would like to share.

Prior Art, And Configuration-Based Validation

Before I started playing around with this concept, I did some Googling for complex object validation in ColdFusion. I didn't find much prior art. Though, of course, ColdBox already has a module for it: cbValidation. This module uses configuration-based validation rules.

cbValidation feels reminiscent of the JavaScript library, Joi, that I used as part of a Node.js project some years ago. It too was configuration based; though, it uses a fluent API to build-up the schema validation rules.

I think the idea of configuration-based validation is great. But, there's something about the way that my brain works that makes a configuration-based approach feel challenging. Especially while I'm still fleshing-out what my data should even look like.

Configuration-based validation may just be too abstract for me. I often like to brute-force things, and see explicitly how logic is implemented in the code. That said, it would be fun to get this working with brute-forced code, setup the behaviors and the expectations, and then come back and try to retrofit it into a configuration-based approach.

Brute Forcing All The Things!

Before we get into the actual code, let's look at the data that I'm trying to validate. My feature flag data structure is heavily influenced by LanchDarkly's feature flag anatomy. As you may know, I'm a massive fan of LaunchDarkly; and, I've given presentations on how their feature flags revolutionize product development.

At a high level, a feature flag provides a set of variants (ie, values) and defines a set of rules that determine which variant will be served to a given user. Each rule is composed of a series of tests and a rollout strategy. Each test can look at either a user "key" or a user "property" and uses one of several operators (ex, OneOf, EndsWith) to compare the input to a set of values. If all tests pass (for a given user), the rollout strategy either serves a single variant or uses a weighted distribution model across all the variants.

And, all of this logic is defined in a single, complex, deeply-nested ColdFusion structure.

The most simple, valid structure for this complex object looks like this:

<cfscript>

	config = {
		key: "product-RAIN-123-emoji-keyboard",
		name: "Product: RAIN-123 - Emoji keyboard",
		description: "I determine if the emoji keyboard automatically pops-up while the user is typing.",
		type: "Boolean",
		variants: [ false, true ],
		rules: [],
		fallthroughVariantRef: 1,
		isEnabled: false
	};

</cfscript>

This configuration has no rules and is not enabled; which means that the fallthroughVariantRef will be used to serve up the feature flag variant. "Variant Refs" are simply array indices for the variants property. So, in this case, the fall-through variant ref of 1 refers to the first (ColdFusion arrays are 1-based) variant in the variants array, which is false.

To make this a bit more complex, we can add a rule. A feature flag can have any number of rules, which will be evaluated in series. The first rule to successfully target a given user will serve up a variant and will short-circuit the rule evaluation.

The most simple rule is one that has no tests. When a rule has no tests, it will match against every user:

<cfscript>

	config = {
		key: "product-RAIN-123-emoji-keyboard",
		name: "Product: RAIN-123 - Emoji keyboard",
		description: "I determine if the emoji keyboard automatically pops-up while the user is typing.",
		type: "Boolean",
		variants: [ false, true ],
		rules: [

			{
				tests: [],
				rollout: {
					type: "Single",
					variantRef: 2
				}
			}

		],
		fallthroughVariantRef: 1,
		isEnabled: true
	};

</cfscript>

This rule matches all users (since it has no tests) and has a "Single" rollout strategy, which means that it always serves the same variant. In this case, we're serving up variant 2, which maps to the second index of the variants array, which is true.

Our rollout strategy could also use a weighted distribution. Maybe we want to slowly roll-out a feature, so we only want 10% of users to get the true variant:

<cfscript>

	config = {
		key: "product-RAIN-123-emoji-keyboard",
		name: "Product: RAIN-123 - Emoji keyboard",
		description: "I determine if the emoji keyboard automatically pops-up while the user is typing.",
		type: "Boolean",
		variants: [ false, true ],
		rules: [
			{
				tests: [],

				rollout: {
					type: "Multi",
					distribution: [
						{
							percent: 90,
							variantRef: 1
						},
						{
							percent: 10,
							variantRef: 2
						}
					]
				}

			}
		],
		fallthroughVariantRef: 1,
		isEnabled: true
	};

</cfscript>

Here, 90% of all users that match against this rule will get variant ref 1 (false) while 10% of users that match against this rule will get variant ref 2 (true).

Instead of a weighted distributions, maybe we just wanted to turn the feature flag on for a specific set of users. To do that, we can start to add some tests. The simplest test is the user key test. The following snippet serves the true variant to users with a specific set of IDs:

<cfscript>

	config = {
		key: "product-RAIN-123-emoji-keyboard",
		name: "Product: RAIN-123 - Emoji keyboard",
		description: "I determine if the emoji keyboard automatically pops-up while the user is typing.",
		type: "Boolean",
		variants: [ false, true ],
		rules: [
			{

				tests: [
					{
						type: "UserKey",
						operation: {
							operator: "OneOf",
							values: [ 1, 2, 3 ]
						}
					}
				],

				rollout: {
					type: "Single",
					variantRef: 2
				}
			}
		],
		fallthroughVariantRef: 1,
		isEnabled: true
	};

</cfscript>

This serves up the true variant to any user with the key 1, 2, or 3. But, maybe instead of targeting a specific users, we want to target a group of users. Perhaps, we want to turn the feature flag on for all users with a given email domain. For that, we would test a "user property" (email), not a "user key":

<cfscript>

	config = {
		key: "product-RAIN-123-emoji-keyboard",
		name: "Product: RAIN-123 - Emoji keyboard",
		description: "I determine if the emoji keyboard automatically pops-up while the user is typing.",
		type: "Boolean",
		variants: [ false, true ],
		rules: [
			{

				tests: [
					{
						type: "UserProperty",
						userProperty: "email",
						operation: {
							operator: "EndsWith",
							values: [ "@bennadel.com" ]
						}
					}
				],

				rollout: {
					type: "Single",
					variantRef: 2
				}
			}
		],
		fallthroughVariantRef: 1,
		isEnabled: true
	};

</cfscript>

And, of course, the tests array can have multiple entries that are all AND'ed together. So, maybe we want to target all users with an @bennadel.com email, but omit a specific user. For that, we just an an additional exclusionary test:

<cfscript>

	config = {
		key: "product-RAIN-123-emoji-keyboard",
		name: "Product: RAIN-123 - Emoji keyboard",
		description: "I determine if the emoji keyboard automatically pops-up while the user is typing.",
		type: "Boolean",
		variants: [ false, true ],
		rules: [
			{
				tests: [
					{
						type: "UserProperty",
						userProperty: "email",
						operation: {
							operator: "EndsWith",
							values: [ "@bennadel.com" ]
						}
					},

					{
						type: "UserKey",
						operation: {
							operator: "NotOneOf",
							values: [ 99 ]
						}
					}

				],
				rollout: {
					type: "Single",
					variantRef: 2
				}
			}
		],
		fallthroughVariantRef: 1,
		isEnabled: true
	};

</cfscript>

This targets all users with an @bennadel.com email, but excludes the user with key 99 from the targeting.

Of course, the point of this post isn't to look at how feature flags work - it's to look at complex object validation. I only wanted to step through some feature flag targeting so that you could see the wide range of "valid" structures we're dealing with. Here are some truths that have to apply to this structure:

  • All the variants have to be of the same type.

  • All "variant refs" have to refer to a defined variant.

  • All rules have to have a tests and rollout key.

  • Each test has to have a type and an operation key; and, optionally, a userProperty key if the type is "userProperty".

  • If a rollout is of type "Multi", then it has to have a distribution key; and, the percent values within the distribution array have to total 100.

To validate this deeply-nested structure, I have a ColdFusion component, FeatureFlagValidation.cfc, that provides a series of test{Property}() methods. Each method looks at a specific part of this complex object; and, if necessary, recursively calls another test() method for a sub-tree within the object graph.

ASIDE: This is not technically "recursion", but it feels like a recursive algorithm for walking the tree of nested Structs and Arrays.

For example, to test the rules property, we have to iterate of the each rule and test it independently:

component {

	/**
	* I test the given rules against the given variants.
	*/
	public void function testRules(
		required array variants,
		required array rules
		) {

		for ( var rule in rules ) {

			testRule( variants, rule );

		}

	}


	/**
	* I test the given rule against the given variants.
	*/
	public void function testRule(
		required array variants,
		required struct rule
		) {

		ensureProperty( "FeatureFlag.Rule", rule, "tests", "array" );
		ensureProperty( "FeatureFlag.Rule", rule, "rollout", "struct" );

		testTests( rule.tests );
		testRollout( variants, rule.rollout );

	}

}

As you can see, the testRules() method turns around and calls the testRule() method for each rule in the collection. The testRule() method then both validates the structure of the rule (ie, do the expected keys exist with the expected type); and then, turns around and calls other methods for testing the tests and rollout sub-properties.

The testRollout() method needs to branch and validate both "Single" and "Multi" type rollouts:

component {

	/**
	* I test the given rollout against the given variants. 
	*/
	public void function testRollout(
		required array variants,
		required struct rollout
		) {

		ensureProperty( "FeatureFlag.Rule.Rollout", rollout, "type", "string" );

		switch ( rollout.type ) {
			case "Multi":
				testMultiRollout( variants, rollout );
			break;
			case "Single":
				testSingleRollout( variants, rollout );
			break;
			default:
				throw(
					type = "FeatureFlag.Rule.Rollout.Type.NotSupported",
					message = "FeatureFlag rollout type is not supported.",
					detail = "Type [#rollout.type#] must be one of [Single, Multi]."
				);
			break;
		}

	}

}

As you can start to see here, the validation methods on this ColdFusion component either "exit quietly" if the data is valid; or, they throw() an error with a specific type if the data is invalid. Our controller layer (ie, the application's "delivery mechanism") can then catch these errors and translate them into error responses for our users.

In some of the validation, we have to pass-down values so that they can be used in subsequent validations. For example, we're passing the variants array around here even though we haven't used it yet. Ultimately, this variants collection will be used to validate the variantRef indices within the rollout configuration. To see this in action, let's look at the testMultiRollout() which has to make sure that the variantRef values are correct and that the percent distributions all add up:

component {

	/**
	* I test the given multi-rollout against the given variants. 
	*/
	public void function testMultiRollout(
		required array variants,
		required struct rollout
		) {

		ensureProperty( "FeatureFlag.Rule.Rollout", rollout, "distribution", "array" );

		var percentTotal = 0;

		for ( var portion in rollout.distribution ) {

			testMultiRolloutPortion( variants, portion );

			percentTotal += portion.percent;

		}

		if ( percentTotal != 100 ) {

			throw(
				type = "FeatureFlag.Rule.Rollout.PercentTotal.Invalid",
				message = "FeatureFlag multi-rollout percent total must be 100.",
				detail = "Percent total: #percentTotal#."
			);

		}

	}


	/**
	* I test the given multi-rollout portion against the given variants. 
	*/
	public void function testMultiRolloutPortion(
		required array variants,
		required struct portion
		) {

		ensureProperty( "FeatureFlag.Rule.Rollout.Distribution", portion, "variantRef", "integer" );
		ensureProperty( "FeatureFlag.Rule.Rollout.Distribution", portion, "percent", "integer" );

		if ( ! variants.isDefined( portion.variantRef ) ) {

			throw(
				type = "FeatureFlag.Rule.Rollout.Distribution.VariantRef.OutOfBounds",
				message = "Multi-rollout variant ref does not match a defined variant.",
				detail = "Distribution variant ref [#portion.variantRef#] must be in the range [1..#variants.len()#]."
			);

		}

		if ( ( portion.percent < 0 ) || ( portion.percent > 100 ) ) {

			throw(
				type = "FeatureFlag.Rule.Rollout.Distribution.Percent.Invalid",
				message = "Multi-rollout percent portion must be between 0 and 100.",
				detail = "Percent: #portion.percent#."
			);

		}

	}

}

Here, you can finally see that the variants array being passed around is getting used in the variantRef validation (see the .isDefined() calls).

There's a lot of code here! The shape of this feature flag data is quite varied; and, there is tight coupling between different aspects of the data in terms of the validation. It would be super interesting to see how something like this would be re-created using config-based validation instead of brute-force method calling (as I have done).

With that said, here's the full code for my FeatureFlagValidation.cfc so you can see how much it takes to make sure all this data is correct.

component
	output = false
	hint = "I provide validation methods for the feature flag inputs."
	{

	/**
	* I test the given fall-through variantRef.
	*/
	public void function testFallthroughVariantRef(
		required array variants,
		required numeric fallthroughVariantRef
		) {

		if ( ! variants.isDefined( fallthroughVariantRef ) ) {

			throw(
				type = "FeatureFlag.FallthroughVariantRef.OutOfBounds",
				message = "FeatureFlag fall-through variant ref does not match a defined variant.",
				detail = "Fall-through variant ref [#fallthroughVariantRef#] must be in the range [1..#variants.len()#]."
			);

		}

	}


	/**
	* I test the given key.
	*/
	public void function testKey( required string key ) {

		if ( ! key.len() ) {

			throw(
				type = "FeatureFlag.Key.Empty",
				message = "FeatureFlag key cannot be empty."
			);

		}

	}


	/**
	* I test the given multi-rollout against the given variants. 
	*/
	public void function testMultiRollout(
		required array variants,
		required struct rollout
		) {

		ensureProperty( "FeatureFlag.Rule.Rollout", rollout, "distribution", "array" );

		var percentTotal = 0;

		for ( var portion in rollout.distribution ) {

			testMultiRolloutPortion( variants, portion );

			percentTotal += portion.percent;

		}

		if ( percentTotal != 100 ) {

			throw(
				type = "FeatureFlag.Rule.Rollout.PercentTotal.Invalid",
				message = "FeatureFlag multi-rollout percent total must be 100.",
				detail = "Percent total: #percentTotal#."
			);

		}

	}


	/**
	* I test the given multi-rollout portion against the given variants. 
	*/
	public void function testMultiRolloutPortion(
		required array variants,
		required struct portion
		) {

		ensureProperty( "FeatureFlag.Rule.Rollout.Distribution", portion, "variantRef", "integer" );
		ensureProperty( "FeatureFlag.Rule.Rollout.Distribution", portion, "percent", "integer" );

		if ( ! variants.isDefined( portion.variantRef ) ) {

			throw(
				type = "FeatureFlag.Rule.Rollout.Distribution.VariantRef.OutOfBounds",
				message = "Multi-rollout variant ref does not match a defined variant.",
				detail = "Distribution variant ref [#portion.variantRef#] must be in the range [1..#variants.len()#]."
			);

		}

		if ( ( portion.percent < 0 ) || ( portion.percent > 100 ) ) {

			throw(
				type = "FeatureFlag.Rule.Rollout.Distribution.Percent.Invalid",
				message = "Multi-rollout percent portion must be between 0 and 100.",
				detail = "Percent: #portion.percent#."
			);

		}

	}


	/**
	* I test the given rollout against the given variants. 
	*/
	public void function testRollout(
		required array variants,
		required struct rollout
		) {

		ensureProperty( "FeatureFlag.Rule.Rollout", rollout, "type", "string" );

		switch ( rollout.type ) {
			case "Multi":
				testMultiRollout( variants, rollout );
			break;
			case "Single":
				testSingleRollout( variants, rollout );
			break;
			default:
				throw(
					type = "FeatureFlag.Rule.Rollout.Type.NotSupported",
					message = "FeatureFlag rollout type is not supported.",
					detail = "Type [#rollout.type#] must be one of [Single, Multi]."
				);
			break;
		}

	}


	/**
	* I test the given rule against the given variants.
	*/
	public void function testRule(
		required array variants,
		required struct rule
		) {

		ensureProperty( "FeatureFlag.Rule", rule, "tests", "array" );
		ensureProperty( "FeatureFlag.Rule", rule, "rollout", "struct" );

		testTests( rule.tests );
		testRollout( variants, rule.rollout );

	}


	/**
	* I test the given rules against the given variants.
	*/
	public void function testRules(
		required array variants,
		required array rules
		) {

		for ( var rule in rules ) {

			testRule( variants, rule );

		}

	}


	/**
	* I test the given single-rollout against the given variants. 
	*/
	public void function testSingleRollout(
		required array variants,
		required struct rollout
		) {

		ensureProperty( "FeatureFlag.Rule.Rollout", rollout, "variantRef", "integer" );

		if ( ! variants.isDefined( rollout.variantRef ) ) {

			throw(
				type = "FeatureFlag.Rule.Rollout.VariantRef.OutOfBounds",
				message = "Single-rollout variant ref does not match a defined variant.",
				detail = "Rollout variant ref [#rollout.variantRef#] must be in the range [1..#variants.len()#]."
			);

		}

	}


	/**
	* I test the given test.
	*/
	public void function testTest( required struct test ) {

		ensureProperty( "FeatureFlag.Rule.Test", test, "type", "string" );

		switch ( test.type ) {
			case "UserKey":
				testUserKeyTest( test );
			break;
			case "UserProperty":
				testUserPropertyTest( test );
			break;
			default:
				throw(
					type = "FeatureFlag.Rule.Test.Type.NotSupported",
					message = "FeatureFlag test type is not supported.",
					detail = "Type [#test.type#] must be one of [UserKey, UserProperty]."
				);
			break;
		}

	}


	/**
	* I test the given operation.
	*/ 
	public void function testTestOperation( required struct operation ) {

		ensureProperty( "FeatureFlag.Rule.Test.Operation", operation, "operator", "string" );
		ensureProperty( "FeatureFlag.Rule.Test.Operation", operation, "values", "array" );

		testTestOperator( operation.operator );

		for ( var value in operation.values ) {

			if ( ! isSimpleValue( value ) ) {

				throw(
					type = "FeatureFlag.Rule.Test.Operation.Value.NotSupported",
					message = "FeatureFlag operands must be simple values.",
					detail = "Operator [#operation.operator#] can only work with simple values."
				);

			}

		}

	}


	/**
	* I test the given operator.
	*/
	public void function testTestOperator( required string operator ) {

		switch ( operator ) {
			case "Contains":
			case "EndsWith":
			case "GreaterThan":
			case "LessThan":
			case "MatchesRegex":
			case "NotContains":
			case "NotEndsWith":
			case "NotMatchesRegex":
			case "NotOneOf":
			case "NotStartsWith":
			case "OneOf":
			case "StartsWith":
				return;
			break;
			default:
				throw(
					type = "FeatureFlag.Rule.Test.Operation.Operator.NotSupported",
					message = "FeatureFlag test operator not supported.",
					detail = "Operator [#operator#] must be one of [Contains, EndsWith, GreaterThan, LessThan, MatchesRegex, NotContains, NotEndsWith, NotMatchesRegex, NotOneOf, NotStartsWith, OneOf, StartsWith]."
				);
			break;
		}

	}


	/**
	* I test the given tests.
	*/
	public void function testTests( required array tests ) {

		for ( var test in tests ) {

			testTest( test );

		}

	}


	/**
	* I test the given variant type.
	*/
	public void function testType( required string type ) {

		switch ( type ) {
			case "Any":
			case "Boolean":
			case "Numeric":
			case "String":
				return;
			break;
			default:
				throw(
					type = "FeatureFlag.Type.NotSupported",
					message = "FeatureFlag type is not supported.",
					detail = "Type [#type#] must be one of [Boolean, Numeric, String, Any]."
				);
			break;
		}

	}


	/**
	* I test the given user-key test.
	*/
	public void function testUserKeyTest( required struct test ) {

		ensureProperty( "FeatureFlag.Rule.Test", test, "operation", "struct" );

		testTestOperation( test.operation );

	}


	/**
	* I test the given user-property test.
	*/
	public void function testUserPropertyTest( required struct test ) {

		ensureProperty( "FeatureFlag.Rule.Test", test, "userProperty", "string" );
		ensureProperty( "FeatureFlag.Rule.Test", test, "operation", "struct" );

		if ( ! test.userProperty.len() ) {

			throw(
				type = "FeatureFlag.Rule.Test.UserProperty.Empty",
				message = "FeatureFlag user property cannot be empty."
			);

		}

		testTestOperation( test.operation );

	}


	/**
	* I test the given variant against the given type.
	*/
	public void function testVariant(
		required string type,
		required any variant
		) {

		// CAUTION: Since ColdFusion auto-casts values on-the-fly, the following checks
		// really only determine if the given variant can be cast to the given type - it
		// doesn't really ensure that the variant is the correct NATIVE type. As such,
		// variant values should be explicitly cast to the correct NATIVE type during data
		// persistence.
		if (
			( type == "any" ) ||
			( ( type == "boolean" ) && isBoolean( variant ) ) ||
			( ( type == "numeric" ) && isNumeric( variant ) ) ||
			( ( type == "string" ) && isValid( "string", variant ) )
			) {

			return;

		}

		var variantValue = isSimpleValue( variant )
			? variant
			: "{Complex Object}"
		;

		throw(
			type = "FeatureFlag.Variant.IncorrectType",
			message = "Variant value does not match feature flag type.",
			detail = "Variant [#variantValue#] is not of type [#type#]."
		);

	}


	/**
	* I test the given variants against the given type.
	*/
	public void function testVariants(
		required string type,
		required array variants
		) {

		for ( var variant in variants ) {

			testVariant( type, variant );

		}

	}

	// ---
	// PRIVATE METHODS.
	// ---

	/**
	* I use the isValid() built-in function to ensure that the given property exists and
	* is of the expected type.
	*/
	private void function ensureProperty(
		required string containerPath,
		required struct container,
		required string propertyName,
		required string propertyType
		) {

		if ( ! container.keyExists( propertyName ) ) {

			throw(
				type = "#containerPath#.#ucfirst( propertyName )#.Missing",
				message = "FeatureFlag is missing a required property.",
				detail = "Property [#propertyName#] expected to be of type [#propertyType#]."
			);

		}

		if ( ! isValid( propertyType, container[ propertyName ] ) ) {

			throw(
				type = "#containerPath#.#ucfirst( propertyName )#.IncorrectType",
				message = "FeatureFlag property is an incorrect type.",
				detail = "Property [#propertyName#] expected to be of type [#propertyType#]."
			);

		}

	}

}

Wow! Lots of code. But, lots of stuff can go wrong with this complex, deeply-nested data structure! In fact, here's the breadth of the errors that this ColdFusion component will throw:

  • FeatureFlag.FallthroughVariantRef.OutOfBounds
  • FeatureFlag.Key.Empty
  • FeatureFlag.Rule.Rollout.Distribution.IncorrectType
  • FeatureFlag.Rule.Rollout.Distribution.Missing
  • FeatureFlag.Rule.Rollout.Distribution.Percent.IncorrectType
  • FeatureFlag.Rule.Rollout.Distribution.Percent.Invalid
  • FeatureFlag.Rule.Rollout.Distribution.Percent.Missing
  • FeatureFlag.Rule.Rollout.Distribution.VariantRef.IncorrectType
  • FeatureFlag.Rule.Rollout.Distribution.VariantRef.Missing
  • FeatureFlag.Rule.Rollout.Distribution.VariantRef.OutOfBounds
  • FeatureFlag.Rule.Rollout.IncorrectType
  • FeatureFlag.Rule.Rollout.Missing
  • FeatureFlag.Rule.Rollout.PercentTotal.Invalid
  • FeatureFlag.Rule.Rollout.Type.IncorrectType
  • FeatureFlag.Rule.Rollout.Type.Missing
  • FeatureFlag.Rule.Rollout.Type.NotSupported
  • FeatureFlag.Rule.Rollout.VariantRef.IncorrectType
  • FeatureFlag.Rule.Rollout.VariantRef.Missing
  • FeatureFlag.Rule.Rollout.VariantRef.OutOfBounds
  • FeatureFlag.Rule.Test.Operation.IncorrectType
  • FeatureFlag.Rule.Test.Operation.Missing
  • FeatureFlag.Rule.Test.Operation.Operator.IncorrectType
  • FeatureFlag.Rule.Test.Operation.Operator.Missing
  • FeatureFlag.Rule.Test.Operation.Operator.NotSupported
  • FeatureFlag.Rule.Test.Operation.Value.NotSupported
  • FeatureFlag.Rule.Test.Operation.Values.IncorrectType
  • FeatureFlag.Rule.Test.Operation.Values.Missing
  • FeatureFlag.Rule.Test.Type.IncorrectType
  • FeatureFlag.Rule.Test.Type.Missing
  • FeatureFlag.Rule.Test.Type.NotSupported
  • FeatureFlag.Rule.Test.UserProperty.Empty
  • FeatureFlag.Rule.Test.UserProperty.IncorrectType
  • FeatureFlag.Rule.Test.UserProperty.Missing
  • FeatureFlag.Rule.Tests.IncorrectType
  • FeatureFlag.Rule.Tests.Missing
  • FeatureFlag.Type.NotSupported
  • FeatureFlag.Variant.IncorrectType

How much or how little of this error avalanche gets translated into a user-friendly error message depends on who the consumer is. If this is for an internally-facing app, maybe none of it has to get translated, since Developers can just look in the error logs to see what is going wrong. But, if you needed to accept complex data from an external developer (such as via an API end-point), then you probably need to catch-and-translate most of these errors in order to be able to deliver a (potentially internationalized) user-friendly error message.

At least, that's the plan so far! As I said, I'm fleshing this out in service of creating feature flags for my blog. As I get farther along in my journey, any and all of this is subject to change. But for now, that's how I'm going to validate my complex, deeply-nested, dynamic ColdFusion structures.

Want to use code from this post? Check out the license.

Reader Comments

10 Comments

Looks pretty neat. I will admit that I didn't read through the entire source of the final embed, but this part caught my eye:

And, of course, the tests array can have multiple entries that are all AND'ed together.

My gut reaction was that you're skipping OR support for now (as I did with Semaphore https://github.com/atuttle/semaphore), but after more consideration I think your "rules" layer supports it. Rules contain tests that are AND'ed together, but if I'm reading correctly your Rules are OR'ed together, which is very similar to the approach I landed on for Semaphore.

Either way, I just like watching passionate people work on stuff they're enjoying. Keep crushin' it. ;)

15,260 Comments

@Adam,

You are 100% correct. The tests within a rule are and'ed but the rules are or'ed. Basically, I am just looping over the rules and finding the first one that matches all the tests and then returning it's variant (based on the rollout, which is either a single value or a weighed distribution).

Passion is right! As we talked about on the podcast, I just freaking love this stuff! Cut me, and I bleed CFML and feature flags 😂 .

15,260 Comments

@Roberto,

Do you have a sense of where in the application should be responsible for this kind of JSON validation? I've been struggling to wrap my head around a solution that feels right. I think for the JSON validation itself - ie, the data that is to be persisted - then that should be in the data persistence layer. But, the layers above that might also need to have other validation (as is outlined in this blog post). Of course, the validation at that level might be different. Meaning, at the persistence layer, a property might be required; but, in the layer above that, the same property might be optional, giving that layer an opportunity to provide a default value.

I could potentially have two different schemas to validate against at the two layers. But, I'm still trying to feel this all out.

15,260 Comments

So, after fleshing-out the underlying demo, I ended up changing my approach slightly. I still have a "Validation" component; and, this component still makes sure nothing untoward gets through; but, the validation object now returns sanitized data. So, instead of just calling something like:

validation.testKey( key )

... I'm not returning the sanitized key as well:

key = validation.testKey( key )

The reason I went this way is because it gives me a single place to both clean and validate data. This allows me to deep copy the user-provided data such that no unexpected struct-keys or control-characters get through.

You can see this here in my FeatureFlagValidation.cfc in my Stangler feature flag exploration.

Post A Comment — I'd Love To Hear From You!

Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.