Skip to content

Commit 9b58cf7

Browse files
committed
Draft refactoring. Added form\file processing
1 parent f0fd010 commit 9b58cf7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+3292
-414
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<p align="center">
1414
<img src="https://img.shields.io/github/license/mr-fatalyst/fastopenapi">
1515
<img src="https://github.com/mr-fatalyst/fastopenapi/actions/workflows/master.yml/badge.svg">
16-
<img src="https://codecov.io/gh/mr-fatalyst/fastopenapi/branch/master/graph/badge.svg?token=USHR1I0CJB">
16+
<img src="https://codecov.io/gh/mr-fatalyst/fastopenapi/branch/master/graph/badge.svg">
1717
<img src="https://img.shields.io/pypi/v/fastopenapi">
1818
<img src="https://img.shields.io/pypi/pyversions/fastopenapi">
1919
<img src="https://static.pepy.tech/badge/fastopenapi" alt="PyPI Downloads">

fastopenapi/core/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44
Header,
55
)
66
from fastopenapi.core.router import BaseRouter
7-
from fastopenapi.core.types import Response
7+
from fastopenapi.core.types import FileUpload, Response
88

9-
__all__ = ["BaseRouter", "Header", "Cookie", "Form", "Response"]
9+
__all__ = ["BaseRouter", "Header", "Cookie", "Form", "Response", "FileUpload"]

fastopenapi/core/types.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,63 @@
1+
import inspect
12
from typing import Any
23

34

5+
class FileUpload:
6+
"""Unified file upload container with framework-agnostic API"""
7+
8+
__slots__ = ("filename", "content_type", "size", "_file")
9+
10+
def __init__(
11+
self,
12+
filename: str,
13+
content_type: str | None = None,
14+
size: int | None = None,
15+
file: Any = None,
16+
):
17+
self.filename = filename
18+
self.content_type = content_type or "application/octet-stream"
19+
self.size = size
20+
self._file = file
21+
22+
@property
23+
def file(self) -> Any:
24+
"""Access native framework file object"""
25+
return self._file
26+
27+
def read(self) -> bytes:
28+
"""Read entire file content (sync frameworks)"""
29+
if isinstance(self._file, bytes):
30+
return self._file
31+
if hasattr(self._file, "read"):
32+
result = self._file.read()
33+
if isinstance(result, bytes):
34+
return result
35+
return result.encode("utf-8") if isinstance(result, str) else result
36+
raise NotImplementedError("File object does not support synchronous read")
37+
38+
async def aread(self) -> bytes:
39+
"""Read entire file content (async frameworks)"""
40+
if isinstance(self._file, bytes):
41+
return self._file
42+
if hasattr(self._file, "read"):
43+
result = self._file.read()
44+
if inspect.iscoroutine(result):
45+
data = await result
46+
if isinstance(data, bytes):
47+
return data
48+
return data.encode("utf-8") if isinstance(data, str) else data
49+
if isinstance(result, bytes):
50+
return result
51+
return result.encode("utf-8") if isinstance(result, str) else result
52+
raise NotImplementedError("File object does not support read")
53+
54+
def __repr__(self) -> str:
55+
return (
56+
f"FileUpload(filename={self.filename!r}, "
57+
f"content_type={self.content_type!r}, size={self.size})"
58+
)
59+
60+
461
class Response:
562
"""Custom response with headers"""
663

@@ -26,7 +83,7 @@ def __init__(
2683
cookies: dict[str, str] = None,
2784
body: Any = None,
2885
form_data: dict[str, Any] = None,
29-
files: dict[str, bytes] = None,
86+
files: dict[str, FileUpload | list[FileUpload]] = None,
3087
):
3188
self.path_params = path_params or {}
3289
self.query_params = query_params or {}

fastopenapi/response/builder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ def build(cls, result: Any, meta: dict) -> Response:
4040
def _serialize(cls, data: Any) -> Any:
4141
"""Serialize response data"""
4242
if isinstance(data, BaseModel):
43-
return data.model_dump(by_alias=True)
43+
return data.model_dump(by_alias=True, mode="json")
4444
if isinstance(data, list) and data and isinstance(data[0], BaseModel):
45-
return [item.model_dump(by_alias=True) for item in data]
45+
return [item.model_dump(by_alias=True, mode="json") for item in data]
4646
if isinstance(data, list):
4747
return [cls._serialize(item) for item in data]
4848
if isinstance(data, dict):

fastopenapi/routers/aiohttp/async_router.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,28 @@ async def _aiohttp_view(request: web.Request, router, endpoint: Callable):
3434

