@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
569 lines • 29.8 kB
JavaScript
/**
* Prescriptive Validator
*
* Handles ALL complex validation scenarios from Swagger spec:
* - Conditional logic ("field X required when Y = Z")
* - JavaScript template generation
* - Complex object validation
* - Cross-entity relationship validation
*/
import { FIELDS } from '../generated/fields.generated.js';
import { automatedErrorEnhancer } from '../errors/AutomatedPrescriptiveErrorEnhancer.js';
export class PrescriptiveValidator {
conditionalRules;
jsTemplates;
complexObjectSchemas;
constructor() {
this.conditionalRules = this.loadConditionalRules();
this.jsTemplates = this.loadJSTemplates();
this.complexObjectSchemas = this.loadComplexObjectSchemas();
}
/**
* Comprehensive validation with auto-generation
*/
validateEntity(entityType, data, context) {
const result = {
isValid: true,
errors: [],
warnings: [],
autoGenerated: {}
};
// 1. Basic Swagger validation (with context)
this.validateSwaggerRules(entityType, data, result, context);
// 2. Numeric range validation
this.validateNumericRanges(entityType, data, result);
// 3. Conditional logic validation
this.validateConditionalRules(entityType, data, result);
// 4. JavaScript field validation/generation
this.validateJavaScriptFields(entityType, data, result);
// 5. Complex object validation
this.validateComplexObjects(entityType, data, result);
// 6. Cross-entity validation
this.validateCrossEntityRules(entityType, data, result);
result.isValid = result.errors.length === 0;
return result;
}
/**
* Load conditional validation rules from Swagger analysis
*/
loadConditionalRules() {
return {
page: [
{
condition: (data) => data.activation_type === 'polling',
requiredFields: ['activation_code'],
templates: {
activation_code: 'function pollFn() {\n // Return true when condition is met\n return document.getElementById("target-element") !== null;\n}'
},
message: 'activation_code required when activation_type is "polling". Provide a function that returns true when the page should activate.'
},
{
condition: (data) => data.activation_type === 'callback',
requiredFields: ['activation_code'],
templates: {
activation_code: 'function callbackFn(activate, options) {\n // Call activate() when ready\n document.addEventListener("click", function(e) {\n if (e.target.matches(".trigger-element")) {\n activate();\n }\n });\n}'
},
message: 'activation_code required when activation_type is "callback". Provide a function that calls activate() when the page should activate.'
}
],
extension: [
{
condition: (data) => !data.implementation,
requiredFields: ['implementation'],
templates: {
implementation: {
html: '<div class="extension-container">\n <!-- Extension HTML -->\n</div>',
css: '.extension-container {\n /* Extension styles */\n}',
apply_js: 'function applyExtension() {\n // Extension application logic\n}',
reset_js: 'function resetExtension() {\n // Cleanup logic\n}'
}
},
message: 'implementation object required for extensions. Provide HTML, CSS, apply_js, and reset_js.'
}
],
change: [
{
condition: (data) => data.type === 'custom_code',
requiredFields: ['value'],
templates: {
value: '// Custom JavaScript code\nwindow.optimizely = window.optimizely || [];\n\n// Your variation code here\nconsole.log("Variation loaded");'
},
message: 'value field required for custom_code changes. Provide JavaScript code as string.'
},
{
condition: (data) => data.type === 'custom_css',
requiredFields: ['value'],
templates: {
value: '/* Custom CSS styles */\n.variation-element {\n /* Your styles here */\n}'
},
message: 'value field required for custom_css changes. Provide CSS code as string.'
},
{
condition: (data) => data.type === 'redirect',
requiredFields: ['destination', 'preserve_parameters', 'allow_additional_redirect'],
templates: {
destination: 'https://example.com/new-page',
preserve_parameters: false,
allow_additional_redirect: false
},
message: 'destination, preserve_parameters, and allow_additional_redirect required for redirect changes.'
},
{
condition: (data) => ['attribute', 'insert_html', 'insert_image'].includes(data.type),
requiredFields: ['selector'],
templates: {
selector: '#target-element'
},
message: 'selector field required for attribute, insert_html, and insert_image changes. Provide CSS selector.'
}
]
};
}
/**
* Load JavaScript code templates
*/
loadJSTemplates() {
return {
page: [
{
pattern: 'activation_code.polling',
description: 'Polling function that checks for page readiness',
code: 'function pollFn() {\n // Check for specific element or condition\n return document.querySelector("#ready-indicator") !== null;\n}'
},
{
pattern: 'activation_code.callback',
description: 'Callback function for event-based activation',
code: 'function callbackFn(activate, options) {\n // Listen for specific event\n document.addEventListener("custom-event", function() {\n activate();\n });\n}'
}
],
project: [
{
pattern: 'web_snippet.project_javascript',
description: 'Global JavaScript that runs before experiments',
code: '// Global project JavaScript\nwindow.projectSettings = {\n debug: false,\n trackingEnabled: true\n};'
}
],
extension: [
{
pattern: 'implementation.apply_js',
description: 'Extension application logic',
code: 'function applyExtension(config) {\n // Apply extension to page\n var container = document.createElement("div");\n container.className = "extension-container";\n document.body.appendChild(container);\n}'
}
]
};
}
/**
* Load complex object schemas with validation
*/
loadComplexObjectSchemas() {
return {
webSnippet: {
schema: {
include_jquery: { type: 'boolean', default: false },
library: {
type: 'string',
enum: ['jquery-1.11.3-trim', 'jquery-1.11.3-full', 'jquery-1.6.4-trim', 'jquery-1.6.4-full', 'none'],
default: 'none'
},
ip_anonymization: { type: 'boolean', default: true },
exclude_names: { type: 'boolean', default: true }
},
validator: (data) => {
const errors = [];
if (data.include_jquery && data.library === 'none') {
errors.push('library cannot be "none" when include_jquery is true');
}
return errors;
}
},
urlTargeting: {
schema: {
edit_url: { type: 'string', required: true },
conditions: { type: 'string', pattern: /^\[.*\]$/ },
activation_type: {
type: 'string',
enum: ['immediate', 'manual', 'polling', 'callback', 'dom_changed', 'url_changed']
}
},
validator: (data) => {
const errors = [];
if ((data.activation_type === 'polling' || data.activation_type === 'callback') && !data.activation_code) {
errors.push(`activation_code required when activation_type is "${data.activation_type}"`);
}
return errors;
}
}
};
}
/**
* Validate basic Swagger rules
*/
validateSwaggerRules(entityType, data, result, context) {
const schema = FIELDS[entityType];
if (!schema)
return;
// Define server-generated fields that should NOT be validated as required inputs
const serverGeneratedFields = new Set([
'id', 'urn', 'created_time', 'updated_time', 'created', 'last_modified',
'url', 'archive_url', 'unarchive_url', 'update_url', 'delete_url',
'revision', 'role', 'created_by_user_email', 'environments',
'results_token', 'earliest', 'latest', 'is_stale'
]);
// Define which fields are actually required for creation per entity type
const creationRequiredFields = {
flag: ['key', 'name'],
experiment: ['project_id'],
page: ['name', 'project_id', 'edit_url'],
event: [], // Most fields are optional for events
audience: ['project_id'],
attribute: ['key', 'project_id'],
campaign: ['project_id'],
extension: ['project_id', 'name', 'edit_url', 'implementation'],
webhook: ['name', 'project_id', 'url'],
group: ['project_id', 'name'],
feature: ['key', 'project_id'],
variable: ['key', 'type', 'default_value'],
variable_definition: ['key', 'type', 'default_value'],
rule: ['key', 'name', 'type'],
ruleset: ['flag_key', 'environment_key', 'rule_priorities']
};
// Use entity-specific required fields if defined, otherwise filter out server-generated
const requiredForCreation = creationRequiredFields[entityType];
if (requiredForCreation) {
// Only check fields that are required for creation
for (const field of requiredForCreation) {
// ENTITY-AWARE VALIDATION: For rulesets, flag_key should be in options (URL path), not data (payload)
let fieldValue;
if (entityType === 'ruleset' && field === 'flag_key') {
fieldValue = context?.options?.flag_key;
}
else {
fieldValue = data[field];
}
if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
const basicError = `Missing required field '${field}'`;
result.errors.push(basicError);
// ENHANCEMENT: Add prescriptive guidance
if (!result.prescriptiveErrors)
result.prescriptiveErrors = [];
const enhancedError = automatedErrorEnhancer.enhanceError(basicError, {
entityType,
field,
platform: context?.platform,
operation: 'create',
projectId: context?.projectId
});
result.prescriptiveErrors.push(enhancedError);
}
}
}
else if (schema.required) {
// Fallback: check all required fields but skip server-generated ones
for (const field of schema.required) {
// CRITICAL FIX: Skip weight field for Feature Experimentation variations
// In Feature Experimentation, variations don't have weight (it's on rules)
const shouldSkipField = serverGeneratedFields.has(field) ||
(entityType === 'variation' && field === 'weight' && context?.platform === 'feature');
if (!shouldSkipField &&
(data[field] === undefined || data[field] === null || data[field] === '')) {
const basicError = `Missing required field '${field}'`;
result.errors.push(basicError);
// ENHANCEMENT: Add prescriptive guidance
if (!result.prescriptiveErrors)
result.prescriptiveErrors = [];
const enhancedError = automatedErrorEnhancer.enhanceError(basicError, {
entityType,
field,
platform: context?.platform,
operation: 'create',
projectId: context?.projectId
});
result.prescriptiveErrors.push(enhancedError);
}
}
}
// Enum validation with helpful error messages
if (schema.enums) {
for (const [field, enumValues] of Object.entries(schema.enums)) {
if (data[field] && !enumValues.includes(data[field])) {
const validOptionsFormatted = enumValues.map((v) => v).join(', ');
// Special handling for platform field to provide clearer guidance
let basicError = `Invalid value "${data[field]}" for field '${field}'. Valid options are: ${validOptionsFormatted}. Please use one of these exact values (case-sensitive).`;
if (field === 'platform' && entityType === 'project') {
const platformValue = data[field];
if (platformValue === 'feature_experimentation' || platformValue === 'feature') {
basicError = `Invalid value "${platformValue}" for field 'platform'. Use "custom" for Feature Experimentation projects. Valid options are: ${validOptionsFormatted}.`;
}
else if (platformValue === 'web_experimentation') {
basicError = `Invalid value "${platformValue}" for field 'platform'. Use "web" for Web Experimentation projects. Valid options are: ${validOptionsFormatted}.`;
}
}
result.errors.push(basicError);
// ENHANCEMENT: Add prescriptive guidance
if (!result.prescriptiveErrors)
result.prescriptiveErrors = [];
const enhancedError = automatedErrorEnhancer.enhanceError(basicError, {
entityType,
field,
platform: context?.platform,
operation: 'create',
projectId: context?.projectId,
metadata: { providedValue: data[field], validOptions: enumValues }
});
result.prescriptiveErrors.push(enhancedError);
}
}
}
// Type validation with helpful error messages
if (schema.fieldTypes) {
for (const [field, expectedType] of Object.entries(schema.fieldTypes)) {
if (data[field] !== undefined && data[field] !== null) {
const value = data[field];
const actualType = Array.isArray(value) ? 'array' : typeof value;
switch (expectedType) {
case 'integer':
if (!Number.isInteger(value)) {
if (typeof value === 'string' && /^\d+$/.test(value)) {
result.warnings.push(`Field '${field}' should be an integer but got string "${value}". ` +
`Consider using: ${parseInt(value, 10)} (without quotes)`);
}
else if (typeof value === 'number' && !Number.isInteger(value)) {
const basicError = `Field '${field}' must be an integer but got decimal ${value}. Use a whole number like: ${Math.round(value)}`;
result.errors.push(basicError);
// ENHANCEMENT: Add prescriptive guidance
if (!result.prescriptiveErrors)
result.prescriptiveErrors = [];
const enhancedError = automatedErrorEnhancer.enhanceError(basicError, {
entityType,
field,
platform: context?.platform,
operation: 'create',
projectId: context?.projectId,
metadata: { providedValue: value, expectedType: 'integer' }
});
result.prescriptiveErrors.push(enhancedError);
}
else {
const basicError = `Field '${field}' must be an integer but got ${actualType} "${value}". Example: 123 (not "123" or 123.45)`;
result.errors.push(basicError);
// ENHANCEMENT: Add prescriptive guidance
if (!result.prescriptiveErrors)
result.prescriptiveErrors = [];
const enhancedError = automatedErrorEnhancer.enhanceError(basicError, {
entityType,
field,
platform: context?.platform,
operation: 'create',
projectId: context?.projectId,
metadata: { providedValue: value, expectedType: 'integer' }
});
result.prescriptiveErrors.push(enhancedError);
}
}
break;
case 'number':
if (typeof value !== 'number') {
if (typeof value === 'string' && !isNaN(parseFloat(value))) {
result.warnings.push(`Field '${field}' should be a number but got string "${value}". ` +
`Consider using: ${parseFloat(value)} (without quotes)`);
}
else {
result.errors.push(`Field '${field}' must be a number but got ${actualType} "${value}". ` +
`Example: 123.45 or 100 (not "123.45")`);
}
}
break;
case 'boolean':
if (typeof value !== 'boolean') {
if (typeof value === 'string' && (value === 'true' || value === 'false')) {
result.warnings.push(`Field '${field}' should be a boolean but got string "${value}". ` +
`Use: ${value} (without quotes)`);
}
else if (value === 1 || value === 0) {
result.warnings.push(`Field '${field}' should be a boolean but got number ${value}. ` +
`Use: ${value === 1 ? 'true' : 'false'}`);
}
else {
result.errors.push(`Field '${field}' must be a boolean but got ${actualType} "${value}". ` +
`Use: true or false (not "true" or 1)`);
}
}
break;
case 'string':
if (typeof value !== 'string') {
result.warnings.push(`Field '${field}' should be a string but got ${actualType} ${JSON.stringify(value)}. ` +
`Consider using: "${value}"`);
}
break;
case 'array':
if (!Array.isArray(value)) {
if (typeof value === 'string' && (value.startsWith('[') || value.includes(','))) {
result.errors.push(`Field '${field}' must be an array but got string "${value}". ` +
`If you meant to send an array, use: ${value} (not as a string). ` +
`Example: ["item1", "item2"] not "[\"item1\", \"item2\"]"`);
}
else {
result.errors.push(`Field '${field}' must be an array but got ${actualType}. ` +
`Example: [${JSON.stringify(value)}] to wrap in array`);
}
}
break;
case 'object':
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
if (typeof value === 'string' && value.startsWith('{')) {
result.errors.push(`Field '${field}' must be an object but got string "${value}". ` +
`If you meant to send JSON, parse it first: JSON.parse('${value}')`);
}
else {
result.errors.push(`Field '${field}' must be an object but got ${actualType}. ` +
`Example: { "key": "value" }`);
}
}
break;
}
}
}
}
// Special validation for page conditions field
if (entityType === 'page' && data.conditions !== undefined) {
if (typeof data.conditions !== 'string') {
result.errors.push(`Field 'conditions' must be a stringified JSON array. ` +
`Example: '["and", {"type": "url", "match_type": "exact", "value": "https://example.com"}]'`);
}
else {
// Validate it's valid JSON
try {
const parsed = JSON.parse(data.conditions);
if (!Array.isArray(parsed)) {
result.errors.push(`Field 'conditions' must be a stringified JSON array starting with ["and", ...]`);
}
}
catch (e) {
result.errors.push(`Field 'conditions' contains invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
}
/**
* Validate numeric ranges based on schema constraints
*/
validateNumericRanges(entityType, data, result) {
const schema = FIELDS[entityType];
if (!schema || !schema.validation)
return;
// Check minimum values
if (schema.validation.minimum) {
for (const [field, minValue] of Object.entries(schema.validation.minimum)) {
if (data[field] !== undefined && typeof data[field] === 'number') {
if (data[field] < minValue) {
result.errors.push(`Field '${field}' value ${data[field]} is below minimum ${minValue}. ` +
`Valid range: ${minValue} to ${schema.validation.maximum?.[field] || 'unlimited'}`);
}
}
}
}
// Check maximum values
if (schema.validation.maximum) {
for (const [field, maxValue] of Object.entries(schema.validation.maximum)) {
if (data[field] !== undefined && typeof data[field] === 'number') {
if (data[field] > maxValue) {
result.errors.push(`Field '${field}' value ${data[field]} exceeds maximum ${maxValue}. ` +
`Valid range: ${schema.validation.minimum?.[field] || 0} to ${maxValue}`);
}
}
}
}
// Special handling for percentage fields (0-10000 basis points)
const percentageFields = ['weight', 'traffic_allocation', 'holdback', 'percentage_included'];
for (const field of percentageFields) {
if (data[field] !== undefined && typeof data[field] === 'number') {
if (data[field] < 0 || data[field] > 10000) {
result.errors.push(`Field '${field}' must be between 0 and 10000 (basis points). ` +
`Current value: ${data[field]}. ` +
`Note: 100% = 10000, 50% = 5000, 1% = 100`);
}
}
}
}
/**
* Validate conditional rules
*/
validateConditionalRules(entityType, data, result) {
const rules = this.conditionalRules[entityType] || [];
for (const rule of rules) {
if (rule.condition(data)) {
for (const field of rule.requiredFields) {
if (!data[field]) {
// Auto-generate if template available
if (rule.templates[field]) {
result.autoGenerated[field] = rule.templates[field];
result.warnings.push(`Auto-generated ${field}: ${JSON.stringify(rule.templates[field])}`);
}
else {
result.errors.push(rule.message);
}
}
}
}
}
}
/**
* Validate JavaScript fields with template generation
*/
validateJavaScriptFields(entityType, data, result) {
const templates = this.jsTemplates[entityType] || [];
// Check for JavaScript fields that need templates
const jsFields = ['activation_code', 'project_javascript', 'apply_js', 'reset_js', 'value'];
for (const field of jsFields) {
if (data[field] === undefined && this.fieldNeedsJavaScript(entityType, field, data)) {
const template = this.findJSTemplate(entityType, field, data);
if (template) {
result.autoGenerated[field] = template.code;
result.warnings.push(`Auto-generated ${field}: ${template.description}`);
}
}
}
}
/**
* Validate complex objects
*/
validateComplexObjects(entityType, data, result) {
// Validate web_snippet for projects
if (entityType === 'project' && data.web_snippet) {
const schema = this.complexObjectSchemas.webSnippet;
const errors = schema.validator(data.web_snippet);
result.errors.push(...errors);
}
// Validate url_targeting for campaigns/experiments
if (['campaign', 'experiment'].includes(entityType) && data.url_targeting) {
const schema = this.complexObjectSchemas.urlTargeting;
const errors = schema.validator(data.url_targeting);
result.errors.push(...errors);
}
}
/**
* Validate cross-entity rules
*/
validateCrossEntityRules(entityType, data, result) {
// Example: Validate project_id exists
if (data.project_id && typeof data.project_id === 'string') {
result.warnings.push('Converting project_id from string to integer');
result.autoGenerated.project_id = parseInt(data.project_id, 10);
}
}
/**
* Helper methods
*/
fieldNeedsJavaScript(entityType, field, data) {
if (entityType === 'page' && field === 'activation_code') {
return ['polling', 'callback'].includes(data.activation_type);
}
return false;
}
findJSTemplate(entityType, field, data) {
const templates = this.jsTemplates[entityType] || [];
const pattern = `${field}.${data.activation_type || 'default'}`;
return templates.find(t => t.pattern === pattern) || null;
}
}
//# sourceMappingURL=PrescriptiveValidator.js.map