Skip to content

Commit 4e14809

Browse files
committed
Introduce handling for the explode property for arrays in OpenAPI parameter parsing
1 parent 75234ff commit 4e14809

File tree

13 files changed

+129
-5
lines changed

13 files changed

+129
-5
lines changed

end_to_end_tests/__snapshots__/test_end_to_end.ambr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969

7070
Path parameter must be required
7171

72-
Parameter(name='optional', param_in=<ParameterLocation.PATH: 'path'>, description=None, required=False, deprecated=False, allowEmptyValue=False, style=None, explode=False, allowReserved=False, param_schema=Schema(title=None, multipleOf=None, maximum=None, exclusiveMaximum=None, minimum=None, exclusiveMinimum=None, maxLength=None, minLength=None, pattern=None, maxItems=None, minItems=None, uniqueItems=None, maxProperties=None, minProperties=None, required=None, enum=None, const=None, type=<DataType.STRING: 'string'>, allOf=[], oneOf=[], anyOf=[], schema_not=None, items=None, prefixItems=[], properties=None, additionalProperties=None, description=None, schema_format=None, default=None, nullable=False, discriminator=None, readOnly=None, writeOnly=None, xml=None, externalDocs=None, example=None, deprecated=None), example=None, examples=None, content=None)
72+
Parameter(name='optional', param_in=<ParameterLocation.PATH: 'path'>, description=None, required=False, deprecated=False, allowEmptyValue=False, style=<Style.SIMPLE: 'simple'>, explode=False, allowReserved=False, param_schema=Schema(title=None, multipleOf=None, maximum=None, exclusiveMaximum=None, minimum=None, exclusiveMinimum=None, maxLength=None, minLength=None, pattern=None, maxItems=None, minItems=None, uniqueItems=None, maxProperties=None, minProperties=None, required=None, enum=None, const=None, type=<DataType.STRING: 'string'>, allOf=[], oneOf=[], anyOf=[], schema_not=None, items=None, prefixItems=[], properties=None, additionalProperties=None, description=None, schema_format=None, default=None, nullable=False, discriminator=None, readOnly=None, writeOnly=None, xml=None, externalDocs=None, example=None, deprecated=None), example=None, examples=None, content=None)
7373

7474
If you believe this was a mistake or this tool is missing a feature you need, please open an issue at https://github.com/openapi-generators/openapi-python-client/issues/new/choose
7575

