From 7928b31e59dd02abcef554050600160f7b80c5f9 Mon Sep 17 00:00:00 2001 From: Matthias Dellweg Date: Mon, 18 May 2026 11:39:41 +0200 Subject: [PATCH] Stringify objects when posting a form-encoded body --- CHANGES/pulp-glue/+deep_objects.bugfix | 1 + pulp-glue/src/pulp_glue/common/openapi.py | 14 ++- pulp-glue/src/pulp_glue/common/schema.py | 32 ++++++- pulp-glue/tests/test_openapi.py | 4 +- pulp-glue/tests/test_schema.py | 106 +++++++++++++++++++++- src/pulp_cli/generic.py | 11 ++- 6 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 CHANGES/pulp-glue/+deep_objects.bugfix diff --git a/CHANGES/pulp-glue/+deep_objects.bugfix b/CHANGES/pulp-glue/+deep_objects.bugfix new file mode 100644 index 000000000..a07e5e7ee --- /dev/null +++ b/CHANGES/pulp-glue/+deep_objects.bugfix @@ -0,0 +1 @@ +Fixed passing nested objects stringified in form-encoded body according to OAS3.1. diff --git a/pulp-glue/src/pulp_glue/common/openapi.py b/pulp-glue/src/pulp_glue/common/openapi.py index 53aafe538..29c4af2f1 100644 --- a/pulp-glue/src/pulp_glue/common/openapi.py +++ b/pulp-glue/src/pulp_glue/common/openapi.py @@ -29,7 +29,12 @@ ValidationError, ) from pulp_glue.common.i18n import get_translation -from pulp_glue.common.schema import encode_json, encode_param, validate +from pulp_glue.common.schema import ( + encode_json, + encode_param, + encode_stringify, + validate, +) translation = get_translation(__package__) _ = translation.gettext @@ -421,7 +426,10 @@ def _render_request_body( if content_type.startswith("application/json"): data = encode_json(body) elif content_type.startswith("application/x-www-form-urlencoded"): - data = body + if isinstance(body, dict): + data = {k: encode_stringify(v) for k, v in body.items()} + else: + data = encode_param(body) elif content_type.startswith("multipart/form-data"): data = {} files = {} @@ -436,7 +444,7 @@ def _render_request_body( "application/octet-stream", ) else: - data[key] = value + data[key] = encode_stringify(value) break else: # No known content-type left diff --git a/pulp-glue/src/pulp_glue/common/schema.py b/pulp-glue/src/pulp_glue/common/schema.py index abcb613f3..e51de9d96 100644 --- a/pulp-glue/src/pulp_glue/common/schema.py +++ b/pulp-glue/src/pulp_glue/common/schema.py @@ -30,18 +30,46 @@ def encode_json(o: t.Any) -> str: def encode_param(value: t.Any) -> t.Any: - if isinstance(value, list): - return [encode_param(item) for item in value] if isinstance(value, datetime.datetime): return value.strftime(ISO_DATETIME_FORMAT) elif isinstance(value, datetime.date): return value.strftime(ISO_DATE_FORMAT) elif isinstance(value, bool): return "true" if value else "false" + elif isinstance(value, (int, float)): + return str(value) + elif isinstance(value, list): + return [encode_param(item) for item in value] + elif isinstance(value, dict): + return {k: encode_param(v) for k, v in value.items()} else: return value +def encode_html(value: t.Any, *, key: str = "", sep: str = "") -> dict[str, str]: + # This is how html forms __can__ supply nested representations. + # According to openAPI 3.1 however they should default to json stringify. + value = encode_param(value) + res = {} + if isinstance(value, list): + for i, item in enumerate(value): + res.update(encode_html(item, key=key + f"[{i}]")) + elif isinstance(value, dict): + for k, v in value.items(): + res.update(encode_html(v, key=key + sep + k, sep=".")) + else: + res[key] = value + return res + + +def encode_stringify(value: str | dict[str, t.Any]) -> str: + if isinstance(value, (dict, list)): + value = encode_json(value) + elif not isinstance(value, str): + value = encode_param(value) + return value + + def _assert_type( name: str, value: t.Any, diff --git a/pulp-glue/tests/test_openapi.py b/pulp-glue/tests/test_openapi.py index 123afae8f..7594bb4fe 100644 --- a/pulp-glue/tests/test_openapi.py +++ b/pulp-glue/tests/test_openapi.py @@ -344,7 +344,7 @@ def test_references_is_implemented(self, mock_openapi: OpenAPI) -> None: res = mock_openapi._render_parameters(path_spec, method_spec, parameters) - assert res["query"] == {"limit": 2} + assert res["query"] == {"limit": "2"} def test_no_parameters_none_specified(self, mock_openapi: OpenAPI) -> None: parameters: dict[str, t.Any] = {} @@ -368,7 +368,7 @@ def test_provided_parameters_are_rendered(self, mock_openapi: OpenAPI) -> None: assert res == { "query": {"query1": "asdf"}, "header": {}, - "path": {"pk": 42}, + "path": {"pk": "42"}, "cookie": {}, } assert parameters == {"query1": "asdf", "pk": 42} diff --git a/pulp-glue/tests/test_schema.py b/pulp-glue/tests/test_schema.py index 70d9ea27a..114d2249b 100644 --- a/pulp-glue/tests/test_schema.py +++ b/pulp-glue/tests/test_schema.py @@ -8,8 +8,10 @@ from pulp_glue.common import oas from pulp_glue.common.exceptions import SchemaError, ValidationError from pulp_glue.common.schema import ( + encode_html, encode_json, encode_param, + encode_stringify, validate, ) @@ -414,10 +416,7 @@ def test_json_encoder_rejects_stream() -> None: @pytest.mark.parametrize( "value", - ( - pytest.param("asdf", id="string"), - pytest.param(42, id="integer"), - ), + (pytest.param("asdf", id="string"),), ) def test_encode_param_keeps(value: t.Any) -> None: assert encode_param(value) == value @@ -426,6 +425,7 @@ def test_encode_param_keeps(value: t.Any) -> None: @pytest.mark.parametrize( "value,expected", ( + pytest.param(42, "42", id="integer"), pytest.param(datetime.date(2000, 1, 1), "2000-01-01", id="date"), pytest.param( datetime.datetime(2000, 1, 1, 12, 30), "2000-01-01T12:30:00.000000Z", id="datetime" @@ -434,3 +434,101 @@ def test_encode_param_keeps(value: t.Any) -> None: ) def test_encode_param_transforms(value: t.Any, expected: t.Any) -> None: assert encode_param(value) == expected + + +@pytest.mark.parametrize( + "value,expected", + ( + pytest.param( + datetime.date(2000, 1, 1), + {"": "2000-01-01"}, + id="date", + ), + pytest.param( + {"a": datetime.datetime(2000, 1, 1, 12, 30)}, + {"a": "2000-01-01T12:30:00.000000Z"}, + id="datetime", + ), + pytest.param( + [1, 1, 12, 30], + {"[0]": "1", "[1]": "1", "[2]": "12", "[3]": "30"}, + id="list", + ), + pytest.param( + {"a": 1, "b": 2}, + {"a": "1", "b": "2"}, + id="dict", + ), + pytest.param( + {"o": {"a": 1, "b": 2}}, + {"o.a": "1", "o.b": "2"}, + id="nested_dict", + ), + pytest.param( + [{"a": 1, "b": 2}, {"c": 3}], + {"[0]a": "1", "[0]b": "2", "[1]c": "3"}, + id="list_of_dicts", + ), + pytest.param( + {"a": [1, 2], "b": [3, 4]}, + {"a[0]": "1", "a[1]": "2", "b[0]": "3", "b[1]": "4"}, + id="dict_of_lists", + ), + ), +) +def test_encode_html(value: t.Any, expected: t.Any) -> None: + assert encode_html(value) == expected + + +@pytest.mark.parametrize( + "value,expected", + ( + pytest.param( + "Hello, World!", + "Hello, World!", + id="string", + ), + pytest.param( + 4, + "4", + id="integer", + ), + pytest.param( + 3.14159, + "3.14159", + id="float", + ), + pytest.param( + False, + "false", + id="bool", + ), + pytest.param( + datetime.date(2000, 1, 1), + "2000-01-01", + id="date", + ), + pytest.param( + {"a": datetime.datetime(2000, 1, 1, 12, 30)}, + '{"a": "2000-01-01T12:30:00.000000Z"}', + id="datetime", + ), + pytest.param( + {"a": 1, "b": 2}, + '{"a": 1, "b": 2}', + id="dict", + ), + pytest.param( + {"o": {"a": 1, "b": 2}}, + '{"o": {"a": 1, "b": 2}}', + id="nested_dict", + ), + pytest.param( + {"a": [1, 2], "b": [3, 4]}, + '{"a": [1, 2], "b": [3, 4]}', + id="dict_of_lists", + ), + ), +) +def test_encode_stringify(value: t.Any, expected: str) -> None: + assert encode_stringify(value) == expected diff --git a/src/pulp_cli/generic.py b/src/pulp_cli/generic.py index a4f4ddfae..0374af0c0 100644 --- a/src/pulp_cli/generic.py +++ b/src/pulp_cli/generic.py @@ -860,12 +860,13 @@ def option_group( require_all: bool = True, expose_value: bool = True, ) -> t.Callable[[PulpCommand], PulpCommand]: + """ + Group a list of options into a group represented as a dictionary. + This allows to add a `callback` function for further processing. + `expose_value` allows to hide the value from the command callback. + """ + def _group_callback(ctx: click.Context) -> None: - """ - Group a list of options into a group represented as a dictionary. - This allows to add a `callback` function for further processing. - `expose_value` allows to hide the value from the command callback. - """ value = {k: v for k, v in ((k, ctx.params.pop(k, None)) for k in options) if v is not None} if value: if require_all and (missing_options := set(options) - set(value.keys())):