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

@@ -1,4 +1,4 @@
name: Publish Django package to PyPI name: Publish Django package to Gitea registry
on: on:
push: push:

View File

@@ -1,4 +1,4 @@
name: Publish React package to npm name: Publish React package to Gitea registry
on: on:
push: push:

View File

@@ -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. - [ ] **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. - [ ] **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. - [ ] **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. - [ ] **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. - [ ] **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`. - [ ] **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`.

View File

@@ -53,6 +53,7 @@ The surface every Mizan adapter implements.
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ | | Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ | | Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ | | Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ |
| File uploads (multipart, `Upload` type) | ✅ | ✅ | ❌ ⁹ | ❌ ⁹ | — ¹⁰ |
### Edge, cache & enforcement ### 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 8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
codegen source — it demonstrates the cache + invalidation protocol is codegen source — it demonstrates the cache + invalidation protocol is
language-agnostic. 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 ## Conformance

View File

@@ -89,6 +89,7 @@ from . import setup
from .channels import ReactChannel from .channels import ReactChannel
from .channels import register as register_channel from .channels import register as register_channel
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose 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 # Shape is lazy-loaded via __getattr__ because django_readers
# imports contenttypes, which can't happen during apps.populate() # imports contenttypes, which can't happen during apps.populate()
@@ -164,6 +165,10 @@ __all__ = [
"GlobalContext", "GlobalContext",
"ServerFunction", "ServerFunction",
"ComposedContext", "ComposedContext",
# File uploads
"Upload",
"File",
"UploadedFile",
# Setup # Setup
"mizan_clients", "mizan_clients",
"mizan_module", "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.cache import get_cache, cache_get, cache_put, cache_purge
from mizan_core.registry import get_function, get_context_groups 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 from mizan.setup.settings import get_settings
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -736,7 +737,8 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
is_multipart = content_type.startswith("multipart/form-data") is_multipart = content_type.startswith("multipart/form-data")
if is_multipart: 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") fn_name = request.POST.get("fn")
if not fn_name: if not fn_name:
return FunctionError( return FunctionError(
@@ -744,12 +746,40 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
message="Missing 'fn' field", message="Missing 'fn' field",
).to_response() ).to_response()
# Get form data (excluding 'fn') fn_class = get_function(fn_name)
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"} 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 if is_form_fn:
request._mizan_form_data = input_data # Form submit — POST fields + FILES handed to Django Form validation.
request._mizan_form_files = request.FILES 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: else:
# JSON body - standard RPC # 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())

View File

@@ -8,6 +8,7 @@ dependencies = [
"mizan-core", "mizan-core",
"fastapi>=0.110", "fastapi>=0.110",
"pydantic>=2.0", "pydantic>=2.0",
"python-multipart>=0.0.9",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -35,8 +35,12 @@ from .executor import (
execute_function, execute_function,
) )
from .router import router, mizan_exception_handler, mizan_validation_handler from .router import router, mizan_exception_handler, mizan_validation_handler
from mizan_core.upload import File, Upload, UploadedFile
__all__ = [ __all__ = [
"Upload",
"File",
"UploadedFile",
"router", "router",
"mizan_exception_handler", "mizan_exception_handler",
"mizan_validation_handler", "mizan_validation_handler",

View File

@@ -14,16 +14,20 @@ FastAPI router exposing Mizan's HTTP endpoints:
from __future__ import annotations from __future__ import annotations
import json
from typing import Any from typing import Any
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse 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.registry import get_context_groups, get_function
from mizan_core.upload import UploadedFile, bind_uploads
from .executor import ( from .executor import (
BadRequest,
ErrorCode, ErrorCode,
MizanError, MizanError,
NotFound, NotFound,
@@ -59,13 +63,58 @@ class CallBody(BaseModel):
args: dict[str, Any] = Field(default_factory=dict) 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/") @router.post("/call/")
async def function_call(body: CallBody, request: Request) -> JSONResponse: async def function_call(request: Request) -> JSONResponse:
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`.""" """RPC dispatch — `{"fn": "...", "args": {...}}` (JSON) or multipart with file
fn_class = get_function(body.fn) parts → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
result = await execute_function(request, body.fn, body.args) fn, args = await _parse_call(request)
invalidate = compute_invalidation(fn_class, body.args) fn_class = get_function(fn)
merges = compute_merges(fn_class, body.args, result) 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} payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
if merges: if merges:
payload["merge"] = merges payload["merge"] = merges

View 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

View File

@@ -6,6 +6,7 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"PyJWT>=2.0", "PyJWT>=2.0",
"pydantic>=2.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -0,0 +1,3 @@
from mizan_core.upload import File, Upload, UploadedFile, validate_upload
__all__ = ["Upload", "File", "UploadedFile", "validate_upload"]

View File

@@ -487,8 +487,10 @@ def _create_server_function(
# Use function name directly # Use function name directly
name = fn.__name__ name = fn.__name__
# Extract type hints and signature # Extract type hints and signature. include_extras keeps `Annotated[...]`
hints = get_type_hints(fn) # 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) sig = inspect.signature(fn)
params = list(sig.parameters.items()) params = list(sig.parameters.items())

View File

@@ -17,6 +17,7 @@ KDL grammar — locked contract:
| list { <type-child> } | list { <type-child> }
| optional { <type-child> } | optional { <type-child> }
| enum "<v1>" "<v2>" ... | 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.registry import get_all_functions, get_context_groups, get_function
from mizan_core.type_utils import extract_list_element, extract_optional from mizan_core.type_utils import extract_list_element, extract_optional
from mizan_core.upload import File, classify_upload
__all__ = ["build_ir"] __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) _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: def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
"""Emit a `struct { field ... }` block for a Pydantic model.""" """Emit a `struct { field ... }` block for a Pydantic model."""
with block.node("struct") as struct_block: 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) props["default"] = _kdl_value(default)
with struct_block.node("field", _kdl_string(field_name), **props) as field_block: 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: class _StructShape:

View 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

View File

@@ -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 * Default Mizan transport — POST `${baseUrl}/call/` and GET
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`, * `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
@@ -397,13 +409,38 @@ export function httpTransport(): MizanTransport {
return { return {
async call(functionName, args) { async call(functionName, args) {
const headers = await resolveHeaders() 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/`, { const res = await fetch(`${config.baseUrl}/call/`, {
method: 'POST', method: 'POST',
headers, headers,
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ fn: functionName, args }), body,
}) })
if (!res.ok) throw new MizanError(res.status, await res.text()) if (!res.ok) throw new MizanError(res.status, await res.text())
return res.json() return res.json()

View File

@@ -165,6 +165,7 @@ fn ts_type_expression(shape: &TypeShape) -> String {
.map(ts_type_expression) .map(ts_type_expression)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" | "), .join(" | "),
TypeShape::Upload(_) => "File".to_string(),
} }
} }

