From 98d7c3f32551ce047eed9586c7a0fd0c478c5329 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 2 Jan 2024 15:54:36 -0500 Subject: [PATCH] feat: add targeting schema, improve def. schema Signed-off-by: Todd Baert --- json/examples/full.json | 241 +++++++++++- json/flagd-definitions.json | 48 ++- json/targeting.json | 506 ++++++++++++++++++++++++++ json/test/positive/example.flagd.json | 53 ++- 4 files changed, 798 insertions(+), 50 deletions(-) create mode 100644 json/targeting.json diff --git a/json/examples/full.json b/json/examples/full.json index 88e359a..52661a1 100644 --- a/json/examples/full.json +++ b/json/examples/full.json @@ -1,4 +1,5 @@ { + "$schema": "../flagd-definitions.json", "flags": { "myBoolFlag": { "state": "ENABLED", @@ -35,6 +36,244 @@ } }, "defaultVariant": "object1" + }, + "fractional-flag": { + "state": "ENABLED", + "variants": { + "clubs": "clubs", + "diamonds": "diamonds", + "hearts": "hearts", + "spades": "spades", + "wild": "wild" + }, + "defaultVariant": "wild", + "targeting": { + "fractional": [ + { "var": "user.name" }, + ["clubs", 25], + ["diamonds", 25], + ["hearts", 25], + ["spades", 25] + ] + } + }, + "shorthand-fractional-flag": { + "state": "ENABLED", + "variants": { + "clubs": "clubs", + "diamonds": "diamonds", + "hearts": "hearts", + "spades": "spades", + "wild": "wild" + }, + "defaultVariant": "wild", + "targeting": { + "fractional": [ + ["clubs", 25], + ["diamonds", 25], + ["hearts", 25], + ["spades", 25] + ] + } + }, + "starts-ends-flag": { + "state": "ENABLED", + "variants": { + "prefix": "prefix", + "postfix": "postfix", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [ + { + "starts_with": [{ "var": "id" }, "abc"] + }, + "prefix", + { + "if": [ + { + "ends_with": [{ "var": "id" }, "xyz"] + }, + "postfix", + { + "if": [ + { + "ends_with": [{ "var": "id" }, 3] + }, + "fail", + "none" + ] + } + ] + } + ] + } + }, + "equal-greater-lesser-version-flag": { + "state": "ENABLED", + "variants": { + "equal": "equal", + "greater": "greater", + "lesser": "lesser", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [ + { + "sem_ver": [{ "var": "version" }, "=", "2.0.0"] + }, + "equal", + { + "if": [ + { + "sem_ver": [{ "var": "version" }, ">", "2.0.0"] + }, + "greater", + { + "if": [ + { + "sem_ver": [{ "var": "version" }, "<", "2.0.0"] + }, + "lesser", + { + "if": [ + { + "sem_ver": [{ "var": "version" }, "=", "2.0.0.0"] + }, + "fail", + null + ] + } + ] + } + ] + } + ] + } + }, + "major-minor-version-flag": { + "state": "ENABLED", + "variants": { + "minor": "minor", + "major": "major", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [ + { + "sem_ver": [{ "var": "version" }, "~", "3.0.0"] + }, + "minor", + { + "if": [ + { + "sem_ver": [{ "var": "version" }, "^", "3.0.0"] + }, + "major", + "none" + ] + } + ] + } + }, + "test-cat": { + "state": "ENABLED", + "variants": { + "minor": "minor", + "major": "major", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "cat": ["1", "@"] + } + }, + "context-aware": { + "state": "ENABLED", + "variants": { + "internal": "INTERNAL", + "external": "EXTERNAL" + }, + "defaultVariant": "external", + "targeting": { + "if": [ + { + "and": [ + { + "==": [ + { + "var": ["fn"] + }, + "Sulisław" + ] + }, + { + "==": [ + { + "var": ["ln"] + }, + "Świętopełk" + ] + }, + { + "==": [ + { + "var": ["age"] + }, + 29 + ] + }, + { + "==": [ + { + "var": ["customer"] + }, + false + ] + } + ] + }, + "internal", + "external" + ] + } + }, + "timestamp-flag": { + "state": "ENABLED", + "variants": { + "past": -1, + "future": 1, + "none": 0 + }, + "defaultVariant": "none", + "targeting": { + "if": [ + { + ">": [{ "var": "$flagd.timestamp" }, { "var": "time" }] + }, + "past", + { + "if": [ + { + "<": [{ "var": "$flagd.timestamp" }, { "var": "time" }] + }, + "future", + "none" + ] + } + ] + } + }, + "wrong-flag": { + "state": "ENABLED", + "variants": { + "one": "uno", + "two": "dos" + }, + "defaultVariant": "one" } } -} \ No newline at end of file +} diff --git a/json/flagd-definitions.json b/json/flagd-definitions.json index 4358dc1..55ae88c 100644 --- a/json/flagd-definitions.json +++ b/json/flagd-definitions.json @@ -5,64 +5,74 @@ "type": "object", "properties": { "flags": { + "title": "Flags", + "description": "Top-level flags object. All flags are defined here.", "type": "object", "$comment": "flag objects are one of the 4 flag types defined in $defs", "additionalProperties": false, "patternProperties": { "^.{1,}$": { + "$comment": "'unevaluatedProperties': 'false' prevents additional props on a flag (ie: targetting)", + "unevaluatedProperties": false, "oneOf": [ { "title": "Boolean flag", - "description": "A flag associated with boolean values", + "description": "A flag having boolean values.", "$ref": "#/$defs/booleanFlag" }, { "title": "String flag", - "description": "A flag associated with string values", + "description": "A flag having string values.", "$ref": "#/$defs/stringFlag" }, { "title": "Numeric flag", - "description": "A flag associated with numeric values", + "description": "A flag having numeric values.", "$ref": "#/$defs/numberFlag" }, { "title": "Object flag", - "description": "A flag associated with arbitrary object values", + "description": "A flag having arbitrary object values.", "$ref": "#/$defs/objectFlag" } ] } } + }, + "$evaluators": { + "title": "Evaluators", + "description": "Reusable targeting rules that can be referenced with \"$ref\": \"myRule\" in multiple flags.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "$comment": "this relative ref means that targeting.json MUST be in the same dir, or available on the same HTTP path", + "$ref": "./targeting.json#/$defs/targeting" + } + } } }, "$defs": { "flag": { - "title": "Flag Base", - "description": "Base object for all flags", + "$comment": "base flag object; no title/description here, allows for better UX, keep it in the overrides", "type": "object", "properties": { "state": { - "description": "Indicates whether the flag is functional. Disabled flags are treated as if they don't exist", + "title": "Flag State", + "description": "Indicates whether the flag is functional. Disabled flags are treated as if they don't exist.", "type": "string", - "enum": [ - "ENABLED", - "DISABLED" - ] + "enum": ["ENABLED", "DISABLED"] }, "defaultVariant": { - "description": "The variant to serve if no dynamic targeting applies", + "title": "Default Variant", + "description": "The variant to serve if no dynamic targeting applies (including if the targeting returns null).", "type": "string" }, "targeting": { - "type": "object", - "description": "JsonLogic expressions to be used for dynamic evaluation. The \"context\" is passed as the data. Rules must resolve one of the defined variants, or the \"defaultVariant\" will be used." + "$ref": "./targeting.json#/$defs/targeting" } }, - "required": [ - "state", - "defaultVariant" - ] + "required": ["state", "defaultVariant"] }, "booleanVariants": { "type": "object", @@ -124,7 +134,7 @@ } } }, - "$comment": "Merge the variants with the base flag to build our typed flags", + "$comment": "merge the variants with the base flag to build our typed flags", "booleanFlag": { "allOf": [ { diff --git a/json/targeting.json b/json/targeting.json new file mode 100644 index 0000000..d649bf7 --- /dev/null +++ b/json/targeting.json @@ -0,0 +1,506 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "flagd Targeting", + "description": "Defines targeting logic for flagd; a extension of JSONLogic, including purpose-built feature-flagging operations.", + "type": "object", + "$defs": { + "targeting": { + "title": "Targeting", + "description": "An expression returning a value which is coerced to a string to be used as a targeting key, or null (to fall back to defaultVariant). If targeting returns a value which is not a variant key, it's considered an error.", + "anyOf": [ + { + "$comment": "we need this to support empty targeting", + "type": "object", + "additionalProperties": false, + "properties": {} + }, + { + "$ref": "#/$defs/anyRule" + } + ] + }, + "primitive": { + "oneOf": [ + { + "description": "When returned from rules, a null value \"exits\", the targeting, and the \"defaultValue\" is returned, with the reason indicating the targeting did not match.", + "type": "null" + }, + { + "description": "When returned from rules, booleans are converted to strings (\"true\"/\"false\"), and used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!", + "type": "boolean" + }, + { + "description": "When returned from rules, the behavior of numbers is not defined.", + "type": "number" + }, + { + "description": "When returned from rules, strings are used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!.", + "type": "string" + } + ] + }, + "varRule": { + "title": "Var Operation", + "description": "Retrieve data from the provided data object.", + "type": "object", + "additionalProperties": false, + "properties": { + "var": { + "anyOf": [ + { + "type": "string", + "description": "flagd automatically injects \"$lagd.timestamp\" (unix epoch) and \"$lagd.flagKey\" (the key of the flag in evaluation) into the context.", + "pattern": "^\\$flagd\\.((timestamp)|(flagKey))$" + }, + { + "type": "string", + "pattern": "^(?!\\$flagd\\.).+" + }, + { + "type": "array", + "items": [{ "type": "string" }, { "type": {} }] + } + ] + } + } + }, + "missingRule": { + "title": "Missing Operation", + "description": "Takes an array of data keys to search for (same format as var). Returns an array of any keys that are missing from the data object, or an empty array.", + "type": "object", + "additionalProperties": false, + "properties": { + "missing": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "missingSomeRule": { + "title": "Missing-Some Operation", + "description": "Takes a minimum number of data keys that are required, and an array of keys to search for (same format as var or missing). Returns an empty array if the minimum is met, or an array of the missing keys otherwise.", + "type": "object", + "additionalProperties": false, + "properties": { + "missing_some": { + "type": "array", + "items": [ + { "type": "number" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + } + } + }, + "ifRule": { + "type": "object", + "additionalProperties": false, + "minItems": 3, + "maxItems": 3, + "properties": { + "if": { + "title": "If Operator", + "description": "The if statement takes 3 arguments: a condition (if), what to do if it’s true (then), and what to do if it’s false (else). Note that the form accepting more than 3 arguments (if/else) is not supported in flagd; use nesting instead.", + + "type": "array", + "items": { + "$ref": "#/$defs/args" + } + } + } + }, + "binaryOp": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "$ref": "#/$defs/args" + } + }, + "binaryRule": { + "title": "Binary Operation", + "description": "Any primitive JSONLogic operation with 2 operands.", + "type": "object", + "additionalProperties": false, + "properties": { + "==": { + "title": "Lose Equality Operation", + "description": "Tests equality, with type coercion. Requires two arguments.", + "$ref": "#/$defs/binaryOp" + }, + "===": { + "title": "Strict Equality Operation", + "description": "Tests strict equality. Requires two arguments.", + "$ref": "#/$defs/binaryOp" + }, + "!=": { + "title": "Lose Inequality Operation", + "description": "Tests not-equal, with type coercion.", + "$ref": "#/$defs/binaryOp" + }, + "!==": { + "title": "Strict Inequality Operation", + "description": "Tests strict not-equal.", + "$ref": "#/$defs/binaryOp" + }, + ">": { + "title": "Greater-Than Operation", + "$ref": "#/$defs/binaryOp" + }, + ">=": { + "title": "Greater-Than-Or-Equal-To Operation", + "$ref": "#/$defs/binaryOp" + }, + "%": { + "title": "Modulo Operation", + "description": "Finds the remainder after the first argument is divided by the second argument.", + "$ref": "#/$defs/binaryOp" + }, + "map": { + "title": "Map Operation", + "description": "Perform an action on every member of an array. Note, that inside the logic being used to map, var operations are relative to the array element being worked on.", + "$ref": "#/$defs/binaryOp" + }, + "reduce": { + "title": "Reduce Operation", + "description": "Combine all the elements in an array into a single value, like adding up a list of numbers. Note, that inside the logic being used to reduce, var operations only have access to an object with a \"current\" and a \"accumulator\".", + "$ref": "#/$defs/binaryOp" + }, + "filter": { + "title": "Filter Operation", + "description": "Keep only elements of the array that pass a test. Note, that inside the logic being used to filter, var operations are relative to the array element being worked on.", + "$ref": "#/$defs/binaryOp" + }, + "all": { + "title": "All Operation", + "description": "Perform a test on each member of that array, returning true if all pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/$defs/binaryOp" + }, + "none": { + "title": "None Operation", + "description": "Perform a test on each member of that array, returning true if none pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/$defs/binaryOp" + }, + "some": { + "title": "Some Operation", + "description": "Perform a test on each member of that array, returning true if some pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/$defs/binaryOp" + }, + "in": { + "title": "In Operation", + "description": "If the second argument is an array, tests that the first argument is a member of the array.", + "$ref": "#/$defs/binaryOp" + }, + "substr": { + "title": "Substring Operation", + "description": "Get a portion of a string, given a positive start position as an index (indexes of course start at zero).", + "$ref": "#/$defs/binaryOp" + } + } + }, + "associativeOp": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/$defs/args" + } + }, + "associativeRule": { + "title": "Mathematically Associative Operation", + "description": "Pperation applicable to 2 or more parameters.", + "type": "object", + "additionalProperties": false, + "properties": { + "+": { + "title": "Addition Operation", + "description": "Addition; associative, will accept and unlimited amount of arguments.", + "$ref": "#/$defs/associativeOp" + }, + "*": { + "title": "Multiplication Operation", + "description": "Multiplication; associative, will accept and unlimited amount of arguments.", + "$ref": "#/$defs/associativeOp" + } + } + }, + "unaryOp": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/$defs/args" + } + }, + "unaryRule": { + "title": "Unary Operation", + "description": "Any primitive JSONLogic operation with 1 operands.", + "type": "object", + "additionalProperties": false, + "properties": { + "!": { + "title": "Negation Operation", + "description": "Logical negation (“not”). Takes just one argument.", + "$ref": "#/$defs/unaryOp" + }, + "!!": { + "title": "Double Negation Operation", + "description": "Double negation, or 'cast to a boolean'. Takes a single argument.", + "$ref": "#/$defs/unaryOp" + }, + "max": { + "title": "Maximum Operation", + "description": "Return the maximum from a list of values.", + "$ref": "#/$defs/unaryOp" + }, + "min": { + "title": "Minimum Operation", + "description": "Return the minimum from a list of values.", + "$ref": "#/$defs/unaryOp" + } + } + }, + "variadicOp": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/args" + } + }, + "variadicRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "or": { + "title": "Or Operation", + "description": "Simple boolean test, with 1 or more arguments. At a more sophisticated level, \"or\" returns the first truthy argument, or the last argument.", + "$ref": "#/$defs/variadicOp" + }, + "and": { + "title": "", + "description": "Simple boolean test, with 1 or more arguments. At a more sophisticated level, \"and\" returns the first falsy argument, or the last argument.", + "$ref": "#/$defs/variadicOp" + }, + "$comment": "< and <= can be used with up to 3 ops (between)", + "<": { + "title": "Less-Than/Between Operation. Can be used to test that one value is between two others.", + "description": "", + "$ref": "#/$defs/variadicOp" + }, + "<=": { + "title": "Less-Than-Or-Equal-To/Between Operation. Can be used to test that one value is between two others.", + "description": "", + "$ref": "#/$defs/variadicOp" + }, + "-": { + "title": "Subtraction Operation", + "$ref": "#/$defs/variadicOp" + }, + "merge": { + "title": "Subtraction Operation", + "description": "Takes one or more arrays, and merges them into one array. If arguments aren't arrays, they get cast to arrays.", + "$ref": "#/$defs/variadicOp" + }, + "cat": { + "title": "Concatenate Operation", + "description": "Concatenate all the supplied arguments. Note that this is not a join or implode operation, there is no “glue” string.", + "$ref": "#/$defs/variadicOp" + } + } + }, + "stringCompareArg": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/anyRule" + } + ] + }, + "stringCompareArgs": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/$defs/stringCompareArg" + } + }, + "stringCompareRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "starts_with": { + "title": "Starts-With Operation", + "description": "Attribute starts with the specified value.", + "$ref": "#/$defs/stringCompareArgs" + }, + "ends_with": { + "title": "Ends-With Operation", + "description": "Attribute ends with the specified value.", + "$ref": "#/$defs/stringCompareArgs" + } + } + }, + "semVerString": { + "title": "Semantic Version String", + "description": "A string representing a valid semantic version expression as per https://semver.org/.", + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "ruleSemVer": { + "type": "object", + "additionalProperties": false, + "properties": { + "sem_ver": { + "title": "Semantic Version Operation", + "description": "Attribute matches a semantic version condition. Accepts \"npm-style\" range specifiers: \"=\", \"!=\", \">\", \"<\", \">=\", \"<=\", \"~\" (match minor version), \"^\" (match major version).", + "type": "array", + "minItems": 3, + "items": [ + { + "oneOf": [ + { + "$ref": "#/$defs/semVerString" + }, + { + "$ref": "#/$defs/varRule" + } + ] + }, + { + "description": "Range specifiers: \"=\", \"!=\", \">\", \"<\", \">=\", \"<=\", \"~\" (match minor version), \"^\" (match major version).", + "enum": ["=", "!=", ">", "<", ">=", "<=", "~", "^"] + }, + { + "oneOf": [ + { + "$ref": "#/$defs/semVerString" + }, + { + "$ref": "#/$defs/varRule" + } + ] + } + ] + } + } + }, + "fractionalWeightArg": { + "$comment": "if we remove the \"some to 100\" restriction, update the descriptions below!", + "description": "Distribution for all possible variants, with their associated weighting out of 100.", + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + { + "description": "If this bucket is randomly selected, this string is used to as a key to retrieve the associated value from the \"variants\" object.", + "type": "string" + } + ], + "additionalItems": { + "description": "Weighted distribution for this variant key (must sum to 100).", + "type": "number" + } + }, + "fractionalOp": { + "type": "array", + "minItems": 3, + "items": [ + { + "description": "Bucketing value used in pseudorandom assignment; should be unique and stable for each subject of flag evaluation. Defaults to a concatenation of the flagKey and targetingKey.", + "$ref": "#/$defs/varRule" + } + ], + "additionalItems": { + "$ref": "#/$defs/fractionalWeightArg" + } + }, + "fractionalShorthandOp": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/$defs/fractionalWeightArg" + } + }, + "fractionalRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "fractional": { + "title": "Fractional Operation", + "description": "Deterministic, pseudorandom fractional distribution.", + "oneOf": [ + { + "$ref": "#/$defs/fractionalOp" + }, + { + "$ref": "#/$defs/fractionalShorthandOp" + } + ] + } + } + }, + "reference": { + "additionalProperties": false, + "type": "object", + "properties": { + "$ref": { + "title": "Reference", + "description": "A reference to another entity, used for $evaluators (shared rules).", + "type": "string" + } + } + }, + "args": { + "oneOf": [ + { + "$ref": "#/$defs/reference" + }, + { + "$ref": "#/$defs/anyRule" + }, + { + "$ref": "#/$defs/primitive" + } + ] + }, + "anyRule": { + "oneOf": [ + { + "$ref": "#/$defs/varRule" + }, + { + "$ref": "#/$defs/missingRule" + }, + { + "$ref": "#/$defs/missingSomeRule" + }, + { + "$ref": "#/$defs/ifRule" + }, + { + "$ref": "#/$defs/binaryRule" + }, + { + "$ref": "#/$defs/associativeRule" + }, + { + "$ref": "#/$defs/unaryRule" + }, + { + "$ref": "#/$defs/variadicRule" + }, + { + "$ref": "#/$defs/stringCompareRule" + }, + { + "$ref": "#/$defs/ruleSemVer" + }, + { + "$ref": "#/$defs/fractionalRule" + } + ] + } + } +} diff --git a/json/test/positive/example.flagd.json b/json/test/positive/example.flagd.json index a18355e..c7276ce 100644 --- a/json/test/positive/example.flagd.json +++ b/json/test/positive/example.flagd.json @@ -1,4 +1,5 @@ { + "$schema": "../../flagd-definitions.json", "flags": { "myBoolFlag": { "state": "ENABLED", @@ -56,9 +57,7 @@ { "==": [ { - "var": [ - "color" - ] + "var": ["color"] }, "yellow" ] @@ -81,7 +80,9 @@ "if": [ { "$ref": "emailWithFaas" - }, "binet", null + }, + "binet", + null ] } }, @@ -97,38 +98,30 @@ "targeting": { "if": [ { - "$ref": "emailWithFaas" + "$ref": 1 }, { "fractionalEvaluation": [ "email", - [ - "red", - 25 - ], - [ - "blue", - 25 - ], - [ - "green", - 25 - ], - [ - "yellow", - 25 - ] + ["red", 25], + ["blue", 25], + ["green", 25], + ["yellow", 25] ] - }, null + }, + null ] } } - }, - "$evaluators": { - "emailWithFaas": { - "in": ["@faas.com", { - "var": ["email"] - }] - } + }, + "$evaluators": { + "emailWithFaas": { + "in": [ + "@faas.com", + { + "var": ["email"] + } + ] } - } \ No newline at end of file + } +}