@featurevisor/core
Version:
Core package of Featurevisor for Node.js usage
750 lines (664 loc) • 21.5 kB
text/typescript
import { z } from "zod";
import { ProjectConfig } from "../config";
const tagRegex = /^[a-z0-9-]+$/;
function isFlatObject(value) {
let isFlat = true;
Object.keys(value).forEach((key) => {
if (typeof value[key] === "object") {
isFlat = false;
}
});
return isFlat;
}
function isArrayOfStrings(value) {
return Array.isArray(value) && value.every((v) => typeof v === "string");
}
function superRefineVariableValue(
projectConfig: ProjectConfig,
variableSchema,
variableValue,
path,
ctx,
) {
if (!variableSchema) {
let message = `Unknown variable with value: ${variableValue}`;
if (path.length > 0) {
const lastPath = path[path.length - 1];
if (typeof lastPath === "string") {
message = `Unknown variable "${lastPath}" with value: ${variableValue}`;
}
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path,
});
return;
}
// string
if (variableSchema.type === "string") {
if (typeof variableValue !== "string") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): ${variableValue}`,
path,
});
}
if (
projectConfig.maxVariableStringLength &&
variableValue.length > projectConfig.maxVariableStringLength
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Variable "${variableSchema.key}" value is too long (${variableValue.length} characters), max length is ${projectConfig.maxVariableStringLength}`,
path,
});
}
return;
}
// integer, double
if (["integer", "double"].indexOf(variableSchema.type) > -1) {
if (typeof variableValue !== "number") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): ${variableValue}`,
path,
});
}
return;
}
// boolean
if (variableSchema.type === "boolean") {
if (typeof variableValue !== "boolean") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): ${variableValue}`,
path,
});
}
return;
}
// array
if (variableSchema.type === "array") {
if (!Array.isArray(variableValue) || !isArrayOfStrings(variableValue)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): \n\n${variableValue}\n\n`,
path,
});
}
if (projectConfig.maxVariableArrayStringifiedLength) {
const stringified = JSON.stringify(variableValue);
if (stringified.length > projectConfig.maxVariableArrayStringifiedLength) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Variable "${variableSchema.key}" array is too long (${stringified.length} characters), max length is ${projectConfig.maxVariableArrayStringifiedLength}`,
path,
});
}
}
return;
}
// object
if (variableSchema.type === "object") {
if (typeof variableValue !== "object" || !isFlatObject(variableValue)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): \n\n${variableValue}\n\n`,
path,
});
}
if (projectConfig.maxVariableObjectStringifiedLength) {
const stringified = JSON.stringify(variableValue);
if (stringified.length > projectConfig.maxVariableObjectStringifiedLength) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Variable "${variableSchema.key}" object is too long (${stringified.length} characters), max length is ${projectConfig.maxVariableObjectStringifiedLength}`,
path,
});
}
}
return;
}
// json
if (variableSchema.type === "json") {
try {
JSON.parse(variableValue as string);
if (projectConfig.maxVariableJSONStringifiedLength) {
const stringified = variableValue;
if (stringified.length > projectConfig.maxVariableJSONStringifiedLength) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Variable "${variableSchema.key}" JSON is too long (${stringified.length} characters), max length is ${projectConfig.maxVariableJSONStringifiedLength}`,
path,
});
}
}
// eslint-disable-next-line
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): \n\n${variableValue}\n\n`,
path,
});
}
return;
}
}
function refineForce({
ctx,
parsedFeature, // eslint-disable-line
variableSchemaByKey,
variationValues,
force,
pathPrefix,
projectConfig,
}) {
force.forEach((f, fN) => {
// force[n].variation
if (f.variation) {
if (variationValues.indexOf(f.variation) === -1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Unknown variation "${f.variation}" in force`,
path: [...pathPrefix, fN, "variation"],
});
}
}
// force[n].variables[key]
if (f.variables) {
Object.keys(f.variables).forEach((variableKey) => {
superRefineVariableValue(
projectConfig,
variableSchemaByKey[variableKey],
f.variables[variableKey],
pathPrefix.concat([fN, "variables", variableKey]),
ctx,
);
});
}
});
}
function refineRules({
ctx,
parsedFeature,
variableSchemaByKey,
variationValues,
rules,
pathPrefix,
projectConfig,
}) {
rules.forEach((rule, ruleN) => {
// rules[n].variables[key]
if (rule.variables) {
Object.keys(rule.variables).forEach((variableKey) => {
superRefineVariableValue(
projectConfig,
variableSchemaByKey[variableKey],
rule.variables[variableKey],
pathPrefix.concat([ruleN, "variables", variableKey]),
ctx,
);
});
}
// rules[n].variationWeights
if (rule.variationWeights) {
if (!parsedFeature.variations) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Variation weights are overridden from rule, but no variations are present in feature.",
path: pathPrefix.concat([ruleN, "variationWeights"]),
});
} else {
const overriddenVariationValues = Object.keys(rule.variationWeights);
const overriddenVariationWeights: number[] = Object.values(rule.variationWeights);
// unique keys
if (overriddenVariationValues.length !== new Set(overriddenVariationValues).size) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Duplicate variation values found in variationWeights: " +
overriddenVariationValues.join(", "),
path: pathPrefix.concat([ruleN, "variationWeights"]),
});
}
// all original variations must be used
const missingVariations = variationValues.filter(
(v) => !overriddenVariationValues.includes(v),
);
if (missingVariations.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Missing variations: " + missingVariations.join(", "),
path: pathPrefix.concat([ruleN, "variationWeights"]),
});
}
// unknown variations
const unknownVariations = overriddenVariationValues.filter(
(v) => !variationValues.includes(v),
);
if (unknownVariations.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Variation weights contain unknown variations: " + unknownVariations.join(", "),
path: pathPrefix.concat([ruleN, "variationWeights"]),
});
}
// weights sum must be 100
const weightsSum = overriddenVariationWeights.reduce((sum, weight) => sum + weight, 0);
if (weightsSum !== 100) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Variation weights must sum to 100",
path: pathPrefix.concat([ruleN, "variationWeights"]),
});
}
}
}
});
}
export function getFeatureZodSchema(
projectConfig: ProjectConfig,
conditionsZodSchema,
availableAttributeKeys: [string, ...string[]],
availableSegmentKeys: [string, ...string[]],
availableFeatureKeys: [string, ...string[]],
) {
const variationValueZodSchema = z.string().min(1);
const variableValueZodSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.unknown()).refine(
(value) => {
return isFlatObject(value);
},
{
message: "object is not flat",
},
),
]);
const plainGroupSegment = z.string().refine(
(value) => value === "*" || availableSegmentKeys.includes(value),
(value) => ({
message: `Unknown segment key "${value}"`,
}),
);
const andOrNotGroupSegment = z.union([
z
.object({
and: z.array(z.lazy(() => groupSegmentZodSchema)),
})
.strict(),
z
.object({
or: z.array(z.lazy(() => groupSegmentZodSchema)),
})
.strict(),
z
.object({
not: z.array(z.lazy(() => groupSegmentZodSchema)),
})
.strict(),
]);
const groupSegmentZodSchema = z.union([andOrNotGroupSegment, plainGroupSegment]);
const groupSegmentsZodSchema = z.union([z.array(groupSegmentZodSchema), groupSegmentZodSchema]);
const exposeSchema = z
.union([z.boolean(), z.array(z.string().refine((value) => projectConfig.tags.includes(value)))])
.optional();
const rulesSchema = z
.array(
z
.object({
key: z.string(),
description: z.string().optional(),
segments: groupSegmentsZodSchema,
percentage: z.number().min(0).max(100),
enabled: z.boolean().optional(),
variation: variationValueZodSchema.optional(),
variables: z.record(variableValueZodSchema).optional(),
variationWeights: z.record(z.number().min(0).max(100)).optional(),
})
.strict(),
)
// must have at least one rule
.refine(
(value) => value.length > 0,
() => ({
message: "Must have at least one rule",
}),
)
// duplicate rules
.refine(
(value) => {
const keys = value.map((v) => v.key);
return keys.length === new Set(keys).size;
},
(value) => ({
message: "Duplicate rule keys found: " + value.map((v) => v.key).join(", "),
}),
)
// enforce catch-all rule
.refine(
(value) => {
if (!projectConfig.enforceCatchAllRule) {
return true;
}
const hasCatchAllAsLastRule = value[value.length - 1].segments === "*";
return hasCatchAllAsLastRule;
},
() => ({
message: `Missing catch-all rule with \`segments: "*"\` at the end`,
}),
);
const forceSchema = z
.array(
z.union([
z
.object({
segments: groupSegmentsZodSchema,
enabled: z.boolean().optional(),
variation: variationValueZodSchema.optional(),
variables: z.record(variableValueZodSchema).optional(),
})
.strict(),
z
.object({
conditions: conditionsZodSchema,
enabled: z.boolean().optional(),
variation: variationValueZodSchema.optional(),
variables: z.record(variableValueZodSchema).optional(),
})
.strict(),
]),
)
.optional();
const attributeKeyZodSchema = z.string().refine(
(value) => value === "*" || availableAttributeKeys.includes(value),
(value) => ({
message: `Unknown attribute "${value}"`,
}),
);
const featureKeyZodSchema = z.string().refine(
(value) => availableFeatureKeys.includes(value),
(value) => ({
message: `Unknown feature "${value}"`,
}),
);
const environmentKeys = projectConfig.environments || [];
const featureZodSchema = z
.object({
archived: z.boolean().optional(),
deprecated: z.boolean().optional(),
description: z.string(),
tags: z
.array(
z.string().refine(
(value) => tagRegex.test(value),
(value) => ({
message: `Tag "${value}" must be lower cased and alphanumeric, and may contain hyphens.`,
}),
),
)
.refine(
(value) => {
return value.length === new Set(value).size;
},
(value) => ({
message: "Duplicate tags found: " + value.join(", "),
}),
),
required: z
.array(
z.union([
featureKeyZodSchema,
z
.object({
key: featureKeyZodSchema,
variation: z.string().optional(),
})
.strict(),
]),
)
.optional(),
bucketBy: z.union([
attributeKeyZodSchema,
z.array(attributeKeyZodSchema),
z
.object({
or: z.array(attributeKeyZodSchema),
})
.strict(),
]),
variablesSchema: z
.record(
z
.object({
deprecated: z.boolean().optional(),
type: z.enum(["string", "integer", "boolean", "double", "array", "object", "json"]),
description: z.string().optional(),
defaultValue: variableValueZodSchema,
useDefaultWhenDisabled: z.boolean().optional(),
disabledValue: variableValueZodSchema.optional(),
})
.strict(),
)
.optional(),
disabledVariationValue: variationValueZodSchema.optional(),
variations: z
.array(
z
.object({
description: z.string().optional(),
value: variationValueZodSchema,
weight: z.number().min(0).max(100),
variables: z.record(variableValueZodSchema).optional(),
variableOverrides: z
.record(
z.array(
z.union([
z
.object({
conditions: conditionsZodSchema,
value: variableValueZodSchema,
})
.strict(),
z
.object({
segments: groupSegmentsZodSchema,
value: variableValueZodSchema,
})
.strict(),
]),
),
)
.optional(),
})
.strict(),
)
.refine(
(value) => {
const variationValues = value.map((v) => v.value);
return variationValues.length === new Set(variationValues).size;
},
(value) => ({
message: "Duplicate variation values found: " + value.map((v) => v.value).join(", "),
}),
)
.optional(),
expose:
projectConfig.environments === false
? exposeSchema.optional()
: z.record(z.enum(environmentKeys as [string, ...string[]]), exposeSchema).optional(),
force:
projectConfig.environments === false
? forceSchema
: z.record(z.enum(environmentKeys as [string, ...string[]]), forceSchema).optional(),
rules:
projectConfig.environments === false
? rulesSchema
: z.record(z.enum(environmentKeys as [string, ...string[]]), rulesSchema),
})
.strict()
.superRefine((value, ctx) => {
// disabledVariationValue
if (value.disabledVariationValue) {
if (!value.variations) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Disabled variation value is set, but no variations are present in feature.",
path: ["disabledVariationValue"],
});
} else {
const variationValues = value.variations.map((v) => v.value);
if (variationValues.indexOf(value.disabledVariationValue) === -1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Disabled variation value "${value.disabledVariationValue}" is not one of the defined variations: ${variationValues.join(", ")}`,
path: ["disabledVariationValue"],
});
}
}
}
if (!value.variablesSchema) {
return;
}
const variableSchemaByKey = value.variablesSchema;
const variationValues: string[] = [];
if (value.variations) {
value.variations.forEach((variation) => {
variationValues.push(variation.value);
});
}
// variablesSchema[key]
const variableKeys = Object.keys(variableSchemaByKey);
variableKeys.forEach((variableKey) => {
const variableSchema = variableSchemaByKey[variableKey];
if (variableKey === "variation") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Variable key "${variableKey}" is reserved and cannot be used.`,
path: ["variablesSchema", variableKey],
});
}
// defaultValue
superRefineVariableValue(
projectConfig,
variableSchema,
variableSchema.defaultValue,
["variablesSchema", variableKey, "defaultValue"],
ctx,
);
// disabledValue
superRefineVariableValue(
projectConfig,
variableSchema,
variableSchema.defaultValue,
["variablesSchema", variableKey, "disabledValue"],
ctx,
);
});
// variations
if (value.variations) {
value.variations.forEach((variation, variationN) => {
if (!variation.variables) {
return;
}
// variations[n].variables[key]
for (const variableKey of Object.keys(variation.variables)) {
const variableValue = variation.variables[variableKey];
superRefineVariableValue(
projectConfig,
variableSchemaByKey[variableKey],
variableValue,
["variations", variationN, "variables", variableKey],
ctx,
);
// variations[n].variableOverrides[n].value
if (variation.variableOverrides) {
for (const variableKey of Object.keys(variation.variableOverrides)) {
const overrides = variation.variableOverrides[variableKey];
if (Array.isArray(overrides)) {
overrides.forEach((override, overrideN) => {
superRefineVariableValue(
projectConfig,
variableSchemaByKey[variableKey],
override.value,
[
"variations",
variationN,
"variableOverrides",
variableKey,
overrideN,
"value",
],
ctx,
);
});
}
}
}
}
});
}
if (environmentKeys.length > 0) {
// with environments
for (const environmentKey of environmentKeys) {
// rules
if (value.rules && value.rules[environmentKey]) {
refineRules({
parsedFeature: value,
variableSchemaByKey,
variationValues,
rules: value.rules[environmentKey],
pathPrefix: ["rules", environmentKey],
ctx,
projectConfig,
});
}
// force
if (value.force && value.force[environmentKey]) {
refineForce({
parsedFeature: value,
variableSchemaByKey,
variationValues,
force: value.force[environmentKey],
pathPrefix: ["force", environmentKey],
ctx,
projectConfig,
});
}
}
} else {
// no environments
// rules
if (value.rules) {
refineRules({
parsedFeature: value,
variableSchemaByKey,
variationValues,
rules: value.rules,
pathPrefix: ["rules"],
ctx,
projectConfig,
});
}
// force
if (value.force) {
refineForce({
parsedFeature: value,
variableSchemaByKey,
variationValues,
force: value.force,
pathPrefix: ["force"],
ctx,
projectConfig,
});
}
}
});
return featureZodSchema;
}