Skip to content

Commit ce4cc4e

Browse files
committed
file upload openapi doc fix: fixed upload upload schema generation
1 parent 5468f68 commit ce4cc4e

File tree

5 files changed

+464
-1
lines changed

5 files changed

+464
-1
lines changed

ellar/common/datastructures.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from starlette.datastructures import (
2525
URLPath,
2626
)
27+
from typing_extensions import Annotated, Doc # type: ignore[attr-defined]
2728

2829
__all__ = [
2930
"URL",
@@ -38,6 +39,46 @@
3839

3940

4041
class UploadFile(StarletteUploadFile):
42+
"""
43+
A file uploaded in a request.
44+
45+
Define it as a *path operation function* (or dependency) parameter.
46+
47+
## Example
48+
49+
```python
50+
51+
from ellar.common import ModuleRouter, File, UploadFile
52+
53+
router = ModuleRouter()
54+
55+
56+
@router.post("/files/")
57+
async def create_file(file: File[bytes]):
58+
return {"file_size": len(file)}
59+
60+
61+
@router.post("/uploadfile/")
62+
async def create_upload_file(file: UploadFile):
63+
return {"filename": file.filename}
64+
```
65+
"""
66+
67+
file: Annotated[
68+
t.BinaryIO,
69+
Doc("The standard Python file object (non-async)."),
70+
]
71+
filename: Annotated[t.Optional[str], Doc("The original file name.")]
72+
size: Annotated[t.Optional[int], Doc("The size of the file in bytes.")]
73+
headers: Annotated[Headers, Doc("The headers of the request.")]
74+
content_type: Annotated[
75+
t.Optional[str], Doc("The content type of the request, from the headers.")
76+
]
77+
78+
@classmethod
79+
def __modify_schema__(cls, field_schema: t.Dict[str, t.Any]) -> None:
80+
field_schema.update({"type": "string", "format": "binary"})
81+
4182
@classmethod
4283
def __get_validators__(
4384
cls: t.Type["UploadFile"],

ellar/common/params/args/request_model.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from ellar.common.constants import MULTI_RESOLVER_KEY
44
from ellar.common.helper.modelfield import create_model_field
55
from ellar.common.interfaces import IExecutionContext
6+
from ellar.common.logger import logger
67
from pydantic import BaseModel, create_model
78
from starlette.convertors import Convertor
89

@@ -11,6 +12,41 @@
1112
from .base import EndpointArgsModel
1213
from .extra_args import ExtraEndpointArg
1314

15+
multipart_not_installed_error = (
16+
'Form data requires "python-multipart" to be installed. \n'
17+
'You can install "python-multipart" with: \n\n'
18+
"pip install python-multipart\n"
19+
)
20+
multipart_incorrect_install_error = (
21+
'Form data requires "python-multipart" to be installed. '
22+
'It seems you installed "multipart" instead. \n'
23+
'You can remove "multipart" with: \n\n'
24+
"pip uninstall multipart\n\n"
25+
'And then install "python-multipart" with: \n\n'
26+
"pip install python-multipart\n"
27+
)
28+
29+
30+
def check_file_field(field: t.Any) -> None:
31+
field_info = field.field_info
32+
if isinstance(field_info, params.FormFieldInfo):
33+
try:
34+
# __version__ is available in both multiparts, and can be mocked
35+
from multipart import __version__
36+
37+
assert __version__
38+
try:
39+
# parse_options_header is only available in the right multipart
40+
from multipart.multipart import parse_options_header
41+
42+
assert parse_options_header
43+
except ImportError:
44+
logger.error(multipart_incorrect_install_error)
45+
raise RuntimeError(multipart_incorrect_install_error) from None
46+
except ImportError:
47+
logger.error(multipart_not_installed_error)
48+
raise RuntimeError(multipart_not_installed_error) from None
49+
1450

1551
class RequestEndpointArgsModel(EndpointArgsModel):
1652
__slots__ = (
@@ -60,6 +96,7 @@ def build_body_field(self) -> None:
6096
)
6197
)
6298
):
99+
check_file_field(body_resolvers[0].model_field) # type: ignore[attr-defined]
63100
self.body_resolver = body_resolvers[0]
64101
elif body_resolvers:
65102
# if body_resolvers is more than one, we create a bulk_body_resolver instead
@@ -107,6 +144,7 @@ def build_body_field(self) -> None:
107144
final_field.field_info = t.cast(
108145
params.ParamFieldInfo, final_field.field_info
109146
)
147+
check_file_field(final_field)
110148
self.body_resolver = final_field.field_info.create_resolver(final_field)
111149

