diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py index 442af52a49d2..2d1ac19df68b 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py @@ -30,6 +30,23 @@ class OpenAPIFunctionExecutionParameters(KernelBaseModel): timeout: float | None = Field( None, description="Default timeout in seconds for HTTP requests. Uses httpx default (5 seconds) if None." ) + enable_file_ref_resolution: bool = Field( + False, + description=( + "Whether to resolve local file $ref references when parsing OpenAPI documents. " + "Disabled by default. When False, only internal JSON pointer references are resolved. " + "Set to True if your OpenAPI spec is split across multiple local files and you trust " + "the document source." + ), + ) + enable_http_ref_resolution: bool = Field( + False, + description=( + "Whether to resolve external HTTP $ref references when parsing OpenAPI documents. " + "Disabled by default. Set to True only if you trust the OpenAPI document source " + "and need external HTTP $ref resolution." + ), + ) def model_post_init(self, __context: Any) -> None: """Post initialization method for the model.""" diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py index 135221f17c59..b825a1635cae 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py @@ -56,7 +56,11 @@ def create_functions_from_openapi( # Parse the document from the given path parser = OpenApiParser() - parsed_doc = parser.parse(openapi_document_path) + parsed_doc = parser.parse( + openapi_document_path, + enable_file_ref_resolution=(execution_settings.enable_file_ref_resolution if execution_settings else False), + enable_http_ref_resolution=(execution_settings.enable_http_ref_resolution if execution_settings else False), + ) if parsed_doc is None: raise FunctionExecutionException(f"Error parsing OpenAPI document: {openapi_document_path}") diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py index 0ed36f209d00..1f6aabe5eeff 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Final from prance import ResolvingParser +from prance.util.resolver import RESOLVE_FILES, RESOLVE_HTTP, RESOLVE_INTERNAL from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import RestApiExpectedResponse from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation @@ -44,9 +45,29 @@ class OpenApiParser: PAYLOAD_PROPERTIES_HIERARCHY_MAX_DEPTH: int = 10 SUPPORTED_MEDIA_TYPES: Final[list[str]] = ["application/json", "text/plain"] - def parse(self, openapi_document: str) -> Any | dict[str, Any] | None: - """Parse the OpenAPI document.""" - parser = ResolvingParser(openapi_document) + def parse( + self, + openapi_document: str, + enable_file_ref_resolution: bool = False, + enable_http_ref_resolution: bool = False, + ) -> Any | dict[str, Any] | None: + """Parse the OpenAPI document. + + Args: + openapi_document: The path or URL to the OpenAPI document. + enable_file_ref_resolution: Whether to resolve local file $ref references. + Disabled by default. When False, only internal JSON pointer references + are resolved. Set to True if your OpenAPI spec is split across multiple + local files. + enable_http_ref_resolution: Whether to resolve external HTTP $ref references. + Disabled by default. + """ + resolve_types = RESOLVE_INTERNAL + if enable_file_ref_resolution: + resolve_types |= RESOLVE_FILES + if enable_http_ref_resolution: + resolve_types |= RESOLVE_HTTP + parser = ResolvingParser(openapi_document, resolve_types=resolve_types) return parser.specification def _parse_parameters(self, parameters: list[dict[str, Any]]): diff --git a/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py b/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py index 05f3f948ff43..37bd6b324d77 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py +++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py @@ -223,7 +223,11 @@ async def test_create_functions_from_openapi_raises_exception(mock_parse): with pytest.raises(FunctionExecutionException, match="Error parsing OpenAPI document: test_openapi_document_path"): create_functions_from_openapi(plugin_name="test_plugin", openapi_document_path="test_openapi_document_path") - mock_parse.assert_called_once_with("test_openapi_document_path") + mock_parse.assert_called_once_with( + "test_openapi_document_path", + enable_file_ref_resolution=False, + enable_http_ref_resolution=False, + ) async def test_run_operation_uses_timeout_from_run_options(): diff --git a/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py b/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py index ecd5e044ba2e..b0c81074e5a8 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py +++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py @@ -189,3 +189,197 @@ def test_no_operationid_raises_error(): openapi_document_path=no_op_path, execution_settings=None, ) + + +def test_parse_blocks_external_http_refs_by_default(): + """Verify that external HTTP $ref resolution is not enabled by default.""" + from unittest.mock import MagicMock, patch + + from prance.util.resolver import RESOLVE_HTTP, RESOLVE_INTERNAL + + with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls: + mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"}) + parser = OpenApiParser() + parser.parse("dummy_path.yaml") + + mock_parser_cls.assert_called_once() + call_kwargs = mock_parser_cls.call_args + resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types") + assert resolve_types == RESOLVE_INTERNAL, ( + f"Expected only RESOLVE_INTERNAL ({RESOLVE_INTERNAL}), got {resolve_types}" + ) + assert not (resolve_types & RESOLVE_HTTP), "RESOLVE_HTTP should not be set by default" + + +def test_parse_resolves_internal_refs_by_default(tmp_path): + """Verify that internal $ref references are still resolved by default.""" + openapi_spec = tmp_path / "spec_with_internal_ref.yaml" + openapi_spec.write_text( + """ +openapi: 3.0.0 +info: + title: Internal Ref Test + version: 1.0.0 +servers: + - url: http://example.com +paths: + /test: + get: + operationId: testOp + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/TestSchema" +components: + schemas: + TestSchema: + type: object + properties: + name: + type: string +""", + encoding="utf-8", + ) + + parser = OpenApiParser() + result = parser.parse(str(openapi_spec)) + + # Internal $ref should be resolved + response_schema = result["paths"]["/test"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + assert "$ref" not in response_schema, "Internal $ref should be resolved" + assert response_schema["type"] == "object" + assert "name" in response_schema["properties"] + + +def test_parse_blocks_file_refs_by_default(): + """Verify that local file $ref resolution is not enabled by default.""" + from unittest.mock import MagicMock, patch + + from prance.util.resolver import RESOLVE_FILES, RESOLVE_INTERNAL + + with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls: + mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"}) + parser = OpenApiParser() + parser.parse("dummy_path.yaml") + + call_kwargs = mock_parser_cls.call_args + resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types") + assert resolve_types == RESOLVE_INTERNAL, ( + f"Expected only RESOLVE_INTERNAL ({RESOLVE_INTERNAL}), got {resolve_types}" + ) + assert not (resolve_types & RESOLVE_FILES), "RESOLVE_FILES should not be set by default" + + +def test_parse_enables_file_refs_when_requested(): + """Verify that local file $ref resolution is enabled when explicitly requested.""" + from unittest.mock import MagicMock, patch + + from prance.util.resolver import RESOLVE_FILES, RESOLVE_INTERNAL + + with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls: + mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"}) + parser = OpenApiParser() + parser.parse("dummy_path.yaml", enable_file_ref_resolution=True) + + call_kwargs = mock_parser_cls.call_args + resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types") + assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_FILES), ( + f"Expected RESOLVE_INTERNAL | RESOLVE_FILES, got {resolve_types}" + ) + + +def test_parse_enables_http_refs_when_requested(): + """Verify that HTTP $ref resolution is enabled when explicitly requested.""" + from unittest.mock import MagicMock, patch + + from prance.util.resolver import RESOLVE_HTTP, RESOLVE_INTERNAL + + with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls: + mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"}) + parser = OpenApiParser() + parser.parse("dummy_path.yaml", enable_http_ref_resolution=True) + + call_kwargs = mock_parser_cls.call_args + resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types") + assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_HTTP), ( + f"Expected RESOLVE_INTERNAL | RESOLVE_HTTP, got {resolve_types}" + ) + + +def test_parse_enables_both_file_and_http_refs_when_requested(): + """Verify both file and HTTP $ref resolution work together.""" + from unittest.mock import MagicMock, patch + + from prance.util.resolver import RESOLVE_FILES, RESOLVE_HTTP, RESOLVE_INTERNAL + + with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls: + mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"}) + parser = OpenApiParser() + parser.parse("dummy_path.yaml", enable_file_ref_resolution=True, enable_http_ref_resolution=True) + + call_kwargs = mock_parser_cls.call_args + resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types") + assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_FILES | RESOLVE_HTTP), ( + f"Expected RESOLVE_INTERNAL | RESOLVE_FILES | RESOLVE_HTTP, got {resolve_types}" + ) + + +def test_create_functions_propagates_enable_http_ref_resolution(): + """Verify enable_http_ref_resolution=True is propagated from settings to parser.""" + from unittest.mock import patch + + from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import ( + OpenAPIFunctionExecutionParameters, + ) + + minimal_spec = { + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": {}, + } + + settings = OpenAPIFunctionExecutionParameters(enable_http_ref_resolution=True) + + with patch.object(OpenApiParser, "parse", return_value=minimal_spec) as mock_parse: + create_functions_from_openapi( + plugin_name="testPlugin", + openapi_document_path="dummy.yaml", + execution_settings=settings, + ) + mock_parse.assert_called_once_with( + "dummy.yaml", + enable_file_ref_resolution=False, + enable_http_ref_resolution=True, + ) + + +def test_create_functions_propagates_enable_file_ref_resolution(): + """Verify enable_file_ref_resolution=True is propagated from settings to parser.""" + from unittest.mock import patch + + from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import ( + OpenAPIFunctionExecutionParameters, + ) + + minimal_spec = { + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": {}, + } + + settings = OpenAPIFunctionExecutionParameters(enable_file_ref_resolution=True) + + with patch.object(OpenApiParser, "parse", return_value=minimal_spec) as mock_parse: + create_functions_from_openapi( + plugin_name="testPlugin", + openapi_document_path="dummy.yaml", + execution_settings=settings, + ) + mock_parse.assert_called_once_with( + "dummy.yaml", + enable_file_ref_resolution=True, + enable_http_ref_resolution=False, + ) diff --git a/python/uv.lock b/python/uv.lock index 10b500f8c457..44c249e89981 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '4' and sys_platform == 'darwin'",