Added file upload support

This commit is contained in:
2026-06-04 04:20:05 -04:00
parent 4effcc7597
commit 67ad91b673
23 changed files with 665 additions and 20 deletions

View File

@@ -89,6 +89,7 @@ from . import setup
from .channels import ReactChannel
from .channels import register as register_channel
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
from mizan_core.upload import File, Upload, UploadedFile
# Shape is lazy-loaded via __getattr__ because django_readers
# imports contenttypes, which can't happen during apps.populate()
@@ -164,6 +165,10 @@ __all__ = [
"GlobalContext",
"ServerFunction",
"ComposedContext",
# File uploads
"Upload",
"File",
"UploadedFile",
# Setup
"mizan_clients",
"mizan_module",

View File

@@ -29,6 +29,7 @@ from pydantic import BaseModel, ValidationError
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
from mizan_core.registry import get_function, get_context_groups
from mizan_core.upload import UploadedFile, bind_uploads
from mizan.setup.settings import get_settings
if TYPE_CHECKING:
@@ -736,7 +737,8 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
is_multipart = content_type.startswith("multipart/form-data")
if is_multipart:
# Multipart form data - used by form submit functions
# Multipart carries two shapes: a form submission (Django Form path) or
# an Upload-typed RPC. `fn` selects the function; its kind routes here.
fn_name = request.POST.get("fn")
if not fn_name:
return FunctionError(
@@ -744,12 +746,40 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
message="Missing 'fn' field",
).to_response()
# Get form data (excluding 'fn')
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
fn_class = get_function(fn_name)
is_form_fn = bool(getattr(fn_class, "_meta", {}).get("form")) if fn_class else False
# Attach parsed form data and files to request for form functions
request._mizan_form_data = input_data
request._mizan_form_files = request.FILES
if is_form_fn:
# Form submit — POST fields + FILES handed to Django Form validation.
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
request._mizan_form_data = input_data
request._mizan_form_files = request.FILES
else:
# Upload RPC — the `args` JSON part carries the non-file fields; the
# file parts bind into the Input's Upload fields (constraints enforced).
raw_args = request.POST.get("args")
try:
input_data = json.loads(raw_args) if raw_args else {}
except json.JSONDecodeError:
return FunctionError(
code=ErrorCode.BAD_REQUEST,
message="Invalid JSON in 'args' field",
).to_response()
input_cls = getattr(fn_class, "Input", None)
if input_cls is not None and hasattr(input_cls, "model_fields"):
files = {
field: [
UploadedFile(f.name, f.content_type, f.read())
for f in request.FILES.getlist(field)
]
for field in request.FILES
}
err = bind_uploads(input_cls, input_data, files)
if err is not None:
return FunctionError(
code=ErrorCode.BAD_REQUEST,
message=err,
).to_response()
else:
# JSON body - standard RPC

View File

@@ -0,0 +1,73 @@
"""Upload dispatch — multipart RPC binds files into Upload fields and enforces
the declarative `File(...)` constraints."""
import json
from typing import Annotated
from django.contrib.auth.models import AnonymousUser
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http import HttpRequest
from django.test import RequestFactory, TestCase
from pydantic import BaseModel
from mizan import Upload, File
from mizan.client import client
from mizan.client.executor import function_call_view
from mizan_core.registry import clear_registry, register
class AvatarOut(BaseModel):
ok: bool
size: int
name: str | None = None
class UploadDispatchTests(TestCase):
def setUp(self):
clear_registry()
self.factory = RequestFactory()
def tearDown(self):
clear_registry()
def _register(self):
@client
def set_avatar(
request: HttpRequest,
user_id: int,
avatar: Annotated[Upload, File(max_size="1MB", content_types=["image/png"])],
) -> AvatarOut:
return AvatarOut(ok=True, size=avatar.size, name=avatar.filename)
register(set_avatar, "set_avatar")
def _post(self, args, files):
data = {"fn": "set_avatar", "args": json.dumps(args), **files}
request = self.factory.post("/api/mizan/call/", data) # multipart
request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True
return function_call_view(request)
def test_upload_binds_and_executes(self):
self._register()
png = SimpleUploadedFile("a.png", b"\x89PNG" + b"x" * 100, content_type="image/png")
resp = self._post({"user_id": 5}, {"avatar": png})
self.assertEqual(resp.status_code, 200)
data = json.loads(resp.content)
self.assertTrue(data["result"]["ok"])
self.assertEqual(data["result"]["name"], "a.png")
self.assertEqual(data["result"]["size"], 104)
def test_max_size_rejected(self):
self._register()
big = SimpleUploadedFile("b.png", b"x" * (2 * 1024 * 1024), content_type="image/png")
resp = self._post({"user_id": 5}, {"avatar": big})
self.assertEqual(resp.status_code, 400)
self.assertIn("max size", resp.content.decode())
def test_content_type_rejected(self):
self._register()
gif = SimpleUploadedFile("c.gif", b"GIF89a", content_type="image/gif")
resp = self._post({"user_id": 5}, {"avatar": gif})
self.assertEqual(resp.status_code, 400)
self.assertIn("content-type", resp.content.decode())