Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 135 additions & 9 deletions python/turboapi/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,6 @@ def format_json_response(
return ResponseHandler.format_response(content, status_code, content_type)



_json_dumps = __import__("json").dumps


Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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"")),
Comment on lines +727 to +732
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve bytes semantics for File() parameters

In create_enhanced_handler, the File() branch always constructs an UploadFile and passes that object to the handler, even when the parameter is annotated as bytes (the documented file: 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 an UploadFile instance instead of bytes.

Useful? React with 👍 / 👎.

)
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)
Expand Down Expand Up @@ -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):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate JSON-body skip to form/file handlers only

callPythonHandler now injects form_fields for any application/x-www-form-urlencoded or multipart request, but this guard skips JSON parsing whenever those collections are non-empty. For handlers that do not declare Form()/File()/UploadFile params (for example def create(payload: dict)), a form-encoded request now bypasses body parsing entirely, so required args are missing and the enhanced wrapper returns a 500 instead of the previous client-facing parse error path. This skip should be conditioned on _has_form_params (the same pattern is present in both async and sync branches).

Useful? React with 👍 / 👎.

parsed_body = RequestBodyParser.parse_json_body(body_data, sig)
parsed_params.update(parsed_body)

Expand Down Expand Up @@ -858,6 +983,7 @@ def enhanced_handler(**kwargs):

return enhanced_handler


def create_pos_handler(original_handler):
"""Minimal positional wrapper for PyObject_Vectorcall dispatch.

Expand Down
74 changes: 60 additions & 14 deletions python/turboapi/testclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import inspect
import json
import uuid
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse

Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept 3-item file tuples in TestClient multipart encoding

files entries are documented/tested as (filename, bytes, content_type), but this code unpacks tuples as exactly two values before checking len(file_info), so any 3-item tuple raises ValueError and the request never gets sent. This breaks the common file-upload path whenever callers provide an explicit content type.

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")
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading