UNPKG

easy-mcp-server

Version:

AI-era Express replacement with zero-config MCP integration - Build AI-ready APIs in 30 seconds

607 lines (545 loc) 17.6 kB
/** * OpenAPI Generator * * Generates OpenAPI 3.0 specifications from discovered API routes. * Extracts schema information from JSDoc annotations and class properties * to create comprehensive API documentation. * * Features: * - Automatic OpenAPI 3.0 specification generation * - JSDoc annotation parsing (@description, @param, @query, @body, @response) * - Schema extraction from class properties * - Component schema reuse * - Tag generation for endpoint grouping * - Response and error schema generation * - TypeScript schema support * * @class OpenAPIGenerator */ class OpenAPIGenerator { constructor(apiLoader) { this.apiLoader = apiLoader; } /** * Generate OpenAPI specification */ generateSpec() { const routes = this.apiLoader.getRoutes(); const paths = this.generatePaths(routes); const components = this.generateComponents(); const spec = { openapi: '3.0.0', info: this.generateInfo(), servers: this.generateServers(), paths: paths, components: components, tags: this.generateTags(routes) }; // Validate and fix the spec to ensure OpenAPI compliance this.validateAndFixSpec(spec); return spec; } /** * Validate and fix OpenAPI specification to ensure compliance * @param {Object} spec - OpenAPI specification object */ validateAndFixSpec(spec) { if (!spec.paths) return; const operationIds = new Set(); // Ensure all path parameters in path are defined in parameters array Object.entries(spec.paths).forEach(([path, pathItem]) => { // Extract parameter names from path (OpenAPI format: {param}) const pathParamNames = new Set(); const pathParamRegex = /\{([^}]+)\}/g; let match; while ((match = pathParamRegex.exec(path)) !== null) { pathParamNames.add(match[1]); } // Check each operation in the path Object.entries(pathItem).forEach(([method, operation]) => { if (!operation || typeof operation !== 'object' || ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'].indexOf(method) === -1) { return; } // Ensure operationId is unique if (operation.operationId) { if (operationIds.has(operation.operationId)) { // Make operationId unique by appending method and index let counter = 1; let uniqueId = `${operation.operationId}_${counter}`; while (operationIds.has(uniqueId)) { counter++; uniqueId = `${operation.operationId}_${counter}`; } operation.operationId = uniqueId; } operationIds.add(operation.operationId); } // Ensure all path parameters are defined if (pathParamNames.size > 0) { operation.parameters = operation.parameters || []; // Check if all path parameters are defined pathParamNames.forEach(paramName => { const paramDefined = operation.parameters.some(p => p.name === paramName && p.in === 'path' ); if (!paramDefined) { // Add missing path parameter operation.parameters.push({ name: paramName, in: 'path', required: true, description: `${paramName} path parameter`, schema: { type: 'string' } }); } else { // Ensure existing path parameter has required: true const param = operation.parameters.find(p => p.name === paramName && p.in === 'path'); if (param) { param.required = true; } } }); } // Ensure responses object exists if (!operation.responses) { operation.responses = { '200': { description: 'Successful response', content: { 'application/json': { schema: { $ref: '#/components/schemas/Success' } } } } }; } // Ensure all responses have content structure (if they have content) Object.entries(operation.responses).forEach(([statusCode, response]) => { if (response && typeof response === 'object' && response.content) { // Ensure content has at least one media type if (Object.keys(response.content).length === 0) { response.content['application/json'] = { schema: statusCode.startsWith('2') || statusCode === '200' || statusCode === '201' ? { $ref: '#/components/schemas/Success' } : { $ref: '#/components/schemas/Error' } }; } } }); }); }); } /** * Convert Express-style path parameters (:param) to OpenAPI format ({param}) * @param {string} path - Express-style path (e.g., /users/:id) * @returns {string} OpenAPI-style path (e.g., /users/{id}) */ convertPathToOpenAPIFormat(path) { if (!path || typeof path !== 'string') { return path; } // Convert :param to {param} format for OpenAPI spec return path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}'); } /** * Generate paths object from routes */ generatePaths(routes) { const paths = {}; if (!routes || !Array.isArray(routes)) { return paths; } routes.forEach(route => { if (!route || !route.path || !route.method) { return; // Skip malformed routes } // Convert Express path format to OpenAPI format const openApiPath = this.convertPathToOpenAPIFormat(route.path); if (!paths[openApiPath]) { paths[openApiPath] = {}; } const processor = route.processorInstance; const method = route.method.toLowerCase(); if (processor) { // Build API info with available properties // Use OpenAPI path format for operationId const operationIdPath = openApiPath.replace(/\//g, '_').replace(/^_/, '').replace(/\{([^}]+)\}/g, '_$1_'); const apiInfo = { summary: `${route.method} ${route.path}`, tags: ['api'], operationId: `${method}_${operationIdPath}` }; // Add description if available if (processor.description) { apiInfo.description = processor.description; } // Add custom OpenAPI info if available (including annotation-based responses) if (processor.openApi) { Object.assign(apiInfo, processor.openApi); } // Extract path parameters from route and add them to the API info // Use the original Express path format for parameter extraction const pathParams = this.extractPathParameters(route.path); if (pathParams.length > 0) { // Merge with existing parameters if any apiInfo.parameters = apiInfo.parameters || []; // Add path parameters that don't already exist pathParams.forEach(param => { const existingParam = apiInfo.parameters.find(p => p.name === param.name && p.in === 'path'); if (!existingParam) { apiInfo.parameters.push(param); } }); } // Only auto-generate responses if no annotation-based responses are available if (!apiInfo.responses || Object.keys(apiInfo.responses).length === 0) { apiInfo.responses = this.generateResponseSchema(processor); } // Add default error responses if not provided if (!apiInfo.responses['400']) { apiInfo.responses['400'] = { description: 'Bad request', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }; } if (!apiInfo.responses['500']) { apiInfo.responses['500'] = { description: 'Internal server error', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }; } paths[openApiPath][method] = apiInfo; } else { paths[openApiPath][method] = { summary: `${route.method} ${route.path}`, tags: ['api'], operationId: `${method}_${openApiPath.replace(/\//g, '_').replace(/^_/, '').replace(/\{([^}]+)\}/g, '_$1_')}`, responses: { '200': { description: 'Successful response', content: { 'application/json': { schema: { $ref: '#/components/schemas/Success' } } } } } }; } }); return paths; } /** * Generate API info */ generateInfo() { const path = require('path'); const fs = require('fs'); // Resolve package.json from the root of the project // Try multiple paths to ensure it works in different environments let packageJson = { version: '1.0.0' }; // Default fallback try { // Try relative path from this file let packagePath = path.resolve(__dirname, '../../package.json'); if (!fs.existsSync(packagePath)) { // Try from process.cwd() for test environments packagePath = path.join(process.cwd(), 'package.json'); } if (fs.existsSync(packagePath)) { packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); } } catch (error) { // Use fallback version if package.json cannot be loaded } return { title: 'Easy MCP Server API', version: packageJson.version, description: 'A dynamic API framework with easy MCP (Model Context Protocol) integration for AI models. Includes LLM.txt support for AI model context.', contact: { name: 'API Support', url: 'https://github.com/easynet-world/easy-mcp-server' }, license: { name: 'MIT', url: 'https://opensource.org/licenses/MIT' }, externalDocs: { description: 'LLM.txt - AI Model Context', url: '/LLM.txt' } }; } /** * Generate servers configuration */ generateServers() { const port = process.env.EASY_MCP_SERVER_PORT || 8887; // Always use localhost for OpenAPI spec, even if server binds to 0.0.0.0 // This is the URL clients will use to access the API const host = 'localhost'; return [ { url: `http://${host}:${port}`, description: 'Local development server' } ]; } /** * Generate components (schemas, etc.) */ generateComponents() { return { schemas: { Error: { type: 'object', required: ['success', 'error', 'timestamp'], properties: { success: { type: 'boolean', example: false, description: 'Operation success status' }, error: { type: 'string', example: 'Error message', description: 'Error description' }, timestamp: { type: 'string', format: 'date-time', description: 'Error timestamp' } } }, Success: { type: 'object', required: ['success', 'timestamp'], properties: { success: { type: 'boolean', example: true, description: 'Operation success status' }, data: { type: 'object', description: 'Response data' }, timestamp: { type: 'string', format: 'date-time', description: 'Response timestamp' } } }, APIResponse: { type: 'object', properties: { success: { type: 'boolean', description: 'Operation success status' }, data: { type: 'object', description: 'Response data' }, message: { type: 'string', description: 'Response message' }, timestamp: { type: 'string', format: 'date-time', description: 'Response timestamp' } } // Additional properties can be added dynamically based on route schemas } }, securitySchemes: { // Add security schemes if needed } }; } /** * Generate tags for API grouping */ generateTags(routes) { const tags = [ { name: 'api', description: 'Dynamic API endpoints' } ]; if (!routes || !Array.isArray(routes)) { return tags; } // Extract unique tags from routes const routeTags = new Set(); routes.forEach(route => { if (route.processorInstance?.openApi?.tags) { route.processorInstance.openApi.tags.forEach(tag => routeTags.add(tag)); } }); routeTags.forEach(tag => { tags.push({ name: tag, description: `${tag} related endpoints` }); }); return tags; } /** * Extract path parameters from route path * @param {string} path - The route path (e.g., /users/:userId/posts/:postId) * @returns {Array} Array of OpenAPI parameter objects */ extractPathParameters(path) { const params = []; const paramRegex = /:([a-zA-Z_][a-zA-Z0-9_]*)/g; let match; while ((match = paramRegex.exec(path)) !== null) { const paramName = match[1]; params.push({ name: paramName, in: 'path', required: true, description: `${paramName} parameter`, schema: { type: 'string' } }); } return params; } /** * Auto-generate response schema by analyzing the processor * @param {Object} processor - The API processor instance * @returns {Object} OpenAPI response schema */ generateResponseSchema(processor) { try { // Try to get a sample response by calling the process method with mock data const mockReq = { body: {}, query: {}, params: {}, headers: {} }; const mockRes = { json: (data) => { // Capture the response data mockRes.responseData = data; }, status: (code) => { mockRes.statusCode = code; return mockRes; } }; // Call the process method to get sample response if (typeof processor.process === 'function') { processor.process(mockReq, mockRes); if (mockRes.responseData) { return this.generateSchemaFromData(mockRes.responseData); } } } catch (error) { console.warn(`Warning: Could not auto-generate schema for ${processor.constructor.name}:`, error.message); } // Fallback to default success response return { '200': { description: 'Successful response', content: { 'application/json': { schema: { $ref: '#/components/schemas/Success' } } } } }; } /** * Generate OpenAPI schema from response data * @param {*} data - The response data * @returns {Object} OpenAPI response schema */ generateSchemaFromData(data) { const schema = this.inferSchemaType(data); return { '200': { description: 'Successful response', content: { 'application/json': { schema: schema } } } }; } /** * Infer OpenAPI schema type from data * @param {*} data - The data to analyze * @returns {Object} OpenAPI schema object */ inferSchemaType(data) { if (data === null) { return { type: 'null' }; } if (Array.isArray(data)) { if (data.length === 0) { return { type: 'array', items: {} }; } return { type: 'array', items: this.inferSchemaType(data[0]) }; } if (typeof data === 'object') { const properties = {}; const required = []; for (const [key, value] of Object.entries(data)) { properties[key] = this.inferSchemaType(value); // Consider primitive values as required if (value !== null && value !== undefined && typeof value !== 'object') { required.push(key); } } return { type: 'object', properties, required: required.length > 0 ? required : undefined }; } // Handle primitive types switch (typeof data) { case 'string': return { type: 'string' }; case 'number': return Number.isInteger(data) ? { type: 'integer' } : { type: 'number' }; case 'boolean': return { type: 'boolean' }; default: return { type: 'string' }; } } } module.exports = OpenAPIGenerator;