Files
agent-fox/packages/server/src/services/openapi-parser.ts

182 lines
6.2 KiB
TypeScript

import SwaggerParser from '@apidevtools/swagger-parser';
import type { OpenAPI, OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
type OpenApiDoc = OpenAPIV3.Document | OpenAPIV3_1.Document;
type SwaggerDoc = OpenAPIV2.Document;
export type ParsedModule = {
name: string;
description: string | null;
source: 'tag' | 'path_prefix';
};
export type ParsedEndpoint = {
method: string;
path: string;
summary: string | null;
description: string | null;
operationId: string | null;
parameters: unknown[];
requestBody: unknown | null;
responses: Record<string, unknown>;
tags: string[];
deprecated: boolean;
moduleName: string;
};
export type ParseResult = {
name: string;
description: string | null;
version: string;
openApiVersion: string;
baseUrl: string | null;
spec: unknown;
modules: ParsedModule[];
endpoints: ParsedEndpoint[];
};
function isSwagger2(api: OpenAPI.Document): api is SwaggerDoc {
return 'swagger' in api && (api as any).swagger?.startsWith('2.');
}
function parseSwagger2Endpoints(api: SwaggerDoc): { endpoints: ParsedEndpoint[]; baseUrl: string | null } {
const baseUrl = api.basePath || (api.host ? `http://${api.host}${api.basePath || ''}` : null);
const endpoints: ParsedEndpoint[] = [];
const paths = api.paths || {};
for (const [pathStr, pathItem] of Object.entries(paths)) {
if (!pathItem) continue;
const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const;
for (const method of methods) {
const operation = (pathItem as Record<string, unknown>)[method] as OpenAPIV2.OperationObject | undefined;
if (!operation) continue;
const endpointTags = operation.tags || [];
const prefix = pathStr.split('/').filter(Boolean)[0] || 'default';
const moduleName = endpointTags[0] || prefix;
// Convert Swagger 2 body parameter to requestBody-like structure
const params = (operation.parameters || []) as OpenAPIV2.Parameter[];
const bodyParam = params.find((p: any) => p.in === 'body');
const nonBodyParams = params.filter((p: any) => p.in !== 'body');
endpoints.push({
method: method.toUpperCase(),
path: pathStr,
summary: operation.summary || null,
description: operation.description || null,
operationId: operation.operationId || null,
parameters: nonBodyParams as unknown[],
requestBody: bodyParam ? { schema: (bodyParam as any).schema } : null,
responses: (operation.responses || {}) as Record<string, unknown>,
tags: endpointTags,
deprecated: operation.deprecated || false,
moduleName,
});
}
}
return { endpoints, baseUrl };
}
function parseOpenApi3Endpoints(api: OpenApiDoc): { endpoints: ParsedEndpoint[]; baseUrl: string | null } {
const baseUrl = api.servers?.[0]?.url || null;
const endpoints: ParsedEndpoint[] = [];
const paths = api.paths || {};
for (const [pathStr, pathItem] of Object.entries(paths)) {
if (!pathItem) continue;
const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const;
for (const method of methods) {
const operation = (pathItem as Record<string, unknown>)[method] as OpenAPIV3.OperationObject | undefined;
if (!operation) continue;
const endpointTags = operation.tags || [];
const prefix = pathStr.split('/').filter(Boolean)[0] || 'default';
const moduleName = endpointTags[0] || prefix;
endpoints.push({
method: method.toUpperCase(),
path: pathStr,
summary: operation.summary || null,
description: operation.description || null,
operationId: operation.operationId || null,
parameters: (operation.parameters || []) as unknown[],
requestBody: operation.requestBody || null,
responses: (operation.responses || {}) as Record<string, unknown>,
tags: endpointTags,
deprecated: operation.deprecated || false,
moduleName,
});
}
}
return { endpoints, baseUrl };
}
export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
let specInput: string | object = input;
// If input is a URL, fetch the content first so that swagger-parser
// works on a plain object and doesn't need network access for $ref resolution
if (typeof input === 'string' && input.startsWith('http')) {
const res = await fetch(input);
if (!res.ok) throw new Error(`Failed to fetch spec from URL: ${res.status} ${res.statusText}`);
specInput = await res.json();
}
// Bundle resolves all $refs into a single document, then dereference inlines them
const bundled = await SwaggerParser.bundle(specInput as any) as OpenAPI.Document;
const api = await SwaggerParser.dereference(bundled, {
dereference: { circular: 'ignore' },
}) as OpenAPI.Document;
const isV2 = isSwagger2(api);
const openApiVersion = isV2 ? (api as SwaggerDoc).swagger : ('openapi' in api ? (api as any).openapi : 'unknown');
const name = api.info.title;
const description = api.info.description || null;
const version = api.info.version;
// Collect tags
const tagMap = new Map<string, string | null>();
if (api.tags) {
for (const tag of api.tags) {
tagMap.set(tag.name, tag.description || null);
}
}
// Parse endpoints based on spec version
const { endpoints, baseUrl } = isV2
? parseSwagger2Endpoints(api as SwaggerDoc)
: parseOpenApi3Endpoints(api as OpenApiDoc);
// Track used tags
const usedTags = new Set<string>();
for (const ep of endpoints) {
for (const tag of ep.tags) {
usedTags.add(tag);
if (!tagMap.has(tag)) tagMap.set(tag, null);
}
}
// Build modules
const modules: ParsedModule[] = [];
const moduleNames = new Set<string>();
for (const [tagName, tagDesc] of tagMap) {
if (usedTags.has(tagName)) {
modules.push({ name: tagName, description: tagDesc, source: 'tag' });
moduleNames.add(tagName);
}
}
for (const endpoint of endpoints) {
if (endpoint.tags.length === 0 && !moduleNames.has(endpoint.moduleName)) {
modules.push({ name: endpoint.moduleName, description: null, source: 'path_prefix' });
moduleNames.add(endpoint.moduleName);
}
}
return { name, description, version, openApiVersion, baseUrl, spec: api, modules, endpoints };
}