Added file upload support
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
73
backends/mizan-django/src/mizan/tests/test_upload.py
Normal file
73
backends/mizan-django/src/mizan/tests/test_upload.py
Normal 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())
|
||||
@@ -8,6 +8,7 @@ dependencies = [
|
||||
"mizan-core",
|
||||
"fastapi>=0.110",
|
||||
"pydantic>=2.0",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -35,8 +35,12 @@ from .executor import (
|
||||
execute_function,
|
||||
)
|
||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||
from mizan_core.upload import File, Upload, UploadedFile
|
||||
|
||||
__all__ = [
|
||||
"Upload",
|
||||
"File",
|
||||
"UploadedFile",
|
||||
"router",
|
||||
"mizan_exception_handler",
|
||||
"mizan_validation_handler",
|
||||
|
||||
@@ -14,16 +14,20 @@ FastAPI router exposing Mizan's HTTP endpoints:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
from mizan_core.upload import UploadedFile, bind_uploads
|
||||
|
||||
from .executor import (
|
||||
BadRequest,
|
||||
ErrorCode,
|
||||
MizanError,
|
||||
NotFound,
|
||||
@@ -59,13 +63,58 @@ class CallBody(BaseModel):
|
||||
args: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
async def _parse_call(request: Request) -> tuple[str, dict[str, Any]]:
|
||||
"""Read a call request, JSON or multipart. Returns `(fn, args)`.
|
||||
|
||||
Multipart carries the non-file fields in a JSON `args` part and each file as
|
||||
its own part; the file parts bind into the Input's Upload fields with the
|
||||
declarative `File(...)` constraints enforced.
|
||||
"""
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if content_type.startswith("multipart/form-data"):
|
||||
form = await request.form()
|
||||
fn = form.get("fn")
|
||||
if not isinstance(fn, str) or not fn:
|
||||
raise BadRequest("Missing 'fn' field")
|
||||
raw_args = form.get("args")
|
||||
try:
|
||||
args: dict[str, Any] = json.loads(raw_args) if raw_args else {}
|
||||
except (TypeError, ValueError):
|
||||
raise BadRequest("Invalid JSON in 'args' field")
|
||||
|
||||
fn_class = get_function(fn)
|
||||
input_cls = getattr(fn_class, "Input", None) if fn_class else None
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
files: dict[str, list[UploadedFile]] = {}
|
||||
for key in set(form.keys()):
|
||||
wrapped = [
|
||||
UploadedFile(p.filename, p.content_type, await p.read())
|
||||
for p in form.getlist(key)
|
||||
if isinstance(p, UploadFile)
|
||||
]
|
||||
if wrapped:
|
||||
files[key] = wrapped
|
||||
err = bind_uploads(input_cls, args, files)
|
||||
if err is not None:
|
||||
raise BadRequest(err)
|
||||
return fn, args
|
||||
|
||||
try:
|
||||
body = CallBody(**(await request.json()))
|
||||
except (ValueError, ValidationError):
|
||||
raise BadRequest("Invalid request body")
|
||||
return body.fn, body.args
|
||||
|
||||
|
||||
@router.post("/call/")
|
||||
async def function_call(body: CallBody, request: Request) -> JSONResponse:
|
||||
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
|
||||
fn_class = get_function(body.fn)
|
||||
result = await execute_function(request, body.fn, body.args)
|
||||
invalidate = compute_invalidation(fn_class, body.args)
|
||||
merges = compute_merges(fn_class, body.args, result)
|
||||
async def function_call(request: Request) -> JSONResponse:
|
||||
"""RPC dispatch — `{"fn": "...", "args": {...}}` (JSON) or multipart with file
|
||||
parts → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
|
||||
fn, args = await _parse_call(request)
|
||||
fn_class = get_function(fn)
|
||||
result = await execute_function(request, fn, args)
|
||||
invalidate = compute_invalidation(fn_class, args)
|
||||
merges = compute_merges(fn_class, args, result)
|
||||
payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
|
||||
if merges:
|
||||
payload["merge"] = merges
|
||||
|
||||
71
backends/mizan-fastapi/tests/test_upload.py
Normal file
71
backends/mizan-fastapi/tests/test_upload.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Upload dispatch over FastAPI multipart — files bind into Upload fields and
|
||||
the declarative `File(...)` constraints are enforced."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Annotated
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.registry import clear_registry, register
|
||||
from mizan_fastapi import File, MizanError, Upload, mizan_exception_handler, router as mizan_router
|
||||
|
||||
|
||||
class AvatarOut(BaseModel):
|
||||
ok: bool
|
||||
size: int
|
||||
name: str | None = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
clear_registry()
|
||||
|
||||
@client
|
||||
def set_avatar(
|
||||
request,
|
||||
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")
|
||||
|
||||
fastapi_app = FastAPI()
|
||||
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
|
||||
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
return fastapi_app
|
||||
|
||||
|
||||
def _post(test_client: TestClient, args: dict, file_tuple: tuple):
|
||||
return test_client.post(
|
||||
"/api/mizan/call/",
|
||||
data={"fn": "set_avatar", "args": json.dumps(args)},
|
||||
files={"avatar": file_tuple},
|
||||
)
|
||||
|
||||
|
||||
def test_upload_binds_and_executes(app):
|
||||
resp = _post(TestClient(app), {"user_id": 5}, ("a.png", b"\x89PNG" + b"x" * 100, "image/png"))
|
||||
assert resp.status_code == 200, resp.text
|
||||
result = resp.json()["result"]
|
||||
assert result["ok"] is True
|
||||
assert result["name"] == "a.png"
|
||||
assert result["size"] == 104
|
||||
|
||||
|
||||
def test_max_size_rejected(app):
|
||||
resp = _post(TestClient(app), {"user_id": 5}, ("b.png", b"x" * (2 * 1024 * 1024), "image/png"))
|
||||
assert resp.status_code == 400
|
||||
assert "max size" in resp.text
|
||||
|
||||
|
||||
def test_content_type_rejected(app):
|
||||
resp = _post(TestClient(app), {"user_id": 5}, ("c.gif", b"GIF89a", "image/gif"))
|
||||
assert resp.status_code == 400
|
||||
assert "content-type" in resp.text
|
||||
Reference in New Issue
Block a user