UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

569 lines 29.8 kB
/** * 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