import SwaggerParser from '@apidevtools/swagger-parser'; import { ApiService } from './api-service'; interface OpenApiOperation { operationId?: string; summary?: string; description?: string; parameters?: any[]; requestBody?: any; responses?: Record; tags?: string[]; method: string; } interface OpenApiEndpoint { path: string; operations: OpenApiOperation[]; } interface ParsedSchema { info: { title?: string; version?: string; description?: string; }; endpoints: OpenApiEndpoint[]; definitions?: any; basePath?: string; host?: string; } export class OpenApiService { private static cache: Record = {}; private static CACHE_TTL_MS = 3600000; // 1 hour cache TTL // Default popular OpenAPI schema URLs to try if the provided URL doesn't contain schema private static defaultSchemaEndpoints = [ '/v2/swagger.json', // Swagger Petstore uses this path '/swagger.json', '/api-docs.json', '/openapi.json', '/swagger/v1/swagger.json', '/api/swagger.json', '/v1/api-docs', '/v2/api-docs', '/swagger', '/swagger/docs/v1', '/openapi', '/api/openapi.json', '/api-docs', '/docs/api.json', '/docs/openapi.json', '/api/v1/swagger.json', '/api/v1/openapi.json' ]; /** * Checks if an object is a valid OpenAPI schema */ private static isValidOpenApiSchema(obj: any): boolean { // Must be an object if (!obj || typeof obj !== 'object') return false; // OpenAPI 3.x has an "openapi" property if (obj.openapi && typeof obj.openapi === 'string' && obj.paths && typeof obj.paths === 'object') { return true; } // Swagger 2.x has a "swagger" property if (obj.swagger && typeof obj.swagger === 'string' && obj.paths && typeof obj.paths === 'object') { return true; } // Check for essential parts that should exist in both versions if (obj.info && typeof obj.info === 'object' && obj.paths && typeof obj.paths === 'object') { return true; } return false; } /** * Try to find a valid schema by appending common schema paths to the base URL */ private static async tryDefaultSchemaEndpoints(baseUrl: string, authType?: 'bearer' | 'basic' | 'interactive' | 'none', authConfig?: any): Promise { // Remove trailing slash if present const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; // If URL path looks like a directory (not ending in .json or containing a file extension), // we should try looking for schemas both in this directory and the parent directory const urlEndsWithDirectory = !normalizedBaseUrl.endsWith('.json') && !normalizedBaseUrl.split('/').pop()?.includes('.'); // Create an array of base URLs to try const baseUrls = [normalizedBaseUrl]; // If it looks like a directory path, also try the parent directory if (urlEndsWithDirectory) { const parentUrl = normalizedBaseUrl.substring(0, normalizedBaseUrl.lastIndexOf('/')); if (parentUrl && parentUrl.startsWith('http')) { baseUrls.push(parentUrl); } } const attemptedUrls: string[] = []; const errors: Record = {}; // Try each base URL with each common schema endpoint path for (const baseUrl of baseUrls) { for (const endpoint of this.defaultSchemaEndpoints) { try { const schemaUrl = `${baseUrl}${endpoint}`; console.log(`Auto-discovery: Trying schema URL: ${schemaUrl}`); attemptedUrls.push(schemaUrl); const response = await ApiService.callApi({ endpoint: schemaUrl, method: 'GET', authType, authConfig }); if (response.success) { // First check if response.data is valid object/JSON if (response.data && typeof response.data === 'object') { if (this.isValidOpenApiSchema(response.data)) { console.log(`Auto-discovery: Valid OpenAPI schema found at: ${schemaUrl}`); try { const parsedSchema = await SwaggerParser.dereference(response.data); return await this.parseSchema(parsedSchema); } catch (parseError: any) { console.log(`Auto-discovery: Using bundled parsing due to possible circular references at ${schemaUrl}: ${parseError.message}`); // Fall back to bundled parsing const bundled = await SwaggerParser.bundle(response.data); return await this.parseSchema(bundled); } } else { errors[schemaUrl] = 'Response received but not a valid OpenAPI schema'; } } else { // Handle non-JSON responses errors[schemaUrl] = 'Response received but not in valid JSON format'; } } else { errors[schemaUrl] = `HTTP ${response.status}: ${response.error || 'Unknown error'}`; } } catch (error: any) { // Log error but continue trying other endpoints errors[`${baseUrl}${endpoint}`] = error.message || 'Unknown error'; continue; } } } // If we get here, no valid schema was found at any of the default locations console.error('Auto-discovery failed. Attempted URLs:', attemptedUrls); console.error('Auto-discovery errors:', JSON.stringify(errors, null, 2)); // Build a more helpful error message let errorMsg = `No valid OpenAPI schema found at ${baseUrl} or at any standard schema paths.`; // Display the attempted paths in a more readable format - just show the path parts const pathsAttempted = attemptedUrls.map(url => { // Extract just the path part after the domain const urlObj = new URL(url); return urlObj.pathname; }); errorMsg += `\nTried the following paths: ${pathsAttempted.join(', ')}`; throw new Error(errorMsg); } static async fetchSchema(url: string, authType?: 'bearer' | 'basic' | 'interactive' | 'none', authConfig?: any): Promise { // Check cache first if (this.cache[url] && (Date.now() - this.cache[url].timestamp < this.CACHE_TTL_MS)) { console.log(`Using cached OpenAPI schema for ${url}`); return this.cache[url].schema; } // Try to fetch and validate the schema from the primary URL try { // Fetch the schema from the URL const response = await ApiService.callApi({ endpoint: url, method: 'GET', authType, authConfig }); if (!response.success) { throw new Error(`Failed to fetch OpenAPI schema: ${response.error}`); } const schema = response.data; // Verify this is actually an OpenAPI schema by checking for required properties if (this.isValidOpenApiSchema(schema)) { try { // Parse the schema - use dereference instead of validate to better handle circular references console.log(`Valid OpenAPI schema found at ${url}`); const parsedSchema = await SwaggerParser.dereference(schema); const result = await this.parseSchema(parsedSchema); // Store in cache with URL as key for direct access next time this.cache[url] = { schema: result, timestamp: Date.now() }; return result; } catch (parseError: any) { // Fall back to a more lenient parse if circular references are causing issues console.warn('Warning: Using bundled parsing due to possible circular references:', parseError.message); const bundled = await SwaggerParser.bundle(schema); const result = await this.parseSchema(bundled); // Store in cache this.cache[url] = { schema: result, timestamp: Date.now() }; return result; } } else { // This URL doesn't contain a valid schema, try default endpoints console.log(`URL ${url} doesn't contain a valid OpenAPI schema, trying auto-discovery`); const result = await this.tryDefaultSchemaEndpoints(url, authType, authConfig); // Store the result in cache with the original URL as key this.cache[url] = { schema: result, timestamp: Date.now() }; return result; } } catch (error: any) { // If the direct fetch fails, try the default endpoints before giving up try { console.log(`Failed to fetch from ${url} directly, trying auto-discovery instead`); const result = await this.tryDefaultSchemaEndpoints(url, authType, authConfig); // Store the result in cache with the original URL as key this.cache[url] = { schema: result, timestamp: Date.now() }; return result; } catch (fallbackError: any) { console.error('Error fetching OpenAPI schema:', error); console.error('Auto-discovery fallback error:', fallbackError); throw new Error(`Failed to fetch or parse OpenAPI schema: ${error.message || 'Unknown error'}. Auto-discovery also failed: ${fallbackError.message || 'Unknown error'}`); } } } /** * Gets all endpoints from an OpenAPI schema */ static async getEndpoints(url: string, authType?: 'bearer' | 'basic' | 'interactive' | 'none', authConfig?: any): Promise { const schema = await this.fetchSchema(url, authType, authConfig); return schema.endpoints; } /** * Gets detailed information about operations for a specific endpoint */ static async getOperations(url: string, path: string, authType?: 'bearer' | 'basic' | 'interactive' | 'none', authConfig?: any): Promise { const schema = await this.fetchSchema(url, authType, authConfig); const endpoint = schema.endpoints.find(e => e.path === path); if (!endpoint) { throw new Error(`Endpoint not found: ${path}`); } return endpoint.operations; } /** * Parses an OpenAPI schema into a standardized format */ private static async parseSchema(schema: any): Promise { const parsedSchema: ParsedSchema = { info: { title: schema.info?.title, version: schema.info?.version, description: schema.info?.description }, endpoints: [], definitions: schema.definitions || schema.components?.schemas, basePath: schema.basePath, host: schema.host }; // Parse paths/endpoints const paths = schema.paths || {}; for (const path in paths) { const pathItem = paths[path]; const operations: OpenApiOperation[] = []; // HTTP methods (get, post, put, delete, etc.) const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head']; for (const method of httpMethods) { if (pathItem[method]) { const operation = pathItem[method]; operations.push({ operationId: operation.operationId, summary: operation.summary, description: operation.description, parameters: operation.parameters, requestBody: operation.requestBody, responses: operation.responses, tags: operation.tags, method: method.toUpperCase() }); } } if (operations.length > 0) { parsedSchema.endpoints.push({ path, operations }); } } // Store in cache with title as key const cacheKey = schema.info?.title || 'schema'; this.cache[cacheKey] = { schema: parsedSchema, timestamp: Date.now() }; return parsedSchema; } }