3535
def build_framework_response(self, response: Response) -> web.Response:
3636
"""Build AioHttp response"""
37+
content_type = response.headers.get("Content-Type", "application/json")
38+
39+
# Binary content
40+
if isinstance(response.content, bytes):
41+
return web.Response(
42+
body=response.content,
43+
status=response.status_code,
44+
headers={**response.headers, "Content-Type": content_type},
45+
)
46+
47+
# String non-JSON content (CSV, XML, plain text, etc.)
48+
if isinstance(response.content, str) and content_type not in [
49+
"application/json",
50+
"text/json",
51+
]:
52+
return web.Response(
53+
text=response.content,
54+
status=response.status_code,
55+
headers={**response.headers, "Content-Type": content_type},
56+
)
57+
58+
# JSON content (dict, list, None)
3759
return web.json_response(
3860
response.content, status=response.status_code, headers=response.headers
3961
)

fastopenapi/routers/aiohttp/extractors.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any
22

3+
from fastopenapi.core.types import FileUpload
34
from fastopenapi.routers.extractors import BaseAsyncRequestDataExtractor
45

56

@@ -42,15 +43,53 @@ async def _get_body(cls, request: Any) -> dict | list | None:
4243
async def _get_form_data(cls, request: Any) -> dict:
4344
"""Extract form data"""
4445
form_data = {}
46+
content_type = request.content_type or ""
4547

46-
if request.content_type == "multipart/form-data":
48+
if content_type == "application/x-www-form-urlencoded":
49+
post_data = await request.post()
50+
for key in post_data:
51+
values = (
52+
post_data.getall(key)
53+
if hasattr(post_data, "getall")
54+
else [post_data[key]]
55+
)
56+
form_data[key] = values[0] if len(values) == 1 else values
57+
58+
elif content_type == "multipart/form-data":
4759
reader = await request.multipart()
4860
async for part in reader:
61+
if part.filename:
62+
continue # Files handled separately
4963
form_data[part.name] = await part.text()
5064

5165
return form_data
5266

5367
@classmethod
54-
async def _get_files(cls, request: Any) -> dict[str, bytes]:
68+
async def _get_files(cls, request: Any) -> dict[str, FileUpload | list[FileUpload]]:
5569
"""Extract files from AioHTTP request"""
56-
return {}
70+
files = {}
71+
content_type = request.content_type or ""
72+
73+
if content_type == "multipart/form-data":
74+
reader = await request.multipart()
75+
async for part in reader:
76+
if not part.filename:
77+
continue
78+
79+
file_data = await part.read()
80+
file_upload = FileUpload(
81+
filename=part.filename,
82+
content_type=part.headers.get("Content-Type"),
83+
size=len(file_data),
84+
file=file_data,
85+
)
86+
87+
if part.name in files:
88+
if isinstance(files[part.name], list):
89+
files[part.name].append(file_upload)
90+
else:
91+
files[part.name] = [files[part.name], file_upload]
92+
else:
93+
files[part.name] = file_upload
94+
95+
return files

fastopenapi/routers/django/extractors.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pydantic_core import from_json
44

