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 <[email protected]>
This commit is contained in:
SergeyMenshykh
2026-05-11 22:48:41 +00:00
committed by GitHub
co-authored by Copilot
parent 590003243a
commit 3f0e2d58fa
4 changed files with 58 additions and 6 deletions
+4 -2
View File
@@ -73,7 +73,7 @@ aws = [
azure = [
"azure-ai-inference >= 1.0.0b6",
"azure-core-tracing-opentelemetry >= 1.0.0b11",
"azure-search-documents >= 11.6.0b4",
"azure-search-documents >= 11.6.0b4, < 12.0.0",
"azure-cosmos ~= 4.7"
]
chroma = [
@@ -118,7 +118,9 @@ ollama = [
onnx = [
# onnxruntime>=1.24.0 dropped Python 3.10 support; pin to last compatible version for 3.10.
"onnxruntime==1.22.1; python_version == '3.10'",
"onnxruntime>=1.24.3; python_version > '3.10'",
# onnxruntime 1.26.0 has no macOS ARM64 wheel; pin to last compatible version.
# https://github.com/microsoft/onnxruntime/issues/28441
"onnxruntime>=1.24.3, <1.26.0; python_version > '3.10'",
"onnxruntime-genai==0.9.0"
]
oracledb = [
@@ -2,7 +2,7 @@
import re
from typing import Any, Final
from urllib.parse import ParseResult, ParseResultBytes, urlencode, urljoin, urlparse, urlunparse
from urllib.parse import ParseResult, ParseResultBytes, quote, urlencode, urljoin, urlparse, urlunparse
from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import (
RestApiExpectedResponse,
@@ -288,7 +288,7 @@ class RestApiOperation:
f"required parameter of the operation - `{self.id}`."
)
continue
path_template = path_template.replace(f"{{{parameter.name}}}", str(argument))
path_template = path_template.replace(f"{{{parameter.name}}}", quote(str(argument), safe=""))
return path_template
def build_query_string(self, arguments: dict[str, Any]) -> str:
@@ -412,6 +412,56 @@ def test_build_path_with_optional_and_required_parameters():
assert operation.build_path(operation.path, arguments) == expected_path
def test_build_path_encodes_special_characters():
parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)]
operation = RestApiOperation(
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters
)
# Characters like /, ?, #, and spaces must be percent-encoded to prevent traversal
arguments = {"id": "foo/bar?q=1#frag data"}
result = operation.build_path(operation.path, arguments)
encoded_part = result.split("/resource/")[1]
assert "/" not in encoded_part
assert "?" not in encoded_part
assert "#" not in encoded_part
assert " " not in encoded_part
# Python's quote(safe="") encodes all except unreserved chars (letters, digits, _, ., -, ~)
assert result == "/resource/foo%2Fbar%3Fq%3D1%23frag%20data"
def test_build_path_prevents_path_traversal():
parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)]
operation = RestApiOperation(
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters
)
arguments = {"id": "../../admin"}
result = operation.build_path(operation.path, arguments)
# The slashes must be encoded so ../../admin becomes a single path segment, not a traversal
assert result == "/resource/..%2F..%2Fadmin"
def test_build_path_double_encodes_pre_encoded_values():
"""Arguments must be raw/unencoded values. Pre-encoded values are double-encoded by design."""
parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)]
operation = RestApiOperation(
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters
)
arguments = {"id": "hello%2Fworld"}
result = operation.build_path(operation.path, arguments)
# %2F in input becomes %252F — the % is encoded, preventing decode-based bypass
assert result == "/resource/hello%252Fworld"
def test_build_path_encodes_unicode_characters():
parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)]
operation = RestApiOperation(
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters
)
arguments = {"id": "café résumé"}
result = operation.build_path(operation.path, arguments)
assert result == "/resource/caf%C3%A9%20r%C3%A9sum%C3%A9"
def test_build_query_string_with_required_parameter():
parameters = [
RestApiParameter(name="query", type="string", location=RestApiParameterLocation.QUERY, is_required=True)
+2 -2
View File
@@ -6480,7 +6480,7 @@ requires-dist = [
{ name = "azure-core-tracing-opentelemetry", marker = "extra == 'azure'", specifier = ">=1.0.0b11" },
{ name = "azure-cosmos", marker = "extra == 'azure'", specifier = "~=4.7" },
{ name = "azure-identity", specifier = ">=1.13" },
{ name = "azure-search-documents", marker = "extra == 'azure'", specifier = ">=11.6.0b4" },
{ name = "azure-search-documents", marker = "extra == 'azure'", specifier = ">=11.6.0b4,<12.0.0" },
{ name = "boto3", marker = "extra == 'aws'", specifier = ">=1.36.4,<1.41.0" },
{ name = "chromadb", marker = "extra == 'chroma'", specifier = ">=0.5,<1.4" },
{ name = "cloudevents", specifier = "~=1.0" },
@@ -6502,7 +6502,7 @@ requires-dist = [
{ name = "numpy", marker = "python_full_version >= '3.12'", specifier = ">=1.26.0" },
{ name = "ollama", marker = "extra == 'ollama'", specifier = "~=0.4" },
{ name = "onnxruntime", marker = "python_full_version == '3.10.*' and extra == 'onnx'", specifier = "==1.22.1" },
{ name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'onnx'", specifier = ">=1.24.3" },
{ name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'onnx'", specifier = ">=1.24.3,<1.26.0" },
{ name = "onnxruntime-genai", marker = "extra == 'onnx'", specifier = "==0.9.0" },
{ name = "openai", specifier = ">=2.0.0" },
{ name = "openapi-core", specifier = ">=0.18,<0.20" },