View File

@@ -118,6 +118,9 @@ fn py_type_expression(shape: &TypeShape) -> String {
.map(py_type_expression) .map(py_type_expression)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" | "), .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(),
} }
} }

View File

@@ -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. // Value so the consumer can match on the runtime variant.
"serde_json::Value".to_string() "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(),
} }
} }

View File

@@ -296,5 +296,6 @@ fn ts_type_expression(shape: &TypeShape) -> String {
.map(ts_type_expression) .map(ts_type_expression)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" | "), .join(" | "),
TypeShape::Upload(_) => "File".to_string(),
} }
} }

View File

@@ -46,6 +46,15 @@ pub enum TypeShape {
Enum(Vec<String>), Enum(Vec<String>),
/// Multi-arm union with two or more non-null branches. /// Multi-arm union with two or more non-null branches.
Union(Vec<TypeShape>), 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")?))), "list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))), "optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
"enum" => Ok(TypeShape::Enum(parse_string_args(node))), "enum" => Ok(TypeShape::Enum(parse_string_args(node))),
"upload" => Ok(TypeShape::Upload(parse_upload_constraints(node))),
"union" => { "union" => {
let children = node.children() let children = node.children()
.ok_or_else(|| anyhow!("union: missing 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> { fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
let name = first_string_arg(node) let name = first_string_arg(node)
.context("`function` requires a name as its first argument")?; .context("`function` requires a name as its first argument")?;

View 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}");
}