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