Skip to content

Commit c95b307

Browse files
authored
Merge pull request #142 from python-ellar/file_form_schema_fix
fix file and form schema route function
2 parents 5e01445 + 21305cf commit c95b307

File tree

5 files changed

+207
-27
lines changed

5 files changed

+207
-27
lines changed

ellar/common/params/args/request_model.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@
55
from ellar.common.interfaces import IExecutionContext
66
from ellar.common.logger import logger
77
from pydantic import BaseModel, create_model
8+
from pydantic.fields import ModelField
89
from starlette.convertors import Convertor
910

1011
from .. import params
11-
from ..resolvers import BaseRouteParameterResolver
12+
from ..resolvers import (
13+
BulkFormParameterResolver,
14+
IRouteParameterResolver,
15+
RouteParameterModelField,
16+
)
1217
from .base import EndpointArgsModel
1318
from .extra_args import ExtraEndpointArg
1419

@@ -78,6 +83,16 @@ def __init__(
7883
)
7984
self.operation_unique_id = operation_unique_id
8085

86+
def _get_body_resolver_model_fields(
87+
self, body_resolvers: t.List[IRouteParameterResolver]
88+
) -> t.Generator[t.Union["RouteParameterModelField", ModelField], t.Any, None]:
89+
for resolver in body_resolvers:
90+
if isinstance(resolver, BulkFormParameterResolver):
91+
for form_resolver in resolver.resolvers:
92+
yield form_resolver.model_field
93+
else:
94+
yield resolver.model_field
95+
8196
def build_body_field(self) -> None:
8297
"""
8398
Group common body / form fields to one field
@@ -91,23 +106,17 @@ def build_body_field(self) -> None:
91106
and len(body_resolvers) == 1
92107
and not (
93108
body_resolvers[0].model_field.field_info.embed # type: ignore[attr-defined]
94-
and isinstance(
95-
body_resolvers[0].model_field.field_info, params.BodyFieldInfo # type: ignore[attr-defined]
96-
)
97109
)
98110
):
99-
check_file_field(body_resolvers[0].model_field) # type: ignore[attr-defined]
111+
check_file_field(body_resolvers[0].model_field)
100112
self.body_resolver = body_resolvers[0]
101113
elif body_resolvers:
102114
# if body_resolvers is more than one, we create a bulk_body_resolver instead
103-
_body_resolvers_model_fields = (
104-
t.cast(BaseRouteParameterResolver, item).model_field
105-
for item in body_resolvers
106-
)
107115
model_name = "body_" + self.operation_unique_id
108116
body_model_field: t.Type[BaseModel] = create_model(model_name)
109117
_fields_required, _body_param_class = [], {}
110-
for f in _body_resolvers_model_fields:
118+
119+
for f in self._get_body_resolver_model_fields(body_resolvers):
111120
f.field_info.embed = True # type:ignore[attr-defined]
112121
body_model_field.__fields__[f.name] = f
113122
_fields_required.append(f.required)
@@ -122,12 +131,12 @@ def build_body_field(self) -> None:
122131
media_type = "application/json"
123132
if len(_body_param_class) == 1:
124133
_, (klass, field_info) = _body_param_class.popitem()
125-
body_field_info = klass
134+
body_field_info = klass # type:ignore[assignment]
126135
media_type = getattr(field_info, "media_type", media_type)
127136
elif len(_body_param_class) > 1:
128137
key = sorted(_body_param_class.keys(), reverse=True)[0]
129138
klass, field_info = _body_param_class[key]
130-
body_field_info = klass
139+
body_field_info = klass # type:ignore[assignment]
131140
media_type = getattr(field_info, "media_type", media_type)
132141

133142
final_field = create_model_field(

ellar/common/params/resolvers/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class RouteParameterModelField(ModelField):
1414

1515

1616
class IRouteParameterResolver(ABC, metaclass=ABCMeta):
17+
model_field: t.Union[RouteParameterModelField, ModelField]
18+
1719
@abstractmethod
1820
@t.no_type_check
1921
async def resolve(self, *args: t.Any, **kwargs: t.Any) -> t.Tuple:

tests/test_routing/document_results.py

Lines changed: 122 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,54 @@
1010
"content": {
1111
"multipart/form-data": {
1212
"schema": {
13-
"title": "Test",
14-
"type": "string",
15-
"format": "binary",
13+
"title": "Body",
14+
"allOf": [
15+
{
16+
"$ref": "#/components/schemas/body_form_upload_single_case_1__post"
17+
}
18+
],
19+
"include_in_schema": True,
20+
}
21+
}
22+
},
23+
"required": True,
24+
},
25+
"responses": {
26+
"200": {
27+
"description": "Successful Response",
28+
"content": {
29+
"application/json": {
30+
"schema": {"title": "Response Model", "type": "object"}
31+
}
32+
},
33+
},
34+
"422": {
35+
"description": "Validation Error",
36+
"content": {
37+
"application/json": {
38+
"schema": {
39+
"$ref": "#/components/schemas/HTTPValidationError"
40+
}
41+
}
42+
},
43+
},
44+
},
45+
}
46+
},
47+
"/form-with-schema-spreading": {
48+
"post": {
49+
"tags": ["default"],
50+
"operationId": "form_params_schema_spreading_form_with_schema_spreading_post",
51+
"requestBody": {
52+
"content": {
53+
"multipart/form-data": {
54+
"schema": {
55+
"title": "Body",
56+
"allOf": [
57+
{
58+
"$ref": "#/components/schemas/body_form_params_schema_spreading_form_with_schema_spreading_post"
59+
}
60+
],
1661
"include_in_schema": True,
1762
}
1863
}
@@ -132,14 +177,12 @@
132177
"content": {
133178
"multipart/form-data": {
134179
"schema": {
135-
"title": "Test1",
136-
"type": "array",
137-
"items": {
138-
"anyOf": [
139-
{"type": "string", "format": "binary"},
140-
{"type": "string"},
141-
]
142-
},
180+
"title": "Body",
181+
"allOf": [
182+
{
183+
"$ref": "#/components/schemas/body_form_upload_multiple_case_1_multiple_post"
184+
}
185+
],
143186
"include_in_schema": True,
144187
}
145188
}
@@ -183,6 +226,12 @@
183226
}
184227
},
185228
},
229+
"Range": {
230+
"title": "Range",
231+
"enum": [20, 50, 200],
232+
"type": "integer",
233+
"description": "An enumeration.",
234+
},
186235
"ValidationError": {
187236
"title": "ValidationError",
188237
"required": ["loc", "msg", "type"],
@@ -197,6 +246,55 @@
197246
"type": {"title": "Error Type", "type": "string"},
198247
},
199248
},
249+
"body_form_params_schema_spreading_form_with_schema_spreading_post": {
250+
# form inputs are combined into one just like json body fields
251+
"title": "body_form_params_schema_spreading_form_with_schema_spreading_post",
252+
"required": ["momentOfTruth"],
253+
"type": "object",
254+
"properties": {
255+
"momentOfTruth": {
256+
"title": "Momentoftruth",
257+
"type": "string",
258+
"format": "binary",
259+
"include_in_schema": True,
260+
},
261+
"to": {
262+
"title": "To",
263+
"type": "string",
264+
"format": "date-time",
265+
"include_in_schema": True,
266+
},
267+
"from": {
268+
"title": "From",
269+
"type": "string",
270+
"format": "date-time",
271+
"include_in_schema": True,
272+
},
273+
"range": {
274+
"allOf": [{"$ref": "#/components/schemas/Range"}],
275+
"default": 20,
276+
"include_in_schema": True,
277+
},
278+
},
279+
},
280+
"body_form_upload_multiple_case_1_multiple_post": {
281+
"title": "body_form_upload_multiple_case_1_multiple_post",
282+
"required": ["test1"],
283+
"type": "object",
284+
"properties": {
285+
"test1": {
286+
"title": "Test1",
287+
"type": "array",
288+
"items": {
289+
"anyOf": [
290+
{"type": "string", "format": "binary"},
291+
{"type": "string"},
292+
]
293+
},
294+
"include_in_schema": True,
295+
}
296+
},
297+
},
200298
"body_form_upload_multiple_case_2_mixed_optional_post": {
201299
"title": "body_form_upload_multiple_case_2_mixed_optional_post",
202300
"type": "object",
@@ -220,6 +318,19 @@
220318
},
221319
},
222320
},
321+
"body_form_upload_single_case_1__post": {
322+
"title": "body_form_upload_single_case_1__post",
323+
"required": ["test"],
324+
"type": "object",
325+
"properties": {
326+
"test": {
327+
"title": "Test",
328+
"type": "string",
329+
"format": "binary",
330+
"include_in_schema": True,
331+
}
332+
},
333+
},
223334
"body_form_upload_single_case_2_mixed_post": {
224335
"title": "body_form_upload_single_case_2_mixed_post",
225336
"required": ["test_alias", "test2"],

tests/test_routing/test_form_schema.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,44 @@ def test_schema():
9393
params = document["paths"]["/form-schema"]["post"]["requestBody"]
9494
assert params == {
9595
"content": {
96-
"application/x-www-form-urlencoded": {
96+
"application/form-data": {
9797
"schema": {
98-
"allOf": [{"$ref": "#/components/schemas/Filter"}],
98+
"allOf": [
99+
{
100+
"$ref": "#/components/schemas/body_form_params_schema_form_schema_post"
101+
}
102+
],
99103
"include_in_schema": True,
100-
"title": "Will Not Work For Schema With Many Field",
104+
"title": "Body",
101105
}
102106
}
107+
}
108+
}
109+
schema = document["components"]["schemas"][
110+
"body_form_params_schema_form_schema_post"
111+
]
112+
assert schema == {
113+
"title": "body_form_params_schema_form_schema_post",
114+
"type": "object",
115+
"properties": {
116+
"to": {
117+
"title": "To",
118+
"type": "string",
119+
"format": "date-time",
120+
"include_in_schema": True,
121+
},
122+
"from": {
123+
"title": "From",
124+
"type": "string",
125+
"format": "date-time",
126+
"include_in_schema": True,
127+
},
128+
"range": {
129+
"allOf": [{"$ref": "#/components/schemas/Range"}],
130+
"default": 20,
131+
"include_in_schema": True,
132+
},
103133
},
104-
"required": True,
105134
}
106135

107136

tests/test_routing/test_formparsers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from starlette.formparsers import UploadFile as StarletteUploadFile
1010

1111
from .document_results import FORM_OPENAPI_DOC
12+
from .sample import Filter
1213

1314
router = ModuleRouter("")
1415

@@ -95,6 +96,14 @@ async def form_upload_multiple_case_2(
9596
}
9697

9798

99+
@router.post("/form-with-schema-spreading")
100+
def form_params_schema_spreading(
101+
file: File[UploadFile, File.P(alias="momentOfTruth")],
102+
filters: Filter = Form(..., alias="will_not_work_for_schema_with_many_field"),
103+
):
104+
return dict(filters.dict(), file_name=file.filename)
105+
106+
98107
tm = Test.create_test_module(routers=(router,))
99108

100109

@@ -123,6 +132,26 @@ def test_multipart_request_files(tmpdir):
123132
}
124133

125134

135+
def test_file_with_form_schema_combines_all_to_one_schema(tmpdir):
136+
path = os.path.join(tmpdir, "test.txt")
137+
with open(path, "wb") as file:
138+
file.write(b"<file content>")
139+
140+
client = tm.get_test_client()
141+
with open(path, "rb") as f:
142+
response = client.post(
143+
"/form-with-schema-spreading",
144+
data={"from": "1", "to": "2", "range": "50"},
145+
files={"momentOfTruth": ("test.txt", f, "text/plain")},
146+
)
147+
assert response.json() == {
148+
"file_name": "test.txt",
149+
"from_datetime": "1970-01-01T00:00:01+00:00",
150+
"range": 50,
151+
"to_datetime": "1970-01-01T00:00:02+00:00",
152+
}
153+
154+
126155
def test_multipart_request_files_with_content_type(tmpdir):
127156
path = os.path.join(tmpdir, "test.txt")
128157
with open(path, "wb") as file:

0 commit comments

Comments
 (0)