@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
353 lines • 16.3 kB
JavaScript
/**
* OpenAPI Error Enhancer
*
* This class enhances API error messages with prescriptive guidance
* by querying the OpenAPI specification to show exactly what the API expects.
*/
import path from 'path';
import { fileURLToPath } from 'url';
import { getLogger } from '../logging/Logger.js';
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export class OpenAPIErrorEnhancer {
webParser;
flagsParser;
initialized = false;
logger = getLogger();
constructor() {
// We'll initialize lazily when needed
}
/**
* Initialize both OpenAPI parsers (Web and Feature Experimentation)
*/
async initialize() {
if (this.initialized)
return;
try {
const fs = await import('fs/promises');
// Load Web Experimentation API spec (v2) - use absolute path from module location
const webSpecPath = path.join(__dirname, '../../docs/api-reference/api-spec/optimizely-swagger.json');
const webContent = await fs.readFile(webSpecPath, 'utf8');
const webSpec = JSON.parse(webContent);
// Load Feature Experimentation API spec (v1)
const flagsSpecPath = path.join(__dirname, '../../docs/api-reference/api-spec/optimizely-swagger-fx.json');
const flagsContent = await fs.readFile(flagsSpecPath, 'utf8');
const flagsSpec = JSON.parse(flagsContent);
// Create parsers for both APIs
const createParser = (spec) => ({
getEndpointDetails: (method, path) => {
const pathItem = spec.paths?.[path];
if (!pathItem)
return null;
const operation = pathItem[method.toLowerCase()];
if (!operation)
return null;
return {
path,
method: method.toUpperCase(),
operationId: operation.operationId,
summary: operation.summary,
description: operation.description,
tags: operation.tags || [],
parameters: [
...(pathItem.parameters || []),
...(operation.parameters || [])
],
requestBody: operation.requestBody,
responses: operation.responses || {},
security: operation.security || spec.security || [],
deprecated: operation.deprecated || false
};
}
});
this.webParser = createParser(webSpec);
this.flagsParser = createParser(flagsSpec);
this.initialized = true;
this.logger.info({
webSpecPath,
flagsSpecPath
}, 'OpenAPIErrorEnhancer initialized with both API specs');
}
catch (error) {
this.logger.error({ error: error.message }, 'Failed to initialize OpenAPIErrorEnhancer');
throw error;
}
}
/**
* Determine which parser to use based on the endpoint path
*/
getParserForEndpoint(apiPath) {
// Feature Experimentation API endpoints start with /flags/v1
if (apiPath.startsWith('/flags/v1')) {
return this.flagsParser;
}
// Web Experimentation API endpoints (v2)
// These include: /experiments, /campaigns, /pages, /audiences, etc.
return this.webParser;
}
/**
* Enhance an API error with OpenAPI schema information
*/
async enhanceError(error, operation, entityType, apiMethod, apiPath) {
try {
await this.initialize();
// Extract error details
const originalError = this.extractErrorMessage(error);
// If we don't have the API method/path, try to infer it
if (!apiMethod || !apiPath) {
const inferred = this.inferEndpoint(operation, entityType);
apiMethod = apiMethod || inferred.method;
apiPath = apiPath || inferred.path;
}
if (!apiMethod || !apiPath) {
return { originalError };
}
// Determine which parser to use based on the endpoint
const parser = this.getParserForEndpoint(apiPath);
if (!parser) {
return { originalError };
}
// Get endpoint information from OpenAPI spec
const endpointDetails = parser.getEndpointDetails(apiMethod, apiPath);
if (!endpointDetails) {
return { originalError };
}
// Convert to our expected format
const endpointInfo = {
endpoint: `${endpointDetails.method} ${endpointDetails.path}`,
description: endpointDetails.summary || endpointDetails.description || '',
pathParams: endpointDetails.parameters
.filter((p) => p.in === 'path')
.map((p) => ({
name: p.name,
required: true, // path params are always required
type: p.schema?.type || 'string',
description: p.description
})),
queryParams: endpointDetails.parameters
.filter((p) => p.in === 'query')
.map((p) => ({
name: p.name,
required: p.required || false,
type: p.schema?.type || 'string',
description: p.description,
example: p.example
})),
requestBody: endpointDetails.requestBody ? {
required: endpointDetails.requestBody.required || false,
contentTypes: Object.keys(endpointDetails.requestBody.content || {}),
schema: endpointDetails.requestBody.content?.['application/json']?.schema
} : undefined,
responses: Object.entries(endpointDetails.responses || {}).map(([code, response]) => ({
statusCode: code,
description: response.description || '',
schema: response.content?.['application/json']?.schema
}))
};
// Generate enhanced guidance based on the error
const guidance = this.generateGuidance(originalError, endpointInfo, entityType);
return {
originalError,
enhancedGuidance: {
message: guidance.message,
expectedSchema: guidance.expectedSchema,
requiredFields: guidance.requiredFields,
examples: guidance.examples,
apiDocumentation: endpointInfo
}
};
}
catch (enhancementError) {
this.logger.warn({ error: enhancementError.message, originalError: error.message }, 'Failed to enhance error with OpenAPI guidance');
// Return just the original error if enhancement fails
return {
originalError: this.extractErrorMessage(error)
};
}
}
/**
* Extract error message from various error formats
*/
extractErrorMessage(error) {
if (error.response && typeof error.response === 'object') {
if (error.response.message) {
return error.response.message;
}
else if (error.response.error) {
return error.response.error;
}
}
return error.message || 'Unknown error';
}
/**
* Infer API endpoint from operation and entity type
*/
inferEndpoint(operation, entityType) {
const endpoints = {
experiment: {
create: { method: 'POST', path: '/v2/experiments' },
update: { method: 'PATCH', path: '/v2/experiments/{experiment_id}' },
delete: { method: 'DELETE', path: '/v2/experiments/{experiment_id}' }
},
flag: {
create: { method: 'POST', path: '/flags/v1/projects/{project_id}/flags' },
update: { method: 'PATCH', path: '/flags/v1/projects/{project_id}/flags/{flag_key}' },
delete: { method: 'DELETE', path: '/flags/v1/projects/{project_id}/flags/{flag_key}' }
},
page: {
create: { method: 'POST', path: '/v2/pages' },
update: { method: 'PATCH', path: '/v2/pages/{page_id}' },
delete: { method: 'DELETE', path: '/v2/pages/{page_id}' }
},
event: {
create: { method: 'POST', path: '/v2/projects/{project_id}/custom_events' },
update: { method: 'PATCH', path: '/v2/projects/{project_id}/custom_events/{event_id}' },
delete: { method: 'DELETE', path: '/v2/projects/{project_id}/custom_events/{event_id}' }
},
campaign: {
create: { method: 'POST', path: '/v2/campaigns' },
update: { method: 'PATCH', path: '/v2/campaigns/{campaign_id}' },
delete: { method: 'DELETE', path: '/v2/campaigns/{campaign_id}' }
},
audience: {
create: { method: 'POST', path: '/v2/audiences' },
update: { method: 'PATCH', path: '/v2/audiences/{audience_id}' },
delete: { method: 'DELETE', path: '/v2/audiences/{audience_id}' }
}
};
return endpoints[entityType]?.[operation] || {};
}
/**
* Generate guidance based on the error and OpenAPI schema
*/
generateGuidance(errorMessage, endpointInfo, entityType) {
const guidance = {
message: '',
requiredFields: []
};
// Handle specific error patterns
if (errorMessage.includes("'edit_url' is a required property")) {
guidance.message = `Web experiments require an 'edit_url' field that specifies the Optimizely editor URL. `;
guidance.message += `This field is automatically generated when creating experiments through the Optimizely editor, `;
guidance.message += `but must be provided when creating via API. Use template mode for easier experiment creation.`;
if (endpointInfo.requestBody?.schema) {
guidance.requiredFields = this.extractRequiredFields(endpointInfo.requestBody.schema);
guidance.examples = {
edit_url: "https://app.optimizely.com/v2/projects/123456789/experiments/edit/987654321",
note: "Use template mode instead of direct creation for complex experiments"
};
}
}
else if (errorMessage.includes("'page_id' is a required property")) {
guidance.message = `Web experiment actions require a 'page_id' field in each action object. `;
guidance.message += `The action structure should be: `;
guidance.message += `actions: [{ page_id: 123456, changes: [{ type: "custom_code", selector: "body", value: "..." }] }]. `;
guidance.message += `Each action targets a specific page and contains an array of changes to make on that page.`;
guidance.examples = {
correct_action_structure: {
page_id: 5117198042136576,
changes: [
{
type: "custom_code",
selector: "body",
value: "console.log('Variation loaded');"
}
]
},
note: "Each action must have a page_id that matches one of the page_ids in the experiment"
};
}
else if (errorMessage.includes("'actions' must be specified for Variations")) {
guidance.message = `Web experiments require 'actions' to be specified in each variation. `;
guidance.message += `Actions define what changes to make on the page (HTML, CSS, JavaScript).`;
// Extract variation schema from the request body
if (endpointInfo.requestBody?.schema) {
const variationSchema = this.findSchemaProperty(endpointInfo.requestBody.schema, 'variations');
if (variationSchema) {
guidance.expectedSchema = variationSchema;
guidance.message += `\n\nExpected variation structure:`;
guidance.examples = {
variations: [{
name: "Control",
weight: 5000,
actions: [] // Empty for control
}, {
name: "Treatment",
weight: 5000,
actions: [{
page_id: 123456,
changes: [{
type: "attribute",
selector: "#button",
attribute: "style",
value: "background: blue"
}]
}]
}]
};
}
}
}
else if (errorMessage.includes("required")) {
// Extract required fields from schema
if (endpointInfo.requestBody?.schema) {
guidance.requiredFields = this.extractRequiredFields(endpointInfo.requestBody.schema);
guidance.message = `Missing required fields. The ${entityType} requires: ${guidance.requiredFields.join(', ')}`;
}
}
else if (errorMessage.includes("Invalid value") || errorMessage.includes("Valid options")) {
// Error already contains enum values, but we can add schema context
guidance.message = errorMessage;
if (endpointInfo.requestBody?.schema) {
guidance.expectedSchema = endpointInfo.requestBody.schema;
}
}
// Add general schema information if we haven't provided specific guidance
if (!guidance.message && endpointInfo.requestBody?.schema) {
guidance.message = `Review the expected request format for ${entityType} creation.`;
guidance.expectedSchema = endpointInfo.requestBody.schema;
guidance.requiredFields = this.extractRequiredFields(endpointInfo.requestBody.schema);
}
return guidance;
}
/**
* Find a specific property in a nested schema
*/
findSchemaProperty(schema, propertyName) {
if (!schema)
return null;
if (schema.properties && schema.properties[propertyName]) {
return schema.properties[propertyName];
}
// Check in nested schemas
if (schema.allOf) {
for (const subSchema of schema.allOf) {
const found = this.findSchemaProperty(subSchema, propertyName);
if (found)
return found;
}
}
return null;
}
/**
* Extract required fields from a schema
*/
extractRequiredFields(schema) {
const required = [];
if (schema.required && Array.isArray(schema.required)) {
required.push(...schema.required);
}
// Check in allOf schemas
if (schema.allOf) {
for (const subSchema of schema.allOf) {
const subRequired = this.extractRequiredFields(subSchema);
required.push(...subRequired);
}
}
return [...new Set(required)]; // Remove duplicates
}
}
// Singleton instance
export const openAPIErrorEnhancer = new OpenAPIErrorEnhancer();
//# sourceMappingURL=OpenAPIErrorEnhancer.js.map