From 436fc1bb2ca5fb0e15add3bccd63b39d951ff9e2 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:25:14 +0200 Subject: [PATCH 1/2] fix(abstractions): handle map-style query parameters with None values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dict values used as map/dictionary query parameters (from an OpenAPI `type: object` with `additionalProperties` field) are now sanitized before being passed to StdUriTemplate. None values are silently dropped per RFC 6570 §2.3 "undefined" semantics, preventing a ValueError when StdUriTemplate tries to convert None to a string. An empty string must be set explicitly to send `key=`. Adds seven test cases covering map expansion, None-value omission, empty-string inclusion, empty dicts, all-None dicts, mixed scalar+map parameters, and the set_query_string_parameters_from_raw_object() path. Co-authored-by: Cursor --- .../kiota_abstractions/request_information.py | 9 +++ .../tests/test_request_information.py | 77 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/abstractions/kiota_abstractions/request_information.py b/packages/abstractions/kiota_abstractions/request_information.py index d4e1518b..47a4be98 100644 --- a/packages/abstractions/kiota_abstractions/request_information.py +++ b/packages/abstractions/kiota_abstractions/request_information.py @@ -309,6 +309,15 @@ def _get_sanitized_value(self, value: Any) -> Any: sanitized_value = temp_date_with_tz_info.isoformat("T") elif any([isinstance(value, UUID), isinstance(value, date), isinstance(value, time)]): sanitized_value = str(value) + elif isinstance(value, dict): + # Map-style query parameter: drop None entries per RFC 6570 §2.3 "undefined" + # semantics, then normalise remaining values. StdUriTemplate raises ValueError + # when it encounters None inside a map, so we strip them here. + sanitized_value = { + str(k): self._get_sanitized_value(v) + for k, v in value.items() + if v is not None + } return sanitized_value def _decode_uri_string(self, uri: Optional[str]) -> str: diff --git a/packages/abstractions/tests/test_request_information.py b/packages/abstractions/tests/test_request_information.py index 9cf3711a..901c262f 100644 --- a/packages/abstractions/tests/test_request_information.py +++ b/packages/abstractions/tests/test_request_information.py @@ -224,6 +224,79 @@ def test_sets_time_only_values_in_path_parameters(): request_info = RequestInformation(Method.GET, "https://example.com/daysFrom/{startDate}") request_info.path_parameters["startDate"] = datetime(year=2020, month=8, day=1, hour=0, minute=20, second=0, microsecond=0).time() assert request_info.url == "https://example.com/daysFrom/00%3A20%3A00" - - \ No newline at end of file + +def test_expands_map_query_parameter_as_individual_key_value_pairs(): + """Tests that a dict query parameter expands as individual key=value pairs via RFC 6570.""" + request_info = RequestInformation(Method.GET, "http://localhost/articles{?query*}") + request_info.query_parameters["query"] = { + "filter": "equals(published,true)", + "sort": "-createdAt", + } + url = request_info.url + assert "?" in url + assert "filter=equals%28published%2Ctrue%29" in url + assert "sort=-createdAt" in url + + +def test_map_query_parameter_none_values_are_omitted(): + """Tests that None values within a map query parameter are silently dropped.""" + request_info = RequestInformation(Method.GET, "http://localhost/articles{?query*}") + request_info.query_parameters["query"] = { + "include": "author", + "exclude": None, + } + url = request_info.url + assert "include=author" in url + assert "exclude" not in url + + +def test_map_query_parameter_empty_string_value_is_included(): + """Tests that an empty-string value in a map query parameter is preserved.""" + request_info = RequestInformation(Method.GET, "http://localhost/articles{?query*}") + request_info.query_parameters["query"] = {"search": ""} + url = request_info.url + assert "search=" in url + + +def test_empty_map_query_parameter_produces_no_query_string(): + """Tests that an empty dict query parameter produces no query string.""" + request_info = RequestInformation(Method.GET, "http://localhost/articles{?query*}") + request_info.query_parameters["query"] = {} + url = request_info.url + assert "?" not in url + + +def test_all_none_map_query_parameter_produces_no_query_string(): + """Tests that a dict with all None values produces no query string.""" + request_info = RequestInformation(Method.GET, "http://localhost/articles{?query*}") + request_info.query_parameters["query"] = {"a": None, "b": None} + url = request_info.url + assert "?" not in url + + +def test_mix_of_map_and_scalar_query_parameters(): + """Tests that map and scalar query parameters can coexist in the same URL.""" + request_info = RequestInformation(Method.GET, "http://localhost/articles{?query*,top}") + request_info.query_parameters["query"] = {"filter": "active"} + request_info.query_parameters["top"] = 5 + url = request_info.url + assert "filter=active" in url + assert "top=5" in url + + +def test_map_query_parameter_via_set_query_string_parameters_from_raw_object(): + """Tests that a dict field on a dataclass query params object is handled correctly.""" + from dataclasses import dataclass + from typing import Optional + + @dataclass + class MapQueryParams: + query: Optional[dict] = None + + request_info = RequestInformation(Method.GET, "http://localhost/articles{?query*}") + params = MapQueryParams(query={"filter": "equals(published,true)", "sort": "-createdAt"}) + request_info.set_query_string_parameters_from_raw_object(params) + url = request_info.url + assert "filter=equals%28published%2Ctrue%29" in url + assert "sort=-createdAt" in url \ No newline at end of file From 79db213456f3b150508d70a1c8bec6d5b99e18e2 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:07:54 +0200 Subject: [PATCH 2/2] fix(abstractions): fix yapf formatting and ignore None keys in map query parameters Address build check feedback by fixing yapf formatting in request_information.py and isort ordering in date_utils.py. Also ignore None keys when sanitizing map query parameters. Co-authored-by: Cursor --- .../kiota_abstractions/date_utils.py | 2 +- .../kiota_abstractions/request_information.py | 3 +-- .../tests/test_request_information.py | 20 ++++++++++++++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/abstractions/kiota_abstractions/date_utils.py b/packages/abstractions/kiota_abstractions/date_utils.py index 05e9572d..bd3b8930 100644 --- a/packages/abstractions/kiota_abstractions/date_utils.py +++ b/packages/abstractions/kiota_abstractions/date_utils.py @@ -1,6 +1,6 @@ -from sys import version_info as sys_version_info import re from datetime import datetime, time, timedelta +from sys import version_info as sys_version_info _ISO8601_DURATION_PATTERN = re.compile( "^P" # Duration P indicator diff --git a/packages/abstractions/kiota_abstractions/request_information.py b/packages/abstractions/kiota_abstractions/request_information.py index 47a4be98..9cc2d4d2 100644 --- a/packages/abstractions/kiota_abstractions/request_information.py +++ b/packages/abstractions/kiota_abstractions/request_information.py @@ -315,8 +315,7 @@ def _get_sanitized_value(self, value: Any) -> Any: # when it encounters None inside a map, so we strip them here. sanitized_value = { str(k): self._get_sanitized_value(v) - for k, v in value.items() - if v is not None + for k, v in value.items() if k is not None and v is not None } return sanitized_value diff --git a/packages/abstractions/tests/test_request_information.py b/packages/abstractions/tests/test_request_information.py index 901c262f..378e41d9 100644 --- a/packages/abstractions/tests/test_request_information.py +++ b/packages/abstractions/tests/test_request_information.py @@ -299,4 +299,22 @@ class MapQueryParams: request_info.set_query_string_parameters_from_raw_object(params) url = request_info.url assert "filter=equals%28published%2Ctrue%29" in url - assert "sort=-createdAt" in url \ No newline at end of file + assert "sort=-createdAt" in url + + +def test_map_query_parameter_with_none_key_or_varied_types(): + """Tests that None keys are ignored and varied value types are sanitized.""" + request_info = RequestInformation(Method.GET, "http://localhost/articles{?query*}") + request_info.query_parameters["query"] = { + "top": 10, + "active": True, + "enumVal": TestEnum.VALUE1, + "nullVal": None, + None: "ignoredNullKey", + } + url = request_info.url + assert "top=10" in url + assert "active=true" in url + assert "enumVal=value1" in url + assert "nullVal" not in url + assert "ignoredNullKey" not in url \ No newline at end of file