Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 84 additions & 15 deletions packages/core/src/evaluation/evaluateForSubject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,27 @@ export function evaluateForSubject<T extends FlagValueType>(
logger: Logger,
evaluationTimestampMs: TimeStamp = timeStampNow()
): ResolutionDetails<FlagTypeToValue<T>> {
if (!flag.enabled) {
logger.debug(`returning default assignment because flag is disabled`, {
if (!isValidFlag(flag)) {
logger.debug(`returning default assignment because flag configuration is invalid`, {
flagKey: flag.key,
subjectKey,
})
return {
value: defaultValue,
reason: 'DISABLED',
reason: 'ERROR',
errorCode: 'PARSE_ERROR' as ErrorCode,
flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs),
}
}

if (!isValidFlag(flag)) {
logger.debug(`returning default assignment because flag configuration is invalid`, {
if (!flag.enabled) {
logger.debug(`returning default assignment because flag is disabled`, {
flagKey: flag.key,
subjectKey,
})
return {
value: defaultValue,
reason: 'DEFAULT',
reason: 'DISABLED',
flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs),
}
}
Expand Down Expand Up @@ -156,16 +157,84 @@ function validateTypeMatch(expectedType: FlagValueType, variantType: VariantType
throw new Error(`Invalid expected type: ${expectedType}`)
}

function isValidFlag(flag: Flag): boolean {
return (
Array.isArray(flag.allocations) &&
flag.allocations.every(
(allocation) =>
Array.isArray(allocation.splits) &&
allocation.splits.every((split) => Array.isArray(split.shards)) &&
(allocation.rules === undefined ||
(Array.isArray(allocation.rules) && allocation.rules.every((rule) => isValidRule(rule))))
function isValidFlag(flag: unknown): boolean {
if (!isRecord(flag) || typeof flag.key !== 'string' || typeof flag.enabled !== 'boolean') {
return false
}
if (!isVariantType(flag.variationType) || !isRecord(flag.variations) || !Array.isArray(flag.allocations)) {
return false
}
Comment on lines +164 to +166
const variationType = flag.variationType
const variations = flag.variations
if (
!Object.entries(variations).every(
([variationKey, variation]) =>
isRecord(variation) && variation.key === variationKey && isValidVariationValue(variationType, variation.value)
)
) {
return false
}

return flag.allocations.every(
(allocation) =>
isRecord(allocation) &&
Array.isArray(allocation.splits) &&
allocation.splits.every(
(split) =>
isRecord(split) &&
typeof split.variationKey === 'string' &&
split.variationKey in variations &&
Array.isArray(split.shards) &&
split.shards.every(isValidShard)
) &&
(allocation.rules === undefined ||
(Array.isArray(allocation.rules) && allocation.rules.every((rule) => isValidRule(rule))))
)
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

function isVariantType(value: unknown): value is VariantType {
return value === 'BOOLEAN' || value === 'INTEGER' || value === 'NUMERIC' || value === 'STRING' || value === 'JSON'
}

function isValidVariationValue(variationType: VariantType, value: unknown): boolean {
switch (variationType) {
case 'BOOLEAN':
return typeof value === 'boolean'
case 'INTEGER':
return typeof value === 'number' && Number.isSafeInteger(value)
case 'NUMERIC':
return typeof value === 'number' && Number.isFinite(value)
case 'STRING':
return typeof value === 'string'
case 'JSON':
return typeof value === 'object' && value !== null
}
}

function isValidShard(shard: unknown): boolean {
if (
!isRecord(shard) ||
typeof shard.salt !== 'string' ||
!Number.isInteger(shard.totalShards) ||
(shard.totalShards as number) <= 0 ||
(shard.totalShards as number) > 0xffffffff ||
!Array.isArray(shard.ranges)
) {
return false
}

return shard.ranges.every(
(range) =>
isRecord(range) &&
Number.isInteger(range.start) &&
Number.isInteger(range.end) &&
(range.start as number) >= 0 &&
(range.end as number) >= (range.start as number) &&
(range.end as number) <= (shard.totalShards as number)
)
}

Expand Down
104 changes: 100 additions & 4 deletions packages/core/src/evaluation/rules.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EvaluationContext, EvaluationContextValue } from '@openfeature/core'
import { compareSemanticVersions, parseSemanticVersion } from './semver'

export type ConditionValueType = EvaluationContextValue | EvaluationContextValue[]

Expand All @@ -12,12 +13,26 @@ export enum OperatorType {
ONE_OF = 'ONE_OF',
NOT_ONE_OF = 'NOT_ONE_OF',
IS_NULL = 'IS_NULL',
SEMVER_EQ = 'SEMVER_EQ',
SEMVER_NEQ = 'SEMVER_NEQ',
SEMVER_GTE = 'SEMVER_GTE',
SEMVER_GT = 'SEMVER_GT',
SEMVER_LTE = 'SEMVER_LTE',
SEMVER_LT = 'SEMVER_LT',
}

const supportedOperators = new Set<string>(Object.values(OperatorType))

type NumericOperator = OperatorType.GTE | OperatorType.GT | OperatorType.LTE | OperatorType.LT

type SemVerOperator =
| OperatorType.SEMVER_EQ
| OperatorType.SEMVER_NEQ
| OperatorType.SEMVER_GTE
| OperatorType.SEMVER_GT
| OperatorType.SEMVER_LTE
| OperatorType.SEMVER_LT

type MatchesCondition = {
operator: OperatorType.MATCHES
attribute: string
Expand Down Expand Up @@ -54,29 +69,51 @@ type NullCondition = {
value: boolean
}

type SemVerCondition = {
operator: SemVerOperator
attribute: string
value: string
}

export type Condition =
| MatchesCondition
| NotMatchesCondition
| OneOfCondition
| NotOneOfCondition
| NumericCondition
| NullCondition
| SemVerCondition

export interface Rule {
conditions: Condition[]
}

export function isValidRule(rule: Rule): boolean {
if (!Array.isArray(rule.conditions)) {
export function isValidRule(rule: unknown): rule is Rule {
if (!isRecord(rule) || !Array.isArray(rule.conditions)) {
return false
}

return rule.conditions.every((condition) => {
if (!isRecord(condition) || typeof condition.attribute !== 'string' || typeof condition.operator !== 'string') {
return false
}
if (!supportedOperators.has(condition.operator)) {
return false
}
if (condition.operator !== OperatorType.MATCHES && condition.operator !== OperatorType.NOT_MATCHES) {
return true
if (isNumericOperator(condition.operator)) {
return typeof condition.value === 'number' && Number.isFinite(condition.value)
}
if (condition.operator === OperatorType.ONE_OF || condition.operator === OperatorType.NOT_ONE_OF) {
return Array.isArray(condition.value) && condition.value.every((value) => typeof value === 'string')
}
if (condition.operator === OperatorType.IS_NULL) {
return typeof condition.value === 'boolean'
}
if (isSemVerOperator(condition.operator)) {
return parseSemanticVersion(condition.value) !== undefined
}
if (typeof condition.value !== 'string') {
return false
}
try {
compileRegex(condition.value)
Expand Down Expand Up @@ -132,11 +169,70 @@ function evaluateCondition(subjectAttributes: EvaluationContext, condition: Cond
return isOneOf(value.toString(), condition.value)
case OperatorType.NOT_ONE_OF:
return isNotOneOf(value.toString(), condition.value)
case OperatorType.SEMVER_EQ:
case OperatorType.SEMVER_NEQ:
case OperatorType.SEMVER_GTE:
case OperatorType.SEMVER_GT:
case OperatorType.SEMVER_LTE:
case OperatorType.SEMVER_LT:
return compareSemanticVersion(value, condition.value, condition.operator)
}
}
return false
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

function isNumericOperator(operator: string): operator is NumericOperator {
return (
operator === OperatorType.GTE ||
operator === OperatorType.GT ||
operator === OperatorType.LTE ||
operator === OperatorType.LT
)
}

function isSemVerOperator(operator: string): operator is SemVerOperator {
return (
operator === OperatorType.SEMVER_EQ ||
operator === OperatorType.SEMVER_NEQ ||
operator === OperatorType.SEMVER_GTE ||
operator === OperatorType.SEMVER_GT ||
operator === OperatorType.SEMVER_LTE ||
operator === OperatorType.SEMVER_LT
)
}

function compareSemanticVersion(
attributeValue: EvaluationContextValue,
conditionValue: string,
operator: SemVerOperator
): boolean {
const left = parseSemanticVersion(attributeValue)
const right = parseSemanticVersion(conditionValue)
if (!left || !right) {
return false
}

const comparison = compareSemanticVersions(left, right)
switch (operator) {
case OperatorType.SEMVER_EQ:
return comparison === 0
case OperatorType.SEMVER_NEQ:
return comparison !== 0
case OperatorType.SEMVER_GTE:
return comparison >= 0
case OperatorType.SEMVER_GT:
return comparison > 0
case OperatorType.SEMVER_LTE:
return comparison <= 0
case OperatorType.SEMVER_LT:
return comparison < 0
}
}

function compileRegex(pattern: string): RegExp {
const inlineFlags = pattern.match(/^\(\?([imsu]+)\)/)
const flags = inlineFlags ? [...new Set(inlineFlags[1])].join('') : ''
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/evaluation/semver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
interface PrereleaseIdentifier {
readonly numeric: boolean
readonly value: string
}

export interface SemanticVersion {
readonly core: readonly [number, number, number]
readonly prerelease: readonly PrereleaseIdentifier[]
}

const identifier = String.raw`(?:0|[1-9]\d*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)`
const maxSemanticVersionLength = 256
const semanticVersionPattern = new RegExp(
String.raw`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(${identifier}(?:\.${identifier})*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`
)

export function parseSemanticVersion(value: unknown): SemanticVersion | undefined {
if (typeof value !== 'string' || value.length > maxSemanticVersionLength) {
return undefined
}

const match = semanticVersionPattern.exec(value)
if (!match) {
return undefined
}

const core = [Number(match[1]), Number(match[2]), Number(match[3])] as const
if (!core.every(Number.isSafeInteger)) {
return undefined
}

return {
core,
prerelease: match[4]
? match[4].split('.').map((part) => ({
numeric: /^\d+$/.test(part),
value: part,
}))
: [],
}
}

export function compareSemanticVersions(left: SemanticVersion, right: SemanticVersion): number {
for (let index = 0; index < left.core.length; index++) {
const difference = left.core[index] - right.core[index]
if (difference !== 0) {
return Math.sign(difference)
}
}

if (left.prerelease.length === 0 || right.prerelease.length === 0) {
return left.prerelease.length === right.prerelease.length ? 0 : left.prerelease.length === 0 ? 1 : -1
}

const identifierCount = Math.max(left.prerelease.length, right.prerelease.length)
for (let index = 0; index < identifierCount; index++) {
const leftIdentifier = left.prerelease[index]
const rightIdentifier = right.prerelease[index]
if (!leftIdentifier || !rightIdentifier) {
return leftIdentifier ? 1 : -1
}
if (leftIdentifier.value === rightIdentifier.value) {
continue
}
if (leftIdentifier.numeric !== rightIdentifier.numeric) {
return leftIdentifier.numeric ? -1 : 1
}
if (leftIdentifier.numeric) {
if (leftIdentifier.value.length !== rightIdentifier.value.length) {
return leftIdentifier.value.length < rightIdentifier.value.length ? -1 : 1
}
}
return leftIdentifier.value < rightIdentifier.value ? -1 : 1
}

return 0
}
1 change: 1 addition & 0 deletions packages/core/test/TestCaseResult.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface TestCase {
result: {
value: FlagValue
reason: string
errorCode?: string
variant?: string
flagMetadata?: PrecomputedFlagMetadata
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/test/evaluation/flags-v1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ describe('Universal Flag Configuration V1', () => {
const details = evaluateDetails(testCase, context)
expect(details.value).toEqual(testCase.result.value)
expect(details.reason).toEqual(testCase.result.reason)
if (testCase.result.errorCode !== undefined) {
expect(details.errorCode).toEqual(testCase.result.errorCode)
}
})
})
})
Loading