Skip to content

Commit 838d5b8

Browse files
Support Rails-style deepObject query param
Updates the data models to capture `style` and `explode` parameters. There was a PR outstanding that went in this direction [1]. We might want to rebase on top of that one day, when it's merged. For now we settle for some more targeted changes. Updates "ModelProperty" and related templates with a `to_deep_dict()` serialization option. This option differs from the regular `to_dict()` in that nested dicts are flattened. It also adds a `[]` suffix for keys that point at arrays. Finally, it takes a "prefix" argument that is the base name of this object. The end result of all this is that we can have a deepObject query param named `filter` that results in some `FooFilter` model param. We can initialize it as ```python FooFilter.from_dict({'owner': { 'id': [12, 34] }}) ``` then invoke its `to_deep_dict()` method to get ```python { 'filter[owner][id][]': [12, 34] } ``` which, when passed in to `httpx`, will result in a query param like ``` ?filter[owner][id][]=12&filter[owner][id][]=34 ``` which is exactly the format that APIv2 expects. This is not a coincidence. We're doing this specifically to support APIv2. Unfortunately, I don't see a straightforward way to polish and upstream this change. The serialization conventions above are "Rails-style" [2], one of many possible interpretations of how an OpenAPI deepObject should act. The spec doesn't actually specify very much here. So, while this is technically not *against* the spec, this is probably not the interpretation that a Python generator would go with by default. (Maybe one day it could be configurable? Or just wait and see if OpenAPI delivers on its promise to fix all this?) I feel like "fork" is the best option for us for now. Since I don't expect this to be upstreamed, I haven't put much effort into updating test suites. I expect that would only make later rebases more difficult, without actually testing what we want most, which is "do the generated libraries work with our API?" [1] openapi-generators#1296 [2] OAI/OpenAPI-Specification#1706 (comment)
1 parent a289533 commit 838d5b8

File tree

5 files changed

+58
-0
lines changed

5 files changed

+58
-0
lines changed

openapi_python_client/parser/openapi.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,8 @@ def add_parameters(
282282
schemas=schemas,
283283
parent_name=endpoint.name,
284284
config=config,
285+
explode=param.explode,
286+
style=param.style,
285287
)
286288

287289
if isinstance(prop, ParseError):

openapi_python_client/parser/properties/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ def property_from_data( # noqa: PLR0911, PLR0912
147147
config: Config,
148148
process_properties: bool = True,
149149
roots: set[ReferencePath | utils.ClassName] | None = None,
150+
explode: bool | None = None,
151+
style: str | None = None,
150152
) -> tuple[Property | PropertyError, Schemas]:
151153
"""Generate a Property from the OpenAPI dictionary representation of it"""
152154
roots = roots or set()
@@ -297,6 +299,8 @@ def property_from_data( # noqa: PLR0911, PLR0912
297299
config=config,
298300
process_properties=process_properties,
299301
roots=roots,
302+
explode=explode,
303+
style=style,
300304
)
301305
return (
302306
AnyProperty.build(

openapi_python_client/parser/properties/model_property.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ class ModelProperty(PropertyProtocol):
2727
data: oai.Schema
2828
description: str
2929
roots: set[ReferencePath | utils.ClassName]
30+
explode: bool | None
31+
style: str | None
32+
3033
required_properties: list[Property] | None
3134
optional_properties: list[Property] | None
3235
relative_imports: set[str] | None
@@ -46,6 +49,8 @@ def build(
4649
name: str,
4750
schemas: Schemas,
4851
required: bool,
52+
explode: bool | None,
53+
style: str | None,
4954
parent_name: str | None,
5055
config: Config,
5156
process_properties: bool,
@@ -108,6 +113,8 @@ def build(
108113
description=data.description or "",
109114
default=None,
110115
required=required,
116+
explode=explode,
117+
style=style,
111118
name=name,
112119
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
113120
example=data.example,

openapi_python_client/templates/model.py.jinja

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,47 @@ return field_dict
147147
{% endfor %}
148148
{{ _to_dict() | indent(8) }}
149149

150+
{%if model.explode and model.style == "deepObject" %}
151+
def _deep_explode(self, params, *, prefix) -> dict[str, Any]:
152+
"""Unnests `dict` values.
153+
154+
Turns
155+
`{ 'a: { 'b': 'val' }, 'xy': [1, 2] }`
156+
into
157+
`{ 'prefix[a][b]': 'val', 'prefix[xy]': [1, 2] }`.
158+
"""
159+
result: dict[str, Any] = {}
160+
for key, value in params.items():
161+
if isinstance(value, dict):
162+
nested = self._deep_explode(value, prefix=f"{prefix}[{key}]")
163+
result.update(nested)
164+
else:
165+
result[f"{prefix}[{key}]"] = value
166+
return result
167+
168+
def _array_keys(self, params) -> dict[str, Any]:
169+
"""Adds `[]` suffix to key names where value is a `list`.
170+
171+
Turns
172+
`{ 'prefix[a][b]': 'val', 'prefix[xy]': [1, 2] }`.
173+
into
174+
`{ 'prefix[a][b]': 'val', 'prefix[xy][]': [1, 2] }`.
175+
"""
176+
result: dict[str, Any] = {}
177+
for key, value in params.items():
178+
if isinstance(value, list):
179+
result[f"{key}[]"] = value
180+
else:
181+
result[key] = value
182+
return result
183+
184+
def to_deep_dict(self) -> dict[str, Any]:
185+
# Embed the param name in the dict. We must do this before
186+
# we process the dict in order to get the correct result.
187+
return self._array_keys(self._deep_explode(self.to_dict(), prefix='{{ model.name }}'))
188+
189+
{% endif %}
190+
150191
{% if model.is_multipart_body %}
151192
def to_multipart(self) -> types.RequestFiles:
152193
files: types.RequestFiles = []

openapi_python_client/templates/property_templates/model_property.py.jinja

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
{% macro check_type_for_construct(property, source) %}isinstance({{ source }}, dict){% endmacro %}
1212

1313
{% macro transform(property, source, destination, declare_type=True) %}
14+
{% if property.style == "deepObject" %}
15+
{% set transformed = source + ".to_deep_dict()" %}
16+
{% else %}
1417
{% set transformed = source + ".to_dict()" %}
18+
{% endif %}
1519
{% set type_string = property.get_type_string(json=True) %}
1620
{% if property.required %}
1721
{{ destination }} = {{ transformed }}

0 commit comments

Comments
 (0)