survey-mcp-server
Version:
Survey management server handling survey creation, response collection, analysis, and reporting with database access for data management
409 lines • 16.7 kB
JavaScript
import axios from 'axios';
import { BaseService } from './base.js';
import { ExternalApiError, ValidationError } from '../middleware/error-handling.js';
import { securityManager } from '../security/index.js';
import { logger } from '../utils/logger.js';
import { config } from '../utils/config.js';
export class ExternalApiService extends BaseService {
constructor(config) {
super('ExternalApiService', config?.circuitBreaker, config?.retry);
this.endpoints = new Map();
this.clients = new Map();
this.rateLimitStates = new Map();
this.requestCount = 0;
this.errorCount = 0;
this.initializeEndpoints();
}
initializeEndpoints() {
// OpenAI API
if (config.openai.apiKey) {
this.registerEndpoint({
name: 'openai',
baseUrl: 'https://api.openai.com/v1',
timeout: 60000,
headers: {
'Authorization': `Bearer ${config.openai.apiKey}`,
'Content-Type': 'application/json'
},
rateLimit: {
requestsPerMinute: 60,
burstSize: 10
}
});
}
// Perplexity API
if (config.perplexity.apiKey) {
this.registerEndpoint({
name: 'perplexity',
baseUrl: 'https://api.perplexity.ai',
timeout: 30000,
headers: {
'Authorization': `Bearer ${config.perplexity.apiKey}`,
'Content-Type': 'application/json'
},
rateLimit: {
requestsPerMinute: 20,
burstSize: 5
}
});
}
// SYIA API
if (config.api.baseUrl && config.api.token) {
this.registerEndpoint({
name: 'syia',
baseUrl: config.api.baseUrl,
timeout: 30000,
headers: {
'Authorization': `Bearer ${config.api.token}`,
'Content-Type': 'application/json'
},
rateLimit: {
requestsPerMinute: 100,
burstSize: 20
}
});
}
// Classification Society APIs
this.registerEndpoint({
name: 'class_societies',
baseUrl: '',
timeout: 120000, // Longer timeout for browser automation
rateLimit: {
requestsPerMinute: 10,
burstSize: 2
}
});
logger.info(`Initialized ${this.endpoints.size} external API endpoints`);
}
registerEndpoint(endpoint) {
this.endpoints.set(endpoint.name, endpoint);
// Create axios instance for this endpoint
const axiosConfig = {
baseURL: endpoint.baseUrl,
timeout: endpoint.timeout || 30000,
headers: endpoint.headers || {},
validateStatus: (status) => status < 500 // Don't throw on 4xx errors
};
const client = axios.create(axiosConfig);
// Add request interceptor for logging and rate limiting
client.interceptors.request.use(async (config) => {
await this.checkRateLimit(endpoint.name);
logger.debug(`External API request`, {
endpoint: endpoint.name,
method: config.method?.toUpperCase(),
url: config.url,
headers: this.sanitizeHeaders(config.headers)
});
return config;
}, (error) => {
logger.error(`External API request error`, {
endpoint: endpoint.name,
error: error.message
});
return Promise.reject(error);
});
// Add response interceptor for logging
client.interceptors.response.use((response) => {
logger.debug(`External API response`, {
endpoint: endpoint.name,
status: response.status,
responseTime: response.config.metadata?.endTime && response.config.metadata?.startTime ?
response.config.metadata.endTime - response.config.metadata.startTime : undefined
});
return response;
}, (error) => {
logger.error(`External API response error`, {
endpoint: endpoint.name,
status: error.response?.status,
error: error.message
});
return Promise.reject(error);
});
this.clients.set(endpoint.name, client);
this.rateLimitStates.set(endpoint.name, { requests: [], lastRequest: new Date() });
}
async performHealthCheck() {
const startTime = Date.now();
const healthChecks = [];
// Check health of key endpoints
const criticalEndpoints = ['syia'];
for (const endpointName of criticalEndpoints) {
if (this.endpoints.has(endpointName)) {
healthChecks.push(this.checkEndpointHealth(endpointName));
}
}
try {
const results = await Promise.allSettled(healthChecks);
const unhealthyEndpoints = results
.filter(result => result.status === 'fulfilled' && !result.value.isHealthy)
.map(result => result.value);
const responseTime = Date.now() - startTime;
if (unhealthyEndpoints.length > 0) {
return {
isHealthy: false,
lastCheck: new Date(),
responseTime,
error: 'Some external API endpoints are unhealthy',
details: { unhealthyEndpoints }
};
}
return {
isHealthy: true,
lastCheck: new Date(),
responseTime,
details: {
requestCount: this.requestCount,
errorCount: this.errorCount,
endpointsChecked: criticalEndpoints.length
}
};
}
catch (error) {
return {
isHealthy: false,
lastCheck: new Date(),
responseTime: Date.now() - startTime,
error: error.message
};
}
}
async checkEndpointHealth(endpointName) {
try {
// For now, just check if we can create a request (basic connectivity)
const endpoint = this.endpoints.get(endpointName);
if (!endpoint) {
return { endpointName, isHealthy: false, error: 'Endpoint not found' };
}
// For SYIA API, we could do a simple health check
if (endpointName === 'syia') {
const response = await this.makeRequest({
endpoint: endpointName,
method: 'GET',
path: '/health',
timeout: 5000,
skipSecurityCheck: true
});
return { endpointName, isHealthy: response.status < 500 };
}
return { endpointName, isHealthy: true };
}
catch (error) {
return { endpointName, isHealthy: false, error: error.message };
}
}
async makeRequest(request) {
return this.executeWithResilience(async () => {
const startTime = Date.now();
try {
const endpoint = this.endpoints.get(request.endpoint);
if (!endpoint) {
throw new ValidationError(`Unknown endpoint: ${request.endpoint}`);
}
const client = this.clients.get(request.endpoint);
if (!client) {
throw new ExternalApiError(`Client not initialized for endpoint: ${request.endpoint}`);
}
// Security validation and sanitization
if (!request.skipSecurityCheck) {
if (request.data) {
const securityCheck = securityManager.performSecurityCheck(request.data, {
sanitizationContext: 'external_api'
});
if (!securityCheck.isSecure) {
throw new ValidationError(`Request data security validation failed: ${securityCheck.issues.join(', ')}`, 'data');
}
request.data = securityCheck.sanitizedInput;
}
if (request.params) {
const securityCheck = securityManager.performSecurityCheck(request.params, {
sanitizationContext: 'external_api'
});
if (!securityCheck.isSecure) {
throw new ValidationError(`Request params security validation failed: ${securityCheck.issues.join(', ')}`, 'params');
}
request.params = securityCheck.sanitizedInput;
}
}
// Rate limiting check
await this.checkRateLimit(request.endpoint);
// Prepare request config
const requestConfig = {
method: request.method,
url: request.path,
data: request.data,
params: request.params,
headers: {
...endpoint.headers,
...request.headers
},
timeout: request.timeout || endpoint.timeout || 30000,
metadata: { startTime }
};
// Make the request
const response = await client.request(requestConfig);
this.requestCount++;
const responseTime = Date.now() - startTime;
// Check for HTTP errors
if (response.status >= 400) {
throw new ExternalApiError(`HTTP ${response.status}: ${response.statusText}`, {
endpoint: request.endpoint,
status: response.status,
statusText: response.statusText,
responseTime
});
}
logger.debug(`External API request completed`, {
endpoint: request.endpoint,
method: request.method,
path: request.path,
status: response.status,
responseTime
});
return {
data: response.data,
status: response.status,
statusText: response.statusText,
headers: response.headers,
responseTime
};
}
catch (error) {
this.errorCount++;
const responseTime = Date.now() - startTime;
logger.error('External API request failed', {
endpoint: request.endpoint,
method: request.method,
path: request.path,
responseTime,
error: error.message
});
if (error instanceof ValidationError) {
throw error;
}
// Handle axios errors
if (error.response) {
throw new ExternalApiError(`HTTP ${error.response.status}: ${error.response.statusText}`, {
endpoint: request.endpoint,
status: error.response.status,
statusText: error.response.statusText,
responseTime
}, this.isRetryableError(error));
}
else if (error.request) {
throw new ExternalApiError('Network error - no response received', {
endpoint: request.endpoint,
error: error.message,
responseTime
}, true // Network errors are generally retryable
);
}
else {
throw new ExternalApiError(`Request setup error: ${error.message}`, {
endpoint: request.endpoint,
error: error.message,
responseTime
});
}
}
}, `makeRequest-${request.endpoint}`, { timeout: request.timeout });
}
async checkRateLimit(endpointName) {
const endpoint = this.endpoints.get(endpointName);
if (!endpoint?.rateLimit) {
return;
}
const state = this.rateLimitStates.get(endpointName);
if (!state) {
return;
}
const now = Date.now();
const windowStart = now - 60000; // 1 minute window
// Remove old requests outside the window
state.requests = state.requests.filter(timestamp => timestamp > windowStart);
// Check if we've exceeded the rate limit
if (state.requests.length >= endpoint.rateLimit.requestsPerMinute) {
const oldestRequest = Math.min(...state.requests);
const waitTime = 60000 - (now - oldestRequest);
if (waitTime > 0) {
logger.warn(`Rate limit exceeded for ${endpointName}, waiting ${waitTime}ms`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
// Add current request
state.requests.push(now);
state.lastRequest = new Date();
}
isRetryableError(error) {
// HTTP status codes that are retryable
const retryableStatusCodes = [429, 502, 503, 504];
if (error.response?.status && retryableStatusCodes.includes(error.response.status)) {
return true;
}
// Network errors are generally retryable
const retryableNetworkCodes = ['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET'];
if (retryableNetworkCodes.includes(error.code)) {
return true;
}
// Timeout errors
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
return true;
}
return false;
}
sanitizeHeaders(headers) {
if (!headers)
return {};
const sanitized = { ...headers };
const sensitiveHeaders = ['authorization', 'x-api-key', 'cookie', 'set-cookie'];
for (const header of sensitiveHeaders) {
if (sanitized[header]) {
sanitized[header] = '[REDACTED]';
}
if (sanitized[header.toLowerCase()]) {
sanitized[header.toLowerCase()] = '[REDACTED]';
}
}
return sanitized;
}
getEndpointStats() {
const stats = {};
for (const [name, endpoint] of this.endpoints) {
const rateLimitState = this.rateLimitStates.get(name);
stats[name] = {
baseUrl: endpoint.baseUrl,
timeout: endpoint.timeout,
hasRateLimit: !!endpoint.rateLimit,
recentRequests: rateLimitState?.requests.length || 0,
lastRequest: rateLimitState?.lastRequest
};
}
return stats;
}
getServiceStats() {
const successRate = this.requestCount > 0 ? ((this.requestCount - this.errorCount) / this.requestCount) * 100 : 100;
return {
requestCount: this.requestCount,
errorCount: this.errorCount,
successRate: Math.round(successRate * 100) / 100,
endpoints: Array.from(this.endpoints.keys()),
endpointStats: this.getEndpointStats()
};
}
async shutdown() {
try {
// Cancel any pending requests
for (const client of this.clients.values()) {
// Axios doesn't have a direct cancel all method, but we can set a short timeout
client.defaults.timeout = 1;
}
await super.shutdown();
logger.info('External API service shutdown completed');
}
catch (error) {
logger.error('Error during external API service shutdown:', error);
throw error;
}
}
}
export const externalApiService = new ExternalApiService();
//# sourceMappingURL=external-api.js.map