Skip to content

Commit 1db4d42

Browse files
committed
Add support for uploading files with multipart/form-data
Still needs some test coverage before it's ready to merge in.
1 parent b726d06 commit 1db4d42

29 files changed

+465
-28
lines changed

.run/run_fastapi.run.xml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<component name="ProjectRunConfigurationManager">
2+
<configuration default="false" name="run_fastapi" type="PythonConfigurationType" factoryName="Python">
3+
<module name="openapi-python-client" />
4+
<option name="INTERPRETER_OPTIONS" value="" />
5+
<option name="PARENT_ENVS" value="true" />
6+
<envs>
7+
<env name="PYTHONUNBUFFERED" value="1" />
8+
</envs>
9+
<option name="SDK_HOME" value="" />
10+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/tests/test_end_to_end/fastapi_app" />
11+
<option name="IS_MODULE_SDK" value="true" />
12+
<option name="ADD_CONTENT_ROOTS" value="true" />
13+
<option name="ADD_SOURCE_ROOTS" value="true" />
14+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
15+
<EXTENSION ID="net.ashald.envfile">
16+
<option name="IS_ENABLED" value="false" />
17+
<option name="IS_SUBST" value="false" />
18+
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
19+
<option name="IS_IGNORE_MISSING_FILES" value="false" />
20+
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
21+
<ENTRIES>
22+
<ENTRY IS_ENABLED="true" PARSER="runconfig" />
23+
</ENTRIES>
24+
</EXTENSION>
25+
<option name="SCRIPT_NAME" value="tests.test_end_to_end.fastapi_app" />
26+
<option name="PARAMETERS" value="" />
27+
<option name="SHOW_COMMAND_LINE" value="false" />
28+
<option name="EMULATE_TERMINAL" value="false" />
29+
<option name="MODULE_MODE" value="true" />
30+
<option name="REDIRECT_INPUT" value="false" />
31+
<option name="INPUT_FILE" value="" />
32+
<method v="2" />
33+
</configuration>
34+
</component>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<component name="ProjectRunConfigurationManager">
2+
<configuration default="false" name="regen_golden_master" type="PythonConfigurationType" factoryName="Python">
3+
<module name="openapi-python-client" />
4+
<option name="INTERPRETER_OPTIONS" value="" />
5+
<option name="PARENT_ENVS" value="true" />
6+
<envs>
7+
<env name="PYTHONUNBUFFERED" value="1" />
8+
</envs>
9+
<option name="SDK_HOME" value="" />
10+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/tests/test_end_to_end" />
11+
<option name="IS_MODULE_SDK" value="true" />
12+
<option name="ADD_CONTENT_ROOTS" value="true" />
13+
<option name="ADD_SOURCE_ROOTS" value="true" />
14+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
15+
<EXTENSION ID="net.ashald.envfile">
16+
<option name="IS_ENABLED" value="false" />
17+
<option name="IS_SUBST" value="false" />
18+
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
19+
<option name="IS_IGNORE_MISSING_FILES" value="false" />
20+
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
21+
<ENTRIES>
22+
<ENTRY IS_ENABLED="true" PARSER="runconfig" />
23+
</ENTRIES>
24+
</EXTENSION>
25+
<option name="SCRIPT_NAME" value="tests.test_end_to_end.regen_golden_master" />
26+
<option name="PARAMETERS" value="" />
27+
<option name="SHOW_COMMAND_LINE" value="false" />
28+
<option name="EMULATE_TERMINAL" value="false" />
29+
<option name="MODULE_MODE" value="true" />
30+
<option name="REDIRECT_INPUT" value="false" />
31+
<option name="INPUT_FILE" value="" />
32+
<method v="2" />
33+
</configuration>
34+
</component>

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
8+
## 0.4.0 - Unreleased
9+
### Additions
10+
- Add support for binary format strings (file payloads)
11+
- Add support for multipart/form bodies
12+
713
## 0.3.0 - 2020-04-25
814
### Additions
915
- Link to the GitHub repository from PyPI (#26). Thanks @theY4Kman!

openapi_python_client/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ def _build_models(self) -> None:
147147
models_init = models_dir / "__init__.py"
148148
imports = []
149149

150+
types_template = self.env.get_template("types.py")
151+
types_path = models_dir / "types.py"
152+
types_path.write_text(types_template.render())
153+
150154
model_template = self.env.get_template("model.pyi")
151155
for schema in self.openapi.schemas.values():
152156
module_path = models_dir / f"{schema.reference.module_name}.py"

openapi_python_client/openapi_parser/openapi.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class Endpoint:
6969
responses: List[Response] = field(default_factory=list)
7070
form_body_reference: Optional[Reference] = None
7171
json_body: Optional[Property] = None
72+
multipart_body_reference: Optional[Reference] = None
7273

7374
@staticmethod
7475
def parse_request_form_body(body: Dict[str, Any], /) -> Optional[Reference]:
@@ -79,6 +80,15 @@ def parse_request_form_body(body: Dict[str, Any], /) -> Optional[Reference]:
7980
return Reference.from_ref(form_body["schema"]["$ref"])
8081
return None
8182

83+
@staticmethod
84+
def parse_multipart_body(body: Dict[str, Any], /) -> Optional[Reference]:
85+
""" Return form_body_reference """
86+
body_content = body["content"]
87+
body = body_content.get("multipart/form-data")
88+
if body:
89+
return Reference.from_ref(body["schema"]["$ref"])
90+
return None
91+
8292
@staticmethod
8393
def parse_request_json_body(body: Dict[str, Any], /) -> Optional[Property]:
8494
""" Return json_body """
@@ -95,9 +105,12 @@ def _add_body(self, data: Dict[str, Any]) -> None:
95105

96106
self.form_body_reference = Endpoint.parse_request_form_body(data["requestBody"])
97107
self.json_body = Endpoint.parse_request_json_body(data["requestBody"])
108+
self.multipart_body_reference = Endpoint.parse_multipart_body(data["requestBody"])
98109

99110
if self.form_body_reference:
100111
self.relative_imports.add(import_string_from_reference(self.form_body_reference, prefix="..models"))
112+
if self.multipart_body_reference:
113+
self.relative_imports.add(import_string_from_reference(self.multipart_body_reference, prefix="..models"))
101114
if (
102115
self.json_body is not None
103116
and isinstance(self.json_body, (ReferenceListProperty, EnumListProperty, RefProperty, EnumProperty))

openapi_python_client/openapi_parser/properties.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from dataclasses import dataclass, field
2-
from typing import Any, ClassVar, Dict, List, Optional
2+
from typing import Any, ClassVar, Dict, List, Optional, Union
33

44
from openapi_python_client import utils
55

@@ -91,6 +91,16 @@ def transform(self) -> str:
9191
return f"{self.python_name}.isoformat()"
9292

9393

94+
@dataclass
95+
class FileProperty(Property):
96+
""" A property used for uploading files """
97+
98+
_type_string: ClassVar[str] = "File"
99+
100+
def transform(self) -> str:
101+
return f"{self.python_name}.to_tuple()"
102+
103+
94104
@dataclass
95105
class FloatProperty(Property):
96106
""" A property of type float """
@@ -246,6 +256,23 @@ class DictProperty(Property):
246256
}
247257

248258

259+
def _string_based_property(
260+
name: str, required: bool, data: Dict[str, Any]
261+
) -> Union[StringProperty, DateProperty, DateTimeProperty, FileProperty]:
262+
""" Construct a Property from the type "string" """
263+
string_format = data.get("format")
264+
if string_format is None:
265+
return StringProperty(name=name, default=data.get("default"), required=required, pattern=data.get("pattern"))
266+
if string_format == "date-time":
267+
return DateTimeProperty(name=name, required=required, default=data.get("default"))
268+
elif string_format == "date":
269+
return DateProperty(name=name, required=required, default=data.get("default"))
270+
elif string_format == "binary":
271+
return FileProperty(name=name, required=required, default=data.get("default"))
272+
else:
273+
raise ValueError(f'Unsupported string format:{data["format"]}')
274+
275+
249276
def property_from_dict(name: str, required: bool, data: Dict[str, Any]) -> Property:
250277
""" Generate a Property from the OpenAPI dictionary representation of it """
251278
if "enum" in data:
@@ -259,14 +286,7 @@ def property_from_dict(name: str, required: bool, data: Dict[str, Any]) -> Prope
259286
if "$ref" in data:
260287
return RefProperty(name=name, required=required, reference=Reference.from_ref(data["$ref"]), default=None)
261288
if data["type"] == "string":
262-
if "format" in data:
263-
if data.get("format") == "date-time":
264-
return DateTimeProperty(name=name, required=required, default=data.get("default"))
265-
elif data.get("format") == "date":
266-
return DateProperty(name=name, required=required, default=data.get("default"))
267-
else:
268-
raise ValueError(f'Unsupported string format:{data["format"]}')
269-
return StringProperty(name=name, default=data.get("default"), required=required, pattern=data.get("pattern"),)
289+
return _string_based_property(name=name, required=required, data=data)
270290
elif data["type"] == "number":
271291
return FloatProperty(name=name, default=data.get("default"), required=required)
272292
elif data["type"] == "integer":

openapi_python_client/templates/async_endpoint_module.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ async def {{ endpoint.name | snakecase }}(
2828
{% if endpoint.form_body_reference %}
2929
form_data: {{ endpoint.form_body_reference.class_name }},
3030
{% endif %}
31+
{# Multipart data if any #}
32+
{% if endpoint.multipart_body_reference %}
33+
multipart_data: {{ endpoint.multipart_body_reference.class_name }},
34+
{% endif %}
3135
{# JSON body if any #}
3236
{% if endpoint.json_body %}
3337
json_body: {{ endpoint.json_body.get_type_string() }},
@@ -72,6 +76,10 @@ async def {{ endpoint.name | snakecase }}(
7276
{% if endpoint.form_body_reference %}
7377
data=asdict(form_data),
7478
{% endif %}
79+
80+
{% if endpoint.multipart_body_reference %}
81+
files=multipart_data.to_dict(),
82+
{% endif %}
7583
{% if endpoint.json_body %}
7684
json={{ endpoint.json_body.transform() }},
7785
{% endif %}

openapi_python_client/templates/endpoint_module.pyi

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ def {{ endpoint.name | snakecase }}(
2828
{% if endpoint.form_body_reference %}
2929
form_data: {{ endpoint.form_body_reference.class_name }},
3030
{% endif %}
31+
{# Multipart data if any #}
32+
{% if endpoint.multipart_body_reference %}
33+
multipart_data: {{ endpoint.multipart_body_reference.class_name }},
34+
{% endif %}
3135
{# JSON body if any #}
3236
{% if endpoint.json_body %}
3337
json_body: {{ endpoint.json_body.get_type_string() }},
@@ -70,6 +74,9 @@ def {{ endpoint.name | snakecase }}(
7074
headers=client.get_headers(),
7175
{% if endpoint.form_body_reference %}
7276
data=asdict(form_data),
77+
{% endif %}
78+
{% if endpoint.multipart_body_reference %}
79+
files=multipart_data.to_dict(),
7380
{% endif %}
7481
{% if endpoint.json_body %}
7582
json={{ endpoint.json_body.transform() }},

openapi_python_client/templates/model.pyi

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

3-
from dataclasses import dataclass
3+
from dataclasses import astuple, dataclass
44
from typing import Any, Dict, List, Optional, cast
55

6+
from .types import *
7+
68
{% for relative in schema.relative_imports %}
79
{{ relative }}
810
{% endfor %}

openapi_python_client/templates/models_init.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
{% for import in imports | sort %}
44
{{ import }}
55
{% endfor %}
6+
from .types import *

0 commit comments

Comments
 (0)