Added file upload support
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
name: Publish Django package to PyPI
|
||||
name: Publish Django package to Gitea registry
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish React package to npm
|
||||
name: Publish React package to Gitea registry
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -11,6 +11,7 @@ no longer exist.
|
||||
- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification.
|
||||
- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom.
|
||||
- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed.
|
||||
- [ ] **Upload dispatch not wired for Rust/Axum + Tauri.** The `Upload` type is first-class end to end — IR (`upload` KDL node), codegen (TS `File`), kernel (auto-multipart), and dispatch+constraint binding on Django and FastAPI. Rust/Axum and Tauri parse the IR node and emit the field, but their dispatch does not yet bind multipart file parts.
|
||||
- [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it.
|
||||
- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: purge atomicity, cross-language stringification, per-param sub-index cleanup, thundering-herd protection, `cache_get`/`cache_put` argument inconsistency, RedisCache test coverage.
|
||||
- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`.
|
||||
|
||||
@@ -53,6 +53,7 @@ The surface every Mizan adapter implements.
|
||||
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ |
|
||||
| File uploads (multipart, `Upload` type) | ✅ | ✅ | ❌ ⁹ | ❌ ⁹ | — ¹⁰ |
|
||||
|
||||
### Edge, cache & enforcement
|
||||
|
||||
@@ -106,6 +107,12 @@ target stack calls for them.
|
||||
8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
|
||||
codegen source — it demonstrates the cache + invalidation protocol is
|
||||
language-agnostic.
|
||||
9. The IR carries the `upload` type and the codegen emits the field across targets, but
|
||||
multipart dispatch binding is wired for Django and FastAPI only; Rust/Axum and Tauri
|
||||
parse the IR node but do not yet bind uploads.
|
||||
10. The TypeScript column is the `mizan-ts` backend adapter, which has no upload
|
||||
dispatch. The matching client side lives in the kernel (`@mizan/base`): `mizanCall`
|
||||
auto-switches to `multipart/form-data` when any argument is a `File`.
|
||||
|
||||
## Conformance
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -6,6 +6,7 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"PyJWT>=2.0",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from mizan_core.upload import File, Upload, UploadedFile, validate_upload
|
||||
|
||||
__all__ = ["Upload", "File", "UploadedFile", "validate_upload"]
|
||||
|
||||
@@ -487,8 +487,10 @@ def _create_server_function(
|
||||
# Use function name directly
|
||||
name = fn.__name__
|
||||
|
||||
# Extract type hints and signature
|
||||
hints = get_type_hints(fn)
|
||||
# Extract type hints and signature. include_extras keeps `Annotated[...]`
|
||||
# metadata (e.g. the `File(...)` marker on an Upload field) intact so it
|
||||
# survives into the generated Input model.
|
||||
hints = get_type_hints(fn, include_extras=True)
|
||||
sig = inspect.signature(fn)
|
||||
params = list(sig.parameters.items())
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ KDL grammar — locked contract:
|
||||
| list { <type-child> }
|
||||
| optional { <type-child> }
|
||||
| enum "<v1>" "<v2>" ...
|
||||
| upload max-size=<int>? { content-type "<mime>" ... }
|
||||
}
|
||||
...
|
||||
}
|
||||
@@ -72,6 +73,7 @@ from pydantic_core import PydanticUndefined
|
||||
|
||||
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||
from mizan_core.upload import File, classify_upload
|
||||
|
||||
|
||||
__all__ = ["build_ir"]
|
||||
@@ -244,6 +246,34 @@ def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]
|
||||
_emit_type_child(alias_block, annotation, named_types)
|
||||
|
||||
|
||||
def _emit_upload_node(block: _Block, spec: File | None) -> None:
|
||||
"""Emit the `upload` type-child, with optional `max-size` + `content-type`s."""
|
||||
props: dict[str, str] = {}
|
||||
if spec is not None and spec.max_size is not None:
|
||||
props["max-size"] = repr(spec.max_size)
|
||||
if spec is not None and spec.content_types:
|
||||
with block.node("upload", **props) as up:
|
||||
for ct in spec.content_types:
|
||||
up.leaf("content-type", _kdl_string(ct))
|
||||
else:
|
||||
block.leaf("upload", **props)
|
||||
|
||||
|
||||
def _emit_upload_child(block: _Block, is_list: bool, is_optional: bool, spec: File | None) -> None:
|
||||
"""Emit an Upload type-child, wrapped in `optional`/`list` to match the field."""
|
||||
if is_optional and is_list:
|
||||
with block.node("optional") as opt, opt.node("list") as lst:
|
||||
_emit_upload_node(lst, spec)
|
||||
elif is_optional:
|
||||
with block.node("optional") as opt:
|
||||
_emit_upload_node(opt, spec)
|
||||
elif is_list:
|
||||
with block.node("list") as lst:
|
||||
_emit_upload_node(lst, spec)
|
||||
else:
|
||||
_emit_upload_node(block, spec)
|
||||
|
||||
|
||||
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
|
||||
"""Emit a `struct { field ... }` block for a Pydantic model."""
|
||||
with block.node("struct") as struct_block:
|
||||
@@ -259,7 +289,11 @@ def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[s
|
||||
props["default"] = _kdl_value(default)
|
||||
|
||||
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
|
||||
_emit_type_child(field_block, field_info.annotation, named_types)
|
||||
is_upload, is_list, is_optional, spec = classify_upload(field_info)
|
||||
if is_upload:
|
||||
_emit_upload_child(field_block, is_list, is_optional, spec)
|
||||
else:
|
||||
_emit_type_child(field_block, field_info.annotation, named_types)
|
||||
|
||||
|
||||
class _StructShape:
|
||||
|
||||
216
cores/mizan-python/src/mizan_core/upload.py
Normal file
216
cores/mizan-python/src/mizan_core/upload.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Mizan Upload — first-class binary input for ``@client`` functions.
|
||||
|
||||
``Upload`` is a Pydantic-composable field type. Declaring an Upload-typed
|
||||
parameter makes a function multipart-aware end to end: the generated client
|
||||
switches that call to ``multipart/form-data``, and each backend adapter parses
|
||||
the file part and binds a uniform :class:`UploadedFile` into the function's
|
||||
``Input``. Constraints declared via :class:`File` are enforced at dispatch.
|
||||
|
||||
from typing import Annotated
|
||||
from mizan import client, Upload, File
|
||||
|
||||
@client(affects=UserContext)
|
||||
def set_avatar(
|
||||
request,
|
||||
user_id: int,
|
||||
avatar: Annotated[Upload, File(max_size="5MB", content_types=["image/png"])],
|
||||
) -> dict:
|
||||
avatar.save(f"/media/{user_id}.png")
|
||||
return {"ok": True}
|
||||
|
||||
Bare ``Upload`` is an unconstrained file; ``Upload | None`` is optional;
|
||||
``list[Upload]`` accepts multiple. The :class:`File` marker is optional and
|
||||
carries the declarative constraints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pydantic import GetCoreSchemaHandler
|
||||
from pydantic_core import core_schema
|
||||
|
||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Upload",
|
||||
"UploadedFile",
|
||||
"File",
|
||||
"parse_size",
|
||||
"validate_upload",
|
||||
"classify_upload",
|
||||
"upload_fields",
|
||||
"bind_uploads",
|
||||
]
|
||||
|
||||
|
||||
_SIZE_UNITS = {"GB": 1024**3, "MB": 1024**2, "KB": 1024, "B": 1}
|
||||
|
||||
|
||||
def parse_size(value: int | str) -> int:
|
||||
"""Parse a byte count. Accepts an int (bytes) or a string like ``"5MB"``."""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
s = value.strip().upper()
|
||||
for unit, mult in _SIZE_UNITS.items():
|
||||
if s.endswith(unit):
|
||||
return int(float(s[: -len(unit)].strip()) * mult)
|
||||
return int(s)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class File:
|
||||
"""Declarative constraints for an ``Upload`` field, placed in ``Annotated``.
|
||||
|
||||
``max_size`` accepts an int (bytes) or a human string (``"5MB"``).
|
||||
``content_types`` is a list of allowed MIME types; an entry ending in
|
||||
``/*`` (e.g. ``"image/*"``) matches any subtype.
|
||||
"""
|
||||
|
||||
max_size: int | str | None = None
|
||||
content_types: tuple[str, ...] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_size is not None:
|
||||
object.__setattr__(self, "max_size", parse_size(self.max_size))
|
||||
if self.content_types is not None and not isinstance(self.content_types, tuple):
|
||||
object.__setattr__(self, "content_types", tuple(self.content_types))
|
||||
|
||||
|
||||
class UploadedFile:
|
||||
"""Uniform file handle handed to ``@client`` functions, adapter-agnostic.
|
||||
|
||||
Backends construct this from their native upload object (Django
|
||||
``UploadedFile``, Starlette ``UploadFile``) so a function body stays
|
||||
portable across adapters.
|
||||
"""
|
||||
|
||||
__slots__ = ("filename", "content_type", "_data")
|
||||
|
||||
def __init__(self, filename: str | None, content_type: str | None, data: bytes):
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
self._data = data
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
def save(self, path: str | os.PathLike) -> None:
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(self._data)
|
||||
|
||||
|
||||
class Upload:
|
||||
"""Pydantic-composable marker type for a binary file input.
|
||||
|
||||
At validation time it accepts any :class:`UploadedFile` (the adapter has
|
||||
already bound the multipart part). The IR emitter recognizes Upload-typed
|
||||
fields and emits an ``upload`` node.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def __get_pydantic_core_schema__(
|
||||
cls, source: Any, handler: GetCoreSchemaHandler
|
||||
) -> core_schema.CoreSchema:
|
||||
return core_schema.no_info_plain_validator_function(cls._validate)
|
||||
|
||||
@staticmethod
|
||||
def _validate(value: Any) -> UploadedFile:
|
||||
if isinstance(value, UploadedFile):
|
||||
return value
|
||||
raise ValueError("expected an uploaded file")
|
||||
|
||||
|
||||
def _content_type_allowed(content_type: str | None, allowed: tuple[str, ...]) -> bool:
|
||||
if not content_type:
|
||||
return False
|
||||
for ct in allowed:
|
||||
if ct == content_type:
|
||||
return True
|
||||
if ct.endswith("/*") and content_type.startswith(ct[:-1]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_upload(file: UploadedFile, spec: File | None) -> str | None:
|
||||
"""Enforce declared constraints. Returns an error message, or ``None`` if ok."""
|
||||
if spec is None:
|
||||
return None
|
||||
if spec.max_size is not None and file.size > spec.max_size:
|
||||
return f"file exceeds max size {spec.max_size} bytes (got {file.size})"
|
||||
if spec.content_types and not _content_type_allowed(file.content_type, spec.content_types):
|
||||
return f"content-type {file.content_type!r} not allowed (expected one of {list(spec.content_types)})"
|
||||
return None
|
||||
|
||||
|
||||
# ─── Field classification + binding (shared by every backend adapter) ─────────
|
||||
|
||||
|
||||
def _strip_annotated_meta(annotation: Any) -> tuple[Any, File | None]:
|
||||
"""Unwrap a ``typing.Annotated``, returning ``(base_type, File-marker-or-None)``."""
|
||||
if hasattr(annotation, "__metadata__"):
|
||||
spec = next((m for m in annotation.__metadata__ if isinstance(m, File)), None)
|
||||
return annotation.__origin__, spec
|
||||
return annotation, None
|
||||
|
||||
|
||||
def classify_upload(field_info: Any) -> tuple[bool, bool, bool, File | None]:
|
||||
"""Detect an ``Upload``-typed field → ``(is_upload, is_list, is_optional, spec)``.
|
||||
|
||||
Composes through ``Optional[...]``, ``list[...]``, and
|
||||
``Annotated[..., File(...)]`` in any order, gathering the ``File`` marker
|
||||
wherever it appears (Pydantic lifts a top-level marker into
|
||||
``field_info.metadata``; nested markers stay inside the annotation).
|
||||
"""
|
||||
spec = next((m for m in getattr(field_info, "metadata", None) or [] if isinstance(m, File)), None)
|
||||
ann = field_info.annotation
|
||||
ann, s = _strip_annotated_meta(ann); spec = spec or s
|
||||
ann, is_optional = extract_optional(ann)
|
||||
ann, s = _strip_annotated_meta(ann); spec = spec or s
|
||||
elem = extract_list_element(ann)
|
||||
is_list = elem is not None
|
||||
if is_list:
|
||||
ann, s = _strip_annotated_meta(elem); spec = spec or s
|
||||
return ann is Upload, is_list, is_optional, spec
|
||||
|
||||
|
||||
def upload_fields(model: Any) -> dict[str, tuple[bool, File | None]]:
|
||||
"""Map each ``Upload``-typed field of a Pydantic model → ``(is_list, spec)``."""
|
||||
out: dict[str, tuple[bool, File | None]] = {}
|
||||
for name, field_info in model.model_fields.items():
|
||||
is_upload, is_list, _is_opt, spec = classify_upload(field_info)
|
||||
if is_upload:
|
||||
out[name] = (is_list, spec)
|
||||
return out
|
||||
|
||||
|
||||
def bind_uploads(
|
||||
input_cls: Any,
|
||||
args: dict[str, Any],
|
||||
files: dict[str, list[UploadedFile]],
|
||||
) -> str | None:
|
||||
"""Place uploaded files into ``args`` by field name, enforcing constraints.
|
||||
|
||||
Mutates ``args`` in place. ``files`` maps a field name to the parts received
|
||||
for it (an array field receives several). Returns an error message on the
|
||||
first constraint violation, else ``None``. Absent files are left for
|
||||
Pydantic's required/optional handling.
|
||||
"""
|
||||
for fname, (is_list, spec) in upload_fields(input_cls).items():
|
||||
bucket = files.get(fname) or []
|
||||
if not bucket:
|
||||
continue
|
||||
for f in bucket:
|
||||
err = validate_upload(f, spec)
|
||||
if err is not None:
|
||||
return f"{fname}: {err}"
|
||||
args[fname] = list(bucket) if is_list else bucket[0]
|
||||
return None
|
||||
@@ -386,6 +386,18 @@ async function resolveHeaders(): Promise<Record<string, string>> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser-safe `File` check — `File` is undefined under Node/SSR. */
|
||||
function isFile(value: unknown): boolean {
|
||||
return typeof File !== 'undefined' && value instanceof File
|
||||
}
|
||||
|
||||
/** True when any arg is a file (or an array containing a file). */
|
||||
function hasFileArg(args: Record<string, any>): boolean {
|
||||
return Object.values(args).some(
|
||||
(v) => isFile(v) || (Array.isArray(v) && v.some(isFile)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Mizan transport — POST `${baseUrl}/call/` and GET
|
||||
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
|
||||
@@ -397,13 +409,38 @@ export function httpTransport(): MizanTransport {
|
||||
return {
|
||||
async call(functionName, args) {
|
||||
const headers = await resolveHeaders()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
// File-typed args switch the call to multipart/form-data: `fn` and a
|
||||
// JSON `args` part for the non-file fields, plus one part per file
|
||||
// (an array field repeats its part). Otherwise JSON as usual. The
|
||||
// server reconstructs the args dict by merging the file parts back in.
|
||||
let body: BodyInit
|
||||
if (hasFileArg(args)) {
|
||||
const form = new FormData()
|
||||
form.append('fn', functionName)
|
||||
const jsonArgs: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (isFile(value)) {
|
||||
form.append(key, value)
|
||||
} else if (Array.isArray(value) && value.some(isFile)) {
|
||||
for (const item of value) form.append(key, item as Blob)
|
||||
} else {
|
||||
jsonArgs[key] = value
|
||||
}
|
||||
}
|
||||
form.append('args', JSON.stringify(jsonArgs))
|
||||
body = form
|
||||
// Content-Type is set by the browser (with the multipart boundary).
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
body = JSON.stringify({ fn: functionName, args })
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.baseUrl}/call/`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ fn: functionName, args }),
|
||||
body,
|
||||
})
|
||||
if (!res.ok) throw new MizanError(res.status, await res.text())
|
||||
return res.json()
|
||||
|
||||
@@ -165,6 +165,7 @@ fn ts_type_expression(shape: &TypeShape) -> String {
|
||||
.map(ts_type_expression)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | "),
|
||||
TypeShape::Upload(_) => "File".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,9 @@ fn py_type_expression(shape: &TypeShape) -> String {
|
||||
.map(py_type_expression)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | "),
|
||||
// The Python (PyO3) client is a consumer, not an upload origin; a file
|
||||
// input surfaces as raw bytes on this target.
|
||||
TypeShape::Upload(_) => "bytes".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -440,5 +440,8 @@ fn rust_type_from_shape(shape: &TypeShape, ctx: &mut EnumCtx) -> String {
|
||||
// Value so the consumer can match on the runtime variant.
|
||||
"serde_json::Value".to_string()
|
||||
}
|
||||
// The Rust adapter does not yet wire multipart; a file input surfaces
|
||||
// as raw bytes until upload dispatch lands on this target.
|
||||
TypeShape::Upload(_) => "Vec<u8>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,5 +296,6 @@ fn ts_type_expression(shape: &TypeShape) -> String {
|
||||
.map(ts_type_expression)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | "),
|
||||
TypeShape::Upload(_) => "File".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,15 @@ pub enum TypeShape {
|
||||
Enum(Vec<String>),
|
||||
/// Multi-arm union with two or more non-null branches.
|
||||
Union(Vec<TypeShape>),
|
||||
/// Binary file input. Carries the declarative `File(...)` constraints.
|
||||
Upload(UploadConstraints),
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UploadConstraints {
|
||||
pub max_size: Option<u64>,
|
||||
pub content_types: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +282,7 @@ fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
|
||||
"list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
|
||||
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
|
||||
"enum" => Ok(TypeShape::Enum(parse_string_args(node))),
|
||||
"upload" => Ok(TypeShape::Upload(parse_upload_constraints(node))),
|
||||
"union" => {
|
||||
let children = node.children()
|
||||
.ok_or_else(|| anyhow!("union: missing children"))?;
|
||||
@@ -285,6 +295,20 @@ fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
|
||||
}
|
||||
|
||||
|
||||
fn parse_upload_constraints(node: &KdlNode) -> UploadConstraints {
|
||||
let max_size = node.entry("max-size")
|
||||
.and_then(|e| e.value().as_integer())
|
||||
.map(|i| i as u64);
|
||||
let content_types = node.children()
|
||||
.map(|children| children.nodes().iter()
|
||||
.filter(|n| n.name().value() == "content-type")
|
||||
.filter_map(|n| first_string_arg(n).ok())
|
||||
.collect())
|
||||
.unwrap_or_default();
|
||||
UploadConstraints { max_size, content_types }
|
||||
}
|
||||
|
||||
|
||||
fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
|
||||
let name = first_string_arg(node)
|
||||
.context("`function` requires a name as its first argument")?;
|
||||
|
||||
79
protocol/mizan-codegen/tests/upload_codegen.rs
Normal file
79
protocol/mizan-codegen/tests/upload_codegen.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! Upload type-shape lowers to TS `File` across cardinalities. Separate from
|
||||
//! the byte-parity baselines (which mustn't carry an upload field — the
|
||||
//! three-way AFI parity gate includes the Rust adapter, which doesn't wire
|
||||
//! uploads yet).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mizan_codegen::config::{Config, SourceConfig};
|
||||
use mizan_codegen::emit::stage1::Stage1;
|
||||
use mizan_codegen::emit::CodegenTarget;
|
||||
use mizan_codegen::fetch::parse_ir_from_str;
|
||||
|
||||
|
||||
const UPLOAD_IR: &str = r#"
|
||||
type "SetAvatarInput" {
|
||||
struct {
|
||||
field "user_id" {
|
||||
primitive "integer"
|
||||
}
|
||||
field "avatar" {
|
||||
upload max-size=5242880 {
|
||||
content-type "image/png"
|
||||
content-type "image/jpeg"
|
||||
}
|
||||
}
|
||||
field "photos" {
|
||||
list {
|
||||
upload
|
||||
}
|
||||
}
|
||||
field "thumb" required=#false {
|
||||
optional {
|
||||
upload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
type "setAvatarOutput" {
|
||||
alias {
|
||||
primitive "string"
|
||||
}
|
||||
}
|
||||
function "set_avatar" {
|
||||
camel "setAvatar"
|
||||
has-input #true
|
||||
input "SetAvatarInput"
|
||||
output "setAvatarOutput"
|
||||
transport "http"
|
||||
affects "user"
|
||||
}
|
||||
"#;
|
||||
|
||||
|
||||
fn cfg() -> Config {
|
||||
Config {
|
||||
project_id: None,
|
||||
output: PathBuf::from("/tmp"),
|
||||
targets: vec!["stage1".to_string()],
|
||||
source: SourceConfig { fastapi: None, django: None, rust: None, script: None },
|
||||
rust_kernel: None,
|
||||
rust_crate_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn upload_fields_lower_to_file_type() {
|
||||
let ir = parse_ir_from_str(UPLOAD_IR).expect("upload IR parses");
|
||||
let files = Stage1.emit(&ir, &cfg());
|
||||
let types = files
|
||||
.iter()
|
||||
.find(|f| f.rel_path.to_string_lossy().contains("types.ts"))
|
||||
.expect("types.ts emitted");
|
||||
let src = &types.content;
|
||||
|
||||
assert!(src.contains("avatar: File"), "required upload → File:\n{src}");
|
||||
assert!(src.contains("File[]"), "list[upload] → File[]:\n{src}");
|
||||
assert!(src.contains("File | null"), "optional upload → File | null:\n{src}");
|
||||
}
|
||||
Reference in New Issue
Block a user