5+
from fastopenapi.core.types import FileUpload
56
from fastopenapi.routers.extractors import (
67
BaseAsyncRequestDataExtractor,
78
BaseRequestDataExtractor,
@@ -54,9 +55,23 @@ def _get_form_data(cls, request: Any) -> dict:
5455
return {}
5556

5657
@classmethod
57-
def _get_files(cls, request: Any) -> dict:
58-
"""Extract files"""
59-
return {}
58+
def _get_files(cls, request: Any) -> dict[str, FileUpload | list[FileUpload]]:
59+
"""Extract files from Django request"""
60+
files = {}
61+
if hasattr(request, "FILES"):
62+
for key in request.FILES.keys():
63+
file_list = request.FILES.getlist(key)
64+
uploads = []
65+
for uploaded_file in file_list:
66+
file_upload = FileUpload(
67+
filename=uploaded_file.name,
68+
content_type=uploaded_file.content_type,
69+
size=uploaded_file.size,
70+
file=uploaded_file,
71+
)
72+
uploads.append(file_upload)
73+
files[key] = uploads[0] if len(uploads) == 1 else uploads
74+
return files
6075

6176

6277
class DjangoAsyncRequestDataExtractor(
@@ -74,6 +89,6 @@ async def _get_form_data(cls, request: Any) -> dict:
7489
return super()._get_form_data(request)
7590

7691
@classmethod
77-
async def _get_files(cls, request: Any) -> dict:
92+
async def _get_files(cls, request: Any) -> dict[str, FileUpload | list[FileUpload]]:
7893
"""Extract files"""
7994
return super()._get_files(request)

fastopenapi/routers/django/sync_router.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,26 @@ def handle(self, req, **path_params): # pragma: no cover
7070

7171
def build_framework_response(self, response: Response) -> HttpResponse:
7272
"""Build Django response"""
73-
if response.content is None:
74-
http_response = HttpResponse(status=response.status_code)
73+
content_type = response.headers.get("Content-Type", "application/json")
74+
75+
# Binary content
76+
if isinstance(response.content, bytes):
77+
http_response = HttpResponse(
78+
content=response.content,
79+
status=response.status_code,
80+
content_type=content_type,
81+
)
82+
# String non-JSON content
83+
elif isinstance(response.content, str) and content_type not in [
84+
"application/json",
85+
"text/json",
86+
]:
87+
http_response = HttpResponse(
88+
content=response.content,
89+
status=response.status_code,
90+
content_type=content_type,
91+
)
92+
# JSON content
7593
else:
7694
http_response = HttpResponse(
7795
content=to_json(response.content).decode("utf-8"),
@@ -80,7 +98,8 @@ def build_framework_response(self, response: Response) -> HttpResponse:
8098
)
8199

82100
for key, value in response.headers.items():
83-
http_response[key] = value
101+
if key.lower() != "content-type":
102+
http_response[key] = value
84103

85104
return http_response
86105

fastopenapi/routers/extractors.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from pydantic_core import from_json
55

6-
from fastopenapi.core.types import RequestData
6+
from fastopenapi.core.types import FileUpload, RequestData
77
from fastopenapi.routers.common import RequestEnvelope
88

99

@@ -42,7 +42,7 @@ def _get_form_data(cls, request: Any) -> dict:
4242

4343
@classmethod
4444
@abstractmethod
45-
def _get_files(cls, request: Any) -> dict:
45+
def _get_files(cls, request: Any) -> dict[str, FileUpload | list[FileUpload]]:
4646
"""Extract files"""
4747

4848
@staticmethod
@@ -97,7 +97,7 @@ async def _get_form_data(cls, request: Any) -> dict:
9797

9898
@classmethod
9999
@abstractmethod
100-
async def _get_files(cls, request: Any) -> dict:
100+
async def _get_files(cls, request: Any) -> dict[str, FileUpload | list[FileUpload]]:
101101
"""Extract files"""
102102

103103
@classmethod

fastopenapi/routers/falcon/extractors.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pydantic_core import from_json
44

5+
from fastopenapi.core.types import FileUpload
56
from fastopenapi.routers.extractors import (
67
BaseAsyncRequestDataExtractor,
78
BaseRequestDataExtractor,
@@ -52,17 +53,46 @@ def _get_body(cls, request: Any) -> dict | list | None:
5253
@classmethod
5354
def _get_form_data(cls, request: Any) -> dict:
5455
"""Extract form data"""
55-
return {}
56+
form_data = {}
57+
ct = (request.content_type or "").lower()
58+
59+
if ct == "application/x-www-form-urlencoded":
60+
form_data = request.media or {}
61+
62+
return form_data
5663

5764
@classmethod
58-
def _get_files(cls, request: Any) -> dict:
59-
"""Extract files"""
65+
def _get_files(cls, request: Any) -> dict[str, FileUpload | list[FileUpload]]:
66+
"""Extract files from Falcon request (sync)"""
6067
files = {}
61-
if hasattr(request, "files"):
62-
for name, file in request.files.items():
63-
# Read file content into temporary file
64-
content = file.stream.read()
65-
files[name] = content
68+
ct = (request.content_type or "").lower()
69+
70+
if "multipart/form-data" in ct and hasattr(request, "get_media"):
71+
form = request.get_media()
72+
for part in form:
73+
filename = getattr(part, "secure_filename", None) or getattr(
74+
part, "filename", None
75+
)
76+
if not filename:
77+
continue
78+
79+
content = part.stream.read()
80+
file_upload = FileUpload(
81+
filename=filename,
82+
content_type=getattr(part, "content_type", None),
83+
size=len(content),
84+
file=content,
85+
)
86+
87+
field_name = getattr(part, "name", filename)
88+
if field_name in files:
89+
if isinstance(files[field_name], list):
90+
files[field_name].append(file_upload)
91+
else:
92+
files[field_name] = [files[field_name], file_upload]
93+
else:
94+
files[field_name] = file_upload
95+
6696
return files
6797

6898

@@ -89,6 +119,6 @@ async def _get_form_data(cls, request: Any) -> dict:
89119
return super()._get_form_data(request)
90120

91121
@classmethod
92-
async def _get_files(cls, request: Any) -> dict:
122+
async def _get_files(cls, request: Any) -> dict[str, FileUpload | list[FileUpload]]:
93123
"""Extract files"""
94124
return super()._get_files(request)

0 commit comments

Comments
 (0)