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

@@ -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",

View File

@@ -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