Skip to content

Commit 2a25a22

Browse files
committed
feat: add SEMVER flag evaluation
1 parent c74dd21 commit 2a25a22

6 files changed

Lines changed: 625 additions & 10 deletions

File tree

packages/core/src/evaluation/evaluateForSubject.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type TimeStamp, timeStampNow } from '../time'
44
import { TargetingKeyMissingError } from './errors'
55
import { createEvaluationTimestampMetadata } from './evaluationMetadata'
66
import { matchesShard } from './matchesShard'
7-
import { isValidRule, matchesRule, type Rule } from './rules'
7+
import { hasInvalidSemverComparand, isValidRule, matchesRule, type Rule } from './rules'
88
import { type Flag, type Split, type VariantType, variantTypeToFlagValueType } from './ufc-v1'
99

1010
export function evaluateForSubject<T extends FlagValueType>(
@@ -157,15 +157,25 @@ function validateTypeMatch(expectedType: FlagValueType, variantType: VariantType
157157
}
158158

159159
function isValidFlag(flag: Flag): boolean {
160-
return (
161-
Array.isArray(flag.allocations) &&
162-
flag.allocations.every(
160+
if (!Array.isArray(flag.allocations)) {
161+
return false
162+
}
163+
164+
if (
165+
flag.allocations.some(
163166
(allocation) =>
164-
Array.isArray(allocation.splits) &&
165-
allocation.splits.every((split) => Array.isArray(split.shards)) &&
166-
(allocation.rules === undefined ||
167-
(Array.isArray(allocation.rules) && allocation.rules.every((rule) => isValidRule(rule))))
167+
Array.isArray(allocation.rules) && allocation.rules.some((rule) => hasInvalidSemverComparand(rule))
168168
)
169+
) {
170+
throw new Error('invalid semantic version comparand')
171+
}
172+
173+
return flag.allocations.every(
174+
(allocation) =>
175+
Array.isArray(allocation.splits) &&
176+
allocation.splits.every((split) => Array.isArray(split.shards)) &&
177+
(allocation.rules === undefined ||
178+
(Array.isArray(allocation.rules) && allocation.rules.every((rule) => isValidRule(rule))))
169179
)
170180
}
171181

packages/core/src/evaluation/rules.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { EvaluationContext, EvaluationContextValue } from '@openfeature/core'
2+
import { compareSemver, parseSemver } from './semver'
23

34
export type ConditionValueType = EvaluationContextValue | EvaluationContextValue[]
45

@@ -12,6 +13,12 @@ export enum OperatorType {
1213
ONE_OF = 'ONE_OF',
1314
NOT_ONE_OF = 'NOT_ONE_OF',
1415
IS_NULL = 'IS_NULL',
16+
SEMVER_EQ = 'SEMVER_EQ',
17+
SEMVER_NEQ = 'SEMVER_NEQ',
18+
SEMVER_LT = 'SEMVER_LT',
19+
SEMVER_LTE = 'SEMVER_LTE',
20+
SEMVER_GT = 'SEMVER_GT',
21+
SEMVER_GTE = 'SEMVER_GTE',
1522
}
1623

1724
const supportedOperators = new Set<string>(Object.values(OperatorType))
@@ -54,13 +61,28 @@ type NullCondition = {
5461
value: boolean
5562
}
5663

64+
type SemverOperator =
65+
| OperatorType.SEMVER_EQ
66+
| OperatorType.SEMVER_NEQ
67+
| OperatorType.SEMVER_LT
68+
| OperatorType.SEMVER_LTE
69+
| OperatorType.SEMVER_GT
70+
| OperatorType.SEMVER_GTE
71+
72+
type SemverCondition = {
73+
operator: SemverOperator
74+
attribute: string
75+
value: string
76+
}
77+
5778
export type Condition =
5879
| MatchesCondition
5980
| NotMatchesCondition
6081
| OneOfCondition
6182
| NotOneOfCondition
6283
| NumericCondition
6384
| NullCondition
85+
| SemverCondition
6486

6587
export interface Rule {
6688
conditions: Condition[]
@@ -75,6 +97,9 @@ export function isValidRule(rule: Rule): boolean {
7597
if (!supportedOperators.has(condition.operator)) {
7698
return false
7799
}
100+
if (isSemverOperator(condition.operator)) {
101+
return parseSemver(condition.value) !== null
102+
}
78103
if (condition.operator !== OperatorType.MATCHES && condition.operator !== OperatorType.NOT_MATCHES) {
79104
return true
80105
}
@@ -132,11 +157,67 @@ function evaluateCondition(subjectAttributes: EvaluationContext, condition: Cond
132157
return isOneOf(value.toString(), condition.value)
133158
case OperatorType.NOT_ONE_OF:
134159
return isNotOneOf(value.toString(), condition.value)
160+
case OperatorType.SEMVER_EQ:
161+
case OperatorType.SEMVER_NEQ:
162+
case OperatorType.SEMVER_LT:
163+
case OperatorType.SEMVER_LTE:
164+
case OperatorType.SEMVER_GT:
165+
case OperatorType.SEMVER_GTE:
166+
return evaluateSemverCondition(value, condition.value, condition.operator)
135167
}
136168
}
137169
return false
138170
}
139171

172+
export function isSemverOperator(operator: string): operator is SemverOperator {
173+
return (
174+
operator === OperatorType.SEMVER_EQ ||
175+
operator === OperatorType.SEMVER_NEQ ||
176+
operator === OperatorType.SEMVER_LT ||
177+
operator === OperatorType.SEMVER_LTE ||
178+
operator === OperatorType.SEMVER_GT ||
179+
operator === OperatorType.SEMVER_GTE
180+
)
181+
}
182+
183+
export function hasInvalidSemverComparand(rule: Rule): boolean {
184+
return rule.conditions.some(
185+
(condition) => isSemverOperator(condition.operator) && parseSemver(condition.value) === null
186+
)
187+
}
188+
189+
function evaluateSemverCondition(
190+
attributeValue: EvaluationContextValue,
191+
comparandValue: string,
192+
operator: SemverOperator
193+
): boolean {
194+
if (typeof attributeValue !== 'string') {
195+
return false
196+
}
197+
198+
const attribute = parseSemver(attributeValue)
199+
const comparand = parseSemver(comparandValue)
200+
if (!attribute || !comparand) {
201+
return false
202+
}
203+
204+
const ordering = compareSemver(attribute, comparand)
205+
switch (operator) {
206+
case OperatorType.SEMVER_EQ:
207+
return ordering === 0
208+
case OperatorType.SEMVER_NEQ:
209+
return ordering !== 0
210+
case OperatorType.SEMVER_LT:
211+
return ordering < 0
212+
case OperatorType.SEMVER_LTE:
213+
return ordering <= 0
214+
case OperatorType.SEMVER_GT:
215+
return ordering > 0
216+
case OperatorType.SEMVER_GTE:
217+
return ordering >= 0
218+
}
219+
}
220+
140221
function compileRegex(pattern: string): RegExp {
141222
const inlineFlags = pattern.match(/^\(\?([imsu]+)\)/)
142223
const flags = inlineFlags ? [...new Set(inlineFlags[1])].join('') : ''
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
const MAX_UINT64 = '18446744073709551615'
2+
3+
/**
4+
* The language-neutral SemVer representation used by the FFE evaluator.
5+
* Build metadata is validated while parsing but is intentionally not retained,
6+
* because it does not affect SemVer precedence.
7+
*/
8+
export interface ParsedSemver {
9+
major: string
10+
minor: string
11+
patch: string
12+
prerelease: string
13+
}
14+
15+
/**
16+
* Parse the SemVer subset.
17+
* Core identifiers are limited to uint64; numeric prerelease identifiers may
18+
* be arbitrarily large.
19+
*/
20+
export function parseSemver(version: unknown): ParsedSemver | null {
21+
if (typeof version !== 'string') {
22+
return null
23+
}
24+
25+
const major = parseCoreIdentifier(version, 0)
26+
if (!major || major.next >= version.length || version[major.next] !== '.') {
27+
return null
28+
}
29+
30+
const minor = parseCoreIdentifier(version, major.next + 1)
31+
if (!minor || minor.next >= version.length || version[minor.next] !== '.') {
32+
return null
33+
}
34+
35+
const patch = parseCoreIdentifier(version, minor.next + 1)
36+
if (!patch) {
37+
return null
38+
}
39+
40+
const parsed: ParsedSemver = {
41+
major: major.value,
42+
minor: minor.value,
43+
patch: patch.value,
44+
prerelease: '',
45+
}
46+
47+
if (patch.next === version.length) {
48+
return parsed
49+
}
50+
51+
let remainder = version.slice(patch.next)
52+
if (remainder.startsWith('-')) {
53+
remainder = remainder.slice(1)
54+
const buildStart = remainder.indexOf('+')
55+
if (buildStart === -1) {
56+
return isValidSemverIdentifiers(remainder, false) ? { ...parsed, prerelease: remainder } : null
57+
}
58+
59+
const prerelease = remainder.slice(0, buildStart)
60+
if (!isValidSemverIdentifiers(prerelease, false)) {
61+
return null
62+
}
63+
parsed.prerelease = prerelease
64+
remainder = remainder.slice(buildStart + 1)
65+
} else if (remainder.startsWith('+')) {
66+
remainder = remainder.slice(1)
67+
} else {
68+
return null
69+
}
70+
71+
return isValidSemverIdentifiers(remainder, true) ? parsed : null
72+
}
73+
74+
/** Compare SemVer precedence. Build metadata is intentionally ignored. */
75+
export function compareSemver(left: ParsedSemver, right: ParsedSemver): number {
76+
for (const [leftValue, rightValue] of [
77+
[left.major, right.major],
78+
[left.minor, right.minor],
79+
[left.patch, right.patch],
80+
]) {
81+
const ordering = compareNumericStrings(leftValue, rightValue)
82+
if (ordering !== 0) {
83+
return ordering
84+
}
85+
}
86+
87+
return compareSemverPrerelease(left.prerelease, right.prerelease)
88+
}
89+
90+
function parseCoreIdentifier(version: string, start: number): { value: string; next: number } | null {
91+
if (start >= version.length || !isAsciiDigit(version.charCodeAt(start))) {
92+
return null
93+
}
94+
95+
if (version[start] === '0') {
96+
return { value: '0', next: start + 1 }
97+
}
98+
99+
let end = start
100+
while (end < version.length && isAsciiDigit(version.charCodeAt(end))) {
101+
end++
102+
}
103+
104+
const value = version.slice(start, end)
105+
if (value.length > MAX_UINT64.length || (value.length === MAX_UINT64.length && value > MAX_UINT64)) {
106+
return null
107+
}
108+
return { value, next: end }
109+
}
110+
111+
function isValidSemverIdentifiers(value: string, allowLeadingZeros: boolean): boolean {
112+
let identifierStart = 0
113+
let identifierNumeric = true
114+
115+
for (let i = 0; i <= value.length; i++) {
116+
if (i === value.length || value[i] === '.') {
117+
if (i === identifierStart) {
118+
return false
119+
}
120+
if (!allowLeadingZeros && identifierNumeric && i - identifierStart > 1 && value[identifierStart] === '0') {
121+
return false
122+
}
123+
identifierStart = i + 1
124+
identifierNumeric = true
125+
continue
126+
}
127+
128+
const code = value.charCodeAt(i)
129+
if (!isAsciiAlphanumeric(code) && value[i] !== '-') {
130+
return false
131+
}
132+
if (!isAsciiDigit(code)) {
133+
identifierNumeric = false
134+
}
135+
}
136+
137+
return true
138+
}
139+
140+
function compareSemverPrerelease(left: string, right: string): number {
141+
if (left === right) {
142+
return 0
143+
}
144+
if (left === '') {
145+
return 1
146+
}
147+
if (right === '') {
148+
return -1
149+
}
150+
151+
let leftRemaining = left
152+
let rightRemaining = right
153+
while (true) {
154+
const [leftIdentifier, nextLeft] = nextSemverIdentifier(leftRemaining)
155+
const [rightIdentifier, nextRight] = nextSemverIdentifier(rightRemaining)
156+
const ordering = compareSemverIdentifier(leftIdentifier, rightIdentifier)
157+
if (ordering !== 0) {
158+
return ordering
159+
}
160+
161+
if (nextLeft === '' || nextRight === '') {
162+
if (nextLeft === '' && nextRight === '') {
163+
return 0
164+
}
165+
return nextLeft === '' ? -1 : 1
166+
}
167+
168+
leftRemaining = nextLeft.slice(1)
169+
rightRemaining = nextRight.slice(1)
170+
}
171+
}
172+
173+
function nextSemverIdentifier(value: string): [string, string] {
174+
const dot = value.indexOf('.')
175+
return dot === -1 ? [value, ''] : [value.slice(0, dot), value.slice(dot)]
176+
}
177+
178+
function compareSemverIdentifier(left: string, right: string): number {
179+
const leftNumeric = isSemverNumericIdentifier(left)
180+
const rightNumeric = isSemverNumericIdentifier(right)
181+
182+
if (leftNumeric && rightNumeric) {
183+
return compareNumericStrings(left, right)
184+
}
185+
if (leftNumeric) {
186+
return -1
187+
}
188+
if (rightNumeric) {
189+
return 1
190+
}
191+
return compareAsciiStrings(left, right)
192+
}
193+
194+
function isSemverNumericIdentifier(value: string): boolean {
195+
for (let i = 0; i < value.length; i++) {
196+
if (!isAsciiDigit(value.charCodeAt(i))) {
197+
return false
198+
}
199+
}
200+
return true
201+
}
202+
203+
function compareNumericStrings(left: string, right: string): number {
204+
if (left.length !== right.length) {
205+
return left.length < right.length ? -1 : 1
206+
}
207+
return compareAsciiStrings(left, right)
208+
}
209+
210+
function compareAsciiStrings(left: string, right: string): number {
211+
const length = Math.min(left.length, right.length)
212+
for (let i = 0; i < length; i++) {
213+
const leftCode = left.charCodeAt(i)
214+
const rightCode = right.charCodeAt(i)
215+
if (leftCode !== rightCode) {
216+
return leftCode < rightCode ? -1 : 1
217+
}
218+
}
219+
if (left.length === right.length) {
220+
return 0
221+
}
222+
return left.length < right.length ? -1 : 1
223+
}
224+
225+
function isAsciiDigit(code: number): boolean {
226+
return code >= 48 && code <= 57
227+
}
228+
229+
function isAsciiAlphanumeric(code: number): boolean {
230+
return isAsciiDigit(code) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
231+
}

0 commit comments

Comments
 (0)