Skip to content

Commit 6d0bd82

Browse files
committed
fix file and form schema route function: fixed file and form schema route function parameter openapi docs when they go together
1 parent 5e01445 commit 6d0bd82

File tree

4 files changed

+132
-10
lines changed

4 files changed

+132
-10
lines changed

ellar/common/params/args/request_model.py

Lines changed: 22 additions & 10 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
@@ -92,22 +107,19 @@ def build_body_field(self) -> None:
92107
and not (
93108
body_resolvers[0].model_field.field_info.embed # type: ignore[attr-defined]
94109
and isinstance(
95-
body_resolvers[0].model_field.field_info, params.BodyFieldInfo # type: ignore[attr-defined]
110+
body_resolvers[0].model_field.field_info, params.BodyFieldInfo
96111
)
97112
)
98113
):
99-
check_file_field(body_resolvers[0].model_field) # type: ignore[attr-defined]
114+
check_file_field(body_resolvers[0].model_field)
100115
self.body_resolver = body_resolvers[0]
101116
elif body_resolvers:
102117
# 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-
)
107118
model_name = "body_" + self.operation_unique_id
108119
body_model_field: t.Type[BaseModel] = create_model(model_name)
109120
_fields_required, _body_param_class = [], {}
110-
for f in _body_resolvers_model_fields:
121+
122+
for f in self._get_body_resolver_model_fields(body_resolvers):
111123
f.field_info.embed = True # type:ignore[attr-defined]
112124
body_model_field.__fields__[f.name] = f
113125
_fields_required.append(f.required)
@@ -122,12 +134,12 @@ def build_body_field(self) -> None:
122134
media_type = "application/json"
123135
if len(_body_param_class) == 1:
124136
_, (klass, field_info) = _body_param_class.popitem()
125-
body_field_info = klass
137+
body_field_info = klass # type:ignore[assignment]
126138
media_type = getattr(field_info, "media_type", media_type)
127139
elif len(_body_param_class) > 1:
128140
key = sorted(_body_param_class.keys(), reverse=True)[0]
129141
klass, field_info = _body_param_class[key]
130-
body_field_info = klass
142+
body_field_info = klass # type:ignore[assignment]
131143
media_type = getattr(field_info, "media_type", media_type)
132144

133145
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: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,48 @@
4141
},
4242
}
4343
},
44+
"/form-with-schema-spreading": {
45+
"post": {
46+
"tags": ["default"],
47+
"operationId": "form_params_schema_spreading_form_with_schema_spreading_post",
48+
"requestBody": {
49+
"content": {
50+
"multipart/form-data": {
51+
"schema": {
52+
"title": "Body",
53+
"allOf": [
54+
{
55+
"$ref": "#/components/schemas/body_form_params_schema_spreading_form_with_schema_spreading_post"
56+
}
57+
],
58+
"include_in_schema": True,
59+
}
60+
}
61+
},
62+
"required": True,
63+
},
64+
"responses": {
65+
"200": {
66+
"description": "Successful Response",
67+
"content": {
68+
"application/json": {
69+
"schema": {"title": "Response Model", "type": "object"}
70+
}
71+
},
72+
},
73+
"422": {
74+
"description": "Validation Error",
75+
"content": {
76+
"application/json": {
77+
"schema": {
78+
"$ref": "#/components/schemas/HTTPValidationError"
79+
}
80+
}
81+
},
82+
},
83+
},
84+
}
85+
},
4486
"/mixed": {
4587
"post": {
4688
"tags": ["default"],
@@ -183,6 +225,12 @@
183225
}
184226
},
185227
},
228+
"Range": {
229+
"title": "Range",
230+
"enum": [20, 50, 200],
231+
"type": "integer",
232+
"description": "An enumeration.",
233+
},
186234
"ValidationError": {
187235
"title": "ValidationError",
188236
"required": ["loc", "msg", "type"],
@@ -197,6 +245,37 @@
197245
"type": {"title": "Error Type", "type": "string"},
198246
},
199247
},
248+
"body_form_params_schema_spreading_form_with_schema_spreading_post": {
249+
# form inputs are combined into one just like json body fields
250+
"title": "body_form_params_schema_spreading_form_with_schema_spreading_post",
251+
"required": ["momentOfTruth"],
252+
"type": "object",
253+
"properties": {
254+
"momentOfTruth": {
255+
"title": "Momentoftruth",
256+
"type": "string",
257+
"format": "binary",
258+
"include_in_schema": True,
259+
},
260+
"to": {
261+
"title": "To",
262+
"type": "string",
263+
"format": "date-time",
264+
"include_in_schema": True,
265+
},
266+
"from": {
267+
"title": "From",
268+
"type": "string",
269+
"format": "date-time",
270+
"include_in_schema": True,
271+
},
272+
"range": {
273+
"allOf": [{"$ref": "#/components/schemas/Range"}],
274+
"default": 20,
275+
"include_in_schema": True,
276+
},
277+
},
278+
},
200279
"body_form_upload_multiple_case_2_mixed_optional_post": {
201280
"title": "body_form_upload_multiple_case_2_mixed_optional_post",
202281
"type": "object",

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)