UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

661 lines (570 loc) 23.8 kB
#!/usr/bin/env node /** * Schema Generator for Optimizely Entities (JavaScript Version) * * This script fetches the Optimizely API specification and generates * schema definitions for all entities. No TypeScript compilation needed! */ 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'); class SchemaGenerator { constructor() { this.webSwagger = null; this.fxSwagger = null; } /** * Main execution method */ async run() { console.log('🚀 Starting 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: Generate TypeScript console.log('📝 Generating TypeScript file...'); const typescript = this.generateTypeScript(schemas); // Step 4: Write to file console.log('💾 Writing to file...'); await this.writeOutput(typescript); console.log(`✅ Schema generation complete! Output: ${OUTPUT_PATH}`); } catch (error) { console.error('❌ Schema generation failed:', error); process.exit(1); } } /** * Fetch Web Experimentation Swagger specification with timeout and fallback */ async fetchWebSwagger() { try { console.log('📡 Fetching from:', WEB_SWAGGER_URL); console.log('⏱️ Timeout: 15 seconds'); // Create timeout controller const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 seconds 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 to parse JSON response 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) { // Check if it's a timeout or abort 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 primary local backup first 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 alternative local backup 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:'); console.error(' - API fetch:', error.message); console.error(' - Primary backup:', localError.message); console.error(' - Alternative backup:', altError.message); throw new Error(`Failed to fetch Web Swagger: API unreachable and no local backups available`); } } } } /** * Fetch Feature Experimentation Swagger specification with timeout and fallback */ async fetchFxSwagger() { try { console.log('📡 Fetching from:', FX_SWAGGER_URL); console.log('⏱️ Timeout: 30 seconds'); // Create timeout controller const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 seconds 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 to parse JSON response 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) { // Check if it's a timeout or abort error if (error.name === 'AbortError') { console.log('⚠️ FX API request timed out after 30 seconds'); } else { console.log('⚠️ FX API request failed:', error.message); } // Fallback to local copy 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 empty spec if not found return { components: { schemas: {} } }; } } } /** * Parse entity schemas from Swagger definitions */ parseEntities(swagger) { const schemas = {}; // Key entity types we care about - mapping from our names to Swagger definition names const entityMappings = { // Core entities 'project': ['Project'], 'experiment': ['Experiment'], 'feature': ['Feature'], // Feature flags 'variation': ['Variation'], 'event': ['CustomEvent', 'Event'], // CustomEvent first as it has required fields 'audience': ['Audience'], 'attribute': ['Attribute'], 'campaign': ['Campaign'], 'page': ['Page'], 'extension': ['Extension'], 'group': ['Group'], 'webhook': ['Webhook'], // Additional important entities 'collaborator': ['CollaboratorEntry'], 'account': ['Account'], 'results': ['ExperimentResults', 'CampaignResults'], 'variable': ['FeatureVariable'], 'environment': ['FeatureEnvironment', 'ExperimentEnvironment'] }; // Parse each entity type for (const [entityType, swaggerNames] of Object.entries(entityMappings)) { let mergedSchema = null; // Try each possible swagger name for this entity and merge schemas for (const swaggerName of swaggerNames) { const schema = this.extractEntitySchemaBySwaggerName(swagger, swaggerName); if (schema) { if (!mergedSchema) { mergedSchema = schema; } else { // Merge schemas, preferring schemas with required fields if (schema.required.length > mergedSchema.required.length) { // Use the schema with more required fields as base const temp = mergedSchema; mergedSchema = schema; // Merge any additional fields from the previous schema Object.keys(temp.fieldTypes).forEach(field => { if (!mergedSchema.fieldTypes[field]) { mergedSchema.optional.push(field); mergedSchema.fieldTypes[field] = temp.fieldTypes[field]; if (temp.fieldDescriptions[field]) { mergedSchema.fieldDescriptions[field] = temp.fieldDescriptions[field]; } if (temp.fieldExamples[field]) { mergedSchema.fieldExamples[field] = temp.fieldExamples[field]; } } }); } } } } if (mergedSchema) { schemas[entityType] = mergedSchema; 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 || {}; // Map FX schema names to our entity types 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: {} }; // Process properties for (const [propName, propDef] of Object.entries(properties)) { // Extract type fieldTypes[propName] = propDef.type || 'any'; // Extract description if (propDef.description) { fieldDescriptions[propName] = propDef.description; } // Extract example if (propDef.example !== undefined) { fieldExamples[propName] = propDef.example; } // Extract default if (propDef.default !== undefined) { defaults[propName] = propDef.default; } // Extract enum if (propDef.enum) { enums[propName] = propDef.enum; } // Extract validation rules 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: {}, // FX endpoints will be added separately validation }; } /** * Extract schema for a specific entity by exact Swagger definition name */ extractEntitySchemaBySwaggerName(swagger, swaggerName) { // Find the definition for this entity const definitions = swagger.definitions || {}; const definition = definitions[swaggerName]; if (!definition) { return null; } console.log(`🔍 Processing ${swaggerName}...`); // Extract schema information 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: {} }; // Process each property for (const [propName, propDef] of Object.entries(properties)) { // Extract type fieldTypes[propName] = propDef.type || 'any'; // Extract description if (propDef.description) { fieldDescriptions[propName] = propDef.description; console.log(` 📝 ${propName}: description found`); } // Extract example if (propDef.example !== undefined) { fieldExamples[propName] = propDef.example; console.log(` 💡 ${propName}: example found`); } // Extract validation constraints 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; } // Extract default value if (propDef.default !== undefined) { defaults[propName] = propDef.default; } // Extract enum values if (propDef.enum) { enums[propName] = propDef.enum; } } // Extract endpoints for this entity from swagger paths const endpoints = this.extractEndpoints(swagger, swaggerName); // 🚨 CUSTOM SCHEMA ENHANCEMENTS // Add custom fields that are not in the OpenAPI spec but are needed for template mode this.applyCustomSchemaEnhancements(swaggerName, optional, fieldTypes, fieldDescriptions, enums); return { required, optional, defaults, enums, fieldTypes, fieldDescriptions, fieldExamples, endpoints, validation }; } /** * Apply custom schema enhancements for template mode support * These are fields that aren't in the OpenAPI spec but are needed for our template system */ applyCustomSchemaEnhancements(swaggerName, optional, fieldTypes, fieldDescriptions, enums) { // Experiment-specific enhancements for multiple audiences support if (swaggerName === 'Experiment') { console.log(' 🔧 Applying custom enhancements for Experiment schema'); // Add audiences field for multiple audience targeting 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'); } // Add audiences_operator field for AND/OR logic 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'); } } // Event-specific enhancements for category enum if (swaggerName === 'Event' || swaggerName === 'CustomEvent') { console.log(' 🔧 Applying custom enhancements for Event schema'); // Add category enum values that are missing from the OpenAPI spec if (!enums['category']) { enums['category'] = [ 'add_to_cart', 'save', 'search', 'share', 'purchase', 'convert', 'sign_up', 'subscribe', 'other' ]; console.log(' ✅ Added category enum values to event schema'); } } // Future: Add more custom enhancements for other entity types here // Example: // if (swaggerName === 'Flag') { // // Add custom flag fields // } } /** * Extract API endpoints for an entity from swagger paths */ extractEndpoints(swagger, swaggerName) { const endpoints = {}; const paths = swagger.paths || {}; // Map of entity names to their potential path patterns 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()]; // Search through all paths for operations related to this entity for (const [path, pathDef] of Object.entries(paths)) { const pathLower = path.toLowerCase(); // Check if this path is related to our entity const isRelated = entityPaths.some(entityPath => pathLower.includes(`/${entityPath}`) || pathLower.includes(`/${entityPath}/`) ); if (!isRelated) continue; // Extract CRUD operations if (pathDef.post) { if (path.includes('{')) { // Usually creation endpoints don't have IDs in path 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. */ export const FIELDS = { `; // Generate each entity schema 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) { // Ensure directory exists const dir = path.dirname(OUTPUT_PATH); await fs.mkdir(dir, { recursive: true }); // Write file await fs.writeFile(OUTPUT_PATH, content, 'utf-8'); console.log(`✅ Written ${content.length} characters to ${OUTPUT_PATH}`); } } // Run if executed directly const generator = new SchemaGenerator(); generator.run();