Skip to content

Commit 25e1270

Browse files
dbantymicha91
authored andcommitted
Fix nullable & required properties in multipart bodies (openapi-generators#995)
WIP Fix for openapi-generators#926 --------- Co-authored-by: Dylan Anthony <dbanty@users.noreply.github.com>
1 parent e6d2b1a commit 25e1270

18 files changed

+238
-71
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
default: patch
3+
---
4+
5+
# Fix nullable and required properties in multipart bodies
6+
7+
Fixes #926.
8+
9+
> [!WARNING]
10+
> This change is likely to break custom templates. Multipart body handling has been completely split from JSON bodies.

end_to_end_tests/baseline_openapi_3.0.json

+30-16
Original file line numberDiff line numberDiff line change
@@ -1466,7 +1466,9 @@
14661466
},
14671467
"/naming/mixed-case": {
14681468
"get": {
1469-
"tags": ["naming"],
1469+
"tags": [
1470+
"naming"
1471+
],
14701472
"operationId": "mixed_case",
14711473
"parameters": [
14721474
{
@@ -1487,30 +1489,32 @@
14871489
}
14881490
],
14891491
"responses": {
1490-
"200": {
1491-
"description": "Successful response",
1492-
"content": {
1493-
"application/json": {
1494-
"schema": {
1495-
"type": "object",
1496-
"properties": {
1497-
"mixed_case": {
1498-
"type": "string"
1499-
},
1500-
"mixedCase": {
1501-
"type": "string"
1502-
}
1492+
"200": {
1493+
"description": "Successful response",
1494+
"content": {
1495+
"application/json": {
1496+
"schema": {
1497+
"type": "object",
1498+
"properties": {
1499+
"mixed_case": {
1500+
"type": "string"
1501+
},
1502+
"mixedCase": {
1503+
"type": "string"
15031504
}
15041505
}
15051506
}
15061507
}
15071508
}
1509+
}
15081510
}
15091511
}
15101512
},
15111513
"/naming/{hyphen-in-path}": {
15121514
"get": {
1513-
"tags": ["naming"],
1515+
"tags": [
1516+
"naming"
1517+
],
15141518
"operationId": "hyphen_in_path",
15151519
"parameters": [
15161520
{
@@ -1914,7 +1918,8 @@
19141918
"required": [
19151919
"some_file",
19161920
"some_object",
1917-
"some_nullable_object"
1921+
"some_nullable_object",
1922+
"some_required_number"
19181923
],
19191924
"type": "object",
19201925
"properties": {
@@ -1947,6 +1952,15 @@
19471952
"title": "Some Number",
19481953
"type": "number"
19491954
},
1955+
"some_nullable_number": {
1956+
"title": "Some Nullable Number",
1957+
"type": "number",
1958+
"nullable": true
1959+
},
1960+
"some_required_number": {
1961+
"title": "Some Required Number",
1962+
"type": "number"
1963+
},
19501964
"some_array": {
19511965
"title": "Some Array",
19521966
"nullable": true,

end_to_end_tests/baseline_openapi_3.1.yaml

+10-1
Original file line numberDiff line numberDiff line change
@@ -1928,7 +1928,8 @@ info:
19281928
"required": [
19291929
"some_file",
19301930
"some_object",
1931-
"some_nullable_object"
1931+
"some_nullable_object",
1932+
"some_required_number"
19321933
],
19331934
"type": "object",
19341935
"properties": {
@@ -1961,6 +1962,14 @@ info:
19611962
"title": "Some Number",
19621963
"type": "number"
19631964
},
1965+
"some_nullable_number": {
1966+
"title": "Some Nullable Number",
1967+
"type": [ "number", "null" ]
1968+
},
1969+
"some_required_number": {
1970+
"title": "Some Number",
1971+
"type": "number"
1972+
},
19641973
"some_array": {
19651974
"title": "Some Array",
19661975
"type": [ "array", "null" ],

openapi_python_client/parser/properties/model_property.py

+22-8
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
from ... import Config, utils
99
from ... import schema as oai
10+
from ...utils import PythonIdentifier
1011
from ..errors import ParseError, PropertyError
12+
from .any import AnyProperty
1113
from .enum_property import EnumProperty
1214
from .protocol import PropertyProtocol, Value
1315
from .schemas import Class, ReferencePath, Schemas, parse_reference_path
@@ -30,7 +32,7 @@ class ModelProperty(PropertyProtocol):
3032
optional_properties: list[Property] | None
3133
relative_imports: set[str] | None
3234
lazy_imports: set[str] | None
33-
additional_properties: bool | Property | None
35+
additional_properties: Property | None
3436
_json_type_string: ClassVar[str] = "Dict[str, Any]"
3537

3638
template: ClassVar[str] = "model_property.py.jinja"
@@ -78,7 +80,7 @@ def build(
7880
optional_properties: list[Property] | None = None
7981
relative_imports: set[str] | None = None
8082
lazy_imports: set[str] | None = None
81-
additional_properties: bool | Property | None = None
83+
additional_properties: Property | None = None
8284
if process_properties:
8385
data_or_err, schemas = _process_property_data(
8486
data=data, schemas=schemas, class_info=class_info, config=config, roots=model_roots
@@ -386,25 +388,37 @@ def _add_if_no_conflict(new_prop: Property) -> PropertyError | None:
386388
)
387389

388390

391+
ANY_ADDITIONAL_PROPERTY = AnyProperty.build(
392+
name="additional",
393+
required=True,
394+
default=None,
395+
description="",
396+
python_name=PythonIdentifier(value="additional", prefix=""),
397+
example=None,
398+
)
399+
400+
389401
def _get_additional_properties(
390402
*,
391403
schema_additional: None | (bool | (oai.Reference | oai.Schema)),
392404
schemas: Schemas,
393405
class_name: utils.ClassName,
394406
config: Config,
395407
roots: set[ReferencePath | utils.ClassName],
396-
) -> tuple[bool | (Property | PropertyError), Schemas]:
408+
) -> tuple[Property | None | PropertyError, Schemas]:
397409
from . import property_from_data
398410

399411
if schema_additional is None:
400-
return True, schemas
412+
return ANY_ADDITIONAL_PROPERTY, schemas
401413

402414
if isinstance(schema_additional, bool):
403-
return schema_additional, schemas
415+
if schema_additional:
416+
return ANY_ADDITIONAL_PROPERTY, schemas
417+
return None, schemas
404418

405419
if isinstance(schema_additional, oai.Schema) and not any(schema_additional.model_dump().values()):
406420
# An empty schema
407-
return True, schemas
421+
return ANY_ADDITIONAL_PROPERTY, schemas
408422

409423
additional_properties, schemas = property_from_data(
410424
name="AdditionalProperty",
@@ -425,7 +439,7 @@ def _process_property_data(
425439
class_info: Class,
426440
config: Config,
427441
roots: set[ReferencePath | utils.ClassName],
428-
) -> tuple[tuple[_PropertyData, bool | Property] | PropertyError, Schemas]:
442+
) -> tuple[tuple[_PropertyData, Property | None] | PropertyError, Schemas]:
429443
property_data = _process_properties(
430444
data=data, schemas=schemas, class_name=class_info.name, config=config, roots=roots
431445
)
@@ -442,7 +456,7 @@ def _process_property_data(
442456
)
443457
if isinstance(additional_properties, PropertyError):
444458
return additional_properties, schemas
445-
elif isinstance(additional_properties, bool):
459+
elif additional_properties is None:
446460
pass
447461
else:
448462
property_data.relative_imports.update(additional_properties.get_imports(prefix=".."))

openapi_python_client/parser/properties/protocol.py

+2
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ def get_type_string(
107107
"""
108108
if json:
109109
type_string = self.get_base_json_type_string(quoted=quoted)
110+
elif multipart:
111+
type_string = "Tuple[None, bytes, str]"
110112
else:
111113
type_string = self.get_base_type_string(quoted=quoted)
112114

openapi_python_client/templates/endpoint_macros.py.jinja

+2-2
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
8383
{% macro multipart_body(body, destination) %}
8484
{% set property = body.prop %}
8585
{% import "property_templates/" + property.template as prop_template %}
86-
{% if prop_template.transform_multipart %}
87-
{{ prop_template.transform_multipart(property, property.python_name, destination) }}
86+
{% if prop_template.transform_multipart_body %}
87+
{{ prop_template.transform_multipart_body(property, property.python_name, destination) }}
8888
{% endif %}
8989
{% endmacro %}
9090

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% macro transform_multipart(property, source, destination) %}
2+
{% if not property.required %}
3+
{{ destination }} = {{source}} if isinstance({{source}}, Unset) else (None, str({{source}}).encode(), "text/plain")
4+
{% else %}
5+
{{ destination }} = (None, str({{ source }}).encode(), "text/plain")
6+
{% endif %}
7+
{% endmacro %}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
{% macro transform_header(source) %}
22
"true" if {{ source }} else "false"
33
{% endmacro %}
4+
5+
{% macro transform_multipart(property, source, destination) %}
6+
{% if not property.required %}
7+
{{ destination }} = {{source}} if isinstance({{source}}, Unset) else (None, str({{source}}).encode(), "text/plain")
8+
{% else %}
9+
{{ destination }} = (None, str({{ source }}).encode(), "text/plain")
10+
{% endif %}
11+
{% endmacro %}

openapi_python_client/templates/property_templates/date_property.py.jinja

+13-5
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,13 @@ isoparse({{ source }}).date()
1010

1111
{% macro check_type_for_construct(property, source) %}isinstance({{ source }}, str){% endmacro %}
1212

13-
{% macro transform(property, source, destination, declare_type=True, multipart=False) %}
13+
{% macro transform(property, source, destination, declare_type=True) %}
1414
{% set transformed = source + ".isoformat()" %}
15-
{% if multipart %}{# Multipart data must be bytes, not str #}
16-
{% set transformed = transformed + ".encode()" %}
17-
{% endif %}
1815
{% if property.required %}
1916
{{ destination }} = {{ transformed }}
2017
{%- else %}
2118
{% if declare_type %}
2219
{% set type_annotation = property.get_type_string(json=True) %}
23-
{% if multipart %}{% set type_annotation = type_annotation | replace("str", "bytes") %}{% endif %}
2420
{{ destination }}: {{ type_annotation }} = UNSET
2521
{% else %}
2622
{{ destination }} = UNSET
@@ -29,3 +25,15 @@ if not isinstance({{ source }}, Unset):
2925
{{ destination }} = {{ transformed }}
3026
{%- endif %}
3127
{% endmacro %}
28+
29+
{% macro transform_multipart(property, source, destination) %}
30+
{% set transformed = source + ".isoformat().encode()" %}
31+
{% if property.required %}
32+
{{ destination }} = {{ transformed }}
33+
{%- else %}
34+
{% set type_annotation = property.get_type_string(json=True) | replace("str", "bytes") %}
35+
{{ destination }}: {{ type_annotation }} = UNSET
36+
if not isinstance({{ source }}, Unset):
37+
{{ destination }} = {{ transformed }}
38+
{%- endif %}
39+
{% endmacro %}

openapi_python_client/templates/property_templates/datetime_property.py.jinja

+13-5
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,13 @@ isoparse({{ source }})
1010

1111
{% macro check_type_for_construct(property, source) %}isinstance({{ source }}, str){% endmacro %}
1212

13-
{% macro transform(property, source, destination, declare_type=True, multipart=False) %}
13+
{% macro transform(property, source, destination, declare_type=True) %}
1414
{% set transformed = source + ".isoformat()" %}
15-
{% if multipart %}{# Multipart data must be bytes, not str #}
16-
{% set transformed = transformed + ".encode()" %}
17-
{% endif %}
1815
{% if property.required %}
1916
{{ destination }} = {{ transformed }}
2017
{%- else %}
2118
{% if declare_type %}
2219
{% set type_annotation = property.get_type_string(json=True) %}
23-
{% if multipart %}{% set type_annotation = type_annotation | replace("str", "bytes") %}{% endif %}
2420
{{ destination }}: {{ type_annotation }} = UNSET
2521
{% else %}
2622
{{ destination }} = UNSET
@@ -29,3 +25,15 @@ if not isinstance({{ source }}, Unset):
2925
{{ destination }} = {{ transformed }}
3026
{%- endif %}
3127
{% endmacro %}
28+
29+
{% macro transform_multipart(property, source, destination) %}
30+
{% set transformed = source + ".isoformat().encode()" %}
31+
{% if property.required %}
32+
{{ destination }} = {{ transformed }}
33+
{%- else %}
34+
{% set type_annotation = property.get_type_string(json=True) | replace("str", "bytes") %}
35+
{{ destination }}: {{ type_annotation }} = UNSET
36+
if not isinstance({{ source }}, Unset):
37+
{{ destination }} = {{ transformed }}
38+
{%- endif %}
39+
{% endmacro %}

openapi_python_client/templates/property_templates/enum_property.py.jinja

+12-4
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,6 @@
1313
{% macro transform(property, source, destination, declare_type=True, multipart=False) %}
1414
{% set transformed = source + ".value" %}
1515
{% set type_string = property.get_type_string(json=True) %}
16-
{% if multipart %}
17-
{% set transformed = "(None, str(" + transformed + ").encode(), \"text/plain\")" %}
18-
{% set type_string = "Union[Unset, Tuple[None, bytes, str]]" %}
19-
{% endif %}
2016
{% if property.required %}
2117
{{ destination }} = {{ transformed }}
2218
{%- else %}
@@ -26,6 +22,18 @@ if not isinstance({{ source }}, Unset):
2622
{% endif %}
2723
{% endmacro %}
2824

25+
{% macro transform_multipart(property, source, destination) %}
26+
{% set transformed = "(None, str(" + source + ".value" + ").encode(), \"text/plain\")" %}
27+
{% set type_string = "Union[Unset, Tuple[None, bytes, str]]" %}
28+
{% if property.required %}
29+
{{ destination }} = {{ transformed }}
30+
{%- else %}
31+
{{ destination }}: {{ type_string }} = UNSET
32+
if not isinstance({{ source }}, Unset):
33+
{{ destination }} = {{ transformed }}
34+
{% endif %}
35+
{% endmacro %}
36+
2937
{% macro transform_header(source) %}
3038
str({{ source }})
3139
{% endmacro %}

openapi_python_client/templates/property_templates/file_property.py.jinja

+11-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ File(
1212

1313
{% macro check_type_for_construct(property, source) %}isinstance({{ source }}, bytes){% endmacro %}
1414

15-
{% macro transform(property, source, destination, declare_type=True, multipart=False) %}
15+
{% macro transform(property, source, destination, declare_type=True) %}
1616
{% if property.required %}
1717
{{ destination }} = {{ source }}.to_tuple()
1818
{% else %}
@@ -21,3 +21,13 @@ if not isinstance({{ source }}, Unset):
2121
{{ destination }} = {{ source }}.to_tuple()
2222
{% endif %}
2323
{% endmacro %}
24+
25+
{% macro transform_multipart(property, source, destination) %}
26+
{% if property.required %}
27+
{{ destination }} = {{ source }}.to_tuple()
28+
{% else %}
29+
{{ destination }}: {{ property.get_type_string(json=True) }} = UNSET
30+
if not isinstance({{ source }}, Unset):
31+
{{ destination }} = {{ source }}.to_tuple()
32+
{% endif %}
33+
{% endmacro %}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
{% macro transform_header(source) %}
22
str({{ source }})
33
{% endmacro %}
4+
5+
{% macro transform_multipart(property, source, destination) %}
6+
{% if not property.required %}
7+
{{ destination }} = {{source}} if isinstance({{source}}, Unset) else (None, str({{source}}).encode(), "text/plain")
8+
{% else %}
9+
{{ destination }} = (None, str({{ source }}).encode(), "text/plain")
10+
{% endif %}
11+
{% endmacro %}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
{% macro transform_header(source) %}
22
str({{ source }})
33
{% endmacro %}
4+
5+
{% macro transform_multipart(property, source, destination) %}
6+
{% if not property.required %}
7+
{{ destination }} = {{source}} if isinstance({{source}}, Unset) else (None, str({{source}}).encode(), "text/plain")
8+
{% else %}
9+
{{ destination }} = (None, str({{ source }}).encode(), "text/plain")
10+
{% endif %}
11+
{% endmacro %}

0 commit comments

Comments
 (0)