Skip to content
Merged
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 @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
194 changes: 194 additions & 0 deletions python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
1 change: 0 additions & 1 deletion python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading