@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
661 lines (570 loc) • 23.8 kB
JavaScript
/**
* 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();