end_to_end_tests/baseline_openapi_3.0.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,19 @@
149149
"name": "an_enum_value_with_only_null",
150150
"in": "query"
151151
},
152+
{
153+
"required": true,
154+
"schema": {
155+
"title": "Non exploded array",
156+
"type": "array",
157+
"items": {
158+
"type": "string"
159+
}
160+
},
161+
"name": "non_exploded_array",
162+
"in": "query",
163+
"explode": false
164+
},
152165
{
153166
"required": true,
154167
"schema": {

end_to_end_tests/baseline_openapi_3.1.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,19 @@ info:
145145
"name": "an_enum_value_with_only_null",
146146
"in": "query"
147147
},
148+
{
149+
"required": true,
150+
"schema": {
151+
"title": "Non exploded array",
152+
"type": "array",
153+
"items": {
154+
"type": "string"
155+
}
156+
},
157+
"name": "non_exploded_array",
158+
"in": "query",
159+
"explode": false
160+
},
148161
{
149162
"required": true,
150163
"schema": {

end_to_end_tests/functional_tests/generated_code_execution/test_docstrings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ def test_model_properties(self, MyModel):
111111
schema:
112112
type: boolean
113113
description: Do you want fries with that?
114+
- name: array
115+
in: query
116+
required: false
117+
schema:
118+
type: array
119+
items:
120+
type: string
114121
responses:
115122
"200":
116123
description: Success!
@@ -160,4 +167,5 @@ def test_params(self, get_attribute_by_index_sync):
160167
"id (str): Which one.",
161168
"index (int):",
162169
"fries (Union[Unset, bool]): Do you want fries with that?",
170+
"array (Union[Unset, list[str]]):",
163171
]

end_to_end_tests/golden-record/my_test_api_client/api/tests/get_user_list.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def _get_kwargs(
1818
an_enum_value: list[AnEnum],
1919
an_enum_value_with_null: list[Union[AnEnumWithNull, None]],
2020
an_enum_value_with_only_null: list[None],
21+
non_exploded_array: list[str],
2122
some_date: Union[datetime.date, datetime.datetime],
2223
) -> dict[str, Any]:
2324
params: dict[str, Any] = {}
@@ -44,6 +45,10 @@ def _get_kwargs(
4445

4546
params["an_enum_value_with_only_null"] = json_an_enum_value_with_only_null
4647

48+
json_non_exploded_array = non_exploded_array
49+
50+
params["non_exploded_array"] = ",".join(str(item) for item in json_non_exploded_array)
51+
4752
json_some_date: str
4853
if isinstance(some_date, datetime.date):
4954
json_some_date = some_date.isoformat()
@@ -106,6 +111,7 @@ def sync_detailed(
106111
an_enum_value: list[AnEnum],
107112
an_enum_value_with_null: list[Union[AnEnumWithNull, None]],
108113
an_enum_value_with_only_null: list[None],
114+
non_exploded_array: list[str],
109115
some_date: Union[datetime.date, datetime.datetime],
110116
) -> Response[Union[HTTPValidationError, list["AModel"]]]:
111117
"""Get List
@@ -116,6 +122,7 @@ def sync_detailed(
116122
an_enum_value (list[AnEnum]):
117123
an_enum_value_with_null (list[Union[AnEnumWithNull, None]]):
118124
an_enum_value_with_only_null (list[None]):
125+
non_exploded_array (list[str]):
119126
some_date (Union[datetime.date, datetime.datetime]):
120127
121128
Raises:
@@ -130,6 +137,7 @@ def sync_detailed(
130137
an_enum_value=an_enum_value,
131138
an_enum_value_with_null=an_enum_value_with_null,
132139
an_enum_value_with_only_null=an_enum_value_with_only_null,
140+
non_exploded_array=non_exploded_array,
133141
some_date=some_date,
134142
)
135143

@@ -146,6 +154,7 @@ def sync(
146154
an_enum_value: list[AnEnum],
147155
an_enum_value_with_null: list[Union[AnEnumWithNull, None]],
148156
an_enum_value_with_only_null: list[None],
157+
non_exploded_array: list[str],
149158
some_date: Union[datetime.date, datetime.datetime],
150159
) -> Optional[Union[HTTPValidationError, list["AModel"]]]:
151160
"""Get List
@@ -156,6 +165,7 @@ def sync(
156165
an_enum_value (list[AnEnum]):
157166
an_enum_value_with_null (list[Union[AnEnumWithNull, None]]):
158167
an_enum_value_with_only_null (list[None]):
168+
non_exploded_array (list[str]):
159169
some_date (Union[datetime.date, datetime.datetime]):
160170
161171
Raises:
@@ -171,6 +181,7 @@ def sync(
171181
an_enum_value=an_enum_value,
172182
an_enum_value_with_null=an_enum_value_with_null,
173183
an_enum_value_with_only_null=an_enum_value_with_only_null,
184+
non_exploded_array=non_exploded_array,
174185
some_date=some_date,
175186
).parsed
176187

@@ -181,6 +192,7 @@ async def asyncio_detailed(
181192
an_enum_value: list[AnEnum],
182193
an_enum_value_with_null: list[Union[AnEnumWithNull, None]],
183194
an_enum_value_with_only_null: list[None],
195+
non_exploded_array: list[str],
184196
some_date: Union[datetime.date, datetime.datetime],
185197
) -> Response[Union[HTTPValidationError, list["AModel"]]]:
186198
"""Get List
@@ -191,6 +203,7 @@ async def asyncio_detailed(
191203
an_enum_value (list[AnEnum]):
192204
an_enum_value_with_null (list[Union[AnEnumWithNull, None]]):
193205
an_enum_value_with_only_null (list[None]):
206+
non_exploded_array (list[str]):
194207
some_date (Union[datetime.date, datetime.datetime]):
195208
196209
Raises:
@@ -205,6 +218,7 @@ async def asyncio_detailed(
205218
an_enum_value=an_enum_value,
206219
an_enum_value_with_null=an_enum_value_with_null,
207220
an_enum_value_with_only_null=an_enum_value_with_only_null,
221+
non_exploded_array=non_exploded_array,
208222
some_date=some_date,
209223
)
210224

@@ -219,6 +233,7 @@ async def asyncio(
219233
an_enum_value: list[AnEnum],
220234
an_enum_value_with_null: list[Union[AnEnumWithNull, None]],
221235
an_enum_value_with_only_null: list[None],
236+
non_exploded_array: list[str],
222237
some_date: Union[datetime.date, datetime.datetime],
223238
) -> Optional[Union[HTTPValidationError, list["AModel"]]]:
224239
"""Get List
@@ -229,6 +244,7 @@ async def asyncio(
229244
an_enum_value (list[AnEnum]):
230245
an_enum_value_with_null (list[Union[AnEnumWithNull, None]]):
231246
an_enum_value_with_only_null (list[None]):
247+
non_exploded_array (list[str]):
232248
some_date (Union[datetime.date, datetime.datetime]):
233249
234250
Raises:
@@ -245,6 +261,7 @@ async def asyncio(
245261
an_enum_value=an_enum_value,
246262
an_enum_value_with_null=an_enum_value_with_null,
247263
an_enum_value_with_only_null=an_enum_value_with_only_null,
264+
non_exploded_array=non_exploded_array,
248265
some_date=some_date,
249266
)
250267
).parsed

openapi_python_client/parser/openapi.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ def add_parameters(
289289
schemas=schemas,
290290
parent_name=endpoint.name,
291291
config=config,
292+
explode=param.explode,
292293
)
293294

294295
if isinstance(prop, ParseError):

openapi_python_client/parser/properties/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ def property_from_data( # noqa: PLR0911, PLR0912
146146
config: Config,
147147
process_properties: bool = True,
148148
roots: set[ReferencePath | utils.ClassName] | None = None,
149+
explode: bool | None = None,
149150
) -> tuple[Property | PropertyError, Schemas]:
150151
"""Generate a Property from the OpenAPI dictionary representation of it"""
151152
roots = roots or set()
@@ -285,6 +286,7 @@ def property_from_data( # noqa: PLR0911, PLR0912
285286
config=config,
286287
process_properties=process_properties,
287288
roots=roots,
289+
explode=explode,
288290
)
289291
if data.type == oai.DataType.OBJECT or data.allOf or (data.type is None and data.properties):
290292
return ModelProperty.build(

openapi_python_client/parser/properties/list_property.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class ListProperty(PropertyProtocol):
2222
description: str | None
2323
example: str | None
2424
inner_property: PropertyProtocol
25+
explode: bool | None = None
2526
template: ClassVar[str] = "list_property.py.jinja"
2627

2728
@classmethod
@@ -36,6 +37,7 @@ def build(
3637
config: Config,
3738
process_properties: bool,
3839
roots: set[ReferencePath | utils.ClassName],
40+
explode: bool | None = None,
3941
) -> tuple[ListProperty | PropertyError, Schemas]:
4042
"""
4143
Build a ListProperty the right way, use this instead of the normal constructor.
@@ -51,6 +53,7 @@ def build(
5153
property data
5254
roots: The set of `ReferencePath`s and `ClassName`s to remove from the schemas if a child reference becomes
5355
invalid
56+
explode: Whether to use `explode` for array properties.
5457
5558
Returns:
5659
`(result, schemas)` where `schemas` is an updated version of the input named the same including any inner
@@ -98,6 +101,7 @@ def build(
98101
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
99102
description=data.description,
100103
example=data.example,
104+
explode=explode,
101105
),
102106
schemas,
103107
)

openapi_python_client/schema/openapi_schema_pydantic/header.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from pydantic import ConfigDict, Field
22

33
from ..parameter_location import ParameterLocation
4+
from ..style import Style
45
from .parameter import Parameter
56

67

@@ -20,6 +21,7 @@ class Header(Parameter):
2021

2122
name: str = Field(default="")
2223
param_in: ParameterLocation = Field(default=ParameterLocation.HEADER, alias="in")
24+
style: Style = Field(default=Style.SIMPLE)
2325
model_config = ConfigDict(
2426
# `Parameter` is not build yet, will rebuild in `__init__.py`:
2527
defer_build=True,

openapi_python_client/schema/openapi_schema_pydantic/parameter.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from typing import Any, Optional
22

3-
from pydantic import BaseModel, ConfigDict, Field
3+
from pydantic import BaseModel, ConfigDict, Field, model_validator
44

55
from ..parameter_location import ParameterLocation
6+
from ..style import Style
67
from .example import Example
78
from .media_type import MediaType
89
from .reference import ReferenceOr
@@ -27,13 +28,53 @@ class Parameter(BaseModel):
2728
required: bool = False
2829
deprecated: bool = False
2930
allowEmptyValue: bool = False
30-
style: Optional[str] = None
31-
explode: bool = False
31+
style: Optional[Style] = None
32+
explode: Optional[bool] = None
3233
allowReserved: bool = False
3334
param_schema: Optional[ReferenceOr[Schema]] = Field(default=None, alias="schema")
3435
example: Optional[Any] = None
3536
examples: Optional[dict[str, ReferenceOr[Example]]] = None
3637
content: Optional[dict[str, MediaType]] = None
38+
39+
@model_validator(mode='after')
40+
@classmethod
41+
def validate_dependencies(cls, model: "Parameter") -> "Parameter":
42+
param_in = model.param_in
43+
explode = model.explode
44+
45+
if model.style is None:
46+
if param_in in [ParameterLocation.PATH, ParameterLocation.HEADER]:
47+
model.style = Style.SIMPLE
48+
elif param_in in [ParameterLocation.QUERY, ParameterLocation.COOKIE]:
49+
model.style = Style.FORM
50+
51+
52+
# Validate style based on parameter location, not all combinations are valid.
53+
# https://swagger.io/docs/specification/v3_0/serialization/
54+
if param_in == ParameterLocation.PATH:
55+
if model.style not in (Style.SIMPLE, Style.LABEL, Style.MATRIX):
56+
raise ValueError(f"Invalid style '{model.style}' for path parameter")
57+
elif param_in == ParameterLocation.QUERY:
58+
if model.style not in (Style.FORM, Style.SPACE_DELIMITED, Style.PIPE_DELIMITED, Style.DEEP_OBJECT):
59+
raise ValueError(f"Invalid style '{model.style}' for query parameter")
60+
elif param_in == ParameterLocation.HEADER:
61+
if model.style != Style.SIMPLE:
62+
raise ValueError(f"Invalid style '{model.style}' for header parameter")
63+
elif param_in == ParameterLocation.COOKIE:
64+
if model.style != Style.FORM:
65+
raise ValueError(f"Invalid style '{model.style}' for cookie parameter")
66+
67+
68+
if explode is None:
69+
if model.style == Style.FORM:
70+
model.explode = True
71+
else:
72+
model.explode = False
73+
74+
return model
75+
76+
77+
3778
model_config = ConfigDict(
3879
# `MediaType` is not build yet, will rebuild in `__init__.py`:
3980
defer_build=True,

0 commit comments

Comments
 (0)