112150
async def resolve_body(
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
FORM_OPENAPI_DOC = {
2+
"openapi": "3.0.2",
3+
"info": {"title": "Ellar API Docs", "version": "1.0.0"},
4+
"paths": {
5+
"/": {
6+
"post": {
7+
"tags": ["default"],
8+
"operationId": "form_upload_single_case_1__post",
9+
"requestBody": {
10+
"content": {
11+
"multipart/form-data": {
12+
"schema": {
13+
"title": "Test",
14+
"type": "string",
15+
"format": "binary",
16+
"include_in_schema": True,
17+
}
18+
}
19+
},
20+
"required": True,
21+
},
22+
"responses": {
23+
"200": {
24+
"description": "Successful Response",
25+
"content": {
26+
"application/json": {
27+
"schema": {"title": "Response Model", "type": "object"}
28+
}
29+
},
30+
},
31+
"422": {
32+
"description": "Validation Error",
33+
"content": {
34+
"application/json": {
35+
"schema": {
36+
"$ref": "#/components/schemas/HTTPValidationError"
37+
}
38+
}
39+
},
40+
},
41+
},
42+
}
43+
},
44+
"/mixed": {
45+
"post": {
46+
"tags": ["default"],
47+
"operationId": "form_upload_single_case_2_mixed_post",
48+
"requestBody": {
49+
"content": {
50+
"multipart/form-data": {
51+
"schema": {
52+
"title": "Body",
53+
"allOf": [
54+
{
55+
"$ref": "#/components/schemas/body_form_upload_single_case_2_mixed_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+
},
86+
"/mixed-optional": {
87+
"post": {
88+
"tags": ["default"],
89+
"operationId": "form_upload_multiple_case_2_mixed_optional_post",
90+
"requestBody": {
91+
"content": {
92+
"multipart/form-data": {
93+
"schema": {
94+
"title": "Body",
95+
"allOf": [
96+
{
97+
"$ref": "#/components/schemas/body_form_upload_multiple_case_2_mixed_optional_post"
98+
}
99+
],
100+
"include_in_schema": True,
101+
}
102+
}
103+
}
104+
},
105+
"responses": {
106+
"200": {
107+
"description": "Successful Response",
108+
"content": {
109+
"application/json": {
110+
"schema": {"title": "Response Model", "type": "object"}
111+
}
112+
},
113+
},
114+
"422": {
115+
"description": "Validation Error",
116+
"content": {
117+
"application/json": {
118+
"schema": {
119+
"$ref": "#/components/schemas/HTTPValidationError"
120+
}
121+
}
122+
},
123+
},
124+
},
125+
}
126+
},
127+
"/multiple": {
128+
"post": {
129+
"tags": ["default"],
130+
"operationId": "form_upload_multiple_case_1_multiple_post",
131+
"requestBody": {
132+
"content": {
133+
"multipart/form-data": {
134+
"schema": {
135+
"title": "Test1",
136+
"type": "array",
137+
"items": {
138+
"anyOf": [
139+
{"type": "string", "format": "binary"},
140+
{"type": "string"},
141+
]
142+
},
143+
"include_in_schema": True,
144+
}
145+
}
146+
},
147+
"required": True,
148+
},
149+
"responses": {
150+
"200": {
151+
"description": "Successful Response",
152+
"content": {
153+
"application/json": {
154+
"schema": {"title": "Response Model", "type": "object"}
155+
}
156+
},
157+
},
158+
"422": {
159+
"description": "Validation Error",
160+
"content": {
161+
"application/json": {
162+
"schema": {
163+
"$ref": "#/components/schemas/HTTPValidationError"
164+
}
165+
}
166+
},
167+
},
168+
},
169+
}
170+
},
171+
},
172+
"components": {
173+
"schemas": {
174+
"HTTPValidationError": {
175+
"title": "HTTPValidationError",
176+
"required": ["detail"],
177+
"type": "object",
178+
"properties": {
179+
"detail": {
180+
"title": "Details",
181+
"type": "array",
182+
"items": {"$ref": "#/components/schemas/ValidationError"},
183+
}
184+
},
185+
},
186+
"ValidationError": {
187+
"title": "ValidationError",
188+
"required": ["loc", "msg", "type"],
189+
"type": "object",
190+
"properties": {
191+
"loc": {
192+
"title": "Location",
193+
"type": "array",
194+
"items": {"type": "string"},
195+
},
196+
"msg": {"title": "Message", "type": "string"},
197+
"type": {"title": "Error Type", "type": "string"},
198+
},
199+
},
200+
"body_form_upload_multiple_case_2_mixed_optional_post": {
201+
"title": "body_form_upload_multiple_case_2_mixed_optional_post",
202+
"type": "object",
203+
"properties": {
204+
"file": {
205+
"title": "File",
206+
"type": "string",
207+
"format": "binary",
208+
"include_in_schema": True,
209+
},
210+
"field0": {
211+
"title": "Field0",
212+
"type": "string",
213+
"default": "",
214+
"include_in_schema": True,
215+
},
216+
"field1": {
217+
"title": "Field1",
218+
"type": "string",
219+
"include_in_schema": True,
220+
},
221+
},
222+
},
223+
"body_form_upload_single_case_2_mixed_post": {
224+
"title": "body_form_upload_single_case_2_mixed_post",
225+
"required": ["test_alias", "test2"],
226+
"type": "object",
227+
"properties": {
228+
"test_alias": {
229+
"title": "Test Alias",
230+
"type": "string",
231+
"format": "binary",
232+
"include_in_schema": True,
233+
},
234+
"test2": {
235+
"title": "Test2",
236+
"type": "string",
237+
"format": "binary",
238+
"include_in_schema": True,
239+
},
240+
},
241+
},
242+
}
243+
},
244+
"tags": [],
245+
}

tests/test_routing/test_formparsers.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
from typing import List, Union
44

55
import pytest
6-
from ellar.common import File, Form, ModuleRouter, UploadFile
6+
from ellar.common import File, Form, ModuleRouter, UploadFile, serialize_object
7+
from ellar.openapi import OpenAPIDocumentBuilder
78
from ellar.testing import Test
89
from starlette.formparsers import UploadFile as StarletteUploadFile
910

11+
from .document_results import FORM_OPENAPI_DOC
12+
1013
router = ModuleRouter("")
1114

1215

@@ -95,6 +98,13 @@ async def form_upload_multiple_case_2(
9598
tm = Test.create_test_module(routers=(router,))
9699

97100

101+
def test_open_api_schema_generation():
102+
document = serialize_object(
103+
OpenAPIDocumentBuilder().build_document(tm.create_application())
104+
)
105+
assert document == FORM_OPENAPI_DOC
106+
107+
98108
@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python >= 3.7")
99109
def test_multipart_request_files(tmpdir):
100110
path = os.path.join(tmpdir, "test.txt")

0 commit comments

Comments
 (0)