Merge branch 'main' into fix/missing-path-level-parameters

This commit is contained in:
Hangfei Lin
2025-07-17 17:46:27 -07:00
committed by GitHub
412 changed files with 83464 additions and 3455 deletions
@@ -21,7 +21,6 @@ from fastapi.openapi.models import Schema
from google.adk.tools.openapi_tool.common.common import ApiParameter
from google.adk.tools.openapi_tool.common.common import PydocHelper
from google.adk.tools.openapi_tool.common.common import rename_python_keywords
from google.adk.tools.openapi_tool.common.common import to_snake_case
from google.adk.tools.openapi_tool.common.common import TypeHintHelper
import pytest
@@ -30,47 +29,6 @@ def dict_to_responses(input: Dict[str, Any]) -> Dict[str, Response]:
return {k: Response.model_validate(input[k]) for k in input}
class TestToSnakeCase:
@pytest.mark.parametrize(
'input_str, expected_output',
[
('lowerCamelCase', 'lower_camel_case'),
('UpperCamelCase', 'upper_camel_case'),
('space separated', 'space_separated'),
('REST API', 'rest_api'),
('Mixed_CASE with_Spaces', 'mixed_case_with_spaces'),
('__init__', 'init'),
('APIKey', 'api_key'),
('SomeLongURL', 'some_long_url'),
('CONSTANT_CASE', 'constant_case'),
('already_snake_case', 'already_snake_case'),
('single', 'single'),
('', ''),
(' spaced ', 'spaced'),
('with123numbers', 'with123numbers'),
('With_Mixed_123_and_SPACES', 'with_mixed_123_and_spaces'),
('HTMLParser', 'html_parser'),
('HTTPResponseCode', 'http_response_code'),
('a_b_c', 'a_b_c'),
('A_B_C', 'a_b_c'),
('fromAtoB', 'from_ato_b'),
('XMLHTTPRequest', 'xmlhttp_request'),
('_leading', 'leading'),
('trailing_', 'trailing'),
(' leading_and_trailing_ ', 'leading_and_trailing'),
('Multiple___Underscores', 'multiple_underscores'),
(' spaces_and___underscores ', 'spaces_and_underscores'),
(' _mixed_Case ', 'mixed_case'),
('123Start', '123_start'),
('End123', 'end123'),
('Mid123dle', 'mid123dle'),
],
)
def test_to_snake_case(self, input_str, expected_output):
assert to_snake_case(input_str) == expected_output
class TestRenamePythonKeywords:
@pytest.mark.parametrize(
@@ -14,6 +14,7 @@
import json
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
@@ -29,11 +30,9 @@ from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_spec_parser impor
from google.adk.tools.openapi_tool.openapi_spec_parser.operation_parser import OperationParser
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import snake_to_lower_camel
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import to_gemini_schema
from google.adk.tools.tool_context import ToolContext
from google.genai.types import FunctionDeclaration
from google.genai.types import Schema
from google.genai.types import Type
import pytest
@@ -196,7 +195,8 @@ class TestRestApiTool:
@patch(
"google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool.requests.request"
)
def test_call_success(
@pytest.mark.asyncio
async def test_call_success(
self,
mock_request,
mock_tool_context,
@@ -219,7 +219,7 @@ class TestRestApiTool:
)
# Call the method
result = tool.call(args={}, tool_context=mock_tool_context)
result = await tool.call(args={}, tool_context=mock_tool_context)
# Check the result
assert result == {"result": "success"}
@@ -227,7 +227,8 @@ class TestRestApiTool:
@patch(
"google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool.requests.request"
)
def test_call_auth_pending(
@pytest.mark.asyncio
async def test_call_auth_pending(
self,
mock_request,
sample_endpoint,
@@ -248,12 +249,14 @@ class TestRestApiTool:
"google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool.ToolAuthHandler.from_tool_context"
) as mock_from_tool_context:
mock_tool_auth_handler_instance = MagicMock()
mock_tool_auth_handler_instance.prepare_auth_credentials.return_value.state = (
"pending"
mock_prepare_result = MagicMock()
mock_prepare_result.state = "pending"
mock_tool_auth_handler_instance.prepare_auth_credentials = AsyncMock(
return_value=mock_prepare_result
)
mock_from_tool_context.return_value = mock_tool_auth_handler_instance
response = tool.call(args={}, tool_context=None)
response = await tool.call(args={}, tool_context=None)
assert response == {
"pending": True,
"message": "Needs your authorization to access your data.",
@@ -777,237 +780,6 @@ class TestRestApiTool:
assert "empty_param" not in request_params["params"]
class TestToGeminiSchema:
def test_to_gemini_schema_none(self):
assert to_gemini_schema(None) is None
def test_to_gemini_schema_not_dict(self):
with pytest.raises(TypeError, match="openapi_schema must be a dictionary"):
to_gemini_schema("not a dict")
def test_to_gemini_schema_empty_dict(self):
result = to_gemini_schema({})
assert isinstance(result, Schema)
assert result.type == Type.OBJECT
assert result.properties is None
def test_to_gemini_schema_dict_with_only_object_type(self):
result = to_gemini_schema({"type": "object"})
assert isinstance(result, Schema)
assert result.type == Type.OBJECT
assert result.properties is None
def test_to_gemini_schema_basic_types(self):
openapi_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"is_active": {"type": "boolean"},
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert isinstance(gemini_schema, Schema)
assert gemini_schema.type == Type.OBJECT
assert gemini_schema.properties["name"].type == Type.STRING
assert gemini_schema.properties["age"].type == Type.INTEGER
assert gemini_schema.properties["is_active"].type == Type.BOOLEAN
def test_to_gemini_schema_array_string_types(self):
openapi_schema = {
"type": "object",
"properties": {
"boolean_field": {"type": "boolean"},
"nonnullable_string": {"type": ["string"]},
"nullable_string": {"type": ["string", "null"]},
"nullable_number": {"type": ["null", "integer"]},
"object_nullable": {"type": "null"},
"multi_types_nullable": {"type": ["string", "null", "integer"]},
"empty_default_object": {},
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert isinstance(gemini_schema, Schema)
assert gemini_schema.type == Type.OBJECT
assert gemini_schema.properties["boolean_field"].type == Type.BOOLEAN
assert gemini_schema.properties["nonnullable_string"].type == Type.STRING
assert not gemini_schema.properties["nonnullable_string"].nullable
assert gemini_schema.properties["nullable_string"].type == Type.STRING
assert gemini_schema.properties["nullable_string"].nullable
assert gemini_schema.properties["nullable_number"].type == Type.INTEGER
assert gemini_schema.properties["nullable_number"].nullable
assert gemini_schema.properties["object_nullable"].type == Type.OBJECT
assert gemini_schema.properties["object_nullable"].nullable
assert gemini_schema.properties["multi_types_nullable"].type == Type.STRING
assert gemini_schema.properties["multi_types_nullable"].nullable
assert gemini_schema.properties["empty_default_object"].type == Type.OBJECT
assert not gemini_schema.properties["empty_default_object"].nullable
def test_to_gemini_schema_nested_objects(self):
openapi_schema = {
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"street": {"type": "string"},
"city": {"type": "string"},
},
}
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.properties["address"].type == Type.OBJECT
assert (
gemini_schema.properties["address"].properties["street"].type
== Type.STRING
)
assert (
gemini_schema.properties["address"].properties["city"].type
== Type.STRING
)
def test_to_gemini_schema_array(self):
openapi_schema = {
"type": "array",
"items": {"type": "string"},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.type == Type.ARRAY
assert gemini_schema.items.type == Type.STRING
def test_to_gemini_schema_nested_array(self):
openapi_schema = {
"type": "array",
"items": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.items.properties["name"].type == Type.STRING
def test_to_gemini_schema_any_of(self):
openapi_schema = {
"anyOf": [{"type": "string"}, {"type": "integer"}],
}
gemini_schema = to_gemini_schema(openapi_schema)
assert len(gemini_schema.any_of) == 2
assert gemini_schema.any_of[0].type == Type.STRING
assert gemini_schema.any_of[1].type == Type.INTEGER
def test_to_gemini_schema_general_list(self):
openapi_schema = {
"type": "array",
"properties": {
"list_field": {"type": "array", "items": {"type": "string"}},
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.properties["list_field"].type == Type.ARRAY
assert gemini_schema.properties["list_field"].items.type == Type.STRING
def test_to_gemini_schema_enum(self):
openapi_schema = {"type": "string", "enum": ["a", "b", "c"]}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.enum == ["a", "b", "c"]
def test_to_gemini_schema_required(self):
openapi_schema = {
"type": "object",
"required": ["name"],
"properties": {"name": {"type": "string"}},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.required == ["name"]
def test_to_gemini_schema_nested_dict(self):
openapi_schema = {
"type": "object",
"properties": {
"metadata": {
"type": "object",
"properties": {
"key1": {"type": "object"},
"key2": {"type": "string"},
},
}
},
}
gemini_schema = to_gemini_schema(openapi_schema)
# Since metadata is not properties nor item, it will call to_gemini_schema recursively.
assert isinstance(gemini_schema.properties["metadata"], Schema)
assert (
gemini_schema.properties["metadata"].type == Type.OBJECT
) # add object type by default
assert len(gemini_schema.properties["metadata"].properties) == 2
assert (
gemini_schema.properties["metadata"].properties["key1"].type
== Type.OBJECT
)
assert (
gemini_schema.properties["metadata"].properties["key2"].type
== Type.STRING
)
def test_to_gemini_schema_ignore_title_default_format(self):
openapi_schema = {
"type": "string",
"title": "Test Title",
"default": "default_value",
"format": "date",
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.title is None
assert gemini_schema.default is None
assert gemini_schema.format is None
def test_to_gemini_schema_property_ordering(self):
openapi_schema = {
"type": "object",
"propertyOrdering": ["name", "age"],
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.property_ordering == ["name", "age"]
def test_to_gemini_schema_converts_property_dict(self):
openapi_schema = {
"properties": {
"name": {"type": "string", "description": "The property key"},
"value": {"type": "string", "description": "The property value"},
},
"type": "object",
"description": "A single property entry in the Properties message.",
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.type == Type.OBJECT
assert gemini_schema.properties["name"].type == Type.STRING
assert gemini_schema.properties["value"].type == Type.STRING
def test_to_gemini_schema_remove_unrecognized_fields(self):
openapi_schema = {
"type": "string",
"description": "A single date string.",
"format": "date",
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.type == Type.STRING
assert not gemini_schema.format
def test_snake_to_lower_camel():
assert snake_to_lower_camel("single") == "single"
assert snake_to_lower_camel("two_words") == "twoWords"
@@ -14,6 +14,7 @@
from typing import Optional
from unittest.mock import MagicMock
from unittest.mock import patch
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.llm_agent import LlmAgent
@@ -115,7 +116,8 @@ def openid_connect_credential():
return credential
def test_openid_connect_no_auth_response(
@pytest.mark.asyncio
async def test_openid_connect_no_auth_response(
openid_connect_scheme, openid_connect_credential
):
# Setup Mock exchanger
@@ -131,12 +133,13 @@ def test_openid_connect_no_auth_response(
credential_exchanger=mock_exchanger,
credential_store=credential_store,
)
result = handler.prepare_auth_credentials()
result = await handler.prepare_auth_credentials()
assert result.state == 'pending'
assert result.auth_credential == openid_connect_credential
def test_openid_connect_with_auth_response(
@pytest.mark.asyncio
async def test_openid_connect_with_auth_response(
openid_connect_scheme, openid_connect_credential, monkeypatch
):
mock_exchanger = MockOpenIdConnectCredentialExchanger(
@@ -147,10 +150,11 @@ def test_openid_connect_with_auth_response(
tool_context = create_mock_tool_context()
mock_auth_handler = MagicMock()
mock_auth_handler.get_auth_response.return_value = AuthCredential(
returned_credentail = AuthCredential(
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
oauth2=OAuth2Auth(auth_response_uri='test_auth_response_uri'),
)
mock_auth_handler.get_auth_response.return_value = returned_credentail
mock_auth_handler_path = 'google.adk.tools.tool_context.AuthHandler'
monkeypatch.setattr(
mock_auth_handler_path, lambda *args, **kwargs: mock_auth_handler
@@ -164,7 +168,7 @@ def test_openid_connect_with_auth_response(
credential_exchanger=mock_exchanger,
credential_store=credential_store,
)
result = handler.prepare_auth_credentials()
result = await handler.prepare_auth_credentials()
assert result.state == 'done'
assert result.auth_credential.auth_type == AuthCredentialTypes.HTTP
assert 'test_access_token' in result.auth_credential.http.credentials.token
@@ -172,11 +176,12 @@ def test_openid_connect_with_auth_response(
stored_credential = credential_store.get_credential(
openid_connect_scheme, openid_connect_credential
)
assert stored_credential == result.auth_credential
assert stored_credential == returned_credentail
mock_auth_handler.get_auth_response.assert_called_once()
def test_openid_connect_existing_token(
@pytest.mark.asyncio
async def test_openid_connect_existing_token(
openid_connect_scheme, openid_connect_credential
):
_, existing_credential = token_to_scheme_credential(
@@ -196,6 +201,77 @@ def test_openid_connect_existing_token(
openid_connect_credential,
credential_store=credential_store,
)
result = handler.prepare_auth_credentials()
result = await handler.prepare_auth_credentials()
assert result.state == 'done'
assert result.auth_credential == existing_credential
@patch(
'google.adk.tools.openapi_tool.openapi_spec_parser.tool_auth_handler.OAuth2CredentialRefresher'
)
@pytest.mark.asyncio
async def test_openid_connect_existing_oauth2_token_refresh(
mock_oauth2_refresher, openid_connect_scheme, openid_connect_credential
):
"""Test that OAuth2 tokens are refreshed when existing credentials are found."""
# Create existing OAuth2 credential
existing_credential = AuthCredential(
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
oauth2=OAuth2Auth(
client_id='test_client_id',
client_secret='test_client_secret',
access_token='existing_token',
refresh_token='refresh_token',
),
)
# Mock the refreshed credential
refreshed_credential = AuthCredential(
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
oauth2=OAuth2Auth(
client_id='test_client_id',
client_secret='test_client_secret',
access_token='refreshed_token',
refresh_token='new_refresh_token',
),
)
# Setup mock OAuth2CredentialRefresher
from unittest.mock import AsyncMock
mock_refresher_instance = MagicMock()
mock_refresher_instance.is_refresh_needed = AsyncMock(return_value=True)
mock_refresher_instance.refresh = AsyncMock(return_value=refreshed_credential)
mock_oauth2_refresher.return_value = mock_refresher_instance
tool_context = create_mock_tool_context()
credential_store = ToolContextCredentialStore(tool_context=tool_context)
# Store the existing credential
key = credential_store.get_credential_key(
openid_connect_scheme, openid_connect_credential
)
credential_store.store_credential(key, existing_credential)
handler = ToolAuthHandler(
tool_context,
openid_connect_scheme,
openid_connect_credential,
credential_store=credential_store,
)
result = await handler.prepare_auth_credentials()
# Verify OAuth2CredentialRefresher was called for refresh
mock_oauth2_refresher.assert_called_once()
mock_refresher_instance.is_refresh_needed.assert_called_once_with(
existing_credential
)
mock_refresher_instance.refresh.assert_called_once_with(
existing_credential, openid_connect_scheme
)
assert result.state == 'done'
# The result should contain the refreshed credential after exchange
assert result.auth_credential is not None