vwo-fme-node-sdk
Version:
VWO Node/JavaScript SDK for Feature Management and Experimentation
335 lines (309 loc) • 14 kB
text/typescript
/**
* Copyright 2024-2025 Wingify Software Pvt. Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { StorageDecorator } from '../../../decorators/StorageDecorator';
import { SettingsModel } from '../../../models/settings/SettingsModel';
import { LogManager } from '../../logger';
import { StorageService } from '../../../services/StorageService';
import { dynamic } from '../../../types/Common';
import { isObject } from '../../../utils/DataTypeUtil';
import { SegmentOperatorValueEnum } from '../enums/SegmentOperatorValueEnum';
import { Segmentation } from '../Segmentation';
import { getKeyValue } from '../utils/SegmentUtil';
import { SegmentOperandEvaluator } from './SegmentOperandEvaluator';
import { ContextModel } from '../../../models/user/ContextModel';
import { FeatureModel } from '../../../models/campaign/FeatureModel';
export class SegmentEvaluator implements Segmentation {
context: ContextModel;
settings: SettingsModel;
feature: FeatureModel;
/**
* Validates if the segmentation defined in the DSL is applicable based on the provided properties.
* @param dsl The domain-specific language defining the segmentation rules.
* @param properties The properties against which the DSL rules are evaluated.
* @returns A Promise resolving to a boolean indicating if the segmentation is valid.
*/
async isSegmentationValid(dsl: Record<string, dynamic>, properties: Record<string, dynamic>): Promise<boolean> {
const { key, value } = getKeyValue(dsl);
const operator = key;
const subDsl = value;
// Evaluate based on the type of segmentation operator
switch (operator) {
case SegmentOperatorValueEnum.NOT:
return !(await this.isSegmentationValid(subDsl, properties));
case SegmentOperatorValueEnum.AND:
return await this.every(subDsl, properties);
case SegmentOperatorValueEnum.OR:
return await this.some(subDsl, properties);
case SegmentOperatorValueEnum.CUSTOM_VARIABLE:
return await new SegmentOperandEvaluator().evaluateCustomVariableDSL(subDsl, properties);
case SegmentOperatorValueEnum.USER:
return new SegmentOperandEvaluator().evaluateUserDSL(subDsl, properties);
case SegmentOperatorValueEnum.UA:
return new SegmentOperandEvaluator().evaluateUserAgentDSL(subDsl, this.context);
default:
return false;
}
}
/**
* Evaluates if any of the DSL nodes are valid using the OR logic.
* @param dslNodes Array of DSL nodes to evaluate.
* @param customVariables Custom variables provided for evaluation.
* @returns A Promise resolving to a boolean indicating if any of the nodes are valid.
*/
async some(dslNodes: Array<Record<string, dynamic>>, customVariables: Record<string, dynamic>): Promise<boolean> {
const uaParserMap: Record<string, string[]> = {};
let keyCount: number = 0; // Initialize count of keys encountered
let isUaParser = false;
for (const dsl of dslNodes) {
for (const key in dsl) {
// Check for user agent related keys
if (
key === SegmentOperatorValueEnum.OPERATING_SYSTEM ||
key === SegmentOperatorValueEnum.BROWSER_AGENT ||
key === SegmentOperatorValueEnum.DEVICE_TYPE ||
key === SegmentOperatorValueEnum.DEVICE
) {
isUaParser = true;
const value = dsl[key];
if (!uaParserMap[key]) {
uaParserMap[key] = [];
}
// Ensure value is treated as an array of strings
const valuesArray = Array.isArray(value) ? value : [value];
valuesArray.forEach((val: dynamic) => {
if (typeof val === 'string') {
uaParserMap[key].push(val);
}
});
keyCount++; // Increment count of keys encountered
}
// Check for feature toggle based on feature ID
if (key === SegmentOperatorValueEnum.FEATURE_ID) {
const featureIdObject = dsl[key] as Record<string, string>;
const featureIdKey: string = Object.keys(featureIdObject)[0];
const featureIdValue: string = featureIdObject[featureIdKey];
if (featureIdValue === 'on' || featureIdValue === 'off') {
const features = this.settings.getFeatures();
const feature = features.find((feature) => feature.getId() === parseInt(featureIdKey));
if (feature) {
const featureKey = feature.getKey();
const result = await this.checkInUserStorage(this.settings, featureKey, this.context);
// if the result is false, then we need to return true as feature is not present in the user storage
if (featureIdValue === 'off') {
return !result;
}
return result;
} else {
LogManager.Instance.error('Feature not found with featureIdKey: ' + featureIdKey);
return null; // Handle the case when feature is not found
}
}
}
}
// Check if the count of keys encountered is equal to dslNodes.length
if (isUaParser && keyCount === dslNodes.length) {
try {
const uaParserResult = await this.checkUserAgentParser(uaParserMap);
return uaParserResult;
} catch (err) {
LogManager.Instance.error('Failed to validate User Agent. Erro: ' + err);
}
}
// Recursively check each DSL node
if (await this.isSegmentationValid(dsl, customVariables)) {
return true;
}
}
return false;
}
/**
* Evaluates all DSL nodes using the AND logic.
* @param dslNodes Array of DSL nodes to evaluate.
* @param customVariables Custom variables provided for evaluation.
* @returns A Promise resolving to a boolean indicating if all nodes are valid.
*/
async every(dslNodes: Array<Record<string, dynamic>>, customVariables: Record<string, dynamic>): Promise<boolean> {
const locationMap: Record<string, dynamic> = {};
for (const dsl of dslNodes) {
// Check if the DSL node contains location-related keys
if (
SegmentOperatorValueEnum.COUNTRY in dsl ||
SegmentOperatorValueEnum.REGION in dsl ||
SegmentOperatorValueEnum.CITY in dsl
) {
this.addLocationValuesToMap(dsl, locationMap);
// Check if the number of location keys matches the number of DSL nodes
if (Object.keys(locationMap).length === dslNodes.length) {
const segmentResult = await this.checkLocationPreSegmentation(locationMap);
return segmentResult;
}
continue;
}
const res = await this.isSegmentationValid(dsl, customVariables);
if (!res) {
return false;
}
}
return true;
}
/**
* Adds location values from a DSL node to a map.
* @param dsl DSL node containing location data.
* @param locationMap Map to store location data.
*/
addLocationValuesToMap(dsl: Record<string, dynamic>, locationMap: Record<string, dynamic>): void {
// Add country, region, and city information to the location map if present
if (SegmentOperatorValueEnum.COUNTRY in dsl) {
locationMap[SegmentOperatorValueEnum.COUNTRY] = dsl[SegmentOperatorValueEnum.COUNTRY];
}
if (SegmentOperatorValueEnum.REGION in dsl) {
locationMap[SegmentOperatorValueEnum.REGION] = dsl[SegmentOperatorValueEnum.REGION];
}
if (SegmentOperatorValueEnum.CITY in dsl) {
locationMap[SegmentOperatorValueEnum.CITY] = dsl[SegmentOperatorValueEnum.CITY];
}
}
/**
* Checks if the user's location matches the expected location criteria.
* @param locationMap Map of expected location values.
* @returns A Promise resolving to a boolean indicating if the location matches.
*/
async checkLocationPreSegmentation(locationMap: Record<string, dynamic>): Promise<boolean> {
// Ensure user's IP address is available
if (this.context?.getIpAddress() === undefined && typeof process.env !== 'undefined') {
LogManager.Instance.error('To evaluate location pre Segment, please pass ipAddress in context object');
return false;
}
// Check if location data is available and matches the expected values
if (
!this.context?.getVwo()?.getLocation() ||
this.context?.getVwo()?.getLocation() === undefined ||
this.context?.getVwo()?.getLocation() === null
) {
return false;
}
return this.valuesMatch(locationMap, this.context?.getVwo()?.getLocation());
}
/**
* Checks if the user's device information matches the expected criteria.
* @param uaParserMap Map of expected user agent values.
* @returns A Promise resolving to a boolean indicating if the user agent matches.
*/
async checkUserAgentParser(uaParserMap: Record<string, string[]>): Promise<boolean> {
// Ensure user's user agent is available
if (!this.context?.getUserAgent() || this.context?.getUserAgent() === undefined) {
LogManager.Instance.error('To evaluate user agent related segments, please pass userAgent in context object');
return false;
}
// Check if user agent data is available and matches the expected values
if (!this.context?.getVwo()?.getUaInfo() || this.context?.getVwo()?.getUaInfo() === undefined) {
return false;
}
return this.checkValuePresent(uaParserMap, this.context?.getVwo()?.getUaInfo());
}
/**
* Checks if the feature is enabled for the user by querying the storage.
* @param settings The settings model containing configuration.
* @param featureKey The key of the feature to check.
* @param user The user object to check against.
* @returns A Promise resolving to a boolean indicating if the feature is enabled for the user.
*/
async checkInUserStorage(settings: SettingsModel, featureKey: string, context: ContextModel): Promise<any> {
const storageService = new StorageService();
// Retrieve feature data from storage
const storedData: Record<any, any> = await new StorageDecorator().getFeatureFromStorage(
featureKey,
context,
storageService,
);
// Check if the stored data is an object and not empty
if (isObject(storedData) && Object.keys(storedData).length > 0) {
return true;
} else {
return false;
}
}
/**
* Checks if the actual values match the expected values specified in the map.
* @param expectedMap A map of expected values for different keys.
* @param actualMap A map of actual values to compare against.
* @returns A Promise resolving to a boolean indicating if all actual values match the expected values.
*/
async checkValuePresent(expectedMap: Record<string, string[]>, actualMap: Record<string, string>): Promise<boolean> {
for (const key in actualMap) {
if (Object.prototype.hasOwnProperty.call(expectedMap, key)) {
const expectedValues = expectedMap[key];
// convert expected values to lowercase
expectedValues.forEach((value, index) => {
expectedValues[index] = value.toLowerCase();
});
const actualValue = actualMap[key];
// Handle wildcard patterns for all keys
for (const val of expectedValues) {
// Check if the value is a wildcard pattern and matches the actual value using regex
if (val.startsWith('wildcard(') && val.endsWith(')')) {
// Extract pattern from wildcard string
const wildcardPattern = val.slice(9, -1);
// Convert wildcard pattern to regex and check if it matches the actual value
const regex = new RegExp(wildcardPattern.replace(/\*/g, '.*'), 'i'); // Convert wildcard pattern to regex, 'i' for case-insensitive
// Check if the actual value matches the regex pattern for the key
if (regex.test(actualValue)) {
// match found, return true as we only need to check if any of the expected values match the actual value
return true;
}
}
}
// this will be checked for all cases where wildcard is not present
if (expectedValues.includes(actualValue?.toLowerCase())) {
return true; // Direct value match found, return true
}
}
}
return false; // No matches found
}
/**
* Compares expected location values with user's location to determine a match.
* @param expectedLocationMap A map of expected location values.
* @param userLocation The user's actual location.
* @returns A boolean indicating if the user's location matches the expected values.
*/
async valuesMatch(expectedLocationMap, userLocation) {
for (const [key, value] of Object.entries(expectedLocationMap)) {
if (key in userLocation) {
const normalizedValue1 = this.normalizeValue(value);
const normalizedValue2 = this.normalizeValue(userLocation[key]);
if (normalizedValue1 !== normalizedValue2) {
return false;
}
} else {
return false;
}
}
return true; // If all values match, return true
}
/**
* Normalizes a value to a consistent format for comparison.
* @param value The value to normalize.
* @returns The normalized value.
*/
normalizeValue(value) {
if (value === null || value === undefined) {
return null;
}
// Remove quotes and trim whitespace
return value.toString().replace(/^"|"$/g, '').trim();
}
}