Skip to content

Commit 3f0e2d5

Browse files
Python: Percent-encode OpenAPI path params & pin azure-search-documents (#13967)
Apply `urllib.parse.quote(safe='')` to path parameter values before substitution in `build_path()`, consistent with the .NET implementation which uses `HttpUtility.UrlEncode`. ### Changes - `rest_api_operation.py`: encode path parameter values per RFC 3986 - `test_sk_openapi.py`: add tests for encoded path parameters - `pyproject.toml` / `uv.lock`: pin `azure-search-documents >= 11.6.0b4, < 12.0.0` ### Pin azure-search-documents < 12.0.0 Version 12.0.0 removed the internal `_endpoint` attribute from `SearchIndexClient`, which breaks `AzureAISearchCollection` initialization at `azure_ai_search.py:158`. Pinning to `< 12.0.0` until the code is updated to use the new public API. ### Tests All existing and new `build_path` tests pass. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5900032 commit 3f0e2d5

4 files changed

Lines changed: 58 additions & 6 deletions

File tree

python/pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ aws = [
7373
azure = [
7474
"azure-ai-inference >= 1.0.0b6",
7575
"azure-core-tracing-opentelemetry >= 1.0.0b11",
76-
"azure-search-documents >= 11.6.0b4",
76+
"azure-search-documents >= 11.6.0b4, < 12.0.0",
7777
"azure-cosmos ~= 4.7"
7878
]
7979
chroma = [
@@ -118,7 +118,9 @@ ollama = [
118118
onnx = [
119119
# onnxruntime>=1.24.0 dropped Python 3.10 support; pin to last compatible version for 3.10.
120120
"onnxruntime==1.22.1; python_version == '3.10'",
121-
"onnxruntime>=1.24.3; python_version > '3.10'",
121+
# onnxruntime 1.26.0 has no macOS ARM64 wheel; pin to last compatible version.
122+
# https://github.com/microsoft/onnxruntime/issues/28441
123+
"onnxruntime>=1.24.3, <1.26.0; python_version > '3.10'",
122124
"onnxruntime-genai==0.9.0"
123125
]
124126
oracledb = [

python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import re
44
from typing import Any, Final
5-
from urllib.parse import ParseResult, ParseResultBytes, urlencode, urljoin, urlparse, urlunparse
5+
from urllib.parse import ParseResult, ParseResultBytes, quote, urlencode, urljoin, urlparse, urlunparse
66

77
from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import (
88
RestApiExpectedResponse,
@@ -288,7 +288,7 @@ def build_path(self, path_template: str, arguments: dict[str, Any]) -> str:
288288
f"required parameter of the operation - `{self.id}`."
289289
)
290290
continue
291-
path_template = path_template.replace(f"{{{parameter.name}}}", str(argument))
291+
path_template = path_template.replace(f"{{{parameter.name}}}", quote(str(argument), safe=""))
292292
return path_template
293293

294294
def build_query_string(self, arguments: dict[str, Any]) -> str:

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,56 @@ def test_build_path_with_optional_and_required_parameters():
412412
assert operation.build_path(operation.path, arguments) == expected_path
413413

414414

415+
def test_build_path_encodes_special_characters():
416+
parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)]
417+
operation = RestApiOperation(
418+
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters
419+
)
420+
# Characters like /, ?, #, and spaces must be percent-encoded to prevent traversal
421+
arguments = {"id": "foo/bar?q=1#frag data"}
422+
result = operation.build_path(operation.path, arguments)
423+
encoded_part = result.split("/resource/")[1]
424+
assert "/" not in encoded_part
425+
assert "?" not in encoded_part
426+
assert "#" not in encoded_part
427+
assert " " not in encoded_part
428+
# Python's quote(safe="") encodes all except unreserved chars (letters, digits, _, ., -, ~)
429+
assert result == "/resource/foo%2Fbar%3Fq%3D1%23frag%20data"
430+
431+
432+
def test_build_path_prevents_path_traversal():
433+
parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)]
434+
operation = RestApiOperation(
435+
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters
436+
)
437+
arguments = {"id": "../../admin"}
438+
result = operation.build_path(operation.path, arguments)
439+
# The slashes must be encoded so ../../admin becomes a single path segment, not a traversal
440+
assert result == "/resource/..%2F..%2Fadmin"
441+
442+
443+
def test_build_path_double_encodes_pre_encoded_values():
444+
"""Arguments must be raw/unencoded values. Pre-encoded values are double-encoded by design."""
445+
parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)]
446+
operation = RestApiOperation(
447+
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters
448+
)
449+
arguments = {"id": "hello%2Fworld"}
450+
result = operation.build_path(operation.path, arguments)
451+
# %2F in input becomes %252F — the % is encoded, preventing decode-based bypass
452+
assert result == "/resource/hello%252Fworld"
453+
454+
455+
def test_build_path_encodes_unicode_characters():
456+
parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)]
457+
operation = RestApiOperation(
458+
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters
459+
)
460+
arguments = {"id": "café résumé"}
461+
result = operation.build_path(operation.path, arguments)
462+
assert result == "/resource/caf%C3%A9%20r%C3%A9sum%C3%A9"
463+
464+
415465
def test_build_query_string_with_required_parameter():
416466
parameters = [
417467
RestApiParameter(name="query", type="string", location=RestApiParameterLocation.QUERY, is_required=True)

python/uv.lock

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

0 commit comments

Comments
 (0)