Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for new constraint operators #289

Merged
merged 12 commits into from
Feb 21, 2022
26 changes: 26 additions & 0 deletions examples/constraint-operators.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const { initialize, isEnabled } = require('../lib');

const client = initialize({
appName: 'my-application',
url: 'http://localhost:4242/api/',
refreshInterval: 1000,
customHeaders: {
Authorization: '*:development.7d9ca8d289c1545f1e28a6e4b2e25453c6cfc90346876ac7240c6668' },
});

client.on('error', console.error);
client.on('warn', console.log);

console.log('Fetching toggles from: http://unleash.herokuapp.com');

setInterval(() => {
const toggle = 'TestOperator';
const context = {
properties: {
email: 'ivar@getunleash.ai',
age: 37
}
}
const enabled = isEnabled(toggle, context);
console.log(`${toggle}: ${enabled ? 'on' : 'off'}`);
}, 1000);
5 changes: 3 additions & 2 deletions examples/simple_usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ const { initialize, isEnabled } = require('../lib');

const client = initialize({
appName: 'my-application',
url: 'http://unleash.herokuapp.com/api/',
url: 'http://localhost:3000/api/',
refreshInterval: 1000,
customHeaders: {
Authorization: '*:development.ba76487db29d7ef2557977a25b477c2e6288e2d9334fd1b91f63e2a9',
}
Expand All @@ -18,4 +19,4 @@ console.log('Fetching toggles from: http://unleash.herokuapp.com');

setInterval(() => {
console.log(`featureX enabled: ${isEnabled('featureX')}`);
}, 1000);
}, 1000);
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"ip": "^1.1.5",
"make-fetch-happen": "^9.0.4",
"murmurhash3js": "^3.0.1",
"nodemon": "^2.0.15"
"nodemon": "^2.0.15",
"semver": "^7.3.5"
},
"engines": {
"node": ">=10",
Expand All @@ -47,8 +48,9 @@
"@types/make-fetch-happen": "9.0.1",
"@types/murmurhash3js": "3.0.2",
"@types/node": "14.18.9",
"@types/semver": "^7.3.9",
"@typescript-eslint/eslint-plugin": "5.10.0",
"@unleash/client-specification": "4.0.0",
"@unleash/client-specification": "4.1.0",
"ava": "3.15.0",
"coveralls": "3.1.1",
"cross-env": "7.0.3",
Expand Down
3 changes: 2 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ export interface Properties {
}

export interface Context {
[key: string]: string | undefined | number | Properties;
[key: string]: string | Date | undefined | number | Properties;
currentTime?: Date;
userId?: string;
sessionId?: string;
remoteAddress?: string;
Expand Down
6 changes: 3 additions & 3 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ export function createFallbackFunction(
return () => false;
}

export function resolveContextValue(context: Context, field: string) {
export function resolveContextValue(context: Context, field: string): string | undefined {
if (context[field]) {
return context[field];
return context[field] as string;
}
if (context.properties && context.properties[field]) {
return context.properties[field];
return context.properties[field] as string;
}
return undefined;
}
Expand Down
148 changes: 142 additions & 6 deletions src/strategy/strategy.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { gt as semverGt, lt as semverLt, eq as semverEq } from 'semver';
import { Context } from '../context';
import { resolveContextValue } from '../helpers';

Expand All @@ -10,14 +11,139 @@ export interface StrategyTransportInterface {
export interface Constraint {
contextName: string;
operator: Operator;
inverted: boolean;
values: string[];
value?: string | number | Date;
caseInsensitive?: boolean;
}

export enum Operator {
IN = <any>'IN',
NOT_IN = <any>'NOT_IN',
IN = 'IN',
NOT_IN = 'NOT_IN',
STR_ENDS_WITH = 'STR_ENDS_WITH',
STR_STARTS_WITH = 'STR_STARTS_WITH',
STR_CONTAINS = 'STR_CONTAINS',
NUM_EQ = 'NUM_EQ',
NUM_GT = 'NUM_GT',
NUM_GTE = 'NUM_GTE',
NUM_LT = 'NUM_LT',
NUM_LTE = 'NUM_LTE',
DATE_AFTER = 'DATE_AFTER',
DATE_BEFORE = 'DATE_BEFORE',
SEMVER_EQ = 'SEMVER_EQ',
SEMVER_GT = 'SEMVER_GT',
SEMVER_LT = 'SEMVER_LT',
}

export type OperatorImpl = (constraint: Constraint, context: Context) => boolean;

const cleanValues = (values: string[]) => values
.filter(v => !!v)
.map(v => v.trim())

const InOperator = (constraint: Constraint, context: Context) => {
const field = constraint.contextName;
const values = cleanValues(constraint.values);
const contextValue = resolveContextValue(context, field);
if(!contextValue) {
return false;
}

const isIn = values.some(val => val === contextValue);
return constraint.operator === Operator.IN ? isIn : !isIn;
}

const StringOperator = (constraint: Constraint, context: Context) => {
const { contextName, operator, caseInsensitive} = constraint;
let values = cleanValues(constraint.values);
let contextValue = resolveContextValue(context, contextName);

if(caseInsensitive) {
values = values.map(v => v.toLocaleLowerCase());
contextValue = contextValue?.toLocaleLowerCase();
}

if(operator === Operator.STR_STARTS_WITH) {
return values.some(val => contextValue?.startsWith(val));
} if(operator === Operator.STR_ENDS_WITH) {
return values.some(val => contextValue?.endsWith(val));
} if(operator === Operator.STR_CONTAINS) {
return values.some(val => contextValue?.includes(val));
}
return false;
}

const SemverOperator = (constraint: Constraint, context: Context) => {
const { contextName, operator} = constraint;
const value = constraint.value as string;
const contextValue = resolveContextValue(context, contextName);
if(!contextValue) {
return false;
}

if(operator === Operator.SEMVER_EQ) {
return semverEq(contextValue, value);
} if(operator === Operator.SEMVER_LT) {
return semverLt(contextValue, value);
} if(operator === Operator.SEMVER_GT) {
return semverGt(contextValue, value);
}
return false;
}

const DateOperator = (constraint: Constraint, context: Context) => {
const { operator } = constraint;
const value = new Date(constraint.value as string);
const currentTime = context.currentTime ? new Date(context.currentTime) : new Date();
Copy link
Member Author

@ivarconr ivarconr Feb 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we might want to move this to the creation of the context.

(+) The benefit will be that the date and time will not change between multiple constriants within the same feature toggle.
(-) The downside would be that we would have to create the currentTime date instance on the context, even when a use is not using any constraints that uses date operators.


if(operator === Operator.DATE_AFTER) {
return currentTime > value;
} if(operator === Operator.DATE_BEFORE) {
return currentTime < value;
}
return false;
}

const NumberOperator = (constraint: Constraint, context: Context) => {
const field = constraint.contextName;
const {operator} = constraint;
const value = Number(constraint.value);
const contextValue = Number(resolveContextValue(context, field));

if(Number.isNaN(value) || Number.isNaN(contextValue)) {
return false;
}

if(operator === Operator.NUM_EQ) {
return contextValue === value;
} if(operator === Operator.NUM_GT) {
return contextValue > value;
} if(operator === Operator.NUM_GTE) {
return contextValue >= value;
} if(operator === Operator.NUM_LT) {
return contextValue < value;
} if(operator === Operator.NUM_LTE) {
return contextValue <= value;
}
return false;
}

const operators = new Map<Operator, OperatorImpl>();
operators.set(Operator.IN, InOperator);
operators.set(Operator.NOT_IN, InOperator);
operators.set(Operator.STR_STARTS_WITH, StringOperator);
operators.set(Operator.STR_ENDS_WITH, StringOperator);
operators.set(Operator.STR_CONTAINS, StringOperator);
operators.set(Operator.NUM_EQ, NumberOperator);
operators.set(Operator.NUM_LT, NumberOperator);
operators.set(Operator.NUM_LTE, NumberOperator);
operators.set(Operator.NUM_GT, NumberOperator);
operators.set(Operator.NUM_GTE, NumberOperator);
operators.set(Operator.DATE_AFTER, DateOperator);
operators.set(Operator.DATE_BEFORE, DateOperator);
operators.set(Operator.SEMVER_EQ, SemverOperator);
operators.set(Operator.SEMVER_GT, SemverOperator);
operators.set(Operator.SEMVER_LT, SemverOperator);
export class Strategy {
public name: string;

Expand All @@ -28,11 +154,21 @@ export class Strategy {
this.returnValue = returnValue;
}



checkConstraint(constraint: Constraint, context: Context) {
const field = constraint.contextName;
const contextValue = resolveContextValue(context, field);
const isIn = constraint.values.some((val) => val.trim() === contextValue);
return constraint.operator === Operator.IN ? isIn : !isIn;
const evaluator = operators.get(constraint.operator);

if(!evaluator) {
return false;
}

if(constraint.inverted) {
return !evaluator(constraint, context);
}

return evaluator(constraint, context);

}

checkConstraints(context: Context, constraints?: Constraint[]) {
Expand Down