diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts index c161adf99..c23943301 100644 --- a/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts +++ b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts @@ -5,6 +5,7 @@ */ import {OpenAPIV3} from 'openapi-types'; +import {snakeCase} from '../../../utils/case_utils.js'; import {experimental} from '../../../utils/experimental.js'; import {ApiParameter, OperationParser} from './operation_parser.js'; @@ -251,8 +252,7 @@ function collectOperations( operation.parameters = [...opParams, ...pathParams]; if (!operation.operationId) { - // Generate operation ID if missing - operation.operationId = `${method}_${path.replace(/[^a-zA-Z0-9]/g, '_')}`; + operation.operationId = snakeCase(`${path}_${method}`); } const parser = new OperationParser(operation, { diff --git a/core/src/utils/case_utils.ts b/core/src/utils/case_utils.ts index 6df137858..ffaf11094 100644 --- a/core/src/utils/case_utils.ts +++ b/core/src/utils/case_utils.ts @@ -4,6 +4,27 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Converts a string to snake_case. + * + * Handles lowerCamelCase, UpperCamelCase, space-separated text, acronyms + * (e.g. "REST API") and consecutive uppercase letters. + * + * This matches the output of `_to_snake_case` in adk-python, which names the + * same OpenAPI tools and tool arguments there. + * + * @param text The string to convert. + * @returns The snake_case version of the string. + */ +export function snakeCase(text: string): string { + return text + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toLowerCase() + .replace(/^_+|_+$/g, ''); +} + /** * Recursively converts snake_case keys of an object to camelCase. * diff --git a/core/test/tools/openapi_tool/openapi_spec_parser_test.ts b/core/test/tools/openapi_tool/openapi_spec_parser_test.ts index 2840961d8..bed08c94b 100644 --- a/core/test/tools/openapi_tool/openapi_spec_parser_test.ts +++ b/core/test/tools/openapi_tool/openapi_spec_parser_test.ts @@ -189,7 +189,7 @@ describe('OpenApiSpecParser', () => { }, ], get: { - // operationId is missing, should be auto-generated as "get__users__id_" + // operationId is missing, so it is synthesized as "users_id_get" responses: {}, }, }, @@ -201,11 +201,69 @@ describe('OpenApiSpecParser', () => { expect(parsed.length).toBe(1); const op = parsed[0]; - expect(op.name).toBe('get__users__id_'); + expect(op.name).toBe('users_id_get'); expect(op.parameters.length).toBe(1); expect(op.parameters[0].name).toBe('id'); }); + it('should synthesize snake_case operationIds matching adk-python', () => { + const spec: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Synthesis API', version: '1.0.0'}, + paths: { + '/userProfiles/{userId}': {get: {responses: {}}}, + '/pets/{petId}/photos': {delete: {responses: {}}}, + '/': {get: {responses: {}}}, + }, + }; + + const parsed = new OpenApiSpecParser().parse(spec); + + expect(parsed.map((op) => op.operation.operationId)).toEqual([ + 'user_profiles_user_id_get', + 'pets_pet_id_photos_delete', + 'get', + ]); + expect(parsed.map((op) => op.name)).toEqual([ + 'user_profiles_user_id_get', + 'pets_pet_id_photos_delete', + 'get', + ]); + }); + + it('should synthesize distinct ids for each method on a path', () => { + const spec: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Methods API', version: '1.0.0'}, + paths: { + '/test': { + get: {responses: {}}, + post: {responses: {}}, + }, + }, + }; + + const parsed = new OpenApiSpecParser().parse(spec); + + expect(parsed.map((op) => op.name)).toEqual(['test_get', 'test_post']); + }); + + it('should not rewrite a declared operationId', () => { + const spec: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Declared API', version: '1.0.0'}, + paths: { + '/test': {get: {operationId: 'testOp', responses: {}}}, + }, + }; + + const parsed = new OpenApiSpecParser().parse(spec); + + expect(parsed.length).toBe(1); + expect(parsed[0].operation.operationId).toBe('testOp'); + expect(parsed[0].name).toBe('test_op'); + }); + it('should resolve security schemes', () => { const spec: OpenAPIV3.Document = { openapi: '3.0.0', diff --git a/core/test/tools/openapi_tool/openapi_toolset_test.ts b/core/test/tools/openapi_tool/openapi_toolset_test.ts index da2e32380..a8f04130a 100644 --- a/core/test/tools/openapi_tool/openapi_toolset_test.ts +++ b/core/test/tools/openapi_tool/openapi_toolset_test.ts @@ -250,7 +250,7 @@ describe('OpenApiSpecParser', () => { const operations = parser.parse(specMissingId); expect(operations.length).toBe(1); - expect(operations[0].operation.operationId).toBe('get__test'); + expect(operations[0].operation.operationId).toBe('test_get'); }); it('should extract specific security scheme', () => { diff --git a/core/test/utils/case_utils_test.ts b/core/test/utils/case_utils_test.ts index e291e3769..e00ab15e2 100644 --- a/core/test/utils/case_utils_test.ts +++ b/core/test/utils/case_utils_test.ts @@ -5,9 +5,29 @@ */ import {describe, expect, it} from 'vitest'; -import {camelCaseKeys} from '../../src/utils/case_utils.js'; +import {camelCaseKeys, snakeCase} from '../../src/utils/case_utils.js'; describe('case_utils', () => { + describe('snakeCase', () => { + // Every expectation below is the output of `_to_snake_case` in adk-python + // (src/google/adk/tools/_gemini_schema_util.py) for the same input. + it.each([ + ['camelCase', 'camel_case'], + ['UpperCamelCase', 'upper_camel_case'], + ['space separated', 'space_separated'], + ['REST API', 'rest_api'], + ['HTTPResponseCode', 'http_response_code'], + ['already_snake_case', 'already_snake_case'], + ['Multiple___Underscores', 'multiple_underscores'], + [' _leading_and_trailing_ ', 'leading_and_trailing'], + ['X-API-Key', 'x_api_key'], + ['123Start', '123_start'], + ['', ''], + ])('should convert %j to %j', (input, expected) => { + expect(snakeCase(input)).toBe(expected); + }); + }); + describe('camelCaseKeys', () => { it('should convert simple object keys', () => { const input = {