import SwaggerParser from '@apidevtools/swagger-parser';
import { readFile } from 'node:fs/promises';
import yaml from 'yaml';
import type { OpenAPIDocument, PathItemObject, OperationObject, ParameterObject } from '../types/openapi.js';
import type { NormalizedOperation, NormalizedParameter, NormalizedRequestBody, SecurityRequirement, HttpMethod } from '../types/index.js';
import type { JSONSchema } from '../types/mcp-tool.js';
import { log } from '../utils/logger.js';
/**
* HTTP methods to extract from path items
*/
const HTTP_METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
/**
* Parse OpenAPI spec from URL
*/
export async function parseFromUrl(url: string): Promise<OpenAPIDocument> {
log.info('Fetching OpenAPI spec from URL', { url });
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`);
}
const content = await response.text();
return parseContent(content);
}
/**
* Parse OpenAPI spec from file
*/
export async function parseFromFile(filePath: string): Promise<OpenAPIDocument> {
log.info('Loading OpenAPI spec from file', { file: filePath });
const content = await readFile(filePath, 'utf-8');
return parseContent(content);
}
/**
* Parse OpenAPI spec from inline content
*/
export async function parseContent(content: string): Promise<OpenAPIDocument> {
// Try to parse as YAML first (also handles JSON)
let parsed: unknown;
try {
parsed = yaml.parse(content);
} catch {
throw new Error('Failed to parse spec content as YAML or JSON');
}
// Validate and dereference the spec
try {
const validated = await SwaggerParser.validate(parsed as OpenAPIDocument);
const dereferenced = await SwaggerParser.dereference(validated);
return dereferenced as OpenAPIDocument;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Invalid OpenAPI specification: ${message}`);
}
}
/**
* Generate operationId from method and path if not provided
*/
function generateOperationId(method: string, path: string): string {
// /pet/{petId}/uploadImage -> pet_petId_uploadImage
// GET /pets -> get_pets
const cleanPath = path
.replace(/\{([^}]+)\}/g, '$1') // Remove braces from params
.replace(/[^a-zA-Z0-9]/g, '_') // Replace non-alphanumeric
.replace(/_+/g, '_') // Collapse multiple underscores
.replace(/^_|_$/g, ''); // Trim leading/trailing
return `${method.toLowerCase()}_${cleanPath}`;
}
/**
* Convert OpenAPI parameter to normalized format
*/
function normalizeParameter(param: ParameterObject): NormalizedParameter {
// Handle the schema - it might be a reference or inline
const schema = 'schema' in param ? (param.schema as JSONSchema) : { type: 'string' as const };
return {
name: param.name,
in: param.in as 'path' | 'query' | 'header' | 'cookie',
required: param.required ?? (param.in === 'path'), // Path params are always required
description: param.description,
schema,
example: 'example' in param ? param.example : undefined,
};
}
/**
* Extract request body from operation
*/
function extractRequestBody(operation: OperationObject): NormalizedRequestBody | undefined {
if (!operation.requestBody) {
return undefined;
}
const body = operation.requestBody as {
required?: boolean;
description?: string;
content?: Record<string, { schema?: JSONSchema }>;
};
// Prefer application/json, fall back to first available
const contentType = body.content?.['application/json']
? 'application/json'
: Object.keys(body.content || {})[0];
if (!contentType || !body.content?.[contentType]?.schema) {
return undefined;
}
return {
required: body.required ?? false,
description: body.description,
contentType,
schema: body.content[contentType].schema,
};
}
/**
* Extract security requirements
*/
function extractSecurity(
operationSecurity: OperationObject['security'],
globalSecurity: OpenAPIDocument['security']
): SecurityRequirement[] {
// Use operation-level security if defined, otherwise global
const securityReqs = operationSecurity ?? globalSecurity ?? [];
return securityReqs.map((req) => {
const [name, scopes] = Object.entries(req)[0] ?? ['', []];
return { name, scopes };
});
}
/**
* Extract all operations from OpenAPI document
*/
export function extractOperations(doc: OpenAPIDocument): NormalizedOperation[] {
const operations: NormalizedOperation[] = [];
if (!doc.paths) {
log.warn('OpenAPI document has no paths defined');
return operations;
}
for (const [path, pathItem] of Object.entries(doc.paths)) {
if (!pathItem) continue;
const pathItemObj = pathItem as PathItemObject;
// Get path-level parameters
const pathParameters = (pathItemObj.parameters ?? []) as ParameterObject[];
for (const method of HTTP_METHODS) {
const methodKey = method.toLowerCase() as keyof PathItemObject;
const operation = pathItemObj[methodKey] as OperationObject | undefined;
if (!operation) continue;
// Merge path and operation parameters
const operationParameters = (operation.parameters ?? []) as ParameterObject[];
const allParameters = [...pathParameters, ...operationParameters];
// Remove duplicates (operation params override path params)
const paramMap = new Map<string, ParameterObject>();
for (const param of allParameters) {
const key = `${param.in}:${param.name}`;
paramMap.set(key, param);
}
const normalizedOp: NormalizedOperation = {
operationId: operation.operationId ?? generateOperationId(method, path),
method,
path,
summary: operation.summary,
description: operation.description,
parameters: Array.from(paramMap.values()).map(normalizeParameter),
requestBody: extractRequestBody(operation),
tags: operation.tags ?? [],
deprecated: operation.deprecated ?? false,
security: extractSecurity(operation.security, doc.security),
};
operations.push(normalizedOp);
}
}
log.info('Extracted operations from OpenAPI spec', { count: operations.length });
return operations;
}
/**
* Parse and extract operations from OpenAPI spec
*/
export async function parseOpenAPI(
source: { url?: string; file?: string; inline?: string }
): Promise<NormalizedOperation[]> {
let doc: OpenAPIDocument;
if (source.url) {
doc = await parseFromUrl(source.url);
} else if (source.file) {
doc = await parseFromFile(source.file);
} else if (source.inline) {
doc = await parseContent(source.inline);
} else {
throw new Error('No spec source provided');
}
return extractOperations(doc);
}