chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,868 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Any
from typing import Dict
from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_spec_parser import OpenApiSpecParser
import pytest
def create_minimal_openapi_spec() -> Dict[str, Any]:
"""Creates a minimal valid OpenAPI spec."""
return {
"openapi": "3.1.0",
"info": {"title": "Minimal API", "version": "1.0.0"},
"paths": {
"/test": {
"get": {
"summary": "Test GET endpoint",
"operationId": "testGet",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {"schema": {"type": "string"}}
},
}
},
}
}
},
}
@pytest.fixture
def openapi_spec_generator():
"""Fixture for creating an OperationGenerator instance."""
return OpenApiSpecParser()
def test_parse_minimal_spec(openapi_spec_generator):
"""Test parsing a minimal OpenAPI specification."""
openapi_spec = create_minimal_openapi_spec()
parsed_operations = openapi_spec_generator.parse(openapi_spec)
op = parsed_operations[0]
assert len(parsed_operations) == 1
assert op.name == "test_get"
assert op.endpoint.path == "/test"
assert op.endpoint.method == "get"
assert op.return_value.type_value == str
def test_parse_spec_with_no_operation_id(openapi_spec_generator):
"""Test parsing a spec where operationId is missing (auto-generation)."""
openapi_spec = create_minimal_openapi_spec()
del openapi_spec["paths"]["/test"]["get"]["operationId"] # Remove operationId
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
# Check if operationId is auto generated based on path and method.
assert parsed_operations[0].name == "test_get"
def test_parse_spec_with_multiple_methods(openapi_spec_generator):
"""Test parsing a spec with multiple HTTP methods for the same path."""
openapi_spec = create_minimal_openapi_spec()
openapi_spec["paths"]["/test"]["post"] = {
"summary": "Test POST endpoint",
"operationId": "testPost",
"responses": {"200": {"description": "Successful response"}},
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
operation_names = {op.name for op in parsed_operations}
assert len(parsed_operations) == 2
assert "test_get" in operation_names
assert "test_post" in operation_names
def test_parse_spec_with_parameters(openapi_spec_generator):
openapi_spec = create_minimal_openapi_spec()
openapi_spec["paths"]["/test"]["get"]["parameters"] = [
{"name": "param1", "in": "query", "schema": {"type": "string"}},
{"name": "param2", "in": "header", "schema": {"type": "integer"}},
]
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations[0].parameters) == 2
assert parsed_operations[0].parameters[0].original_name == "param1"
assert parsed_operations[0].parameters[0].param_location == "query"
assert parsed_operations[0].parameters[1].original_name == "param2"
assert parsed_operations[0].parameters[1].param_location == "header"
def test_parse_spec_with_request_body(openapi_spec_generator):
openapi_spec = create_minimal_openapi_spec()
openapi_spec["paths"]["/test"]["post"] = {
"summary": "Endpoint with request body",
"operationId": "testPostWithBody",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
}
},
"responses": {"200": {"description": "OK"}},
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
post_operations = [
op for op in parsed_operations if op.endpoint.method == "post"
]
op = post_operations[0]
assert len(post_operations) == 1
assert op.name == "test_post_with_body"
assert len(op.parameters) == 1
assert op.parameters[0].original_name == "name"
assert op.parameters[0].type_value == str
def test_parse_spec_with_reference(openapi_spec_generator):
"""Test parsing a specification with $ref."""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "API with Refs", "version": "1.0.0"},
"paths": {
"/test_ref": {
"get": {
"summary": "Endpoint with ref",
"operationId": "testGetRef",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MySchema"
}
}
},
}
},
}
}
},
"components": {
"schemas": {
"MySchema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
},
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
op = parsed_operations[0]
assert len(parsed_operations) == 1
assert op.return_value.type_value.__origin__ is dict
def test_parse_spec_with_circular_reference(openapi_spec_generator):
"""Test correct handling of circular $ref (important!)."""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "Circular Ref API", "version": "1.0.0"},
"paths": {
"/circular": {
"get": {
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/A"}
}
},
}
}
}
}
},
"components": {
"schemas": {
"A": {
"type": "object",
"properties": {"b": {"$ref": "#/components/schemas/B"}},
},
"B": {
"type": "object",
"properties": {"a": {"$ref": "#/components/schemas/A"}},
},
}
},
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
op = parsed_operations[0]
assert op.return_value.type_value.__origin__ is dict
assert op.return_value.type_hint == "Dict[str, Any]"
def test_parse_no_paths(openapi_spec_generator):
"""Test with a spec that has no paths defined."""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "No Paths API", "version": "1.0.0"},
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 0 # Should be empty
def test_parse_empty_path_item(openapi_spec_generator):
"""Test a path item that is present but empty."""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "Empty Path Item API", "version": "1.0.0"},
"paths": {"/empty": None},
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 0
def test_parse_spec_with_global_auth_scheme(openapi_spec_generator):
"""Test parsing with a global security scheme."""
openapi_spec = create_minimal_openapi_spec()
openapi_spec["security"] = [{"api_key": []}]
openapi_spec["components"] = {
"securitySchemes": {
"api_key": {"type": "apiKey", "in": "header", "name": "X-API-Key"}
}
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
op = parsed_operations[0]
assert len(parsed_operations) == 1
assert op.auth_scheme is not None
assert op.auth_scheme.type_.value == "apiKey"
def test_parse_spec_with_local_auth_scheme(openapi_spec_generator):
"""Test parsing with a local (operation-level) security scheme."""
openapi_spec = create_minimal_openapi_spec()
openapi_spec["paths"]["/test"]["get"]["security"] = [{"local_auth": []}]
openapi_spec["components"] = {
"securitySchemes": {"local_auth": {"type": "http", "scheme": "bearer"}}
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
op = parsed_operations[0]
assert op.auth_scheme is not None
assert op.auth_scheme.type_.value == "http"
assert op.auth_scheme.scheme == "bearer"
def test_parse_spec_with_servers(openapi_spec_generator):
"""Test parsing with server URLs."""
openapi_spec = create_minimal_openapi_spec()
openapi_spec["servers"] = [
{"url": "https://api.example.com"},
{"url": "http://localhost:8000"},
]
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
assert parsed_operations[0].endpoint.base_url == "https://api.example.com"
def test_parse_spec_with_no_servers(openapi_spec_generator):
"""Test with no servers defined (should default to empty string)."""
openapi_spec = create_minimal_openapi_spec()
if "servers" in openapi_spec:
del openapi_spec["servers"]
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
assert parsed_operations[0].endpoint.base_url == ""
def test_parse_spec_with_description(openapi_spec_generator):
openapi_spec = create_minimal_openapi_spec()
expected_description = "This is a test description."
openapi_spec["paths"]["/test"]["get"]["description"] = expected_description
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
assert parsed_operations[0].description == expected_description
def test_parse_spec_with_empty_description(openapi_spec_generator):
openapi_spec = create_minimal_openapi_spec()
openapi_spec["paths"]["/test"]["get"]["description"] = ""
openapi_spec["paths"]["/test"]["get"]["summary"] = ""
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
assert parsed_operations[0].description == ""
def test_parse_spec_with_no_description(openapi_spec_generator):
openapi_spec = create_minimal_openapi_spec()
# delete description
if "description" in openapi_spec["paths"]["/test"]["get"]:
del openapi_spec["paths"]["/test"]["get"]["description"]
if "summary" in openapi_spec["paths"]["/test"]["get"]:
del openapi_spec["paths"]["/test"]["get"]["summary"]
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
assert (
parsed_operations[0].description == ""
) # it should be initialized with empty string
def test_parse_invalid_openapi_spec_type(openapi_spec_generator):
"""Test that passing a non-dict object to parse raises TypeError"""
with pytest.raises(AttributeError):
openapi_spec_generator.parse(123) # type: ignore
with pytest.raises(AttributeError):
openapi_spec_generator.parse("openapi_spec") # type: ignore
with pytest.raises(AttributeError):
openapi_spec_generator.parse([]) # type: ignore
def test_parse_external_ref_raises_error(openapi_spec_generator):
"""Check that external references (not starting with #) raise ValueError."""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "External Ref API", "version": "1.0.0"},
"paths": {
"/external": {
"get": {
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": (
"external_file.json#/components/schemas/ExternalSchema"
)
}
}
},
}
}
}
}
},
}
with pytest.raises(ValueError):
openapi_spec_generator.parse(openapi_spec)
def test_parse_spec_with_multiple_paths_deep_refs(openapi_spec_generator):
"""Test specs with multiple paths, request/response bodies using deep refs."""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "Multiple Paths Deep Refs API", "version": "1.0.0"},
"paths": {
"/path1": {
"post": {
"operationId": "postPath1",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Request1"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Response1"
}
}
},
}
},
}
},
"/path2": {
"put": {
"operationId": "putPath2",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Request2"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Response2"
}
}
},
}
},
},
"get": {
"operationId": "getPath2",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Response2"
}
}
},
}
},
},
},
},
"components": {
"schemas": {
"Request1": {
"type": "object",
"properties": {
"req1_prop1": {"$ref": "#/components/schemas/Level1_1"}
},
},
"Response1": {
"type": "object",
"properties": {
"res1_prop1": {"$ref": "#/components/schemas/Level1_2"}
},
},
"Request2": {
"type": "object",
"properties": {
"req2_prop1": {"$ref": "#/components/schemas/Level1_1"}
},
},
"Response2": {
"type": "object",
"properties": {
"res2_prop1": {"$ref": "#/components/schemas/Level1_2"}
},
},
"Level1_1": {
"type": "object",
"properties": {
"level1_1_prop1": {
"$ref": "#/components/schemas/Level2_1"
}
},
},
"Level1_2": {
"type": "object",
"properties": {
"level1_2_prop1": {
"$ref": "#/components/schemas/Level2_2"
}
},
},
"Level2_1": {
"type": "object",
"properties": {
"level2_1_prop1": {"$ref": "#/components/schemas/Level3"}
},
},
"Level2_2": {
"type": "object",
"properties": {"level2_2_prop1": {"type": "string"}},
},
"Level3": {"type": "integer"},
}
},
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 3
# Verify Path 1
path1_ops = [op for op in parsed_operations if op.endpoint.path == "/path1"]
assert len(path1_ops) == 1
path1_op = path1_ops[0]
assert path1_op.name == "post_path1"
assert len(path1_op.parameters) == 1
assert path1_op.parameters[0].original_name == "req1_prop1"
assert (
path1_op.parameters[0]
.param_schema.properties["level1_1_prop1"]
.properties["level2_1_prop1"]
.type
== "integer"
)
assert (
path1_op.return_value.param_schema.properties["res1_prop1"]
.properties["level1_2_prop1"]
.properties["level2_2_prop1"]
.type
== "string"
)
# Verify Path 2
path2_ops = [
op
for op in parsed_operations
if op.endpoint.path == "/path2" and op.name == "put_path2"
]
path2_op = path2_ops[0]
assert path2_op is not None
assert len(path2_op.parameters) == 1
assert path2_op.parameters[0].original_name == "req2_prop1"
assert (
path2_op.parameters[0]
.param_schema.properties["level1_1_prop1"]
.properties["level2_1_prop1"]
.type
== "integer"
)
assert (
path2_op.return_value.param_schema.properties["res2_prop1"]
.properties["level1_2_prop1"]
.properties["level2_2_prop1"]
.type
== "string"
)
def test_parse_spec_with_duplicate_parameter_names(openapi_spec_generator):
"""Test handling of duplicate parameter names (one in query, one in body).
The expected behavior is that both parameters should be captured but with
different suffix, and
their `original_name` attributes should reflect their origin (query or body).
"""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "Duplicate Parameter Names API", "version": "1.0.0"},
"paths": {
"/duplicate": {
"post": {
"operationId": "createWithDuplicate",
"parameters": [{
"name": "name",
"in": "query",
"schema": {"type": "string"},
}],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "integer"}},
}
}
}
},
"responses": {"200": {"description": "OK"}},
}
}
},
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
op = parsed_operations[0]
assert op.name == "create_with_duplicate"
assert len(op.parameters) == 2
query_param = None
body_param = None
for param in op.parameters:
if param.param_location == "query" and param.original_name == "name":
query_param = param
elif param.param_location == "body" and param.original_name == "name":
body_param = param
assert query_param is not None
assert query_param.original_name == "name"
assert query_param.py_name == "name"
assert body_param is not None
assert body_param.original_name == "name"
assert body_param.py_name == "name_0"
def test_parse_spec_with_path_level_parameters(openapi_spec_generator):
"""Test that operation parameters are correctly combined with path-level parameters."""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "Combine Parameters API", "version": "1.0.0"},
"paths": {
"/test": {
"parameters": [{
"name": "global_param",
"in": "query",
"schema": {"type": "string"},
}],
"get": {
"parameters": [{
"name": "local_param",
"in": "header",
"schema": {"type": "integer"},
}],
"operationId": "testGet",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {"schema": {"type": "string"}}
},
}
},
},
}
},
}
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
operation = parsed_operations[0]
assert len(operation.parameters) == 2
# Verify the combined parameters
global_param = next(
(p for p in operation.parameters if p.original_name == "global_param"),
None,
)
local_param = next(
(p for p in operation.parameters if p.original_name == "local_param"),
None,
)
assert global_param is not None
assert global_param.param_location == "query"
assert global_param.type_value is str
assert local_param is not None
assert local_param.param_location == "header"
assert local_param.type_value is int
def test_parse_spec_with_invalid_type_any(openapi_spec_generator):
"""Test that schemas with type='Any' are sanitized for Pydantic 2.11+.
External APIs like Google Integration Connectors may return schemas with
non-standard types like 'Any'. This test verifies that such types are
removed to allow parsing to succeed.
"""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "API with Any type", "version": "1.0.0"},
"paths": {
"/test": {
"get": {
"operationId": "testAnyType",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"type": "Any"}}
},
}
},
}
}
},
}
# This should not raise a ValidationError
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
assert parsed_operations[0].name == "test_any_type"
def test_parse_spec_with_nested_invalid_types(openapi_spec_generator):
"""Test that nested schemas with invalid types are sanitized."""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "Nested Invalid Types API", "version": "1.0.0"},
"paths": {
"/test": {
"post": {
"operationId": "testNestedInvalid",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"valid_prop": {"type": "string"},
"invalid_prop": {"type": "Unknown"},
"nested_obj": {
"type": "object",
"properties": {
"deeply_invalid": {
"type": "CustomType"
}
},
},
},
}
}
}
},
"responses": {"200": {"description": "OK"}},
}
}
},
}
# This should not raise a ValidationError
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
op = parsed_operations[0]
# The valid properties should still be parsed
param_names = [p.original_name for p in op.parameters]
assert "valid_prop" in param_names
assert "invalid_prop" in param_names
assert "nested_obj" in param_names
def test_parse_spec_with_type_list_containing_invalid(openapi_spec_generator):
"""Test that type arrays with invalid values are filtered."""
openapi_spec = {
"openapi": "3.1.0",
"info": {"title": "Type List API", "version": "1.0.0"},
"paths": {
"/test": {
"get": {
"operationId": "testTypeList",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {"type": ["string", "Any", "null"]}
}
},
}
},
}
}
},
}
# This should not raise a ValidationError
parsed_operations = openapi_spec_generator.parse(openapi_spec)
assert len(parsed_operations) == 1
def test_sanitize_schema_types_removes_invalid_types(openapi_spec_generator):
"""Test that _sanitize_schema_types correctly handles invalid types."""
spec_with_invalid = {
"components": {
"schemas": {
"InvalidSchema": {"type": "Any", "description": "Invalid type"},
"ValidSchema": {"type": "string", "description": "Valid type"},
}
}
}
sanitized = openapi_spec_generator._sanitize_schema_types(spec_with_invalid)
# Invalid type should be removed
assert "type" not in sanitized["components"]["schemas"]["InvalidSchema"]
assert (
sanitized["components"]["schemas"]["InvalidSchema"]["description"]
== "Invalid type"
)
# Valid type should be preserved
assert sanitized["components"]["schemas"]["ValidSchema"]["type"] == "string"
def test_sanitize_schema_types_does_not_touch_security_schemes(
openapi_spec_generator,
):
"""Test that schema type sanitization does not affect security schemes."""
spec = {
"components": {
"schemas": {"InvalidSchema": {"type": "Any"}},
"securitySchemes": {
"api_key": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
}
},
}
}
sanitized = openapi_spec_generator._sanitize_schema_types(spec)
assert "type" not in sanitized["components"]["schemas"]["InvalidSchema"]
assert (
sanitized["components"]["securitySchemes"]["api_key"]["type"] == "apiKey"
)
def test_sanitize_schema_types_filters_type_lists(openapi_spec_generator):
"""Test that type lists with invalid values are filtered."""
spec_with_list = {"schema": {"type": ["string", "Any", "null", "Unknown"]}}
sanitized = openapi_spec_generator._sanitize_schema_types(spec_with_list)
# Only valid types should remain
assert sanitized["schema"]["type"] == ["string", "null"]
def test_sanitize_schema_types_removes_all_invalid_list(openapi_spec_generator):
"""Test that type field is removed when all list values are invalid."""
spec_with_all_invalid = {"schema": {"type": ["Any", "Unknown", "Custom"]}}
sanitized = openapi_spec_generator._sanitize_schema_types(
spec_with_all_invalid
)
# Type field should be removed entirely
assert "type" not in sanitized["schema"]
@@ -0,0 +1,334 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
from typing import Any
from typing import Dict
from fastapi.openapi.models import APIKey
from fastapi.openapi.models import APIKeyIn
from fastapi.openapi.models import MediaType
from fastapi.openapi.models import OAuth2
from fastapi.openapi.models import ParameterInType
from fastapi.openapi.models import SecuritySchemeType
from google.adk.auth.auth_credential import AuthCredential
from google.adk.auth.auth_credential import AuthCredentialTypes
from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_toolset import OpenAPIToolset
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool
import pytest
import yaml
def load_spec(file_path: str) -> Dict:
"""Loads the OpenAPI specification from a YAML file."""
with open(file_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
@pytest.fixture
def openapi_spec() -> Dict:
"""Fixture to load the OpenAPI specification."""
current_dir = os.path.dirname(os.path.abspath(__file__))
# Join the directory path with the filename
yaml_path = os.path.join(current_dir, "test.yaml")
return load_spec(yaml_path)
def test_openapi_toolset_initialization_from_dict(openapi_spec: Dict):
"""Test initialization of OpenAPIToolset with a dictionary."""
toolset = OpenAPIToolset(spec_dict=openapi_spec)
assert isinstance(toolset._tools, list)
assert len(toolset._tools) == 5
assert all(isinstance(tool, RestApiTool) for tool in toolset._tools)
def test_openapi_toolset_initialization_from_yaml_string(openapi_spec: Dict):
"""Test initialization of OpenAPIToolset with a YAML string."""
spec_str = yaml.dump(openapi_spec)
toolset = OpenAPIToolset(spec_str=spec_str, spec_str_type="yaml")
assert isinstance(toolset._tools, list)
assert len(toolset._tools) == 5
assert all(isinstance(tool, RestApiTool) for tool in toolset._tools)
def test_openapi_toolset_tool_existing(openapi_spec: Dict):
"""Test the tool() method for an existing tool."""
toolset = OpenAPIToolset(spec_dict=openapi_spec)
tool_name = "calendar_calendars_insert" # Example operationId from the spec
tool = toolset.get_tool(tool_name)
assert isinstance(tool, RestApiTool)
assert tool.name == tool_name
assert tool.description == "Creates a secondary calendar."
assert tool.endpoint.method == "post"
assert tool.endpoint.base_url == "https://www.googleapis.com/calendar/v3"
assert tool.endpoint.path == "/calendars"
assert tool.is_long_running is False
assert tool.operation.operationId == "calendar.calendars.insert"
assert tool.operation.description == "Creates a secondary calendar."
assert isinstance(
tool.operation.requestBody.content["application/json"], MediaType
)
assert len(tool.operation.responses) == 1
response = tool.operation.responses["200"]
assert response.description == "Successful response"
assert isinstance(response.content["application/json"], MediaType)
assert isinstance(tool.auth_scheme, OAuth2)
tool_name = "calendar_calendars_get"
tool = toolset.get_tool(tool_name)
assert isinstance(tool, RestApiTool)
assert tool.name == tool_name
assert tool.description == "Returns metadata for a calendar."
assert tool.endpoint.method == "get"
assert tool.endpoint.base_url == "https://www.googleapis.com/calendar/v3"
assert tool.endpoint.path == "/calendars/{calendarId}"
assert tool.is_long_running is False
assert tool.operation.operationId == "calendar.calendars.get"
assert tool.operation.description == "Returns metadata for a calendar."
assert len(tool.operation.parameters) == 8
assert tool.operation.parameters[0].name == "calendarId"
assert tool.operation.parameters[0].in_ == ParameterInType.path
assert tool.operation.parameters[0].required is True
assert tool.operation.parameters[0].schema_.type == "string"
assert (
tool.operation.parameters[0].description
== "Calendar identifier. To retrieve calendar IDs call the"
" calendarList.list method. If you want to access the primary calendar"
' of the currently logged in user, use the "primary" keyword.'
)
assert isinstance(tool.auth_scheme, OAuth2)
assert isinstance(toolset.get_tool("calendar_calendars_update"), RestApiTool)
assert isinstance(toolset.get_tool("calendar_calendars_delete"), RestApiTool)
assert isinstance(toolset.get_tool("calendar_calendars_patch"), RestApiTool)
def test_openapi_toolset_tool_non_existing(openapi_spec: Dict):
"""Test the tool() method for a non-existing tool."""
toolset = OpenAPIToolset(spec_dict=openapi_spec)
tool = toolset.get_tool("non_existent_tool")
assert tool is None
def test_openapi_toolset_configure_auth_on_init(openapi_spec: Dict):
"""Test configuring auth during initialization."""
auth_scheme = APIKey(**{
"in": APIKeyIn.header, # Use alias name in dict
"name": "api_key",
"type": SecuritySchemeType.http,
})
auth_credential = AuthCredential(auth_type=AuthCredentialTypes.API_KEY)
toolset = OpenAPIToolset(
spec_dict=openapi_spec,
auth_scheme=auth_scheme,
auth_credential=auth_credential,
)
assert all(tool.auth_scheme == auth_scheme for tool in toolset._tools)
assert all(tool.auth_credential == auth_credential for tool in toolset._tools)
@pytest.mark.parametrize(
"verify_value", ["/path/to/enterprise-ca-bundle.crt", False]
)
def test_openapi_toolset_verify_on_init(
openapi_spec: Dict[str, Any], verify_value: str | bool
):
"""Test configuring verify during initialization."""
toolset = OpenAPIToolset(
spec_dict=openapi_spec,
ssl_verify=verify_value,
)
assert all(tool._ssl_verify == verify_value for tool in toolset._tools)
def test_openapi_toolset_httpx_client_factory_on_init(
openapi_spec: Dict[str, Any],
):
"""The httpx_client_factory is forwarded to every generated tool."""
custom_factory = lambda: None # noqa: E731 - placeholder, never invoked here
toolset = OpenAPIToolset(
spec_dict=openapi_spec, httpx_client_factory=custom_factory
)
assert toolset._httpx_client_factory is custom_factory
assert all(
tool._httpx_client_factory is custom_factory for tool in toolset._tools
)
def test_openapi_toolset_httpx_client_factory_none_by_default(
openapi_spec: Dict[str, Any],
):
"""httpx_client_factory is None on the toolset and each tool by default."""
toolset = OpenAPIToolset(spec_dict=openapi_spec)
assert toolset._httpx_client_factory is None
assert all(tool._httpx_client_factory is None for tool in toolset._tools)
def test_openapi_toolset_configure_verify_all(openapi_spec: Dict[str, Any]):
"""Test configure_verify_all method."""
toolset = OpenAPIToolset(spec_dict=openapi_spec)
# Initially verify should be None
assert all(tool._ssl_verify is None for tool in toolset._tools)
# Configure verify for all tools
ca_bundle_path = "/path/to/custom-ca.crt"
toolset.configure_ssl_verify_all(ca_bundle_path)
assert all(tool._ssl_verify == ca_bundle_path for tool in toolset._tools)
async def test_openapi_toolset_tool_name_prefix(openapi_spec: Dict[str, Any]):
"""Test tool_name_prefix parameter prefixes tool names."""
prefix = "my_api"
toolset = OpenAPIToolset(spec_dict=openapi_spec, tool_name_prefix=prefix)
# Verify the toolset has the prefix set
assert toolset.tool_name_prefix == prefix
prefixed_tools = await toolset.get_tools_with_prefix()
assert len(prefixed_tools) == 5
# Verify all tool names are prefixed
assert all(tool.name.startswith(f"{prefix}_") for tool in prefixed_tools)
# Verify specific tool name is prefixed
expected_prefixed_name = "my_api_calendar_calendars_insert"
prefixed_tool_names = [t.name for t in prefixed_tools]
assert expected_prefixed_name in prefixed_tool_names
def test_openapi_toolset_header_provider(openapi_spec: Dict[str, Any]):
"""Test header_provider parameter is passed to tools."""
def my_header_provider(context):
return {"X-Custom-Header": "custom-value", "X-Request-ID": "12345"}
toolset = OpenAPIToolset(
spec_dict=openapi_spec,
header_provider=my_header_provider,
)
# Verify the toolset has the header_provider set
assert toolset._header_provider is my_header_provider
# Verify all tools have the header_provider
assert all(
tool._header_provider is my_header_provider for tool in toolset._tools
)
def test_openapi_toolset_header_provider_none_by_default(
openapi_spec: Dict[str, Any],
):
"""Test that header_provider is None by default."""
toolset = OpenAPIToolset(spec_dict=openapi_spec)
# Verify the toolset has no header_provider by default
assert toolset._header_provider is None
# Verify all tools have no header_provider
assert all(tool._header_provider is None for tool in toolset._tools)
def test_openapi_toolset_preserve_property_names(openapi_spec: Dict[str, Any]):
"""Test that preserve_property_names keeps original camelCase names."""
toolset = OpenAPIToolset(
spec_dict=openapi_spec,
preserve_property_names=True,
)
tool = toolset.get_tool("calendar_calendars_get")
assert tool is not None
# The calendarId parameter should keep its original camelCase name
params = tool._operation_parser.get_parameters()
param_names = [p.py_name for p in params]
assert "calendarId" in param_names
# The JSON schema should also use the original name
schema = tool._operation_parser.get_json_schema()
assert "calendarId" in schema["properties"]
def test_openapi_toolset_default_snake_case_conversion(
openapi_spec: Dict[str, Any],
):
"""Test that default behavior still converts to snake_case."""
toolset = OpenAPIToolset(spec_dict=openapi_spec)
tool = toolset.get_tool("calendar_calendars_get")
assert tool is not None
# The calendarId parameter should be converted to snake_case by default
params = tool._operation_parser.get_parameters()
param_names = [p.py_name for p in params]
assert "calendar_id" in param_names
assert "calendarId" not in param_names
# The JSON schema should also use snake_case
schema = tool._operation_parser.get_json_schema()
assert "calendar_id" in schema["properties"]
assert "calendarId" not in schema["properties"]
def test_openapi_toolset_preserve_property_names_body_params():
"""Test preserve_property_names with request body properties."""
spec = {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/users": {
"post": {
"operationId": "createUser",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"firstName": {"type": "string"},
"lastName": {"type": "string"},
"emailAddress": {"type": "string"},
},
}
}
}
},
"responses": {"200": {"description": "OK"}},
}
}
},
}
# With preserve_property_names=True
toolset = OpenAPIToolset(
spec_dict=spec,
preserve_property_names=True,
)
tool = toolset.get_tool("create_user")
params = tool._operation_parser.get_parameters()
param_names = [p.py_name for p in params]
assert "firstName" in param_names
assert "lastName" in param_names
assert "emailAddress" in param_names
# Without preserve_property_names (default)
toolset_default = OpenAPIToolset(spec_dict=spec)
tool_default = toolset_default.get_tool("create_user")
params_default = tool_default._operation_parser.get_parameters()
param_names_default = [p.py_name for p in params_default]
assert "first_name" in param_names_default
assert "last_name" in param_names_default
assert "email_address" in param_names_default
@@ -0,0 +1,452 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from fastapi.openapi.models import MediaType
from fastapi.openapi.models import Operation
from fastapi.openapi.models import Parameter
from fastapi.openapi.models import RequestBody
from fastapi.openapi.models import Response
from fastapi.openapi.models import Schema
from google.adk.tools.openapi_tool.common.common import ApiParameter
from google.adk.tools.openapi_tool.openapi_spec_parser.operation_parser import OperationParser
import pytest
@pytest.fixture
def sample_operation() -> Operation:
"""Fixture to provide a sample OpenAPI Operation object."""
return Operation(
operationId='test_operation',
summary='Test Summary',
description='Test Description',
parameters=[
Parameter(**{
'name': 'param1',
'in': 'query',
'schema': Schema(type='string'),
'description': 'Parameter 1',
}),
Parameter(**{
'name': 'param2',
'in': 'header',
'schema': Schema(type='string'),
'description': 'Parameter 2',
}),
],
requestBody=RequestBody(
content={
'application/json': MediaType(
schema=Schema(
type='object',
properties={
'prop1': Schema(
type='string', description='Property 1'
),
'prop2': Schema(
type='integer', description='Property 2'
),
},
)
)
},
description='Request body description',
),
responses={
'200': Response(
description='Success',
content={
'application/json': MediaType(schema=Schema(type='string'))
},
),
'400': Response(description='Client Error'),
},
security=[{'oauth2': ['resource: read', 'resource: write']}],
)
def test_operation_parser_initialization(sample_operation):
"""Test initialization of OperationParser."""
parser = OperationParser(sample_operation)
assert parser._operation == sample_operation
assert len(parser._params) == 4 # 2 params + 2 request body props
assert parser._return_value is not None
def test_process_operation_parameters(sample_operation):
"""Test _process_operation_parameters method."""
parser = OperationParser(sample_operation, should_parse=False)
parser._process_operation_parameters()
assert len(parser._params) == 2
assert parser._params[0].original_name == 'param1'
assert parser._params[0].param_location == 'query'
assert parser._params[1].original_name == 'param2'
assert parser._params[1].param_location == 'header'
def test_process_request_body(sample_operation):
"""Test _process_request_body method."""
parser = OperationParser(sample_operation, should_parse=False)
parser._process_request_body()
assert len(parser._params) == 2 # 2 properties in request body
assert parser._params[0].original_name == 'prop1'
assert parser._params[0].param_location == 'body'
assert parser._params[1].original_name == 'prop2'
assert parser._params[1].param_location == 'body'
def test_process_request_body_array():
"""Test _process_request_body method with array schema."""
operation = Operation(
requestBody=RequestBody(
content={
'application/json': MediaType(
schema=Schema(
type='array',
items=Schema(
type='object',
properties={
'item_prop1': Schema(
type='string', description='Item Property 1'
),
'item_prop2': Schema(
type='integer', description='Item Property 2'
),
},
),
)
)
}
)
)
parser = OperationParser(operation, should_parse=False)
parser._process_request_body()
assert len(parser._params) == 1
assert parser._params[0].original_name == 'array'
assert parser._params[0].param_location == 'body'
# Check that schema is correctly propagated and is a dictionary
assert parser._params[0].param_schema.type == 'array'
assert parser._params[0].param_schema.items.type == 'object'
assert 'item_prop1' in parser._params[0].param_schema.items.properties
assert 'item_prop2' in parser._params[0].param_schema.items.properties
assert (
parser._params[0].param_schema.items.properties['item_prop1'].description
== 'Item Property 1'
)
assert (
parser._params[0].param_schema.items.properties['item_prop2'].description
== 'Item Property 2'
)
def test_process_request_body_no_name():
"""Test _process_request_body with a schema that has no properties (unnamed)"""
operation = Operation(
requestBody=RequestBody(
content={'application/json': MediaType(schema=Schema(type='string'))}
)
)
parser = OperationParser(operation, should_parse=False)
parser._process_request_body()
assert len(parser._params) == 1
assert parser._params[0].original_name == '' # No name
assert parser._params[0].param_location == 'body'
def test_process_request_body_one_of_schema_assigns_name():
"""Ensures oneOf bodies result in a named parameter."""
operation = Operation(
operationId='one_of_request',
requestBody=RequestBody(
content={
'application/json': MediaType(
schema=Schema(
oneOf=[
Schema(
type='object',
properties={
'type': Schema(type='string'),
'stage': Schema(type='string'),
},
)
],
discriminator={'propertyName': 'type'},
)
)
}
),
responses={'200': Response(description='ok')},
)
parser = OperationParser(operation)
params = parser.get_parameters()
assert len(params) == 1
assert params[0].original_name == 'body'
assert params[0].py_name == 'body'
schema = parser.get_json_schema()
assert 'body' in schema['properties']
assert '' not in schema['properties']
def test_process_request_body_empty_object():
"""Test _process_request_body with a schema that is of type object but with no properties."""
operation = Operation(
requestBody=RequestBody(
content={'application/json': MediaType(schema=Schema(type='object'))}
)
)
parser = OperationParser(operation, should_parse=False)
parser._process_request_body()
assert len(parser._params) == 0
def test_dedupe_param_names(sample_operation):
"""Test _dedupe_param_names method."""
parser = OperationParser(sample_operation, should_parse=False)
# Add duplicate named parameters.
parser._params = [
ApiParameter(original_name='test', param_location='', param_schema={}),
ApiParameter(original_name='test', param_location='', param_schema={}),
ApiParameter(original_name='test', param_location='', param_schema={}),
]
parser._dedupe_param_names()
assert parser._params[0].py_name == 'test'
assert parser._params[1].py_name == 'test_0'
assert parser._params[2].py_name == 'test_1'
def test_process_return_value(sample_operation):
"""Test _process_return_value method."""
parser = OperationParser(sample_operation, should_parse=False)
parser._process_return_value()
assert parser._return_value is not None
assert parser._return_value.type_hint == 'str'
def test_process_return_value_no_2xx(sample_operation):
"""Tests _process_return_value when no 2xx response exists."""
operation_no_2xx = Operation(
responses={'400': Response(description='Client Error')}
)
parser = OperationParser(operation_no_2xx, should_parse=False)
parser._process_return_value()
assert parser._return_value is not None
assert parser._return_value.type_hint == 'Any'
def test_process_return_value_multiple_2xx(sample_operation):
"""Tests _process_return_value when multiple 2xx responses exist."""
operation_multi_2xx = Operation(
responses={
'201': Response(
description='Success',
content={
'application/json': MediaType(schema=Schema(type='integer'))
},
),
'202': Response(
description='Success',
content={'text/plain': MediaType(schema=Schema(type='string'))},
),
'200': Response(
description='Success',
content={
'application/pdf': MediaType(schema=Schema(type='boolean'))
},
),
'400': Response(
description='Failure',
content={
'application/xml': MediaType(schema=Schema(type='object'))
},
),
}
)
parser = OperationParser(operation_multi_2xx, should_parse=False)
parser._process_return_value()
assert parser._return_value is not None
# Take the content type of the 200 response since it's the smallest response
# code
assert parser._return_value.param_schema.type == 'boolean'
def test_process_return_value_no_content(sample_operation):
"""Test when 2xx response has no content"""
operation_no_content = Operation(
responses={'200': Response(description='Success', content={})}
)
parser = OperationParser(operation_no_content, should_parse=False)
parser._process_return_value()
assert parser._return_value.type_hint == 'Any'
def test_process_return_value_no_schema(sample_operation):
"""Tests when the 2xx response's content has no schema."""
operation_no_schema = Operation(
responses={
'200': Response(
description='Success',
content={'application/json': MediaType(schema=None)},
)
}
)
parser = OperationParser(operation_no_schema, should_parse=False)
parser._process_return_value()
assert parser._return_value.type_hint == 'Any'
def test_get_function_name(sample_operation):
"""Test get_function_name method."""
parser = OperationParser(sample_operation)
assert parser.get_function_name() == 'test_operation'
def test_get_function_name_missing_id():
"""Tests get_function_name when operationId is missing"""
operation = Operation() # No ID
parser = OperationParser(operation)
with pytest.raises(ValueError, match='Operation ID is missing'):
parser.get_function_name()
def test_get_return_type_hint(sample_operation):
"""Test get_return_type_hint method."""
parser = OperationParser(sample_operation)
assert parser.get_return_type_hint() == 'str'
def test_get_return_type_value(sample_operation):
"""Test get_return_type_value method."""
parser = OperationParser(sample_operation)
assert parser.get_return_type_value() == str
def test_get_parameters(sample_operation):
"""Test get_parameters method."""
parser = OperationParser(sample_operation)
params = parser.get_parameters()
assert len(params) == 4 # Correct count after processing
assert all(isinstance(p, ApiParameter) for p in params)
def test_get_return_value(sample_operation):
"""Test get_return_value method."""
parser = OperationParser(sample_operation)
return_value = parser.get_return_value()
assert isinstance(return_value, ApiParameter)
def test_get_auth_scheme_name(sample_operation):
"""Test get_auth_scheme_name method."""
parser = OperationParser(sample_operation)
assert parser.get_auth_scheme_name() == 'oauth2'
def test_get_auth_scheme_name_no_security():
"""Test get_auth_scheme_name when no security is present."""
operation = Operation(responses={})
parser = OperationParser(operation)
assert parser.get_auth_scheme_name() == ''
def test_get_pydoc_string(sample_operation):
"""Test get_pydoc_string method."""
parser = OperationParser(sample_operation)
pydoc_string = parser.get_pydoc_string()
assert 'Test Summary' in pydoc_string
assert 'Args:' in pydoc_string
assert 'param1 (str): Parameter 1' in pydoc_string
assert 'prop1 (str): Property 1' in pydoc_string
assert 'Returns (str):' in pydoc_string
assert 'Success' in pydoc_string
def test_get_json_schema(sample_operation):
"""Test get_json_schema method."""
parser = OperationParser(sample_operation)
json_schema = parser.get_json_schema()
assert json_schema['title'] == 'test_operation_Arguments'
assert json_schema['type'] == 'object'
assert 'param1' in json_schema['properties']
assert 'prop1' in json_schema['properties']
# By default nothing is required unless explicitly stated
assert 'required' not in json_schema or json_schema['required'] == []
def test_get_signature_parameters(sample_operation):
"""Test get_signature_parameters method."""
parser = OperationParser(sample_operation)
signature_params = parser.get_signature_parameters()
assert len(signature_params) == 4
assert signature_params[0].name == 'param1'
assert signature_params[0].annotation == str
assert signature_params[2].name == 'prop1'
assert signature_params[2].annotation == str
def test_get_annotations(sample_operation):
"""Test get_annotations method."""
parser = OperationParser(sample_operation)
annotations = parser.get_annotations()
assert len(annotations) == 5 # 4 parameters + return
assert annotations['param1'] == str
assert annotations['prop1'] == str
assert annotations['return'] == str
def test_load():
"""Test the load classmethod."""
operation = Operation(operationId='my_op') # Minimal operation
params = [
ApiParameter(
original_name='p1',
param_location='',
param_schema={'type': 'integer'},
)
]
return_value = ApiParameter(
original_name='', param_location='', param_schema={'type': 'string'}
)
parser = OperationParser.load(operation, params, return_value)
assert isinstance(parser, OperationParser)
assert parser._operation == operation
assert parser._params == params
assert parser._return_value == return_value
assert (
parser.get_function_name() == 'my_op'
) # Check that the operation is loaded
def test_operation_parser_with_dict():
"""Test initialization of OperationParser with a dictionary."""
operation_dict = {
'operationId': 'test_dict_operation',
'parameters': [
{'name': 'dict_param', 'in': 'query', 'schema': {'type': 'string'}}
],
'responses': {
'200': {
'description': 'Dict Success',
'content': {'application/json': {'schema': {'type': 'string'}}},
}
},
}
parser = OperationParser(operation_dict)
assert parser._operation.operationId == 'test_dict_operation'
assert len(parser._params) == 1
assert parser._params[0].original_name == 'dict_param'
assert parser._return_value.type_hint == 'str'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,417 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Optional
from unittest.mock import AsyncMock
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
from google.adk.auth.auth_credential import AuthCredential
from google.adk.auth.auth_credential import AuthCredentialTypes
from google.adk.auth.auth_credential import HttpAuth
from google.adk.auth.auth_credential import HttpCredentials
from google.adk.auth.auth_credential import OAuth2Auth
from google.adk.auth.auth_schemes import AuthScheme
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
from google.adk.tools.openapi_tool.auth.auth_helpers import openid_dict_to_scheme_credential
from google.adk.tools.openapi_tool.auth.auth_helpers import token_to_scheme_credential
from google.adk.tools.openapi_tool.auth.credential_exchangers.auto_auth_credential_exchanger import OAuth2CredentialExchanger
from google.adk.tools.openapi_tool.openapi_spec_parser import tool_auth_handler
from google.adk.tools.openapi_tool.openapi_spec_parser.tool_auth_handler import ToolAuthHandler
from google.adk.tools.openapi_tool.openapi_spec_parser.tool_auth_handler import ToolContextCredentialStore
from google.adk.tools.tool_context import ToolContext
import pytest
# Helper function to create a mock ToolContext
def create_mock_tool_context():
return ToolContext(
function_call_id='test-fc-id',
invocation_context=InvocationContext(
agent=LlmAgent(name='test'),
session=Session(app_name='test', user_id='123', id='123'),
invocation_id='123',
session_service=InMemorySessionService(),
),
)
# Test cases for OpenID Connect
class MockOpenIdConnectCredentialExchanger(OAuth2CredentialExchanger):
def __init__(
self, expected_scheme, expected_credential, expected_access_token
):
self.expected_scheme = expected_scheme
self.expected_credential = expected_credential
self.expected_access_token = expected_access_token
def exchange_credential(
self,
auth_scheme: AuthScheme,
auth_credential: Optional[AuthCredential] = None,
) -> AuthCredential:
if auth_credential.oauth2 and (
auth_credential.oauth2.auth_response_uri
or auth_credential.oauth2.auth_code
):
auth_code = (
auth_credential.oauth2.auth_response_uri
if auth_credential.oauth2.auth_response_uri
else auth_credential.oauth2.auth_code
)
# Simulate the token exchange
updated_credential = AuthCredential(
auth_type=AuthCredentialTypes.HTTP, # Store as a bearer token
http=HttpAuth(
scheme='bearer',
credentials=HttpCredentials(
token=auth_code + self.expected_access_token
),
),
)
return updated_credential
# simulate the case of getting auth_uri
return None
def get_mock_openid_scheme_credential():
config_dict = {
'authorization_endpoint': 'test.com',
'token_endpoint': 'test.com',
}
scopes = ['test_scope']
credential_dict = {
'client_id': '123',
'client_secret': '456',
'redirect_uri': 'test.com',
}
return openid_dict_to_scheme_credential(config_dict, scopes, credential_dict)
# Fixture for the OpenID Connect security scheme
@pytest.fixture
def openid_connect_scheme():
scheme, _ = get_mock_openid_scheme_credential()
return scheme
# Fixture for a base OpenID Connect credential
@pytest.fixture
def openid_connect_credential():
_, credential = get_mock_openid_scheme_credential()
return credential
@pytest.mark.asyncio
async def test_openid_connect_no_auth_response(
openid_connect_scheme, openid_connect_credential
):
# Setup Mock exchanger
mock_exchanger = MockOpenIdConnectCredentialExchanger(
openid_connect_scheme, openid_connect_credential, None
)
tool_context = create_mock_tool_context()
credential_store = ToolContextCredentialStore(tool_context=tool_context)
handler = ToolAuthHandler(
tool_context,
openid_connect_scheme,
openid_connect_credential,
credential_exchanger=mock_exchanger,
credential_store=credential_store,
)
result = await handler.prepare_auth_credentials()
assert result.state == 'pending'
assert result.auth_credential == openid_connect_credential
@pytest.mark.asyncio
async def test_openid_connect_uses_explicit_credential_key(
openid_connect_scheme, openid_connect_credential
):
tool_context = create_mock_tool_context()
handler = ToolAuthHandler(
tool_context,
openid_connect_scheme,
openid_connect_credential,
credential_key='my_tool_tokens',
)
result = await handler.prepare_auth_credentials()
assert result.state == 'pending'
requested = tool_context.actions.requested_auth_configs['test-fc-id']
assert requested.credential_key == 'my_tool_tokens'
@pytest.mark.asyncio
async def test_openid_connect_with_auth_response(
openid_connect_scheme, openid_connect_credential, monkeypatch
):
mock_exchanger = MockOpenIdConnectCredentialExchanger(
openid_connect_scheme,
openid_connect_credential,
'test_access_token',
)
tool_context = create_mock_tool_context()
mock_auth_handler = MagicMock()
returned_credential = 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_credential
mock_auth_handler_path = 'google.adk.auth.auth_handler.AuthHandler'
monkeypatch.setattr(
mock_auth_handler_path, lambda *args, **kwargs: mock_auth_handler
)
credential_store = ToolContextCredentialStore(tool_context=tool_context)
handler = ToolAuthHandler(
tool_context,
openid_connect_scheme,
openid_connect_credential,
credential_exchanger=mock_exchanger,
credential_store=credential_store,
)
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
# Verify that the credential was stored:
stored_credential = credential_store.get_credential(
openid_connect_scheme, openid_connect_credential
)
assert stored_credential == returned_credential
mock_auth_handler.get_auth_response.assert_called_once()
@pytest.mark.asyncio
async def test_openid_connect_existing_token(
openid_connect_scheme, openid_connect_credential
):
_, existing_credential = token_to_scheme_credential(
'oauth2Token', 'header', 'bearer', '123123123'
)
tool_context = create_mock_tool_context()
# Store the credential to simulate existing credential
credential_store = ToolContextCredentialStore(tool_context=tool_context)
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()
assert result.state == 'done'
assert result.auth_credential == existing_credential
@patch.object(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
@patch.object(tool_auth_handler, 'OAuth2CredentialRefresher')
@pytest.mark.asyncio
async def test_refreshed_credential_is_persisted_to_store(
mock_oauth2_refresher, openid_connect_scheme, openid_connect_credential
):
"""Test that refreshed OAuth2 credentials are persisted back to the store."""
# Create existing OAuth2 credential with an "old" refresh token.
existing_credential = AuthCredential(
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
oauth2=OAuth2Auth(
client_id='test_client_id',
client_secret='test_client_secret',
access_token='old_access_token',
refresh_token='old_refresh_token',
),
)
# The refresher will return a credential with rotated tokens.
refreshed_credential = AuthCredential(
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
oauth2=OAuth2Auth(
client_id='test_client_id',
client_secret='test_client_secret',
access_token='new_access_token',
refresh_token='new_refresh_token',
),
)
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 (stale) 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,
)
await handler.prepare_auth_credentials()
# The critical assertion: the *refreshed* credential must now be in the
# store so that the next invocation reads the new tokens, not the old ones.
persisted = credential_store.get_credential(
openid_connect_scheme, openid_connect_credential
)
assert persisted is not None
assert persisted.oauth2.access_token == 'new_access_token'
assert persisted.oauth2.refresh_token == 'new_refresh_token'
def test_credential_key_is_stable_across_redirect_uri():
"""get_credential_key should be invariant under redirect_uri changes.
redirect_uri is deployment configuration (which callback URL the auth
server should redirect to), not part of the credential identity. Two
AuthCredential instances that share the same client_id, client_secret,
and scopes but differ only in redirect_uri should produce the same key.
"""
scheme, _ = get_mock_openid_scheme_credential()
credential_local = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id='client',
client_secret='secret',
redirect_uri='http://localhost:8001/oauth2callback',
),
)
credential_deployed = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id='client',
client_secret='secret',
redirect_uri='https://deployed.example.com/oauth2callback',
),
)
store = ToolContextCredentialStore(tool_context=create_mock_tool_context())
assert store.get_credential_key(
scheme, credential_local
) == store.get_credential_key(scheme, credential_deployed)
def test_legacy_credential_key_is_stable_across_redirect_uri():
"""_get_legacy_credential_key should be invariant under redirect_uri changes.
The same redirect_uri-strip behavior must apply to the legacy key path so
that already-stored credentials remain findable after the fix.
"""
scheme, _ = get_mock_openid_scheme_credential()
credential_local = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id='client',
client_secret='secret',
redirect_uri='http://localhost:8001/oauth2callback',
),
)
credential_deployed = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id='client',
client_secret='secret',
redirect_uri='https://deployed.example.com/oauth2callback',
),
)
store = ToolContextCredentialStore(tool_context=create_mock_tool_context())
assert store._get_legacy_credential_key(
scheme, credential_local
) == store._get_legacy_credential_key(scheme, credential_deployed)