Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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, {
Expand Down
21 changes: 21 additions & 0 deletions core/src/utils/case_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
62 changes: 60 additions & 2 deletions core/test/tools/openapi_tool/openapi_spec_parser_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
},
},
Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion core/test/tools/openapi_tool/openapi_toolset_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
22 changes: 21 additions & 1 deletion core/test/utils/case_utils_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading