From 89c37b2f0210f976ed8a1fe20e23dfb674bb9f8b Mon Sep 17 00:00:00 2001 From: samanhappy Date: Wed, 23 Jul 2025 19:02:43 +0800 Subject: [PATCH] Enhance operation name generation in OpenAPIClient (#244) --- .../__tests__/openapi-operation-name.test.ts | 200 ++++++++++++++++++ src/clients/openapi.ts | 57 ++++- 2 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 src/clients/__tests__/openapi-operation-name.test.ts diff --git a/src/clients/__tests__/openapi-operation-name.test.ts b/src/clients/__tests__/openapi-operation-name.test.ts new file mode 100644 index 0000000..0c4feda --- /dev/null +++ b/src/clients/__tests__/openapi-operation-name.test.ts @@ -0,0 +1,200 @@ +import { OpenAPIClient } from '../openapi.js'; +import { ServerConfig } from '../../types/index.js'; +import { OpenAPIV3 } from 'openapi-types'; + +describe('OpenAPIClient - Operation Name Generation', () => { + describe('generateOperationName', () => { + test('should generate operation name from method and path', async () => { + const config: ServerConfig = { + type: 'openapi', + openapi: { + schema: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/users': { + get: { + summary: 'Get users', + responses: { '200': { description: 'Success' } }, + }, + post: { + summary: 'Create user', + responses: { '201': { description: 'Created' } }, + }, + }, + '/users/{id}': { + get: { + summary: 'Get user by ID', + responses: { '200': { description: 'Success' } }, + }, + delete: { + summary: 'Delete user', + responses: { '204': { description: 'Deleted' } }, + }, + }, + '/admin/settings': { + get: { + summary: 'Get admin settings', + responses: { '200': { description: 'Success' } }, + }, + }, + '/': { + get: { + summary: 'Root endpoint', + responses: { '200': { description: 'Success' } }, + }, + }, + }, + } as OpenAPIV3.Document, + }, + }; + + const testClient = new OpenAPIClient(config); + await testClient.initialize(); + const tools = testClient.getTools(); + + // Verify generated operation names + expect(tools).toHaveLength(6); + + const toolNames = tools.map((t) => t.name).sort(); + expect(toolNames).toEqual( + [ + 'delete_users', + 'get_admin_settings', + 'get_root', + 'get_users', + 'post_users', + 'get_users1', // Second GET /users/{id}, will add numeric suffix + ].sort(), + ); + }); + + test('should use operationId when available and generate name when missing', async () => { + const config: ServerConfig = { + type: 'openapi', + openapi: { + schema: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/users': { + get: { + operationId: 'listUsers', + summary: 'Get users', + responses: { '200': { description: 'Success' } }, + }, + post: { + // No operationId, should generate post_users + summary: 'Create user', + responses: { '201': { description: 'Created' } }, + }, + }, + '/users/{id}': { + get: { + operationId: 'getUserById', + summary: 'Get user by ID', + responses: { '200': { description: 'Success' } }, + }, + }, + }, + } as OpenAPIV3.Document, + }, + }; + + const testClient = new OpenAPIClient(config); + await testClient.initialize(); + const tools = testClient.getTools(); + + expect(tools).toHaveLength(3); + + const toolsByName = tools.reduce( + (acc, tool) => { + acc[tool.name] = tool; + return acc; + }, + {} as Record, + ); + + // Those with operationId should use the original operationId + expect(toolsByName['listUsers']).toBeDefined(); + expect(toolsByName['listUsers'].operationId).toBe('listUsers'); + expect(toolsByName['getUserById']).toBeDefined(); + expect(toolsByName['getUserById'].operationId).toBe('getUserById'); + + // Those without operationId should generate names + expect(toolsByName['post_users']).toBeDefined(); + expect(toolsByName['post_users'].operationId).toBe('post_users'); + }); + + test('should handle duplicate generated names with counter', async () => { + const config: ServerConfig = { + type: 'openapi', + openapi: { + schema: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/users': { + get: { + summary: 'Get users', + responses: { '200': { description: 'Success' } }, + }, + }, + '/users/': { + get: { + summary: 'Get users with trailing slash', + responses: { '200': { description: 'Success' } }, + }, + }, + }, + } as OpenAPIV3.Document, + }, + }; + + const testClient = new OpenAPIClient(config); + await testClient.initialize(); + const tools = testClient.getTools(); + + expect(tools).toHaveLength(2); + + const toolNames = tools.map((t) => t.name).sort(); + expect(toolNames).toEqual(['get_users', 'get_users1']); + }); + + test('should handle complex paths with parameters and special characters', async () => { + const config: ServerConfig = { + type: 'openapi', + openapi: { + schema: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/api/v1/users/{user-id}/posts/{post_id}': { + get: { + summary: 'Get user post', + responses: { '200': { description: 'Success' } }, + }, + }, + '/api-v2/user-profiles': { + post: { + summary: 'Create user profile', + responses: { '201': { description: 'Created' } }, + }, + }, + }, + } as OpenAPIV3.Document, + }, + }; + + const testClient = new OpenAPIClient(config); + await testClient.initialize(); + const tools = testClient.getTools(); + + expect(tools).toHaveLength(2); + + const toolNames = tools.map((t) => t.name); + expect(toolNames).toContain('get_api_v1_users_posts'); // Path parameters removed, special characters cleaned + expect(toolNames).toContain('post_apiv2_userprofiles'); // Hyphens and underscores cleaned, lowercase with underscores + }); + }); +}); diff --git a/src/clients/openapi.ts b/src/clients/openapi.ts index e8cd901..f997134 100644 --- a/src/clients/openapi.ts +++ b/src/clients/openapi.ts @@ -27,7 +27,7 @@ export class OpenAPIClient { throw new Error('OpenAPI URL or schema is required'); } - // 初始 baseUrl,将在 initialize() 中从 OpenAPI servers 字段更新 + // Initial baseUrl, will be updated from OpenAPI servers field in initialize() this.baseUrl = config.openapi?.url ? this.extractBaseUrl(config.openapi.url) : ''; this.securityConfig = config.openapi.security; @@ -117,7 +117,7 @@ export class OpenAPIClient { throw new Error('Either OpenAPI URL or schema must be provided'); } - // 从 OpenAPI servers 字段更新 baseUrl + // Update baseUrl from OpenAPI servers field this.updateBaseUrlFromServers(); this.extractTools(); @@ -127,33 +127,48 @@ export class OpenAPIClient { } } + private generateOperationName(method: string, path: string): string { + // Clean path, remove parameter brackets and special characters + const cleanPath = path + .replace(/\{[^}]+\}/g, '') // Remove {param} format parameters + .replace(/[^\w/]/g, '') // Remove special characters, keep alphanumeric and slashes + .split('/') + .filter((segment) => segment.length > 0) // Remove empty segments + .map((segment) => segment.toLowerCase()) // Convert to lowercase + .join('_'); // Join with underscores + + // Convert method to lowercase and combine with path + const methodName = method.toLowerCase(); + return `${methodName}_${cleanPath || 'root'}`; + } + private updateBaseUrlFromServers(): void { if (!this.spec?.servers || this.spec.servers.length === 0) { return; } - // 获取第一个 server 的 URL + // Get the first server's URL const serverUrl = this.spec.servers[0].url; - // 如果是相对路径,需要与原始 spec URL 结合 + // If it's a relative path, combine with original spec URL if (serverUrl.startsWith('/')) { - // 相对路径,使用原始 spec URL 的协议和主机 + // Relative path, use protocol and host from original spec URL if (this.config.openapi?.url) { const originalUrl = new URL(this.config.openapi.url); this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}${serverUrl}`; } } else if (serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) { - // 绝对路径 + // Absolute path this.baseUrl = serverUrl; } else { - // 相对路径但不以 / 开头,可能是相对于当前路径 + // Relative path but doesn't start with /, might be relative to current path if (this.config.openapi?.url) { const originalUrl = new URL(this.config.openapi.url); this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}/${serverUrl}`; } } - // 更新 HTTP 客户端的 baseURL + // Update HTTP client's baseURL this.httpClient.defaults.baseURL = this.baseUrl; } @@ -163,6 +178,7 @@ export class OpenAPIClient { } this.tools = []; + const generatedNames = new Set(); // Used to ensure generated names are unique for (const [path, pathItem] of Object.entries(this.spec.paths)) { if (!pathItem) continue; @@ -180,14 +196,33 @@ export class OpenAPIClient { for (const method of methods) { const operation = pathItem[method] as OpenAPIV3.OperationObject | undefined; - if (!operation || !operation.operationId) continue; + if (!operation) continue; + + // Generate operation name: use operationId first, otherwise generate unique name + let operationName: string; + if (operation.operationId) { + operationName = operation.operationId; + } else { + operationName = this.generateOperationName(method, path); + + // Ensure name uniqueness, add numeric suffix if duplicate + let uniqueName = operationName; + let counter = 1; + while (generatedNames.has(uniqueName) || this.tools.some((t) => t.name === uniqueName)) { + uniqueName = `${operationName}${counter}`; + counter++; + } + operationName = uniqueName; + } + + generatedNames.add(operationName); const tool: OpenAPIToolInfo = { - name: operation.operationId, + name: operationName, description: operation.summary || operation.description || `${method.toUpperCase()} ${path}`, inputSchema: this.generateInputSchema(operation, path, method as string), - operationId: operation.operationId, + operationId: operation.operationId || operationName, method: method as string, path, parameters: operation.parameters as OpenAPIV3.ParameterObject[],