UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

320 lines 13.9 kB
/** * Comprehensive Schema Validator * * This validator uses fields.generated.ts to validate entity data against * actual API schemas, catching issues before they reach the API. */ import { FIELDS } from '../generated/fields.generated.js'; import { getLogger } from '../logging/Logger.js'; import { SchemaCache } from './SchemaCache.js'; export class ComprehensiveSchemaValidator { logger = getLogger(); schemaCache; constructor(schemaCache) { // Use provided cache or create a new one this.schemaCache = schemaCache || new SchemaCache({ ttl: 3600000, // 1 hour maxSize: 50, preloadAll: true }); } /** * Validate entity data against API schema from fields.generated.ts */ validateEntity(entityType, data, options) { const errors = []; const warnings = []; const autoFixed = {}; // Get schema from cache const schema = this.schemaCache.get(entityType); if (!schema) { errors.push(`Unknown entity type: ${entityType}. Valid types: ${Object.keys(FIELDS).join(', ')}`); return { valid: false, errors, warnings }; } this.logger.debug({ entityType, dataFields: Object.keys(data), schemaFields: { required: schema.required, optional: schema.optional, enums: Object.keys(schema.enums || {}) } }, 'Validating entity against schema'); // Define server-generated fields that should not be required for CREATE operations const serverGeneratedFields = new Set([ 'id', 'urn', 'created_time', 'updated_time', 'url', 'created_by_user_email', 'updated_by_user_email', 'revision', 'version' ]); // 1. Check required fields const requiredFields = options?.operation === 'create' ? schema.required.filter(field => !serverGeneratedFields.has(field)) : schema.required; for (const field of requiredFields) { if (!(field in data) || data[field] === undefined || data[field] === null || data[field] === '') { // Special handling for fields with defaults if (schema.defaults && typeof schema.defaults === 'object' && field in schema.defaults) { if (options?.autoFix) { autoFixed[field] = schema.defaults[field]; warnings.push(`Missing required field '${field}' - auto-filled with default: ${JSON.stringify(schema.defaults[field])}`); } else { errors.push(`Missing required field '${field}'. This field is required by the API.`); } } else { errors.push(`Missing required field '${field}'. This field is required by the API.`); } } } // 2. Check enum values if (schema.enums) { for (const [field, enumValues] of Object.entries(schema.enums)) { if (field in data && data[field] !== undefined) { const value = data[field]; if (!enumValues.includes(value)) { // Special handling for common mistakes const suggestion = this.getSuggestionForInvalidEnum(entityType, field, value, enumValues); errors.push(`Invalid ${field}: '${value}'. ` + `Valid values are: ${enumValues.join(', ')}. ` + (suggestion ? `${suggestion}` : '')); } } } } // 3. Check field types if (schema.fieldTypes) { for (const [field, expectedType] of Object.entries(schema.fieldTypes)) { if (field in data && data[field] !== undefined) { const value = data[field]; // Skip type validation for template parameters if (typeof value === 'string' && /\$\{[^}]+\}/.test(value)) { warnings.push(`Field '${field}' contains template parameter '${value}' - type validation will be performed at execution time`); continue; } const actualType = Array.isArray(value) ? 'array' : typeof value; // Allow some type flexibility const typeMatches = this.checkTypeCompatibility(actualType, expectedType); if (!typeMatches) { errors.push(`Invalid type for '${field}': expected ${expectedType}, got ${actualType}. ` + `Value: ${JSON.stringify(value)}`); } } } } // 4. Check validation constraints if (schema.validation) { // Pattern validation if (schema.validation.pattern) { for (const [field, pattern] of Object.entries(schema.validation.pattern)) { if (field in data && data[field] !== undefined) { const value = data[field]; // Skip pattern validation for template parameters if (typeof value === 'string' && /\$\{[^}]+\}/.test(value)) { warnings.push(`Field '${field}' contains template parameter '${value}' - pattern validation will be performed at execution time`); continue; } const regex = new RegExp(pattern); if (!regex.test(String(value))) { errors.push(`Field '${field}' doesn't match required pattern: ${pattern}`); } } } } // Min/max validation if (schema.validation.minimum) { for (const [field, min] of Object.entries(schema.validation.minimum)) { if (field in data && data[field] !== undefined && data[field] < min) { errors.push(`Field '${field}' must be at least ${min}, got ${data[field]}`); } } } if (schema.validation.maximum) { for (const [field, max] of Object.entries(schema.validation.maximum)) { if (field in data && data[field] !== undefined && data[field] > max) { errors.push(`Field '${field}' must be at most ${max}, got ${data[field]}`); } } } // String length validation if (schema.validation.minLength) { for (const [field, minLen] of Object.entries(schema.validation.minLength)) { if (field in data && data[field] !== undefined) { const length = String(data[field]).length; const minLength = Number(minLen); if (length < minLength) { errors.push(`Field '${field}' must be at least ${minLength} characters, got ${length}`); } } } } if (schema.validation.maxLength) { for (const [field, maxLen] of Object.entries(schema.validation.maxLength)) { if (field in data && data[field] !== undefined) { const length = String(data[field]).length; const maxLength = Number(maxLen); if (length > maxLength) { errors.push(`Field '${field}' must be at most ${maxLength} characters, got ${length}`); } } } } } // 5. Check for unknown fields const knownFields = new Set([ ...(schema.required || []), ...(schema.optional || []) ]); for (const field of Object.keys(data)) { if (!knownFields.has(field)) { warnings.push(`Unknown field '${field}' - this field may be ignored by the API`); } } // 6. Platform-specific validation if (options?.platform) { this.validatePlatformSpecific(entityType, data, options.platform, errors, warnings); } return { valid: errors.length === 0, errors, warnings, ...(Object.keys(autoFixed).length > 0 && { autoFixed }) }; } /** * Get suggestion for common enum mistakes */ getSuggestionForInvalidEnum(entityType, field, value, validValues) { // Event category specific suggestions if (entityType === 'event' && field === 'category') { if (value === 'conversion') { return "Did you mean 'convert'? 'conversion' is not a valid category."; } if (value === 'click' || value === 'pageview') { return `For '${value}' events, use category: 'other' and event_type: '${value}'.`; } } // Project platform suggestions if (entityType === 'project' && field === 'platform') { if (value === 'feature_experimentation') { return "Use 'custom' for Feature Experimentation projects, not 'feature_experimentation'."; } if (value === 'web_experimentation') { return "Use 'web' for Web Experimentation projects, not 'web_experimentation'."; } } // Generic case-insensitive match const lowerValue = String(value).toLowerCase(); const caseInsensitiveMatch = validValues.find(v => String(v).toLowerCase() === lowerValue); if (caseInsensitiveMatch && caseInsensitiveMatch !== value) { return `Did you mean '${caseInsensitiveMatch}'? (case-sensitive)`; } return null; } /** * Check if types are compatible (allowing some flexibility) */ checkTypeCompatibility(actual, expected) { // Exact match if (actual === expected) return true; // Allow number/integer interchangeability if ((actual === 'number' && expected === 'integer') || (actual === 'integer' && expected === 'number')) { return true; } // Allow 'any' type if (expected === 'any') return true; // String can accept numbers (will be converted) if (expected === 'string' && (actual === 'number' || actual === 'boolean')) { return true; } return false; } /** * Platform-specific validation rules */ validatePlatformSpecific(entityType, data, platform, errors, warnings) { // Web-only entities const webOnlyEntities = ['experiment', 'campaign', 'page', 'extension']; if (webOnlyEntities.includes(entityType) && platform === 'feature') { errors.push(`Entity type '${entityType}' is only available on Web Experimentation platform`); } // Feature-only entities const featureOnlyEntities = ['flag', 'ruleset', 'rule', 'variable_definition']; if (featureOnlyEntities.includes(entityType) && platform === 'web') { errors.push(`Entity type '${entityType}' is only available on Feature Experimentation platform`); } // Experiment metrics scope validation if (entityType === 'experiment' && data.metrics && Array.isArray(data.metrics)) { data.metrics.forEach((metric, index) => { if (metric.scope && metric.scope !== 'visitor') { errors.push(`Experiment metric[${index}] has invalid scope '${metric.scope}'. ` + `Experiments require scope: 'visitor'. Campaigns use scope: 'session'.`); } }); } // Campaign metrics scope validation if (entityType === 'campaign' && data.metrics && Array.isArray(data.metrics)) { data.metrics.forEach((metric, index) => { if (metric.scope && metric.scope !== 'session') { warnings.push(`Campaign metric[${index}] has scope '${metric.scope}'. ` + `Campaigns typically use scope: 'session' (not 'visitor').`); } }); } } /** * Batch validate multiple entities */ validateEntities(entities, options) { const results = new Map(); let allValid = true; for (const entity of entities) { const id = entity.id || `${entity.entityType}_${Date.now()}`; const result = this.validateEntity(entity.entityType, entity.data, options); results.set(id, result); if (!result.valid) { allValid = false; } } return { allValid, results }; } /** * Get cache statistics */ getCacheStats() { return this.schemaCache.getStats(); } /** * Clear schema cache */ clearCache(entityType) { if (entityType) { this.schemaCache.clear(entityType); } else { this.schemaCache.clearAll(); } } /** * Refresh schema in cache */ refreshSchema(entityType) { this.schemaCache.refresh(entityType); } /** * Get cached entity types */ getCachedTypes() { return this.schemaCache.getCachedTypes(); } /** * Clean up expired cache entries */ cleanupCache() { return this.schemaCache.cleanup(); } } //# sourceMappingURL=ComprehensiveSchemaValidator.js.map