Enhance operation name generation in OpenAPIClient (#244)

This commit is contained in:
samanhappy
2025-07-23 19:02:43 +08:00
committed by GitHub
parent c316cb896e
commit 89c37b2f02
2 changed files with 246 additions and 11 deletions

View File

@@ -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<string, any>,
);
// 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
});
});
});

View File

@@ -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<string>(); // 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[],