Skip to content

Commit 644eb0a

Browse files
Python: [Breaking] Update OpenAPI document parsing options (#14009)
Update OpenAPI document parsing to gate file and HTTP ref resolution separately. ### Breaking change - `RESOLVE_FILES` is no longer enabled by default. Only internal JSON pointer references are resolved by default. - Users with multi-file OpenAPI specs must now pass `enable_file_ref_resolution=True` via `OpenAPIFunctionExecutionParameters`. - `enable_external_ref_resolution` has been renamed to `enable_http_ref_resolution`. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 58f4a66 commit 644eb0a

6 files changed

Lines changed: 245 additions & 6 deletions

File tree

python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,23 @@ class OpenAPIFunctionExecutionParameters(KernelBaseModel):
3030
timeout: float | None = Field(
3131
None, description="Default timeout in seconds for HTTP requests. Uses httpx default (5 seconds) if None."
3232
)
33+
enable_file_ref_resolution: bool = Field(
34+
False,
35+
description=(
36+
"Whether to resolve local file $ref references when parsing OpenAPI documents. "
37+
"Disabled by default. When False, only internal JSON pointer references are resolved. "
38+
"Set to True if your OpenAPI spec is split across multiple local files and you trust "
39+
"the document source."
40+
),
41+
)
42+
enable_http_ref_resolution: bool = Field(
43+
False,
44+
description=(
45+
"Whether to resolve external HTTP $ref references when parsing OpenAPI documents. "
46+
"Disabled by default. Set to True only if you trust the OpenAPI document source "
47+
"and need external HTTP $ref resolution."
48+
),
49+
)
3350

3451
def model_post_init(self, __context: Any) -> None:
3552
"""Post initialization method for the model."""

python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ def create_functions_from_openapi(
5656

5757
# Parse the document from the given path
5858
parser = OpenApiParser()
59-
parsed_doc = parser.parse(openapi_document_path)
59+
parsed_doc = parser.parse(
60+
openapi_document_path,
61+
enable_file_ref_resolution=(execution_settings.enable_file_ref_resolution if execution_settings else False),
62+
enable_http_ref_resolution=(execution_settings.enable_http_ref_resolution if execution_settings else False),
63+
)
6064
if parsed_doc is None:
6165
raise FunctionExecutionException(f"Error parsing OpenAPI document: {openapi_document_path}")
6266

python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import TYPE_CHECKING, Any, Final
77

88
from prance import ResolvingParser
9+
from prance.util.resolver import RESOLVE_FILES, RESOLVE_HTTP, RESOLVE_INTERNAL
910

1011
from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import RestApiExpectedResponse
1112
from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation
@@ -44,9 +45,29 @@ class OpenApiParser:
4445
PAYLOAD_PROPERTIES_HIERARCHY_MAX_DEPTH: int = 10
4546
SUPPORTED_MEDIA_TYPES: Final[list[str]] = ["application/json", "text/plain"]
4647

47-
def parse(self, openapi_document: str) -> Any | dict[str, Any] | None:
48-
"""Parse the OpenAPI document."""
49-
parser = ResolvingParser(openapi_document)
48+
def parse(
49+
self,
50+
openapi_document: str,
51+
enable_file_ref_resolution: bool = False,
52+
enable_http_ref_resolution: bool = False,
53+
) -> Any | dict[str, Any] | None:
54+
"""Parse the OpenAPI document.
55+
56+
Args:
57+
openapi_document: The path or URL to the OpenAPI document.
58+
enable_file_ref_resolution: Whether to resolve local file $ref references.
59+
Disabled by default. When False, only internal JSON pointer references
60+
are resolved. Set to True if your OpenAPI spec is split across multiple
61+
local files.
62+
enable_http_ref_resolution: Whether to resolve external HTTP $ref references.
63+
Disabled by default.
64+
"""
65+
resolve_types = RESOLVE_INTERNAL
66+
if enable_file_ref_resolution:
67+
resolve_types |= RESOLVE_FILES
68+
if enable_http_ref_resolution:
69+
resolve_types |= RESOLVE_HTTP
70+
parser = ResolvingParser(openapi_document, resolve_types=resolve_types)
5071
return parser.specification
5172

5273
def _parse_parameters(self, parameters: list[dict[str, Any]]):

python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,11 @@ async def test_create_functions_from_openapi_raises_exception(mock_parse):
223223
with pytest.raises(FunctionExecutionException, match="Error parsing OpenAPI document: test_openapi_document_path"):
224224
create_functions_from_openapi(plugin_name="test_plugin", openapi_document_path="test_openapi_document_path")
225225

226-
mock_parse.assert_called_once_with("test_openapi_document_path")
226+
mock_parse.assert_called_once_with(
227+
"test_openapi_document_path",
228+
enable_file_ref_resolution=False,
229+
enable_http_ref_resolution=False,
230+
)
227231

228232

229233
async def test_run_operation_uses_timeout_from_run_options():

python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,197 @@ def test_no_operationid_raises_error():
189189
openapi_document_path=no_op_path,
190190
execution_settings=None,
191191
)
192+
193+
194+
def test_parse_blocks_external_http_refs_by_default():
195+
"""Verify that external HTTP $ref resolution is not enabled by default."""
196+
from unittest.mock import MagicMock, patch
197+
198+
from prance.util.resolver import RESOLVE_HTTP, RESOLVE_INTERNAL
199+
200+
with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
201+
mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
202+
parser = OpenApiParser()
203+
parser.parse("dummy_path.yaml")
204+
205+
mock_parser_cls.assert_called_once()
206+
call_kwargs = mock_parser_cls.call_args
207+
resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
208+
assert resolve_types == RESOLVE_INTERNAL, (
209+
f"Expected only RESOLVE_INTERNAL ({RESOLVE_INTERNAL}), got {resolve_types}"
210+
)
211+
assert not (resolve_types & RESOLVE_HTTP), "RESOLVE_HTTP should not be set by default"
212+
213+
214+
def test_parse_resolves_internal_refs_by_default(tmp_path):
215+
"""Verify that internal $ref references are still resolved by default."""
216+
openapi_spec = tmp_path / "spec_with_internal_ref.yaml"
217+
openapi_spec.write_text(
218+
"""
219+
openapi: 3.0.0
220+
info:
221+
title: Internal Ref Test
222+
version: 1.0.0
223+
servers:
224+
- url: http://example.com
225+
paths:
226+
/test:
227+
get:
228+
operationId: testOp
229+
responses:
230+
"200":
231+
description: ok
232+
content:
233+
application/json:
234+
schema:
235+
$ref: "#/components/schemas/TestSchema"
236+
components:
237+
schemas:
238+
TestSchema:
239+
type: object
240+
properties:
241+
name:
242+
type: string
243+
""",
244+
encoding="utf-8",
245+
)
246+
247+
parser = OpenApiParser()
248+
result = parser.parse(str(openapi_spec))
249+
250+
# Internal $ref should be resolved
251+
response_schema = result["paths"]["/test"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
252+
assert "$ref" not in response_schema, "Internal $ref should be resolved"
253+
assert response_schema["type"] == "object"
254+
assert "name" in response_schema["properties"]
255+
256+
257+
def test_parse_blocks_file_refs_by_default():
258+
"""Verify that local file $ref resolution is not enabled by default."""
259+
from unittest.mock import MagicMock, patch
260+
261+
from prance.util.resolver import RESOLVE_FILES, RESOLVE_INTERNAL
262+
263+
with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
264+
mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
265+
parser = OpenApiParser()
266+
parser.parse("dummy_path.yaml")
267+
268+
call_kwargs = mock_parser_cls.call_args
269+
resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
270+
assert resolve_types == RESOLVE_INTERNAL, (
271+
f"Expected only RESOLVE_INTERNAL ({RESOLVE_INTERNAL}), got {resolve_types}"
272+
)
273+
assert not (resolve_types & RESOLVE_FILES), "RESOLVE_FILES should not be set by default"
274+
275+
276+
def test_parse_enables_file_refs_when_requested():
277+
"""Verify that local file $ref resolution is enabled when explicitly requested."""
278+
from unittest.mock import MagicMock, patch
279+
280+
from prance.util.resolver import RESOLVE_FILES, RESOLVE_INTERNAL
281+
282+
with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
283+
mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
284+
parser = OpenApiParser()
285+
parser.parse("dummy_path.yaml", enable_file_ref_resolution=True)
286+
287+
call_kwargs = mock_parser_cls.call_args
288+
resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
289+
assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_FILES), (
290+
f"Expected RESOLVE_INTERNAL | RESOLVE_FILES, got {resolve_types}"
291+
)
292+
293+
294+
def test_parse_enables_http_refs_when_requested():
295+
"""Verify that HTTP $ref resolution is enabled when explicitly requested."""
296+
from unittest.mock import MagicMock, patch
297+
298+
from prance.util.resolver import RESOLVE_HTTP, RESOLVE_INTERNAL
299+
300+
with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
301+
mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
302+
parser = OpenApiParser()
303+
parser.parse("dummy_path.yaml", enable_http_ref_resolution=True)
304+
305+
call_kwargs = mock_parser_cls.call_args
306+
resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
307+
assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_HTTP), (
308+
f"Expected RESOLVE_INTERNAL | RESOLVE_HTTP, got {resolve_types}"
309+
)
310+
311+
312+
def test_parse_enables_both_file_and_http_refs_when_requested():
313+
"""Verify both file and HTTP $ref resolution work together."""
314+
from unittest.mock import MagicMock, patch
315+
316+
from prance.util.resolver import RESOLVE_FILES, RESOLVE_HTTP, RESOLVE_INTERNAL
317+
318+
with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
319+
mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
320+
parser = OpenApiParser()
321+
parser.parse("dummy_path.yaml", enable_file_ref_resolution=True, enable_http_ref_resolution=True)
322+
323+
call_kwargs = mock_parser_cls.call_args
324+
resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
325+
assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_FILES | RESOLVE_HTTP), (
326+
f"Expected RESOLVE_INTERNAL | RESOLVE_FILES | RESOLVE_HTTP, got {resolve_types}"
327+
)
328+
329+
330+
def test_create_functions_propagates_enable_http_ref_resolution():
331+
"""Verify enable_http_ref_resolution=True is propagated from settings to parser."""
332+
from unittest.mock import patch
333+
334+
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
335+
OpenAPIFunctionExecutionParameters,
336+
)
337+
338+
minimal_spec = {
339+
"openapi": "3.0.0",
340+
"info": {"title": "Test", "version": "1.0.0"},
341+
"paths": {},
342+
}
343+
344+
settings = OpenAPIFunctionExecutionParameters(enable_http_ref_resolution=True)
345+
346+
with patch.object(OpenApiParser, "parse", return_value=minimal_spec) as mock_parse:
347+
create_functions_from_openapi(
348+
plugin_name="testPlugin",
349+
openapi_document_path="dummy.yaml",
350+
execution_settings=settings,
351+
)
352+
mock_parse.assert_called_once_with(
353+
"dummy.yaml",
354+
enable_file_ref_resolution=False,
355+
enable_http_ref_resolution=True,
356+
)
357+
358+
359+
def test_create_functions_propagates_enable_file_ref_resolution():
360+
"""Verify enable_file_ref_resolution=True is propagated from settings to parser."""
361+
from unittest.mock import patch
362+
363+
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
364+
OpenAPIFunctionExecutionParameters,
365+
)
366+
367+
minimal_spec = {
368+
"openapi": "3.0.0",
369+
"info": {"title": "Test", "version": "1.0.0"},
370+
"paths": {},
371+
}
372+
373+
settings = OpenAPIFunctionExecutionParameters(enable_file_ref_resolution=True)
374+
375+
with patch.object(OpenApiParser, "parse", return_value=minimal_spec) as mock_parse:
376+
create_functions_from_openapi(
377+
plugin_name="testPlugin",
378+
openapi_document_path="dummy.yaml",
379+
execution_settings=settings,
380+
)
381+
mock_parse.assert_called_once_with(
382+
"dummy.yaml",
383+
enable_file_ref_resolution=True,
384+
enable_http_ref_resolution=False,
385+
)

python/uv.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)