-
Notifications
You must be signed in to change notification settings - Fork 27
feat: Zig-native multipart/form-data parser for file upload parity #126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7bde1f4
39b7e8d
3198036
07b5d7b
560155f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -577,7 +577,6 @@ def format_json_response( | |
| return ResponseHandler.format_response(content, status_code, content_type) | ||
|
|
||
|
|
||
|
|
||
| _json_dumps = __import__("json").dumps | ||
|
|
||
|
|
||
|
|
@@ -636,6 +635,7 @@ def create_enhanced_handler(original_handler, route_definition): | |
| _param_names = set(sig.parameters.keys()) | ||
| _has_dependencies = False | ||
| _has_header_params = False | ||
| _has_form_params = False | ||
| from turboapi.datastructures import Header | ||
|
|
||
| try: | ||
|
|
@@ -644,17 +644,31 @@ def create_enhanced_handler(original_handler, route_definition): | |
| _has_security = True | ||
| except ImportError: | ||
| _has_security = False | ||
| try: | ||
| from turboapi.datastructures import File, Form | ||
| from turboapi.datastructures import UploadFile as _UploadFile | ||
|
|
||
| _has_form_types = True | ||
| except ImportError: | ||
| _has_form_types = False | ||
|
|
||
| for pname, param in sig.parameters.items(): | ||
| if isinstance(param.default, Header): | ||
| _has_header_params = True | ||
| elif _has_form_types and isinstance(param.default, (Form, File)): | ||
| _has_form_params = True | ||
| elif ( | ||
| _has_form_types | ||
| and param.annotation is _UploadFile | ||
| or (isinstance(param.annotation, type) and issubclass(param.annotation, _UploadFile)) | ||
| ): | ||
| _has_form_params = True | ||
| elif not ( | ||
| _has_security | ||
| and ( | ||
| isinstance(param.default, (Depends, SecurityBase)) | ||
| or get_depends(param) is not None | ||
| isinstance(param.default, (Depends, SecurityBase)) or get_depends(param) is not None | ||
| ) | ||
| ): | ||
| # Plain param (no explicit non-header marker) — may be an implicit header | ||
| _has_header_params = True | ||
| if _has_security and ( | ||
| isinstance(param.default, (Depends, SecurityBase)) or get_depends(param) is not None | ||
|
|
@@ -691,11 +705,66 @@ async def enhanced_handler(**kwargs): | |
| header_params = HeaderParser.parse_headers(headers_dict, sig) | ||
| parsed_params.update(header_params) | ||
|
|
||
| # 4. Parse request body (JSON) | ||
| # 3.5. Resolve Form / File / UploadFile parameters from Zig-parsed data | ||
| _form_fields = kwargs.get("form_fields", {}) | ||
| _file_fields = kwargs.get("file_fields", []) | ||
| if _has_form_params: | ||
| for pname, param in sig.parameters.items(): | ||
| if _has_form_types and isinstance(param.default, Form): | ||
| key = param.default.alias or pname | ||
| if key in _form_fields: | ||
| parsed_params[pname] = _form_fields[key] | ||
| elif param.default.default is not ...: | ||
| parsed_params[pname] = param.default.default | ||
| elif _has_form_types and isinstance(param.default, File): | ||
| key = param.default.alias or pname | ||
| matched = None | ||
| for ff in _file_fields: | ||
| if ff.get("name") == key: | ||
| matched = ff | ||
| break | ||
| if matched: | ||
| uf = _UploadFile( | ||
| filename=matched.get("filename"), | ||
| content_type=matched.get( | ||
| "content_type", "application/octet-stream" | ||
| ), | ||
| size=len(matched.get("body", b"")), | ||
| ) | ||
| uf.file.write(matched.get("body", b"")) | ||
| uf.file.seek(0) | ||
| parsed_params[pname] = uf | ||
| elif param.default.default is not ...: | ||
| parsed_params[pname] = param.default.default | ||
| elif _has_form_types and ( | ||
| param.annotation is _UploadFile | ||
| or ( | ||
| isinstance(param.annotation, type) | ||
| and issubclass(param.annotation, _UploadFile) | ||
| ) | ||
| ): | ||
| key = pname | ||
| matched = None | ||
| for ff in _file_fields: | ||
| if ff.get("name") == key: | ||
| matched = ff | ||
| break | ||
| if matched: | ||
| uf = _UploadFile( | ||
| filename=matched.get("filename"), | ||
| content_type=matched.get( | ||
| "content_type", "application/octet-stream" | ||
| ), | ||
| size=len(matched.get("body", b"")), | ||
| ) | ||
| uf.file.write(matched.get("body", b"")) | ||
| uf.file.seek(0) | ||
| parsed_params[pname] = uf | ||
|
|
||
| # 4. Parse request body (JSON) — skip if form data was already parsed | ||
| if "body" in kwargs: | ||
| body_data = kwargs["body"] | ||
|
|
||
| if body_data: # Only parse if body is not empty | ||
| if body_data and not (_form_fields or _file_fields): | ||
| parsed_body = RequestBodyParser.parse_json_body(body_data, sig) | ||
| # Merge parsed body params (body params take precedence) | ||
| parsed_params.update(parsed_body) | ||
|
|
@@ -793,9 +862,65 @@ def enhanced_handler(**kwargs): | |
| header_params = HeaderParser.parse_headers(headers_dict, sig) | ||
| parsed_params.update(header_params) | ||
|
|
||
| # 4. Parse request body (JSON) | ||
| # 3.5. Resolve Form / File / UploadFile parameters from Zig-parsed data | ||
| _form_fields = kwargs.get("form_fields", {}) | ||
| _file_fields = kwargs.get("file_fields", []) | ||
| if _has_form_params: | ||
| for pname, param in sig.parameters.items(): | ||
| if _has_form_types and isinstance(param.default, Form): | ||
| key = param.default.alias or pname | ||
| if key in _form_fields: | ||
| parsed_params[pname] = _form_fields[key] | ||
| elif param.default.default is not ...: | ||
| parsed_params[pname] = param.default.default | ||
| elif _has_form_types and isinstance(param.default, File): | ||
| key = param.default.alias or pname | ||
| matched = None | ||
| for ff in _file_fields: | ||
| if ff.get("name") == key: | ||
| matched = ff | ||
| break | ||
| if matched: | ||
| uf = _UploadFile( | ||
| filename=matched.get("filename"), | ||
| content_type=matched.get( | ||
| "content_type", "application/octet-stream" | ||
| ), | ||
| size=len(matched.get("body", b"")), | ||
| ) | ||
| uf.file.write(matched.get("body", b"")) | ||
| uf.file.seek(0) | ||
| parsed_params[pname] = uf | ||
| elif param.default.default is not ...: | ||
| parsed_params[pname] = param.default.default | ||
| elif _has_form_types and ( | ||
| param.annotation is _UploadFile | ||
| or ( | ||
| isinstance(param.annotation, type) | ||
| and issubclass(param.annotation, _UploadFile) | ||
| ) | ||
| ): | ||
| key = pname | ||
| matched = None | ||
| for ff in _file_fields: | ||
| if ff.get("name") == key: | ||
| matched = ff | ||
| break | ||
| if matched: | ||
| uf = _UploadFile( | ||
| filename=matched.get("filename"), | ||
| content_type=matched.get( | ||
| "content_type", "application/octet-stream" | ||
| ), | ||
| size=len(matched.get("body", b"")), | ||
| ) | ||
| uf.file.write(matched.get("body", b"")) | ||
| uf.file.seek(0) | ||
| parsed_params[pname] = uf | ||
|
|
||
| # 4. Parse request body (JSON) — skip if form data was already parsed | ||
| body_data = kwargs.get("body", b"") | ||
| if body_data: | ||
| if body_data and not (_form_fields or _file_fields): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| parsed_body = RequestBodyParser.parse_json_body(body_data, sig) | ||
| parsed_params.update(parsed_body) | ||
|
|
||
|
|
@@ -858,6 +983,7 @@ def enhanced_handler(**kwargs): | |
|
|
||
| return enhanced_handler | ||
|
|
||
|
|
||
| def create_pos_handler(original_handler): | ||
| """Minimal positional wrapper for PyObject_Vectorcall dispatch. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
|
|
||
| import inspect | ||
| import json | ||
| import uuid | ||
| from typing import Any | ||
| from urllib.parse import parse_qs, urlencode, urlparse | ||
|
|
||
|
|
@@ -118,30 +119,66 @@ def _request( | |
| params: dict | None = None, | ||
| json: Any = None, | ||
| data: dict | None = None, | ||
| files: dict | None = None, | ||
| headers: dict | None = None, | ||
| cookies: dict | None = None, | ||
| content: bytes | None = None, | ||
| ) -> TestResponse: | ||
| """Execute a request against the app.""" | ||
| import asyncio | ||
|
|
||
| # Parse URL | ||
| parsed = urlparse(url) | ||
| path = parsed.path or "/" | ||
| query_string = parsed.query or "" | ||
|
|
||
| # Add query params | ||
| if params: | ||
| if query_string: | ||
| query_string += "&" + urlencode(params) | ||
| else: | ||
| query_string = urlencode(params) | ||
|
|
||
| # Build request body | ||
| body = b"" | ||
| request_headers = dict(headers or {}) | ||
|
|
||
| if json is not None: | ||
| if files is not None: | ||
| boundary = f"----TurboAPIBoundary{uuid.uuid4().hex[:16]}" | ||
| parts = [] | ||
| for field_name, file_info in files.items(): | ||
| if isinstance(file_info, tuple): | ||
| filename, file_content = file_info | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| if isinstance(file_content, str): | ||
| file_content = file_content.encode("utf-8") | ||
| file_ct = "application/octet-stream" | ||
| if len(file_info) > 2: | ||
| file_ct = file_info[2] | ||
| elif isinstance(file_info, dict): | ||
| filename = file_info.get("filename", "upload") | ||
| file_content = file_info.get("content", b"") | ||
| if isinstance(file_content, str): | ||
| file_content = file_content.encode("utf-8") | ||
| file_ct = file_info.get("content_type", "application/octet-stream") | ||
| else: | ||
| filename = "upload" | ||
| file_content = file_info | ||
| file_ct = "application/octet-stream" | ||
| parts.append( | ||
| f"--{boundary}\r\n".encode() | ||
| + f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'.encode() | ||
| + f"Content-Type: {file_ct}\r\n\r\n".encode() | ||
| + file_content | ||
| + b"\r\n" | ||
| ) | ||
| if data: | ||
| for k, v in data.items(): | ||
| parts.append( | ||
| f"--{boundary}\r\n".encode() | ||
| + f'Content-Disposition: form-data; name="{k}"\r\n\r\n'.encode() | ||
| + str(v).encode("utf-8") | ||
| + b"\r\n" | ||
| ) | ||
| body = b"".join(parts) + f"--{boundary}--\r\n".encode() | ||
| request_headers.setdefault("content-type", f"multipart/form-data; boundary={boundary}") | ||
| elif json is not None: | ||
| import json as json_module | ||
|
|
||
| body = json_module.dumps(json).encode("utf-8") | ||
|
|
@@ -161,12 +198,12 @@ def _request( | |
| request_headers["cookie"] = cookie_str | ||
|
|
||
| # Issue #104: Check mounted apps (e.g. StaticFiles) before route matching | ||
| if hasattr(self.app, '_mounts'): | ||
| if hasattr(self.app, "_mounts"): | ||
| for mount_path, mount_info in self.app._mounts.items(): | ||
| if path.startswith(mount_path + "/") or path == mount_path: | ||
| sub_path = path[len(mount_path):] | ||
| sub_path = path[len(mount_path) :] | ||
| mounted_app = mount_info["app"] | ||
| if hasattr(mounted_app, 'get_file'): | ||
| if hasattr(mounted_app, "get_file"): | ||
| result = mounted_app.get_file(sub_path) | ||
| if result is not None: | ||
| content_bytes, content_type, size = result | ||
|
|
@@ -177,27 +214,36 @@ def _request( | |
| ) | ||
|
|
||
| # Issue #102: Serve docs and openapi URLs | ||
| if hasattr(self.app, 'openapi_url') and self.app.openapi_url and path == self.app.openapi_url: | ||
| if ( | ||
| hasattr(self.app, "openapi_url") | ||
| and self.app.openapi_url | ||
| and path == self.app.openapi_url | ||
| ): | ||
| import json as json_module | ||
|
|
||
| schema = self.app.openapi() | ||
| body = json_module.dumps(schema).encode("utf-8") | ||
| return TestResponse(status_code=200, content=body, headers={"content-type": "application/json"}) | ||
| return TestResponse( | ||
| status_code=200, content=body, headers={"content-type": "application/json"} | ||
| ) | ||
|
|
||
| if hasattr(self.app, 'docs_url') and self.app.docs_url and path == self.app.docs_url: | ||
| if hasattr(self.app, "docs_url") and self.app.docs_url and path == self.app.docs_url: | ||
| html = f"""<!DOCTYPE html> | ||
| <html><head><title>{self.app.title} - Swagger UI</title></head> | ||
| <body><div id="swagger-ui"></div></body></html>""" | ||
| return TestResponse(status_code=200, content=html.encode("utf-8"), headers={"content-type": "text/html"}) | ||
| return TestResponse( | ||
| status_code=200, content=html.encode("utf-8"), headers={"content-type": "text/html"} | ||
| ) | ||
|
|
||
| # Find matching route | ||
| route, path_params = self._find_route(method.upper(), path) | ||
| if route is None: | ||
| return TestResponse(status_code=404, content=b'{"detail":"Not Found"}') | ||
|
|
||
| # Issue #103: Enforce router-level dependencies | ||
| if hasattr(route, 'dependencies') and route.dependencies: | ||
| if hasattr(route, "dependencies") and route.dependencies: | ||
| for dep in route.dependencies: | ||
| dep_fn = dep.dependency if hasattr(dep, 'dependency') else dep | ||
| dep_fn = dep.dependency if hasattr(dep, "dependency") else dep | ||
| if dep_fn is not None: | ||
| try: | ||
| if inspect.iscoroutinefunction(dep_fn): | ||
|
|
@@ -289,7 +335,7 @@ def _request( | |
| result = handler(**kwargs) | ||
| except Exception as e: | ||
| # Issue #100: Check registered custom exception handlers first | ||
| if hasattr(self.app, '_exception_handlers'): | ||
| if hasattr(self.app, "_exception_handlers"): | ||
| for exc_class, exc_handler in self.app._exception_handlers.items(): | ||
| if isinstance(e, exc_class): | ||
| result = exc_handler(None, e) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In
create_enhanced_handler, theFile()branch always constructs anUploadFileand passes that object to the handler, even when the parameter is annotated asbytes(the documentedfile: bytes = File()pattern). In that case handler code that expects raw bytes (e.g.,len(file)or byte operations) will fail or behave incorrectly because it receives anUploadFileinstance instead ofbytes.Useful? React with 👍 / 👎.