UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

590 lines (500 loc) 20.5 kB
#!/usr/bin/env node /** * Enhanced Schema Generator for Optimizely Entities * * This enhanced version includes proper extraction of event category enums * and other missing validation constraints from the API specifications. */ import fetch from 'node-fetch'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; // Get script directory const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PROJECT_ROOT = path.resolve(__dirname, '..'); // Configuration const WEB_SWAGGER_URL = 'https://api.optimizely.com/v2/swagger.json'; const FX_SWAGGER_URL = 'https://docs.developers.optimizely.com/feature-experimentation/openapi/64ef5b583c011a140fba7b40'; const FX_SWAGGER_PATH = path.join(PROJECT_ROOT, 'docs/api-reference/api-spec/optimizely-swagger-fx.json'); const OUTPUT_PATH = path.join(PROJECT_ROOT, 'src/generated/fields.generated.ts'); // CRITICAL: Hardcoded enum values that are missing from the OpenAPI spec const MISSING_ENUMS = { event: { category: ['add_to_cart', 'save', 'search', 'share', 'purchase', 'convert', 'sign_up', 'subscribe', 'other'] } }; class EnhancedSchemaGenerator { constructor() { this.webSwagger = null; this.fxSwagger = null; } /** * Main execution method */ async run() { console.log('🚀 Starting enhanced schema generation...'); try { // Step 1: Fetch Web Experimentation Swagger spec console.log('📥 Fetching Web Experimentation API specification...'); this.webSwagger = await this.fetchWebSwagger(); console.log(`✅ Fetched Web API with ${Object.keys(this.webSwagger.definitions || {}).length} definitions`); // Step 2: Fetch Feature Experimentation Swagger spec console.log('📥 Fetching Feature Experimentation API specification...'); this.fxSwagger = await this.fetchFxSwagger(); console.log(`✅ Loaded FX API with ${Object.keys(this.fxSwagger.components?.schemas || {}).length} schemas`); // Step 3: Parse entities from both specs console.log('🔍 Parsing entity schemas from both APIs...'); const webSchemas = this.parseEntities(this.webSwagger); const fxSchemas = this.parseFxEntities(this.fxSwagger); // Merge schemas, with FX taking precedence for shared entities const schemas = { ...webSchemas, ...fxSchemas }; console.log(`✅ Parsed ${Object.keys(schemas).length} total entity schemas`); // Step 3.5: Apply missing enums and validation rules console.log('🔧 Applying missing enum values and validation rules...'); this.applyMissingEnums(schemas); // Step 4: Generate TypeScript console.log('📝 Generating TypeScript file...'); const typescript = this.generateTypeScript(schemas); // Step 5: Write to file console.log('💾 Writing to file...'); await this.writeOutput(typescript); console.log(`✅ Enhanced schema generation complete! Output: ${OUTPUT_PATH}`); } catch (error) { console.error('❌ Schema generation failed:', error); process.exit(1); } } /** * Apply missing enum values that are not in the OpenAPI spec */ applyMissingEnums(schemas) { for (const [entityType, missingEnums] of Object.entries(MISSING_ENUMS)) { if (schemas[entityType]) { console.log(` 🔧 Adding missing enums to ${entityType}`); schemas[entityType].enums = { ...schemas[entityType].enums, ...missingEnums }; console.log(` ✅ Added category enum with ${missingEnums.category.length} values`); } } } /** * Fetch Web Experimentation Swagger specification with timeout and fallback */ async fetchWebSwagger() { try { console.log('📡 Fetching from:', WEB_SWAGGER_URL); console.log('⏱️ Timeout: 15 seconds'); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); const response = await fetch(WEB_SWAGGER_URL, { signal: controller.signal, headers: { 'User-Agent': 'Optimizely-MCP-Schema-Generator/1.0' } }); clearTimeout(timeoutId); if (!response.ok) { console.log(`⚠️ API returned ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`); } try { const json = await response.json(); console.log('✅ Successfully fetched from API'); return json; } catch (parseError) { console.log('⚠️ Failed to parse API response as JSON:', parseError.message); throw new Error(`Invalid JSON response from API: ${parseError.message}`); } } catch (error) { if (error.name === 'AbortError') { console.log('⚠️ Request timed out after 15 seconds'); } else { console.log('⚠️ API request failed:', error.message); } // Fallback to local copy const localPath = path.join(PROJECT_ROOT, 'docs/api-reference/api-spec/optimizely-swagger.json'); const altLocalPath = path.join(PROJECT_ROOT, 'scripts/swagger.json'); try { console.log('📁 Trying primary local backup:', localPath); const content = await fs.readFile(localPath, 'utf-8'); console.log('✅ Using primary local Swagger backup'); return JSON.parse(content); } catch (localError) { console.log('⚠️ Primary backup not found, trying alternative...'); try { console.log('📁 Trying alternative local backup:', altLocalPath); const content = await fs.readFile(altLocalPath, 'utf-8'); console.log('✅ Using alternative local Swagger backup'); return JSON.parse(content); } catch (altError) { console.error('❌ All fallback options failed'); throw new Error(`Failed to fetch Web Swagger: API unreachable and no local backups available`); } } } } /** * Fetch Feature Experimentation Swagger specification */ async fetchFxSwagger() { try { console.log('📡 Fetching from:', FX_SWAGGER_URL); console.log('⏱️ Timeout: 30 seconds'); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); const response = await fetch(FX_SWAGGER_URL, { signal: controller.signal, headers: { 'User-Agent': 'Optimizely-MCP-Schema-Generator/1.0' } }); clearTimeout(timeoutId); if (!response.ok) { console.log(`⚠️ API returned ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`); } try { const json = await response.json(); console.log('✅ Successfully fetched FX API from URL'); return json; } catch (parseError) { console.log('⚠️ Failed to parse FX API response as JSON:', parseError.message); throw new Error(`Invalid JSON response from FX API: ${parseError.message}`); } } catch (error) { if (error.name === 'AbortError') { console.log('⚠️ FX API request timed out after 30 seconds'); } else { console.log('⚠️ FX API request failed:', error.message); } try { console.log('📁 Falling back to local FX API file:', FX_SWAGGER_PATH); const content = await fs.readFile(FX_SWAGGER_PATH, 'utf-8'); console.log('✅ Using local FX API backup'); return JSON.parse(content); } catch (localError) { console.warn(`⚠️ Could not load local FX Swagger: ${localError.message}`); console.warn('⚠️ Proceeding without Feature Experimentation API schemas'); return { components: { schemas: {} } }; } } } /** * Parse entity schemas from Swagger definitions */ parseEntities(swagger) { const schemas = {}; const entityMappings = { 'project': ['Project'], 'experiment': ['Experiment'], 'feature': ['Feature'], 'variation': ['Variation'], 'event': ['Event', 'CustomEvent'], 'audience': ['Audience'], 'attribute': ['Attribute'], 'campaign': ['Campaign'], 'page': ['Page'], 'extension': ['Extension'], 'group': ['Group'], 'webhook': ['Webhook'], 'collaborator': ['CollaboratorEntry'], 'account': ['Account'], 'results': ['ExperimentResults', 'CampaignResults'], 'variable': ['FeatureVariable'], 'environment': ['FeatureEnvironment', 'ExperimentEnvironment'] }; for (const [entityType, swaggerNames] of Object.entries(entityMappings)) { let schema = null; for (const swaggerName of swaggerNames) { schema = this.extractEntitySchemaBySwaggerName(swagger, swaggerName); if (schema) { break; } } if (schema) { schemas[entityType] = schema; console.log(`✅ Found schema for ${entityType} (${swaggerNames.join(', ')})`); } else { console.log(`⚠️ No definition found for ${entityType} (tried: ${swaggerNames.join(', ')})`); } } return schemas; } /** * Parse entity schemas from Feature Experimentation OpenAPI spec */ parseFxEntities(fxSwagger) { const schemas = {}; const components = fxSwagger.components?.schemas || {}; const fxEntityMap = { 'Flag': 'flag', 'Rule': 'rule', 'Ruleset': 'ruleset', 'VariableDefinition': 'variable_definition', 'FlagVariation': 'flag_variation', 'Environment': 'fx_environment' }; for (const [schemaName, entityType] of Object.entries(fxEntityMap)) { const schema = components[schemaName]; if (schema) { console.log(`🔍 Processing FX ${schemaName}...`); schemas[entityType] = this.parseFxSchema(schemaName, schema); console.log(`✅ Found schema for ${entityType} (${schemaName})`); } } return schemas; } /** * Parse a single FX schema into our format */ parseFxSchema(schemaName, schema) { const required = schema.required || []; const properties = schema.properties || {}; const optional = Object.keys(properties).filter(prop => !required.includes(prop)); const defaults = {}; const enums = {}; const fieldTypes = {}; const fieldDescriptions = {}; const fieldExamples = {}; const validation = { minLength: {}, maxLength: {}, pattern: {}, minimum: {}, maximum: {} }; for (const [propName, propDef] of Object.entries(properties)) { fieldTypes[propName] = propDef.type || 'any'; if (propDef.description) { fieldDescriptions[propName] = propDef.description; } if (propDef.example !== undefined) { fieldExamples[propName] = propDef.example; } if (propDef.default !== undefined) { defaults[propName] = propDef.default; } if (propDef.enum) { enums[propName] = propDef.enum; } if (propDef.minimum !== undefined) validation.minimum[propName] = propDef.minimum; if (propDef.maximum !== undefined) validation.maximum[propName] = propDef.maximum; if (propDef.minLength !== undefined) validation.minLength[propName] = propDef.minLength; if (propDef.maxLength !== undefined) validation.maxLength[propName] = propDef.maxLength; if (propDef.pattern !== undefined) validation.pattern[propName] = propDef.pattern; } return { required, optional, defaults, enums, fieldTypes, fieldDescriptions, fieldExamples, endpoints: {}, validation }; } /** * Extract schema for a specific entity by exact Swagger definition name */ extractEntitySchemaBySwaggerName(swagger, swaggerName) { const definitions = swagger.definitions || {}; const definition = definitions[swaggerName]; if (!definition) { return null; } console.log(`🔍 Processing ${swaggerName}...`); const required = definition.required || []; const properties = definition.properties || {}; const allFields = Object.keys(properties); const optional = allFields.filter(field => !required.includes(field)); const defaults = {}; const enums = {}; const fieldTypes = {}; const fieldDescriptions = {}; const fieldExamples = {}; const validation = { minLength: {}, maxLength: {}, pattern: {}, minimum: {}, maximum: {} }; for (const [propName, propDef] of Object.entries(properties)) { fieldTypes[propName] = propDef.type || 'any'; if (propDef.description) { fieldDescriptions[propName] = propDef.description; console.log(` 📝 ${propName}: description found`); } if (propDef.example !== undefined) { fieldExamples[propName] = propDef.example; console.log(` 💡 ${propName}: example found`); } if (propDef.minLength !== undefined) { validation.minLength[propName] = propDef.minLength; } if (propDef.maxLength !== undefined) { validation.maxLength[propName] = propDef.maxLength; } if (propDef.pattern !== undefined) { validation.pattern[propName] = propDef.pattern; } if (propDef.minimum !== undefined) { validation.minimum[propName] = propDef.minimum; } if (propDef.maximum !== undefined) { validation.maximum[propName] = propDef.maximum; } if (propDef.default !== undefined) { defaults[propName] = propDef.default; } if (propDef.enum) { enums[propName] = propDef.enum; } } const endpoints = this.extractEndpoints(swagger, swaggerName); this.applyCustomSchemaEnhancements(swaggerName, optional, fieldTypes, fieldDescriptions); return { required, optional, defaults, enums, fieldTypes, fieldDescriptions, fieldExamples, endpoints, validation }; } /** * Apply custom schema enhancements for template mode support */ applyCustomSchemaEnhancements(swaggerName, optional, fieldTypes, fieldDescriptions) { if (swaggerName === 'Experiment') { console.log(' 🔧 Applying custom enhancements for Experiment schema'); if (!optional.includes('audiences')) { optional.push('audiences'); fieldTypes['audiences'] = 'array'; fieldDescriptions['audiences'] = 'Array of audience references for multiple audience targeting (template mode only)'; console.log(' ✅ Added audiences field to experiment schema'); } if (!optional.includes('audiences_operator')) { optional.push('audiences_operator'); fieldTypes['audiences_operator'] = 'string'; fieldDescriptions['audiences_operator'] = 'Logical operator for multiple audiences: "and" or "or" (template mode only)'; console.log(' ✅ Added audiences_operator field to experiment schema'); } } } /** * Extract API endpoints for an entity from swagger paths */ extractEndpoints(swagger, swaggerName) { const endpoints = {}; const paths = swagger.paths || {}; const entityPathMappings = { 'Project': ['projects'], 'Experiment': ['experiments'], 'Event': ['events', 'custom_events'], 'CustomEvent': ['custom_events'], 'Audience': ['audiences'], 'Attribute': ['attributes'], 'Campaign': ['campaigns'], 'Page': ['pages'], 'Extension': ['extensions'], 'Group': ['groups'], 'Webhook': ['webhooks'], 'Feature': ['features'], 'FeatureVariable': ['variables'] }; const entityPaths = entityPathMappings[swaggerName] || [swaggerName.toLowerCase()]; for (const [path, pathDef] of Object.entries(paths)) { const pathLower = path.toLowerCase(); const isRelated = entityPaths.some(entityPath => pathLower.includes(`/${entityPath}`) || pathLower.includes(`/${entityPath}/`) ); if (!isRelated) continue; if (pathDef.post) { if (path.includes('{')) { if (!path.includes(`{${swaggerName.toLowerCase()}_id}`) && !path.includes('{id}')) { endpoints.create = `POST ${path}`; } } else { endpoints.create = `POST ${path}`; } } if (pathDef.get) { if (path.includes('{')) { endpoints.get = `GET ${path}`; } else { endpoints.list = `GET ${path}`; } } if (pathDef.patch || pathDef.put) { endpoints.update = `${pathDef.patch ? 'PATCH' : 'PUT'} ${path}`; } if (pathDef.delete) { endpoints.delete = `DELETE ${path}`; } } return endpoints; } /** * Generate TypeScript code from schemas */ generateTypeScript(schemas) { const timestamp = new Date().toISOString(); let output = `/** * AUTO-GENERATED FILE - DO NOT EDIT * Generated from: ${WEB_SWAGGER_URL} and ${FX_SWAGGER_URL} * Generated at: ${timestamp} * * This file contains entity schemas extracted from Optimizely's API specification. * It is used by the ConfigurableDefaultsManager to provide type-safe defaults. * * ENHANCED: This version includes missing enum values like event categories. */ export const FIELDS = { `; for (const [entityName, schema] of Object.entries(schemas)) { output += ` ${entityName}: {\n`; output += ` required: ${JSON.stringify(schema.required, null, 6).replace(/\n/g, '\n ')},\n`; output += ` optional: ${JSON.stringify(schema.optional, null, 6).replace(/\n/g, '\n ')},\n`; output += ` defaults: ${JSON.stringify(schema.defaults, null, 6).replace(/\n/g, '\n ')},\n`; output += ` enums: ${JSON.stringify(schema.enums, null, 6).replace(/\n/g, '\n ')},\n`; output += ` fieldTypes: ${JSON.stringify(schema.fieldTypes, null, 6).replace(/\n/g, '\n ')},\n`; output += ` fieldDescriptions: ${JSON.stringify(schema.fieldDescriptions, null, 6).replace(/\n/g, '\n ')},\n`; output += ` fieldExamples: ${JSON.stringify(schema.fieldExamples, null, 6).replace(/\n/g, '\n ')},\n`; output += ` endpoints: ${JSON.stringify(schema.endpoints, null, 6).replace(/\n/g, '\n ')},\n`; output += ` validation: ${JSON.stringify(schema.validation, null, 6).replace(/\n/g, '\n ')}\n`; output += ` },\n`; } output += `} as const; // Type exports for compile-time safety export type EntityName = keyof typeof FIELDS; export type EntitySchema<T extends EntityName> = typeof FIELDS[T]; // Helper types export type RequiredFields<T extends EntityName> = typeof FIELDS[T]['required'][number]; export type DefaultValues<T extends EntityName> = typeof FIELDS[T]['defaults']; export type EnumValues<T extends EntityName> = typeof FIELDS[T]['enums']; export type FieldTypes<T extends EntityName> = typeof FIELDS[T]['fieldTypes']; `; return output; } /** * Write generated TypeScript to file */ async writeOutput(content) { const dir = path.dirname(OUTPUT_PATH); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(OUTPUT_PATH, content, 'utf-8'); console.log(`✅ Written ${content.length} characters to ${OUTPUT_PATH}`); } } // Run if executed directly const generator = new EnhancedSchemaGenerator(); generator.run();