From 67ad91b673a2dd5ab6f06b8a1564a14b925741f8 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Thu, 4 Jun 2026 04:20:05 -0400 Subject: [PATCH] Added file upload support --- .gitea/workflows/publish-django.yaml | 2 +- .gitea/workflows/publish-react.yaml | 2 +- ISSUES.md | 1 + README.md | 7 + backends/mizan-django/src/mizan/__init__.py | 5 + .../mizan-django/src/mizan/client/executor.py | 42 +++- .../src/mizan/tests/test_upload.py | 73 ++++++ backends/mizan-fastapi/pyproject.toml | 1 + .../src/mizan_fastapi/__init__.py | 4 + .../mizan-fastapi/src/mizan_fastapi/router.py | 63 ++++- backends/mizan-fastapi/tests/test_upload.py | 71 ++++++ cores/mizan-python/pyproject.toml | 1 + cores/mizan-python/src/mizan_core/__init__.py | 3 + .../src/mizan_core/client/function.py | 6 +- cores/mizan-python/src/mizan_core/ir.py | 36 ++- cores/mizan-python/src/mizan_core/upload.py | 216 ++++++++++++++++++ frontends/mizan-base/src/index.ts | 41 +++- protocol/mizan-codegen/src/emit/channels.rs | 1 + protocol/mizan-codegen/src/emit/python.rs | 3 + protocol/mizan-codegen/src/emit/rust.rs | 3 + protocol/mizan-codegen/src/emit/stage1.rs | 1 + protocol/mizan-codegen/src/ir.rs | 24 ++ .../mizan-codegen/tests/upload_codegen.rs | 79 +++++++ 23 files changed, 665 insertions(+), 20 deletions(-) create mode 100644 backends/mizan-django/src/mizan/tests/test_upload.py create mode 100644 backends/mizan-fastapi/tests/test_upload.py create mode 100644 cores/mizan-python/src/mizan_core/upload.py create mode 100644 protocol/mizan-codegen/tests/upload_codegen.rs diff --git a/.gitea/workflows/publish-django.yaml b/.gitea/workflows/publish-django.yaml index 3d98e0a..d38c83e 100644 --- a/.gitea/workflows/publish-django.yaml +++ b/.gitea/workflows/publish-django.yaml @@ -1,4 +1,4 @@ -name: Publish Django package to PyPI +name: Publish Django package to Gitea registry on: push: diff --git a/.gitea/workflows/publish-react.yaml b/.gitea/workflows/publish-react.yaml index 879d95e..6b293a1 100644 --- a/.gitea/workflows/publish-react.yaml +++ b/.gitea/workflows/publish-react.yaml @@ -1,4 +1,4 @@ -name: Publish React package to npm +name: Publish React package to Gitea registry on: push: diff --git a/ISSUES.md b/ISSUES.md index 2571696..da73931 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -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`. diff --git a/README.md b/README.md index a95d543..a6980a4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backends/mizan-django/src/mizan/__init__.py b/backends/mizan-django/src/mizan/__init__.py index bda06c6..06f1b8d 100644 --- a/backends/mizan-django/src/mizan/__init__.py +++ b/backends/mizan-django/src/mizan/__init__.py @@ -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", diff --git a/backends/mizan-django/src/mizan/client/executor.py b/backends/mizan-django/src/mizan/client/executor.py index b3c6a52..ec908c8 100644 --- a/backends/mizan-django/src/mizan/client/executor.py +++ b/backends/mizan-django/src/mizan/client/executor.py @@ -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 diff --git a/backends/mizan-django/src/mizan/tests/test_upload.py b/backends/mizan-django/src/mizan/tests/test_upload.py new file mode 100644 index 0000000..41dd414 --- /dev/null +++ b/backends/mizan-django/src/mizan/tests/test_upload.py @@ -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()) diff --git a/backends/mizan-fastapi/pyproject.toml b/backends/mizan-fastapi/pyproject.toml index 029d485..e9f6d8f 100644 --- a/backends/mizan-fastapi/pyproject.toml +++ b/backends/mizan-fastapi/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "mizan-core", "fastapi>=0.110", "pydantic>=2.0", + "python-multipart>=0.0.9", ] [project.optional-dependencies] diff --git a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py index 13c2bc4..3003cf1 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py @@ -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", diff --git a/backends/mizan-fastapi/src/mizan_fastapi/router.py b/backends/mizan-fastapi/src/mizan_fastapi/router.py index de8c828..77c67e1 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/router.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/router.py @@ -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 diff --git a/backends/mizan-fastapi/tests/test_upload.py b/backends/mizan-fastapi/tests/test_upload.py new file mode 100644 index 0000000..731ec54 --- /dev/null +++ b/backends/mizan-fastapi/tests/test_upload.py @@ -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 diff --git a/cores/mizan-python/pyproject.toml b/cores/mizan-python/pyproject.toml index 5259805..752a5c5 100644 --- a/cores/mizan-python/pyproject.toml +++ b/cores/mizan-python/pyproject.toml @@ -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] diff --git a/cores/mizan-python/src/mizan_core/__init__.py b/cores/mizan-python/src/mizan_core/__init__.py index e69de29..0e996fb 100644 --- a/cores/mizan-python/src/mizan_core/__init__.py +++ b/cores/mizan-python/src/mizan_core/__init__.py @@ -0,0 +1,3 @@ +from mizan_core.upload import File, Upload, UploadedFile, validate_upload + +__all__ = ["Upload", "File", "UploadedFile", "validate_upload"] diff --git a/cores/mizan-python/src/mizan_core/client/function.py b/cores/mizan-python/src/mizan_core/client/function.py index bcdda59..0ca1ba1 100644 --- a/cores/mizan-python/src/mizan_core/client/function.py +++ b/cores/mizan-python/src/mizan_core/client/function.py @@ -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()) diff --git a/cores/mizan-python/src/mizan_core/ir.py b/cores/mizan-python/src/mizan_core/ir.py index e9838a8..6e0c7d0 100644 --- a/cores/mizan-python/src/mizan_core/ir.py +++ b/cores/mizan-python/src/mizan_core/ir.py @@ -17,6 +17,7 @@ KDL grammar — locked contract: | list { } | optional { } | enum "" "" ... + | upload max-size=? { content-type "" ... } } ... } @@ -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: diff --git a/cores/mizan-python/src/mizan_core/upload.py b/cores/mizan-python/src/mizan_core/upload.py new file mode 100644 index 0000000..3cac300 --- /dev/null +++ b/cores/mizan-python/src/mizan_core/upload.py @@ -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 diff --git a/frontends/mizan-base/src/index.ts b/frontends/mizan-base/src/index.ts index 62eca86..1a6b8f3 100644 --- a/frontends/mizan-base/src/index.ts +++ b/frontends/mizan-base/src/index.ts @@ -386,6 +386,18 @@ async function resolveHeaders(): Promise> { } } +/** 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): 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 = {} + 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() diff --git a/protocol/mizan-codegen/src/emit/channels.rs b/protocol/mizan-codegen/src/emit/channels.rs index 8b84a3d..51205c1 100644 --- a/protocol/mizan-codegen/src/emit/channels.rs +++ b/protocol/mizan-codegen/src/emit/channels.rs @@ -165,6 +165,7 @@ fn ts_type_expression(shape: &TypeShape) -> String { .map(ts_type_expression) .collect::>() .join(" | "), + TypeShape::Upload(_) => "File".to_string(), } } diff --git a/protocol/mizan-codegen/src/emit/python.rs b/protocol/mizan-codegen/src/emit/python.rs index 310fa28..89ff77b 100644 --- a/protocol/mizan-codegen/src/emit/python.rs +++ b/protocol/mizan-codegen/src/emit/python.rs @@ -118,6 +118,9 @@ fn py_type_expression(shape: &TypeShape) -> String { .map(py_type_expression) .collect::>() .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(), } } diff --git a/protocol/mizan-codegen/src/emit/rust.rs b/protocol/mizan-codegen/src/emit/rust.rs index ba1a32e..7a5b89d 100644 --- a/protocol/mizan-codegen/src/emit/rust.rs +++ b/protocol/mizan-codegen/src/emit/rust.rs @@ -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".to_string(), } } diff --git a/protocol/mizan-codegen/src/emit/stage1.rs b/protocol/mizan-codegen/src/emit/stage1.rs index c23bfbe..0378e2c 100644 --- a/protocol/mizan-codegen/src/emit/stage1.rs +++ b/protocol/mizan-codegen/src/emit/stage1.rs @@ -296,5 +296,6 @@ fn ts_type_expression(shape: &TypeShape) -> String { .map(ts_type_expression) .collect::>() .join(" | "), + TypeShape::Upload(_) => "File".to_string(), } } diff --git a/protocol/mizan-codegen/src/ir.rs b/protocol/mizan-codegen/src/ir.rs index 544594e..26055d8 100644 --- a/protocol/mizan-codegen/src/ir.rs +++ b/protocol/mizan-codegen/src/ir.rs @@ -46,6 +46,15 @@ pub enum TypeShape { Enum(Vec), /// Multi-arm union with two or more non-null branches. Union(Vec), + /// Binary file input. Carries the declarative `File(...)` constraints. + Upload(UploadConstraints), +} + + +#[derive(Debug, Clone, Default)] +pub struct UploadConstraints { + pub max_size: Option, + pub content_types: Vec, } @@ -273,6 +282,7 @@ fn parse_type_shape(node: &KdlNode) -> Result { "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 { } +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 { let name = first_string_arg(node) .context("`function` requires a name as its first argument")?; diff --git a/protocol/mizan-codegen/tests/upload_codegen.rs b/protocol/mizan-codegen/tests/upload_codegen.rs new file mode 100644 index 0000000..577ad8a --- /dev/null +++ b/protocol/mizan-codegen/tests/upload_codegen.rs @@ -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}"); +}