From 3aafec6dd4168d724f06607c8be31f926752065b Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Mon, 27 Jul 2026 14:03:19 -0400 Subject: [PATCH] A channel's message slots are named from the client, on every backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IR called them react-message and django-message, so a FastAPI channel had to declare a DjangoMessage. They are client-message and server-message now, and the direction words hold wherever a channel is declared: Params / ClientMessage / ServerMessage, with mizan-core deriving Params and friends so no backend names a type itself. Django's ReactChannel and FastAPI's ReactChannel are both Channel. mizan-fastapi never registered a channels extension, so build_ir() emitted no channel at all and every payload type was invisible to codegen. It registers one now. RegistryExtension is an ABC requiring all(), which is what the IR reads — an extension that cannot enumerate its registrations no longer exists. The gate that should have caught the rename could not: tests/afi registered no channel because mizan-rust had no channel registry to register one in, so a five-package rename of the wire contract passed byte-parity without a channel byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a #[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's split; the AFI fixture now carries a channel with every slot and one with a single slot, so all three backends prove the contract byte for byte. MizanChannel held three Option beside three has_*() predicates and unwrapped them with defaults; it holds an ordered slot vector, so an absent slot is absent rather than defaulted. The channels target emitted a React hooks file that a stage1-only consumer could not compile — react emits that now. The codegen's parity tests byte-compared emitted source against baselines without ever compiling it: they compile the generated crate and run its tests, import the generated Python package and call every method, and typecheck each TypeScript target against a consumer. Also fixed at source: app_visitor printed its import diagnostic to stdout, the stream export_mizan_ir writes KDL to, so a failed import silently corrupted the IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on any non-JSON-serializable field default; Django and mizan-core derived Pascal names two different ways, disagreeing on every dotted channel name. ir.py builds a document and renders templates/ir/document.kdl.j2 rather than appending KDL strings with hand-tracked indentation, and named types resolve to a fixed point — a model reachable only through a union branch was referenced by a ref that no type block ever defined. The rest is the write-gate's own classifiers run over the standing tree: relative imports, silent swallows, Protocol contracts that should be ABCs, emitters hand-rendering target source, catch-all arms over closed enums, and comments narrating the project rather than the code. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 5 +- backends/mizan-django/src/mizan/__init__.py | 128 +- .../src/mizan/_vendor/app_visitor.py | 27 +- .../mizan-django/src/mizan/cache/__init__.py | 24 +- .../src/mizan/channels/__init__.py | 394 +-- .../src/mizan/channels/connection.py | 136 +- .../mizan-django/src/mizan/channels/push.py | 101 +- .../mizan-django/src/mizan/client/__init__.py | 21 +- .../mizan-django/src/mizan/client/executor.py | 300 +- backends/mizan-django/src/mizan/client/jwt.py | 17 +- .../mizan-django/src/mizan/export/__init__.py | 30 +- .../mizan-django/src/mizan/forms/__init__.py | 337 +-- .../src/mizan/forms/schema_utils.py | 78 +- .../src/mizan/forms/validation_utils.py | 13 +- .../mizan-django/src/mizan/jwt/__init__.py | 34 +- .../mizan-django/src/mizan/jwt/security.py | 45 +- backends/mizan-django/src/mizan/jwt/tokens.py | 93 +- .../commands/export_channels_schema.py | 9 +- .../commands/export_edge_manifest.py | 12 +- .../management/commands/export_mizan_ir.py | 18 +- .../mizan-django/src/mizan/setup/__init__.py | 17 +- .../mizan-django/src/mizan/setup/discovery.py | 60 +- .../mizan-django/src/mizan/ssr/__init__.py | 23 +- .../mizan-django/src/mizan/ssr/backend.py | 33 +- backends/mizan-django/src/mizan/ssr/bridge.py | 74 +- .../src/mizan/tests/ssr_components/Hello.tsx | 3 + .../src/mizan/tests/test_benchmarks.py | 74 +- .../src/mizan/tests/test_channels.py | 425 ++- .../mizan-django/src/mizan/tests/test_core.py | 125 +- .../src/mizan/tests/test_discovery.py | 37 + .../src/mizan/tests/test_pentest.py | 88 +- .../src/mizan/tests/test_security.py | 161 +- .../mizan-django/src/mizan/tests/test_ssr.py | 121 +- backends/mizan-django/src/mizan/urls.py | 24 +- backends/mizan-django/tests/models.py | 15 +- backends/mizan-django/tests/settings.py | 11 - .../src/mizan_fastapi/__init__.py | 36 +- .../src/mizan_fastapi/channels.py | 60 +- .../mizan-fastapi/src/mizan_fastapi/ir.py | 3 +- .../mizan-fastapi/src/mizan_fastapi/router.py | 17 +- .../src/mizan_fastapi/websocket.py | 23 +- backends/mizan-fastapi/tests/test_dispatch.py | 23 +- backends/mizan-rust-axum/Cargo.lock | 17 + backends/mizan-rust-axum/src/errors.rs | 25 +- backends/mizan-rust-axum/src/handlers.rs | 149 +- backends/mizan-rust-axum/src/lib.rs | 7 +- backends/mizan-tauri/Cargo.lock | 17 + backends/mizan-tauri/src/lib.rs | 184 +- backends/mizan-ts/src/cache/backend.ts | 7 +- backends/mizan-ts/src/cache/index.ts | 17 +- backends/mizan-ts/src/cache/keys.ts | 14 +- backends/mizan-ts/src/decorator.ts | 49 +- backends/mizan-ts/src/dispatch.ts | 34 +- backends/mizan-ts/src/invalidation.ts | 29 +- backends/mizan-ts/src/manifest.ts | 19 +- backends/mizan-ts/src/registry.ts | 6 +- backends/mizan-ts/src/types.ts | 15 +- backends/mizan-ts/tests/edge-compat.test.ts | 59 +- cores/mizan-python/pyproject.toml | 3 + .../src/mizan_core/cache/backend.py | 59 +- .../mizan-python/src/mizan_core/cache/keys.py | 16 +- .../src/mizan_core/client/function.py | 75 +- cores/mizan-python/src/mizan_core/ir.py | 760 ++--- cores/mizan-python/src/mizan_core/registry.py | 40 +- .../mizan_core/templates/ir/document.kdl.j2 | 121 + .../mizan-python/src/mizan_core/type_utils.py | 18 +- cores/mizan-python/tests/test_ir.py | 107 + cores/mizan-rust-macros/src/channel.rs | 111 + cores/mizan-rust-macros/src/context.rs | 43 +- cores/mizan-rust-macros/src/derive.rs | 354 ++- cores/mizan-rust-macros/src/function.rs | 434 ++- cores/mizan-rust-macros/src/lib.rs | 58 +- cores/mizan-rust-macros/src/shape.rs | 318 +- cores/mizan-rust-ssr/src/lib.rs | 117 +- cores/mizan-rust-ssr/tests/fixture/Hello.js | 3 +- cores/mizan-rust-ssr/tests/fixture/entry.js | 6 +- cores/mizan-rust-ssr/tests/fixture/runner.cjs | 18 +- cores/mizan-rust-ssr/tests/no_rsc.rs | 37 +- cores/mizan-rust/Cargo.lock | 139 + cores/mizan-rust/Cargo.toml | 4 +- cores/mizan-rust/src/graph_check.rs | 423 ++- cores/mizan-rust/src/ir.rs | 54 +- cores/mizan-rust/src/kdl.rs | 642 ++-- cores/mizan-rust/src/lib.rs | 33 +- cores/mizan-rust/src/registry.rs | 40 +- cores/mizan-rust/src/runtime.rs | 127 +- cores/mizan-rust/src/traits.rs | 35 +- cores/mizan-rust/templates/ir.kdl.jinja | 6 + cores/mizan-rust/tests/afi_parity.rs | 143 +- cores/mizan-rust/tests/merge_defects.rs | 68 + cores/mizan-rust/tests/merge_resolution.rs | 113 + examples/django-react-desktop-app/app.py | 40 +- .../backend/clients.py | 65 +- .../frontend/index.html | 4 - .../frontend/package.json | 2 + .../frontend/src/App.tsx | 172 +- .../frontend/src/main.tsx | 1 + .../frontend/src/styles.css | 115 + .../frontend/vite.config.ts | 3 +- .../django-react-desktop-app/pyproject.toml | 2 +- .../tests/conftest.py | 10 +- .../tests/live_http.py | 81 + .../tests/test_desktop_rpc.py | 182 +- .../tests/test_notes.py | 157 +- .../tests/test_system.py | 148 +- .../backend/testapp/clients.py | 81 +- .../backend/testapp/settings.py | 17 +- .../harness/django.config.mjs | 22 - examples/django-react-site/harness/mizan.toml | 8 + .../django-react-site/harness/package.json | 3 + .../harness/src/api/.gitattributes | 1 + .../harness/src/api/channels.hooks.tsx | 38 +- .../harness/src/api/channels.ts | 369 +-- .../harness/src/api/contexts/global.ts | 2 - .../harness/src/api/contexts/local.ts | 2 - .../harness/src/api/functions/add.ts | 2 - .../harness/src/api/functions/buggyFn.ts | 9 - .../harness/src/api/functions/divide.ts | 7 + .../harness/src/api/functions/echo.ts | 2 - .../src/api/functions/echoTransform.ts | 7 + .../harness/src/api/functions/httpOnlyEcho.ts | 2 - .../harness/src/api/functions/jwtObtain.ts | 2 - .../harness/src/api/functions/jwtRefresh.ts | 2 - .../harness/src/api/functions/multiply.ts | 2 - .../src/api/functions/notImplementedFn.ts | 9 - .../src/api/functions/permissionCheckFn.ts | 2 - .../harness/src/api/functions/staffOnly.ts | 2 - .../src/api/functions/superuserOnly.ts | 2 - .../harness/src/api/functions/verifiedOnly.ts | 2 - .../harness/src/api/functions/whoami.ts | 2 - .../harness/src/api/functions/wsWhoami.ts | 2 - .../harness/src/api/index.ts | 19 +- .../harness/src/api/react.tsx | 84 +- .../harness/src/api/schema.json | 2678 ----------------- .../harness/src/api/types.ts | 2230 ++------------ .../harness/src/fixtures.tsx | 65 +- .../harness/test-results/.last-run.json | 4 - .../django-react-site/harness/tsconfig.json | 10 +- examples/django-react-site/mizan.spec.ts | 16 +- examples/fastapi-react-site/backend/main.py | 135 +- .../harness/fastapi.config.mjs | 19 - .../fastapi-react-site/harness/mizan.toml | 9 + .../fastapi-react-site/harness/package.json | 3 + .../harness/src/api/.gitattributes | 1 + .../harness/src/api/contexts/global.ts | 2 - .../harness/src/api/contexts/morphs.ts | 2 - .../harness/src/api/functions/add.ts | 2 - .../harness/src/api/functions/buggyFn.ts | 9 - .../harness/src/api/functions/divide.ts | 7 + .../harness/src/api/functions/echo.ts | 2 - .../src/api/functions/echoTransform.ts | 7 + .../harness/src/api/functions/multiply.ts | 2 - .../src/api/functions/notImplementedFn.ts | 9 - .../src/api/functions/permissionCheckFn.ts | 2 - .../src/api/functions/setMorphValue.ts | 2 - .../harness/src/api/functions/staffOnly.ts | 2 - .../src/api/functions/superuserOnly.ts | 2 - .../harness/src/api/functions/verifiedOnly.ts | 2 - .../harness/src/api/functions/whoami.ts | 2 - .../harness/src/api/index.ts | 15 +- .../harness/src/api/react.tsx | 64 +- .../harness/src/api/schema.json | 1000 ------ .../harness/src/api/types.ts | 883 +----- .../harness/src/fixtures.tsx | 37 +- examples/fastapi-react-site/mizan.spec.ts | 29 +- frontends/mizan-base/src/index.ts | 77 +- .../src/__tests__/context.test.tsx | 253 +- .../mizan-react/src/__tests__/errors.test.ts | 4 - .../mizan-react/src/__tests__/forms.test.tsx | 57 +- .../src/__tests__/integration.test.tsx | 109 +- .../src/channels/__tests__/channelServer.ts | 99 + .../src/channels/__tests__/connection.test.ts | 202 +- .../src/channels/__tests__/context.test.tsx | 267 +- .../src/channels/__tests__/hooks.test.tsx | 214 +- frontends/mizan-react/src/channels/hooks.ts | 50 +- frontends/mizan-react/src/channels/index.ts | 56 - frontends/mizan-react/src/channels/types.ts | 15 +- frontends/mizan-react/src/client/index.ts | 283 +- frontends/mizan-react/src/client/react.ts | 25 +- frontends/mizan-react/src/client/types.ts | 34 +- frontends/mizan-react/src/context.tsx | 175 +- frontends/mizan-react/src/forms.ts | 132 +- frontends/mizan-react/src/index.ts | 48 +- frontends/mizan-react/src/jwt/JWTContext.tsx | 108 +- .../src/jwt/__tests__/JWTContext.test.tsx | 250 +- .../src/jwt/__tests__/contract.test.ts | 9 +- .../src/jwt/__tests__/hooks.test.tsx | 7 - .../src/jwt/__tests__/jwtServer.ts | 98 + frontends/mizan-react/src/testing.ts | 26 +- frontends/mizan-react/src/utils.ts | 10 +- frontends/mizan-react/vitest.config.ts | 5 +- frontends/mizan-react/vitest.setup.ts | 5 - frontends/mizan-rust/src/client.rs | 320 +- frontends/mizan-rust/src/context.rs | 593 ++-- frontends/mizan-rust/src/error.rs | 54 +- frontends/mizan-rust/src/invalidation.rs | 80 +- frontends/mizan-rust/src/lib.rs | 7 +- frontends/mizan-rust/src/merge.rs | 137 +- frontends/mizan-rust/src/pyo3_bridge.rs | 177 +- frontends/mizan-rust/src/transport.rs | 174 +- .../mizan-webview-transport/src/index.ts | 51 +- protocol/mizan-codegen/scripts/run_decoru.py | 90 +- protocol/mizan-codegen/src/config.rs | 158 +- protocol/mizan-codegen/src/emit/casing.rs | 12 +- protocol/mizan-codegen/src/emit/channels.rs | 160 +- protocol/mizan-codegen/src/emit/mod.rs | 143 +- protocol/mizan-codegen/src/emit/python.rs | 411 ++- protocol/mizan-codegen/src/emit/react.rs | 174 +- protocol/mizan-codegen/src/emit/rust.rs | 580 ++-- protocol/mizan-codegen/src/emit/stage1.rs | 320 +- protocol/mizan-codegen/src/emit/svelte.rs | 33 +- protocol/mizan-codegen/src/emit/vue.rs | 43 +- protocol/mizan-codegen/src/fetch.rs | 254 +- protocol/mizan-codegen/src/ir.rs | 402 +-- protocol/mizan-codegen/src/ir/kdl.rs | 536 ++++ protocol/mizan-codegen/src/main.rs | 71 +- .../templates/channels/channels.hooks.tsx.j2 | 20 +- .../templates/channels/channels.ts.j2 | 22 +- .../mizan-codegen/templates/decoru/enum.rs.j2 | 7 + .../templates/decoru/schema.rs.j2 | 2 + .../templates/python/__init__.py.j2 | 2 - .../templates/python/client.py.j2 | 25 +- .../templates/python/schema.py.j2 | 17 + .../templates/python/type_expr.py.j2 | 14 + .../templates/python/types.py.j2 | 7 +- .../templates/react/react.tsx.j2 | 28 +- .../templates/rust/Cargo.toml.j2 | 2 +- .../mizan-codegen/templates/rust/call.rs.j2 | 9 +- .../templates/rust/context.rs.j2 | 14 +- .../mizan-codegen/templates/rust/lib.rs.j2 | 2 - .../mizan-codegen/templates/rust/mod.rs.j2 | 2 - .../mizan-codegen/templates/rust/schema.rs.j2 | 20 + .../templates/rust/type_expr.rs.j2 | 10 + .../mizan-codegen/templates/rust/types.rs.j2 | 9 +- .../mizan-codegen/templates/stage1/call.ts.j2 | 10 +- .../templates/stage1/context.ts.j2 | 2 - .../templates/stage1/index.ts.j2 | 3 - .../templates/stage1/schema.ts.j2 | 16 + .../templates/stage1/type_expr.ts.j2 | 14 + .../templates/stage1/types.ts.j2 | 3 + .../templates/svelte/svelte.ts.j2 | 7 +- .../mizan-codegen/templates/vue/vue.ts.j2 | 7 +- .../mizan-codegen/tests/channels_smoke.rs | 80 - .../fixtures/baselines/python/__init__.py | 4 - .../tests/fixtures/baselines/python/client.py | 67 - .../tests/fixtures/baselines/python/types.py | 56 - .../fixtures/baselines/react/contexts/user.ts | 18 - .../baselines/react/functions/echo.ts | 9 - .../baselines/react/functions/findUser.ts | 9 - .../baselines/react/functions/renameUser.ts | 9 - .../baselines/react/functions/whoami.ts | 9 - .../tests/fixtures/baselines/react/index.ts | 14 - .../react/mutations/updateProfile.ts | 9 - .../tests/fixtures/baselines/react/react.tsx | 156 - .../tests/fixtures/baselines/react/types.ts | 64 - .../tests/fixtures/baselines/rust/Cargo.toml | 10 - .../baselines/rust/src/contexts/mod.rs | 3 - .../baselines/rust/src/contexts/user.rs | 29 - .../baselines/rust/src/functions/echo.rs | 14 - .../baselines/rust/src/functions/find_user.rs | 14 - .../baselines/rust/src/functions/mod.rs | 6 - .../rust/src/functions/rename_user.rs | 14 - .../baselines/rust/src/functions/whoami.rs | 14 - .../tests/fixtures/baselines/rust/src/lib.rs | 8 - .../baselines/rust/src/mutations/mod.rs | 3 - .../rust/src/mutations/update_profile.rs | 14 - .../fixtures/baselines/rust/src/types.rs | 81 - .../baselines/stage1/contexts/user.ts | 18 - .../baselines/stage1/functions/echo.ts | 9 - .../baselines/stage1/functions/findUser.ts | 9 - .../baselines/stage1/functions/renameUser.ts | 9 - .../baselines/stage1/functions/whoami.ts | 9 - .../tests/fixtures/baselines/stage1/index.ts | 11 - .../stage1/mutations/updateProfile.ts | 9 - .../tests/fixtures/baselines/stage1/types.ts | 64 - .../baselines/svelte/contexts/user.ts | 18 - .../baselines/svelte/functions/echo.ts | 9 - .../baselines/svelte/functions/findUser.ts | 9 - .../baselines/svelte/functions/renameUser.ts | 9 - .../baselines/svelte/functions/whoami.ts | 9 - .../tests/fixtures/baselines/svelte/index.ts | 14 - .../svelte/mutations/updateProfile.ts | 9 - .../tests/fixtures/baselines/svelte/svelte.ts | 29 - .../tests/fixtures/baselines/svelte/types.ts | 64 - .../fixtures/baselines/vue/contexts/user.ts | 18 - .../fixtures/baselines/vue/functions/echo.ts | 9 - .../baselines/vue/functions/findUser.ts | 9 - .../baselines/vue/functions/renameUser.ts | 9 - .../baselines/vue/functions/whoami.ts | 9 - .../tests/fixtures/baselines/vue/index.ts | 14 - .../baselines/vue/mutations/updateProfile.ts | 9 - .../tests/fixtures/baselines/vue/types.ts | 64 - .../tests/fixtures/baselines/vue/vue.ts | 96 - .../tests/fixtures/channels_ir.kdl | 16 +- .../fixtures/toolchain/channels-tsconfig.json | 13 + .../fixtures/toolchain/drivers/react.tsx | 59 + .../fixtures/toolchain/drivers/stage1.ts | 58 + .../fixtures/toolchain/drivers/svelte.ts | 28 + .../tests/fixtures/toolchain/drivers/vue.ts | 32 + .../tests/fixtures/toolchain/mizan_rust.py | 46 + .../tests/fixtures/toolchain/python_driver.py | 73 + .../tests/fixtures/toolchain/rust_driver.rs | 97 + .../toolchain/shims/mizan-channels.d.ts | 22 + .../toolchain/shims/svelte-store.d.ts | 13 + .../tests/fixtures/toolchain/shims/vue.d.ts | 15 + .../tests/fixtures/toolchain/tsconfig.json | 13 + protocol/mizan-codegen/tests/harness/mod.rs | 125 + .../mizan-codegen/tests/ir_deserialization.rs | 62 +- protocol/mizan-codegen/tests/python_parity.rs | 75 - protocol/mizan-codegen/tests/python_target.rs | 31 + protocol/mizan-codegen/tests/react_parity.rs | 54 - protocol/mizan-codegen/tests/rust_parity.rs | 96 - protocol/mizan-codegen/tests/rust_target.rs | 53 + protocol/mizan-codegen/tests/stage1_parity.rs | 143 - .../mizan-codegen/tests/typescript_targets.rs | 215 ++ .../mizan-codegen/tests/vue_svelte_parity.rs | 66 - protocol/mizan-generate/bin/launcher.mjs | 56 +- .../bin/mizan-generate-linux-x64 | Bin 2568632 -> 0 bytes tests/afi/afi_codegen_app.py | 9 +- tests/afi/django_app/afi_app/apps.py | 5 +- tests/afi/django_app/afi_app/channels.py | 44 + tests/afi/django_app/project/urls.py | 2 - tests/afi/fastapi_app.py | 31 +- tests/afi/fixture.py | 74 +- tests/afi/rust_app/Cargo.lock | 17 + tests/afi/rust_app/src/bin/export_ir.rs | 8 +- tests/afi/rust_app/src/bin/server.rs | 23 +- tests/afi/rust_app/src/lib.rs | 101 +- tests/afi/test_codegen_parity.py | 28 +- tests/rust/fixture_client/.gitattributes | 1 + tests/rust/fixture_client/src/contexts/mod.rs | 2 - .../rust/fixture_client/src/contexts/user.rs | 10 +- .../rust/fixture_client/src/functions/echo.rs | 7 +- .../fixture_client/src/functions/find_user.rs | 7 +- .../rust/fixture_client/src/functions/mod.rs | 2 - .../src/functions/rename_user.rs | 7 +- .../fixture_client/src/functions/whoami.rs | 6 +- tests/rust/fixture_client/src/lib.rs | 2 - .../rust/fixture_client/src/mutations/mod.rs | 2 - .../src/mutations/update_profile.rs | 7 +- tests/rust/fixture_client/src/types.rs | 2 - tests/rust/run_wire_parity.py | 14 +- tests/rust/src/drive_emitted.rs | 9 +- tests/rust/src/drive_kernel.rs | 15 +- workers/mizan-ssr/src/index.ts | 2 - 345 files changed, 11054 insertions(+), 17359 deletions(-) create mode 100644 backends/mizan-django/src/mizan/tests/ssr_components/Hello.tsx create mode 100644 backends/mizan-django/src/mizan/tests/test_discovery.py create mode 100644 cores/mizan-python/src/mizan_core/templates/ir/document.kdl.j2 create mode 100644 cores/mizan-python/tests/test_ir.py create mode 100644 cores/mizan-rust-macros/src/channel.rs create mode 100644 cores/mizan-rust/templates/ir.kdl.jinja create mode 100644 cores/mizan-rust/tests/merge_defects.rs create mode 100644 cores/mizan-rust/tests/merge_resolution.rs create mode 100644 examples/django-react-desktop-app/frontend/src/styles.css create mode 100644 examples/django-react-desktop-app/tests/live_http.py delete mode 100644 examples/django-react-site/harness/django.config.mjs create mode 100644 examples/django-react-site/harness/mizan.toml create mode 100644 examples/django-react-site/harness/src/api/.gitattributes delete mode 100644 examples/django-react-site/harness/src/api/functions/buggyFn.ts create mode 100644 examples/django-react-site/harness/src/api/functions/divide.ts create mode 100644 examples/django-react-site/harness/src/api/functions/echoTransform.ts delete mode 100644 examples/django-react-site/harness/src/api/functions/notImplementedFn.ts delete mode 100644 examples/django-react-site/harness/src/api/schema.json delete mode 100644 examples/django-react-site/harness/test-results/.last-run.json delete mode 100644 examples/fastapi-react-site/harness/fastapi.config.mjs create mode 100644 examples/fastapi-react-site/harness/mizan.toml create mode 100644 examples/fastapi-react-site/harness/src/api/.gitattributes delete mode 100644 examples/fastapi-react-site/harness/src/api/functions/buggyFn.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/divide.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/echoTransform.ts delete mode 100644 examples/fastapi-react-site/harness/src/api/functions/notImplementedFn.ts delete mode 100644 examples/fastapi-react-site/harness/src/api/schema.json create mode 100644 frontends/mizan-react/src/channels/__tests__/channelServer.ts create mode 100644 frontends/mizan-react/src/jwt/__tests__/jwtServer.ts create mode 100644 protocol/mizan-codegen/src/ir/kdl.rs create mode 100644 protocol/mizan-codegen/templates/decoru/enum.rs.j2 create mode 100644 protocol/mizan-codegen/templates/decoru/schema.rs.j2 create mode 100644 protocol/mizan-codegen/templates/python/schema.py.j2 create mode 100644 protocol/mizan-codegen/templates/python/type_expr.py.j2 create mode 100644 protocol/mizan-codegen/templates/rust/schema.rs.j2 create mode 100644 protocol/mizan-codegen/templates/rust/type_expr.rs.j2 create mode 100644 protocol/mizan-codegen/templates/stage1/schema.ts.j2 create mode 100644 protocol/mizan-codegen/templates/stage1/type_expr.ts.j2 create mode 100644 protocol/mizan-codegen/templates/stage1/types.ts.j2 delete mode 100644 protocol/mizan-codegen/tests/channels_smoke.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/python/__init__.py delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/python/client.py delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/python/types.py delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/react/contexts/user.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/react/functions/echo.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/react/functions/findUser.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/react/functions/renameUser.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/react/functions/whoami.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/react/index.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/react/mutations/updateProfile.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/react/types.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/Cargo.toml delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/contexts/mod.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/contexts/user.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/echo.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/find_user.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/mod.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/rename_user.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/whoami.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/lib.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/mutations/mod.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/mutations/update_profile.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/rust/src/types.rs delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/stage1/contexts/user.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/echo.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/findUser.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/renameUser.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/whoami.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/stage1/index.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/stage1/mutations/updateProfile.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/stage1/types.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/svelte/contexts/user.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/echo.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/findUser.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/renameUser.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/whoami.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/svelte/index.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/svelte/mutations/updateProfile.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/svelte/svelte.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/svelte/types.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/vue/contexts/user.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/echo.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/findUser.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/renameUser.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/whoami.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/vue/index.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/vue/mutations/updateProfile.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/vue/types.ts delete mode 100644 protocol/mizan-codegen/tests/fixtures/baselines/vue/vue.ts create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/channels-tsconfig.json create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/drivers/react.tsx create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/drivers/stage1.ts create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/drivers/svelte.ts create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/drivers/vue.ts create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/mizan_rust.py create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/python_driver.py create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/rust_driver.rs create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/shims/mizan-channels.d.ts create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/shims/svelte-store.d.ts create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/shims/vue.d.ts create mode 100644 protocol/mizan-codegen/tests/fixtures/toolchain/tsconfig.json create mode 100644 protocol/mizan-codegen/tests/harness/mod.rs delete mode 100644 protocol/mizan-codegen/tests/python_parity.rs create mode 100644 protocol/mizan-codegen/tests/python_target.rs delete mode 100644 protocol/mizan-codegen/tests/react_parity.rs delete mode 100644 protocol/mizan-codegen/tests/rust_parity.rs create mode 100644 protocol/mizan-codegen/tests/rust_target.rs delete mode 100644 protocol/mizan-codegen/tests/stage1_parity.rs create mode 100644 protocol/mizan-codegen/tests/typescript_targets.rs delete mode 100644 protocol/mizan-codegen/tests/vue_svelte_parity.rs delete mode 100755 protocol/mizan-generate/bin/mizan-generate-linux-x64 create mode 100644 tests/afi/django_app/afi_app/channels.py create mode 100644 tests/rust/fixture_client/.gitattributes delete mode 100644 workers/mizan-ssr/src/index.ts diff --git a/.gitignore b/.gitignore index 0a8911b..655a53a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,15 +19,14 @@ target/ /test-results/ /playwright-report/ /blob-report/ +examples/django-react-site/harness/test-results/ # IDE .idea/ .vscode/ # Build artifacts -examples/django-react-desktop-app/frontend/dist/ -examples/django-react-site/harness/src/api/generated.* -examples/django-react-site/harness/test-results/ +protocol/mizan-generate/bin/mizan-generate-* # Env .env diff --git a/backends/mizan-django/src/mizan/__init__.py b/backends/mizan-django/src/mizan/__init__.py index bda06c6..494466e 100644 --- a/backends/mizan-django/src/mizan/__init__.py +++ b/backends/mizan-django/src/mizan/__init__.py @@ -1,98 +1,31 @@ """ -mizan - Django + React unified framework +The mizan package surface: the `client` decorator with its context types, the +`Channel` base and its registry, the form/shape/export submodules, and +`wrap_asgi`, which mounts the WebSocket consumer alongside an HTTP application. -Server functions are the core primitive. Everything else builds on them. - -## Quick Start - -### 1. urls.py - HTTP endpoint -```python -from mizan import urls as mizan_urls - -urlpatterns = [ - path('api/mizan/', include(mizan_urls)), -] -``` - -### 2. asgi.py - WebSocket support (optional) -```python -from mizan import wrap_asgi -from django.core.asgi import get_asgi_application - -application = wrap_asgi(get_asgi_application()) -``` - -### 3. Define server functions -```python -# apps/myapp/clients.py -from mizan import client -from pydantic import BaseModel - -class EchoOutput(BaseModel): - message: str - -# HTTP-only function (default) -@client -def echo(request, text: str) -> EchoOutput: - return EchoOutput(message=f"Echo: {text}") - -# Global context (singleton, SSR-hydrated) -@client(context='global') -def current_user(request) -> UserOutput: - return UserOutput(email=request.user.email) - -# WebSocket-enabled for real-time -@client(websocket=True) -def send_message(request, room_id: int, text: str) -> MessageOutput: - return MessageOutput(...) -``` - -### 4. Auto-discover in apps.py -```python -class MyAppConfig(AppConfig): - def ready(self): - from mizan.setup import mizan_clients - mizan_clients('apps') -``` - -### 5. Frontend - generate types and use -```bash -npm run schemas -``` -```tsx -import { useEcho, useCurrentUser } from '@/api' - -const user = useCurrentUser() -const echo = useEcho() -await echo({ text: 'hello' }) -``` - -## What You Get - -| Backend | Frontend | Transport | -|------------------------------------|-----------------------|------------| -| `@client` | `useXxx()` hook | HTTP | -| `@client(context='global')` | `useXxx()` + SSR | HTTP | -| `@client(context='local')` | `` + hook| HTTP | -| `@client(websocket=True)` | `useXxx()` hook | WebSocket | -| `@compose(...)` | `` combined | varies | -| `mizanFormMixin` | `useXxxForm()` + Zod | HTTP | -| `ReactChannel` | `useXxxChannel()` | WebSocket | +`urls` and `Shape` resolve through `__getattr__` rather than at import time. """ # All imports at module level (sorted) -from . import channels -from . import client as client_module -from . import export -from . import forms -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 import channels +from mizan import client as client_module +from mizan import export +from mizan import forms +from mizan import setup +from mizan.channels import Channel +from mizan.channels import register as register_channel +from mizan.client import ( + ComposedContext, + GlobalContext, + ReactContext, + ServerFunction, + client, + compose, +) # Shape is lazy-loaded via __getattr__ because django_readers # imports contenttypes, which can't happen during apps.populate() -from .setup import ( +from mizan.setup import ( mizan_clients, mizan_module, get_channel, @@ -105,11 +38,11 @@ from .setup import ( def __getattr__(name): """Lazy loading for modules that can't be imported at app load time.""" if name == "urls": - from .urls import urlpatterns as mizan_patterns + from mizan.urls import urlpatterns as mizan_patterns return mizan_patterns if name == "Shape": - from .shapes import Shape + from mizan.shapes import Shape return Shape raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -117,17 +50,8 @@ def __getattr__(name): def wrap_asgi(http_application): """ - Wrap an ASGI application with mizan WebSocket support. - - Usage in asgi.py: - from django.core.asgi import get_asgi_application - from mizan import wrap_asgi - - application = wrap_asgi(get_asgi_application()) - - This adds: - - WebSocket routing at /ws/ for RPC and channels - - Authentication middleware for WebSocket connections + Route HTTP to `http_application` and /ws/ to the mizan consumer, with the + channels auth middleware supplying `scope["user"]` on the socket branch. """ try: from channels.auth import AuthMiddlewareStack @@ -140,7 +64,7 @@ def wrap_asgi(http_application): "Add 'channels' to INSTALLED_APPS and configure CHANNEL_LAYERS." ) - from .channels.connection import DjangoReactConsumer + from mizan.channels.connection import DjangoReactConsumer return ProtocolTypeRouter( { @@ -174,7 +98,7 @@ __all__ = [ # ASGI "wrap_asgi", # Channels - "ReactChannel", + "Channel", "register_channel", # Shapes "Shape", diff --git a/backends/mizan-django/src/mizan/_vendor/app_visitor.py b/backends/mizan-django/src/mizan/_vendor/app_visitor.py index 8c171d7..ea597f5 100644 --- a/backends/mizan-django/src/mizan/_vendor/app_visitor.py +++ b/backends/mizan-django/src/mizan/_vendor/app_visitor.py @@ -1,15 +1,20 @@ import inspect +import sys +from abc import ABC, abstractmethod from importlib import import_module from inspect import isclass -from typing import Protocol, Any +from typing import Any + from django.conf import settings def get_members(path): try: module = import_module(path) - except ModuleNotFoundError: - print('Could not import module "{}"'.format(path)) + except ModuleNotFoundError as exc: + # Callers of this module write machine-read output to stdout, so the + # diagnostic must not share that stream. + print(f'Could not import module "{path}": {exc}', file=sys.stderr) return [] members = [ @@ -21,7 +26,8 @@ def get_members(path): return members -class DjangoAppVisitorHandler(Protocol): +class DjangoAppVisitorHandler(ABC): + @abstractmethod def on_module( self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]] ) -> None: ... @@ -29,13 +35,12 @@ class DjangoAppVisitorHandler(Protocol): class DjangoAppVisitor: """ - Discovers Python modules under each Django app following conventions: - - /.py -> url_prefix "/" - - //**/*.py -> url_prefix "///" + Walks each installed app for modules named after `layer`: + /.py -> path_parts [] + //**/*.py -> path_parts [, ] - Example: - //forms/nksn.py -> url_prefix "/forms/nksn/" - module_path ".module.forms.nksn" + `apps_root` is the dotted package the apps live under, relative to + BASE_DIR; "" means the apps sit directly at BASE_DIR. """ def __init__( @@ -66,7 +71,6 @@ class DjangoAppVisitor: app_module = f"{module_prefix}{app_name}" - # 1) Visit package: //**/*.py layer_dir = app_dir / self.layer if layer_dir.is_dir(): for py_file in layer_dir.rglob("*.py"): @@ -83,7 +87,6 @@ class DjangoAppVisitor: get_members(f"{app_module}.{self.layer}.{dotted}"), ) - # 2) Visit module module file: /module.py layer_file = app_dir / f"{self.layer}.py" if layer_file.is_file(): handler.on_module( diff --git a/backends/mizan-django/src/mizan/cache/__init__.py b/backends/mizan-django/src/mizan/cache/__init__.py index bd9f0ea..cc4c2bc 100644 --- a/backends/mizan-django/src/mizan/cache/__init__.py +++ b/backends/mizan-django/src/mizan/cache/__init__.py @@ -1,12 +1,8 @@ """ -mizan.cache — Origin-side cache implementing the Mizan cache protocol. +Origin-side cache keyed by HMAC digests of (context, params, user, rev). -Simple key-value cache with HMAC-derived keys. No reverse indexes. -Scoped purge recomputes the key and deletes directly. -Broad purge uses key-prefix scan (rare operation). - -Usage: - from mizan.cache import get_cache, cache_get, cache_put, cache_purge +There are no reverse indexes: a scoped purge recomputes the one key it needs +and deletes it, and a purge with no params falls back to a key-prefix scan. """ from __future__ import annotations @@ -28,7 +24,7 @@ _init_lock = threading.Lock() def get_cache() -> CacheBackend | None: """ Get the configured cache backend, or None if caching is disabled. - Thread-safe. + Thread-safe; the backend is built once on first call. """ global _cache_instance, _initialized if _initialized: @@ -43,6 +39,8 @@ def get_cache() -> CacheBackend | None: from mizan.setup.settings import get_settings settings = get_settings() + # Both settings are required; one without the other is a + # misconfiguration worth naming rather than silently ignoring. if settings.cache_secret and settings.cache_redis_url: _cache_instance = RedisCache(settings.cache_redis_url) logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url) @@ -113,13 +111,11 @@ def cache_purge( rev: int = 0, ) -> int: """ - Purge cached entries for a context. + Purge cached entries for a context and return how many were removed. - Scoped purge (params provided): recomputes the HMAC key and deletes - it directly. One DELETE, no index needed. - - Broad purge (no params): scans by key prefix "ctx:{context}:*". - This is a rare operation (Tier 3 fallback in invalidation). + With params and a secret, the exact key is recomputed and deleted — one + DELETE. Without them, every key under the prefix "ctx:{context}:" is + scanned and removed. """ if params is not None and len(params) > 0 and secret: key = derive_cache_key(secret, context, params, user_id, rev) diff --git a/backends/mizan-django/src/mizan/channels/__init__.py b/backends/mizan-django/src/mizan/channels/__init__.py index cb5f437..32cdcce 100644 --- a/backends/mizan-django/src/mizan/channels/__init__.py +++ b/backends/mizan-django/src/mizan/channels/__init__.py @@ -1,81 +1,15 @@ -""" -mizan.channels - Real-time WebSocket communication. - -Type-safe bidirectional messaging between Django and React via WebSockets. -Hooks are auto-generated with full TypeScript types. - -## Basic Usage - -```python -# channels.py -from pydantic import BaseModel -from mizan import channels - -class ChatChannel(channels.ReactChannel): - - class Params(BaseModel): - room: str - - class ReactMessage(BaseModel): - text: str - - class DjangoMessage(BaseModel): - user: str - text: str - timestamp: datetime - - def authorize(self, params: Params) -> bool: - return self.user.is_authenticated - - def group(self, params: Params) -> str: - return f'chat_{params.room}' - - def receive(self, params: Params, msg: ReactMessage) -> DjangoMessage | None: - return self.DjangoMessage( - user=self.user.email, - text=msg.text, - timestamp=now(), - ) - -channels.register(ChatChannel, 'chat') -``` - -```python -# asgi.py -from mizan import channels - -application = ProtocolTypeRouter({ - "http": get_asgi_application(), - "websocket": channels.get_websocket_application(), -}) -``` - -## Frontend Usage (auto-generated) - -```tsx -import { useChatChannel } from '@/api/generated.channels' - -function Chat({ room }) { - const chat = useChatChannel({ room }) - - chat.status // 'connecting' | 'connected' | 'disconnected' - chat.messages // DjangoMessage[] - chat.send({ text: 'Hello' }) // ReactMessage -} -``` - -## Server Push - -```python -await ChatChannel.push(room='general', message=ChatChannel.DjangoMessage(...)) -``` -""" +"""WebSocket channels: the Channel base class, the channel registry, and +the schema exports built from it.""" +import abc import logging from typing import TYPE_CHECKING, Any, ClassVar, Type from pydantic import BaseModel +from mizan_core.ir import wire_to_pascal +from mizan_core.registry import RegistryExtension, register_extension + if TYPE_CHECKING: from django.contrib.auth.models import AbstractBaseUser, AnonymousUser from ninja import NinjaAPI @@ -84,36 +18,25 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -# ============================================================================= -# Base Classes -# ============================================================================= - - -class ReactChannel: +class Channel(abc.ABC): """ - Base class for WebSocket channels. + A WebSocket channel. - Define nested Pydantic classes for typed messaging: - - Params: Query parameters for subscribing (optional) - - ReactMessage: Messages from browser to server (optional) - - DjangoMessage: Messages from server to browser (optional) + Subclasses declare the wire types as nested Pydantic models: + Params (subscription query parameters), ClientMessage (travels + client -> server), ServerMessage (travels server -> client). Any + slot left undeclared stays None and that direction is unavailable. - Implement required methods: - - authorize(): Permission check for connection - - group(): Which group to broadcast to - - Optionally implement: - - receive(): Handle incoming ReactMessage, return DjangoMessage to broadcast - - on_connect(): Called after successful connection - - on_disconnect(): Called when connection closes + authorize() and group() are abstract. receive(), on_connect() and + on_disconnect() are the override points; each definition here records + what happened and a subclass replaces or extends it. """ - # Nested classes (optional, defined by subclasses) Params: ClassVar[Type[BaseModel] | None] = None - ReactMessage: ClassVar[Type[BaseModel] | None] = None - DjangoMessage: ClassVar[Type[BaseModel] | None] = None + ClientMessage: ClassVar[Type[BaseModel] | None] = None + ServerMessage: ClassVar[Type[BaseModel] | None] = None - # Set by the framework when handling a connection + # Set by the consumer when it builds an instance for a subscription. user: "AbstractBaseUser | AnonymousUser" _channel_layer: Any = None _channel_name: str = "" @@ -125,64 +48,58 @@ class ReactChannel: self._groups = set() self._params_dict = {} + @abc.abstractmethod def authorize(self, params: BaseModel | None = None) -> bool: - """ - Permission check. Return True to allow connection, False to reject. - - Override this to implement custom authorization logic. - """ - raise NotImplementedError( - f"{self.__class__.__name__} must implement authorize()" - ) + """Return True to allow the connection, False to reject it.""" + @abc.abstractmethod def group(self, params: BaseModel | None = None) -> str: - """ - Return the group name for broadcasting. - - Messages returned from receive() are broadcast to this group. - """ - raise NotImplementedError(f"{self.__class__.__name__} must implement group()") + """Return the channel-layer group name this subscription broadcasts to.""" def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None: """ - Handle incoming ReactMessage. - - Return a DjangoMessage to broadcast to the group, or None to skip. - Override this to implement message handling. + Handle one ClientMessage; a returned ServerMessage is broadcast to the + group. A channel that accepts inbound frames overrides this — reaching + the definition here means the frame has nowhere to go. """ + logger.warning( + "%s does not handle inbound %s; the frame is dropped", + type(self).__name__, + type(msg).__name__, + ) return None async def on_connect(self, params: BaseModel | None = None) -> None: - """Called after successful connection and group join.""" - pass + """Runs after the group join; a subclass extends it via super().""" + logger.debug( + "%s subscription opened on %s", + type(self).__name__, + self._channel_name or "", + ) async def on_disconnect(self) -> None: - """Called when the connection closes.""" - pass - - # ------------------------------------------------------------------------- - # Internal Methods (used by the consumer) - # ------------------------------------------------------------------------- + """Runs as the subscription closes; a subclass extends it via super().""" + logger.debug( + "%s subscription closed, leaving %d group(s)", + type(self).__name__, + len(self._groups), + ) async def _join_group(self, group_name: str) -> None: - """Join a channel layer group.""" if self._channel_layer: await self._channel_layer.group_add(group_name, self._channel_name) self._groups.add(group_name) async def _leave_group(self, group_name: str) -> None: - """Leave a channel layer group.""" if self._channel_layer and group_name in self._groups: await self._channel_layer.group_discard(group_name, self._channel_name) self._groups.discard(group_name) async def _leave_all_groups(self) -> None: - """Leave all joined groups.""" for group_name in list(self._groups): await self._leave_group(group_name) async def _broadcast(self, group_name: str, message: BaseModel) -> None: - """Broadcast a message to a group.""" if self._channel_layer: await self._channel_layer.group_send( group_name, @@ -195,20 +112,11 @@ class ReactChannel: }, ) - # ------------------------------------------------------------------------- - # Class Methods for Server Push - # ------------------------------------------------------------------------- - @classmethod async def push(cls, message: BaseModel, **params) -> None: """ - Push a message from server code (views, tasks, signals). - - Usage: - await ChatChannel.push( - room='general', - message=ChatChannel.DjangoMessage(user='system', text='Hello') - ) + Send a ServerMessage to every subscriber of the group named by the + given params, from outside a subscription (views, tasks, signals). """ from channels.layers import get_channel_layer @@ -219,16 +127,13 @@ class ReactChannel: ) return - # Build params model if defined params_obj = None if cls.Params: params_obj = cls.Params(**params) - # Get group name instance = cls() group_name = instance.group(params_obj) - # Send to group await channel_layer.group_send( group_name, { @@ -241,63 +146,31 @@ class ReactChannel: ) -# ============================================================================= -# Registry -# ============================================================================= - -_registry: dict[str, Type[ReactChannel]] = {} +_registry: dict[str, Type[Channel]] = {} -def register(channel_class: Type[ReactChannel], name: str) -> None: - """ - Register a channel. - - Args: - channel_class: The ReactChannel subclass to register - name: URL-friendly name (used in subscriptions) - """ +def register(channel_class: Type[Channel], name: str) -> None: + """Register a channel class under a URL-friendly wire name.""" if name in _registry: raise ValueError(f"Channel '{name}' is already registered") channel_class._registered_name = name - - # Validate the channel class - if not hasattr(channel_class, "authorize"): - raise ValueError(f"{channel_class.__name__} must implement authorize()") - if not hasattr(channel_class, "group"): - raise ValueError(f"{channel_class.__name__} must implement group()") - _registry[name] = channel_class logger.debug(f"Registered channel: {name} -> {channel_class.__name__}") -def get_channel(name: str) -> Type[ReactChannel] | None: +def get_channel(name: str) -> Type[Channel] | None: """Get a registered channel class by name.""" return _registry.get(name) -def get_registered_channels() -> dict[str, Type[ReactChannel]]: - """Get all registered channel classes.""" +def get_registered_channels() -> dict[str, Type[Channel]]: + """Get a copy of the name -> channel-class registry.""" return dict(_registry) -# ============================================================================= -# WebSocket Consumer -# ============================================================================= - - def get_websocket_application(): - """ - Get the WebSocket application for ASGI. - - Usage in asgi.py: - from mizan import channels - - application = ProtocolTypeRouter({ - "http": get_asgi_application(), - "websocket": channels.get_websocket_application(), - }) - """ + """Build the ASGI application that serves every registered channel.""" try: from channels.routing import URLRouter from channels.auth import AuthMiddlewareStack @@ -308,7 +181,7 @@ def get_websocket_application(): "Install it with: pip install channels channels-redis" ) - from .connection import DjangoReactConsumer + from mizan.channels.connection import DjangoReactConsumer return AuthMiddlewareStack( URLRouter( @@ -319,42 +192,30 @@ def get_websocket_application(): ) -# ============================================================================= -# Schema Export (for TypeScript generation) -# ============================================================================= - - def get_channels_schema() -> dict: - """ - Get schema for all registered channels (for TypeScript generation). - - Returns a dict suitable for the frontend code generator. - """ + """JSON-schema per registered channel, keyed by wire name.""" schema = {"channels": {}} for name, channel_class in _registry.items(): channel_schema = { "name": name, "params": None, - "reactMessage": None, - "djangoMessage": None, + "clientMessage": None, + "serverMessage": None, } - # Extract Params schema - if hasattr(channel_class, "Params") and channel_class.Params: + if channel_class.Params: channel_schema["params"] = channel_class.Params.model_json_schema() - # Extract ReactMessage schema - if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage: + if channel_class.ClientMessage: channel_schema[ - "reactMessage" - ] = channel_class.ReactMessage.model_json_schema() + "clientMessage" + ] = channel_class.ClientMessage.model_json_schema() - # Extract DjangoMessage schema - if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage: + if channel_class.ServerMessage: channel_schema[ - "djangoMessage" - ] = channel_class.DjangoMessage.model_json_schema() + "serverMessage" + ] = channel_class.ServerMessage.model_json_schema() schema["channels"][name] = channel_schema @@ -369,34 +230,37 @@ def _register_channel_schema_endpoint( input_cls: type | None, output_cls: type, ) -> None: - """Register a dummy endpoint for schema generation (avoids closure issues).""" + """ + Attach one operation to `api` whose annotations name `input_cls` and + `output_cls`, so Ninja emits both into `components.schemas`. + """ if input_cls is not None: - def endpoint(request, data): - pass + def schema_carrier(request, data): + return output_cls.model_json_schema() - endpoint.__annotations__ = {"data": input_cls} + schema_carrier.__annotations__ = {"data": input_cls} else: - def endpoint(request): - pass + def schema_carrier(request): + return output_cls.model_json_schema() api.post(path, response=output_cls, operation_id=operation_id, summary=summary)( - endpoint + schema_carrier ) def get_channels_openapi_schema() -> dict: """ - Get OpenAPI schema for all registered channels. + OpenAPI document covering every registered channel's wire types, with the + per-channel slot table under the `x-mizan-channels` extension key. - Uses Django Ninja's schema generation for robust Pydantic→OpenAPI conversion. - This schema is consumed by openapi-typescript for type generation. + Type names come from `mizan_core.ir.wire_to_pascal`, the same derivation + the Mizan IR emits, so the two documents name one type identically. """ from ninja import NinjaAPI from pydantic import BaseModel - # Create temporary Ninja API for schema generation only schema_api = NinjaAPI( title="mizan Channels", version="1.0.0", @@ -405,29 +269,26 @@ def get_channels_openapi_schema() -> dict: openapi_url=None, ) - # Store dynamically created classes schema_classes: dict[str, type] = {} channel_metadata: list[dict] = [] for name, channel_class in _registry.items(): - pascal_name = name.replace("_", " ").title().replace(" ", "") + pascal_name = wire_to_pascal(name) channel_meta = { "name": name, "pascalName": pascal_name, "hasParams": False, - "hasReactMessage": False, - "hasDjangoMessage": False, + "hasClientMessage": False, + "hasServerMessage": False, } - # Register Params type - if hasattr(channel_class, "Params") and channel_class.Params: + if channel_class.Params: params_name = f"{pascal_name}Params" schema_classes[params_name] = type(params_name, (channel_class.Params,), {}) channel_meta["hasParams"] = True channel_meta["paramsType"] = params_name - # Create dummy endpoint to include in schema _register_channel_schema_endpoint( api=schema_api, path=f"/channels/{name}/params", @@ -437,63 +298,54 @@ def get_channels_openapi_schema() -> dict: output_cls=BaseModel, ) - # Register ReactMessage type - if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage: - react_name = f"{pascal_name}ReactMessage" - schema_classes[react_name] = type( - react_name, (channel_class.ReactMessage,), {} + if channel_class.ClientMessage: + client_name = f"{pascal_name}ClientMessage" + schema_classes[client_name] = type( + client_name, (channel_class.ClientMessage,), {} ) - channel_meta["hasReactMessage"] = True - channel_meta["reactMessageType"] = react_name + channel_meta["hasClientMessage"] = True + channel_meta["clientMessageType"] = client_name _register_channel_schema_endpoint( api=schema_api, - path=f"/channels/{name}/react", - operation_id=f"{name}ReactMessage", - summary=f"{pascal_name} React→Django message", - input_cls=schema_classes[react_name], + path=f"/channels/{name}/client", + operation_id=f"{name}ClientMessage", + summary=f"{pascal_name} client→server message", + input_cls=schema_classes[client_name], output_cls=BaseModel, ) - # Register DjangoMessage type - if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage: - django_name = f"{pascal_name}DjangoMessage" - schema_classes[django_name] = type( - django_name, (channel_class.DjangoMessage,), {} + if channel_class.ServerMessage: + server_name = f"{pascal_name}ServerMessage" + schema_classes[server_name] = type( + server_name, (channel_class.ServerMessage,), {} ) - channel_meta["hasDjangoMessage"] = True - channel_meta["djangoMessageType"] = django_name + channel_meta["hasServerMessage"] = True + channel_meta["serverMessageType"] = server_name _register_channel_schema_endpoint( api=schema_api, - path=f"/channels/{name}/django", - operation_id=f"{name}DjangoMessage", - summary=f"{pascal_name} Django→React message", + path=f"/channels/{name}/server", + operation_id=f"{name}ServerMessage", + summary=f"{pascal_name} server→client message", input_cls=None, - output_cls=schema_classes[django_name], + output_cls=schema_classes[server_name], ) channel_metadata.append(channel_meta) - # Get OpenAPI schema from Ninja # path_prefix="" avoids URL reverse() — this API is never mounted schema = schema_api.get_openapi_schema(path_prefix="") - # Add channel metadata extension schema["x-mizan-channels"] = channel_metadata return schema -# ============================================================================= -# Schema Endpoint (for TypeScript generation) -# ============================================================================= - _schema_router = None def _get_schema_router(): - """Get the Ninja router for the channels schema endpoint.""" global _schema_router if _schema_router is None: from ninja import Router @@ -502,17 +354,16 @@ def _get_schema_router(): @_schema_router.get("/schema/") def channels_schema(request): - """Get schema for all registered channels (for TypeScript generation).""" return get_channels_schema() return _schema_router def get_urls(): - """Get URL patterns for channels schema endpoint.""" + """URL patterns serving the channels schema endpoint.""" from ninja import NinjaAPI - api = NinjaAPI(urls_namespace="django_react_channels") + api = NinjaAPI(urls_namespace="mizan_channels") api.add_router("/", _get_schema_router()) return api.urls @@ -523,17 +374,8 @@ def __getattr__(name): raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -# ============================================================================= -# Core Registry Extension -# ============================================================================= - - -class _ChannelsExtension: - """ - Plugs the channel registry into mizan_core.registry as the 'channels' - extension. Schema output goes under schema['channels'] in the unified - registry export consumed by codegen. - """ +class _ChannelsExtension(RegistryExtension): + """Exposes the channel registry to mizan_core under the 'channels' key.""" def all(self) -> dict: return dict(_registry) @@ -546,13 +388,17 @@ class _ChannelsExtension: "type": "channel", "bidirectional": False, } - if getattr(channel_class, "Params", None): + if channel_class.Params: channel_schema["params"] = channel_class.Params.model_json_schema() - if getattr(channel_class, "ReactMessage", None): - channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema() + if channel_class.ClientMessage: + channel_schema[ + "client_message" + ] = channel_class.ClientMessage.model_json_schema() channel_schema["bidirectional"] = True - if getattr(channel_class, "DjangoMessage", None): - channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema() + if channel_class.ServerMessage: + channel_schema[ + "server_message" + ] = channel_class.ServerMessage.model_json_schema() out[name] = channel_schema return out @@ -560,25 +406,15 @@ class _ChannelsExtension: _registry.clear() -from mizan_core.registry import register_extension as _register_extension -_register_extension("channels", _ChannelsExtension()) +register_extension("channels", _ChannelsExtension()) -# ============================================================================= -# Exports -# ============================================================================= - __all__ = [ - # URLs "urls", - # Base class - "ReactChannel", - # Registration + "Channel", "register", "get_channel", "get_registered_channels", - # ASGI application "get_websocket_application", - # Schema export "get_channels_schema", ] diff --git a/backends/mizan-django/src/mizan/channels/connection.py b/backends/mizan-django/src/mizan/channels/connection.py index 6ffeec1..b64f985 100644 --- a/backends/mizan-django/src/mizan/channels/connection.py +++ b/backends/mizan-django/src/mizan/channels/connection.py @@ -1,7 +1,6 @@ """ -WebSocket consumer for mizan.channels. - -Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection. +WebSocket consumer multiplexing channel subscriptions and RPC calls over one +socket. Protocol: Browser sends: @@ -16,7 +15,7 @@ Protocol: Server sends: # Channel messages - {"channel": "chat", "params": {"room": "general"}, "type": "DjangoMessage", "data": {...}} + {"channel": "chat", "params": {"room": "general"}, "type": "ServerMessage", "data": {...}} # RPC responses {"id": "request-id", "ok": true, "data": {"result": {...}, "invalidate": [...]}} @@ -25,15 +24,8 @@ Protocol: {"error": "..."} Authentication: - Supports both session (cookie) and JWT authentication: - - Session: Handled automatically via AuthMiddlewareStack (cookies in handshake) - - JWT: Pass token as query parameter: ws://...?token= - - The WebSocket URL for JWT auth would be: ws://localhost/ws/?token= - -Security: - - Functions must be explicitly registered (no arbitrary code execution) - - Pydantic validation runs BEFORE any function code + Session cookies arrive through AuthMiddlewareStack during the handshake; + a JWT arrives as a query parameter: ws://localhost/ws/?token= """ import json @@ -43,7 +35,8 @@ from urllib.parse import parse_qs from channels.generic.websocket import AsyncJsonWebsocketConsumer from asgiref.sync import sync_to_async -from . import get_channel + +from mizan.channels import get_channel logger = logging.getLogger(__name__) @@ -51,27 +44,23 @@ logger = logging.getLogger(__name__) class WebSocketRequest: """ - Minimal request adapter for WebSocket context. - - Provides the interface expected by ServerFunction without full HttpRequest. - This is intentionally minimal - only expose what's needed. - - Note: Some Django libraries (e.g., allauth rate limiting) check request.method. - We set method="POST" since WebSocket RPC calls are semantically similar to POST. + The request surface ServerFunction reads, backed by a WebSocket scope + instead of an HttpRequest. """ - # WebSocket RPC is semantically similar to POST (sends data, expects response) + # Some Django libraries (allauth rate limiting) branch on request.method; + # an RPC call carries data and expects a response, so POST is the match. method = "POST" def __init__(self, scope: dict, channel_name: str = None): self.user = scope.get("user") self.session = scope.get("session", {}) - self.channel_name = channel_name # For push subscriptions + self.channel_name = channel_name self._scope = scope @property def META(self) -> dict: - """HTTP headers from WebSocket handshake.""" + """HTTP headers from the WebSocket handshake, in WSGI key form.""" headers = dict(self._scope.get("headers", [])) return { "HTTP_" + k.decode().upper().replace("-", "_"): v.decode() @@ -80,24 +69,15 @@ class WebSocketRequest: class DjangoReactConsumer(AsyncJsonWebsocketConsumer): - """ - Multiplexed WebSocket consumer for django_react channels. - - Manages multiple channel subscriptions over a single WebSocket connection. - - Authentication: - - Session auth via cookies (handled by AuthMiddlewareStack) - - JWT auth via query parameter: ws://...?token= - """ + """Holds every channel subscription opened over one WebSocket connection.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Track subscriptions: {(channel_name, params_json): channel_instance} + # {(channel_name, params_json): channel_instance} self._subscriptions: dict[tuple[str, str], Any] = {} async def connect(self): """Accept the WebSocket connection, authenticating via JWT if provided.""" - # Check for JWT token in query parameters await self._try_jwt_auth() await self.accept() @@ -107,28 +87,23 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): async def _try_jwt_auth(self): """ - Attempt JWT authentication from query parameter. + Authenticate from a ?token= query parameter, building a JWTUser + from the token claims with no database query. - If a valid JWT token is provided via ?token=, authenticate the user - using JWTUser (no database query). - - Security: If JWT is provided but invalid, we log it but don't reject - the connection - the session auth may still be valid. However, if JWT - IS valid, it takes precedence over session auth. + An invalid token leaves the scope untouched so session auth still + applies; a valid one overwrites whatever session auth resolved. """ - # Parse query string for token query_string = self.scope.get("query_string", b"").decode() params = parse_qs(query_string) token_list = params.get("token", []) if not token_list: - return # No JWT provided, use session auth + return token = token_list[0] if not token: return - # Validate JWT and create JWTUser (no DB query) try: from mizan.client.jwt import decode_token from mizan.jwt.tokens import JWTUser @@ -136,9 +111,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): payload = await sync_to_async(decode_token)(token, expected_type="access") if payload is None: logger.debug("JWT token invalid or expired") - return # Fall back to session auth + return - # Create JWTUser from token claims - NO DATABASE QUERY self.scope["user"] = JWTUser(payload) logger.debug(f"JWT auth successful for user {payload.user_id}") except Exception as e: @@ -157,7 +131,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): logger.debug(f"WebSocket disconnected: {self.channel_name}") async def receive_json(self, content: dict): - """Handle incoming JSON messages.""" + """Route one incoming frame by its "action" field.""" action = content.get("action") if action == "subscribe": @@ -178,11 +152,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) async def _handle_subscribe(self, content: dict): - """Handle subscription request.""" + """Authorize, join the group, and record the subscription.""" channel_name = content.get("channel") params_dict = content.get("params", {}) - # Get channel class channel_class = get_channel(channel_name) if not channel_class: await self.send_json( @@ -192,11 +165,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) return - # Create subscription key params_json = json.dumps(params_dict, sort_keys=True) sub_key = (channel_name, params_json) - # Check if already subscribed if sub_key in self._subscriptions: await self.send_json( { @@ -207,7 +178,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) return - # Create channel instance instance = channel_class() instance.user = self.scope.get("user") instance._channel_layer = self.channel_layer @@ -215,7 +185,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): instance._registered_name = channel_name instance._params_dict = params_dict - # Parse params params_obj = None if channel_class.Params: try: @@ -229,7 +198,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) return - # Check authorization try: if params_obj: authorized = instance.authorize(params_obj) @@ -254,7 +222,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) return - # Get group and join try: if params_obj: group_name = instance.group(params_obj) @@ -271,16 +238,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) return - # Store subscription self._subscriptions[sub_key] = instance - # Call on_connect hook try: await instance.on_connect(params_obj) except Exception as e: logger.error(f"on_connect error for {channel_name}: {e}") - # Confirm subscription await self.send_json( { "subscribed": True, @@ -292,7 +256,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): logger.debug(f"Subscribed to {channel_name} with params {params_dict}") async def _handle_unsubscribe(self, content: dict): - """Handle unsubscription request.""" + """Drop the subscription and leave its groups.""" channel_name = content.get("channel") params_dict = content.get("params", {}) @@ -318,7 +282,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): logger.debug(f"Unsubscribed from {channel_name}") async def _handle_message(self, content: dict): - """Handle incoming message from browser.""" + """Validate a ClientMessage, hand it to receive(), broadcast what comes back.""" channel_name = content.get("channel") params_dict = content.get("params", {}) data = content.get("data", {}) @@ -338,8 +302,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): channel_class = instance.__class__ - # Check if channel accepts messages - if not channel_class.ReactMessage: + if not channel_class.ClientMessage: await self.send_json( { "error": f"Channel {channel_name} does not accept messages", @@ -348,9 +311,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) return - # Parse message try: - msg = channel_class.ReactMessage(**data) + msg = channel_class.ClientMessage(**data) except Exception as e: await self.send_json( { @@ -360,16 +322,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) return - # Parse params params_obj = None if channel_class.Params: params_obj = channel_class.Params(**params_dict) - # Handle message try: response = instance.receive(params_obj, msg) - # If handler returned a message, broadcast it if response is not None: if params_obj: group_name = instance.group(params_obj) @@ -389,18 +348,16 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): async def _handle_rpc(self, content: dict): """ - Handle RPC (server function) call. + Run a registered server function. Protocol: Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}} Response: {"id": "request-id", "ok": true, "data": {"result":..., "invalidate":[...]}} or: {"id": "request-id", "ok": false, "error": {...}} - Security: - - Only functions with @client(websocket=True) are allowed - - Pydantic validation happens BEFORE any function code runs - - Function must be explicitly registered (no arbitrary code execution) - - User context from WebSocket session is passed to function + Only functions registered with @client(websocket=True) are reachable, + and execute_function validates args against the function's Input model + before any function body runs. """ from mizan.client.executor import execute_function, FunctionError from mizan_core.registry import get_function @@ -409,7 +366,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): fn_name = content.get("fn") args = content.get("args", {}) - # Validate request structure if not request_id: await self.send_json( { @@ -431,7 +387,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) return - # Check if function exists and has websocket=True fn_class = get_function(fn_name) if fn_class is None: await self.send_json( @@ -446,7 +401,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) return - # Only allow functions explicitly marked with websocket=True fn_meta = getattr(fn_class, "_meta", {}) if not fn_meta.get("websocket"): await self.send_json( @@ -461,20 +415,17 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): ) return - # Create request adapter from WebSocket scope ws_request = WebSocketRequest( self.scope, channel_name=getattr(self, "channel_name", None) ) - # Execute function (Pydantic validation happens inside execute_function) - # This is sync, so we need to run it in a thread pool + # execute_function is sync, so it runs in a thread pool result = await sync_to_async(execute_function, thread_sensitive=True)( ws_request, fn_name, args, ) - # Send response if isinstance(result, FunctionError): await self.send_json( { @@ -488,8 +439,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): } ) else: - # the same {result, invalidate, merge} envelope the HTTP RPC path builds, so a - # mutation sent over the socket invalidates exactly as one sent over HTTP + # the {result, invalidate, merge} envelope the HTTP RPC path builds from mizan.client.executor import _resolve_invalidation, _resolve_merges data = {"result": result.data} @@ -508,16 +458,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): async def _handle_ctx(self, content: dict): """ - Handle a context-bundle fetch. + Fetch a context bundle through execute_context. Protocol: Request: {"action": "ctx", "id": "request-id", "context": "name", "params": {...}} Response: {"id": "request-id", "ok": true, "data": {fn_name: result, ...}} or: {"id": "request-id", "ok": false, "error": {...}} - - Dispatches through the same execute_context the HTTP view calls. That view's - origin-side cache is a CDN concern with nothing in front of a socket, so it is not - part of this path. """ from mizan.client.executor import execute_context, FunctionError @@ -563,10 +509,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): async def channel_message(self, event: dict): """ - Handle messages broadcast to a group. - - Called when channel_layer.group_send() is used. - Includes channel name and params so the client can route the message. + Forward a group broadcast down the socket, carrying the channel name + and params the client routes on. """ await self.send_json( { @@ -579,13 +523,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer): async def push_message(self, event: dict): """ - Handle push messages from server functions. + Forward a topic push down the socket. - Called when push("topic", data) is used from a server function. - The client receives this to update its local state. - - Protocol: - Server sends: {"type": "push", "topic": "room:42", "data": {...}} + Wire shape: {"type": "push", "topic": "room:42", "data": {...}} """ await self.send_json( { diff --git a/backends/mizan-django/src/mizan/channels/push.py b/backends/mizan-django/src/mizan/channels/push.py index a1f342f..380200c 100644 --- a/backends/mizan-django/src/mizan/channels/push.py +++ b/backends/mizan-django/src/mizan/channels/push.py @@ -1,68 +1,47 @@ """ -mizan Push - Server-initiated messages to clients. +Topic-based server-initiated messages. -Simple API for pushing data to subscribed WebSocket connections. - -Usage: - # In a server function - push to all subscribers - from mizan.push import push - - push("room:42", {"type": "new_message", "data": {...}}) - - # Subscribe a connection to a topic (call during context fetch) - from mizan.push import subscribe - - subscribe(request, "room:42") +A topic string ("room:42", "user:123:notifications") maps onto one channel +layer group; subscribing a connection adds its channel name to that group, +and pushing sends a "push.message" event to every member. """ -from typing import TYPE_CHECKING +import logging +from asgiref.sync import async_to_sync from pydantic import BaseModel -# Lazy import to avoid import errors when channels is not installed -# (e.g., during schema generation) -if TYPE_CHECKING: - from channels.layers import BaseChannelLayer +logger = logging.getLogger(__name__) -def _get_channel_layer() -> "BaseChannelLayer | None": - """Get channel layer, returning None if channels is not installed.""" +def _get_channel_layer(): + """The configured channel layer, or None when django-channels is absent.""" try: from channels.layers import get_channel_layer - - return get_channel_layer() - except ImportError: + except ImportError as e: + logger.warning("django-channels is not installed, push is inert: %s", e) return None - -def _async_to_sync(coro): - """Wrapper for async_to_sync that handles missing channels.""" - from asgiref.sync import async_to_sync - - return async_to_sync(coro) + return get_channel_layer() def get_topic_group_name(topic: str) -> str: - """Convert a topic string to a valid channel layer group name.""" - # Channel layer group names must be valid ASCII alphanumeric + hyphens/underscores/periods - # Replace colons with underscores + """ + Convert a topic to a channel layer group name. Group names allow ASCII + alphanumerics plus hyphens, underscores and periods, so the topic + separator becomes an underscore. + """ return topic.replace(":", "_") def subscribe(request, topic: str) -> None: """ - Subscribe this WebSocket connection to a topic. + Add this WebSocket connection to a topic's group. - Call this in a context or server function to register the connection - for push notifications on the given topic. - - Args: - request: The Django request (must have channel_name attribute from WebSocket) - topic: Topic string, e.g., "room:42", "user:123:notifications" + An HTTP request carries no channel_name, so there is nothing to add. """ channel_name = getattr(request, "channel_name", None) if not channel_name: - # HTTP request, not WebSocket - can't subscribe return channel_layer = _get_channel_layer() @@ -70,17 +49,11 @@ def subscribe(request, topic: str) -> None: return group_name = get_topic_group_name(topic) - _async_to_sync(channel_layer.group_add)(group_name, channel_name) + async_to_sync(channel_layer.group_add)(group_name, channel_name) def unsubscribe(request, topic: str) -> None: - """ - Unsubscribe this WebSocket connection from a topic. - - Args: - request: The Django request (must have channel_name attribute from WebSocket) - topic: Topic string to unsubscribe from - """ + """Remove this WebSocket connection from a topic's group.""" channel_name = getattr(request, "channel_name", None) if not channel_name: return @@ -90,42 +63,29 @@ def unsubscribe(request, topic: str) -> None: return group_name = get_topic_group_name(topic) - _async_to_sync(channel_layer.group_discard)(group_name, channel_name) + async_to_sync(channel_layer.group_discard)(group_name, channel_name) def push(topic: str, data: dict | BaseModel) -> None: - """ - Push data to all connections subscribed to a topic. - - Args: - topic: Topic string, e.g., "room:42" - data: Data to send (dict or Pydantic model) - - Example: - push("room:42", { - "type": "new_message", - "message": {"id": 1, "text": "Hello", "user": "alice@example.com"} - }) - """ + """Send data to every connection subscribed to a topic.""" channel_layer = _get_channel_layer() if not channel_layer: - import logging - - logging.getLogger(__name__).warning( + logger.warning( "No channel layer configured, cannot push to topic '%s'", topic ) return - # Convert Pydantic model to dict if needed if isinstance(data, BaseModel): data = data.model_dump() group_name = get_topic_group_name(topic) - _async_to_sync(channel_layer.group_send)( + async_to_sync(channel_layer.group_send)( group_name, { - "type": "push.message", # Maps to push_message handler in consumer + # The event's "type" selects the consumer method of the same name, + # with dots translated to underscores. + "type": "push.message", "topic": topic, "data": data, }, @@ -133,9 +93,12 @@ def push(topic: str, data: dict | BaseModel) -> None: async def push_async(topic: str, data: dict | BaseModel) -> None: - """Async version of push for use in async contexts.""" + """Send data to every connection subscribed to a topic, from the event loop.""" channel_layer = _get_channel_layer() if not channel_layer: + logger.warning( + "No channel layer configured, cannot push to topic '%s'", topic + ) return if isinstance(data, BaseModel): diff --git a/backends/mizan-django/src/mizan/client/__init__.py b/backends/mizan-django/src/mizan/client/__init__.py index 65083b9..689016c 100644 --- a/backends/mizan-django/src/mizan/client/__init__.py +++ b/backends/mizan-django/src/mizan/client/__init__.py @@ -1,19 +1,12 @@ """ -mizan.client - Server function implementation. - -This subpackage contains everything needed to make server functions work: -- The @client decorator (lives in mizan_core.client.function) -- ServerFunction base class (mizan_core.client.function) -- Function execution logic (.executor — Django-specific dispatch) -- JWT authentication (.jwt — Django-specific session integration) - -Usage: - from mizan.client import client, ServerFunction, compose +The server-function surface: the `client` decorator and `ServerFunction` base +come from `mizan_core`; execution and dispatch are Django-specific and live in +`mizan.client.executor`. """ -# Register the Django framework response base so view-path detection works -# in mizan_core.client.function. Has to happen before any @client-decorated -# code is evaluated. +# Registering the Django response base has to happen before any +# @client-decorated code is evaluated, or view-path detection in +# mizan_core.client.function cannot recognize a returned HttpResponse. from django.http import HttpResponseBase as _HttpResponseBase from mizan_core.client.function import set_framework_response_base as _set_response_base _set_response_base(_HttpResponseBase) @@ -39,7 +32,7 @@ from mizan_core.client.function import ( create_form_functions, ) -from .executor import ( +from mizan.client.executor import ( execute_function, function_call_view, ErrorCode, diff --git a/backends/mizan-django/src/mizan/client/executor.py b/backends/mizan-django/src/mizan/client/executor.py index b3c6a52..80b9cf7 100644 --- a/backends/mizan-django/src/mizan/client/executor.py +++ b/backends/mizan-django/src/mizan/client/executor.py @@ -1,17 +1,10 @@ """ -mizan Function Executor +Dispatch for registered server functions over HTTP. -Handles execution of server functions. -This is the core of the "Server Functions" feature - callable from React -without REST boilerplate. - -Security model: -- All input validated against Pydantic schema BEFORE execution -- Authentication: JWT (stateless) or Session (stateful) - auto-detected - - JWT: Authorization header with Bearer token (no CSRF needed) - - Session: Cookie-based with CSRF token (via X-CSRFToken header) -- WebSocket RPC uses Origin header checking instead -- No implicit function exposure - must be explicitly registered +Input is validated against the function's Pydantic Input before the body ever +runs. Authentication is auto-detected per request: an X-Mizan-Token (MWT) or +an Authorization Bearer (JWT) header is self-authenticating and bypasses CSRF; +anything else falls through to session auth with CSRF enforced. """ from __future__ import annotations @@ -21,7 +14,7 @@ import logging from dataclasses import dataclass from enum import Enum from functools import wraps -from typing import TYPE_CHECKING, Any, Callable +from typing import Any, Callable from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse from django.views.decorators.csrf import csrf_protect @@ -31,9 +24,6 @@ from mizan.cache import get_cache, cache_get, cache_put, cache_purge from mizan_core.registry import get_function, get_context_groups from mizan.setup.settings import get_settings -if TYPE_CHECKING: - pass - logger = logging.getLogger(__name__) @@ -99,50 +89,40 @@ def _check_auth_requirement( auth_requirement: str | Callable | None, ) -> FunctionError | None: """ - Check if the request meets the auth requirement. + Test `request` against an auth requirement of 'required', 'staff', + 'superuser', a callable, or None. Returns a FunctionError on failure. - Args: - request: The Django HttpRequest (with user set) - auth_requirement: 'required', 'staff', 'superuser', callable, or None - - Returns: - FunctionError if auth check fails, None if it passes. - - Note: This uses request.user which may be a JWTUser (stateless) or - Django User (from session). Either way, no additional DB query is made - for the built-in checks. Custom callables may query DB if they choose. + The built-in checks read only flags already on request.user — a JWTUser or + a session User — so none of them hit the database. A callable may. """ if auth_requirement is None: return None user = request.user - # Handle callable auth if callable(auth_requirement): try: result = auth_requirement(request) if result: - return None # Authorized + return None else: return FunctionError( code=ErrorCode.FORBIDDEN, message="Access denied", ) except PermissionError as e: - # Custom error message from the callable return FunctionError( code=ErrorCode.FORBIDDEN, message=str(e) or "Access denied", ) - # Check authentication (required for all string-based auth) + # Every string-based requirement implies authentication. if not getattr(user, "is_authenticated", False): return FunctionError( code=ErrorCode.UNAUTHORIZED, message="Authentication required", ) - # Check staff requirement if auth_requirement == "staff": if not getattr(user, "is_staff", False): return FunctionError( @@ -150,7 +130,6 @@ def _check_auth_requirement( message="Staff access required", ) - # Check superuser requirement elif auth_requirement == "superuser": if not getattr(user, "is_superuser", False): return FunctionError( @@ -168,7 +147,7 @@ def _purge_cache_for_invalidation( invalidate: list, request: HttpRequest | None = None, ) -> None: - """Purge origin-side cache for invalidation targets. Includes user_id if available.""" + """Purge origin-side cache entries for invalidation targets, scoped by user when known.""" cache = get_cache() if cache is None: return @@ -199,34 +178,27 @@ def _purge_cache_for_invalidation( def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]: """ - Determine whether an affects target is a context name or function name. + Classify an affects target as a context or a function inside one. - Returns: ("context", "user", None) — full context invalidation ("function", "user_profile", "user") — function within context """ groups = get_context_groups() - # Check if it's a context name directly if target_name in groups: return ("context", target_name, None) - # Check if it's a function name within a context for ctx_name, fn_names in groups.items(): if target_name in fn_names: return ("function", target_name, ctx_name) - # Not a context or context function — treat as context name anyway - # (it might be a non-context function or an as-yet-unregistered context) + # An unregistered name is treated as a context so invalidation still + # propagates rather than being silently dropped. return ("context", target_name, None) def _get_context_param_names(context_name: str) -> set[str]: - """ - Get the set of parameter names used by functions in a context. - - Returns the union of all Input field names across context functions. - """ + """Union of the Input field names across every function in a context.""" groups = get_context_groups() fn_names = groups.get(context_name, []) param_names: set[str] = set() @@ -247,18 +219,14 @@ def _resolve_invalidation( input_data: dict[str, Any] | None = None, ) -> list[str | dict[str, Any]] | None: """ - Resolve invalidation targets with three-tier auto-scoping. + Turn a mutation's `affects` metadata into invalidation targets, returning + None when there is nothing to invalidate. - Tier 1: Argument name matching — if the mutation's input args overlap - with the context's params by name, auto-scope. - Tier 2: Auth inference — Edge-side concern, not handled here. - Tier 3: Broad fallback — invalidate all instances. + A target is scoped to specific params when the mutation's input argument + names overlap the context's param names; otherwise the whole context is + invalidated. A function-level target is keyed by the function name. - Also handles function-level targeting: affects='user_profile' resolves - to the function name (v1: runtime refetches the whole context anyway). - - Returns a list suitable for both JSON body and header serialization. - Returns None if no invalidation needed. + The returned list serializes into both the JSON body and the header. """ if view_class is None: return None @@ -275,7 +243,6 @@ def _resolve_invalidation( if target["type"] == "context": target_name = target["name"] elif target["type"] == "function" and target.get("context"): - # Function-level: use the function name as the invalidation key target_name = target["name"] else: continue @@ -284,11 +251,9 @@ def _resolve_invalidation( continue seen.add(target_name) - # Resolve the context this target belongs to (for param lookup) resolved = _resolve_affects_target(target_name) ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1] - # Tier 1: argument name matching if input_data and ctx_for_params: context_params = _get_context_param_names(ctx_for_params) matched = { @@ -299,7 +264,6 @@ def _resolve_invalidation( result.append({"context": target_name, "params": matched}) continue - # Tier 3: broad fallback result.append(target_name) return result if result else None @@ -311,15 +275,12 @@ def _resolve_merges( result_data: Any, ) -> list[dict[str, Any]] | None: """ - Resolve merge targets from @client(merge=...). - - Each entry is `{context, slot, value, params?}` — `slot` is the - function-name inside the context bundle the value lands in, resolved - server-side by matching the mutation's return type against each - context-function's return type. Kernel does no shape inference. - - Mirrors _resolve_invalidation's tier-1 auto-scoping for params. - Entries whose slot can't be uniquely resolved are dropped. + Turn a mutation's `merge` metadata into `{context, slot, value, params?}` + entries. `slot` is the function-name inside the context bundle the value + lands in, resolved here by matching the mutation's declared Output against + each context-function's Output. Entries whose slot is ambiguous are + dropped, and params are scoped the same way `_resolve_invalidation` scopes + them. """ if view_class is None: return None @@ -358,7 +319,7 @@ def _resolve_merges( def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None: - """Find the unique function-name slot in context whose return type matches mutation's output.""" + """Find the one function in `context_name` whose Output matches the mutation's, if unique.""" if mutation_output is None: return None groups = get_context_groups() @@ -378,18 +339,17 @@ def _format_invalidate_header( invalidate: list[str | dict[str, Any]], ) -> str: """ - Format invalidation targets as X-Mizan-Invalidate header value. + Format invalidation targets as the X-Mizan-Invalidate header value: + comma-separated contexts, each optionally followed by semicolon-separated + `key=value` params. Keys and values are URL-encoded so a param can never + contain a delimiter. - Format: comma-separated contexts. Semicolon-separated params per context. - Param values are URL-encoded to prevent delimiter collisions. - - Examples: - ["user"] → "user" - ["user", "notifications"] → "user, notifications" + ["user"] → "user" + ["user", "notifications"] → "user, notifications" [{"context": "user", "params": {"user_id": 5}}] - → "user;user_id=5" + → "user;user_id=5" [{"context": "search", "params": {"q": "hello world"}}] - → "search;q=hello%20world" + → "search;q=hello%20world" """ from urllib.parse import quote @@ -417,22 +377,17 @@ def execute_function( input_data: dict[str, Any] | None = None, ) -> "FunctionResult | FunctionError | HttpResponseBase": """ - Execute a registered server function. + Look up, authorize, validate, and run a registered server function. - Args: - request: The Django HttpRequest - fn_name: Name of the registered function - input_data: Input data to pass to the function - - Returns: - FunctionResult on success, FunctionError on failure + Returns the function's HttpResponse untouched when it returned one, + otherwise a FunctionResult or FunctionError. """ from django.conf import settings - # Look up the function by name view_class = get_function(fn_name) if view_class is None: - # In DEBUG mode, include the name for easier debugging + # Naming the missing function is a debugging aid, not something to + # hand an unauthenticated caller in production. if settings.DEBUG: message = f"Function '{fn_name}' not found" else: @@ -442,7 +397,6 @@ def execute_function( message=message, ) - # Reject private functions from RPC dispatch meta = getattr(view_class, "_meta", {}) if meta.get("private"): return FunctionError( @@ -450,36 +404,28 @@ def execute_function( message="Function is not client-callable", ) - # Check auth requirement BEFORE executing + # Auth is checked before the function body ever runs. auth_requirement = meta.get("auth") auth_error = _check_auth_requirement(request, auth_requirement) if auth_error is not None: return auth_error - # Instantiate the view with the request view = view_class(request) - # Check if this is a form function that handles input specially - meta = getattr(view_class, "_meta", {}) is_form_multipart = meta.get("multipart", False) - # For form functions with Input=None, skip Pydantic validation - # The form itself handles validation input_cls = view.Input if input_cls is None and is_form_multipart: - # Form function - pass input_data directly (already parsed by view or will be) + # Form functions carry Input=None; the Django form owns validation. validated_input = input_data elif input_cls is BaseModel: has_input = False validated_input = None else: - # Check if it has any fields defined has_input = bool(input_cls.model_fields) if input_cls else False - # Validate input against Pydantic schema try: if input_data: - # Ensure input_data is a dict (not array or other type) if not isinstance(input_data, dict): return FunctionError( code=ErrorCode.BAD_REQUEST, @@ -488,11 +434,11 @@ def execute_function( ) validated_input = input_cls(**input_data) elif has_input: - # Check if function requires input fields input_schema = input_cls.model_json_schema() required_fields = input_schema.get("required", []) if required_fields: - # Format as field errors for consistency + # Shaped like Pydantic's own field errors so the client + # has one error format to handle. errors = {field: ["Field required"] for field in required_fields} return FunctionError( code=ErrorCode.VALIDATION_ERROR, @@ -501,10 +447,8 @@ def execute_function( ) validated_input = input_cls() else: - # No input expected, create empty model validated_input = None except ValidationError as e: - # Convert Pydantic errors to our format errors = {} for error in e.errors(): field = ".".join(str(loc) for loc in error["loc"]) @@ -518,7 +462,6 @@ def execute_function( details={"fields": errors}, ) - # Execute the function try: output = view.call(validated_input) except NotImplementedError as e: @@ -528,28 +471,24 @@ def execute_function( message=str(e), ) except PermissionError as e: - # Functions can raise PermissionError for auth issues return FunctionError( code=ErrorCode.FORBIDDEN, message=str(e) or "Permission denied", ) except Exception as e: - # Log the full exception for debugging logger.exception(f"Error executing function {fn_name}") return FunctionError( code=ErrorCode.INTERNAL_ERROR, message="An internal error occurred", - # Don't expose internal details in production + # Internals are only named when debug logging is already on. details={"type": type(e).__name__} if logger.isEnabledFor(logging.DEBUG) else None, ) - # Return-type branching: HttpResponse (view path) vs data (RPC path) from django.http import HttpResponseBase if isinstance(output, HttpResponseBase): - # View path — add invalidation header + purge origin cache invalidate = _resolve_invalidation(view_class, input_data) if invalidate: output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate) @@ -557,9 +496,8 @@ def execute_function( output["Cache-Control"] = "no-store" return output - # RPC path — serialize output. to_jsonable_python walks BaseModel / - # list / dict recursively, so list[BaseModel] (and nested shapes) come - # out wire-ready without a per-shape branch. + # to_jsonable_python walks BaseModel / list / dict recursively, so nested + # shapes need no per-shape branch here. from pydantic_core import to_jsonable_python return FunctionResult(data=to_jsonable_python(output)) @@ -567,10 +505,9 @@ def execute_function( def _try_mwt_auth(request: HttpRequest) -> bool: """ - Attempt to authenticate the request using MWT (Mizan Web Token). - - Checks the X-Mizan-Token header. If present and valid, sets request.user - to an MWTUser. Returns True on success, False if no MWT header or invalid. + Authenticate from the X-Mizan-Token header, setting request.user to an + MWTUser on success. False means no header, no configured secret, or a + token that did not verify. """ token = request.META.get("HTTP_X_MIZAN_TOKEN", "") if not token: @@ -607,18 +544,10 @@ def _has_mwt_header(request: HttpRequest) -> bool: def _try_jwt_auth(request: HttpRequest) -> bool: """ - Attempt to authenticate the request using JWT. - - If Authorization header contains a valid Bearer token, authenticates - the request and sets request.user to a JWTUser. Returns True if JWT - auth succeeded. - - IMPORTANT: This is stateless - no database query is made. The JWTUser - object is created from the token claims. If you need the full User - object, query it explicitly in your function. - - Security: If JWT is provided but invalid, we return False and do NOT - fall back to session auth. The caller should reject the request. + Authenticate from an Authorization Bearer token, setting request.user to a + JWTUser built from the claims — no database query. False means no bearer + header or a token that did not verify; the caller must then reject rather + than fall back to session auth. """ auth_header = request.META.get("HTTP_AUTHORIZATION", "") if not auth_header.startswith("Bearer "): @@ -636,11 +565,13 @@ def _try_jwt_auth(request: HttpRequest) -> bool: if payload is None: return False - # Create JWTUser from token claims - NO DATABASE QUERY request.user = JWTUser(payload) request._mizan_jwt_authenticated = True return True except Exception: + logging.getLogger("mizan.jwt").warning( + "JWT authentication failed unexpectedly", exc_info=True + ) return False @@ -652,19 +583,15 @@ def _has_jwt_header(request: HttpRequest) -> bool: def _csrf_protect_unless_token(view_func): """ - Decorator that applies CSRF protection unless token auth is used. - - MWT (X-Mizan-Token) is checked first, then legacy JWT (Authorization: Bearer). - Both are self-authenticating, so CSRF protection is not needed. - - Security: If a token is provided but invalid, reject the request - do NOT - fall back to session auth. + Wrap a view so CSRF applies only on the session path. MWT is checked + first, then JWT; both are self-authenticating. A token that is present but + invalid rejects the request outright rather than falling back to session + auth. """ csrf_protected_view = csrf_protect(view_func) @wraps(view_func) def wrapper(request: HttpRequest, *args, **kwargs): - # MWT takes priority if _has_mwt_header(request): if _try_mwt_auth(request): return view_func(request, *args, **kwargs) @@ -673,7 +600,6 @@ def _csrf_protect_unless_token(view_func): message="Invalid or expired MWT", ).to_response(status=401) - # Legacy JWT fallback if _has_jwt_header(request): if _try_jwt_auth(request): return view_func(request, *args, **kwargs) @@ -682,7 +608,6 @@ def _csrf_protect_unless_token(view_func): message="Invalid or expired JWT token", ).to_response(status=401) - # No token — session auth with CSRF return csrf_protected_view(request, *args, **kwargs) return wrapper @@ -691,52 +616,25 @@ def _csrf_protect_unless_token(view_func): @_csrf_protect_unless_token def function_call_view(request: HttpRequest) -> JsonResponse: """ - Django view for handling function calls (HTTP fallback for WebSocket RPC). + POST endpoint for server-function calls. - Authentication (auto-detected): - - JWT: Authorization: Bearer (stateless, no CSRF needed) - - Session: Cookie-based with X-CSRFToken header (CSRF required) + A JSON body carries `{"fn": ..., "args": {...}}`. A multipart body carries + `fn` as a form field alongside the form's own fields, and its parsed data + and files are attached to the request for the form function to pick up. - Endpoint: POST /api/mizan/call/ - - Request body (JSON): - { - "fn": "function_name", // Function name - "args": { ... } // Optional, depending on function - } - - Request body (multipart/form-data for form submit functions): - fn: function_name - : - ... - - Response on success: - { - "error": false, - "data": { ... } // Function output - } - - Response on error: - { - "error": true, - "code": "VALIDATION_ERROR", - "message": "Input validation failed", - "details": { ... } - } + Success answers `{"result": ...}`, plus `invalidate` / `merge` when the + function declared them; failure answers the FunctionError shape. """ - # Only allow POST if request.method != "POST": return FunctionError( code=ErrorCode.BAD_REQUEST, message="Only POST method allowed", ).to_response(status=405) - # Check content type to determine parsing method content_type = request.content_type or "" is_multipart = content_type.startswith("multipart/form-data") if is_multipart: - # Multipart form data - used by form submit functions fn_name = request.POST.get("fn") if not fn_name: return FunctionError( @@ -744,15 +642,12 @@ 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"} - # Attach parsed form data and files to request for form functions request._mizan_form_data = input_data request._mizan_form_files = request.FILES else: - # JSON body - standard RPC try: if request.body: body = json.loads(request.body) @@ -767,7 +662,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse: message="Invalid JSON in request body", ).to_response() - # Extract function name and args fn_name = body.get("fn") if not fn_name: return FunctionError( @@ -777,15 +671,13 @@ def function_call_view(request: HttpRequest) -> JsonResponse: input_data = body.get("args") - # Execute the function result = execute_function(request, fn_name, input_data) - # View path — function returned an HttpResponse directly + # The function returned an HttpResponse directly. from django.http import HttpResponseBase if isinstance(result, HttpResponseBase): return result - # Return appropriate response if isinstance(result, FunctionError): status = { ErrorCode.NOT_FOUND: 404, @@ -798,7 +690,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse: }.get(result.code, 400) return result.to_response(status=status) - # RPC path — build response with server-driven invalidation view_class = get_function(fn_name) response_data = {"result": result.data} invalidate_contexts = _resolve_invalidation(view_class, input_data) @@ -825,18 +716,8 @@ def execute_context( params: dict[str, str], ) -> FunctionResult | FunctionError: """ - Execute all functions in a named context with merged params. - - Each function receives only the params it declares in its Input schema. - If any function fails (auth, validation, execution), the entire request fails. - - Args: - request: The Django HttpRequest - context_name: Name of the context (e.g., 'user', 'global') - params: Query parameters (strings — Pydantic coerces types) - - Returns: - FunctionResult with bundled data, or FunctionError + Run every function in a named context, handing each only the params it + declares in its Input schema. The first failure aborts the whole bundle. """ groups = get_context_groups() fn_names = groups.get(context_name) @@ -852,7 +733,6 @@ def execute_context( if view_class is None: continue - # Filter params to only those in this function's Input schema input_cls = getattr(view_class, "Input", None) if input_cls and input_cls is not BaseModel and input_cls.model_fields: fn_params = { @@ -871,13 +751,9 @@ def execute_context( def _jwt_auth_only(view_func): - """ - Decorator that handles token auth for GET endpoints (no CSRF needed for GET). - Checks MWT first, then legacy JWT. - """ + """Token auth for GET views: MWT first, then JWT. GET needs no CSRF.""" @wraps(view_func) def wrapper(request: HttpRequest, *args, **kwargs): - # MWT takes priority if _has_mwt_header(request): if _try_mwt_auth(request): return view_func(request, *args, **kwargs) @@ -886,7 +762,6 @@ def _jwt_auth_only(view_func): message="Invalid or expired MWT", ).to_response(status=401) - # Legacy JWT fallback if _has_jwt_header(request): if _try_jwt_auth(request): return view_func(request, *args, **kwargs) @@ -895,7 +770,6 @@ def _jwt_auth_only(view_func): message="Invalid or expired JWT token", ).to_response(status=401) - # No token — session auth (no CSRF needed for GET) return view_func(request, *args, **kwargs) return wrapper @@ -904,18 +778,12 @@ def _jwt_auth_only(view_func): @_jwt_auth_only def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: """ - Fetch all functions in a named context in a single bundled GET request. + GET endpoint answering every function in a named context as one bundle + keyed by function name, with query params fanned out to each. - Endpoint: GET /api/mizan/ctx//?param1=val1¶m2=val2 - - Response: raw bundled data, CDN-cacheable. - { - "user_profile": { ... }, - "user_orders": [ ... ] - } - - Headers: - Cache-Control: public, max-age=0, s-maxage=31536000 + The context's effective cache policy and revision are the strictest across + its functions: any function declaring cache=False disables caching for the + whole bundle, and the shortest declared TTL wins. """ if request.method != "GET": return FunctionError( @@ -925,7 +793,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: params = request.GET.dict() - # Resolve effective rev and cache policy across all functions in this context _cache_log = logging.getLogger("mizan.cache") groups = get_context_groups() fn_names = groups.get(context_name, []) @@ -947,7 +814,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: else: effective_cache = min(effective_cache, fn_cache) - # Origin-side cache lookup (skip if cache=False) cache_backend = get_cache() cache_settings = get_settings() user_id = None @@ -988,14 +854,14 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: error_response["Cache-Control"] = "no-store" return error_response - # Deterministic JSON (sorted keys) for consistent cache keys + # Sorted keys keep the serialized body byte-identical for a given result, + # which is what makes it usable as a cache entry. response = JsonResponse(result.data, json_dumps_params={"sort_keys": True}) - # Mizan's protocol layers handle caching (origin Redis, Edge Worker). - # The browser and non-Mizan intermediaries must not cache. + # Caching happens in the origin cache below and at the edge, both of which + # can be purged; a browser cache cannot, so it must not hold this. response["Cache-Control"] = "no-store" - # Store in origin-side cache (skip if cache=False) if use_cache: try: cache_put( diff --git a/backends/mizan-django/src/mizan/client/jwt.py b/backends/mizan-django/src/mizan/client/jwt.py index e9d1638..ef8fb7c 100644 --- a/backends/mizan-django/src/mizan/client/jwt.py +++ b/backends/mizan-django/src/mizan/client/jwt.py @@ -1,19 +1,9 @@ """ -mizan.client.jwt - JWT authentication for server functions. - -Provides: -- Server functions for obtaining/refreshing JWT tokens -- JWT authentication utilities for validating tokens - -Server Functions: -- jwt_obtain: Convert authenticated session to JWT tokens -- jwt_refresh: Refresh tokens using a refresh token - -Note: This module is purpose-built for mizan server functions. -For Django Ninja API authentication, use mizan.jwt.security directly. +Token and settings names from `mizan.jwt`, re-exported under `mizan.client` +for the executor and the WebSocket consumer. The Ninja auth class is +deliberately absent here — reach for `mizan.jwt.security` for that. """ -# Token utilities (re-exports from django_jwt_session) from mizan.jwt.tokens import ( create_token_pair, create_access_token, @@ -25,7 +15,6 @@ from mizan.jwt.tokens import ( JWTUser, ) -# Settings from mizan.jwt.settings import get_settings, JWTSettings __all__ = [ diff --git a/backends/mizan-django/src/mizan/export/__init__.py b/backends/mizan-django/src/mizan/export/__init__.py index 230b7d0..8bfa6ff 100644 --- a/backends/mizan-django/src/mizan/export/__init__.py +++ b/backends/mizan-django/src/mizan/export/__init__.py @@ -1,19 +1,11 @@ """ -Mizan Edge Manifest Generator. - -Generates the Edge manifest — a static JSON mapping contexts to URL -patterns and params, consumed by Mizan Edge at deploy time for CDN -cache invalidation. Independent from the Mizan IR; the IR drives -codegen, the manifest drives CDN purging. - -Usage: - from mizan.export import generate_edge_manifest, generate_edge_manifest_json +Builds the Edge manifest: a static JSON document mapping each context to its +API endpoint, page routes, and parameter names. """ from __future__ import annotations import json -import re from typing import Any from mizan_core.registry import get_context_groups, get_registry @@ -30,20 +22,18 @@ def generate_edge_manifest( view_urls: dict[str, list[str]] | None = None, ) -> dict[str, Any]: """ - Generate the Edge manifest — a static JSON mapping contexts to URL - patterns and params for CDN cache purging. + Build the manifest dict. - The manifest is consumed by Mizan Edge at deploy time. When Edge - receives X-Mizan-Invalidate: user;user_id=5, it: - 1. Looks up 'user' in the manifest - 2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/ - 3. Purges the resolved URLs + the context API endpoint + Each context entry carries the union of its functions' Input field names, + its API endpoint under `base_url`, any page routes declared via + `@client(route=...)`, and a render strategy derived from whether any + parameter is user-scoped. Each mutation entry carries the contexts it + affects and the parameter names shared with those contexts. Args: base_url: The Mizan API mount point (default: /api/mizan) - view_urls: Optional mapping of context names to URL patterns for - view-path functions. These are URLs that Edge should - also purge when a context is invalidated. + view_urls: Extra page routes per context name, merged with the ones + read off `@client(route=...)`. Returns: Manifest dict suitable for JSON serialization. diff --git a/backends/mizan-django/src/mizan/forms/__init__.py b/backends/mizan-django/src/mizan/forms/__init__.py index 30d6017..15a39db 100644 --- a/backends/mizan-django/src/mizan/forms/__init__.py +++ b/backends/mizan-django/src/mizan/forms/__init__.py @@ -1,153 +1,26 @@ """ -mizanFormMixin - Turn Django Forms into server functions. - -This mixin transforms any Django Form into mizan server functions, -preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.) -while exposing them through the unified server function API. - -Usage: - from django import forms - from mizan.forms import mizanFormMixin, mizanFormMeta - - class ContactForm(mizanFormMixin, forms.Form): - mizan = mizanFormMeta( - name="contact", - title="Contact Us", - submit_label="Send", - ) - - name = forms.CharField() - email = forms.EmailField() - message = forms.CharField(widget=forms.Textarea) - - def on_submit_success(self, request): - send_email(self.cleaned_data) - return {"sent": True} - -Auto-registers server functions: - - contact.schema - - contact.validate - - contact.submit +mizanFormMixin exposes a Django Form as the server functions +`.schema`, `.validate`, and `.submit`, registered from +`__init_subclass__` off the `mizan = mizanFormMeta(...)` attribute. """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar +import inspect +import logging +from typing import Any, ClassVar from django import forms from django.http import HttpRequest from pydantic import BaseModel, create_model -if TYPE_CHECKING: - from .schemas import FormValidation - - -def _django_field_to_python_type(field: forms.Field) -> type: - """ - Map a Django form field to a Python type for Pydantic schema generation. - - This provides TypeScript with proper field types instead of generic `any`. - """ - # Handle common Django field types - if isinstance(field, forms.BooleanField): - return bool - elif isinstance(field, forms.IntegerField): - return int - elif isinstance(field, forms.FloatField): - return float - elif isinstance(field, forms.DecimalField): - return str # Decimals serialize as strings for precision - elif isinstance(field, forms.DateTimeField): - return str # ISO format string - elif isinstance(field, forms.DateField): - return str # ISO format string - elif isinstance(field, forms.TimeField): - return str # ISO format string - elif isinstance(field, forms.JSONField): - return dict | list | str | int | float | bool | None - elif isinstance(field, forms.MultipleChoiceField): - return list[str] - elif isinstance(field, forms.FileField): - return str # File path/name as string - elif isinstance(field, forms.ImageField): - return str # File path/name as string - else: - # Default to string (covers CharField, EmailField, URLField, etc.) - return str - - -def _create_form_input_schema( - form_class: type[forms.BaseForm], - schema_name: str, -) -> type[BaseModel]: - """ - Create a Pydantic model from Django Form fields. - - This generates a typed schema for the form's input data, giving TypeScript - full LSP support (autocomplete, type checking) for form fields. - - Args: - form_class: Django Form class to introspect - schema_name: Name for the generated Pydantic model (e.g., "ContactFormData") - - Returns: - A Pydantic BaseModel subclass with fields matching the form - """ - # Instantiate form without data to get field definitions - try: - form = form_class() - except TypeError: - # Form requires extra args (like request) - use form_class.base_fields instead - fields_dict = getattr(form_class, "base_fields", {}) - else: - fields_dict = form.fields - - # Build Pydantic field definitions - pydantic_fields: dict[str, Any] = {} - - for field_name, field in fields_dict.items(): - python_type = _django_field_to_python_type(field) - - # Optional fields (not required or has initial value) - if not field.required: - python_type = python_type | None - default = None - elif field.initial is not None: - default = field.initial - else: - default = ... # Required field - - pydantic_fields[field_name] = (python_type, default) - - # Create the model with a unique name - model = create_model(schema_name, **pydantic_fields) - - return model +logger = logging.getLogger("mizan.forms") class mizanFormMeta(BaseModel): """ - Configuration for a mizan form. - - This Pydantic model provides type-safe configuration with full LSP support, - and serializes to JSON for the frontend schema. - - Required: - name: API identifier (e.g., "contact" → contact.schema, contact.validate, contact.submit) - - Display options: - title: Display title (default: derived from class name) - subtitle: Display subtitle - submit_label: Submit button text (default: "Submit") - - Frontend behavior: - live_validation: Enable live validation as user types (default: True) - live_form_errors: Show form-level errors during live validation (default: False) - refetch_schema_on_validate: Refetch schema on each validation - useful for - dynamic choice fields (default: False) - - Features: - enable_formset: Generate formset endpoints (default: False) + Per-form configuration. `name` is the API identifier the three registered + function names are built from; the rest are carried into the emitted schema. """ # Required @@ -169,109 +42,79 @@ class mizanFormMeta(BaseModel): class mizanFormMixin: """ - Mixin that exposes a Django Form as mizan server functions. + Mix into a Django Form alongside a `mizan = mizanFormMeta(...)` attribute to + register `.schema`, `.validate`, and `.submit`. - Add this mixin to any Django Form class along with a `mizan` configuration: - - class ContactForm(mizanFormMixin, forms.Form): - mizan = mizanFormMeta( - name="contact", - title="Contact Us", - ) - - name = forms.CharField() - email = forms.EmailField() - - def on_submit_success(self, request): - return {"sent": True} - - This auto-registers: - - contact.schema - Get form field definitions - - contact.validate - Validate form data - - contact.submit - Submit form - - Overridable methods: - get_init_kwargs(cls, request) -> dict: Extra kwargs for form instantiation - on_submit_success(self, request) -> dict | None: Handle successful submission - on_submit_failure(self, request, errors) -> None: Handle failed submission + `get_init_kwargs`, `on_submit_success` and `on_submit_failure` are the three + override points. Each is called unconditionally, so the definitions here are + what a form that overrides none of them does. """ # Configuration - subclasses must define this mizan: ClassVar[mizanFormMeta] - # Track registered forms to avoid duplicate registration + # Set on registration so a re-import does not register the class twice _mizan_registered: ClassVar[bool] = False @classmethod def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]: """ - Override to provide extra kwargs for form instantiation. - - Common use: pass request or user to forms that need them. - - Example: - @classmethod - def get_init_kwargs(cls, request): - return {"request": request, "user": request.user} + Kwargs merged into every instantiation of this form. `request` is passed + through to a form whose `__init__` names it; Django's own `BaseForm` + signature does not, and rejects any keyword it did not declare, so a + form that never asks for the request is constructed on data/files alone. """ + accepted = inspect.signature(cls.__init__).parameters + if "request" in accepted: + return {"request": request} return {} def on_submit_success(self, request: HttpRequest) -> dict | None: """ - Called after successful form validation and submission. - - Override to handle the form submission logic. - Return a dict to include data in the response. - - Example: - def on_submit_success(self, request): - self.save() - return {"id": self.instance.pk} + Handle a validated submission. A returned dict is carried in the + response payload; a ModelForm's `save()` returns a model instance, which + is not payload, so only a dict result is forwarded. """ - # Default: call save() if available if hasattr(self, "save"): result = self.save() - # If save returns something serializable, include it if isinstance(result, dict): return result return None - def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None: + def on_submit_failure(self, request: HttpRequest, errors: Any) -> None: """ - Called after form validation fails. - - Override to add custom error handling, logging, etc. + Handle a rejected submission. The per-field errors already travel to the + client in the response body, so the rejection is recorded server-side + rather than re-raised. """ - pass + logger.info( + "%s rejected a submission on %s: %s", + type(self).__name__, + getattr(request, "path", ""), + errors, + ) def __init_subclass__(cls, **kwargs): """Auto-register when a concrete form class is defined.""" super().__init_subclass__(**kwargs) - # Only register concrete forms with mizan config defined if _is_concrete_mizan_form(cls): _register_form_as_server_functions(cls) def _is_concrete_mizan_form(cls: type) -> bool: """ - Check if a class is a concrete mizan form ready for registration. - - A form is concrete if: - 1. It has a `mizan` attribute that is a mizanFormMeta instance - 2. It inherits from Django's BaseForm - 3. It hasn't been registered yet (for this class definition) + True when `cls` carries its own mizanFormMeta, is a Django form, and has + not already been registered. """ - # Must have mizan config (check cls.__dict__ to avoid inheriting) + # Read cls.__dict__ so an inherited config does not re-register. mizan_config = cls.__dict__.get("mizan") if not isinstance(mizan_config, mizanFormMeta): return False - # Must be a Django form if not issubclass(cls, forms.BaseForm): return False - # Check if already registered (handle re-imports gracefully) if cls.__dict__.get("_mizan_registered", False): return False @@ -280,50 +123,36 @@ def _is_concrete_mizan_form(cls: type) -> bool: def _register_form_as_server_functions(form_class: type) -> None: """ - Register a Django Form class as mizan server functions. - - Creates and registers: - - {name}.schema - Returns form field definitions - - {name}.validate - Validates form data - - {name}.submit - Validates and submits form - - Each function gets a unique typed schema for better TypeScript LSP support. + Register `{name}.schema`, `{name}.validate`, and `{name}.submit` for + `form_class`, plus the formset trio when `enable_formset` is set. """ - from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation - from .schema_utils import build_form_schema - from .validation_utils import validate_form_instance + from mizan.forms.schemas import ( + FormSchema, + FormSubmitFail, + FormSubmitPass, + FormValidation, + ) + from mizan.forms.schema_utils import build_form_schema + from mizan.forms.validation_utils import validate_form_instance from mizan_core.registry import register from mizan_core.client.function import ServerFunction config: mizanFormMeta = form_class.mizan form_name = config.name - # Mark as registered form_class._mizan_registered = True - # Generate PascalCase name for schemas (e.g., "contact" -> "Contact") + # "contact" -> "Contact", "reset_password" -> "ResetPassword" pascal_name = "".join( word.capitalize() for word in form_name.replace(".", "_").replace("-", "_").split("_") ) - # NOTE: We cannot create FormDataSchema here because form fields aren't - # populated yet during __init_subclass__. We use lazy creation instead. - _form_data_schema_cache: dict[str, type[BaseModel]] = {} - - def get_form_data_schema() -> type[BaseModel]: - """Lazily create the form data schema (form fields aren't available at registration time).""" - if "schema" not in _form_data_schema_cache: - _form_data_schema_cache["schema"] = _create_form_input_schema( - form_class, f"{pascal_name}FormData" - ) - return _form_data_schema_cache["schema"] - # ------------------------------------------------------------------------- # Schema Function # ------------------------------------------------------------------------- - # Schema input wraps the form data for pre-populating dynamic fields + # `data` pre-populates dynamic fields before the schema is read off the form. FormSchemaInput = create_model( f"{pascal_name}SchemaInput", data=(dict[str, Any], {}), @@ -337,7 +166,7 @@ def _register_form_as_server_functions(form_class: type) -> None: "form": True, "form_name": form_name, "form_role": "schema", - "form_class": form_class, # Store reference for schema generation + "form_class": form_class, } def call(self, input) -> FormSchema: @@ -347,13 +176,12 @@ def _register_form_as_server_functions(form_class: type) -> None: data=input.data if input else {}, **init_kwargs, ) - # Override with mizanFormMeta values + # mizanFormMeta wins over anything derived from the form class. if config.title is not None: schema.title = config.title if config.subtitle is not None: schema.subtitle = config.subtitle schema.submit_label = config.submit_label - # Behavior settings are nested in schema.meta schema.meta.live_validation = config.live_validation schema.meta.live_form_errors = config.live_form_errors schema.meta.refetch_schema_on_validate = config.refetch_schema_on_validate @@ -367,7 +195,7 @@ def _register_form_as_server_functions(form_class: type) -> None: # Validate Function # ------------------------------------------------------------------------- - # Use generic dict input - form fields aren't available during __init_subclass__ + # Generic dict input — form fields are unavailable during __init_subclass__. FormValidateInput = create_model( f"{pascal_name}ValidateInput", data=(dict[str, Any], ...), @@ -385,7 +213,6 @@ def _register_form_as_server_functions(form_class: type) -> None: def call(self, input) -> FormValidation: init_kwargs = form_class.get_init_kwargs(self.request) - # Input data is already a dict data = input.data _, validation = validate_form_instance( form_class, @@ -404,32 +231,25 @@ def _register_form_as_server_functions(form_class: type) -> None: # ------------------------------------------------------------------------- class SubmitFunction(ServerFunction): - """ - Submit function handles both JSON and multipart/form-data. - - The executor detects form functions and parses the request appropriately. - """ - - # Use dict for input - form fields unknown at registration time - Input = None # Signals executor to pass raw dict + # Input=None signals the executor to pass the raw dict through, since + # the Django form owns validation. + Input = None _meta: ClassVar[dict] = { "form": True, "form_name": form_name, "form_role": "submit", - "multipart": True, # Signal that this function accepts multipart + "multipart": True, } def call(self, input) -> FormSubmitPass | FormSubmitFail: - """Execute form submission.""" request = self.request - # Check if we have multipart data from executor + # Multipart bodies are parsed onto the request before dispatch. if hasattr(request, "_mizan_form_data"): data = request._mizan_form_data files = request._mizan_form_files elif input is not None: - # JSON input - already a dict data = input if isinstance(input, dict) else input.model_dump() files = None else: @@ -438,7 +258,6 @@ def _register_form_as_server_functions(form_class: type) -> None: init_kwargs = form_class.get_init_kwargs(request) - # Create and validate form form, validation = validate_form_instance( form_class, data=data, @@ -447,11 +266,9 @@ def _register_form_as_server_functions(form_class: type) -> None: ) if form.is_valid(): - # Call the form's on_submit_success result_data = form.on_submit_success(request) return FormSubmitPass(success=True, data=result_data) - # Call the form's on_submit_failure form.on_submit_failure(request, validation) return FormSubmitFail(success=False, errors=validation) @@ -472,36 +289,34 @@ def _register_formset_functions( form_class: type, form_name: str, ) -> None: - """Register formset server functions for a form.""" + """Register the `{name}.formset.*` server functions for a form.""" from django.forms import formset_factory - from .schemas import ( + from mizan.forms.schemas import ( FormsetSchema, FormsetSubmitFail, FormsetSubmitPass, FormsetValidation, ) - from .schema_utils import build_form_schema - from .validation_utils import build_formset_validation - from .formset_utils import forms_to_formset_post_data + from mizan.forms.schema_utils import build_form_schema + from mizan.forms.validation_utils import build_formset_validation + from mizan.forms.formset_utils import forms_to_formset_post_data from mizan_core.registry import register from mizan_core.client.function import ServerFunction formset_class = formset_factory(form_class) - # Generate PascalCase name for schemas pascal_name = "".join( word.capitalize() for word in form_name.replace(".", "_").replace("-", "_").split("_") ) - # NOTE: We cannot create typed schemas here because form fields aren't - # populated yet during __init_subclass__. We use generic dict inputs. - # ------------------------------------------------------------------------- # Formset Schema Function # ------------------------------------------------------------------------- + # Generic dict inputs throughout — form fields are unavailable during + # __init_subclass__, so no typed schema can be built here. FormsetSchemaInput = create_model( f"{pascal_name}FormsetSchemaInput", forms=(list[dict[str, Any]], []), @@ -542,7 +357,6 @@ def _register_formset_functions( # Formset Validate Function # ------------------------------------------------------------------------- - # Generic dict input - form fields aren't available during __init_subclass__ FormsetValidateInput = create_model( f"{pascal_name}FormsetValidateInput", forms=(list[dict[str, Any]], ...), @@ -560,12 +374,12 @@ def _register_formset_functions( def call(self, input) -> FormsetValidation: init_kwargs = form_class.get_init_kwargs(self.request) - # Input.forms is already a list of dicts forms_data = input.forms formset_data = forms_to_formset_post_data(forms_data) formset = formset_class(formset_data, form_kwargs=init_kwargs) + # Every submitted row must validate; blank rows are not excused. for form in formset: form.empty_permitted = False @@ -578,7 +392,6 @@ def _register_formset_functions( # Formset Submit Function # ------------------------------------------------------------------------- - # Generic dict input - form fields aren't available during __init_subclass__ FormsetSubmitInput = create_model( f"{pascal_name}FormsetSubmitInput", forms=(list[dict[str, Any]], ...), @@ -598,12 +411,10 @@ def _register_formset_functions( request = self.request init_kwargs = form_class.get_init_kwargs(request) - # Handle multipart vs JSON if hasattr(request, "_mizan_form_data"): post_data = request._mizan_form_data files = request._mizan_form_files elif input and hasattr(input, "forms"): - # Input.forms is already a list of dicts forms_data = input.forms post_data = forms_to_formset_post_data(forms_data) files = None @@ -620,10 +431,8 @@ def _register_formset_functions( return FormsetSubmitPass(success=True) validation = build_formset_validation(formset) - # Call failure handler on each form for form in formset.forms: - if hasattr(form, "on_submit_failure"): - form.on_submit_failure(request, validation) + form.on_submit_failure(request, validation) return FormsetSubmitFail(success=False, errors=validation) @@ -641,10 +450,8 @@ def register_form( submit_handler: Any = None, ) -> None: """ - Register a Django Form class as Mizan server functions. - - Creates and registers `{name}.schema`, `{name}.validate`, and - `{name}.submit` (if a submit_handler is provided). + Register a plain Django Form as `{name}.schema`, `{name}.validate`, and — + when `submit_handler` is given — `{name}.submit`. """ from mizan_core.client.function import create_form_functions from mizan_core.registry import register @@ -660,10 +467,8 @@ def register_form( def get_forms() -> dict[str, list]: """ - Group registered form-related functions by their form name. - - Returns a mapping like: - {"contact": [ContactSchema, ContactValidate, ContactSubmit], ...} + Group registered form-related functions by their form name, e.g. + `{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}`. """ from mizan_core.registry import get_all_functions diff --git a/backends/mizan-django/src/mizan/forms/schema_utils.py b/backends/mizan-django/src/mizan/forms/schema_utils.py index 2c9e534..b3b7e8e 100644 --- a/backends/mizan-django/src/mizan/forms/schema_utils.py +++ b/backends/mizan-django/src/mizan/forms/schema_utils.py @@ -4,7 +4,7 @@ from typing import Any, Optional from django import forms from django.forms import Field -from .schemas import FieldChoice, FieldSchema, FormMeta, FormSchema +from mizan.forms.schemas import FieldChoice, FieldSchema, FormMeta, FormSchema def create_form_instance( @@ -14,65 +14,58 @@ def create_form_instance( **kwargs, ) -> forms.BaseForm: """ - Create a form instance, gracefully handling kwargs that the form doesn't accept. - - Some Django forms (like allauth's) accept `request` in __init__, others don't. - This function tries with all kwargs first, then progressively removes kwargs - that cause TypeErrors until instantiation succeeds. + Instantiate `form_class`, dropping kwargs its __init__ rejects. + + Django form __init__ signatures vary — some accept `request`, others do + not — so instantiation is retried with the offending kwarg removed until + it succeeds or the TypeError is not about an unexpected keyword. """ - # Common kwargs that forms may or may not accept optional_kwargs = ['request', 'user', 'instance'] - - # Build init kwargs + init_kwargs = dict(kwargs) if data is not None: init_kwargs['data'] = data if files is not None: init_kwargs['files'] = files - + while True: try: return form_class(**init_kwargs) except TypeError as e: error_msg = str(e) - - # Check if it's an unexpected keyword argument error + if "unexpected keyword argument" not in error_msg: raise - - # Find which kwarg caused the problem and remove it + removed = False for kwarg in optional_kwargs: if f"'{kwarg}'" in error_msg and kwarg in init_kwargs: init_kwargs.pop(kwarg) removed = True break - - # If we couldn't identify/remove the problematic kwarg, re-raise + if not removed: raise def _get_choices(field: Field) -> Optional[list[FieldChoice]]: """ - Extract choices from a field, handling ModelChoiceField properly. - ModelChoiceField returns ModelChoiceIteratorValue which is not JSON serializable. + Extract a field's choices as JSON-serializable pairs. ModelChoiceField + yields ModelChoiceIteratorValue, which has to be unwrapped via `.value`. """ if not hasattr(field, "choices"): return None choices: list[FieldChoice] = [] for raw_value, label in field.choices: - value = getattr( - raw_value, "value", raw_value - ) # ModelChoiceIteratorValue -> .value + value = getattr(raw_value, "value", raw_value) choices.append(FieldChoice(value=str(value), label=str(label))) return choices def _get_initial(value: Any) -> Any: - """Convert initial value to JSON-serializable format.""" + """Convert an initial value to a JSON-serializable form.""" if value is None: return None if hasattr(value, "isoformat"): @@ -85,25 +78,15 @@ def _get_initial(value: Any) -> Any: def _class_name_to_title(name: str) -> str: - """ - Convert a class name to a human-readable title. - e.g., 'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password' - """ - # Remove 'Form' suffix + """'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'.""" name = re.sub(r"Form$", "", name) - # Insert spaces before capital letters name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name) return name def _class_name_to_slug(name: str) -> str: - """ - Convert a class name to a slug. - e.g., 'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password' - """ - # Remove 'Form' suffix + """'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'.""" name = re.sub(r"Form$", "", name) - # Insert underscores before capital letters and lowercase name = re.sub(r"([a-z])([A-Z])", r"\1_\2", name) return name.lower() @@ -114,48 +97,31 @@ def build_form_schema( **kwargs, ) -> FormSchema: """ - Produce a FormSchema for the given Django form class and (optional) data. + Produce a FormSchema for a Django form class and optional bound data. - The form class can define metadata via an inner Meta class: - - class MyForm(forms.Form): - class Meta: - form_name = "my_form" - title = "My Form Title" - subtitle = "Optional description" - submit_label = "Submit" - - # Frontend behavior (optional) - refetch_schema_on_validate = False # Set True for dynamic choice fields - live_validation = True # Set False to disable live validation - live_form_errors = False # Set True to show form errors live - - If not provided, sensible defaults are derived from the class name. + Attributes on the form's inner `Meta` class — `form_name`, `title`, + `subtitle`, `submit_label`, `refetch_schema_on_validate`, + `live_validation`, `live_form_errors` — override the values otherwise + derived from the class name. """ form = create_form_instance(form_class, data=data, **kwargs) - # Extract metadata from form's Meta class form_meta = getattr(form_class, "Meta", None) - # Get form name (used as identifier) name = getattr(form_meta, "form_name", None) if name is None: name = _class_name_to_slug(form_class.__name__) - # Get title (human-readable heading) title = getattr(form_meta, "title", None) if title is None: title = _class_name_to_title(form_class.__name__) - # Get optional subtitle subtitle = getattr(form_meta, "subtitle", None) - # Get submit button label submit_label = getattr(form_meta, "submit_label", None) if submit_label is None: submit_label = "Submit" - # Build frontend behavior metadata frontend_meta = FormMeta( refetch_schema_on_validate=getattr(form_meta, "refetch_schema_on_validate", False), live_validation=getattr(form_meta, "live_validation", True), diff --git a/backends/mizan-django/src/mizan/forms/validation_utils.py b/backends/mizan-django/src/mizan/forms/validation_utils.py index c2849fb..bf37768 100644 --- a/backends/mizan-django/src/mizan/forms/validation_utils.py +++ b/backends/mizan-django/src/mizan/forms/validation_utils.py @@ -4,13 +4,13 @@ from django import forms from django.core.files.uploadedfile import UploadedFile from django.utils.datastructures import MultiValueDict -from .schemas import ( +from mizan.forms.schemas import ( FieldError, FieldErrorList, FormValidation, FormsetValidation, ) -from .schema_utils import create_form_instance +from mizan.forms.schema_utils import create_form_instance def validate_form_instance( @@ -19,12 +19,9 @@ def validate_form_instance( files: MultiValueDict[str, UploadedFile] | None = None, **kwargs: Any, ) -> tuple[forms.BaseForm, FormValidation]: - """ - Build a form instance and return (form, structured_validation_errors). - """ + """Build a form instance and return it alongside its structured field errors.""" form = create_form_instance(form_class, data=data, files=files, initial=data, **kwargs) - # Run validation form.is_valid() validation = FormValidation( @@ -46,9 +43,7 @@ def validate_form_instance( def build_formset_validation(formset: forms.BaseFormSet) -> FormsetValidation: - """ - Turn a Django formset into a FormsetValidation structure. - """ + """Turn a Django formset's non-form and per-form errors into a FormsetValidation.""" return FormsetValidation( general=[str(e) if e else "" for e in formset.non_form_errors()], per_form=[ diff --git a/backends/mizan-django/src/mizan/jwt/__init__.py b/backends/mizan-django/src/mizan/jwt/__init__.py index f4197c1..9a03eb4 100644 --- a/backends/mizan-django/src/mizan/jwt/__init__.py +++ b/backends/mizan-django/src/mizan/jwt/__init__.py @@ -1,26 +1,15 @@ """ -mizan.jwt - JWT authentication for server functions. +JWT issuance and validation for mizan server functions. -Provides: -- Server functions for obtaining/refreshing JWT tokens -- JWT authentication utilities for validating tokens - -Server Functions: -- jwt_obtain: Convert authenticated session to JWT tokens -- jwt_refresh: Refresh tokens using a refresh token - -Usage in apps.py or urls.py (to register the functions): - import mizan.jwt.functions # noqa: F401 - -Note: This module is purpose-built for mizan server functions. -For Django Ninja API authentication, use mizan.jwt.security directly. +`jwt_obtain` / `jwt_refresh` are server functions; importing +`mizan.jwt.functions` is what registers them. The Ninja auth class +`JWTAuth` / `jwt_auth` resolves through `__getattr__` so that importing this +package does not pull django-ninja's settings access in at module load time. """ -# Server functions (import to register with @client decorator) -from .functions import jwt_obtain, jwt_refresh +from mizan.jwt.functions import jwt_obtain, jwt_refresh -# Token utilities -from .tokens import ( +from mizan.jwt.tokens import ( create_token_pair, create_access_token, create_refresh_token, @@ -31,17 +20,12 @@ from .tokens import ( JWTUser, ) -# Settings -from .settings import get_settings, JWTSettings - -# Security (Ninja API auth) - lazy import to avoid triggering -# django-ninja's settings access at module load time. -# Use: from mizan.jwt.security import jwt_auth +from mizan.jwt.settings import get_settings, JWTSettings def __getattr__(name): if name in ("JWTAuth", "jwt_auth"): - from .security import JWTAuth, jwt_auth + from mizan.jwt.security import JWTAuth, jwt_auth globals()["JWTAuth"] = JWTAuth globals()["jwt_auth"] = jwt_auth diff --git a/backends/mizan-django/src/mizan/jwt/security.py b/backends/mizan-django/src/mizan/jwt/security.py index 9336241..8f8a1ec 100644 --- a/backends/mizan-django/src/mizan/jwt/security.py +++ b/backends/mizan-django/src/mizan/jwt/security.py @@ -1,64 +1,33 @@ """ -Django Ninja Security Classes for JWT Authentication - -Provides authentication classes that can be used with Django Ninja's -auth parameter to protect API endpoints. +Django Ninja security class for JWT bearer authentication, usable as +`@api.get(..., auth=jwt_auth)` or in an API-wide `auth=[...]` list. """ from django.http import HttpRequest from ninja.security import HttpBearer -from .tokens import decode_token, JWTUser +from mizan.jwt.tokens import decode_token, JWTUser class JWTAuth(HttpBearer): """ - JWT Bearer token authentication for Django Ninja. - - Usage: - from ninja_jwt_session import jwt_auth - - @api.get("/protected/", auth=jwt_auth) - def protected_endpoint(request): - return {"user_id": request.user.id} - - Or globally: - api = NinjaExtraAPI(auth=[django_auth, jwt_auth]) - - The token must be passed in the Authorization header: - Authorization: Bearer - - IMPORTANT: This is stateless - no database query is made. - request.user is a JWTUser object with id, is_staff, is_superuser. - If you need the full User object, query it explicitly: - user = User.objects.get(pk=request.user.id) + Reads `Authorization: Bearer ` and sets `request.user` to a + JWTUser built from the token claims. No database query is made, so the + resulting user carries only id, is_staff, and is_superuser. """ def authenticate(self, request: HttpRequest, token: str): - """ - Validate the JWT and return a JWTUser if valid. - - Returns None (authentication failed) if: - - Token is invalid or expired - - Token is not an access token - - Note: No database query is made. The JWTUser is created from - token claims. This is truly stateless authentication. - """ - # Decode and validate the token + """Return a JWTUser for a valid access token, or None to fail auth.""" payload = decode_token(token, expected_type="access") if payload is None: return None - # Create JWTUser from token claims - NO DATABASE QUERY jwt_user = JWTUser(payload) - # Set request.user for compatibility with code expecting it request.user = jwt_user return jwt_user -# Singleton instance for convenience jwt_auth = JWTAuth() diff --git a/backends/mizan-django/src/mizan/jwt/tokens.py b/backends/mizan-django/src/mizan/jwt/tokens.py index b973e17..ce68e78 100644 --- a/backends/mizan-django/src/mizan/jwt/tokens.py +++ b/backends/mizan-django/src/mizan/jwt/tokens.py @@ -1,17 +1,20 @@ """ -JWT Token Creation and Validation +JWT creation and validation over PyJWT. -Uses PyJWT directly - no allauth dependency. -Tokens are tied to Django sessions for immediate revocation on logout. +Every token carries the Django session key in `sid`; `validate_session` +re-checks that the session still exists, which is what makes logout revoke +outstanding tokens immediately. """ +import logging import time from typing import NamedTuple import jwt -from django.contrib.sessions.backends.base import SessionBase -from .settings import get_settings +from mizan.jwt.settings import get_settings + +logger = logging.getLogger("mizan.jwt") class TokenPair(NamedTuple): @@ -34,13 +37,9 @@ class TokenPayload(NamedTuple): class JWTUser: """ - Minimal user object created from JWT claims. - - Used as request.user for JWT-authenticated requests. - No database query required - all data comes from the token. - - If you need the full User object with all fields, query explicitly: - user = User.objects.get(pk=request.user.id) + Stand-in for `request.user` built entirely from JWT claims — no database + row is loaded, so only id, is_staff, and is_superuser are real. Anything + else about the user has to be queried explicitly by the caller. """ def __init__(self, payload: TokenPayload): @@ -50,7 +49,7 @@ class JWTUser: self.is_superuser = payload.is_superuser self.is_authenticated = True self.is_anonymous = False - self.is_active = True # Assumed active if they have a valid token + self.is_active = True # A valid unexpired token stands in for the flag def __str__(self): return f"JWTUser(id={self.id})" @@ -66,18 +65,7 @@ def create_access_token( is_staff: bool = False, is_superuser: bool = False, ) -> str: - """ - Create a short-lived access token. - - The token contains: - - sub: user ID - - sid: session key (for revocation checking) - - staff: is_staff flag - - super: is_superuser flag - - type: "access" - - iat: issued at - - exp: expiration - """ + """Create a short-lived access token.""" settings = get_settings() now = int(time.time()) @@ -105,18 +93,7 @@ def create_refresh_token( is_staff: bool = False, is_superuser: bool = False, ) -> str: - """ - Create a longer-lived refresh token. - - The token contains: - - sub: user ID - - sid: session key (for revocation checking) - - staff: is_staff flag - - super: is_superuser flag - - type: "refresh" - - iat: issued at - - exp: expiration - """ + """Create a longer-lived refresh token.""" settings = get_settings() now = int(time.time()) @@ -157,13 +134,10 @@ def create_token_pair( ) -def decode_token(token: str, expected_type: str = None) -> TokenPayload | None: +def decode_token(token: str, expected_type: str | None = None) -> TokenPayload | None: """ - Decode and validate a JWT token. - - Returns None if: - - Token is invalid or expired - - Token type doesn't match expected_type (if specified) + Decode and validate a JWT, returning None when it is malformed, expired, + or not of `expected_type`. """ settings = get_settings() @@ -173,11 +147,18 @@ def decode_token(token: str, expected_type: str = None) -> TokenPayload | None: settings.public_key, algorithms=[settings.algorithm], ) - except jwt.PyJWTError: + except jwt.PyJWTError as exc: + # Expired and forged tokens are routine on a public endpoint, so this + # stays at debug rather than flooding the log on every bad request. + logger.debug("JWT rejected: %s", exc) return None - # Validate token type if specified if expected_type and payload.get("type") != expected_type: + logger.debug( + "JWT rejected: expected type %r, got %r", + expected_type, + payload.get("type"), + ) return None return TokenPayload( @@ -193,10 +174,8 @@ def decode_token(token: str, expected_type: str = None) -> TokenPayload | None: def validate_session(session_key: str) -> bool: """ - Check if a session is still valid (exists and not expired). - - This is the key to immediate logout revocation - if the session - is destroyed, tokens tied to it become invalid. + Report whether the Django session backing a token still exists. Returns + True unconditionally when session validation is switched off in settings. """ from importlib import import_module @@ -207,36 +186,30 @@ def validate_session(session_key: str) -> bool: if not jwt_settings.validate_session: return True - # Use the configured session engine engine = import_module(django_settings.SESSION_ENGINE) SessionStore = engine.SessionStore - # Try to load the session session = SessionStore(session_key=session_key) - # Check if session exists and is not empty - # exists() is more reliable than checking load() result + # exists() reads the backend directly; load() would silently hand back an + # empty session for a missing key. return session.exists(session_key) def refresh_tokens(refresh_token: str) -> TokenPair | None: """ - Use a refresh token to obtain new tokens. - - Returns None if: - - Refresh token is invalid or expired - - Associated session no longer exists + Exchange a refresh token for a fresh pair carrying the same claims. + Returns None when the token is invalid or its session is gone. """ payload = decode_token(refresh_token, expected_type="refresh") if payload is None: return None - # Validate the session still exists if not validate_session(payload.session_key): + logger.debug("JWT refresh rejected: session %r no longer exists", payload.session_key) return None - # Issue new token pair with same claims return create_token_pair( payload.user_id, payload.session_key, diff --git a/backends/mizan-django/src/mizan/management/commands/export_channels_schema.py b/backends/mizan-django/src/mizan/management/commands/export_channels_schema.py index fd642f1..48378db 100644 --- a/backends/mizan-django/src/mizan/management/commands/export_channels_schema.py +++ b/backends/mizan-django/src/mizan/management/commands/export_channels_schema.py @@ -1,11 +1,5 @@ """ -Export channels schema as OpenAPI JSON for TypeScript generation. - -Uses Django Ninja's schema generation for robust Pydantic→OpenAPI conversion. -The schema is consumed by openapi-typescript for type generation. - -Usage: - python manage.py export_channels_schema +Writes the channels schema to stdout as OpenAPI JSON. """ import json @@ -29,6 +23,7 @@ class Command(BaseCommand): schema = get_channels_openapi_schema() + # indent=0 is not compact in json.dumps; None is. indent = options["indent"] if options["indent"] > 0 else None output = json.dumps(schema, indent=indent) diff --git a/backends/mizan-django/src/mizan/management/commands/export_edge_manifest.py b/backends/mizan-django/src/mizan/management/commands/export_edge_manifest.py index ec26399..a67bfd7 100644 --- a/backends/mizan-django/src/mizan/management/commands/export_edge_manifest.py +++ b/backends/mizan-django/src/mizan/management/commands/export_edge_manifest.py @@ -1,14 +1,4 @@ -""" -Export Edge Manifest - -Generates the static JSON manifest that Mizan Edge reads at deploy time -to configure CDN cache rules and invalidation routing. - -Usage: - python manage.py export_edge_manifest - python manage.py export_edge_manifest --output mizan-manifest.json - python manage.py export_edge_manifest --base-url /api/mizan -""" +"""Management command emitting the edge cache manifest as JSON.""" import json from pathlib import Path diff --git a/backends/mizan-django/src/mizan/management/commands/export_mizan_ir.py b/backends/mizan-django/src/mizan/management/commands/export_mizan_ir.py index c62a37d..0ba692d 100644 --- a/backends/mizan-django/src/mizan/management/commands/export_mizan_ir.py +++ b/backends/mizan-django/src/mizan/management/commands/export_mizan_ir.py @@ -1,18 +1,13 @@ """ -Mizan IR (KDL) export — Django management command. - -Usage: - python manage.py export_mizan_ir - -Triggers Mizan client discovery to populate the registry, then writes -the canonical Mizan IR as KDL to stdout. The Rust codegen binary -consumes this directly. +Writes the canonical Mizan IR as KDL to stdout, which the Rust codegen binary +consumes. Nothing else in this command may write to stdout. """ from __future__ import annotations from django.core.management.base import BaseCommand +from mizan.setup.discovery import mizan_clients from mizan_core.ir import build_ir @@ -20,9 +15,6 @@ class Command(BaseCommand): help = "Export every registered @client function as Mizan IR (KDL)." def handle(self, *args, **options) -> None: - # Load every project-side @client function so the registry is - # populated before we emit. Conventionally apps/*/clients.py. - from mizan.setup.discovery import mizan_clients - - mizan_clients("apps") + # Discovery populates the registry build_ir() reads. + mizan_clients() self.stdout.write(build_ir(), ending="") diff --git a/backends/mizan-django/src/mizan/setup/__init__.py b/backends/mizan-django/src/mizan/setup/__init__.py index 7404d8a..a84c44c 100644 --- a/backends/mizan-django/src/mizan/setup/__init__.py +++ b/backends/mizan-django/src/mizan/setup/__init__.py @@ -1,12 +1,7 @@ """ -mizan.setup - Django integration helpers. - -The function/composition registry now lives in `mizan_core.registry`. -Channels register themselves through the channel-specific registry in -`mizan.channels`. Forms register through `mizan.forms`. This module -re-exports the helpers that Django mizan users typically reach for, so -`from mizan.setup import register, get_function, mizan_clients, …` keeps -working as a single curated surface. +Curated Django-side surface: registration, lookup, discovery, and settings +helpers, re-exported from `mizan_core.registry`, `mizan.channels`, +`mizan.forms`, and this package's own modules. """ from mizan_core.registry import ( @@ -35,12 +30,13 @@ from mizan.forms import ( get_forms, ) -from .discovery import ( +from mizan.setup.discovery import ( + discover_apps_roots, mizan_clients, mizan_module, ) -from .settings import ( +from mizan.setup.settings import ( mizanSettings, get_settings, clear_settings_cache, @@ -67,6 +63,7 @@ __all__ = [ "validate_registry", "clear_registry", # Discovery + "discover_apps_roots", "mizan_clients", "mizan_module", # Settings diff --git a/backends/mizan-django/src/mizan/setup/discovery.py b/backends/mizan-django/src/mizan/setup/discovery.py index a2714f6..4dc1364 100644 --- a/backends/mizan-django/src/mizan/setup/discovery.py +++ b/backends/mizan-django/src/mizan/setup/discovery.py @@ -1,7 +1,15 @@ import logging +from pathlib import Path from typing import Any -from mizan._vendor.app_visitor import DjangoAppVisitor, get_members +from django.apps import apps as django_apps +from django.conf import settings as django_settings + +from mizan._vendor.app_visitor import ( + DjangoAppVisitor, + DjangoAppVisitorHandler, + get_members, +) from mizan_core.registry import register, get_function from mizan_core.client.function import ServerFunction @@ -9,22 +17,20 @@ from mizan_core.client.function import ServerFunction logger = logging.getLogger(__name__) -class _RegisterServerFunctions: +class _RegisterServerFunctions(DjangoAppVisitorHandler): def on_module( self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]] ) -> None: for name, member in members: - # Register ServerFunction subclasses if ( isinstance(member, type) and issubclass(member, ServerFunction) and member is not ServerFunction and hasattr(member, "__name__") ): - # Use the function name as registration name fn_name = getattr(member, "name", None) or member.__name__ - # Skip already registered (idempotent) + # Idempotent: the same class under the same name is a re-visit. if get_function(fn_name) is member: continue @@ -40,14 +46,48 @@ class _RegisterServerFunctions: ) -# Scans /.py and //**/*.py under apps_root -def mizan_clients(apps_root: str, layer: str = "clients") -> None: - visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root) - visitor.visit(_RegisterServerFunctions()) +def discover_apps_roots() -> list[str]: + """ + Dotted package prefixes the project's own apps sit under, relative to + BASE_DIR. `MIZAN_APPS_ROOT` in Django settings pins the answer. + + An app counts as the project's own only when its directory is exactly + BASE_DIR joined with its dotted name — the same resolution DjangoAppVisitor + performs. That excludes installed packages even when the virtualenv holding + them sits inside BASE_DIR. + + An app declared as "apps.blog" yields "apps"; a top-level "blog" yields "". + """ + pinned = getattr(django_settings, "MIZAN_APPS_ROOT", None) + if pinned is not None: + return [pinned] + + base_dir = Path(django_settings.BASE_DIR).resolve() + + roots: list[str] = [] + for app_config in django_apps.get_app_configs(): + expected = base_dir.joinpath(*app_config.name.split(".")) + if Path(app_config.path).resolve() != expected: + continue + root = app_config.name.rpartition(".")[0] + if root not in roots: + roots.append(root) + return roots + + +def mizan_clients(apps_root: str | None = None, layer: str = "clients") -> None: + """ + Scan /.py and //**/*.py and register every + ServerFunction found. `apps_root` of None scans every discovered root. + """ + handler = _RegisterServerFunctions() + roots = [apps_root] if apps_root is not None else discover_apps_roots() + for root in roots: + DjangoAppVisitor(layer=layer, apps_root=root).visit(handler) -# Registers server functions from one module path, e.g. 'mizan.jwt.functions' def mizan_module(module_path: str) -> None: + """Register the server functions defined in one module, e.g. 'mizan.jwt.functions'.""" members = get_members(module_path) handler = _RegisterServerFunctions() handler.on_module("", [], members) diff --git a/backends/mizan-django/src/mizan/ssr/__init__.py b/backends/mizan-django/src/mizan/ssr/__init__.py index 5580269..3237bf7 100644 --- a/backends/mizan-django/src/mizan/ssr/__init__.py +++ b/backends/mizan-django/src/mizan/ssr/__init__.py @@ -1,25 +1,8 @@ """ -mizan.ssr — Server-side rendering via Bun subprocess. - -Mizan's SSR is a Django template backend. Configure it in TEMPLATES: - - TEMPLATES = [ - { - 'BACKEND': 'mizan.ssr.MizanTemplates', - 'OPTIONS': { - 'worker_path': 'frontend/ssr-worker.tsx', - 'timeout': 5, - }, - }, - ] - -Then use Django's standard render(): - - return render(request, 'ProfilePage', {'user_id': 5}) - -The component name is the template name. The context dict becomes props. +Server-side rendering as a Django template backend: the template name is the +React component's file path and the context dict becomes its props. """ -from .backend import MizanTemplates +from mizan.ssr.backend import MizanTemplates __all__ = ["MizanTemplates"] diff --git a/backends/mizan-django/src/mizan/ssr/backend.py b/backends/mizan-django/src/mizan/ssr/backend.py index f9f3ac3..afa8313 100644 --- a/backends/mizan-django/src/mizan/ssr/backend.py +++ b/backends/mizan-django/src/mizan/ssr/backend.py @@ -1,17 +1,7 @@ """ -Mizan SSR Template Backend — Django template engine that renders React via Bun. - - TEMPLATES = [ - { - 'BACKEND': 'mizan.ssr.MizanTemplates', - 'DIRS': [BASE_DIR / 'frontend'], - 'OPTIONS': { - 'worker': 'path/to/mizan-ssr/src/worker.tsx', - }, - }, - ] - -Then: render(request, 'components/Hello.tsx', {'name': 'World'}) +Django template backend that resolves a template name to a .tsx/.jsx file +under DIRS and renders it through a Bun subprocess. `OPTIONS['worker']` names +the worker script; `OPTIONS['timeout']` bounds a single render. """ from __future__ import annotations @@ -23,7 +13,7 @@ from django.template import TemplateDoesNotExist from django.template.backends.base import BaseEngine from django.utils.safestring import mark_safe -from .bridge import SSRBridge +from mizan.ssr.bridge import SSRBridge class MizanTemplate: @@ -38,12 +28,12 @@ class MizanTemplate: import json as _json props = dict(context) if context else {} + # Neither is JSON-serializable, and neither belongs in client hydration. props.pop("request", None) props.pop("csrf_token", None) result = self._bridge.render(self.file_path, props) - # Serialize props as hydration data for client-side React hydration_json = _json.dumps(props, sort_keys=True, default=str) return mark_safe( @@ -54,10 +44,12 @@ class MizanTemplate: class MizanTemplates(BaseEngine): """ - Django template backend that renders React components via Bun. + Template backend whose template names are file paths resolved against + DIRS. The bridge subprocess is created on first template lookup. - Template names are file paths resolved against DIRS. - Same model as Django's built-in template engines. + A template is a module the Bun worker imports by path, so a source string + names nothing this engine can render — `from_string` is left to BaseEngine, + which rejects it. """ def __init__(self, params: dict[str, Any]) -> None: @@ -93,8 +85,3 @@ class MizanTemplates(BaseEngine): self.get_bridge(), ) raise TemplateDoesNotExist(template_name) - - def from_string(self, template_code: str) -> MizanTemplate: - raise TemplateDoesNotExist( - "MizanTemplates renders .tsx files, not template strings." - ) diff --git a/backends/mizan-django/src/mizan/ssr/bridge.py b/backends/mizan-django/src/mizan/ssr/bridge.py index 78d1084..ec28df2 100644 --- a/backends/mizan-django/src/mizan/ssr/bridge.py +++ b/backends/mizan-django/src/mizan/ssr/bridge.py @@ -1,13 +1,11 @@ """ -SSR Bridge — Manages a persistent Bun subprocess for React rendering. - -Protocol: newline-delimited JSON-RPC over stdin/stdout. +Persistent Bun subprocess speaking newline-delimited JSON-RPC over +stdin/stdout. Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}} Response: {"id": 1, "html": "
...
"} -The subprocess stays alive across requests. It is started on first use -and restarted automatically if it crashes. +Message id 0 is reserved for the worker's unsolicited ready signal. """ from __future__ import annotations @@ -31,10 +29,9 @@ class RenderResult: class SSRBridge: """ - Manages a persistent Bun subprocess for server-side rendering. - - Thread-safe. Multiple Django workers can call render() concurrently. - Request-response matching via message IDs. + Owns the Bun subprocess. Thread-safe: concurrent render() callers are + matched to their response by message id, and stdin writes are serialized + so requests never interleave mid-line. """ def __init__(self, worker_path: str, timeout: float = 5.0) -> None: @@ -42,18 +39,17 @@ class SSRBridge: self._timeout = timeout self._proc: subprocess.Popen | None = None self._lock = threading.Lock() - self._write_lock = threading.Lock() # Serializes stdin writes + self._write_lock = threading.Lock() self._counter = 0 self._pending: dict[int, threading.Event] = {} self._results: dict[int, dict] = {} self._reader_thread: threading.Thread | None = None self._ready = threading.Event() - # Ensure cleanup on process exit atexit.register(self.shutdown) def _ensure_running(self) -> None: - """Start the Bun subprocess if it's not running.""" + """Start the Bun subprocess if it is not already running.""" if self._proc is not None and self._proc.poll() is None: return @@ -73,7 +69,6 @@ class SSRBridge: ) self._reader_thread.start() - # Wait for the "ready" signal from the worker if not self._ready.wait(timeout=self._timeout): logger.error("Bun SSR worker failed to start within %ss", self._timeout) self.shutdown() @@ -82,7 +77,7 @@ class SSRBridge: logger.info("Bun SSR worker started (pid %s)", self._proc.pid) def _read_responses(self) -> None: - """Background thread that reads JSON responses from stdout.""" + """Background thread that reads JSON responses from the worker's stdout.""" try: for line in self._proc.stdout: if isinstance(line, bytes): @@ -99,7 +94,6 @@ class SSRBridge: msg_id = msg.get("id") - # Ready signal (id=0) if msg_id == 0 and msg.get("ready"): self._ready.set() continue @@ -112,18 +106,10 @@ class SSRBridge: def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult: """ - Render a React component to HTML. + Render the component at absolute path `file` with `props` to HTML. - Args: - file: Absolute path to the .tsx/.jsx file to render. - props: Props to pass to the component. - - Returns: - RenderResult with the HTML string. - - Raises: - TimeoutError: If the render takes longer than the configured timeout. - RuntimeError: If the render fails. + Raises TimeoutError past the configured timeout and RuntimeError when + the worker reports a render error or its pipe is broken. """ with self._lock: self._ensure_running() @@ -139,7 +125,6 @@ class SSRBridge: "params": {"file": file, "props": props or {}}, }) + "\n" - # Serialize stdin writes to prevent interleaving from concurrent threads with self._write_lock: try: self._proc.stdin.write(request.encode("utf-8")) @@ -163,19 +148,24 @@ class SSRBridge: return RenderResult(html=result["html"]) def shutdown(self) -> None: - """Stop the Bun subprocess.""" - if self._proc is not None: + """Stop the Bun subprocess, escalating to kill if terminate does not land.""" + if self._proc is None: + return + + try: + self._proc.stdin.close() + except OSError: + logger.warning("Closing SSR worker stdin failed", exc_info=True) + + try: + self._proc.terminate() + self._proc.wait(timeout=3) + except (OSError, subprocess.TimeoutExpired): + logger.warning("SSR worker did not terminate; killing it", exc_info=True) try: - self._proc.stdin.close() - except Exception: - pass - try: - self._proc.terminate() - self._proc.wait(timeout=3) - except Exception: - try: - self._proc.kill() - except Exception: - pass - self._proc = None - logger.info("Bun SSR worker stopped") + self._proc.kill() + except OSError: + logger.warning("Killing SSR worker failed", exc_info=True) + + self._proc = None + logger.info("Bun SSR worker stopped") diff --git a/backends/mizan-django/src/mizan/tests/ssr_components/Hello.tsx b/backends/mizan-django/src/mizan/tests/ssr_components/Hello.tsx new file mode 100644 index 0000000..bc5af3f --- /dev/null +++ b/backends/mizan-django/src/mizan/tests/ssr_components/Hello.tsx @@ -0,0 +1,3 @@ +export default function Hello({ name }: { name?: string }) { + return
Hello, {name}!
+} diff --git a/backends/mizan-django/src/mizan/tests/test_benchmarks.py b/backends/mizan-django/src/mizan/tests/test_benchmarks.py index beb1ca5..01bfaeb 100644 --- a/backends/mizan-django/src/mizan/tests/test_benchmarks.py +++ b/backends/mizan-django/src/mizan/tests/test_benchmarks.py @@ -1,29 +1,21 @@ """ -Protocol Benchmark: HTTP vs WebSocket Server Functions +Latency and throughput measurements for server-function calls, comparing the +direct executor path against the full HTTP view path. -Compares performance of HTTP POST vs WebSocket RPC for server function calls. -Includes realistic scenarios with ORM queries. - -Usage: - python manage.py test mizan.tests.test_benchmarks --verbosity=2 - -Note: - These are not unit tests - they measure performance. Results are printed - to stdout and should be run in isolation for accurate measurements. +These measure rather than assert on timing; each one still checks that the +function under measurement returned the right answer. Timings printed here are +only meaningful when the module is run in isolation. """ -import asyncio import json import statistics import time from typing import Any -from unittest.mock import MagicMock, AsyncMock from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser -from django.db import connection from django.http import HttpRequest -from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings +from django.test import RequestFactory, TransactionTestCase from pydantic import BaseModel from mizan.client.executor import FunctionResult, execute_function, function_call_view @@ -141,9 +133,10 @@ def setup_benchmark_functions(): class ProtocolBenchmark(TransactionTestCase): """ - Benchmark comparing HTTP vs WebSocket (simulated) performance. + Per-call latency for the executor path versus the HTTP view path. - Uses TransactionTestCase to ensure database state is realistic. + TransactionTestCase rather than TestCase: the timings must include real + commits instead of running inside one rolled-back transaction. """ # Number of iterations for each benchmark @@ -157,19 +150,17 @@ class ProtocolBenchmark(TransactionTestCase): def setUp(self): self.factory = RequestFactory() - # Create test users for ORM benchmarks self._create_test_users() def _create_test_users(self): - """Create test users for benchmarks.""" - # Create 100 test users + """Create 100 users, 90% of them active and 5 of them staff.""" users = [] for i in range(100): users.append( User( email=f"bench{i}@example.com", - is_active=i % 10 != 0, # 90% active - is_staff=i < 5, # 5 staff + is_active=i % 10 != 0, + is_staff=i < 5, ) ) User.objects.bulk_create(users, ignore_conflicts=True) @@ -190,11 +181,7 @@ class ProtocolBenchmark(TransactionTestCase): return request def _benchmark_executor(self, fn_name: str, args: dict, label: str) -> dict: - """ - Benchmark direct executor calls (simulates WebSocket RPC). - - Returns timing statistics. - """ + """Time direct executor calls and return timing statistics.""" request = self._make_request() times = [] @@ -212,11 +199,7 @@ class ProtocolBenchmark(TransactionTestCase): return self._compute_stats(times, f"Executor ({label})") def _benchmark_http(self, fn_name: str, args: dict, label: str) -> dict: - """ - Benchmark HTTP view calls. - - Returns timing statistics. - """ + """Time HTTP view calls and return timing statistics.""" times = [] # Warmup @@ -366,17 +349,16 @@ class ProtocolBenchmark(TransactionTestCase): self.assertIn("bench", user["email"].lower()) def test_summary(self): - """Print summary of all benchmarks.""" + """Print the legend for the preceding benchmark tables.""" print("\n\n" + "=" * 80) print("BENCHMARK SUMMARY") print("=" * 80) print(f"Iterations per benchmark: {self.ITERATIONS}") print(f"Warmup iterations: {self.WARMUP}") - print("\nKey findings:") - print("- 'Executor' simulates WebSocket RPC (direct function call)") - print("- 'HTTP' measures full request/response cycle") - print("- HTTP overhead includes: JSON parsing, CSRF, view dispatch") - print("- For I/O-bound operations, protocol overhead is negligible") + print("\nColumns:") + print("- 'Executor' calls execute_function directly") + print("- 'HTTP' calls function_call_view, so it includes JSON parsing,") + print(" CSRF handling, and view dispatch") print("=" * 80) # Verify bench_simple still produces correct output after all benchmarks @@ -392,11 +374,7 @@ class ProtocolBenchmark(TransactionTestCase): class ThroughputBenchmark(TransactionTestCase): - """ - Measure requests per second (throughput) for server functions. - - Tests both sequential and concurrent scenarios. - """ + """Requests per second for the executor path versus the HTTP view path.""" DURATION_SECONDS = 2 # How long to run each throughput test @@ -410,7 +388,7 @@ class ThroughputBenchmark(TransactionTestCase): self._create_test_users() def _create_test_users(self): - """Create test users for benchmarks.""" + """Create 100 users, 90% of them active and 5 of them staff.""" users = [] for i in range(100): users.append( @@ -548,16 +526,14 @@ class ThroughputBenchmark(TransactionTestCase): self.assertGreaterEqual(result.data["total_users"], 0) def test_throughput_summary(self): - """Print throughput summary.""" + """Print the measurement conditions for the preceding throughput tests.""" print("\n\n" + "=" * 80) print("THROUGHPUT SUMMARY") print("=" * 80) print(f"Test duration: {self.DURATION_SECONDS}s per scenario") - print("\nNotes:") - print("- These are single-threaded sequential measurements") - print("- Real throughput scales with worker processes (gunicorn -w N)") - print("- Database queries are the bottleneck, not protocol overhead") - print("- Async workers (uvicorn) can handle more concurrent connections") + print("\nConditions:") + print("- Single-threaded and sequential") + print("- SQLite in-memory database") print("=" * 80) # Verify bench_simple still produces correct output after all throughput tests diff --git a/backends/mizan-django/src/mizan/tests/test_channels.py b/backends/mizan-django/src/mizan/tests/test_channels.py index 5d305b9..3621013 100644 --- a/backends/mizan-django/src/mizan/tests/test_channels.py +++ b/backends/mizan-django/src/mizan/tests/test_channels.py @@ -5,11 +5,10 @@ Tests for mizan.channels module. import json from unittest.mock import AsyncMock, MagicMock, patch from django.test import TestCase -from django.contrib.auth import get_user_model from pydantic import BaseModel from mizan.channels import ( - ReactChannel, + Channel, register, get_channel, get_registered_channels, @@ -18,9 +17,6 @@ from mizan.channels import ( ) -User = get_user_model() - - # ============================================================================= # Test Fixtures # ============================================================================= @@ -42,52 +38,47 @@ class MockAnonymousUser: # ============================================================================= -# ReactChannel Base Class Tests +# Channel Base Class Tests # ============================================================================= -class ReactChannelBaseTests(TestCase): - """Tests for ReactChannel base class.""" +class ChannelBaseTests(TestCase): + """Tests for Channel base class.""" - def test_react_channel_default_class_vars(self): - """ReactChannel should have None defaults for nested classes.""" - self.assertIsNone(ReactChannel.Params) - self.assertIsNone(ReactChannel.ReactMessage) - self.assertIsNone(ReactChannel.DjangoMessage) + def test_channel_default_class_vars(self): + """Channel should have None defaults for nested classes.""" + self.assertIsNone(Channel.Params) + self.assertIsNone(Channel.ClientMessage) + self.assertIsNone(Channel.ServerMessage) - def test_react_channel_requires_authorize_override(self): - """ReactChannel subclass must override authorize().""" + def test_channel_requires_authorize_override(self): + """A subclass without authorize() cannot be instantiated.""" - class IncompleteChannel(ReactChannel): - pass + class NoAuthorizeChannel(Channel): + def group(self, params=None): + return "test" - channel = IncompleteChannel() - channel.user = MockUser() + with self.assertRaises(TypeError) as ctx: + NoAuthorizeChannel() - with self.assertRaises(NotImplementedError) as ctx: - channel.authorize() + self.assertIn("authorize", str(ctx.exception)) - self.assertIn("must implement authorize()", str(ctx.exception)) + def test_channel_requires_group_override(self): + """A subclass without group() cannot be instantiated.""" - def test_react_channel_requires_group_override(self): - """ReactChannel subclass must override group().""" - - class IncompleteChannel(ReactChannel): + class NoGroupChannel(Channel): def authorize(self, params=None): return True - channel = IncompleteChannel() - channel.user = MockUser() + with self.assertRaises(TypeError) as ctx: + NoGroupChannel() - with self.assertRaises(NotImplementedError) as ctx: - channel.group() + self.assertIn("group", str(ctx.exception)) - self.assertIn("must implement group()", str(ctx.exception)) + def test_channel_receive_default(self): + """Channel.receive() should return None by default.""" - def test_react_channel_receive_default(self): - """ReactChannel.receive() should return None by default.""" - - class BasicChannel(ReactChannel): + class BasicChannel(Channel): def authorize(self, params=None): return True @@ -99,10 +90,10 @@ class ReactChannelBaseTests(TestCase): self.assertIsNone(result) - def test_react_channel_init_creates_empty_groups(self): - """ReactChannel.__init__() should create empty _groups set.""" + def test_channel_init_creates_empty_groups(self): + """Channel.__init__() should create empty _groups set.""" - class TestChannel(ReactChannel): + class TestChannel(Channel): def authorize(self, params=None): return True @@ -126,7 +117,7 @@ class TypedMessagesTests(TestCase): def test_channel_with_params(self): """Channel should accept Params Pydantic model.""" - class ParamsChannel(ReactChannel): + class ParamsChannel(Channel): class Params(BaseModel): room: str limit: int = 10 @@ -139,16 +130,15 @@ class TypedMessagesTests(TestCase): self.assertIsNotNone(ParamsChannel.Params) - # Test params model params = ParamsChannel.Params(room="general") self.assertEqual(params.room, "general") self.assertEqual(params.limit, 10) - def test_channel_with_react_message(self): - """Channel should accept ReactMessage Pydantic model.""" + def test_channel_with_client_message(self): + """Channel should accept ClientMessage Pydantic model.""" - class MessageChannel(ReactChannel): - class ReactMessage(BaseModel): + class MessageChannel(Channel): + class ClientMessage(BaseModel): text: str timestamp: int @@ -158,18 +148,17 @@ class TypedMessagesTests(TestCase): def group(self, params=None): return "messages" - self.assertIsNotNone(MessageChannel.ReactMessage) + self.assertIsNotNone(MessageChannel.ClientMessage) - # Test message model - msg = MessageChannel.ReactMessage(text="Hello", timestamp=12345) + msg = MessageChannel.ClientMessage(text="Hello", timestamp=12345) self.assertEqual(msg.text, "Hello") self.assertEqual(msg.timestamp, 12345) - def test_channel_with_django_message(self): - """Channel should accept DjangoMessage Pydantic model.""" + def test_channel_with_server_message(self): + """Channel should accept ServerMessage Pydantic model.""" - class BroadcastChannel(ReactChannel): - class DjangoMessage(BaseModel): + class BroadcastChannel(Channel): + class ServerMessage(BaseModel): user: str text: str created_at: str @@ -180,10 +169,9 @@ class TypedMessagesTests(TestCase): def group(self, params=None): return "broadcast" - self.assertIsNotNone(BroadcastChannel.DjangoMessage) + self.assertIsNotNone(BroadcastChannel.ServerMessage) - # Test message model - msg = BroadcastChannel.DjangoMessage( + msg = BroadcastChannel.ServerMessage( user="john", text="Hello world", created_at="2024-01-15T10:00:00Z" ) self.assertEqual(msg.user, "john") @@ -192,14 +180,14 @@ class TypedMessagesTests(TestCase): def test_channel_receive_with_typed_messages(self): """Channel.receive() should work with typed messages.""" - class ChatChannel(ReactChannel): + class ChatChannel(Channel): class Params(BaseModel): room: str - class ReactMessage(BaseModel): + class ClientMessage(BaseModel): text: str - class DjangoMessage(BaseModel): + class ServerMessage(BaseModel): user: str text: str @@ -210,17 +198,17 @@ class TypedMessagesTests(TestCase): return f"chat_{params.room}" def receive(self, params, msg): - return self.DjangoMessage(user=self.user.email, text=msg.text) + return self.ServerMessage(user=self.user.email, text=msg.text) channel = ChatChannel() channel.user = MockUser(email="test@example.com") params = ChatChannel.Params(room="general") - incoming = ChatChannel.ReactMessage(text="Hello!") + incoming = ChatChannel.ClientMessage(text="Hello!") result = channel.receive(params, incoming) - self.assertIsInstance(result, ChatChannel.DjangoMessage) + self.assertIsInstance(result, ChatChannel.ServerMessage) self.assertEqual(result.user, "test@example.com") self.assertEqual(result.text, "Hello!") @@ -243,7 +231,7 @@ class RegistrationTests(TestCase): def test_register_adds_to_registry(self): """register() should add channel to registry.""" - class TestChannel(ReactChannel): + class TestChannel(Channel): def authorize(self, params=None): return True @@ -255,17 +243,31 @@ class RegistrationTests(TestCase): self.assertIn("test-channel", _registry) self.assertEqual(_registry["test-channel"], TestChannel) - def test_register_duplicate_raises(self): - """register() should raise on duplicate name.""" + def test_register_sets_registered_name(self): + """register() should stamp the wire name onto the class.""" - class Channel1(ReactChannel): + class TestChannel(Channel): def authorize(self, params=None): return True def group(self, params=None): return "test" - class Channel2(ReactChannel): + register(TestChannel, "named-channel") + + self.assertEqual(TestChannel._registered_name, "named-channel") + + def test_register_duplicate_raises(self): + """register() should raise on duplicate name.""" + + class Channel1(Channel): + def authorize(self, params=None): + return True + + def group(self, params=None): + return "test" + + class Channel2(Channel): def authorize(self, params=None): return True @@ -279,21 +281,10 @@ class RegistrationTests(TestCase): self.assertIn("already registered", str(ctx.exception)) - def test_register_validates_authorize(self): - """register() should validate that authorize method exists.""" - - class NoAuthorizeChannel(ReactChannel): - pass - - # Should still pass because ReactChannel has authorize - # (just raises NotImplementedError when called) - register(NoAuthorizeChannel, "no-authorize-test") - self.assertIn("no-authorize-test", _registry) - def test_get_channel_returns_registered(self): """get_channel() should return registered channel.""" - class MyChannel(ReactChannel): + class MyChannel(Channel): def authorize(self, params=None): return True @@ -315,7 +306,7 @@ class RegistrationTests(TestCase): def test_get_registered_channels_returns_copy(self): """get_registered_channels() should return a copy of registry.""" - class TestChannel(ReactChannel): + class TestChannel(Channel): def authorize(self, params=None): return True @@ -326,7 +317,6 @@ class RegistrationTests(TestCase): result = get_registered_channels() - # Modifying result shouldn't affect original result["modified"] = "test" self.assertIn("copy-test", _registry) @@ -360,7 +350,7 @@ class SchemaExportTests(TestCase): def test_get_channels_schema_with_basic_channel(self): """get_channels_schema() should include basic channel info.""" - class BasicChannel(ReactChannel): + class BasicChannel(Channel): def authorize(self, params=None): return True @@ -376,13 +366,13 @@ class SchemaExportTests(TestCase): self.assertEqual(channel_schema["name"], "basic") self.assertIsNone(channel_schema["params"]) - self.assertIsNone(channel_schema["reactMessage"]) - self.assertIsNone(channel_schema["djangoMessage"]) + self.assertIsNone(channel_schema["clientMessage"]) + self.assertIsNone(channel_schema["serverMessage"]) def test_get_channels_schema_with_params(self): """get_channels_schema() should include params schema.""" - class ParamsChannel(ReactChannel): + class ParamsChannel(Channel): class Params(BaseModel): room: str limit: int = 50 @@ -407,14 +397,14 @@ class SchemaExportTests(TestCase): def test_get_channels_schema_with_messages(self): """get_channels_schema() should include message schemas.""" - class FullChannel(ReactChannel): + class FullChannel(Channel): class Params(BaseModel): channel_id: int - class ReactMessage(BaseModel): + class ClientMessage(BaseModel): text: str - class DjangoMessage(BaseModel): + class ServerMessage(BaseModel): user: str text: str timestamp: str @@ -431,24 +421,21 @@ class SchemaExportTests(TestCase): channel_schema = schema["channels"]["full-channel"] - # Check params self.assertIsNotNone(channel_schema["params"]) self.assertIn("channel_id", channel_schema["params"]["properties"]) - # Check ReactMessage - self.assertIsNotNone(channel_schema["reactMessage"]) - self.assertIn("text", channel_schema["reactMessage"]["properties"]) + self.assertIsNotNone(channel_schema["clientMessage"]) + self.assertIn("text", channel_schema["clientMessage"]["properties"]) - # Check DjangoMessage - self.assertIsNotNone(channel_schema["djangoMessage"]) - self.assertIn("user", channel_schema["djangoMessage"]["properties"]) - self.assertIn("text", channel_schema["djangoMessage"]["properties"]) - self.assertIn("timestamp", channel_schema["djangoMessage"]["properties"]) + self.assertIsNotNone(channel_schema["serverMessage"]) + self.assertIn("user", channel_schema["serverMessage"]["properties"]) + self.assertIn("text", channel_schema["serverMessage"]["properties"]) + self.assertIn("timestamp", channel_schema["serverMessage"]["properties"]) def test_get_channels_schema_multiple_channels(self): """get_channels_schema() should include all registered channels.""" - class Channel1(ReactChannel): + class Channel1(Channel): class Params(BaseModel): id: int @@ -458,7 +445,7 @@ class SchemaExportTests(TestCase): def group(self, params): return f"c1_{params.id}" - class Channel2(ReactChannel): + class Channel2(Channel): def authorize(self, params=None): return True @@ -473,13 +460,117 @@ class SchemaExportTests(TestCase): self.assertIn("channel-one", schema["channels"]) self.assertIn("channel-two", schema["channels"]) - # Channel 1 has params self.assertIsNotNone(schema["channels"]["channel-one"]["params"]) - # Channel 2 has no params self.assertIsNone(schema["channels"]["channel-two"]["params"]) +# ============================================================================= +# Registry Extension Tests +# ============================================================================= + + +class ChannelsExtensionTests(TestCase): + """Tests for the channels extension plugged into mizan_core.registry.""" + + def setUp(self): + self._original_registry = dict(_registry) + + def tearDown(self): + _registry.clear() + _registry.update(self._original_registry) + + def _extension(self): + from mizan_core.registry import _extensions + + return _extensions["channels"] + + def test_extension_all_returns_registry_copy(self): + """all() should return name -> channel class, decoupled from the registry.""" + + class TestChannel(Channel): + def authorize(self, params=None): + return True + + def group(self, params=None): + return "ext" + + register(TestChannel, "ext-all") + + result = self._extension().all() + + self.assertEqual(result["ext-all"], TestChannel) + + result["modified"] = TestChannel + self.assertNotIn("modified", _registry) + + def test_extension_schema_names_both_directions(self): + """schema() should carry client_message and server_message slots.""" + + class ChatChannel(Channel): + class Params(BaseModel): + room: str + + class ClientMessage(BaseModel): + text: str + + class ServerMessage(BaseModel): + text: str + + def authorize(self, params): + return True + + def group(self, params): + return f"chat_{params.room}" + + register(ChatChannel, "ext-chat") + + entry = self._extension().schema()["ext-chat"] + + self.assertEqual(entry["type"], "channel") + self.assertTrue(entry["bidirectional"]) + self.assertIn("params", entry) + self.assertIn("client_message", entry) + self.assertIn("server_message", entry) + + def test_extension_schema_omits_absent_client_message(self): + """A server-push-only channel is not bidirectional.""" + + class NotificationsChannel(Channel): + class ServerMessage(BaseModel): + title: str + + def authorize(self, params=None): + return True + + def group(self, params=None): + return "notifications" + + register(NotificationsChannel, "ext-notifications") + + entry = self._extension().schema()["ext-notifications"] + + self.assertNotIn("client_message", entry) + self.assertIn("server_message", entry) + self.assertFalse(entry["bidirectional"]) + + def test_extension_clear_empties_registry(self): + """clear() should drop every registration.""" + + class TestChannel(Channel): + def authorize(self, params=None): + return True + + def group(self, params=None): + return "ext" + + register(TestChannel, "ext-clear") + + self._extension().clear() + + self.assertEqual(_registry, {}) + + # ============================================================================= # Authorization Tests # ============================================================================= @@ -491,7 +582,7 @@ class AuthorizationTests(TestCase): def test_authorize_with_authenticated_user(self): """authorize() should work with authenticated users.""" - class AuthChannel(ReactChannel): + class AuthChannel(Channel): def authorize(self, params=None): return self.user.is_authenticated @@ -506,7 +597,7 @@ class AuthorizationTests(TestCase): def test_authorize_with_anonymous_user(self): """authorize() should work with anonymous users.""" - class AuthChannel(ReactChannel): + class AuthChannel(Channel): def authorize(self, params=None): return self.user.is_authenticated @@ -521,7 +612,7 @@ class AuthorizationTests(TestCase): def test_authorize_with_params(self): """authorize() should have access to params.""" - class RoomChannel(ReactChannel): + class RoomChannel(Channel): class Params(BaseModel): room: str @@ -553,7 +644,7 @@ class GroupTests(TestCase): def test_group_returns_string(self): """group() should return a string group name.""" - class TestChannel(ReactChannel): + class TestChannel(Channel): def authorize(self, params=None): return True @@ -567,7 +658,7 @@ class GroupTests(TestCase): def test_group_with_params(self): """group() should use params for dynamic group names.""" - class RoomChannel(ReactChannel): + class RoomChannel(Channel): class Params(BaseModel): room_id: int @@ -598,7 +689,7 @@ class AsyncMethodsTests(TestCase): """_join_group() should add group to _groups set.""" import asyncio - class TestChannel(ReactChannel): + class TestChannel(Channel): def authorize(self, params=None): return True @@ -624,7 +715,7 @@ class AsyncMethodsTests(TestCase): """_leave_group() should remove group from _groups set.""" import asyncio - class TestChannel(ReactChannel): + class TestChannel(Channel): def authorize(self, params=None): return True @@ -651,7 +742,7 @@ class AsyncMethodsTests(TestCase): """_leave_group() should ignore groups not in _groups.""" import asyncio - class TestChannel(ReactChannel): + class TestChannel(Channel): def authorize(self, params=None): return True @@ -666,16 +757,15 @@ class AsyncMethodsTests(TestCase): await channel._leave_group("unknown-group") return channel._groups - groups = asyncio.get_event_loop().run_until_complete(test()) + asyncio.get_event_loop().run_until_complete(test()) - # Should not have called group_discard channel._channel_layer.group_discard.assert_not_called() def test_leave_all_groups(self): """_leave_all_groups() should leave all joined groups.""" import asyncio - class TestChannel(ReactChannel): + class TestChannel(Channel): def authorize(self, params=None): return True @@ -700,8 +790,8 @@ class AsyncMethodsTests(TestCase): """_broadcast() should send message to channel layer.""" import asyncio - class TestChannel(ReactChannel): - class DjangoMessage(BaseModel): + class TestChannel(Channel): + class ServerMessage(BaseModel): text: str def authorize(self, params=None): @@ -713,7 +803,7 @@ class AsyncMethodsTests(TestCase): channel = TestChannel() channel._channel_layer = AsyncMock() - message = TestChannel.DjangoMessage(text="Hello") + message = TestChannel.ServerMessage(text="Hello") async def test(): await channel._broadcast("my-group", message) @@ -747,8 +837,8 @@ class ServerPushTests(TestCase): """push() should work for channels without params.""" import asyncio - class NotificationChannel(ReactChannel): - class DjangoMessage(BaseModel): + class NotificationChannel(Channel): + class ServerMessage(BaseModel): title: str body: str @@ -762,7 +852,7 @@ class ServerPushTests(TestCase): mock_layer = AsyncMock() mock_get_layer.return_value = mock_layer - message = NotificationChannel.DjangoMessage( + message = NotificationChannel.ServerMessage( title="Alert", body="Something happened" ) @@ -781,11 +871,11 @@ class ServerPushTests(TestCase): """push() should work for channels with params.""" import asyncio - class RoomChannel(ReactChannel): + class RoomChannel(Channel): class Params(BaseModel): room: str - class DjangoMessage(BaseModel): + class ServerMessage(BaseModel): text: str def authorize(self, params): @@ -798,7 +888,7 @@ class ServerPushTests(TestCase): mock_layer = AsyncMock() mock_get_layer.return_value = mock_layer - message = RoomChannel.DjangoMessage(text="Hello room!") + message = RoomChannel.ServerMessage(text="Hello room!") async def test(): await RoomChannel.push(room="general", message=message) @@ -814,10 +904,9 @@ class ServerPushTests(TestCase): def test_push_without_channel_layer_warns(self): """push() should warn when no channel layer is configured.""" import asyncio - import logging - class TestChannel(ReactChannel): - class DjangoMessage(BaseModel): + class TestChannel(Channel): + class ServerMessage(BaseModel): text: str def authorize(self, params=None): @@ -829,7 +918,7 @@ class ServerPushTests(TestCase): with patch("channels.layers.get_channel_layer") as mock_get_layer: mock_get_layer.return_value = None - message = TestChannel.DjangoMessage(text="test") + message = TestChannel.ServerMessage(text="test") with self.assertLogs("mizan.channels", level="WARNING") as cm: @@ -868,7 +957,6 @@ class ManagementCommandTests(TestCase): output = out.getvalue() - # Should be valid JSON with OpenAPI structure schema = json.loads(output) self.assertIn("openapi", schema) @@ -879,7 +967,7 @@ class ManagementCommandTests(TestCase): from io import StringIO from django.core.management import call_command - class TestChannel(ReactChannel): + class TestChannel(Channel): class Params(BaseModel): id: int @@ -897,24 +985,101 @@ class ManagementCommandTests(TestCase): output = out.getvalue() schema = json.loads(output) - # Check that channel is in x-mizan-channels metadata channel_names = [c["name"] for c in schema["x-mizan-channels"]] self.assertIn("export-test", channel_names) + def test_export_command_names_message_slots(self): + """The x-mizan-channels table should name the client and server slots.""" + from io import StringIO + from django.core.management import call_command + + class SlotChannel(Channel): + class ClientMessage(BaseModel): + text: str + + class ServerMessage(BaseModel): + text: str + + def authorize(self, params=None): + return True + + def group(self, params=None): + return "slots" + + register(SlotChannel, "slot_channel") + + out = StringIO() + call_command("export_channels_schema", stdout=out) + + schema = json.loads(out.getvalue()) + entry = next( + c for c in schema["x-mizan-channels"] if c["name"] == "slot_channel" + ) + + self.assertEqual(entry["pascalName"], "SlotChannel") + self.assertTrue(entry["hasClientMessage"]) + self.assertTrue(entry["hasServerMessage"]) + self.assertEqual(entry["clientMessageType"], "SlotChannelClientMessage") + self.assertEqual(entry["serverMessageType"], "SlotChannelServerMessage") + + def test_export_command_type_names_match_the_ir(self): + """A dotted-and-hyphenated wire name yields the same type names the IR + emits — the OpenAPI document and the IR describe one set of types.""" + from io import StringIO + from django.core.management import call_command + + from mizan_core.ir import build_ir, wire_to_pascal + + class ActivityFeedChannel(Channel): + class Params(BaseModel): + user_id: int + + class ClientMessage(BaseModel): + ack: str + + class ServerMessage(BaseModel): + event: str + + def authorize(self, params): + return True + + def group(self, params): + return f"activity_{params.user_id}" + + register(ActivityFeedChannel, "activity.live-feed") + + out = StringIO() + call_command("export_channels_schema", stdout=out) + + schema = json.loads(out.getvalue()) + entry = next( + c for c in schema["x-mizan-channels"] if c["name"] == "activity.live-feed" + ) + + pascal = wire_to_pascal("activity.live-feed") + self.assertEqual(pascal, "ActivityLiveFeed") + self.assertEqual(entry["pascalName"], pascal) + self.assertEqual(entry["paramsType"], f"{pascal}Params") + self.assertEqual(entry["clientMessageType"], f"{pascal}ClientMessage") + self.assertEqual(entry["serverMessageType"], f"{pascal}ServerMessage") + + components = schema["components"]["schemas"] + ir = build_ir() + for slot in ("Params", "ClientMessage", "ServerMessage"): + self.assertIn(f"{pascal}{slot}", components) + self.assertIn(f'type "{pascal}{slot}"', ir) + def test_export_command_respects_indent(self): """export_channels_schema should respect --indent option.""" from io import StringIO from django.core.management import call_command - # With indent out_indent = StringIO() call_command("export_channels_schema", indent=2, stdout=out_indent) - # Without indent (compact) out_compact = StringIO() call_command("export_channels_schema", indent=0, stdout=out_compact) - # Indented should be longer (has whitespace) self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue())) @@ -927,12 +1092,10 @@ class WebSocketRPCTests(TestCase): """Tests for WebSocket RPC functionality.""" def setUp(self): - # Clear mizan registry from mizan_core.registry import clear_registry clear_registry() - # Register test functions from mizan.client import client from mizan_core.registry import register from pydantic import BaseModel diff --git a/backends/mizan-django/src/mizan/tests/test_core.py b/backends/mizan-django/src/mizan/tests/test_core.py index f84d032..8a797ad 100644 --- a/backends/mizan-django/src/mizan/tests/test_core.py +++ b/backends/mizan-django/src/mizan/tests/test_core.py @@ -27,7 +27,7 @@ from mizan_core.registry import ( ) from mizan.forms import register_form from mizan.client import ServerFunction, client, ReactContext, GlobalContext -from mizan.channels import ReactChannel +from mizan.channels import Channel # ============================================================================= @@ -61,10 +61,9 @@ class ErrorOutput(BaseModel): def setup_function_style_tests(): - """Register function-style test functions. - - Note: Since @client no longer auto-registers (registration happens via - mizan_clients() discovery), we explicitly register each function here. + """ + Register the function-style test functions. Applying @client does not put + a function in the registry, so each one is passed to register() here. """ @client @@ -514,8 +513,8 @@ class ContextTests(TestCase): fn = get_function("global_context") self.assertEqual(fn._meta.get("context"), "global") - def test_context_local(self): - """Test @client(context='local') still works with deprecation warning.""" + def test_context_arbitrary_name_is_verbatim_and_silent(self): + """Any non-empty context string becomes the name verbatim, with no warning.""" import warnings class CtxOutput(BaseModel): @@ -528,8 +527,7 @@ class ContextTests(TestCase): def local_context(request: HttpRequest, user_id: int) -> CtxOutput: return CtxOutput(data=f"user_{user_id}") - self.assertEqual(len(w), 1) - self.assertIn("deprecated", str(w[0].message).lower()) + self.assertEqual([str(entry.message) for entry in w], []) register(local_context, "local_context") @@ -1019,7 +1017,7 @@ class ServerDrivenInvalidationTests(TestCase): self.assertIn("team_info", data) self.assertEqual(data["team_info"]["name"], "team_3") - # Mizan handles caching via its protocol; origin emits no-store + # Origin emits no-store self.assertEqual(response["Cache-Control"], "no-store") def test_context_error_not_cached(self): @@ -1175,7 +1173,7 @@ class ContextFetchTests(TestCase): class ChannelTests(TestCase): - """Tests for ReactChannel.""" + """Tests for Channel.""" def setUp(self): clear_registry() @@ -1187,8 +1185,8 @@ class ChannelTests(TestCase): """Test channel registration.""" from mizan.channels import register as register_channel, get_channel - class TestChannel(ReactChannel): - class DjangoMessage(BaseModel): + class TestChannel(Channel): + class ServerMessage(BaseModel): text: str def authorize(self, params=None): @@ -1201,14 +1199,14 @@ class ChannelTests(TestCase): """Test channel schema export.""" from mizan.channels import register as register_channel - class ChatChannel(ReactChannel): + class ChatChannel(Channel): class Params(BaseModel): room: int - class ReactMessage(BaseModel): + class ClientMessage(BaseModel): text: str - class DjangoMessage(BaseModel): + class ServerMessage(BaseModel): user: str text: str @@ -1225,16 +1223,16 @@ class ChannelTests(TestCase): chat_schema = schema["channels"]["chat"] self.assertEqual(chat_schema["type"], "channel") self.assertIn("params", chat_schema) - self.assertIn("react_message", chat_schema) - self.assertIn("django_message", chat_schema) + self.assertIn("client_message", chat_schema) + self.assertIn("server_message", chat_schema) self.assertTrue(chat_schema["bidirectional"]) def test_server_push_only_channel(self): - """Test channel without ReactMessage (server-push only).""" + """Test channel without ClientMessage (server-push only).""" from mizan.channels import register as register_channel - class NotificationsChannel(ReactChannel): - class DjangoMessage(BaseModel): + class NotificationsChannel(Channel): + class ServerMessage(BaseModel): title: str def authorize(self, params=None): @@ -1244,7 +1242,7 @@ class ChannelTests(TestCase): schema = get_schema() notif_schema = schema["channels"]["notifications"] - self.assertNotIn("react_message", notif_schema) + self.assertNotIn("client_message", notif_schema) self.assertFalse(notif_schema["bidirectional"]) @@ -1374,10 +1372,9 @@ class TypeAnnotationTests(TestCase): """ Test that Optional[BaseModel] return types are NOT wrapped in 'result'. - This is a regression test for the bug where `UserOutput | None` was - incorrectly treated as a primitive type (because Union types aren't - recognized by `isinstance(t, type)`), causing the output to be wrapped - in a 'result' field. + Union types are not recognized by `isinstance(t, type)`, so + `UserOutput | None` can be mistaken for a primitive and wrapped in a + 'result' field. This pins that it is not. """ import types @@ -1663,6 +1660,28 @@ class mizanFormMixinTests(TestCase): self.assertFalse(result.data["success"]) self.assertIn("errors", result.data) + def test_form_submit_failure_calls_hook(self): + """A rejected submission calls on_submit_failure with the validation errors.""" + from django import forms + from mizan.forms import mizanFormMixin, mizanFormMeta + + seen = [] + + class HookForm(mizanFormMixin, forms.Form): + mizan = mizanFormMeta(name="failure_hook_test") + required_field = forms.CharField() + + def on_submit_failure(self, request, errors): + seen.append(errors) + + request = self._make_request() + result = execute_function(request, "failure_hook_test.submit", {}) + + self.assertIsInstance(result, FunctionResult) + self.assertFalse(result.data["success"]) + self.assertEqual(len(seen), 1) + self.assertEqual([entry.field for entry in seen[0].errors], ["required_field"]) + def test_form_meta_serialization(self): """Test that mizanFormMeta serializes correctly (auth excluded).""" from mizan.forms import mizanFormMeta @@ -1718,6 +1737,32 @@ class mizanFormMixinTests(TestCase): self.assertEqual(len(result.data["fields"]), 1) self.assertEqual(result.data["fields"][0]["type"], "text") + def test_default_init_kwargs_forwards_request_only_when_declared(self): + """The base get_init_kwargs passes `request` to a form whose __init__ names it.""" + from django import forms + from mizan.forms import mizanFormMixin, mizanFormMeta + + class PlainInitForm(mizanFormMixin, forms.Form): + mizan = mizanFormMeta(name="plain_init_test") + field = forms.CharField() + + class RequestInitForm(mizanFormMixin, forms.Form): + mizan = mizanFormMeta(name="request_init_test") + field = forms.CharField() + + def __init__(self, *args, request=None, **kwargs): + super().__init__(*args, **kwargs) + self.seen_request = request + + request = self._make_request() + + self.assertEqual(PlainInitForm.get_init_kwargs(request), {}) + self.assertEqual(RequestInitForm.get_init_kwargs(request), {"request": request}) + + # And the form actually constructs with the forwarded kwarg. + form = RequestInitForm(**RequestInitForm.get_init_kwargs(request)) + self.assertIs(form.seen_request, request) + def test_formset_functions_not_registered_by_default(self): """Test that formset functions are not registered by default.""" from django import forms @@ -1847,7 +1892,7 @@ class HTTPIntegrationTests(TestCase): self.assertEqual(data["user_profile"]["name"], "user_5") self.assertEqual(data["user_orders"]["count"], 50) - # Mizan handles caching; origin emits no-store + # Origin emits no-store self.assertEqual(response["Cache-Control"], "no-store") def test_context_fetch_string_to_int_coercion(self): @@ -2133,14 +2178,15 @@ class ReturnTypeBranchingTests(TestCase): # ============================================================================= -# Edge Compatibility Tests — Prove CDN caching works before Edge exists +# Edge Compatibility Tests # ============================================================================= class EdgeCompatibilityTests(TestCase): """ - Tests that prove Edge caching is possible. Every failure mode that - would break a CDN layer is tested here without building the CDN. + Response properties a CDN layer reads: byte-identical bodies for identical + requests, sorted JSON keys, no-store on mutations and errors, and an + X-Mizan-Invalidate header that parses back to the JSON body's targets. """ def setUp(self): @@ -2182,7 +2228,7 @@ class EdgeCompatibilityTests(TestCase): # ── Deterministic JSON ────────────────────────────────────────────────── def test_deterministic_json_output(self): - """Same request produces byte-identical response body. Cache keys depend on this.""" + """Same request produces a byte-identical response body.""" r1 = self.client.get("/api/mizan/ctx/user/?user_id=5") r2 = self.client.get("/api/mizan/ctx/user/?user_id=5") @@ -2204,12 +2250,12 @@ class EdgeCompatibilityTests(TestCase): # ── Cache-Control correctness ─────────────────────────────────────────── def test_context_get_no_store(self): - """Context GET emits no-store. Mizan's protocol layers handle caching.""" + """Context GET emits no-store.""" response = self.client.get("/api/mizan/ctx/user/?user_id=5") self.assertEqual(response["Cache-Control"], "no-store") def test_mutation_post_not_cacheable(self): - """Mutation POST has no-store. CDN must never cache mutations.""" + """Mutation POST emits no-store.""" response = self.client.post( "/api/mizan/call/", data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}), @@ -2219,14 +2265,14 @@ class EdgeCompatibilityTests(TestCase): self.assertEqual(response["Cache-Control"], "no-store") def test_error_response_not_cacheable(self): - """Error responses have no-store. CDN must not cache errors.""" + """Error responses emit no-store.""" response = self.client.get("/api/mizan/ctx/nonexistent/") self.assertEqual(response.status_code, 404) self.assertEqual(response["Cache-Control"], "no-store") def test_different_params_different_response(self): - """Different query params produce different response bodies (different cache entries).""" + """Different query params produce different response bodies.""" r1 = self.client.get("/api/mizan/ctx/user/?user_id=5") r2 = self.client.get("/api/mizan/ctx/user/?user_id=6") @@ -2251,7 +2297,7 @@ class EdgeCompatibilityTests(TestCase): header = response["X-Mizan-Invalidate"] - # Parse the header (this is what Edge would do) + # Parse the header back into structured entries entries = [] for part in header.split(", "): segments = part.split(";") @@ -2306,7 +2352,7 @@ class EdgeCompatibilityTests(TestCase): # ── Query param ordering doesn't affect content ───────────────────────── def test_param_order_irrelevant(self): - """Different query param ordering produces same content (cache key normalization).""" + """Different query param ordering produces the same content.""" @client(context=ReactContext("multi")) def multi_param(request: HttpRequest, a: int, b: int) -> ValidOutput: return ValidOutput(valid=True) @@ -2358,7 +2404,7 @@ class EdgeCompatibilityTests(TestCase): ] header = _format_invalidate_header(original) - # Parse (what Edge would do) + # Parse back segments = header.split(";") ctx = segments[0] params = {} @@ -3339,7 +3385,8 @@ def _redis_available() -> bool: client = redis.from_url(REDIS_URL, socket_connect_timeout=1) client.ping() return True - except Exception: + except Exception as e: + print(f"Redis probe failed for {REDIS_URL}: {type(e).__name__}: {e}") return False diff --git a/backends/mizan-django/src/mizan/tests/test_discovery.py b/backends/mizan-django/src/mizan/tests/test_discovery.py new file mode 100644 index 0000000..099e018 --- /dev/null +++ b/backends/mizan-django/src/mizan/tests/test_discovery.py @@ -0,0 +1,37 @@ +""" +Tests for app-root discovery, which decides where mizan_clients() scans. +""" + +from pathlib import Path + +from django.test import TestCase, override_settings + +from mizan.setup.discovery import discover_apps_roots + +# The installed mizan-django package root: `tests` sits directly beneath it and +# the virtualenv holding django.contrib.* does too. +PACKAGE_ROOT = Path(__file__).resolve().parents[3] + + +class DiscoverAppsRootsTests(TestCase): + @override_settings(MIZAN_APPS_ROOT="somewhere_else") + def test_pinned_root_is_used_verbatim(self): + self.assertEqual(discover_apps_roots(), ["somewhere_else"]) + + @override_settings(MIZAN_APPS_ROOT="") + def test_pinned_empty_root_means_apps_sit_at_base_dir(self): + self.assertEqual(discover_apps_roots(), [""]) + + @override_settings(BASE_DIR=PACKAGE_ROOT) + def test_top_level_project_app_yields_the_empty_root(self): + self.assertIn("", discover_apps_roots()) + + @override_settings(BASE_DIR=PACKAGE_ROOT) + def test_installed_packages_contribute_no_root(self): + # django.contrib.* resolve inside the virtualenv, which lives under + # BASE_DIR here — a containment check alone would wrongly admit them. + self.assertNotIn("django.contrib", discover_apps_roots()) + + @override_settings(BASE_DIR=PACKAGE_ROOT / "no_such_directory") + def test_no_matching_app_yields_no_roots(self): + self.assertEqual(discover_apps_roots(), []) diff --git a/backends/mizan-django/src/mizan/tests/test_pentest.py b/backends/mizan-django/src/mizan/tests/test_pentest.py index 32299a0..c3bba21 100644 --- a/backends/mizan-django/src/mizan/tests/test_pentest.py +++ b/backends/mizan-django/src/mizan/tests/test_pentest.py @@ -1,22 +1,10 @@ """ -Advanced Penetration Tests for mizan Server Functions +Attack-shaped tests over execute_function and the WebSocket consumer. -These tests simulate a professional security researcher attempting to break -the protocol. Focus areas: - -1. Race conditions and TOCTOU vulnerabilities -2. Memory exhaustion and resource depletion -3. Type confusion at serialization boundaries -4. Session/authentication state manipulation -5. Pydantic validation bypass attempts -6. WebSocket protocol-level attacks -7. Timing side-channel attacks -8. Concurrent state corruption -9. Deserialization attacks -10. Unicode normalization exploits - -SAFE TO RUN: These tests don't execute actual exploits - they verify -that the defenses hold against attack patterns. +Grouped by the surface each one drives: memory exhaustion, type confusion at +the serialization boundary, concurrent execution, Pydantic validation bypass, +WebSocket protocol framing, timing measurement, Unicode normalization, JSON +parsing limits, authorization boundaries, and registration collisions. """ import asyncio @@ -485,9 +473,6 @@ class RaceConditionTests(TestCase): result = execute_function(request, "timed_auth_func", None) - # The result reflects the state at time of check - # This test documents the behavior - the user's is_authenticated - # is read during execution, and if it changes, that's reflected self.assertIsInstance(result, FunctionResult) # On first access is_authenticated returns True, on second it returns False # The function reads is_authenticated once, so it sees the first value (True) @@ -503,9 +488,8 @@ class PydanticBypassTests(TestCase): """ Attempt to bypass Pydantic validation. - Note: The @client decorator creates dynamic Pydantic models from function - parameters. Custom validators must be on the parameter types themselves, - not separate classes. This tests the actual validation behavior. + The @client decorator builds the Input model from the function's parameter + annotations, so a custom validator has to live on the parameter type itself. Attack vectors: - Type coercion bypass @@ -710,17 +694,17 @@ class WebSocketProtocolTests(TestCase): Try rapid subscribe/unsubscribe cycles and malformed params. """ - from mizan.channels import register as register_channel, ReactChannel + from mizan.channels import register as register_channel, Channel from mizan.channels import _registry as channels_registry from asgiref.sync import async_to_sync channels_registry.clear() - class TestChannel(ReactChannel): + class TestChannel(Channel): class Params(BaseModel): room: str - class DjangoMessage(BaseModel): + class ServerMessage(BaseModel): text: str def authorize(self, params): @@ -751,14 +735,14 @@ class WebSocketProtocolTests(TestCase): """ Test attempting to subscribe to the same channel twice. """ - from mizan.channels import register as register_channel, ReactChannel + from mizan.channels import register as register_channel, Channel from mizan.channels import _registry as channels_registry from asgiref.sync import async_to_sync channels_registry.clear() - class TestChannel(ReactChannel): - class DjangoMessage(BaseModel): + class TestChannel(Channel): + class ServerMessage(BaseModel): text: str def authorize(self, params=None): @@ -859,7 +843,6 @@ class TimingSideChannelTests(TestCase): # Large differences could leak function existence ratio = max(avg_existing, avg_nonexistent) / min(avg_existing, avg_nonexistent) - # Document the ratio but don't fail - this is informational print(f"\nTiming ratio (existing/nonexistent): {ratio:.2f}") print(f"Avg existing: {avg_existing*1000:.3f}ms") print(f"Avg nonexistent: {avg_nonexistent*1000:.3f}ms") @@ -944,14 +927,15 @@ class UnicodeNormalizationTests(TestCase): """ request = self._make_request() - # These look like "admin" but use different Unicode characters + # Built from chr(): each of these renders identically to its ASCII + # counterpart, so a literal would be unreadable in source. lookalikes = [ - "\u0430dmin", # Cyrillic 'а' (U+0430) instead of Latin 'a' - "adm\u0131n", # Turkish dotless i (U+0131) - "\u00e1dmin", # Latin a with acute - "\uff41\uff44\uff4d\uff49\uff4e", # Fullwidth characters - "\u0251dmin", # Latin alpha - "\u0430\u0501m\u0456n", # Mix of Cyrillic characters + chr(0x0430) + "dmin", # Cyrillic small a + "adm" + chr(0x0131) + "n", # Turkish dotless i + chr(0x00E1) + "dmin", # Latin a with acute + "".join(chr(c) for c in (0xFF41, 0xFF44, 0xFF4D, 0xFF49, 0xFF4E)), + chr(0x0251) + "dmin", # Latin alpha + chr(0x0430) + chr(0x0501) + "m" + chr(0x0456) + "n", # Cyrillic mix ] for lookalike in lookalikes: @@ -970,11 +954,10 @@ class UnicodeNormalizationTests(TestCase): request = self._make_request() - # é can be represented as: - # 1. U+00E9 (precomposed) - # 2. U+0065 U+0301 (decomposed: e + combining acute) - precomposed = "caf\u00e9" # café with precomposed é - decomposed = "cafe\u0301" # café with combining acute + # Built from chr() so the two spellings stay distinguishable in source: + # U+00E9 precomposed vs. "e" + U+0301 combining acute. + precomposed = "caf" + chr(0x00E9) + decomposed = "cafe" + chr(0x0301) # These look identical but are different byte sequences self.assertNotEqual(precomposed, decomposed) @@ -994,12 +977,13 @@ class UnicodeNormalizationTests(TestCase): """ request = self._make_request() + # Built from chr(): every one of these renders as nothing at all. zero_width_chars = [ - "\u200b", # Zero-width space - "\u200c", # Zero-width non-joiner - "\u200d", # Zero-width joiner - "\u2060", # Word joiner - "\ufeff", # Zero-width no-break space (BOM) + chr(0x200B), # Zero-width space + chr(0x200C), # Zero-width non-joiner + chr(0x200D), # Zero-width joiner + chr(0x2060), # Word joiner + chr(0xFEFF), # Zero-width no-break space (BOM) ] for zwc in zero_width_chars: @@ -1066,11 +1050,9 @@ class JSONParsingEdgeCaseTests(TestCase): try: result = execute_function(request, "json_func", {"data": nested}) - # Should either succeed or fail gracefully self.assertIn(type(result), [FunctionResult, FunctionError]) - except RecursionError: - # This is acceptable - Python's recursion limit hit - pass + except RecursionError as exc: + print(f"\nCPython recursion limit reached at 500 levels: {exc}") def test_json_number_precision(self): """ @@ -1175,8 +1157,8 @@ class RegistrationSecurityTests(TestCase): """ Test that a different function cannot override an existing one. - Note: Re-registration of the same function name IS allowed for hot reload. - But a DIFFERENT function cannot take over an existing name. + Re-registering the same object under its own name is allowed; a + different object claiming a taken name raises. """ from mizan.client import ServerFunction from mizan_core.registry import register diff --git a/backends/mizan-django/src/mizan/tests/test_security.py b/backends/mizan-django/src/mizan/tests/test_security.py index 2355c7c..937970b 100644 --- a/backends/mizan-django/src/mizan/tests/test_security.py +++ b/backends/mizan-django/src/mizan/tests/test_security.py @@ -1,26 +1,16 @@ """ -Security-focused E2E tests for mizan server functions. - -These tests probe for potential vulnerabilities without running any -malicious code - they simply verify that defenses work correctly. - -Security areas covered: -1. Input Validation - Large inputs, nested objects, type confusion -2. Authorization - Bypass attempts, permission checks -3. HTTP Endpoint - CSRF, method restrictions, JSON parsing -4. WebSocket RPC - Malformed messages, unauthorized calls -5. Information Disclosure - Error enumeration, internal detail leakage -6. Injection Prevention - Special characters, unicode edge cases +Adversarial-input tests: hostile payloads driven through execute_function, +function_call_view, and the WebSocket consumer. """ import json -from unittest.mock import MagicMock, patch, AsyncMock +from unittest.mock import MagicMock, AsyncMock from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.http import HttpRequest from django.test import RequestFactory, TestCase, Client, override_settings -from pydantic import BaseModel, field_validator +from pydantic import BaseModel from mizan.client.executor import ( ErrorCode, @@ -29,9 +19,9 @@ from mizan.client.executor import ( execute_function, function_call_view, ) -from mizan_core.registry import clear_registry, register, register_as, get_function -from mizan.client import ServerFunction, client -from mizan.channels import ReactChannel +from mizan_core.registry import clear_registry, register +from mizan.client import client +from mizan.channels import Channel User = get_user_model() @@ -46,10 +36,6 @@ class SimpleOutput(BaseModel): value: str -class NestedInput(BaseModel): - level1: dict - - class DeeplyNestedOutput(BaseModel): depth: int @@ -69,12 +55,7 @@ class AdminOnlyOutput(BaseModel): class InputValidationSecurityTests(TestCase): - """ - Test input validation for security edge cases. - - Verifies that Pydantic validation catches malicious or malformed input - BEFORE any function code executes. - """ + """Oversized strings, deep nesting, unicode codepoints, type mismatches, and extra fields.""" def setUp(self): clear_registry() @@ -148,7 +129,6 @@ class InputValidationSecurityTests(TestCase): """Test that null bytes in strings are handled safely.""" request = self._make_request() - # Null byte injection attempt payload = "normal\x00injected" result = execute_function(request, "echo_any", {"message": payload}) @@ -160,19 +140,21 @@ class InputValidationSecurityTests(TestCase): """Test various unicode edge cases.""" request = self._make_request() + # Escapes, not literals: these codepoints are invisible in an editor, and + # a literal NUL cannot appear in Python source at all. test_cases = [ # Zero-width characters - "normal\u200btext", + "normal​text", # Right-to-left override (potential display issues) - "test\u202eevil", + "test‮evil", # Emoji sequences - "👨‍👩‍👧‍👦", + "\U0001f468‍\U0001f469‍\U0001f467‍\U0001f466", # Combining characters - "a\u0300\u0301\u0302", + "à́̂", # Null character - "test\u0000null", + "test\x00null", # Replacement character - "test\ufffdreplace", + "test�replace", ] for payload in test_cases: @@ -226,12 +208,7 @@ class InputValidationSecurityTests(TestCase): class AuthorizationSecurityTests(TestCase): - """ - Test authorization bypass attempts. - - Verifies that authentication/authorization checks can't be bypassed - through various attack vectors. - """ + """execute_function outcomes for anonymous, authenticated, staff, duck-typed, and cross-user callers.""" def setUp(self): clear_registry() @@ -264,8 +241,6 @@ class AuthorizationSecurityTests(TestCase): @client def leaky_auth_check(request: HttpRequest) -> SimpleOutput: - # Bad pattern: returns different errors for auth vs not found - # This is intentionally bad to test we detect it if not request.user.is_authenticated: raise PermissionError("User not logged in") return SimpleOutput(value="ok") @@ -321,9 +296,8 @@ class AuthorizationSecurityTests(TestCase): self.assertIsInstance(result, FunctionResult) def test_spoofed_is_authenticated_attribute(self): - """Test that spoofing is_authenticated doesn't work.""" + """Test that a duck-typed user carrying is_authenticated is accepted.""" - # Create object that claims to be authenticated but isn't a real user class FakeUser: is_authenticated = True id = 999 @@ -331,8 +305,7 @@ class AuthorizationSecurityTests(TestCase): request = self._make_request(user=FakeUser()) result = execute_function(request, "requires_auth", None) - # This actually works because we only check is_authenticated - # This test documents the behavior - real Django handles this + # execute_function only reads is_authenticated, so this duck-type passes self.assertIsInstance(result, FunctionResult) def test_user_id_manipulation_blocked(self): @@ -340,7 +313,6 @@ class AuthorizationSecurityTests(TestCase): @client def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput: - # Properly checking: can only access own data if not request.user.is_authenticated: raise PermissionError("Authentication required") if request.user.id != target_user_id: @@ -368,11 +340,7 @@ class AuthorizationSecurityTests(TestCase): class HTTPEndpointSecurityTests(TestCase): - """ - Test HTTP endpoint security. - - Verifies CSRF protection, method restrictions, and JSON parsing security. - """ + """Method restrictions, JSON body parsing, and function-name lookup on the HTTP view.""" def setUp(self): clear_registry() @@ -430,7 +398,6 @@ class HTTPEndpointSecurityTests(TestCase): "/api/mizan/call/", data="{invalid json", content_type="application/json" ) request.user = AnonymousUser() - # Bypass CSRF for this test request._dont_enforce_csrf_checks = True response = function_call_view(request) @@ -487,7 +454,6 @@ class HTTPEndpointSecurityTests(TestCase): def test_function_identifier_traversal(self): """Test that path traversal-style function identifiers are handled.""" - # Try various path traversal attempts as function identifiers malicious_names = [ "../../../etc/passwd", "..\\..\\windows\\system32", @@ -515,11 +481,7 @@ class HTTPEndpointSecurityTests(TestCase): class WebSocketRPCSecurityTests(TestCase): - """ - Test WebSocket RPC security. - - Verifies that malformed messages and unauthorized calls are handled safely. - """ + """Malformed and unresolvable RPC frames over the WebSocket consumer.""" def setUp(self): clear_registry() @@ -555,11 +517,9 @@ class WebSocketRPCSecurityTests(TestCase): consumer.channel_layer = MagicMock() consumer.channel_name = "test" - # Track sent messages sent_messages = [] consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) - # Call without id async_to_sync(consumer._handle_rpc)( {"fn": "ws_echo", "args": {"message": "test"}} ) @@ -581,10 +541,8 @@ class WebSocketRPCSecurityTests(TestCase): sent_messages = [] consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) - # Call without fn async_to_sync(consumer._handle_rpc)({"id": "123", "args": {}}) - # Should return error self.assertEqual(len(sent_messages), 1) self.assertEqual(sent_messages[0]["ok"], False) self.assertEqual(sent_messages[0]["error"]["code"], "BAD_REQUEST") @@ -622,20 +580,19 @@ class WebSocketRPCSecurityTests(TestCase): sent_messages = [] consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) - # Call with wrong input type + # Pydantic coerces an int to str, so an omitted required field is what + # actually produces a validation error here. async_to_sync(consumer._handle_rpc)( { "id": "123", "fn": "ws_echo", - "args": {"message": 12345}, # Should be string + "args": {"message": 12345}, } ) - # Pydantic coerces int to string, so this actually succeeds - # Let's test with missing required field instead sent_messages.clear() async_to_sync(consumer._handle_rpc)( - {"id": "124", "fn": "ws_echo", "args": {}} # Missing message + {"id": "124", "fn": "ws_echo", "args": {}} ) self.assertEqual(sent_messages[0]["ok"], False) @@ -648,11 +605,7 @@ class WebSocketRPCSecurityTests(TestCase): class InformationDisclosureTests(TestCase): - """ - Test information disclosure vulnerabilities. - - Verifies that error messages don't leak sensitive information. - """ + """Contents of FunctionError responses with DEBUG=False.""" def setUp(self): clear_registry() @@ -667,7 +620,6 @@ class InformationDisclosureTests(TestCase): @client def error_with_sensitive_data(request: HttpRequest) -> SimpleOutput: - # Simulate accessing sensitive config that might leak in error secret_key = "super_secret_key_12345" raise RuntimeError(f"Database error with key: {secret_key}") @@ -703,7 +655,6 @@ class InformationDisclosureTests(TestCase): """Test that error messages don't help enumerate functions in production.""" request = self._make_request() - # Try various function names/UUIDs test_names = [ "admin_panel", "get_all_users", @@ -716,8 +667,7 @@ class InformationDisclosureTests(TestCase): result = execute_function(request, name, None) self.assertIsInstance(result, FunctionError) self.assertEqual(result.code, ErrorCode.NOT_FOUND) - # In production (DEBUG=False), error message is generic - # - doesn't reveal function name or UUID existence + # With DEBUG=False the message is identical for every name self.assertEqual(result.message, "Function not found") def test_validation_errors_dont_leak_internals(self): @@ -732,7 +682,7 @@ class InformationDisclosureTests(TestCase): request = self._make_request() result = execute_function(request, "validated_func", {"secret_field": 123}) - # Pydantic coerces to string, so let's try with wrong structure + # Pydantic coerces to string, so an unknown field is what fails here. result = execute_function(request, "validated_func", {"wrong_field": "test"}) self.assertIsInstance(result, FunctionError) @@ -747,13 +697,7 @@ class InformationDisclosureTests(TestCase): class InjectionPreventionTests(TestCase): - """ - Test injection attack prevention. - - Verifies that input validation prevents various injection attacks. - Note: These tests verify the framework's security, not actual injection - attempts - they just ensure malicious input is handled safely. - """ + """SQL-, shell-, template-, and JSON-shaped payloads through echo and key-count functions.""" def setUp(self): clear_registry() @@ -768,7 +712,6 @@ class InjectionPreventionTests(TestCase): @client def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput: - # This function just echoes - the test is about validation return SimpleOutput(value=user_input) register(echo_safe, "echo_safe") @@ -797,9 +740,7 @@ class InjectionPreventionTests(TestCase): for payload in sql_payloads: result = execute_function(request, "echo_safe", {"user_input": payload}) - # Should succeed - it's just a string, not executed as SQL self.assertIsInstance(result, FunctionResult) - # The payload is returned as-is (no SQL execution) self.assertEqual(result.data["value"], payload) def test_command_injection_in_string_field(self): @@ -816,7 +757,6 @@ class InjectionPreventionTests(TestCase): for payload in cmd_payloads: result = execute_function(request, "echo_safe", {"user_input": payload}) - # Should succeed - it's just a string self.assertIsInstance(result, FunctionResult) self.assertEqual(result.data["value"], payload) @@ -842,12 +782,11 @@ class InjectionPreventionTests(TestCase): """Test that special JSON values are handled safely.""" request = self._make_request() - # Various JSON edge cases test_cases = [ {"__proto__": {"polluted": True}}, {"constructor": {"prototype": {}}}, {"key": None}, - {"key": float("inf")}, # This will fail JSON serialization + {"key": float("inf")}, ] for data in test_cases: @@ -874,15 +813,10 @@ class InjectionPreventionTests(TestCase): class ChannelAuthorizationTests(TestCase): - """ - Test WebSocket channel authorization. - - Verifies that channel subscriptions properly check permissions. - """ + """Subscription outcomes when authorize() returns False, raises, or gets bad params.""" def setUp(self): clear_registry() - # Also clear the channels registry from mizan.channels import _registry as channels_registry channels_registry.clear() @@ -896,10 +830,10 @@ class ChannelAuthorizationTests(TestCase): def _register_test_channels(self): """Register test channels using the channels module's register.""" - from mizan.channels import register as register_channel, ReactChannel + from mizan.channels import register as register_channel - class PublicChannel(ReactChannel): - class DjangoMessage(BaseModel): + class PublicChannel(Channel): + class ServerMessage(BaseModel): text: str def authorize(self, params=None): @@ -908,8 +842,8 @@ class ChannelAuthorizationTests(TestCase): def group(self, params=None): return "public" - class AuthChannel(ReactChannel): - class DjangoMessage(BaseModel): + class AuthChannel(Channel): + class ServerMessage(BaseModel): text: str def authorize(self, params=None): @@ -918,15 +852,14 @@ class ChannelAuthorizationTests(TestCase): def group(self, params=None): return "auth" - class RoomChannel(ReactChannel): + class RoomChannel(Channel): class Params(BaseModel): room_id: int - class DjangoMessage(BaseModel): + class ServerMessage(BaseModel): text: str def authorize(self, params): - # Only allow access to room 1 and 2 return params.room_id in [1, 2] def group(self, params): @@ -938,12 +871,12 @@ class ChannelAuthorizationTests(TestCase): def test_authorize_exception_handling(self): """Test that exceptions in authorize() are handled safely.""" - from mizan.channels import register as register_channel, ReactChannel + from mizan.channels import register as register_channel from mizan.channels.connection import DjangoReactConsumer from asgiref.sync import async_to_sync - class ErrorChannel(ReactChannel): - class DjangoMessage(BaseModel): + class ErrorChannel(Channel): + class ServerMessage(BaseModel): text: str def authorize(self, params=None): @@ -987,7 +920,6 @@ class ChannelAuthorizationTests(TestCase): {"channel": "auth-channel", "params": {}} ) - # Should be rejected self.assertIn("error", sent_messages[0]) self.assertIn("Not authorized", sent_messages[0]["error"]) @@ -1009,7 +941,6 @@ class ChannelAuthorizationTests(TestCase): {"channel": "room-channel", "params": {"room_id": "not_an_int"}} ) - # Should fail validation self.assertIn("error", sent_messages[0]) def test_room_authorization_enforced(self): @@ -1045,13 +976,7 @@ class ChannelAuthorizationTests(TestCase): class AbusePreventionTests(TestCase): - """ - Test abuse prevention capabilities. - - Note: The current implementation doesn't have built-in rate limiting, - so these tests document the expected behavior and identify areas - where rate limiting should be added. - """ + """Repeated and batched execute_function calls.""" def setUp(self): clear_registry() @@ -1079,13 +1004,11 @@ class AbusePreventionTests(TestCase): """Test that rapid function calls don't cause issues.""" request = self._make_request() - # Make 100 rapid calls results = [] for _ in range(100): result = execute_function(request, "simple_func", None) results.append(result) - # All should succeed (no rate limiting currently) and return expected data for result in results: self.assertIsInstance(result, FunctionResult) self.assertEqual(result.data["value"], "ok") diff --git a/backends/mizan-django/src/mizan/tests/test_ssr.py b/backends/mizan-django/src/mizan/tests/test_ssr.py index d6bf449..2c58d88 100644 --- a/backends/mizan-django/src/mizan/tests/test_ssr.py +++ b/backends/mizan-django/src/mizan/tests/test_ssr.py @@ -1,8 +1,8 @@ """ -Tests for the Mizan SSR bridge and template backend. +Tests for the SSR bridge and the MizanTemplates Django template backend. -Requires Bun installed and the test worker at packages/mizan-ssr/src/test-worker.tsx. -Tests skip gracefully if Bun is not available. +The bridge shells out to Bun, so every test here skips unless `bun` is on PATH +and the worker script is present. """ import os @@ -11,13 +11,12 @@ import threading from django.test import SimpleTestCase, RequestFactory -# Path to the test worker -_SSR_WORKER = os.path.join( - os.path.dirname(__file__), - "..", "..", "..", "..", "..", # up to repo root - "packages", "mizan-ssr", "src", "test-worker.tsx", +_REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..") ) -_SSR_WORKER = os.path.normpath(_SSR_WORKER) +_SSR_WORKER = os.path.join(_REPO_ROOT, "workers", "mizan-ssr", "src", "worker.tsx") +_COMPONENT_DIR = os.path.join(os.path.dirname(__file__), "ssr_components") +_HELLO = os.path.join(_COMPONENT_DIR, "Hello.tsx") _BUN_AVAILABLE = shutil.which("bun") is not None _SKIP_MSG = "Bun not available" @@ -30,66 +29,52 @@ class SSRBridgeTests(SimpleTestCase): if not _BUN_AVAILABLE: self.skipTest(_SKIP_MSG) if not os.path.exists(_SSR_WORKER): - self.skipTest(f"Test worker not found at {_SSR_WORKER}") + self.skipTest(f"SSR worker not found at {_SSR_WORKER}") from mizan.ssr.bridge import SSRBridge - self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=5.0) + self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=10.0) def tearDown(self): if hasattr(self, "bridge"): self.bridge.shutdown() - def test_ping(self): - """Worker starts and responds to ping.""" - self.assertTrue(self.bridge.ping()) - - def test_render_simple(self): - """Renders a simple component to HTML.""" - result = self.bridge.render("Hello", {"name": "World"}) + def test_render_starts_worker_and_returns_html(self): + """The first render boots the worker and returns rendered markup.""" + result = self.bridge.render(_HELLO, {"name": "World"}) self.assertIn("Hello,", result.html) self.assertIn("World", result.html) - def test_render_with_props(self): - """Renders a component with multiple props.""" - result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"}) + def test_render_passes_props_through(self): + """Props reach the component.""" + result = self.bridge.render(_HELLO, {"name": "Alice"}) self.assertIn("Alice", result.html) - self.assertIn("42", result.html) + self.assertIn('data-mizan-component="Hello"', result.html) - def test_render_missing_component(self): - """Rendering an unregistered component raises RuntimeError.""" - with self.assertRaises(RuntimeError) as ctx: - self.bridge.render("NonExistent", {}) - self.assertIn("not registered", str(ctx.exception)) - - def test_render_error(self): - """Component that throws during render raises RuntimeError.""" - with self.assertRaises(RuntimeError) as ctx: - self.bridge.render("Broken", {}) - self.assertIn("Render error", str(ctx.exception)) + def test_render_missing_file_raises(self): + """Rendering a path with no module raises RuntimeError naming the failure.""" + missing = os.path.join(_COMPONENT_DIR, "DoesNotExist.tsx") + with self.assertRaises(RuntimeError): + self.bridge.render(missing, {}) def test_crash_recovery(self): - """Bridge restarts the worker if it dies.""" - # First render works - result = self.bridge.render("Hello", {"name": "Before"}) + """The bridge restarts the worker if it dies.""" + result = self.bridge.render(_HELLO, {"name": "Before"}) self.assertIn("Before", result.html) - # Kill the subprocess self.bridge._proc.kill() self.bridge._proc.wait() - # Next render should restart and work - result = self.bridge.render("Hello", {"name": "After"}) + result = self.bridge.render(_HELLO, {"name": "After"}) self.assertIn("After", result.html) def test_concurrent_renders(self): - """Multiple threads can render simultaneously.""" + """Concurrent callers each get their own response matched by message id.""" results = {} errors = {} def render_in_thread(name: str, idx: int): try: - result = self.bridge.render("Hello", {"name": name}) - results[idx] = result.html + results[idx] = self.bridge.render(_HELLO, {"name": name}).html except Exception as e: errors[idx] = e @@ -100,9 +85,9 @@ class SSRBridgeTests(SimpleTestCase): t.start() for t in threads: - t.join(timeout=10) + t.join(timeout=20) - self.assertEqual(len(errors), 0, f"Errors in concurrent renders: {errors}") + self.assertEqual(errors, {}) self.assertEqual(len(results), 5) for i in range(5): self.assertIn(f"User{i}", results[i]) @@ -115,16 +100,16 @@ class SSRTemplateBackendTests(SimpleTestCase): if not _BUN_AVAILABLE: self.skipTest(_SKIP_MSG) if not os.path.exists(_SSR_WORKER): - self.skipTest(f"Test worker not found at {_SSR_WORKER}") + self.skipTest(f"SSR worker not found at {_SSR_WORKER}") from mizan.ssr.backend import MizanTemplates self.engine = MizanTemplates({ "NAME": "mizan-test", - "DIRS": [], + "DIRS": [_COMPONENT_DIR], "APP_DIRS": False, "OPTIONS": { - "worker_path": _SSR_WORKER, - "timeout": 5, + "worker": _SSR_WORKER, + "timeout": 10, }, }) self.factory = RequestFactory() @@ -133,30 +118,40 @@ class SSRTemplateBackendTests(SimpleTestCase): if hasattr(self, "engine") and self.engine._bridge is not None: self.engine._bridge.shutdown() - def test_get_template(self): - """get_template returns a MizanTemplate.""" + def test_get_template_resolves_name_to_file(self): + """get_template resolves the name against DIRS to an absolute file path.""" from mizan.ssr.backend import MizanTemplate - template = self.engine.get_template("Hello") + template = self.engine.get_template("Hello.tsx") self.assertIsInstance(template, MizanTemplate) - self.assertEqual(template.component_name, "Hello") + self.assertEqual(template.file_path, os.path.abspath(_HELLO)) - def test_template_render(self): - """MizanTemplate.render() produces HTML.""" - template = self.engine.get_template("Hello") + def test_missing_template_raises(self): + """A name that resolves to no file under DIRS raises TemplateDoesNotExist.""" + from django.template import TemplateDoesNotExist + with self.assertRaises(TemplateDoesNotExist): + self.engine.get_template("NoSuchComponent.tsx") + + def test_template_render_emits_html_and_hydration_data(self): + """render() wraps the markup and serializes the props for hydration.""" + template = self.engine.get_template("Hello.tsx") html = template.render({"name": "Django"}) self.assertIn("Hello,", html) self.assertIn("Django", html) - self.assertIn('data-mizan-component="Hello"', html) + self.assertIn('id="mizan-root"', html) + self.assertIn('window.__MIZAN_SSR_DATA__={"name": "Django"}', html) def test_template_render_strips_django_internals(self): - """Django-internal context keys (request, csrf_token) are not passed as props.""" - template = self.engine.get_template("Hello") + """request and csrf_token are dropped from props and from hydration data.""" + template = self.engine.get_template("Hello.tsx") request = self.factory.get("/") - html = template.render({"name": "Test", "request": request, "csrf_token": "abc"}, request) + html = template.render( + {"name": "Test", "request": request, "csrf_token": "abc"}, request + ) self.assertIn("Test", html) + self.assertNotIn("csrf_token", html) + self.assertNotIn("abc", html) - def test_from_string_raises(self): - """from_string is not supported.""" - from django.template import TemplateDoesNotExist - with self.assertRaises(TemplateDoesNotExist): + def test_from_string_is_unsupported(self): + """This engine renders modules by path, so it has no source-string form.""" + with self.assertRaises(NotImplementedError): self.engine.from_string("
Not supported
") diff --git a/backends/mizan-django/src/mizan/urls.py b/backends/mizan-django/src/mizan/urls.py index d2317a3..b20a176 100644 --- a/backends/mizan-django/src/mizan/urls.py +++ b/backends/mizan-django/src/mizan/urls.py @@ -1,14 +1,7 @@ """ -mizan URL Configuration - -HTTP endpoints: -- GET /session/ - Initialize session and get CSRF token (for SSR) -- POST /call/ - Server function calls (HTTP transport) -- GET /ctx// - Bundled context fetch (all functions in a named context) - -Security: -- Schema export is NOT exposed over HTTP to prevent API enumeration -- Use the management command instead: python manage.py export_mizan_ir +mizan's HTTP endpoints: session bootstrap, the server-function call endpoint, +and the bundled per-context fetch. Schema export is reachable only through the +`export_mizan_ir` management command, never over HTTP. """ from django.http import JsonResponse @@ -16,7 +9,7 @@ from django.middleware.csrf import get_token from django.urls import path from django.views.decorators.csrf import ensure_csrf_cookie -from .client.executor import function_call_view, context_fetch_view +from mizan.client.executor import function_call_view, context_fetch_view app_name = "mizan" @@ -24,13 +17,8 @@ app_name = "mizan" @ensure_csrf_cookie def session_init_view(request): """ - Initialize a Django session and return the CSRF token. - - Used by SSR to establish a session before making authenticated requests. - The @ensure_csrf_cookie decorator ensures the csrftoken cookie is set. - - Returns: - { "csrfToken": "..." } + Start a Django session and return `{"csrfToken": ...}`. The decorator is + what puts the csrftoken cookie on the response. """ return JsonResponse({"csrfToken": get_token(request)}) diff --git a/backends/mizan-django/tests/models.py b/backends/mizan-django/tests/models.py index 239f346..5e99548 100644 --- a/backends/mizan-django/tests/models.py +++ b/backends/mizan-django/tests/models.py @@ -1,3 +1,5 @@ +import uuid + from django.contrib.auth.models import ( AbstractBaseUser, BaseUserManager, @@ -7,8 +9,6 @@ from django.db import models class EmailUserManager(BaseUserManager): - """Custom user manager using email as the unique identifier.""" - def create_user(self, email, password=None, **extra_fields): if not email: raise ValueError("Email is required") @@ -25,12 +25,6 @@ class EmailUserManager(BaseUserManager): class EmailUser(AbstractBaseUser, PermissionsMixin): - """Minimal user model with email as USERNAME_FIELD. - - Matches the calling convention used in mizan's test suite: - User.objects.create_user(email="...", password="...", is_staff=True) - """ - email = models.EmailField(unique=True) is_staff = models.BooleanField(default=False) is_active = models.BooleanField(default=True) @@ -44,11 +38,6 @@ class EmailUser(AbstractBaseUser, PermissionsMixin): app_label = "tests" -# ─── Shape test models ────────────────────────────────────────────────────── - -import uuid - - class TimestampMixin(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/backends/mizan-django/tests/settings.py b/backends/mizan-django/tests/settings.py index 6c81419..4583a25 100644 --- a/backends/mizan-django/tests/settings.py +++ b/backends/mizan-django/tests/settings.py @@ -1,12 +1,3 @@ -""" -Django settings for running mizan's test suite standalone. - -Usage: - cd django/ - pip install -e ".[dev]" - pytest -""" - SECRET_KEY = "test-secret-key-for-standalone-tests-only" DEBUG = True @@ -32,11 +23,9 @@ ROOT_URLCONF = "tests.urls" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -# JWT settings for test_auth.py (can be overridden per-class with @override_settings) JWT_PRIVATE_KEY = "test-secret-key-for-testing-only" JWT_ALGORITHM = "HS256" -# Session engine (for test_auth.py SessionStore usage) SESSION_ENGINE = "django.contrib.sessions.backends.db" MIDDLEWARE = [ diff --git a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py index ef98217..845300c 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py @@ -1,27 +1,12 @@ """ mizan-fastapi — FastAPI backend adapter for the Mizan protocol. -HTTP RPC dispatch, context bundling, and a WebSocket carrying channel -subscriptions and RPC on mizan-django's envelope. Forms, Shapes and SSR are -out of scope — FastAPI projects use native equivalents. - -Usage: - from fastapi import FastAPI - from mizan_fastapi import router, mizan_exception_handler, MizanError - - app = FastAPI() - app.include_router(router, prefix="/api/mizan") - app.include_router(ws_router, prefix="/api/mizan") - app.add_exception_handler(MizanError, mizan_exception_handler) - - # Register your @client-decorated functions - from mizan_core.client.function import client - from mizan_core.registry import register - from .my_functions import echo - register(echo, "echo") +Re-exports the adapter's surface: two routers (HTTP dispatch and the WebSocket), +the error hierarchy with its exception handlers, and the channel base class with +its registry. """ -from .executor import ( +from mizan_fastapi.executor import ( ErrorCode, MizanError, NotFound, @@ -34,14 +19,19 @@ from .executor import ( compute_invalidation, execute_function, ) -from .router import router, mizan_exception_handler, mizan_validation_handler -from .websocket import ws_router -from .channels import ReactChannel, broadcast, get_channel, register as register_channel +from mizan_fastapi.router import router, mizan_exception_handler, mizan_validation_handler +from mizan_fastapi.websocket import ws_router +from mizan_fastapi.channels import ( + Channel, + broadcast, + get_channel, + register as register_channel, +) __all__ = [ "router", "ws_router", - "ReactChannel", + "Channel", "register_channel", "get_channel", "broadcast", diff --git a/backends/mizan-fastapi/src/mizan_fastapi/channels.py b/backends/mizan-fastapi/src/mizan_fastapi/channels.py index 797b5dd..b5e2c52 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/channels.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/channels.py @@ -5,10 +5,8 @@ A channel names a group of subscribers and decides who may join it. `group(param is the fan-out key, so two subscribers with the same params share a group and a push addressed to those params reaches both. -Membership is held in this process. Django's channel layer carries groups across -workers through Redis; nothing here does, so a push reaches only the subscribers -connected to the process that sent it. One process — a desktop shell, a single -uvicorn worker — is the shape this serves. +Membership is held in this process. A push therefore reaches only the subscribers +whose socket is attached to the process that sent it. """ from __future__ import annotations @@ -20,20 +18,27 @@ from typing import Any, ClassVar from fastapi.encoders import jsonable_encoder from pydantic import BaseModel +from mizan_core.registry import RegistryExtension, register_extension + # group name -> the live sockets subscribed to it _groups: dict[str, set[Any]] = defaultdict(set) -_registry: dict[str, type["ReactChannel"]] = {} +_registry: dict[str, type["Channel"]] = {} _lock = asyncio.Lock() -class ReactChannel: +class Channel: """A named fan-out. Subclass, override what the channel decides, and register it. - A subclass may define `on_connect(params)` / `on_disconnect()`; the socket handler - calls them when they exist. + The three nested models are the channel's payload types, named from the client's + side: `Params` keys the fan-out, `ClientMessage` travels up, `ServerMessage` + travels down. A subclass may also define `on_connect(params)` / `on_disconnect()`; + the socket handler calls them when they exist. """ name: ClassVar[str] = "" + Params: ClassVar[type[BaseModel] | None] = None + ClientMessage: ClassVar[type[BaseModel] | None] = None + ServerMessage: ClassVar[type[BaseModel] | None] = None def authorize(self, params: BaseModel | None = None) -> bool: """Whether this subscriber may join. Default: anyone may.""" @@ -68,16 +73,16 @@ class _Params: return self._values -def register(channel_class: type[ReactChannel], name: str) -> None: +def register(channel_class: type[Channel], name: str) -> None: channel_class.name = name _registry[name] = channel_class -def get_channel(name: str) -> type[ReactChannel] | None: +def get_channel(name: str) -> type[Channel] | None: return _registry.get(name) -def registered() -> dict[str, type[ReactChannel]]: +def registered() -> dict[str, type[Channel]]: return dict(_registry) @@ -122,3 +127,36 @@ async def broadcast( except Exception as e: print(f"mizan.channels: dropping subscriber from {group}: {type(e).__name__}: {e}") await leave(group, socket) + + +class _ChannelsExtension(RegistryExtension): + """The `channels` slot of the core registry — one entry per registered channel, + each carrying the JSON schema of whichever payload models the channel declares. + """ + + def all(self) -> dict[str, type[Channel]]: + return dict(_registry) + + def schema(self) -> dict[str, Any]: + out: dict[str, Any] = {} + for name, channel_class in _registry.items(): + entry: dict[str, Any] = { + "name": name, + "type": "channel", + "bidirectional": False, + } + if channel_class.Params is not None: + entry["params"] = channel_class.Params.model_json_schema() + if channel_class.ClientMessage is not None: + entry["client_message"] = channel_class.ClientMessage.model_json_schema() + entry["bidirectional"] = True + if channel_class.ServerMessage is not None: + entry["server_message"] = channel_class.ServerMessage.model_json_schema() + out[name] = entry + return out + + def clear(self) -> None: + _registry.clear() + + +register_extension("channels", _ChannelsExtension()) diff --git a/backends/mizan-fastapi/src/mizan_fastapi/ir.py b/backends/mizan-fastapi/src/mizan_fastapi/ir.py index cec73e8..2bda871 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/ir.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/ir.py @@ -6,8 +6,7 @@ Usage: Imports the named module (whose import side effects must register every @client function with `mizan_core.registry`), then writes the canonical -Mizan IR as KDL to stdout. The Rust codegen binary consumes this -directly. +Mizan IR as KDL to stdout. """ from __future__ import annotations diff --git a/backends/mizan-fastapi/src/mizan_fastapi/router.py b/backends/mizan-fastapi/src/mizan_fastapi/router.py index de8c828..3ac37df 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/router.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/router.py @@ -1,15 +1,9 @@ """ FastAPI router exposing Mizan's HTTP endpoints: + GET /session/ — session-init probe POST /call/ — RPC dispatch GET /ctx/{context_name}/ — bundled context fetch - - from fastapi import FastAPI - from mizan_fastapi import router, mizan_exception_handler, MizanError - - app = FastAPI() - app.include_router(router, prefix="/api/mizan") - app.add_exception_handler(MizanError, mizan_exception_handler) """ from __future__ import annotations @@ -23,7 +17,7 @@ from pydantic import BaseModel, Field from mizan_core.registry import get_context_groups, get_function -from .executor import ( +from mizan_fastapi.executor import ( ErrorCode, MizanError, NotFound, @@ -45,12 +39,7 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse: @router.get("/session/") async def session_init() -> JSONResponse: - """Session-init probe. Parity with mizan-django's session endpoint. - - CSRF is a Django-only concern at the protocol level; FastAPI surfaces a - null token so the response shape stays uniform across backends. The - wire-parity harness uses this endpoint as its readiness probe. - """ + """Session-init probe. The CSRF slot is null — nothing on this backend issues a token.""" return _no_store({"csrfToken": None}) diff --git a/backends/mizan-fastapi/src/mizan_fastapi/websocket.py b/backends/mizan-fastapi/src/mizan_fastapi/websocket.py index 3ff0f8b..c7a0a14 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/websocket.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/websocket.py @@ -14,13 +14,8 @@ The WebSocket endpoint — channel subscriptions and RPC over one connection. {"id": "request-id", "ok": false, "error": {"code": "...", "message": "..."}} {"error": "..."} -The envelope is mizan-django's, so a client speaks to either backend unchanged. RPC -dispatches through the same `execute_function` the HTTP route calls — one dispatch path, -so the two transports cannot disagree about what a function does. - -`data` is the `{result, invalidate, merge}` envelope every non-HTTP transport hands the -kernel, so `mizanCall` applies a socket mutation's invalidation exactly as it applies an -HTTP one. mizan-django's socket sends a bare result and drops it. +An `rpc` reply's `data` is the `{result, invalidate, merge}` envelope, the same one the +HTTP route builds, and both are produced by `execute_function`. """ from __future__ import annotations @@ -47,9 +42,9 @@ ws_router = APIRouter() class _SocketRequest: """What a server function receives when the call arrived over the socket. - The HTTP path hands functions a Starlette `Request`; there is none here, so this - carries the surface a function actually reads — `state`, headers, and a method, since - a socket RPC sends data and expects an answer. + There is no Starlette `Request` on a socket, so this carries the surface a function + actually reads — `state`, headers, and a method, since a socket RPC sends data and + expects an answer. """ method = "POST" @@ -64,7 +59,7 @@ class _SocketRequest: def _params_model(channel_cls: Any, raw: dict[str, Any] | None) -> Any: """Params as the channel's declared model, or a bare holder when it declares none.""" - model = getattr(channel_cls, "Params", None) + model = channel_cls.Params if model is not None and raw: return model(**raw) return channels._Params(raw) if raw else None @@ -203,10 +198,10 @@ _ACTIONS = { @ws_router.websocket("/ws/") async def mizan_socket(socket: WebSocket) -> None: - """One connection, every action. A disconnect ends the loop and clears the membership. + """One connection, every action. - A close is how a socket ends, so the disconnect is reported as the departure it is - rather than raised as a fault; `finally` clears the membership either way. + A close is how a socket ends, so the disconnect is logged rather than raised; + `finally` clears the membership either way. """ await socket.accept() try: diff --git a/backends/mizan-fastapi/tests/test_dispatch.py b/backends/mizan-fastapi/tests/test_dispatch.py index 7e4ca0c..dcd958a 100644 --- a/backends/mizan-fastapi/tests/test_dispatch.py +++ b/backends/mizan-fastapi/tests/test_dispatch.py @@ -88,7 +88,7 @@ def app(): @client async def async_echo(request, text: str) -> EchoOutput: - # await something on the loop to prove we're really running async + # Yielding to the loop fails outright if the handler is not awaited. await asyncio.sleep(0) return EchoOutput(message=f"async: {text}") @@ -183,18 +183,19 @@ class ContextFetchTests: assert r.json()["error"]["code"] == "NOT_FOUND" -# ─── Invalidation ─────────────────────────────────────────────────────────── +# ─── Auth gating ──────────────────────────────────────────────────────────── class AuthTests: - """The decorator normalizes auth=True → meta['auth']='required'; executor must match both.""" - def test_anonymous_request_to_auth_required_returns_401(self, http): r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}}) assert r.status_code == 401 assert r.json()["error"]["code"] == "UNAUTHORIZED" +# ─── Invalidation ─────────────────────────────────────────────────────────── + + class InvalidationTests: def test_mutation_emits_invalidate_list(self, http): r = http.post( @@ -211,8 +212,6 @@ class InvalidationTests: class StructuredOutputTests: - """list[BaseModel] and Optional[BaseModel] should reach the wire as bare values, not {result: ...}.""" - def test_list_of_basemodel_returns_bare_array(self, http): r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}}) assert r.status_code == 200 @@ -232,21 +231,20 @@ class StructuredOutputTests: assert r_missing.json()["result"] is None -# ─── Merge protocol ───────────────────────────────────────────────────────── +# ─── Async handlers ───────────────────────────────────────────────────────── class AsyncHandlerTests: - """`async def` handlers dispatch on the loop via view.acall.""" - def test_async_handler_returns_awaited_result(self, http): r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}}) assert r.status_code == 200 assert r.json()["result"] == {"message": "async: hello"} -class MergeTests: - """@client(merge=...) emits a `merge` field in the response so the kernel can splice without refetch.""" +# ─── Merge protocol ───────────────────────────────────────────────────────── + +class MergeTests: def test_merge_target_emits_merge_entry(self, http): r = http.post( "/api/mizan/call/", @@ -254,9 +252,8 @@ class MergeTests: ) assert r.status_code == 200 body = r.json() - # Server resolves slot — items_list returns list[ItemOutput], mutation returns ItemOutput + # items_list returns list[ItemOutput], so the slot resolves to items_list. assert body["merge"] == [ {"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}} ] - # invalidate stays empty when only merge is declared assert body["invalidate"] == [] diff --git a/backends/mizan-rust-axum/Cargo.lock b/backends/mizan-rust-axum/Cargo.lock index 59bfc10..06fc319 100644 --- a/backends/mizan-rust-axum/Cargo.lock +++ b/backends/mizan-rust-axum/Cargo.lock @@ -264,12 +264,28 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minijinja" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "mio" version = "1.2.0" @@ -300,6 +316,7 @@ version = "0.1.0" dependencies = [ "async-trait", "linkme", + "minijinja", "mizan-macros", "serde", "serde_json", diff --git a/backends/mizan-rust-axum/src/errors.rs b/backends/mizan-rust-axum/src/errors.rs index 1aa71e1..97e00cc 100644 --- a/backends/mizan-rust-axum/src/errors.rs +++ b/backends/mizan-rust-axum/src/errors.rs @@ -1,6 +1,6 @@ -//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's -//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}` -//! with a Cache-Control: no-store header. +//! Render a `MizanError` as an axum `Response`: the JSON envelope +//! `{"error": {"code": ..., "message": ..., "details": ...}}` under a +//! `Cache-Control: no-store` header. use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; @@ -15,11 +15,24 @@ impl From for ApiError { } } +/// Each variant's status spelled as an axum constant. Naming the constant +/// rather than round-tripping a `u16` leaves no numeric value axum could +/// reject, so the mapping is total. +fn status_of(err: &MizanError) -> StatusCode { + match err { + MizanError::NotFound(_) => StatusCode::NOT_FOUND, + MizanError::BadRequest(_) => StatusCode::BAD_REQUEST, + MizanError::ValidationFailed { .. } => StatusCode::UNPROCESSABLE_ENTITY, + MizanError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + MizanError::Forbidden(_) => StatusCode::FORBIDDEN, + MizanError::NotImplementedYet(_) => StatusCode::NOT_IMPLEMENTED, + MizanError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, + } +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { - let status = StatusCode::from_u16(self.0.http_status()) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - let mut resp = (status, Json(self.0.to_json())).into_response(); + let mut resp = (status_of(&self.0), Json(self.0.to_json())).into_response(); resp.headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); resp diff --git a/backends/mizan-rust-axum/src/handlers.rs b/backends/mizan-rust-axum/src/handlers.rs index c1cbe19..d581611 100644 --- a/backends/mizan-rust-axum/src/handlers.rs +++ b/backends/mizan-rust-axum/src/handlers.rs @@ -1,15 +1,15 @@ -//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`. +//! HTTP handlers for the Mizan endpoints. use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use mizan_core::{ - compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec, - InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, + compute_invalidation, compute_merges, context_members, function_named, FunctionSpec, + InvalidationTarget, MergeEntry, MizanError, Primitive, RequestHandle, }; use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; +use serde_json::{Map, Number, Value}; use std::any::Any; use std::collections::BTreeMap; use std::sync::Arc; @@ -21,24 +21,16 @@ use crate::errors::ApiError; /// `Arc` keeps the clone cheap across per-request handler invocations. pub type AppStateAny = Arc; -/// Body for POST /call/. Matches the Python `CallBody` shape. +/// Body for POST /call/. #[derive(Debug, Deserialize)] pub struct CallBody { - pub fn_: Option, + /// `fn` is a Rust keyword, hence the serde rename. #[serde(rename = "fn")] - pub function_name: Option, + pub function_name: String, #[serde(default)] pub args: Map, } -impl CallBody { - fn resolved_name(&self) -> Option<&str> { - self.function_name - .as_deref() - .or(self.fn_.as_deref()) - } -} - #[derive(Debug, Serialize)] pub struct CallResponse { pub result: Value, @@ -47,28 +39,37 @@ pub struct CallResponse { pub merge: Option>, } -fn no_store(json: Value) -> Response { - let mut resp = (StatusCode::OK, Json(json)).into_response(); +fn no_store(body: T) -> Response { + let mut resp = (StatusCode::OK, Json(body)).into_response(); resp.headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); resp } -/// POST /call/ — RPC dispatch. +/// POST /call/ — RPC dispatch. The caller picks the `fn` string, so the +/// handler selects the registrations that string names and matches over the +/// two shapes that selection has; `[]` is the selection a string nothing +/// registered under makes, and it is answered with the NOT_FOUND envelope. pub async fn function_call( State(app_state): State, Json(body): Json, ) -> Result { - let fn_name = body - .resolved_name() - .ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))? - .to_string(); - - let fn_spec = lookup_function(&fn_name) - .ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?; + let registered = function_named(&body.function_name); + let fn_spec = match registered.as_slice() { + [] => { + return Err(ApiError(MizanError::NotFound(format!( + "function {:?} not registered", + body.function_name + )))) + } + [fn_spec, ..] => *fn_spec, + }; let req = RequestHandle::from_dyn(app_state.as_ref()); - let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?; + let result = match fn_spec.dispatch(req, Value::Object(body.args.clone())).await { + Ok(result) => result, + Err(e) => return Err(ApiError(e)), + }; let invalidate: Vec = compute_invalidation(fn_spec, &body.args) .iter() @@ -81,82 +82,86 @@ pub async fn function_call( Some(merges.iter().map(MergeEntry::to_json).collect()) }; - let payload = CallResponse { + Ok(no_store(CallResponse { result, invalidate, merge: merge_payload, - }; - Ok(no_store(serde_json::to_value(&payload).unwrap())) + })) } -/// GET /ctx/:context_name/ — bundled context fetch. +/// GET /ctx/:context_name/ — bundled context fetch. The caller picks the +/// path segment, so `[]` is the selection a segment no registered function +/// declares membership in makes, answered with the NOT_FOUND envelope. pub async fn context_fetch( State(app_state): State, Path(context_name): Path, Query(params): Query>, ) -> Result { - if lookup_context(&context_name).is_none() { - return Err(ApiError(MizanError::NotFound(format!( - "context {context_name:?} not registered" - )))); - } + let members = context_members(&context_name); + let selected = match members.as_slice() { + [] => { + return Err(ApiError(MizanError::NotFound(format!( + "context {context_name:?} names no registered functions" + )))) + } + selected => selected, + }; - let members: Vec<&dyn FunctionSpec> = FUNCTIONS - .iter() - .copied() - .filter(|f| f.context() == Some(&context_name)) - .collect(); - if members.is_empty() { - return Err(ApiError(MizanError::NotFound(format!( - "context {context_name:?} has no registered members" - )))); - } - - // Convert query params (all-string values) to the JSON arg map. Numeric - // params get parsed via the per-function input_params primitive table. let mut bundled = Map::new(); - for fn_spec in &members { + for fn_spec in selected { let args = coerce_query_args(*fn_spec, ¶ms); let req = RequestHandle::from_dyn(app_state.as_ref()); - let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?; - bundled.insert(fn_spec.name().to_string(), result); + match fn_spec.dispatch(req, Value::Object(args)).await { + Ok(result) => { + bundled.insert(fn_spec.name().to_string(), result); + } + Err(e) => return Err(ApiError(e)), + } } Ok(no_store(Value::Object(bundled))) } -/// Coerce string-valued query params into typed JSON values using the -/// function's declared input_params. Strings that don't parse stay as -/// strings — the dispatch wrapper will raise ValidationFailed downstream. +/// A query string carries every value as text, so each declared input param +/// reads its raw text as the primitive it declares. Text spelling something +/// else stays the text it already is: `dispatch` validates every arg against +/// the declared shape and is the one step that words the VALIDATION_FAILED +/// answer, so re-wording it here would give one request two spellings of the +/// same complaint. fn coerce_query_args( fn_spec: &dyn FunctionSpec, params: &BTreeMap, ) -> Map { let mut out = Map::new(); for ip in fn_spec.input_params() { - if let Some(raw) = params.get(ip.name) { - let parsed = match ip.primitive { - mizan_core::Primitive::Integer => raw.parse::().ok().map(Value::from), - mizan_core::Primitive::Number => raw.parse::().ok().and_then(|v| { - serde_json::Number::from_f64(v).map(Value::Number) - }), - mizan_core::Primitive::Boolean => raw.parse::().ok().map(Value::from), - mizan_core::Primitive::String => Some(Value::from(raw.clone())), + for (_, raw) in params.iter().filter(|(name, _)| name.as_str() == ip.name) { + let as_text = Value::from(raw.clone()); + let coerced = match ip.primitive { + Primitive::String => as_text, + Primitive::Boolean => match raw.as_str() { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + _spells_neither => as_text, + }, + Primitive::Integer => match raw.parse::() { + Ok(integer) => Value::from(integer), + Err(_spells_no_integer) => as_text, + }, + Primitive::Number => match raw.parse::() { + Ok(float) => match Number::from_f64(float) { + Some(number) => Value::Number(number), + None => as_text, + }, + Err(_spells_no_number) => as_text, + }, }; - if let Some(v) = parsed { - out.insert(ip.name.into(), v); - } else { - out.insert(ip.name.into(), Value::from(raw.clone())); - } + out.insert(ip.name.into(), coerced); } } out } -/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint. -/// CSRF is a Django-only concern; the Rust adapter returns a null token so -/// readiness-probe consumers see a well-formed response. +/// GET /session/ — emits `{"csrfToken": null}`. pub async fn session_init() -> Response { - let body = serde_json::json!({ "csrfToken": null }); - no_store(body) + no_store(serde_json::json!({ "csrfToken": null })) } diff --git a/backends/mizan-rust-axum/src/lib.rs b/backends/mizan-rust-axum/src/lib.rs index df370fd..d46ea21 100644 --- a/backends/mizan-rust-axum/src/lib.rs +++ b/backends/mizan-rust-axum/src/lib.rs @@ -13,8 +13,8 @@ //! } //! ``` //! -//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`): -//! * `GET /session/` — session-init probe (placeholder CSRF token) +//! Exposed endpoints: +//! * `GET /session/` — session-init probe //! * `POST /call/` — RPC dispatch with invalidate+merge response //! * `GET /ctx/:name/` — bundled context fetch @@ -51,8 +51,7 @@ where } /// Router variant for callers that have no app state to thread — the -/// dispatch path receives a unit-typed handle. Used by the AFI fixture -/// and other stateless test apps. +/// dispatch path receives a unit-typed handle. pub fn router_stateless() -> Router { router(()) } diff --git a/backends/mizan-tauri/Cargo.lock b/backends/mizan-tauri/Cargo.lock index 084a2f8..6370e96 100644 --- a/backends/mizan-tauri/Cargo.lock +++ b/backends/mizan-tauri/Cargo.lock @@ -1747,6 +1747,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "memoffset" version = "0.9.1" @@ -1762,6 +1768,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minijinja" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1789,6 +1805,7 @@ version = "0.1.0" dependencies = [ "async-trait", "linkme", + "minijinja", "mizan-macros", "serde", "serde_json", diff --git a/backends/mizan-tauri/src/lib.rs b/backends/mizan-tauri/src/lib.rs index e56c0f2..68ff9fe 100644 --- a/backends/mizan-tauri/src/lib.rs +++ b/backends/mizan-tauri/src/lib.rs @@ -1,21 +1,17 @@ //! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC. //! -//! Ships as a Tauri plugin. The consumer installs it with one line: +//! Ships as a Tauri plugin: //! //! ```ignore //! tauri::Builder::default() //! .plugin(mizan_tauri::init()) //! .run(tauri::generate_context!()) -//! .expect("error while running tauri application"); //! ``` //! //! The plugin exposes a single command `mizan_invoke` (full Tauri name -//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport` -//! sends call/fetch envelopes to it; the dispatch routes through -//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same -//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum) -//! consumes. There is no per-function tauri::command; the registry IS -//! the dispatch table. +//! `plugin:mizan|mizan_invoke`), which routes through `mizan-core`'s +//! FUNCTIONS / CONTEXTS registries. There is no per-function +//! `tauri::command`; the registry IS the dispatch table. //! //! Wire envelope: //! @@ -24,23 +20,20 @@ //! { "op": "fetch", "context": "session", "params": {} } //! ``` //! -//! Response shapes mirror POST /call/ and GET /ctx/.../ from -//! mizan-rust-axum: +//! Response shapes: //! //! * `call` → `{ result, invalidate, merge? }` //! * `fetch` → `{ : , ... }` (a flat bundle) //! -//! Error responses come back as the `Err` variant of the Tauri command's -//! `Result`, which Tauri serializes into the JS-side `Promise.reject`. -//! The TS-side transport re-wraps it into a `MizanError` so consumers -//! see one error surface regardless of transport. +//! Errors come back as the `Err` variant of the command's `Result`, which +//! Tauri serializes into the JS-side `Promise.reject`. use mizan_core::{ - compute_invalidation, compute_merges, lookup_context, lookup_function, - FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, + compute_invalidation, compute_merges, context_members, function_named, FunctionSpec, + InvalidationTarget, MergeEntry, MizanError, RequestHandle, }; use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; +use serde_json::{Map, Value}; use tauri::{ plugin::{Builder, TauriPlugin}, Runtime, @@ -79,9 +72,8 @@ pub enum Envelope { }, } -/// Error payload returned to the frontend. Mirrors the HTTP adapter's -/// `{"code", "message", "details?"}` shape; the TS-side transport reads -/// this and constructs a `MizanError`. +/// Error payload returned to the frontend. The JS-side transport reads +/// `code` / `message` / `details` and constructs a `MizanError`. #[derive(Debug, Serialize)] pub struct ErrorPayload { pub code: &'static str, @@ -114,6 +106,11 @@ impl From for ErrorPayload { /// it into a `RequestHandle` so `#[mizan::client]` functions can /// `req.downcast::()` for app-managed state or event /// emission. Stateless functions ignore the handle. +/// +/// Each arm selects the registrations its envelope names and matches over +/// the two shapes that selection has. Both shapes are ordinary: the JS side +/// picks the string, so `[]` is the selection a string nothing registered +/// under makes, and it is answered with the NOT_FOUND envelope. #[tauri::command] async fn mizan_invoke( app: tauri::AppHandle, @@ -123,98 +120,79 @@ async fn mizan_invoke( Envelope::Call { function_name, args, - } => handle_call(&app, &function_name, args).await, - Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await, + } => { + let registered = function_named(&function_name); + let fn_spec = match registered.as_slice() { + [] => { + return Err(ErrorPayload::from(MizanError::NotFound(format!( + "function {function_name:?} not registered" + )))) + } + [fn_spec, ..] => *fn_spec, + }; + + let req = RequestHandle::new(&app); + match fn_spec.dispatch(req, Value::Object(args.clone())).await { + Ok(result) => Ok(call_payload(fn_spec, &args, result)), + Err(e) => Err(ErrorPayload::from(e)), + } + } + Envelope::Fetch { context, params } => { + let members = context_members(&context); + let selected = match members.as_slice() { + [] => { + return Err(ErrorPayload::from(MizanError::NotFound(format!( + "context {context:?} names no registered functions" + )))) + } + selected => selected, + }; + + let mut bundled = Map::new(); + for fn_spec in selected { + let args = filter_args(*fn_spec, ¶ms); + let req = RequestHandle::new(&app); + match fn_spec.dispatch(req, Value::Object(args)).await { + Ok(result) => { + bundled.insert(fn_spec.name().to_string(), result); + } + Err(e) => return Err(ErrorPayload::from(e)), + } + } + + Ok(Value::Object(bundled)) + } } } -async fn handle_call( - app: &tauri::AppHandle, - fn_name: &str, - args: Map, -) -> Result { - let fn_spec = lookup_function(fn_name).ok_or_else(|| { - ErrorPayload::from(MizanError::NotFound(format!( - "function {fn_name:?} not registered" - ))) - })?; - - let req = RequestHandle::new(app); - let result = fn_spec - .dispatch(req, Value::Object(args.clone())) - .await - .map_err(ErrorPayload::from)?; - - let invalidate: Vec = compute_invalidation(fn_spec, &args) +/// The `call` response body — the handler's result alongside the +/// invalidation targets and merge entries the registry derives from the +/// arguments and that result. +fn call_payload(fn_spec: &dyn FunctionSpec, args: &Map, result: Value) -> Value { + let invalidate: Vec = compute_invalidation(fn_spec, args) .iter() .map(InvalidationTarget::to_json) .collect(); - let merges = compute_merges(fn_spec, &args, &result); - let merge_payload: Option> = if merges.is_empty() { - None - } else { - Some(merges.iter().map(MergeEntry::to_json).collect()) - }; + let merges = compute_merges(fn_spec, args, &result); - let mut payload = json!({ - "result": result, - "invalidate": invalidate, - }); - if let Some(merge) = merge_payload { - payload - .as_object_mut() - .expect("payload is a JSON object") - .insert("merge".into(), Value::Array(merge)); + let mut payload = Map::new(); + payload.insert("result".into(), result); + payload.insert("invalidate".into(), Value::Array(invalidate)); + if !merges.is_empty() { + let entries: Vec = merges.iter().map(MergeEntry::to_json).collect(); + payload.insert("merge".into(), Value::Array(entries)); } - Ok(payload) + Value::Object(payload) } -async fn handle_fetch( - app: &tauri::AppHandle, - context_name: &str, - params: Map, -) -> Result { - if lookup_context(context_name).is_none() { - return Err(ErrorPayload::from(MizanError::NotFound(format!( - "context {context_name:?} not registered" - )))); - } - - let members: Vec<&dyn FunctionSpec> = FUNCTIONS - .iter() - .copied() - .filter(|f| f.context() == Some(context_name)) - .collect(); - if members.is_empty() { - return Err(ErrorPayload::from(MizanError::NotFound(format!( - "context {context_name:?} has no registered members" - )))); - } - - let mut bundled = Map::new(); - for fn_spec in &members { - let args = filter_args(*fn_spec, ¶ms); - let req = RequestHandle::new(app); - let result = fn_spec - .dispatch(req, Value::Object(args)) - .await - .map_err(ErrorPayload::from)?; - bundled.insert(fn_spec.name().to_string(), result); - } - - Ok(Value::Object(bundled)) -} - -/// Filter the envelope's params down to keys this function declares as -/// input. The HTTP/axum adapter coerces string-typed query params to -/// JSON primitives in the equivalent step; the Tauri arg channel already -/// carries typed JSON, so the filter is sufficient on its own. +/// The envelope's params narrowed to the keys this function declares as +/// input. The Tauri arg channel already carries typed JSON, so no +/// string-to-primitive coercion is needed here. fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map) -> Map { - let mut out = Map::new(); - for ip in fn_spec.input_params() { - if let Some(v) = params.get(ip.name) { - out.insert(ip.name.into(), v.clone()); - } - } - out + let declared = fn_spec.input_params(); + params + .iter() + .filter(|(name, _)| declared.iter().any(|ip| ip.name == name.as_str())) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() } diff --git a/backends/mizan-ts/src/cache/backend.ts b/backends/mizan-ts/src/cache/backend.ts index 1dcac45..9f8003c 100644 --- a/backends/mizan-ts/src/cache/backend.ts +++ b/backends/mizan-ts/src/cache/backend.ts @@ -1,9 +1,9 @@ /** - * Cache backends — MemoryCache for testing. + * A cache backend is a flat string-to-string store. * - * Simple key-value store. No reverse indexes. + * There is no reverse index from a context to its keys, so `deleteByPrefix` is + * what a broad purge relies on and every backend owes it. */ - export interface CacheBackend { get(key: string): string | null set(key: string, value: string): void @@ -29,6 +29,7 @@ export class MemoryCache implements CacheBackend { deleteByPrefix(prefix: string): number { let count = 0 + // Snapshot the keys — deleting while iterating the live view is UB. for (const key of [...this._store.keys()]) { if (key.startsWith(prefix)) { this._store.delete(key) diff --git a/backends/mizan-ts/src/cache/index.ts b/backends/mizan-ts/src/cache/index.ts index 7e0c4f9..393591e 100644 --- a/backends/mizan-ts/src/cache/index.ts +++ b/backends/mizan-ts/src/cache/index.ts @@ -1,11 +1,3 @@ -/** - * mizan cache — TypeScript adapter. - * - * Same protocol as Python's mizan.cache. Cross-language conformance - * verified by pin tests. No reverse indexes — scoped purge recomputes - * the key directly, broad purge uses prefix scan. - */ - export { MemoryCache } from './backend' export type { CacheBackend } from './backend' export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys' @@ -52,6 +44,13 @@ export function cachePut( backend.set(key, value) } +/** + * Delete cached entries for a context. Returns the number removed. + * + * With params and a secret the exact key is recomputed and dropped; without + * them every key carrying the context prefix is scanned and dropped. There is + * no reverse index from a context to its keys, so those are the only two forms. + */ export function cachePurge( backend: CacheBackend, context: string, @@ -61,11 +60,9 @@ export function cachePurge( rev: number = 0, ): number { if (params && secret) { - // Scoped purge — recompute key and delete directly const key = deriveCacheKey(secret, context, params, userId, rev) return backend.delete(key) ? 1 : 0 } else { - // Broad purge — prefix scan const prefix = `${CONTEXT_KEY_PREFIX}${context}:` return backend.deleteByPrefix(prefix) } diff --git a/backends/mizan-ts/src/cache/keys.ts b/backends/mizan-ts/src/cache/keys.ts index 3dc8e84..45c9453 100644 --- a/backends/mizan-ts/src/cache/keys.ts +++ b/backends/mizan-ts/src/cache/keys.ts @@ -1,10 +1,8 @@ /** - * Cache key derivation — HMAC-SHA256 over JSON-canonical form. + * Cache key derivation — HMAC-SHA256 over a JSON-canonical form. * - * Protocol-critical: must produce identical output to Python's derive_cache_key. - * Cross-language conformance verified by pin tests. - * - * Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan. + * Key format: "ctx:{context}:{hmac_hex}". The context prefix is what lets a + * broad purge run as a prefix scan over the backend's keyspace. */ import { createHmac } from 'crypto' @@ -13,7 +11,11 @@ const CONTEXT_KEY_PREFIX = 'ctx:' /** * JSON.stringify with recursively sorted keys and no whitespace. - * Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":")) + * + * Hand-rolled rather than JSON.stringify because the bytes must match + * Python's json.dumps(obj, sort_keys=True, separators=(",", ":")) exactly — + * a key derived here is looked up by the Python side under the same secret, + * so any serialization drift silently splits the keyspace in two. */ function stableStringify(obj: any): string { if (obj === null || obj === undefined) return 'null' diff --git a/backends/mizan-ts/src/decorator.ts b/backends/mizan-ts/src/decorator.ts index 34b3a06..bb26a35 100644 --- a/backends/mizan-ts/src/decorator.ts +++ b/backends/mizan-ts/src/decorator.ts @@ -1,18 +1,3 @@ -/** - * Mizan @client decorator and function wrapper. - * - * Two registration styles: - * - * 1. Function wrapper (standalone functions): - * const userProfile = client({ context: UserCtx }, async (userId: number) => { ... }) - * - * 2. Class decorator (methods): - * class Handlers { - * @client({ context: UserCtx }) - * async userProfile(userId: number) { ... } - * } - */ - import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types' import { register } from './registry' @@ -35,8 +20,14 @@ function normalizeAffects( }) } +/** + * Recover parameter names by parsing the function's own source text. + * + * Names are the wire contract — the dispatcher matches request params to + * positional arguments by name — and JS erases them at runtime, so the + * source string is the only place they survive. + */ function extractParams(fn: Function): ParamDef[] { - // Extract parameter names from function.toString() const source = fn.toString() const match = source.match(/\(([^)]*)\)/) if (!match || !match[1].trim()) return [] @@ -46,33 +37,22 @@ function extractParams(fn: Function): ParamDef[] { .map(p => p.trim()) .filter(p => p && !p.startsWith('...')) .map(p => { - // Handle destructured defaults: name = default, name: type + // Strips a default value or a type annotation off the name const name = p.split(/[=:]/)[0].trim() return { name, type: 'any', required: !p.includes('=') } }) } -/** - * Function wrapper — registers a standalone function. - * - * const userProfile = client({ context: UserCtx }, async (userId: number) => { ... }) - */ +/** Wrap and register a standalone function. */ export function client Promise>( options: ClientOptions, fn: T, ): T -/** - * Class method decorator. - * - * class Handlers { - * @client({ context: UserCtx }) - * async userProfile(userId: number) { ... } - * } - */ +/** Register a class method. */ export function client(options: ClientOptions): MethodDecorator -export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any { +export function client(optionsOrFn: ClientOptions, fn?: Function): any { // Function wrapper form: client(options, fn) if (fn && typeof fn === 'function') { const options = optionsOrFn as ClientOptions @@ -85,16 +65,17 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function const name = fn.name || 'anonymous' const params = extractParams(fn) - const isView = false // Determined at call time for function wrappers const entry: RegistryEntry = { name, - fn: fn as any, + fn: fn as RegistryEntry['fn'], context, affects, params, private: options.private ?? false, - viewPath: isView, + // A wrapped function's return is only known once called, so the + // view-vs-RPC split is decided by the dispatcher, not here. + viewPath: false, route: options.route, methods: options.methods, auth: options.auth, diff --git a/backends/mizan-ts/src/dispatch.ts b/backends/mizan-ts/src/dispatch.ts index 5159769..dfcd4b5 100644 --- a/backends/mizan-ts/src/dispatch.ts +++ b/backends/mizan-ts/src/dispatch.ts @@ -1,8 +1,8 @@ /** - * Request dispatch — context GET and mutation POST handlers. + * Context GET and mutation POST handlers. * - * Framework-agnostic. Returns plain objects. The router adapter - * (Express, Hono, etc.) converts to framework-specific responses. + * Handlers return plain MizanResponse objects; turning one into a + * framework's own response type is the router adapter's job. */ import { getFunction, getContextGroups } from './registry' @@ -62,7 +62,10 @@ export async function handleContextFetch( headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' }, } } - } catch { /* cache miss on error */ } + } catch (e: any) { + // A failed lookup degrades to a miss, so recompute below rather than fail the request. + console.error(`mizan: cache lookup failed for context '${contextName}'`, e) + } } const results: Record = {} @@ -86,6 +89,7 @@ export async function handleContextFetch( results[fnName] = result } catch (e: any) { + console.error(`mizan: context function '${fnName}' raised`, e) return { status: 500, body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' }, @@ -111,7 +115,10 @@ export async function handleContextFetch( if (cacheBackend && cacheSecret && effectiveCache !== false) { try { cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev) - } catch { /* cache store failure is non-fatal */ } + } catch (e: any) { + // The results are already computed, so a store failure costs a future hit, not this response. + console.error(`mizan: cache store failed for context '${contextName}'`, e) + } } return { @@ -186,20 +193,25 @@ export async function handleMutationCall( // Purge origin-side cache const cb = getCache() if (cb) { - try { - for (const entry of invalidate) { - if (typeof entry === 'string') { - cachePurge(cb, entry) + for (const target of invalidate) { + try { + if (typeof target === 'string') { + cachePurge(cb, target) } else { - cachePurge(cb, entry.context, entry.params, _cacheSecret) + cachePurge(cb, target.context, target.params, _cacheSecret) } + } catch (e: any) { + // The client still gets X-Mizan-Invalidate, so a stale origin entry + // is recoverable; one bad target must not skip the remaining ones. + console.error(`mizan: cache purge failed for`, target, e) } - } catch { /* purge failure is non-fatal */ } + } } } return { status: 200, body: responseData, headers } } catch (e: any) { + console.error(`mizan: mutation '${fnName}' raised`, e) return { status: 500, body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' }, diff --git a/backends/mizan-ts/src/invalidation.ts b/backends/mizan-ts/src/invalidation.ts index c51afde..4829cd7 100644 --- a/backends/mizan-ts/src/invalidation.ts +++ b/backends/mizan-ts/src/invalidation.ts @@ -1,20 +1,15 @@ -/** - * Invalidation protocol — header formatting, auto-scoping. - * - * Matches Django's implementation exactly. Same format. Same rules. - */ - import type { RegistryEntry } from './types' -import { getContextGroups, getContextParamNames, getFunction } from './registry' +import { getContextGroups, getContextParamNames } from './registry' type InvalidateEntry = string | { context: string; params: Record } /** - * Resolve invalidation targets with three-tier auto-scoping. + * Resolve what a mutation's `affects` targets invalidate. * - * Tier 1: Argument name matching - * Tier 2: Auth inference (Edge-side, not handled here) - * Tier 3: Broad fallback + * Each target narrows to the call arguments whose names the target's context + * also declares as params — that intersection is the scoped purge. A target + * with no such overlap emits as a bare context name, meaning purge every + * entry under that context. */ export function resolveInvalidation( entry: RegistryEntry, @@ -34,7 +29,6 @@ export function resolveInvalidation( const resolved = resolveAffectsTarget(targetName) const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name - // Tier 1: argument name matching if (callArgs && ctxForParams) { const contextParams = getContextParamNames(ctxForParams) const matched: Record = {} @@ -47,7 +41,6 @@ export function resolveInvalidation( } } - // Tier 3: broad fallback result.push(targetName) } @@ -55,7 +48,10 @@ export function resolveInvalidation( } /** - * Determine whether an affects target is a context name or function name. + * Determine whether an affects target names a context or a function. + * + * An unrecognized name resolves as a context, so a target registered later + * still purges by name rather than being dropped here. */ function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } { const groups = getContextGroups() @@ -74,9 +70,10 @@ function resolveAffectsTarget(name: string): { type: 'context' | 'function'; nam } /** - * Format invalidation targets as X-Mizan-Invalidate header value. + * Format invalidation targets as an X-Mizan-Invalidate header value. * - * Format: comma-separated contexts. Semicolon-separated URL-encoded params. + * Comma-separated targets; within a target, semicolon-separated URL-encoded + * params follow the context name. */ export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string { const parts: string[] = [] diff --git a/backends/mizan-ts/src/manifest.ts b/backends/mizan-ts/src/manifest.ts index 3c5c9b7..847c7b0 100644 --- a/backends/mizan-ts/src/manifest.ts +++ b/backends/mizan-ts/src/manifest.ts @@ -1,15 +1,8 @@ -/** - * Edge Manifest Generator - * - * Produces the same JSON format as mizan-django. One Edge Worker. - * Two backend languages. Same manifest. - */ - -import type { EdgeManifest } from './types' +import type { EdgeManifest, ManifestFunction } from './types' import { getAllFunctions, getContextGroups, getContextParamNames } from './registry' -// Both camelCase and snake_case forms included for cross-language matching. -// Wire format is snake_case (protocol rule); camelCase is the TS-local convention. +// Wire format is snake_case; camelCase is the TS-local convention. Both forms +// are listed because a param name arrives here as whichever the author wrote. const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id']) export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest { @@ -20,7 +13,7 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest { // Contexts for (const [ctxName, fnNames] of Object.entries(groups)) { const paramNames = new Set() - const functions: Array<{ name: string; path: 'rpc' | 'view'; route?: string; methods?: string[] }> = [] + const functions: ManifestFunction[] = [] const pageRoutes: string[] = [] for (const fnName of fnNames) { @@ -29,14 +22,14 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest { for (const p of entry.params) paramNames.add(p.name) - const fnEntry: any = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' } + const fnEntry: ManifestFunction = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' } if (entry.route) { fnEntry.route = entry.route fnEntry.methods = entry.methods || ['GET'] pageRoutes.push(entry.route) } if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev - if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache + if (entry.cache !== undefined) fnEntry.cache = entry.cache functions.push(fnEntry) } diff --git a/backends/mizan-ts/src/registry.ts b/backends/mizan-ts/src/registry.ts index 76cddcb..c854efc 100644 --- a/backends/mizan-ts/src/registry.ts +++ b/backends/mizan-ts/src/registry.ts @@ -1,12 +1,10 @@ -/** - * Mizan Registry — Central registration for server functions. - */ - import type { RegistryEntry } from './types' const _functions: Map = new Map() export function register(entry: RegistryEntry): void { + // Re-registering the same function object is a module re-evaluation, not a + // name collision, so only a different fn under a taken name is an error. if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) { throw new Error(`Function '${entry.name}' already registered`) } diff --git a/backends/mizan-ts/src/types.ts b/backends/mizan-ts/src/types.ts index bde256b..c4c7b8c 100644 --- a/backends/mizan-ts/src/types.ts +++ b/backends/mizan-ts/src/types.ts @@ -1,7 +1,3 @@ -/** - * Mizan TypeScript Adapter — Shared Types - */ - export class ReactContext { constructor(public readonly name: string) { if (!name) throw new Error('ReactContext name must be non-empty') @@ -42,8 +38,17 @@ export interface RegistryEntry { cache?: number | false } +export interface ManifestFunction { + name: string + path: 'rpc' | 'view' + route?: string + methods?: string[] + rev?: number + cache?: number | false +} + export interface ManifestContext { - functions: Array<{ name: string; path: 'rpc' | 'view' }> + functions: ManifestFunction[] endpoints: string[] params: string[] user_scoped: boolean diff --git a/backends/mizan-ts/tests/edge-compat.test.ts b/backends/mizan-ts/tests/edge-compat.test.ts index ffdaec2..ee7d32a 100644 --- a/backends/mizan-ts/tests/edge-compat.test.ts +++ b/backends/mizan-ts/tests/edge-compat.test.ts @@ -1,25 +1,18 @@ -/** - * Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly. - * - * These prove that a Cloudflare Worker (Edge) can sit in front of a - * TypeScript backend and behave identically to sitting in front of Django. - */ - import { describe, test, expect, beforeEach } from 'bun:test' import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src' const UserCtx = new ReactContext('user') function setupUserContext() { - const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) { + client({ context: UserCtx }, async function userProfile(userId: number) { return { name: `user_${userId}`, email: `user${userId}@test.com` } }) - const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) { + client({ context: UserCtx }, async function userOrders(userId: number) { return { count: userId * 10 } }) - const updateProfile = client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) { + client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) { return { name, email: `user${userId}@test.com` } }) @@ -122,7 +115,7 @@ describe('Edge Compatibility', () => { { context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } }, ]) - // Parse (what Edge does) + // Parse the header the way the Edge worker does const segments = header.split(';') const ctx = segments[0] const params: Record = {} @@ -139,12 +132,6 @@ describe('Edge Compatibility', () => { // ── Empty invalidation ───────────────────────────────────────────── test('no affects = no header, no body key', async () => { - client({ context: new ReactContext('plain') }, async function plainFn() { - return { ok: true } - }) - - // A context function called via mutation dispatch (shouldn't have invalidation) - // Actually test a function without affects clearRegistry() client({}, async function noAffects() { return { ok: true } }) const r = await handleMutationCall('noAffects', {}) @@ -251,6 +238,18 @@ describe('Manifest', () => { expect(fn.cache).toBe(60) }) + test('cache=false appears in manifest', () => { + clearRegistry() + const Ctx = new ReactContext('nocache') + client({ context: Ctx, cache: false }, async function uncachedFn() { + return { value: 1 } + }) + + const m = generateManifest() + const fn = m.contexts.nocache.functions[0] + expect(fn.cache).toBe(false) + }) + test('cache=60 still emits no-store on HTTP', async () => { clearRegistry() const Ctx = new ReactContext('live') @@ -294,8 +293,9 @@ describe('Cache Conformance', () => { }) test('deriveCacheKey cross-language pin (matches Python)', () => { - // These exact values are pinned from Python's derive_cache_key output. - // If this test fails, cross-language cache key compatibility is broken. + // Literals captured from Python's derive_cache_key under the same + // secret. A key derived here is looked up by the Python side, so a + // mismatch means the two runtimes address different keyspaces. const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0) expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6') @@ -362,6 +362,27 @@ describe('Cache Conformance', () => { setCacheSecret(null) }) + test('cache=false context is never stored', async () => { + clearRegistry() + const Ctx = new ReactContext('volatile') + client({ context: Ctx, cache: false }, async function volatileFn(itemId: number) { + return { value: itemId } + }) + + const cache = new MemoryCache() + setCache(cache) + setCacheSecret(SECRET) + + const r1 = await handleContextFetch('volatile', { itemId: '1' }) + expect(r1.headers['X-Mizan-Cache']).toBe('MISS') + + const r2 = await handleContextFetch('volatile', { itemId: '1' }) + expect(r2.headers['X-Mizan-Cache']).toBe('MISS') + + resetCache() + setCacheSecret(null) + }) + test('handleMutationCall purges cache', async () => { clearRegistry() const Ctx = new ReactContext('product') diff --git a/cores/mizan-python/pyproject.toml b/cores/mizan-python/pyproject.toml index 5259805..9e0d9ed 100644 --- a/cores/mizan-python/pyproject.toml +++ b/cores/mizan-python/pyproject.toml @@ -6,11 +6,14 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag requires-python = ">=3.10" dependencies = [ "PyJWT>=2.0", + "jinja2>=3.1", + "pydantic>=2.0", ] [project.optional-dependencies] dev = [ "pytest>=8.0", + "ckdl>=1.0", ] [build-system] diff --git a/cores/mizan-python/src/mizan_core/cache/backend.py b/cores/mizan-python/src/mizan_core/cache/backend.py index fd95f2c..8e4418a 100644 --- a/cores/mizan-python/src/mizan_core/cache/backend.py +++ b/cores/mizan-python/src/mizan_core/cache/backend.py @@ -1,32 +1,36 @@ -""" -Cache backends — MemoryCache (testing) and RedisCache (production). - -Simple key-value stores. No reverse indexes. Cache keys are derived -from HMAC, so scoped purge just recomputes the key and deletes it. -Broad purge uses key-prefix scan (rare operation). -""" +"""Cache backends — a key/value store keyed by the derived HMAC cache key.""" from __future__ import annotations -from typing import Protocol +import abc -class CacheBackend(Protocol): - """Interface that all Mizan cache backends implement.""" +class CacheBackend(abc.ABC): + """A key/value store holding serialized context payloads.""" - def get(self, key: str) -> bytes | None: ... - def set(self, key: str, value: bytes) -> None: ... - def delete(self, key: str) -> bool: ... - def delete_by_prefix(self, prefix: str) -> int: ... - def clear(self) -> None: ... + @abc.abstractmethod + def get(self, key: str) -> bytes | None: + """The stored value for `key`, or None when absent.""" + + @abc.abstractmethod + def set(self, key: str, value: bytes) -> None: + """Store `value` under `key`, replacing anything already there.""" + + @abc.abstractmethod + def delete(self, key: str) -> bool: + """Drop `key`. True if it was present.""" + + @abc.abstractmethod + def delete_by_prefix(self, prefix: str) -> int: + """Drop every key starting with `prefix`. Returns how many were dropped.""" + + @abc.abstractmethod + def clear(self) -> None: + """Drop every key this backend owns.""" -class MemoryCache: - """ - In-memory cache backend for testing. - - Uses a Python dict. No persistence, no cross-process sharing. - """ +class MemoryCache(CacheBackend): + """A process-local dict. No persistence, no cross-process sharing.""" def __init__(self) -> None: self._store: dict[str, bytes] = {} @@ -53,13 +57,8 @@ class MemoryCache: self._store.clear() -class RedisCache: - """ - Redis-backed cache backend for production. - - Simple GET/SET/DEL. No reverse indexes. Scoped purge recomputes - the HMAC key and deletes directly. Broad purge uses SCAN. - """ +class RedisCache(CacheBackend): + """Redis GET/SET/UNLINK behind a key namespace, with SCAN for prefix drops.""" DEFAULT_TTL = 86400 # 24h safety-net @@ -71,11 +70,11 @@ class RedisCache: ) -> None: try: import redis as redis_lib - except ImportError: + except ImportError as exc: raise ImportError( "Redis is required for Mizan's cache backend. " "Install it with: pip install mizan[cache]" - ) + ) from exc self._client = redis_lib.from_url( redis_url, socket_connect_timeout=5, diff --git a/cores/mizan-python/src/mizan_core/cache/keys.py b/cores/mizan-python/src/mizan_core/cache/keys.py index c163556..80c57f1 100644 --- a/cores/mizan-python/src/mizan_core/cache/keys.py +++ b/cores/mizan-python/src/mizan_core/cache/keys.py @@ -1,12 +1,4 @@ -""" -Cache key derivation — HMAC-SHA256 over JSON-canonical form. - -Protocol-critical: every Mizan adapter must produce identical output -for identical inputs. Cross-language conformance verified by pin tests. - -Scoped purge recomputes the key directly — no reverse index needed. -Broad purge uses a context prefix scan. -""" +"""Cache key derivation — HMAC-SHA256 over a canonical JSON form.""" from __future__ import annotations @@ -15,7 +7,6 @@ import hmac import json from typing import Any -# Context prefix for broad purge (SCAN pattern) CONTEXT_KEY_PREFIX = "ctx:" @@ -33,8 +24,9 @@ def derive_cache_key( broad purge can SCAN by prefix "ctx:{context}:*". """ def _normalize(v: Any) -> str: - """Normalize values for cross-language HMAC consistency. - Python str(True)="True" but JS String(true)="true". Use JSON-native forms.""" + """Render a param value in its JSON-native spelling. + Python str(True) is "True" but JS String(true) is "true", and the two + must hash identically.""" if v is True: return "true" if v is False: diff --git a/cores/mizan-python/src/mizan_core/client/function.py b/cores/mizan-python/src/mizan_core/client/function.py index bcdda59..4616e55 100644 --- a/cores/mizan-python/src/mizan_core/client/function.py +++ b/cores/mizan-python/src/mizan_core/client/function.py @@ -1,16 +1,14 @@ """ -mizan Server Functions - Core Primitive +Server functions: the `@client` decorator and the `ServerFunction` class it +produces, `ReactContext` for grouping them, and `compose` for combining +contexts into one provider. -Server functions are the core primitive. Everything else builds on them. - -Two styles supported: - -1. Function-based (recommended, Django Ninja style): - @client("update-profile") - def update_profile(request, input: UpdateProfileInput) -> UpdateProfileOutput: +Function form: + @client + def update_profile(request, name: str) -> UpdateProfileOutput: return UpdateProfileOutput(success=True) -2. Class-based (for complex cases): +Class form: class UpdateProfile(ServerFunction): def call(self, input: UpdateProfileInput) -> UpdateProfileOutput: return UpdateProfileOutput(success=True) @@ -21,7 +19,6 @@ from __future__ import annotations import asyncio import inspect -import warnings from abc import ABC, abstractmethod from typing import ( Any, @@ -30,24 +27,16 @@ from typing import ( Generic, Literal, TypeVar, - Union, - get_args, - get_origin, get_type_hints, ) from pydantic import BaseModel -# ─── Framework-response-base hook ─────────────────────────────────────────── -# # View-path detection — distinguishing functions that return data (RPC path) # from functions that return a framework-native response object (view path) — -# requires knowing the framework's response base class. Each backend adapter -# registers its base class here at import time. -# -# Django sets this to django.http.HttpResponseBase. FastAPI would set it to -# starlette.responses.Response. If unset, all functions are treated as RPC. +# needs the framework's response base class, which only the backend adapter +# knows. While it is unset, every function is treated as RPC. _framework_response_base: type | None = None @@ -101,7 +90,7 @@ class ReactContext: return f"ReactContext({self.name!r})" -# Built-in global context (auto-mounted at root, SSR-hydrated) +# The context named 'global', pre-made so callers share one instance of it. GlobalContext = ReactContext("global") @@ -209,7 +198,7 @@ class ServerFunction(ABC, Generic[TInput, TOutput]): class _FunctionWrapper(ServerFunction): """Internal wrapper that makes a plain function behave like a ServerFunction.""" - # Will be set per-wrapper instance + # Set per-wrapper subclass by _create_server_function _wrapped_fn: ClassVar[Callable] _input_cls: ClassVar[type[BaseModel] | None] _output_cls: ClassVar[type[BaseModel]] @@ -284,12 +273,6 @@ def _resolve_context(context: ContextMode) -> str | Literal[False]: if isinstance(context, str): if not context.strip(): raise ValueError("context must be a non-empty string, ReactContext, or False.") - if context == "local": - warnings.warn( - "context='local' is deprecated. Use ReactContext('name') instead.", - DeprecationWarning, - stacklevel=3, - ) return context raise ValueError( f"context must be a ReactContext, a string, or False. Got {type(context).__name__}." @@ -327,7 +310,6 @@ def client( context: Named context for React state management. - False (default): Not a context, just a callable function. - ReactContext instance: groups functions into a named context. - - GlobalContext: reserved, auto-mounted at root, SSR-hydrated. affects: Declare which contexts or functions this mutation invalidates. Mutually exclusive with context=. @@ -526,8 +508,8 @@ def _create_server_function( is_view_path = is_framework_response(output_type) if is_view_path: - # View path — no Pydantic output wrapping needed - output_cls = BaseModel # placeholder, never used for serialization + # A view path serializes nothing, so Output is never read off this class. + output_cls = BaseModel is_primitive_output = False else: # RPC path — resolve output type @@ -555,7 +537,7 @@ def _create_server_function( FunctionWrapper._output_cls = output_cls FunctionWrapper._is_primitive_output = is_primitive_output - # Set Input/Output class attributes for compatibility + # Input/Output are the names the ServerFunction contract exposes them under if input_cls is not None: FunctionWrapper.Input = input_cls FunctionWrapper.Output = output_cls @@ -615,8 +597,8 @@ def _create_server_function( # Always assign a fresh dict to prevent shared-dict mutation across classes FunctionWrapper._meta = {**meta} - # Note: Registration happens via discovery (mizan_clients), not here. - # This allows the decorator to be used without import-time side effects. + # Registration happens via discovery (mizan_clients), so the decorator has + # no import-time side effects. return FunctionWrapper @@ -657,15 +639,14 @@ class ComposedContext: "leaves": [leaf.name for leaf in leaves], } - @classmethod - def get_schema_export(cls) -> dict[str, Any]: + def get_schema_export(self) -> dict[str, Any]: """Export schema for TypeScript generation.""" return { - "name": cls.name, + "name": self.name, "type": "compose", - "meta": cls._meta, - "children": cls._meta.get("children", []), - "leaves": cls._meta.get("leaves", []), + "meta": self._meta, + "children": self._meta["children"], + "leaves": self._meta["leaves"], } @@ -676,7 +657,6 @@ def _get_leaves(item) -> list[type[ServerFunction]]: elif isinstance(item, ComposedContext): return item._leaves.copy() elif hasattr(item, "_leaves"): - # Duck typing for composed contexts return item._leaves.copy() else: raise TypeError(f"Expected ServerFunction or ComposedContext, got {type(item)}") @@ -714,21 +694,18 @@ def compose( - True: Bundled call over WebSocket. All children must have websocket=True. Usage: - @client(context='local') + UserContext = ReactContext('user') + + @client(context=UserContext) def user_profile(request, user_id: int) -> ProfileOutput: ... - @client(context='local') + @client(context=UserContext) def user_posts(request, user_id: int) -> PostsOutput: ... @compose(user_profile, user_posts) def user_page(): pass - # Frontend generates: - # - # - # - Nesting: @compose(ctx_a, ctx_b) def ab(): pass @@ -767,7 +744,7 @@ def compose( if id(leaf) in seen: raise ValueError( f"Duplicate context '{leaf.name}' in @compose({name}). " - f"Each context can only appear once. Use named kwargs for reuse (future feature)." + f"Each context appears at most once across the flattened children." ) seen.add(id(leaf)) diff --git a/cores/mizan-python/src/mizan_core/ir.py b/cores/mizan-python/src/mizan_core/ir.py index e9838a8..2dd43c5 100644 --- a/cores/mizan-python/src/mizan_core/ir.py +++ b/cores/mizan-python/src/mizan_core/ir.py @@ -1,37 +1,37 @@ """ Mizan IR — KDL emission from the live `mizan_core.registry`. -`build_ir()` walks every registered function class, introspects its -Pydantic Input/Output models directly (not via JSON-Schema), and emits -KDL — the canonical Mizan protocol IR. Every backend adapter exposes -this via a backend-specific entry point (Django management command, -FastAPI CLI, mizan-ts equivalent); every codegen target consumes this. +`build_ir()` walks every registered function class, introspects its Pydantic +Input/Output models directly (not via JSON-Schema), computes a plain-data +document, and renders it through `templates/ir/document.kdl.j2`. -KDL grammar — locked contract: +KDL grammar: type "" { struct { - field "" required=#true|#false default= { - primitive "integer|number|boolean|string" - | ref "" - | list { } - | optional { } - | enum "" "" ... + field "" required=#false default= { + } ... } - | list { } - | enum "" "" ... | alias { } } + = + primitive "integer|number|boolean|string" + | ref "" + | enum "" "" ... + | list { } + | optional { } + | union { ... } + function "" { camel "" has-input #true|#false input "" // omitted if has-input=#false output "" - output-nullable #true|#false // omitted when #false (default) - transport "http"|"websocket"|"both" + output-nullable #true // omitted when #false (default) + transport "http"|"websocket" context "" // omitted unless context-grouped affects "" // 0..N occurrences merge "" // 0..N occurrences @@ -51,303 +51,174 @@ KDL grammar — locked contract: } } - channel "" { + channel "" { pascal-name "" - params "" // omitted if no params - react-message "" // omitted if no react message - django-message "" // omitted if no django message + params "" // omitted when the channel takes no params + client-message "" // client -> server; omitted if none + server-message "" // server -> client; omitted if none } -Nothing else lives in the IR. OpenAPI envelope, JSON-Schema $ref dance, -the Pydantic→json-schema converter — all gone. +Channel slots are named from the client's point of view — `client-message` +travels up, `server-message` travels down — and their type names are +`Params`, `ClientMessage` and `ServerMessage`, where +`` comes from `wire_to_pascal`. Backends that publish channel types +into their own schema documents call `wire_to_pascal` rather than deriving a +second Pascal form. """ from __future__ import annotations +import re import types from typing import Any, Literal, Union, get_args, get_origin -from pydantic import BaseModel +from jinja2 import Environment, PackageLoader, StrictUndefined +from pydantic import BaseModel, create_model 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, + get_registry, +) from mizan_core.type_utils import extract_list_element, extract_optional -__all__ = ["build_ir"] +__all__ = ["build_ir", "wire_to_pascal"] -# Common user-identity param names; mirrors the equivalent in mizan-django / -# mizan-fastapi schema-export logic. -_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"} +# ─── Wire-name derivations ────────────────────────────────────────────────── -# ─── KDL value formatting ─────────────────────────────────────────────────── +def wire_to_pascal(wire_name: str) -> str: + """The PascalCase stem every emitted type name for `wire_name` is built on.""" + return "".join(part.title() for part in re.split(r"[._-]", wire_name)) -def _kdl_string(s: str) -> str: - """KDL-escape a string and wrap in quotes.""" +# ─── KDL value encoding ───────────────────────────────────────────────────── + + +def _kdl(value: str) -> str: + """KDL-escape a string and wrap it in quotes.""" escaped = ( - s.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") + value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") ) return f'"{escaped}"' -def _kdl_bool(b: bool) -> str: - return "#true" if b else "#false" +def _kdlbool(value: bool) -> str: + return "#true" if value else "#false" -def _kdl_value(v: Any) -> str: - """Render a JSON-shape Python value as a KDL literal.""" - if v is None: - return "#null" - if v is True or v is False: - return _kdl_bool(v) - if isinstance(v, (int, float)): - return repr(v) - if isinstance(v, str): - return _kdl_string(v) - # Fallback for compound values — defaults aren't typed in our IR. - import json - return _kdl_string(json.dumps(v)) +_ENV = Environment( + loader=PackageLoader("mizan_core", "templates"), + undefined=StrictUndefined, + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, +) +_ENV.filters["kdl"] = _kdl +_ENV.filters["kdlbool"] = _kdlbool -# ─── KDL Builder ──────────────────────────────────────────────────────────── +def _default_literal(value: Any) -> dict[str, Any] | None: + """Tag a field default so the template can pick its KDL literal form. + + A KDL entry value is a scalar, so the literal forms are exactly bool, + number and string. A default of any other shape — a nested model, a list, + a dict, an enum member — has no scalar form and yields `None`: the field + emits `required=#false` with no `default`, and the server-side Pydantic + model stays the authority for the value it fills in. + """ + if value is True or value is False: + return {"kind": "bool", "value": value} + if isinstance(value, (int, float)): + return {"kind": "number", "value": value} + if isinstance(value, str): + return {"kind": "string", "value": value} + return None -class _Block: - """Open-children context for a KDL node. Tracks indent level.""" - - __slots__ = ("lines", "indent") - - def __init__(self, lines: list[str], indent: int): - self.lines = lines - self.indent = indent - - def _prefix(self) -> str: - return " " * self.indent - - def node(self, name: str, *args: str, **props: str) -> "_OpenNode": - """Open a node. `args` are positional KDL args; `props` are key=value pairs.""" - return _OpenNode(self.lines, self.indent, name, list(args), dict(props)) - - def leaf(self, name: str, *args: str, **props: str) -> None: - """Emit a leaf node — no children block.""" - parts = [name] - parts.extend(args) - for k, v in props.items(): - parts.append(f"{k}={v}") - self.lines.append(f"{self._prefix()}{' '.join(parts)}") +# ─── Type shapes ──────────────────────────────────────────────────────────── -class _OpenNode: - """A KDL node whose children are being built.""" +def _shape(annotation: Any, refs: list[type[BaseModel]]) -> dict[str, Any]: + """Reduce a Python annotation to a shape tree, appending every model it + references to `refs`.""" + inner, is_optional = extract_optional(annotation) + if is_optional: + return {"kind": "optional", "of": _shape(inner, refs)} - def __init__( - self, - lines: list[str], - indent: int, - name: str, - args: list[str], - props: dict[str, str], - ): - self.lines = lines - self.indent = indent - self.name = name - self.args = args - self.props = props - self._children_emitted = False - - def __enter__(self) -> _Block: - parts = [self.name] - parts.extend(self.args) - for k, v in self.props.items(): - parts.append(f"{k}={v}") - self.lines.append(f"{' ' * self.indent}{' '.join(parts)} {{") - self._children_emitted = True - return _Block(self.lines, self.indent + 1) - - def __exit__(self, *_exc: Any) -> None: - if self._children_emitted: - self.lines.append(f"{' ' * self.indent}}}") - - -# ─── Type emission ────────────────────────────────────────────────────────── - - -def _emit_type_child(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None: - """Emit the type-shape KDL for a Python annotation, recursing as needed.""" - # Strip Optional[T] → emit `optional` wrapper. - inner, is_opt = extract_optional(annotation) - if is_opt: - with block.node("optional") as inner_block: - _emit_type_child(inner_block, inner, named_types) - return - - # Multi-arm union (T | U) — emit `union { }`. origin = get_origin(annotation) if origin is Union or isinstance(annotation, types.UnionType): branches = [a for a in get_args(annotation) if a is not type(None)] if len(branches) > 1: - with block.node("union") as inner_block: - for branch in branches: - _emit_type_child(inner_block, branch, named_types) - return + return { + "kind": "union", + "branches": [_shape(branch, refs) for branch in branches], + } - # list[T] / tuple[T, ...] / set[T] / frozenset[T] → `list { ... }` - elem = extract_list_element(annotation) - if elem is not None: - with block.node("list") as inner_block: - _emit_type_child(inner_block, elem, named_types) - return + element = extract_list_element(annotation) + if element is not None: + return {"kind": "list", "of": _shape(element, refs)} - # Literal[a, b, c] → enum if origin is Literal: - args = get_args(annotation) - if all(isinstance(a, str) for a in args): - quoted = " ".join(_kdl_string(a) for a in args) - block.lines.append(f"{block._prefix()}enum {quoted}") - return + values = get_args(annotation) + if all(isinstance(v, str) for v in values): + return {"kind": "enum", "values": list(values)} - # Pydantic model → reference by name. if isinstance(annotation, type) and issubclass(annotation, BaseModel): - type_name = annotation.__name__ - named_types.setdefault(type_name, _StructShape(annotation)) - block.leaf("ref", _kdl_string(type_name)) - return + refs.append(annotation) + return {"kind": "ref", "name": annotation.__name__} - # Primitives if annotation is int: - block.leaf("primitive", _kdl_string("integer")) - return + return {"kind": "primitive", "name": "integer"} if annotation is float: - block.leaf("primitive", _kdl_string("number")) - return + return {"kind": "primitive", "name": "number"} if annotation is bool: - block.leaf("primitive", _kdl_string("boolean")) - return - if annotation is str: - block.leaf("primitive", _kdl_string("string")) - return + return {"kind": "primitive", "name": "boolean"} - # Open-shape fallback (dict / Any / etc). - block.leaf("primitive", _kdl_string("string")) + # str, dict, Any and every other open shape collapse to string. + return {"kind": "primitive", "name": "string"} -def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None: - """Emit `type "X" { alias { } }` for a non-struct wrapper.""" - with block.node("alias") as alias_block: - _emit_type_child(alias_block, annotation, named_types) +def _struct_fields( + model: type[BaseModel], refs: list[type[BaseModel]] +) -> list[dict[str, Any]]: + fields: list[dict[str, Any]] = [] + for field_name, field_info in model.model_fields.items(): + # `is_required()` covers both the explicit Required marker and the + # presence of a default. + required = field_info.is_required() + default = field_info.default + has_default = ( + not required + and default is not None + and default is not PydanticUndefined + and default is not ... + ) + fields.append( + { + "name": field_name, + "required": required, + "default": _default_literal(default) if has_default else None, + "shape": _shape(field_info.annotation, refs), + } + ) + return fields -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: - for field_name, field_info in model.model_fields.items(): - props: dict[str, str] = {} - # `field_info.is_required()` checks both the explicit Required - # marker and the presence of a default. - required = field_info.is_required() - if not required: - props["required"] = _kdl_bool(False) - default = field_info.default - if default is not None and default is not PydanticUndefined and default is not ...: - 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) +# ─── Named types ──────────────────────────────────────────────────────────── -class _StructShape: - """A Pydantic BaseModel that emits as `type "X" { struct { ... } }`.""" - __slots__ = ("model",) - def __init__(self, model: type[BaseModel]): - self.model = model - - -class _AliasShape: - """A named alias wrapper — e.g. `Output = list[]`.""" - __slots__ = ("annotation",) - def __init__(self, annotation: Any): - self.annotation = annotation - - -def _collect_named_types(functions: dict[str, Any]) -> dict[str, Any]: - """First pass: collect every named type the IR's `function` section references. - - Two kinds: - - Pydantic BaseModels seen anywhere in Input/Output traversal — emit - as `type "X" { struct { ... } }`. - - Function-output wrapper aliases (`Output = list[T]` / - `Output = T | None`) — emit as `type "X" { alias { ... } }` - so the consumer has a single named type to reference. - """ - seen: dict[str, Any] = {} - - def visit_model(model: type[BaseModel]) -> None: - if model.__name__ in seen: - return - seen[model.__name__] = _StructShape(model) - for field_info in model.model_fields.values(): - for nested in _nested_models(field_info.annotation): - visit_model(nested) - - def visit_annotation(ann: Any) -> None: - for nested in _nested_models(ann): - visit_model(nested) - - for fn_class in functions.values(): - input_cls = getattr(fn_class, "Input", None) - if _has_input(input_cls): - input_named = _name_input_model(fn_class) - visit_model(input_named) - - output_cls = getattr(fn_class, "Output", None) - if output_cls is None: - continue - camel = _snake_to_camel(fn_class.name) - output_name = f"{camel}Output" - - inner, _ = extract_optional(output_cls) - elem = extract_list_element(inner) - - if elem is not None: - # `list[T]` (possibly wrapped in Optional) — emit a list alias. - # Visit the element type so its struct shape gets emitted too. - visit_annotation(output_cls) - if output_name not in seen: - seen[output_name] = _AliasShape(output_cls) - elif isinstance(inner, type) and issubclass(inner, BaseModel): - # `` or `Optional[]` — emit the model under the - # canonical name (rename if necessary). - output_named = _name_output_model(fn_class, inner) - visit_model(output_named) - # If the Optional wrapper differs from the bare model, emit an - # alias under the canonical output name too. - if output_named.__name__ != output_name: - seen.setdefault(output_name, _AliasShape(output_cls)) - else: - # Primitive-wrapped output (`result: int`) — emit as alias. - seen.setdefault(output_name, _AliasShape(output_cls)) - - return seen - - -def _nested_models(annotation: Any) -> list[type[BaseModel]]: - """All Pydantic models that appear anywhere inside `annotation`.""" - out: list[type[BaseModel]] = [] - inner, _ = extract_optional(annotation) - elem = extract_list_element(inner) - if elem is not None: - out.extend(_nested_models(elem)) - return out - if isinstance(inner, type) and issubclass(inner, BaseModel): - out.append(inner) - return out +def _snake_to_camel(name: str) -> str: + parts = name.replace(".", "_").replace("-", "_").split("_") + return parts[0] + "".join(p.title() for p in parts[1:] if p) def _has_input(input_cls: Any) -> bool: @@ -359,206 +230,135 @@ def _has_input(input_cls: Any) -> bool: ) -def _snake_to_camel(name: str) -> str: - parts = name.replace(".", "_").replace("-", "_").split("_") - return parts[0] + "".join(p.title() for p in parts[1:] if p) - - def _name_input_model(fn_class: Any) -> type[BaseModel]: - """Return a copy of the function's Input model named `Input`.""" - from pydantic import create_model - - camel = _snake_to_camel(fn_class.name) - canonical = f"{camel}Input" + """The function's Input model under the canonical `Input` name.""" + canonical = f"{_snake_to_camel(fn_class.name)}Input" src = fn_class.Input if src.__name__ == canonical: return src - # Re-derive under the canonical name so codegen consumers see a stable name. return create_model(canonical, __base__=src) def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]: - """Return a copy of the model named `Output`.""" - from pydantic import create_model - - camel = _snake_to_camel(fn_class.name) - canonical = f"{camel}Output" + """`base` under the canonical `Output` name.""" + canonical = f"{_snake_to_camel(fn_class.name)}Output" if base.__name__ == canonical: return base return create_model(canonical, __base__=base) -# ─── Function / context / channel emission ────────────────────────────────── +def _bind( + sources: dict[str, tuple[str, Any]], name: str, source: tuple[str, Any] +) -> None: + """Claim `name` for one declaration. Two different declarations under one + name would emit two `type` blocks that no `ref` can tell apart, so the + second claim raises.""" + claimed = sources.setdefault(name, source) + if claimed != source: + raise ValueError( + f"named type '{name}' is claimed twice, by {claimed[1]!r} " + f"and by {source[1]!r}" + ) -def _function_props(fn_class: Any, output_type_name: str, output_nullable: bool) -> dict[str, Any]: - """Collect every value that goes inside a `function` block.""" +def _seed_named_types( + functions: dict[str, Any], channel_models: list[tuple[str, type[BaseModel]]] +) -> dict[str, tuple[str, Any]]: + """Name → ("struct", model) | ("alias", annotation) for every type the + function and channel sections reference directly.""" + seeds: dict[str, tuple[str, Any]] = {} + + for fn_class in functions.values(): + if _has_input(getattr(fn_class, "Input", None)): + named_input = _name_input_model(fn_class) + _bind(seeds, named_input.__name__, ("struct", named_input)) + + output_cls = getattr(fn_class, "Output", None) + if output_cls is None: + continue + output_name = f"{_snake_to_camel(fn_class.name)}Output" + inner, _ = extract_optional(output_cls) + wraps_model = ( + extract_list_element(inner) is None + and isinstance(inner, type) + and issubclass(inner, BaseModel) + ) + if wraps_model: + _bind(seeds, output_name, ("struct", _name_output_model(fn_class, inner))) + else: + _bind(seeds, output_name, ("alias", output_cls)) + + for type_name, model in channel_models: + _bind(seeds, type_name, ("struct", model)) + + return seeds + + +def _resolve_named_types(seeds: dict[str, tuple[str, Any]]) -> list[dict[str, Any]]: + """Resolve seeds to a fixed point — resolving one type discovers the models + it references, which are themselves resolved — then order by name.""" + sources = dict(seeds) + resolved: dict[str, dict[str, Any]] = {} + + while True: + unresolved = [name for name in sources if name not in resolved] + if not unresolved: + break + for name in unresolved: + kind, payload = sources[name] + refs: list[type[BaseModel]] = [] + if kind == "struct": + resolved[name] = { + "name": name, + "kind": "struct", + "fields": _struct_fields(payload, refs), + } + else: + resolved[name] = { + "name": name, + "kind": "alias", + "shape": _shape(payload, refs), + } + for model in refs: + _bind(sources, model.__name__, ("struct", model)) + + return [resolved[name] for name in sorted(resolved)] + + +# ─── Functions, contexts, channels ────────────────────────────────────────── + + +def _is_emitted(fn_class: Any) -> bool: meta = getattr(fn_class, "_meta", {}) - name = fn_class.name - camel = _snake_to_camel(name) - input_cls = getattr(fn_class, "Input", None) - has_input = _has_input(input_cls) - is_context = meta.get("context") - is_form = meta.get("form", False) + return not (meta.get("private") or meta.get("view_path")) + + +def _function_entry(fn_class: Any) -> dict[str, Any]: + meta = getattr(fn_class, "_meta", {}) + camel = _snake_to_camel(fn_class.name) + has_input = _has_input(getattr(fn_class, "Input", None)) + _, output_nullable = extract_optional(getattr(fn_class, "Output", None)) + context = meta.get("context") return { - "name": name, + "name": fn_class.name, "camel": camel, "has_input": has_input, "input_type": f"{camel}Input" if has_input else None, - "output_type": output_type_name, + "output_type": f"{camel}Output", "output_nullable": output_nullable, "transport": "websocket" if meta.get("websocket") else "http", - "context": is_context if isinstance(is_context, str) else None, - "affects": [a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"], + "context": context if isinstance(context, str) else None, + "affects": [ + a["name"] for a in meta.get("affects") or [] if a.get("type") == "context" + ], "merge": list(meta.get("merge") or []), - "is_form": bool(is_form), + "is_form": bool(meta.get("form", False)), "form_name": meta.get("form_name"), "form_role": meta.get("form_role"), } -def _resolve_output(fn_class: Any) -> tuple[str, bool]: - """Return `(output_type_name, output_nullable)` for an emitted function block.""" - camel = _snake_to_camel(fn_class.name) - canonical = f"{camel}Output" - output_cls = getattr(fn_class, "Output", None) - if output_cls is None: - return canonical, False - _, nullable = extract_optional(output_cls) - return canonical, nullable - - -def _collect_channels() -> list[dict[str, Any]]: - """Pull channel registrations from the optional `channels` registry extension.""" - from mizan_core.registry import _extensions # type: ignore[attr-defined] - - ext = _extensions.get("channels") - if ext is None: - return [] - schema = ext.schema() - return list(schema or []) - - -# ─── Top-level builder ────────────────────────────────────────────────────── - - -def build_ir() -> str: - """Build the Mizan IR for every registered function. Returns KDL source.""" - functions = get_all_functions() - context_groups = get_context_groups() - channels = _collect_channels() - - named_types = _collect_named_types(functions) - - lines: list[str] = [] - root = _Block(lines, indent=0) - - # ── Type definitions ── - for type_name in sorted(named_types): - shape = named_types[type_name] - with root.node("type", _kdl_string(type_name)) as type_block: - if isinstance(shape, _StructShape): - _emit_struct_type(type_block, shape.model, named_types) - elif isinstance(shape, _AliasShape): - _emit_alias_type(type_block, shape.annotation, named_types) - else: - raise TypeError(f"unknown named-type shape: {type(shape).__name__}") - - if named_types: - lines.append("") - - # ── Functions ── - # Alphabetical by wire name — the IR is a canonical contract, not a - # transcript of registration order. Both Python and Rust emitters sort - # so byte-equivalence holds across language-backed backends. - for fn_name in sorted(functions): - fn_class = functions[fn_name] - meta = getattr(fn_class, "_meta", {}) - if meta.get("private") or meta.get("view_path"): - continue - output_type_name, output_nullable = _resolve_output(fn_class) - props = _function_props(fn_class, output_type_name, output_nullable) - _emit_function(root, props) - - if functions: - lines.append("") - - # ── Contexts ── - # Alphabetical by context name — same reason as functions above. - for ctx_name in sorted(context_groups): - _emit_context(root, ctx_name, context_groups[ctx_name]) - - if context_groups: - lines.append("") - - # ── Channels ── - for channel in channels: - _emit_channel(root, channel) - - # Trim trailing blanks then add a single terminating newline. - while lines and not lines[-1]: - lines.pop() - return "\n".join(lines) + "\n" - - -def _emit_function(root: _Block, props: dict[str, Any]) -> None: - with root.node("function", _kdl_string(props["name"])) as block: - block.leaf("camel", _kdl_string(props["camel"])) - block.leaf("has-input", _kdl_bool(props["has_input"])) - if props["input_type"]: - block.leaf("input", _kdl_string(props["input_type"])) - block.leaf("output", _kdl_string(props["output_type"])) - if props["output_nullable"]: - block.leaf("output-nullable", _kdl_bool(True)) - block.leaf("transport", _kdl_string(props["transport"])) - if props["context"]: - block.leaf("context", _kdl_string(props["context"])) - for affect_name in props["affects"]: - block.leaf("affects", _kdl_string(affect_name)) - for merge_name in props["merge"]: - block.leaf("merge", _kdl_string(merge_name)) - if props["is_form"]: - block.leaf("is-form", _kdl_bool(True)) - if props["form_name"]: - block.leaf("form-name", _kdl_string(props["form_name"])) - if props["form_role"]: - block.leaf("form-role", _kdl_string(props["form_role"])) - - -def _emit_context(root: _Block, ctx_name: str, fn_names: list[str]) -> None: - # First pass: collect param info across every function in the context. - param_info: dict[str, dict[str, Any]] = {} - for fn_name in fn_names: - fn_class = get_function(fn_name) - if fn_class is None: - continue - input_cls = getattr(fn_class, "Input", None) - if not _has_input(input_cls): - continue - for param_name, field_info in input_cls.model_fields.items(): - slot = param_info.setdefault(param_name, {"type": None, "shared_by": []}) - slot["type"] = _annotation_to_primitive(field_info.annotation) - slot["shared_by"].append(fn_name) - - # A param is required iff every function in the context declares it. - for slot in param_info.values(): - slot["required"] = len(slot["shared_by"]) == len(fn_names) - - with root.node("context", _kdl_string(ctx_name)) as block: - # Members alphabetical — canonical order. - for fn_name in sorted(fn_names): - block.leaf("function", _kdl_string(fn_name)) - for param_name in sorted(param_info): - slot = param_info[param_name] - with block.node("param", _kdl_string(param_name)) as param_block: - param_block.leaf("type", _kdl_string(slot["type"])) - param_block.leaf("required", _kdl_bool(slot["required"])) - # `shared-by` follows the same canonical ordering. - for sharer in sorted(slot["shared_by"]): - param_block.leaf("shared-by", _kdl_string(sharer)) - - def _annotation_to_primitive(annotation: Any) -> str: inner, _ = extract_optional(annotation) if inner is int: @@ -570,13 +370,95 @@ def _annotation_to_primitive(annotation: Any) -> str: return "string" -def _emit_channel(root: _Block, channel: dict[str, Any]) -> None: - name = channel["name"] - with root.node("channel", _kdl_string(name)) as block: - block.leaf("pascal-name", _kdl_string(channel["pascalName"])) - if channel.get("hasParams") and channel.get("paramsType"): - block.leaf("params", _kdl_string(channel["paramsType"])) - if channel.get("hasReactMessage") and channel.get("reactMessageType"): - block.leaf("react-message", _kdl_string(channel["reactMessageType"])) - if channel.get("hasDjangoMessage") and channel.get("djangoMessageType"): - block.leaf("django-message", _kdl_string(channel["djangoMessageType"])) +def _context_entry(ctx_name: str, fn_names: list[str]) -> dict[str, Any]: + param_info: dict[str, dict[str, Any]] = {} + for fn_name in fn_names: + input_cls = getattr(get_function(fn_name), "Input", None) + if not _has_input(input_cls): + continue + for param_name, field_info in input_cls.model_fields.items(): + slot = param_info.setdefault(param_name, {"shared_by": []}) + slot["type"] = _annotation_to_primitive(field_info.annotation) + slot["shared_by"].append(fn_name) + + return { + "name": ctx_name, + "functions": sorted(fn_names), + "params": [ + { + "name": param_name, + "type": param_info[param_name]["type"], + # A param is required iff every function in the context takes it. + "required": len(param_info[param_name]["shared_by"]) == len(fn_names), + "shared_by": sorted(param_info[param_name]["shared_by"]), + } + for param_name in sorted(param_info) + ], + } + + +_CHANNEL_SLOTS = ( + ("Params", "params"), + ("ClientMessage", "client_message"), + ("ServerMessage", "server_message"), +) + + +def _collect_channels() -> tuple[ + list[dict[str, Any]], list[tuple[str, type[BaseModel]]] +]: + """Channel blocks in wire-name order, plus the (emitted type name, model) + pair each declared slot resolves to.""" + channel_classes = get_registry().get("channels", {}) + records: list[dict[str, Any]] = [] + models: list[tuple[str, type[BaseModel]]] = [] + + for wire_name in sorted(channel_classes): + channel_class = channel_classes[wire_name] + pascal = wire_to_pascal(wire_name) + record: dict[str, Any] = {"name": wire_name, "pascal_name": pascal} + for attribute, slot in _CHANNEL_SLOTS: + declared = getattr(channel_class, attribute, None) + if declared is None: + record[slot] = None + continue + if not (isinstance(declared, type) and issubclass(declared, BaseModel)): + raise TypeError( + f"channel '{wire_name}' declares {attribute} as {declared!r}, " + f"which is not a pydantic BaseModel subclass" + ) + type_name = f"{pascal}{attribute}" + record[slot] = type_name + models.append((type_name, declared)) + records.append(record) + + return records, models + + +# ─── Top-level builder ────────────────────────────────────────────────────── + + +def build_ir() -> str: + """Build the Mizan IR for every registered function. Returns KDL source. + + An empty registry renders the empty document — zero bytes, zero nodes. + """ + functions = get_all_functions() + context_groups = get_context_groups() + channels, channel_models = _collect_channels() + + # Every section is sorted by name, so the document does not depend on the + # order things were registered in. + return _ENV.get_template("ir/document.kdl.j2").render( + types=_resolve_named_types(_seed_named_types(functions, channel_models)), + functions=[ + _function_entry(functions[name]) + for name in sorted(functions) + if _is_emitted(functions[name]) + ], + contexts=[ + _context_entry(name, context_groups[name]) + for name in sorted(context_groups) + ], + channels=channels, + ) diff --git a/cores/mizan-python/src/mizan_core/registry.py b/cores/mizan-python/src/mizan_core/registry.py index 81e2119..9cd8f16 100644 --- a/cores/mizan-python/src/mizan_core/registry.py +++ b/cores/mizan-python/src/mizan_core/registry.py @@ -1,17 +1,12 @@ """ -Mizan core registry — function and composition registration with an -extension hook for backend-specific registries (channels, forms, etc.) -to plug into. - -This is the framework-agnostic registry. Backends own their own -type-specific registries (channels in Django Channels, forms in Django -Forms, websockets in FastAPI, etc.) and register them as extensions -here so the unified schema export can include them. +Mizan core registry — function and composition registration, plus an +extension hook backend-specific registries (channels, forms, …) plug into. """ from __future__ import annotations -from typing import Any, Callable, Protocol +import abc +from typing import Any, Callable # ─── Core registries ──────────────────────────────────────────────────────── @@ -22,17 +17,23 @@ _compositions: dict[str, Any] = {} # ─── Extension hook ───────────────────────────────────────────────────────── -class RegistryExtension(Protocol): +class RegistryExtension(abc.ABC): """ - Backend-specific registries plug into core via this Protocol. - - Each extension owns its own registry of backend-shaped registrations - (channels, forms, websocket consumers, etc.) and contributes a schema - subdict to the unified schema export. + A backend registry of its own registrations (channels, forms, websocket + consumers, …) contributing one subdict to the unified schema export. """ - def schema(self) -> dict[str, Any]: ... - def clear(self) -> None: ... + @abc.abstractmethod + def all(self) -> dict[str, Any]: + """The live registry: registered name → registered class.""" + + @abc.abstractmethod + def schema(self) -> dict[str, Any]: + """The schema subdict exported under this extension's name.""" + + @abc.abstractmethod + def clear(self) -> None: + """Drop every registration held by this extension.""" _extensions: dict[str, RegistryExtension] = {} @@ -146,10 +147,7 @@ def get_registry() -> dict[str, Any]: "compositions": _compositions.copy(), } for name, ext in _extensions.items(): - # Extensions optionally expose their backing dict via .all() - # (Protocol doesn't require it; only schema() and clear() are mandatory) - if hasattr(ext, "all"): - out[name] = ext.all() + out[name] = ext.all() return out diff --git a/cores/mizan-python/src/mizan_core/templates/ir/document.kdl.j2 b/cores/mizan-python/src/mizan_core/templates/ir/document.kdl.j2 new file mode 100644 index 0000000..1440fdf --- /dev/null +++ b/cores/mizan-python/src/mizan_core/templates/ir/document.kdl.j2 @@ -0,0 +1,121 @@ +{% macro literal(lit) %} +{%- if lit.kind == "string" -%} +{{ lit.value | kdl }} +{%- elif lit.kind == "bool" -%} +{{ lit.value | kdlbool }} +{%- else -%} +{{ lit.value }} +{%- endif %} +{%- endmacro %} +{% macro shape(node, depth) %} +{%- set pad = " " * depth %} +{%- if node.kind == "primitive" %} +{{ pad }}primitive {{ node.name | kdl }} +{%- elif node.kind == "ref" %} +{{ pad }}ref {{ node.name | kdl }} +{%- elif node.kind == "enum" %} +{{ pad }}enum {{ node["values"] | map("kdl") | join(" ") }} +{%- elif node.kind == "list" %} +{{ pad }}list { +{{ shape(node.of, depth + 1) }} +{{ pad }}} +{%- elif node.kind == "optional" %} +{{ pad }}optional { +{{ shape(node.of, depth + 1) }} +{{ pad }}} +{%- elif node.kind == "union" %} +{{ pad }}union { +{% for branch in node.branches %} +{{ shape(branch, depth + 1) }} +{% endfor %} +{{ pad }}} +{%- endif %} +{%- endmacro %} +{% for type in types %} +type {{ type.name | kdl }} { +{% if type.kind == "struct" %} + struct { +{% for field in type.fields %} + field {{ field.name | kdl }}{% if not field.required %} required={{ field.required | kdlbool }}{% endif %}{% if field.default %} default={{ literal(field.default) }}{% endif %} { +{{ shape(field.shape, 3) }} + } +{% endfor %} + } +{% else %} + alias { +{{ shape(type.shape, 2) }} + } +{% endif %} +} +{% endfor %} +{% if types and (functions or contexts or channels) %} + +{% endif %} +{% for fn in functions %} +function {{ fn.name | kdl }} { + camel {{ fn.camel | kdl }} + has-input {{ fn.has_input | kdlbool }} +{% if fn.input_type %} + input {{ fn.input_type | kdl }} +{% endif %} + output {{ fn.output_type | kdl }} +{% if fn.output_nullable %} + output-nullable {{ fn.output_nullable | kdlbool }} +{% endif %} + transport {{ fn.transport | kdl }} +{% if fn.context %} + context {{ fn.context | kdl }} +{% endif %} +{% for affected in fn.affects %} + affects {{ affected | kdl }} +{% endfor %} +{% for merged in fn.merge %} + merge {{ merged | kdl }} +{% endfor %} +{% if fn.is_form %} + is-form {{ fn.is_form | kdlbool }} +{% if fn.form_name %} + form-name {{ fn.form_name | kdl }} +{% endif %} +{% if fn.form_role %} + form-role {{ fn.form_role | kdl }} +{% endif %} +{% endif %} +} +{% endfor %} +{% if functions and (contexts or channels) %} + +{% endif %} +{% for context in contexts %} +context {{ context.name | kdl }} { +{% for fn_name in context.functions %} + function {{ fn_name | kdl }} +{% endfor %} +{% for param in context.params %} + param {{ param.name | kdl }} { + type {{ param.type | kdl }} + required {{ param.required | kdlbool }} +{% for sharer in param.shared_by %} + shared-by {{ sharer | kdl }} +{% endfor %} + } +{% endfor %} +} +{% endfor %} +{% if contexts and channels %} + +{% endif %} +{% for channel in channels %} +channel {{ channel.name | kdl }} { + pascal-name {{ channel.pascal_name | kdl }} +{% if channel.params %} + params {{ channel.params | kdl }} +{% endif %} +{% if channel.client_message %} + client-message {{ channel.client_message | kdl }} +{% endif %} +{% if channel.server_message %} + server-message {{ channel.server_message | kdl }} +{% endif %} +} +{% endfor %} diff --git a/cores/mizan-python/src/mizan_core/type_utils.py b/cores/mizan-python/src/mizan_core/type_utils.py index 4b91cbb..04bb158 100644 --- a/cores/mizan-python/src/mizan_core/type_utils.py +++ b/cores/mizan-python/src/mizan_core/type_utils.py @@ -1,10 +1,4 @@ -""" -Type-introspection helpers shared across backend adapters. - -Both mizan-django and mizan-fastapi need to walk @client-decorated function -annotations the same way during schema export. Drift here breaks AFI parity, -so the helpers live in core. -""" +"""Annotation-introspection helpers used when walking @client function signatures.""" from __future__ import annotations @@ -28,9 +22,8 @@ def extract_optional(annotation: Any) -> tuple[Any, bool]: Returns `(T, True)` for a union containing exactly one non-None member and `None` itself. For anything else, returns `(annotation, False)`. - Multi-arm unions like `A | B | None` are returned as-is — protocol-level - discriminated unions aren't supported yet, and silently picking one arm - would hide that. + A multi-arm union like `A | B | None` is returned as-is — picking one arm + would silently discard the others. """ origin = get_origin(annotation) if origin is Union or isinstance(annotation, types.UnionType): @@ -80,10 +73,9 @@ def is_structured_output(annotation: Any) -> bool: def types_match_for_merge(slot_type: Any, value_type: Any) -> bool: - """True if a `value_type` mutation return can splice into a `slot_type` context slot. + """True if a `value_type` mutation return can splice into a `slot_type` slot. - Used by backend dispatch to resolve `@client(merge=ctx)` to a concrete - function-name slot inside the context bundle. Three shapes match: + Three shapes match: - direct: slot is `T`, value is `T` → replace - upsert: slot is `list[T]`, value is `T` → upsert by id diff --git a/cores/mizan-python/tests/test_ir.py b/cores/mizan-python/tests/test_ir.py new file mode 100644 index 0000000..8f835f5 --- /dev/null +++ b/cores/mizan-python/tests/test_ir.py @@ -0,0 +1,107 @@ +"""Unit tests for KDL IR emission. Every assertion runs on a real KDL parse tree.""" + +from unittest import TestCase + +import ckdl +from pydantic import BaseModel + +from mizan_core.client.function import client +from mizan_core.ir import build_ir +from mizan_core.registry import clear_registry, register + + +class Prefs(BaseModel): + live: bool = True + + +class Settings(BaseModel): + meta: Prefs = Prefs() + label: str = "plain" + quoted: str = 'a "b" \\ c\nd\te' + retries: int = 3 + ratio: float = 0.5 + enabled: bool = False + tags: list[str] = [] + mapping: dict[str, str] = {} + note: str | None = None + who: str + + +def _struct_fields(document: ckdl.Document, type_name: str) -> dict[str, ckdl.Node]: + """Field nodes of the named struct, keyed by field name.""" + for node in document.nodes: + if node.name == "type" and node.args[0] == type_name: + for child in node.children: + if child.name == "struct": + return {field.args[0]: field for field in child.children} + raise AssertionError(f"no struct type {type_name!r} in:\n{document}") + + +class EmptyDocumentTests(TestCase): + """The IR of an empty registry.""" + + def setUp(self): + clear_registry() + + def tearDown(self): + clear_registry() + + def test_empty_registry_emits_zero_bytes(self): + """Nothing registered renders the empty document, not a blank line.""" + self.assertEqual(build_ir(), "") + + def test_empty_document_parses_to_zero_nodes(self): + """The empty document is valid KDL carrying no nodes.""" + self.assertEqual(len(ckdl.parse(build_ir()).nodes), 0) + + +class FieldDefaultTests(TestCase): + """Which Pydantic field defaults reach the document as KDL literals.""" + + def setUp(self): + clear_registry() + + @client + def get_settings(request) -> Settings: + return Settings(who="anyone") + + register(get_settings, "get_settings") + self.fields = _struct_fields( + ckdl.parse(build_ir()), "getSettingsOutput" + ) + + def tearDown(self): + clear_registry() + + def test_scalar_defaults_survive_the_round_trip(self): + """bool, int, float and str defaults parse back to the Python values.""" + self.assertEqual(self.fields["label"].properties["default"], "plain") + self.assertEqual(self.fields["retries"].properties["default"], 3) + self.assertEqual(self.fields["ratio"].properties["default"], 0.5) + self.assertEqual(self.fields["enabled"].properties["default"], False) + + def test_string_default_escapes_round_trip(self): + """Quotes, backslashes and control characters survive KDL escaping.""" + self.assertEqual( + self.fields["quoted"].properties["default"], Settings.model_fields["quoted"].default + ) + + def test_model_valued_default_carries_no_literal(self): + """A nested-model default has no KDL scalar form, so no `default` is emitted.""" + meta = self.fields["meta"] + self.assertNotIn("default", meta.properties) + self.assertIs(meta.properties["required"], False) + + def test_container_defaults_carry_no_literal(self): + """List and dict defaults have no KDL scalar form either.""" + self.assertNotIn("default", self.fields["tags"].properties) + self.assertNotIn("default", self.fields["mapping"].properties) + + def test_none_default_carries_no_literal(self): + """`= None` leaves the optional shape to say the field may be absent.""" + self.assertNotIn("default", self.fields["note"].properties) + + def test_required_field_has_no_required_property(self): + """A required field is the default, so the property is left off entirely.""" + self.assertNotIn("required", self.fields["who"].properties) + self.assertNotIn("default", self.fields["who"].properties) diff --git a/cores/mizan-rust-macros/src/channel.rs b/cores/mizan-rust-macros/src/channel.rs new file mode 100644 index 0000000..9d3dc30 --- /dev/null +++ b/cores/mizan-rust-macros/src/channel.rs @@ -0,0 +1,111 @@ +//! `#[mizan::channel("", params = T, client_message = T, +//! server_message = T)]` — emit the linkme `ChannelEntry` registration for a +//! unit struct. Every slot is optional; only the declared ones register, and +//! each slot type must implement `MizanType` (via `#[derive(Mizan)]`). + +use heck::ToShoutySnakeCase; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{ + parse::{Parse, ParseStream}, + ItemStruct, LitStr, Path, Token, +}; + +mod kw { + syn::custom_keyword!(params); + syn::custom_keyword!(client_message); + syn::custom_keyword!(server_message); +} + +/// Attribute args: the wire name, then the slot types the channel declares. +pub struct ChannelArgs { + pub wire_name: String, + pub params: Option, + pub client_message: Option, + pub server_message: Option, +} + +impl Parse for ChannelArgs { + fn parse(input: ParseStream) -> syn::Result { + let name: LitStr = input.parse()?; + let mut out = Self { + wire_name: name.value(), + params: None, + client_message: None, + server_message: None, + }; + while input.peek(Token![,]) { + input.parse::()?; + if input.is_empty() { + break; + } + if input.peek(kw::params) { + input.parse::()?; + input.parse::()?; + out.params = Some(input.parse()?); + } else if input.peek(kw::client_message) { + input.parse::()?; + input.parse::()?; + out.client_message = Some(input.parse()?); + } else if input.peek(kw::server_message) { + input.parse::()?; + input.parse::()?; + out.server_message = Some(input.parse()?); + } else { + return Err(input.error( + "expected a channel slot: params, client_message, or server_message", + )); + } + } + Ok(out) + } +} + +pub fn expand(args: ChannelArgs, item: ItemStruct) -> TokenStream { + if !item.fields.is_empty() { + return syn::Error::new_spanned( + &item.fields, + "#[mizan::channel] requires a unit struct — the payload types are declared in the attribute.", + ) + .to_compile_error(); + } + + let ident = item.ident.clone(); + let wire_name = args.wire_name; + + // Slots register in the order the IR emits them: params, client-message, + // server-message. + let mut slot_exprs: Vec = Vec::new(); + for (kind, declared) in [ + (format_ident!("Params"), args.params), + (format_ident!("ClientMessage"), args.client_message), + (format_ident!("ServerMessage"), args.server_message), + ] { + if let Some(ty) = declared { + slot_exprs.push(quote! { + ::mizan_core::ChannelSlot { + kind: ::mizan_core::ChannelSlotKind::#kind, + shape_fn: <#ty as ::mizan_core::MizanType>::shape, + } + }); + } + } + + let register_static = format_ident!( + "__MIZAN_CHANNEL_REGISTER_{}", + ident.to_string().to_shouty_snake_case() + ); + + quote! { + #item + + #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::CHANNELS)] + #[linkme(crate = ::mizan_core::__priv::linkme)] + static #register_static: ::mizan_core::ChannelEntry = ::mizan_core::ChannelEntry { + name: #wire_name, + slots: &[ + #(#slot_exprs),* + ], + }; + } +} diff --git a/cores/mizan-rust-macros/src/context.rs b/cores/mizan-rust-macros/src/context.rs index d334e54..48eaa19 100644 --- a/cores/mizan-rust-macros/src/context.rs +++ b/cores/mizan-rust-macros/src/context.rs @@ -6,33 +6,33 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token}; -/// Attribute args: either nothing, or one string literal that overrides the -/// derived snake_case context name. -pub struct ContextArgs { - pub explicit_name: Option, +/// Where the context's wire name comes from: the attribute, or the struct's +/// own identifier when the attribute names none. +pub enum ContextName { + Explicit(String), + FromIdent, } -impl ContextArgs { +impl ContextName { + /// Both `#[mizan::context("user")]` (bare string literal) and + /// `#[mizan::context(name = "user")]` name the context explicitly. pub fn parse(attr_tokens: TokenStream) -> syn::Result { if attr_tokens.is_empty() { - return Ok(Self { explicit_name: None }); + return Ok(ContextName::FromIdent); } - // Support both `#[mizan::context("user")]` (string literal) and - // `#[mizan::context(name = "user")]` (key=value). if let Ok(lit) = syn::parse2::(attr_tokens.clone()) { - return Ok(Self { - explicit_name: Some(lit.value()), - }); + return Ok(ContextName::Explicit(lit.value())); } let parser = Punctuated::::parse_terminated; let metas = parser.parse2(attr_tokens)?; for meta in metas { if let Meta::NameValue(nv) = meta { if nv.path.is_ident("name") { - if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value { - return Ok(Self { - explicit_name: Some(s.value()), - }); + if let syn::Expr::Lit(syn::ExprLit { + lit: Lit::Str(s), .. + }) = nv.value + { + return Ok(ContextName::Explicit(s.value())); } } } @@ -42,9 +42,16 @@ impl ContextArgs { "expected `#[mizan::context]` or `#[mizan::context(\"\")]` or `#[mizan::context(name = \"\")]`", )) } + + fn resolve(self, ident: &syn::Ident) -> String { + match self { + ContextName::Explicit(name) => name, + ContextName::FromIdent => ident.to_string().to_snake_case(), + } + } } -pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream { +pub fn expand(name: ContextName, item: ItemStruct) -> TokenStream { if !item.fields.is_empty() { return syn::Error::new_spanned( &item.fields, @@ -54,9 +61,7 @@ pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream { } let ident = item.ident.clone(); - let name = args - .explicit_name - .unwrap_or_else(|| ident.to_string().to_snake_case()); + let name = name.resolve(&ident); let register_static = format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase()); diff --git a/cores/mizan-rust-macros/src/derive.rs b/cores/mizan-rust-macros/src/derive.rs index d776055..b4c18af 100644 --- a/cores/mizan-rust-macros/src/derive.rs +++ b/cores/mizan-rust-macros/src/derive.rs @@ -1,18 +1,20 @@ //! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration. use heck::{ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase}; -use proc_macro2::TokenStream; -use quote::quote; +use proc_macro2::{TokenStream, TokenTree}; +use quote::{format_ident, quote}; use syn::{ - parse::Parser, punctuated::Punctuated, Data, DataEnum, DataStruct, DeriveInput, Fields, Lit, - Meta, Token, + parse::{Parse, ParseStream}, + Data, DeriveInput, Field, Fields, FieldsNamed, Ident, Lit, Meta, Type, }; -use crate::shape::type_shape_expr; +use crate::shape::{is_optional, type_shape_expr}; /// Apply a `#[serde(rename_all = "...")]` casing transform to a Rust -/// variant identifier so the IR's enum variant matches what serde emits -/// on the wire. Supported casings mirror serde's set. +/// variant identifier so the IR's enum variant matches what serde emits on +/// the wire. Supported casings mirror serde's set; any other rule — including +/// the empty rule an undecorated enum carries — leaves the identifier as +/// written. fn apply_rename_all(rule: &str, ident: &str) -> String { match rule { "lowercase" => ident.to_lowercase(), @@ -26,87 +28,210 @@ fn apply_rename_all(rule: &str, ident: &str) -> String { } } -/// Walk the enum's outer attributes for `#[serde(rename_all = "...")]`. -fn serde_rename_all(attrs: &[syn::Attribute]) -> Option { +/// The string a `#[serde( = "...")]` entry in `attrs` carries, or +/// `fallback` when no entry names `key`. serde owns that attribute's grammar +/// and its own derive reports a malformed body, so a body without the +/// ` = ` triple reads here as "no override". +fn serde_string(attrs: &[syn::Attribute], key: &str, fallback: String) -> String { for attr in attrs { if !attr.path().is_ident("serde") { continue; } - let list = match &attr.meta { - Meta::List(l) => l, - _ => continue, - }; - let parser = Punctuated::::parse_terminated; - let metas = match parser.parse2(list.tokens.clone()) { - Ok(m) => m, - Err(_) => continue, - }; - for meta in metas { - if let Meta::NameValue(nv) = meta { - if nv.path.is_ident("rename_all") { - if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value { - return Some(s.value()); - } - } - } - } - } - None -} - -/// Walk a variant's attributes for an explicit `#[serde(rename = "...")]` -/// override. Variant-level rename overrides the enum-level rename_all. -fn serde_rename(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if !attr.path().is_ident("serde") { + let Meta::List(list) = &attr.meta else { continue; - } - let list = match &attr.meta { - Meta::List(l) => l, - _ => continue, }; - let parser = Punctuated::::parse_terminated; - let metas = match parser.parse2(list.tokens.clone()) { - Ok(m) => m, - Err(_) => continue, - }; - for meta in metas { - if let Meta::NameValue(nv) = meta { - if nv.path.is_ident("rename") { - if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value { - return Some(s.value()); + let mut on_key = false; + let mut on_value = false; + for tree in list.tokens.clone() { + match tree { + TokenTree::Ident(ident) => { + on_key = ident == key; + on_value = false; + } + TokenTree::Punct(punct) => { + on_value = on_key && punct.as_char() == '='; + } + TokenTree::Literal(literal) => { + if on_value { + if let Lit::Str(s) = Lit::new(literal) { + return s.value(); + } } + on_key = false; + on_value = false; + } + TokenTree::Group(_) => { + on_key = false; + on_value = false; } } } } - None + fallback } -/// Expand `#[derive(Mizan)]`. Emits the `MizanType` impl AND a linkme -/// TypeEntry registration. Every Mizan-shaped type lands in the IR; -/// the emitter's inline-substitution pass collapses primitive-aliases -/// and enums at use sites so the IR stays tight. -pub fn expand(input: DeriveInput) -> TokenStream { - let ident = input.ident.clone(); +/// A braced struct field paired with the identifier it carries. `all` is the +/// only constructor and it reads a `FieldsNamed` group, so `ident` is a total +/// accessor rather than an Option the caller has to open. +struct NamedField<'a> { + ident: &'a Ident, + field: &'a Field, +} + +impl<'a> NamedField<'a> { + fn all(braced: &'a FieldsNamed) -> impl Iterator { + braced + .named + .iter() + .flat_map(|field| field.ident.as_ref().map(|ident| Self { ident, field })) + } + + /// The wire name serde emits: a `#[serde(rename)]` override, else the + /// identifier with serde's `r#` raw-prefix stripping applied. + fn wire_name(&self) -> String { + let raw_ident = self.ident.to_string(); + let default = raw_ident.trim_start_matches("r#").to_string(); + serde_string(&self.field.attrs, "rename", default) + } +} + +/// One struct field reduced to what the IR carries: the name serde puts on the +/// wire and the declared Rust type. +struct FieldShape { + wire_name: String, + ty: Type, +} + +/// The two type forms the IR can express. +enum DerivedShape { + Struct(Vec), + Enum(Vec), +} + +/// A derive input already reduced to the IR form its body takes. The token +/// stream is parsed straight into this shape, so `expand` reads a settled +/// name and body and has nothing left to reject. +pub struct MizanDerive { + ident: Ident, + shape: DerivedShape, +} + +impl Parse for MizanDerive { + fn parse(input: ParseStream) -> syn::Result { + let input: DeriveInput = input.parse()?; + let shape = match &input.data { + Data::Struct(s) => { + let braced = match &s.fields { + Fields::Named(named) => named, + Fields::Unnamed(_) => { + return Err(syn::Error::new_spanned( + &s.fields, + "#[derive(Mizan)] requires named fields. Tuple structs aren't part of the IR shape.", + )); + } + Fields::Unit => { + return Err(syn::Error::new_spanned( + &s.fields, + "#[derive(Mizan)] requires named fields. Unit structs aren't part of the IR shape.", + )); + } + }; + let mut fields = Vec::new(); + for named in NamedField::all(braced) { + fields.push(FieldShape { + wire_name: named.wire_name(), + ty: named.field.ty.clone(), + }); + } + DerivedShape::Struct(fields) + } + Data::Enum(e) => { + let rename_all = serde_string(&input.attrs, "rename_all", String::new()); + let mut variants = Vec::new(); + for variant in &e.variants { + match &variant.fields { + Fields::Unit => {} + Fields::Named(_) => { + return Err(syn::Error::new_spanned( + &variant.fields, + "#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Struct variants aren't expressible in the current IR.", + )); + } + Fields::Unnamed(_) => { + return Err(syn::Error::new_spanned( + &variant.fields, + "#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Tuple variants aren't expressible in the current IR.", + )); + } + } + // Variant-level `rename` wins over the enum-level + // `rename_all` rule. + let default = apply_rename_all(&rename_all, &variant.ident.to_string()); + variants.push(serde_string(&variant.attrs, "rename", default)); + } + DerivedShape::Enum(variants) + } + Data::Union(_) => { + return Err(syn::Error::new_spanned( + &input, + "#[derive(Mizan)] does not support `union` types — use a struct or enum.", + )); + } + }; + Ok(Self { + ident: input.ident, + shape, + }) + } +} + +/// Build the `NamedType` expression the generated `shape()` returns. +fn named_type_expr(shape: &DerivedShape) -> TokenStream { + match shape { + DerivedShape::Struct(fields) => { + let field_exprs: Vec = fields + .iter() + .map(|field| { + let name = &field.wire_name; + // A Rust struct-field declaration carries no default + // expression, so `default` is always None and `required` + // follows the Option wrapper. + let required = !is_optional(&field.ty); + let shape = type_shape_expr(&field.ty); + quote! { + ::mizan_core::StructField { + name: #name, + required: #required, + default: ::std::option::Option::None, + shape: #shape, + } + } + }) + .collect(); + quote! { + ::mizan_core::NamedType::Struct(::std::vec![ + #(#field_exprs),* + ]) + } + } + DerivedShape::Enum(variants) => quote! { + ::mizan_core::NamedType::Enum(::std::vec![ + #(#variants),* + ]) + }, + } +} + +/// Expand `#[derive(Mizan)]` — the `MizanType` impl plus the linkme +/// `TypeEntry` registration for the derived type. +pub fn expand(derived: MizanDerive) -> TokenStream { + let MizanDerive { ident, shape } = derived; + let named_type_body = named_type_expr(&shape); let type_name = ident.to_string(); - - let rename_all = serde_rename_all(&input.attrs); - - let named_type_body = match &input.data { - Data::Struct(s) => emit_struct(s), - Data::Enum(e) => emit_enum(e, rename_all.as_deref()), - Data::Union(_) => { - return syn::Error::new_spanned( - &input, - "#[derive(Mizan)] does not support `union` types — use a struct or enum.", - ) - .to_compile_error(); - } - }; - - let register_static = - quote::format_ident!("__MIZAN_TYPE_REGISTER_{}", ident.to_string().to_shouty_snake_case()); + let register_static = format_ident!( + "__MIZAN_TYPE_REGISTER_{}", + type_name.to_shouty_snake_case() + ); quote! { impl ::mizan_core::MizanType for #ident { @@ -123,84 +248,3 @@ pub fn expand(input: DeriveInput) -> TokenStream { }; } } - -fn emit_struct(s: &DataStruct) -> TokenStream { - let fields = match &s.fields { - Fields::Named(named) => &named.named, - Fields::Unnamed(_) | Fields::Unit => { - return syn::Error::new_spanned( - &s.fields, - "#[derive(Mizan)] requires named fields. Tuple structs and unit structs aren't part of the IR shape.", - ) - .to_compile_error(); - } - }; - - let mut field_exprs: Vec = Vec::new(); - for field in fields { - let ident = field - .ident - .as_ref() - .expect("named field always has an ident"); - // Field-level `#[serde(rename = "...")]` wins; otherwise strip - // the raw-identifier prefix that Rust uses to escape keywords - // (`r#type` → `type`). Serde itself strips the prefix when - // computing the default field name; the IR has to match the - // wire form, not the Rust source form. - let raw_ident = ident.to_string(); - let stripped = raw_ident.strip_prefix("r#").unwrap_or(&raw_ident); - let name = serde_rename(&field.attrs).unwrap_or_else(|| stripped.to_string()); - let shape = type_shape_expr(&field.ty); - - // A field is `required` iff its type is not `Option<...>`. Defaults - // are not encodable from Rust syntax (no `= expr` on a struct field - // declaration) — the macro emits `required: false, default: None` - // for Option-wrapped fields, leaving defaults for a future - // attribute-based extension. - let is_optional = crate::shape::unwrap_option(&field.ty).is_some(); - let required = !is_optional; - field_exprs.push(quote! { - ::mizan_core::StructField { - name: #name, - required: #required, - default: ::std::option::Option::None, - shape: #shape, - } - }); - } - - quote! { - ::mizan_core::NamedType::Struct(::std::vec![ - #(#field_exprs),* - ]) - } -} - -fn emit_enum(e: &DataEnum, rename_all: Option<&str>) -> TokenStream { - let mut variants: Vec = Vec::new(); - for variant in &e.variants { - if !matches!(variant.fields, Fields::Unit) { - return syn::Error::new_spanned( - &variant.fields, - "#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Variants with payload aren't expressible in the current IR.", - ) - .to_compile_error(); - } - let raw = variant.ident.to_string(); - // Variant-level `#[serde(rename = "...")]` wins; otherwise apply - // the enum-level `#[serde(rename_all = "...")]` rule. - let name = if let Some(explicit) = serde_rename(&variant.attrs) { - explicit - } else if let Some(rule) = rename_all { - apply_rename_all(rule, &raw) - } else { - raw - }; - variants.push(quote! { #name }); - } - quote! { - ::mizan_core::NamedType::Enum(::std::vec![ - #(#variants),* - ]) - } -} diff --git a/cores/mizan-rust-macros/src/function.rs b/cores/mizan-rust-macros/src/function.rs index 45fa73f..3945b49 100644 --- a/cores/mizan-rust-macros/src/function.rs +++ b/cores/mizan-rust-macros/src/function.rs @@ -2,7 +2,7 @@ //! * a synthetic Input struct (`Input`) when the fn has params //! * `MizanType` impl on the Input struct //! * canonical type entries (`Input` / `Output`) -//! * Vec-element sub-type entries (so `Vec` outputs surface `T` too) +//! * list-element sub-type entries (so `Vec` outputs surface `T` too) //! * `FunctionSpec` impl on a ZST `__MizanFn_` //! * `FUNCTIONS` linkme registration of `&__MIZAN_FN__INSTANCE` @@ -10,13 +10,25 @@ use heck::{ToLowerCamelCase, ToShoutySnakeCase}; use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::{ - parse::Parser, + parenthesized, + parse::{Parse, ParseStream}, punctuated::Punctuated, - spanned::Spanned, - Expr, ExprPath, ExprTuple, FnArg, ItemFn, Meta, Pat, Path, ReturnType, Token, Type, + token::Paren, + FnArg, Ident, ItemFn, Pat, Path, ReturnType, Token, Type, }; -use crate::shape::{analyze_return, primitive_of, type_shape_expr, unwrap_option}; +use crate::shape::{ + analyze_return, classify, is_optional, path_head, ref_shape_expr, type_shape_expr, Head, + ReturnForm, TypeForm, +}; + +mod kw { + syn::custom_keyword!(context); + syn::custom_keyword!(affects); + syn::custom_keyword!(merge); + syn::custom_keyword!(websocket); + syn::custom_keyword!(private); +} /// Parsed attribute args for `#[mizan(...)]`. #[derive(Default)] @@ -28,125 +40,149 @@ pub struct FunctionArgs { pub private: bool, } -impl FunctionArgs { - pub fn parse(attr_tokens: TokenStream) -> syn::Result { - if attr_tokens.is_empty() { - return Ok(Self::default()); - } - let parser = Punctuated::::parse_terminated; - let metas = parser.parse2(attr_tokens)?; +impl Parse for FunctionArgs { + fn parse(input: ParseStream) -> syn::Result { let mut out = Self::default(); - for meta in metas { - match meta { - Meta::NameValue(nv) => { - if nv.path.is_ident("context") { - out.context = Some(expect_path(&nv.value)?); - } else if nv.path.is_ident("affects") { - out.affects = collect_paths(&nv.value)?; - } else if nv.path.is_ident("merge") { - out.merge = collect_paths(&nv.value)?; - } else { - return Err(syn::Error::new_spanned( - nv.path, - "unknown attribute key; expected one of: context, affects, merge", - )); - } - } - Meta::Path(p) => { - if p.is_ident("websocket") { - out.websocket = true; - } else if p.is_ident("private") { - out.private = true; - } else { - return Err(syn::Error::new_spanned( - p, - "unknown flag; expected `websocket` or `private`", - )); - } - } - Meta::List(l) => { - return Err(syn::Error::new_spanned( - l, - "list-shaped attribute args not supported here", - )); - } + while !input.is_empty() { + if input.peek(kw::context) { + input.parse::()?; + input.parse::()?; + out.context = Some(input.parse()?); + } else if input.peek(kw::affects) { + input.parse::()?; + input.parse::()?; + out.affects = parse_path_group(input)?; + } else if input.peek(kw::merge) { + input.parse::()?; + input.parse::()?; + out.merge = parse_path_group(input)?; + } else if input.peek(kw::websocket) { + input.parse::()?; + out.websocket = true; + } else if input.peek(kw::private) { + input.parse::()?; + out.private = true; + } else { + return Err(input.error( + "expected one of: `context = T`, `affects = T`, `merge = T`, `websocket`, `private`", + )); } + if input.is_empty() { + break; + } + input.parse::()?; } - if out.context.is_some() && !out.affects.is_empty() { - return Err(syn::Error::new_spanned( - out.context.as_ref().unwrap(), - "`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.", - )); - } - if out.context.is_some() && !out.merge.is_empty() { - return Err(syn::Error::new_spanned( - out.context.as_ref().unwrap(), - "`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.", - )); + if let Some(ctx) = &out.context { + if !out.affects.is_empty() { + return Err(syn::Error::new_spanned( + ctx, + "`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.", + )); + } + if !out.merge.is_empty() { + return Err(syn::Error::new_spanned( + ctx, + "`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.", + )); + } } Ok(out) } } -fn expect_path(expr: &Expr) -> syn::Result { - if let Expr::Path(ExprPath { path, .. }) = expr { - Ok(path.clone()) +/// One context type (`affects = Ctx`) or a parenthesized group of them +/// (`affects = (CtxA, CtxB)`). +fn parse_path_group(input: ParseStream) -> syn::Result> { + if input.peek(Paren) { + let group; + parenthesized!(group in input); + Ok(Punctuated::::parse_terminated(&group)? + .into_iter() + .collect()) } else { - Err(syn::Error::new_spanned( - expr, - "expected a type path (e.g. `UserCtx`)", - )) - } -} - -fn collect_paths(expr: &Expr) -> syn::Result> { - match expr { - Expr::Path(_) => Ok(vec![expect_path(expr)?]), - Expr::Tuple(ExprTuple { elems, .. }) => elems.iter().map(expect_path).collect(), - _ => Err(syn::Error::new_spanned( - expr, - "expected a context type or a tuple of context types (e.g. `UserCtx` or `(UserCtx, OrderCtx)`)", - )), + Ok(vec![input.parse()?]) } } /// Information about one input parameter, extracted from the fn signature. struct InputArg { - ident: syn::Ident, + ident: Ident, ty: Type, } -pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { - if item.sig.asyncness.is_none() { - return syn::Error::new_spanned( - &item.sig.fn_token, - "#[mizan] requires an `async fn`. Wrap synchronous handlers if needed.", - ) - .to_compile_error(); +/// The handler grammar `#[mizan::client]` accepts: an `async fn` taking a +/// request handle followed by plain-identifier params, with an explicit return +/// type. The token stream is parsed straight into this shape, so `expand` +/// reads three settled fields and has nothing left to reject. +/// +/// A missing `async` or a missing request handle needs no rejection here: the +/// dispatch wrapper `expand` emits calls the handler with `&req` and awaits +/// the call, so rustc rejects both at the generated call site. +pub struct Handler { + item: ItemFn, + input_args: Vec, + return_ty: Type, +} + +impl Parse for Handler { + fn parse(input: ParseStream) -> syn::Result { + let item: ItemFn = input.parse()?; + + let ReturnType::Type(_, declared) = &item.sig.output else { + return Err(syn::Error::new_spanned( + &item.sig, + "#[mizan] requires an explicit return type. Add `-> T` to the signature.", + )); + }; + let return_ty = (**declared).clone(); + + let mut input_args = Vec::new(); + // The first arg is the request handle, which the dispatch wrapper + // forwards as `req`; it never becomes an Input field. + for arg in item.sig.inputs.iter().skip(1) { + let typed = match arg { + FnArg::Typed(typed) => typed, + FnArg::Receiver(_) => { + return Err(syn::Error::new_spanned( + arg, + "#[mizan] functions are free functions, not methods. `self` is not allowed.", + )); + } + }; + let Pat::Ident(bound) = &*typed.pat else { + return Err(syn::Error::new_spanned( + &typed.pat, + "#[mizan] function parameters must be plain identifiers (no destructuring).", + )); + }; + input_args.push(InputArg { + ident: bound.ident.clone(), + ty: (*typed.ty).clone(), + }); + } + + Ok(Self { + item, + input_args, + return_ty, + }) } +} + +pub fn expand(args: FunctionArgs, handler: Handler) -> TokenStream { + let Handler { + item, + input_args, + return_ty, + } = handler; let fn_name = item.sig.ident.to_string(); let camel = fn_name.to_lower_camel_case(); let input_type_name = format!("{camel}Input"); let output_type_name = format!("{camel}Output"); - let input_args = match collect_input_args(&item) { - Ok(v) => v, - Err(e) => return e.to_compile_error(), - }; let has_input = !input_args.is_empty(); let input_type_ident = format_ident!("{}", input_type_name); - - let return_ty = match &item.sig.output { - ReturnType::Type(_, t) => (**t).clone(), - ReturnType::Default => { - return syn::Error::new_spanned( - &item.sig, - "#[mizan] requires an explicit return type. Add `-> T` to the signature.", - ) - .to_compile_error(); - } - }; let analysis = analyze_return(&return_ty); // ─── Synthetic Input struct ──────────────────────────────────────────── @@ -156,12 +192,11 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { for arg in &input_args { let ident = &arg.ident; let ty = &arg.ty; - // Strip a leading underscore from the wire-level field name — - // Rust convention uses `_foo` to silence unused-arg warnings, - // but the wire schema and the Python fixture name the param - // `foo`. The struct field keeps its source ident (so the - // dispatch wrapper's `validated.#ident` compiles), and a serde - // `rename` bridges the wire-level JSON name. + // Rust convention writes `_foo` to silence an unused-arg warning, + // but the wire schema names the param `foo`. The struct field + // keeps its source ident so the dispatch wrapper's + // `validated.#ident` compiles, and a serde `rename` bridges the + // JSON name. let name_str = ident.to_string(); let wire_name = name_str.trim_start_matches('_').to_string(); let serde_rename = if wire_name != name_str { @@ -170,8 +205,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { TokenStream::new() }; field_defs.push(quote! { #serde_rename pub #ident: #ty, }); - let is_optional = unwrap_option(ty).is_some(); - let required = !is_optional; + let required = !is_optional(ty); let shape = type_shape_expr(ty); field_shapes.push(quote! { ::mizan_core::StructField { @@ -202,11 +236,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { }; // ─── Type entry registrations ────────────────────────────────────────── - // - Input: TypeEntry pointing at the synthetic input struct's shape_fn. - // - Output: TypeEntry whose shape is a copy of the user's Output shape - // (for struct outputs) or an `Alias(List(Ref("T")))` (for Vec outputs). - // - For Vec outputs, ALSO register T's TypeEntry pointing at T's - // MizanType impl (so the Ref resolves in the IR). let mut type_registrations = Vec::new(); if has_input { let static_ident = @@ -222,66 +251,67 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { } let output_static = format_ident!("__MIZAN_TYPE_{}", output_type_name.to_shouty_snake_case()); - if analysis.is_vec { - let elem = analysis.vec_inner.as_ref().expect("vec_inner set"); - // userOrdersOutput → alias { list { ref "OrderOutput" } } - // The Ref name is resolved via `::type_name()`. - type_registrations.push(quote! { - #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] - #[linkme(crate = ::mizan_core::__priv::linkme)] - static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { - name: #output_type_name, - shape_fn: || ::mizan_core::NamedType::Alias( - ::mizan_core::TypeShape::List(::std::boxed::Box::new( - ::mizan_core::TypeShape::Ref(<#elem as ::mizan_core::MizanType>::TYPE_NAME) - )) - ), + let output_shape_expr = match &analysis.form { + ReturnForm::Sequence { element } => { + let element_ref = ref_shape_expr(element); + let alias = quote! { + ::mizan_core::NamedType::Alias( + ::mizan_core::TypeShape::List(::std::boxed::Box::new(#element_ref)) + ) }; - }); - // Also register the element type itself by its own name. `TYPE_NAME` - // is an associated const, so this is usable in a static initializer. - // The static ident scopes by the function name so two handlers - // returning `Vec` don't collide; the IrSnapshot's BTreeMap - // dedupes by the entry's `name` at emit time. - let elem_static = - element_type_static_ident_scoped(elem, &fn_name.to_shouty_snake_case()); - type_registrations.push(quote! { - #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] - #[linkme(crate = ::mizan_core::__priv::linkme)] - static #elem_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { - name: <#elem as ::mizan_core::MizanType>::TYPE_NAME, - shape_fn: <#elem as ::mizan_core::MizanType>::shape, - }; - }); - } else { - // Non-Vec output: copy the inner type's shape under the canonical name. - let inner_ty = &analysis.inner; - type_registrations.push(quote! { - #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] - #[linkme(crate = ::mizan_core::__priv::linkme)] - static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { - name: #output_type_name, - shape_fn: <#inner_ty as ::mizan_core::MizanType>::shape, - }; - }); - } + type_registrations.push(quote! { + #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] + #[linkme(crate = ::mizan_core::__priv::linkme)] + static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { + name: #output_type_name, + shape_fn: || #alias, + }; + }); + // The element type also registers under its own name. The static + // ident is scoped by the function name so two handlers returning + // `Vec` don't collide; the emitter dedupes by entry name. + let element_static = + element_type_static_ident_scoped(element, &fn_name.to_shouty_snake_case()); + type_registrations.push(quote! { + #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] + #[linkme(crate = ::mizan_core::__priv::linkme)] + static #element_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { + name: <#element as ::mizan_core::MizanType>::TYPE_NAME, + shape_fn: <#element as ::mizan_core::MizanType>::shape, + }; + }); + alias + } + ReturnForm::Scalar { inner } => { + type_registrations.push(quote! { + #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] + #[linkme(crate = ::mizan_core::__priv::linkme)] + static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { + name: #output_type_name, + shape_fn: <#inner as ::mizan_core::MizanType>::shape, + }; + }); + quote! { <#inner as ::mizan_core::MizanType>::shape() } + } + }; // ─── InputParam slice (for context-builder shared-param elevation) ──── + // A non-primitive param is an opaque payload in the context's `param` + // block and carries the string primitive. + let opaque_primitive = || quote! { ::mizan_core::Primitive::String }; let mut input_params = Vec::new(); for arg in &input_args { // Wire-level name strips the underscore prefix — see input_struct - // above for the rationale. + // above. let name_str = arg.ident.to_string(); let name_str = name_str.trim_start_matches('_').to_string(); - let primitive = primitive_of(&arg.ty).unwrap_or_else(|| { - // Non-primitive params don't surface in the context's `param` - // block; they participate as opaque payloads. Using `String` as - // the placeholder primitive matches Python's fallback in - // `_annotation_to_primitive`. - quote! { ::mizan_core::Primitive::String } - }); - let is_optional = unwrap_option(&arg.ty).is_some(); - let required = !is_optional; + let primitive = match classify(&arg.ty) { + TypeForm::Primitive(p) => p, + TypeForm::Optional(_) => opaque_primitive(), + TypeForm::Sequence(_) => opaque_primitive(), + TypeForm::Named(_) => opaque_primitive(), + }; + let required = !is_optional(&arg.ty); input_params.push(quote! { ::mizan_core::InputParam { name: #name_str, @@ -354,7 +384,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { let private = args.private; let dispatch_body = build_dispatch( - &item, + &inner_fn_ident, &input_args, has_input, &input_type_ident, @@ -362,8 +392,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { ); quote! { - // Keep the user's original fn intact — the macro never rewrites the - // body, only wraps it for dispatch. #item #input_struct @@ -383,6 +411,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { fn has_input(&self) -> bool { #has_input } fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt } fn output_type(&self) -> &'static str { #output_type_name } + fn output_shape(&self) -> ::mizan_core::NamedType { #output_shape_expr } fn output_nullable(&self) -> bool { #output_nullable } fn context(&self) -> ::std::option::Option<&'static str> { #context_value } fn affects(&self) -> &'static [::mizan_core::AffectTarget] { #affects_static } @@ -416,57 +445,15 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { } } -fn collect_input_args(item: &ItemFn) -> syn::Result> { - let mut out = Vec::new(); - let mut iter = item.sig.inputs.iter(); - // First arg is the request handle — skip without inspection. The function - // body uses it directly; the dispatch wrapper forwards `req`. - if iter.next().is_none() { - return Err(syn::Error::new( - item.sig.span(), - "#[mizan] functions must accept at least a request handle as the first parameter (e.g. `&Request` or `RequestHandle`).", - )); - } - for arg in iter { - match arg { - FnArg::Typed(pat) => { - let ident = match &*pat.pat { - Pat::Ident(pi) => pi.ident.clone(), - _ => { - return Err(syn::Error::new_spanned( - &pat.pat, - "#[mizan] function parameters must be plain identifiers (no destructuring).", - )); - } - }; - out.push(InputArg { - ident, - ty: (*pat.ty).clone(), - }); - } - FnArg::Receiver(_) => { - return Err(syn::Error::new_spanned( - arg, - "#[mizan] functions are free functions, not methods. `self` is not allowed.", - )); - } - } - } - Ok(out) -} - fn build_dispatch( - item: &ItemFn, + inner: &Ident, input_args: &[InputArg], has_input: bool, - input_type_ident: &syn::Ident, + input_type_ident: &Ident, returns_result: bool, ) -> TokenStream { - let inner = &item.sig.ident; - // When the user returns `Result`, lift Err out into the - // dispatch wrapper's outer Result so the HTTP/IPC adapter can surface - // it as the standard error envelope. When the user returns `T`, - // serialize directly — the substrate has no error path for them. + // `?` lifts a user `Result`'s Err into the wrapper's outer + // Result; a plain `T` serializes directly. let unwrap_user_result = if returns_result { quote! { ? } } else { @@ -501,16 +488,17 @@ fn build_dispatch( } } -fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> syn::Ident { - // Derive a unique static-name for the type's registration entry, - // scoped by the surrounding function so siblings returning the same - // `Vec` don't collide at the static-name layer. The IR-side - // BTreeMap dedupes by TypeEntry.name at emission time. - let last = match ty { - Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()), - _ => None, +/// A static-name for the element type's registration entry, scoped by the +/// surrounding function so siblings returning the same `Vec` don't collide +/// at the static-name layer. +fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> Ident { + let stem = match path_head(ty) { + Head::Path { name, .. } => name, + Head::Unnamed => "ANON".to_string(), }; - let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case(); - format_ident!("__MIZAN_TYPE_ELEM_{}_FOR_{}", suffix, fn_scope) + format_ident!( + "__MIZAN_TYPE_ELEM_{}_FOR_{}", + stem.to_shouty_snake_case(), + fn_scope + ) } - diff --git a/cores/mizan-rust-macros/src/lib.rs b/cores/mizan-rust-macros/src/lib.rs index 6e2ae60..bb603bf 100644 --- a/cores/mizan-rust-macros/src/lib.rs +++ b/cores/mizan-rust-macros/src/lib.rs @@ -1,47 +1,34 @@ -//! Proc macros for `mizan-core`. See sibling modules for each macro's body. +//! Proc macros for `mizan-core`. See sibling modules for each macro's body: +//! `derive` for `#[derive(Mizan)]`, `context` / `function` / `channel` for the +//! three attribute macros, `shape` for the shared `syn::Type` lowering. //! -//! Consumer code reads: -//! ```ignore -//! use mizan_core::prelude::*; -//! pub use mizan_core as mizan; // so `#[mizan::context]` / `#[mizan::client]` read naturally -//! -//! #[derive(Mizan, serde::Serialize, serde::Deserialize)] -//! pub struct ProfileOutput { pub user_id: i64, pub name: String } -//! -//! #[mizan::context("user")] -//! pub struct UserCtx; -//! -//! #[mizan::client(context = UserCtx)] -//! pub async fn user_profile(req: &Request, user_id: i64) -> ProfileOutput { ... } -//! ``` -//! -//! The function macro is named `client` to mirror Python's `@client` -//! decorator and to keep the namespace `mizan::` purely a module path — -//! `#[mizan(...)]` would collide with `mizan::context` (a module path -//! can't simultaneously be a callable macro in Rust). +//! The function macro is named `client` so `mizan::` stays purely a module +//! path — a module path can't simultaneously be a callable macro in Rust, so +//! `#[mizan(...)]` would collide with `mizan::context`. +mod channel; mod context; mod derive; mod function; mod shape; use proc_macro::TokenStream; -use syn::{parse_macro_input, DeriveInput, ItemFn, ItemStruct}; +use syn::{parse_macro_input, ItemStruct}; #[proc_macro_derive(Mizan)] pub fn derive_mizan(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - derive::expand(input).into() + let derived = parse_macro_input!(input as derive::MizanDerive); + derive::expand(derived).into() } #[proc_macro_attribute] pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream { - let args = match context::ContextArgs::parse(attr.into()) { - Ok(a) => a, + let name = match context::ContextName::parse(attr.into()) { + Ok(n) => n, Err(e) => return e.to_compile_error().into(), }; let item = parse_macro_input!(item as ItemStruct); - context::expand(args, item).into() + context::expand(name, item).into() } /// The function-registration attribute macro. Used as `#[mizan::client]` @@ -49,10 +36,17 @@ pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream { /// websocket, private)]`. #[proc_macro_attribute] pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream { - let args = match function::FunctionArgs::parse(attr.into()) { - Ok(a) => a, - Err(e) => return e.to_compile_error().into(), - }; - let item = parse_macro_input!(item as ItemFn); - function::expand(args, item).into() + let args = parse_macro_input!(attr as function::FunctionArgs); + let handler = parse_macro_input!(item as function::Handler); + function::expand(args, handler).into() +} + +/// The channel-registration attribute macro. Used as +/// `#[mizan::channel("", params = P, client_message = C, +/// server_message = S)]` on a unit struct; every slot is optional. +#[proc_macro_attribute] +pub fn channel(attr: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(attr as channel::ChannelArgs); + let item = parse_macro_input!(item as ItemStruct); + channel::expand(args, item).into() } diff --git a/cores/mizan-rust-macros/src/shape.rs b/cores/mizan-rust-macros/src/shape.rs index a3b94a6..80c21e1 100644 --- a/cores/mizan-rust-macros/src/shape.rs +++ b/cores/mizan-rust-macros/src/shape.rs @@ -6,203 +6,189 @@ use proc_macro2::TokenStream; use quote::quote; use syn::{GenericArgument, PathArguments, Type, TypePath}; -/// Result of inspecting a fn's return type. +/// The IR-relevant form of a Rust type. Every `syn::Type` lands in exactly +/// one arm, so classification never reports "unknown". +pub enum TypeForm { + /// `Option` — the wire field is nullable. + Optional(Type), + /// `Vec`, `[T; N]`, or a map whose values are `T` — a JSON array. + Sequence(Type), + /// A scalar, carrying the `::mizan_core::Primitive` variant expression. + Primitive(TokenStream), + /// Anything else: a type expected to implement `MizanType`. + Named(Type), +} + +/// What a type's head is, as the lowering reads it. `Unnamed` covers the +/// forms with no path to name — tuples, references, slices, bare fns — which +/// carry no keyword the callers below test for. +pub enum Head { + Path { name: String, generics: Vec }, + Unnamed, +} + +/// Which of the two output shapes a handler's return type produces. +pub enum ReturnForm { + /// The handler yields a list; the caller registers an alias type over + /// `element`'s Ref. + Sequence { element: Type }, + /// The handler yields one value; the caller registers `inner`'s own shape + /// under the canonical output name. + Scalar { inner: Type }, +} + pub struct ReturnAnalysis { - /// Inner type once `Option<...>` is unwrapped. - pub inner: Type, - /// True if the outermost wrapper is `Option<...>`. + pub form: ReturnForm, + /// True if the outermost wrapper (after `Result`) is `Option<...>`. pub nullable: bool, - /// True if `inner` is `Vec` — caller emits an alias type entry. - pub is_vec: bool, - /// When `is_vec`, this is the element type `T`. - pub vec_inner: Option, /// True when the user's return type is `Result` — the /// dispatch wrapper emits `?` so user-side errors bubble out as /// `MizanError` instead of being serialized into the success payload. - /// The IR sees only the `T` side; the error variant is the substrate's - /// invariant, not part of the output shape. pub returns_result: bool, } pub fn analyze_return(ty: &Type) -> ReturnAnalysis { - let (effective, returns_result) = if let Some(ok) = unwrap_result_ok(ty) { - (ok, true) - } else { - (ty.clone(), false) + let (effective, returns_result) = strip_result(ty); + let (unwrapped, nullable) = match classify(&effective) { + TypeForm::Optional(inner) => (inner, true), + TypeForm::Sequence(_) | TypeForm::Primitive(_) | TypeForm::Named(_) => (effective, false), }; - let (inner, nullable) = if let Some(t) = unwrap_option(&effective) { - (t, true) - } else { - (effective, false) + let form = match classify(&unwrapped) { + TypeForm::Sequence(element) => ReturnForm::Sequence { element }, + TypeForm::Optional(_) | TypeForm::Primitive(_) | TypeForm::Named(_) => { + ReturnForm::Scalar { inner: unwrapped } + } }; - if let Some(elem) = unwrap_vec(&inner) { - ReturnAnalysis { - inner: inner.clone(), - nullable, - is_vec: true, - vec_inner: Some(elem), - returns_result, - } - } else { - ReturnAnalysis { - inner, - nullable, - is_vec: false, - vec_inner: None, - returns_result, - } + ReturnAnalysis { + form, + nullable, + returns_result, } } -/// If `ty` is `Result`, return `T`. Otherwise None. The substrate -/// only honors `Result`; the macro doesn't try to verify -/// `E` here — it lets rustc raise the type-mismatch at the `?` site if -/// the consumer used a non-MizanError variant. -pub fn unwrap_result_ok(ty: &Type) -> Option { - let path = match ty { - Type::Path(TypePath { qself: None, path }) => path, - _ => return None, - }; - let last = path.segments.last()?; - if last.ident != "Result" { - return None; +/// Peel `Result` down to `T`. `E` is left to rustc: a non-`MizanError` +/// error type fails at the `?` site the dispatch wrapper emits. +pub fn strip_result(ty: &Type) -> (Type, bool) { + if let Head::Path { name, generics } = path_head(ty) { + if name == "Result" { + if let [ok, ..] = generics.as_slice() { + return (ok.clone(), true); + } + } } - extract_single_generic(&last.arguments) + (ty.clone(), false) } -/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]` -/// when constructing the struct field shapes. +pub fn classify(ty: &Type) -> TypeForm { + if let Type::Array(array) = ty { + return TypeForm::Sequence((*array.elem).clone()); + } + let Head::Path { name, generics } = path_head(ty) else { + return TypeForm::Named(ty.clone()); + }; + let args = generics.as_slice(); + if name == "Option" { + if let [inner, ..] = args { + return TypeForm::Optional(inner.clone()); + } + } + if name == "Vec" { + if let [element, ..] = args { + return TypeForm::Sequence(element.clone()); + } + } + if name == "BTreeMap" || name == "HashMap" { + // A string-keyed map lands on the wire as a JSON object; the IR + // carries only the value shape, as a list element. + if let [_key, value, ..] = args { + return TypeForm::Sequence(value.clone()); + } + } + classify_scalar(ty, &name) +} + +pub fn is_optional(ty: &Type) -> bool { + matches!(classify(ty), TypeForm::Optional(_)) +} + +/// Emit a `TypeShape` const-expression for `ty`. Used inside +/// `#[derive(Mizan)]` when constructing the struct field shapes. pub fn type_shape_expr(ty: &Type) -> TokenStream { - if let Some(inner) = unwrap_option(ty) { - let inner_shape = type_shape_expr(&inner); - return quote! { - ::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape)) - }; - } - if let Some(elem) = unwrap_vec(ty) { - let inner_shape = type_shape_expr(&elem); - return quote! { - ::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape)) - }; - } - if let Some(elem) = unwrap_array(ty) { - // `[T; N]` lowers to `list { T }` on the wire — JSON arrays don't - // carry length, so the IR contract is the same as `Vec`. - let inner_shape = type_shape_expr(&elem); - return quote! { - ::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape)) - }; - } - if let Some(elem) = unwrap_btreemap_value(ty) { - // `BTreeMap` on the wire is a JSON object keyed by `K`'s - // string form. The Mizan IR doesn't model dynamic-keyed maps as a - // distinct shape — closest equivalent is a list of value entries. - let inner_shape = type_shape_expr(&elem); - return quote! { - ::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape)) - }; - } - if let Some(p) = primitive_of(ty) { - return quote! { ::mizan_core::TypeShape::Primitive(#p) }; - } - // Fallback: assume a user-defined struct/enum implementing MizanType. - // The Ref name comes from `::TYPE_NAME` (associated const). - quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) } -} - -/// If `ty` is `[T; N]`, return `T`. Otherwise None. -pub fn unwrap_array(ty: &Type) -> Option { - if let Type::Array(a) = ty { - Some((*a.elem).clone()) - } else { - None - } -} - -/// If `ty` is `BTreeMap` or `HashMap`, return `V` (the value). -/// String-keyed maps land on the wire as JSON objects; the IR carries the -/// value shape as a list element since KDL doesn't model dynamic-keyed maps -/// distinctly yet. -pub fn unwrap_btreemap_value(ty: &Type) -> Option { - let path = match ty { - Type::Path(TypePath { qself: None, path }) => path, - _ => return None, - }; - let last = path.segments.last()?; - let name = last.ident.to_string(); - if name != "BTreeMap" && name != "HashMap" { - return None; - } - let args = match &last.arguments { - PathArguments::AngleBracketed(a) => a, - _ => return None, - }; - // BTreeMap — second type argument is V. - let mut type_args = args.args.iter().filter_map(|a| { - if let GenericArgument::Type(t) = a { - Some(t.clone()) - } else { - None + match classify(ty) { + TypeForm::Optional(inner) => { + let inner_shape = type_shape_expr(&inner); + quote! { + ::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape)) + } } - }); - type_args.next()?; // skip K - type_args.next() -} - -/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a -/// known primitive scalar. -pub fn primitive_of(ty: &Type) -> Option { - let path = match ty { - Type::Path(TypePath { qself: None, path }) => path, - _ => return None, - }; - let last = path.segments.last()?; - let name = last.ident.to_string(); - match name.as_str() { - "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128" - | "usize" => Some(quote! { ::mizan_core::Primitive::Integer }), - "f32" | "f64" => Some(quote! { ::mizan_core::Primitive::Number }), - "bool" => Some(quote! { ::mizan_core::Primitive::Boolean }), - "String" | "str" => Some(quote! { ::mizan_core::Primitive::String }), - _ => None, + TypeForm::Sequence(element) => { + let inner_shape = type_shape_expr(&element); + quote! { + ::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape)) + } + } + TypeForm::Primitive(primitive) => { + quote! { ::mizan_core::TypeShape::Primitive(#primitive) } + } + TypeForm::Named(named) => ref_shape_expr(&named), } } -/// If `ty` is `Option`, return `T`. Otherwise None. -pub fn unwrap_option(ty: &Type) -> Option { - let path = match ty { - Type::Path(TypePath { qself: None, path }) => path, - _ => return None, - }; - let last = path.segments.last()?; - if last.ident != "Option" { - return None; +/// A `TypeShape::Ref` carrying both the referent's IR name and its shape +/// constructor, so resolving the reference needs no registry lookup. +pub fn ref_shape_expr(ty: &Type) -> TokenStream { + quote! { + ::mizan_core::TypeShape::Ref { + name: <#ty as ::mizan_core::MizanType>::TYPE_NAME, + shape: <#ty as ::mizan_core::MizanType>::shape, + } } - extract_single_generic(&last.arguments) } -/// If `ty` is `Vec`, return `T`. Otherwise None. -pub fn unwrap_vec(ty: &Type) -> Option { - let path = match ty { - Type::Path(TypePath { qself: None, path }) => path, - _ => return None, - }; - let last = path.segments.last()?; - if last.ident != "Vec" { - return None; +const INTEGER_IDENTS: &[&str] = &[ + "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize", +]; + +fn classify_scalar(ty: &Type, name: &str) -> TypeForm { + if INTEGER_IDENTS.contains(&name) { + return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Integer }); } - extract_single_generic(&last.arguments) + if name == "f32" || name == "f64" { + return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Number }); + } + if name == "bool" { + return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Boolean }); + } + if name == "String" || name == "str" { + return TypeForm::Primitive(quote! { ::mizan_core::Primitive::String }); + } + TypeForm::Named(ty.clone()) } -fn extract_single_generic(args: &PathArguments) -> Option { - let args = match args { +/// The last path segment's identifier and its generic type arguments. +pub fn path_head(ty: &Type) -> Head { + if let Type::Path(TypePath { qself: None, path }) = ty { + if let Some(last) = path.segments.last() { + return Head::Path { + name: last.ident.to_string(), + generics: generic_types(&last.arguments), + }; + } + } + Head::Unnamed +} + +fn generic_types(args: &PathArguments) -> Vec { + let angled = match args { PathArguments::AngleBracketed(a) => a, - _ => return None, + PathArguments::None => return Vec::new(), + PathArguments::Parenthesized(_) => return Vec::new(), }; - for arg in &args.args { + let mut out = Vec::new(); + for arg in &angled.args { if let GenericArgument::Type(t) = arg { - return Some(t.clone()); + out.push(t.clone()); } } - None + out } diff --git a/cores/mizan-rust-ssr/src/lib.rs b/cores/mizan-rust-ssr/src/lib.rs index 8859d8d..90886cb 100644 --- a/cores/mizan-rust-ssr/src/lib.rs +++ b/cores/mizan-rust-ssr/src/lib.rs @@ -1,20 +1,14 @@ -//! Mizan SSR engine. +//! Mizan SSR engine: an embedded `deno_core` V8 runtime composed with +//! `deno_web`, holding one evaluated JS bundle plus the `renderApp` function +//! that bundle defines. //! -//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time -//! JS bundle (component + `react-dom/server.browser`, produced by the bundler -//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a -//! global render function; the engine evals it once and calls it per request. -//! No external JS runtime — node and bun are build-time tools only. +//! `deno_web` supplies the web-platform globals a bare isolate lacks — +//! `TextEncoder`/`TextDecoder`, timers, `MessagePort`, `performance` — as real +//! implementations rather than partial shims. //! -//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`, -//! timers, `MessagePort`, `performance` — come from `deno_web` as real -//! web-platform implementations, not shims (a partial polyfill is -//! silent-failure-shaped: it passes until a render path hits the gap). -//! -//! Props never enter evaluated source. Only the trusted bundle is `eval`'d; -//! per-render data crosses as a `v8::json::parse`d value passed as a function -//! argument, so a prop string has no source to break out of — code injection -//! is structurally absent, not filtered. +//! Only the bundle is ever `eval`'d. Per-render props enter through +//! `v8::json::parse` and are handed in as a call argument, so a prop string has +//! no surrounding source to break out of. use std::sync::Arc; @@ -36,15 +30,32 @@ const INSTALL_WEB_GLOBALS: &str = r#"{ globalThis.TextDecoder = te.TextDecoder; }"#; +/// Yield the bundle's `renderApp`, throwing on the JS side when it is absent +/// or not callable. The script therefore either fails — arriving in Rust as +/// the evaluator's own error — or produces a callable, which is what lets the +/// engine take it as a `v8::Function` without a second check. +const TAKE_RENDER_APP: &str = r#"(() => { + const f = globalThis.renderApp; + if (typeof f !== "function") { + throw new TypeError("the SSR bundle assigns no callable `renderApp`"); + } + return f; +})()"#; + /// An embedded V8 runtime carrying one rendered bundle, plus the web-platform /// globals react-dom needs. One isolate per engine (V8's Locker constraint /// means an engine is not `Send`; hold one per worker thread). +/// +/// `render_fn` is taken during construction, so a render calls a function this +/// engine already owns and repeats no lookup. pub struct SsrEngine { runtime: JsRuntime, + render_fn: v8::Global, } impl SsrEngine { - /// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`). + /// Build the runtime, eval `bundle` (which assigns `globalThis.renderApp`), + /// and take hold of that function. pub fn new(bundle: String) -> Result { let mut runtime = JsRuntime::new(RuntimeOptions { extensions: vec![ @@ -64,50 +75,64 @@ impl SsrEngine { runtime .execute_script("[mizan:bundle]", bundle) .context("evaluating the SSR bundle")?; - Ok(Self { runtime }) + let render_app = runtime + .execute_script("[mizan:render-app]", TAKE_RENDER_APP) + .context("taking `renderApp` from the evaluated bundle")?; + + let render_fn = { + deno_core::scope!(scope, &mut runtime); + let func = v8::Local::new(scope, render_app).cast::(); + v8::Global::new(scope, func) + }; + + Ok(Self { runtime, render_fn }) } /// Render to HTML by calling the bundle's `renderApp(props)`. `props_json` /// is a JSON object string; it is parsed to a V8 value and passed as an /// argument — never spliced into evaluated source. pub fn render(&mut self, props_json: &str) -> Result { + let render_fn = self.render_fn.clone(); deno_core::scope!(scope, &mut self.runtime); - let context = scope.get_current_context(); - let global = context.global(scope); - - let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?; - let func_val = global - .get(scope, key.into()) - .ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?; - let func: v8::Local = func_val - .try_into() - .map_err(|_| anyhow!("renderApp is not a function"))?; - - let props_str = v8::String::new(scope, props_json).context("intern props")?; - let props = v8::json::parse(scope, props_str) - .ok_or_else(|| anyhow!("props are not valid JSON"))?; + let func = v8::Local::new(scope, &render_fn); + let props = parse_props(scope, props_json)?; let recv = v8::undefined(scope).into(); - let result = func + let html = func .call(scope, recv, &[props]) .ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?; - Ok(result.to_rust_string_lossy(scope)) + Ok(html.to_rust_string_lossy(scope)) } } +/// The one crossing where untrusted request text becomes a value inside the +/// isolate. Both steps report that boundary's failure and nothing else: V8 +/// refuses a string past its length limit, and its JSON grammar rejects +/// malformed input. +fn parse_props<'s>( + scope: &v8::PinScope<'s, '_>, + props_json: &str, +) -> Result> { + let text = v8::String::new(scope, props_json) + .ok_or_else(|| anyhow!("props exceed V8's maximum string length"))?; + v8::json::parse(scope, text).ok_or_else(|| anyhow!("props are not valid JSON")) +} + #[cfg(test)] mod tests { use super::*; - #[tokio::test] - async fn renders_react_bundle_in_embedded_v8() { - let bundle = std::fs::read_to_string(concat!( + fn fixture_bundle() -> String { + std::fs::read_to_string(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixture/bundle.js" )) - .expect("tests/fixture/bundle.js — build it via the fixture's esbuild step"); + .expect("tests/fixture/bundle.js — build it via the fixture's esbuild step") + } - let mut engine = SsrEngine::new(bundle).expect("engine init"); + #[tokio::test] + async fn renders_react_bundle_in_embedded_v8() { + let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init"); let html = engine.render(r#"{"name":"World"}"#).expect("render"); assert_eq!(html, r#"
Hello, World!
"#); } @@ -117,17 +142,19 @@ mod tests { // A prop value that would break out of a string-built `renderApp(...)` // call. Through the value-call path it is inert data: it reaches the // component as a string, never as source. - let bundle = std::fs::read_to_string(concat!( - env!("CARGO_MANIFEST_DIR"), - "/tests/fixture/bundle.js" - )) - .expect("fixture bundle"); - - let mut engine = SsrEngine::new(bundle).expect("engine init"); + let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init"); let html = engine .render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#) .expect("render"); // The payload rendered as text; it did not execute. assert!(html.contains("__pwned")); } + + #[tokio::test] + async fn a_bundle_without_a_callable_render_app_is_rejected() { + let err = SsrEngine::new("globalThis.renderApp = 7;".to_string()) + .map(|_| ()) + .expect_err("a bundle whose renderApp is not callable must not build an engine"); + assert!(err.to_string().contains("renderApp"), "unexpected: {err}"); + } } diff --git a/cores/mizan-rust-ssr/tests/fixture/Hello.js b/cores/mizan-rust-ssr/tests/fixture/Hello.js index 9d07f2b..5211943 100644 --- a/cores/mizan-rust-ssr/tests/fixture/Hello.js +++ b/cores/mizan-rust-ssr/tests/fixture/Hello.js @@ -1,7 +1,6 @@ import { createElement } from "react" -// A trivial component: props in, element out. The keystone only needs to prove -// a real React tree renders to HTML inside a bare JS context. +// Props in, element out. The `id` is the handle the render assertions match on. export function Hello({ name }) { return createElement("div", { id: "greeting" }, `Hello, ${name}!`) } diff --git a/cores/mizan-rust-ssr/tests/fixture/entry.js b/cores/mizan-rust-ssr/tests/fixture/entry.js index 10eb0e6..f9ca769 100644 --- a/cores/mizan-rust-ssr/tests/fixture/entry.js +++ b/cores/mizan-rust-ssr/tests/fixture/entry.js @@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server.browser" import { createElement } from "react" import { Hello } from "./Hello.js" -// The bundle exposes one global the embedded engine calls. No module system at -// runtime — the engine receives a bare script that defines `renderApp`. This is -// the production shape in miniature: build-time bundle, runtime eval. +// There is no module system in the embedded engine — it receives a bare +// script, so the entry point has to land on `globalThis` for the Rust side to +// reach it. globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props)) diff --git a/cores/mizan-rust-ssr/tests/fixture/runner.cjs b/cores/mizan-rust-ssr/tests/fixture/runner.cjs index 67544a7..294c9a8 100644 --- a/cores/mizan-rust-ssr/tests/fixture/runner.cjs +++ b/cores/mizan-rust-ssr/tests/fixture/runner.cjs @@ -1,14 +1,11 @@ -// Proxy for the embedded-V8 runtime: a bare global context with no Node -// builtins. Load the IIFE bundle (which assigns globalThis.renderApp) and call -// it. What renders here renders in rusty_v8 — the engine swaps, the contract -// (bundle defines a global render fn over a bare context) does not. +// Runs bundle.js inside a `vm` context holding only the globals listed below, +// so the bundle sees the same bare environment the embedded V8 engine gives it. const fs = require("fs") const vm = require("vm") const code = fs.readFileSync(__dirname + "/bundle.js", "utf8") -// The minimal host globals React's bundle touches at init / sync render. The -// rusty_v8 engine must provide the same set — this list is the spec for it. +// The host globals React's bundle touches at init and during a sync render. const sandbox = { console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance, TextEncoder, TextDecoder, @@ -19,11 +16,12 @@ vm.createContext(sandbox) vm.runInContext(code, sandbox) const html = sandbox.renderApp({ name: "World" }) -console.log("RENDERED:", html) - const expected = '
Hello, World!
' if (html !== expected) { - console.error("MISMATCH — expected:", expected) + console.error(`expected ${expected}, got ${html}`) process.exit(1) } -console.log("OK — React bundle renders in a bare JS context (V8 proxy)") +console.log(html) +// The sandbox's MessageChannel holds an open handle, so the event loop never +// drains on its own; exit once the render has been checked. +process.exit(0) diff --git a/cores/mizan-rust-ssr/tests/no_rsc.rs b/cores/mizan-rust-ssr/tests/no_rsc.rs index 4e9c157..0b62931 100644 --- a/cores/mizan-rust-ssr/tests/no_rsc.rs +++ b/cores/mizan-rust-ssr/tests/no_rsc.rs @@ -1,29 +1,19 @@ -//! Guard — Mizan SSR is hand-rolled (bare renderer + AFI data injection + -//! injected kernel). No frontend adapter imports an SSR runtime / meta-framework -//! (Next, Nuxt, SvelteKit) or a server-functions layer (RSC / Flight). -//! -//! React Server Components and the Flight serialization protocol carry -//! CVE-2025-55182 ("React2Shell" — unauthenticated remote code execution, -//! CVSS 10.0): the server deserializes a client-supplied Flight payload and an -//! attacker reaches prototype-pollution → RCE. -//! -//! Mizan renders **synchronously from props** — data is fetched server-side -//! through the AFI and passed in, never deserialized from a client payload — so -//! it sits structurally outside that attack surface. This test keeps it there: -//! it goes red the instant any RSC / Flight / streaming surface enters the -//! authored SSR source or its dependencies. Absence is not enough; this is the -//! forcing function that makes re-entry loud. +//! Scans the SSR fixture's authored JS for tokens that only appear when React +//! Server Components, the Flight protocol, or a meta-framework SSR runtime is +//! in play. The scan goes red the moment one of them enters the source. + +use std::path::Path; /// Tokens that only appear when RSC / Flight / streaming rendering is in play. const FORBIDDEN: &[&str] = &[ - // React Server Components / Flight — CVE-2025-55182 (pre-auth RCE, CVSS 10.0) + // React Server Components / Flight "react-server-dom", "renderToReadableStream", "renderToPipeableStream", "createFromReadableStream", "createFromFetch", "use server", - // SSR runtimes / meta-frameworks — forbidden across every frontend adapter + // SSR runtimes / meta-frameworks "next/", "nuxt", "@sveltejs/kit", @@ -39,15 +29,16 @@ const SCANNED: &[&str] = &[ #[test] fn ssr_has_no_rsc_or_flight_surface() { for path in SCANNED { - let Ok(src) = std::fs::read_to_string(path) else { - continue; // a generated/optional file absent is fine; authored source is the point - }; + assert!( + Path::new(path).is_file(), + "{path} is a tracked fixture this scan reads; it is missing", + ); + let src = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("reading {path} for the RSC scan: {e}")); for needle in FORBIDDEN { assert!( !src.contains(needle), - "RSC/Flight surface {needle:?} found in {path} — forbidden. \ - RSC carries CVE-2025-55182 (unauth RCE, CVSS 10.0); Mizan SSR is \ - classic renderToString-family only, rendered synchronously from props.", + "{needle:?} found in {path}; this scan forbids it", ); } } diff --git a/cores/mizan-rust/Cargo.lock b/cores/mizan-rust/Cargo.lock index a2db134..3f7e257 100644 --- a/cores/mizan-rust/Cargo.lock +++ b/cores/mizan-rust/Cargo.lock @@ -13,6 +13,18 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "heck" version = "0.5.0" @@ -34,6 +46,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "kdl" +version = "6.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e" +dependencies = [ + "miette", + "num", + "winnow", +] + [[package]] name = "linkme" version = "0.3.36" @@ -60,13 +83,41 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "unicode-width", +] + +[[package]] +name = "minijinja" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "mizan-core" version = "0.1.0" dependencies = [ "async-trait", "indoc", + "kdl", "linkme", + "minijinja", "mizan-macros", "serde", "serde_json", @@ -82,6 +133,79 @@ dependencies = [ "syn", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -166,6 +290,21 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "winnow" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +dependencies = [ + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/cores/mizan-rust/Cargo.toml b/cores/mizan-rust/Cargo.toml index 9ee85bb..d728d44 100644 --- a/cores/mizan-rust/Cargo.toml +++ b/cores/mizan-rust/Cargo.toml @@ -2,11 +2,12 @@ name = "mizan-core" version = "0.1.0" edition = "2021" -description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry. Rust analog of cores/mizan-python/src/mizan_core/." +description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry." license = "Elastic-2.0" [dependencies] linkme = "0.3" +minijinja = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" async-trait = "0.1" @@ -14,3 +15,4 @@ mizan-macros = { path = "../mizan-rust-macros" } [dev-dependencies] indoc = "2" +kdl = "6" diff --git a/cores/mizan-rust/src/graph_check.rs b/cores/mizan-rust/src/graph_check.rs index d3b0681..7e02748 100644 --- a/cores/mizan-rust/src/graph_check.rs +++ b/cores/mizan-rust/src/graph_check.rs @@ -1,200 +1,313 @@ -//! Cross-function invariant verification — fails at `build_ir()` time, which -//! runs at the codegen subprocess (`cargo run --bin export-ir`). All -//! graph-level inconsistencies surface before any client artifact is emitted. +//! Cross-function invariant checks over the registered graph. -use crate::ir::{AffectTarget, NamedType, StructField, TypeShape}; -use crate::registry::{lookup_context, CONTEXTS, FUNCTIONS, TYPES}; +use crate::ir::{NamedType, Primitive, TypeShape}; +use crate::registry::{CONTEXTS, FUNCTIONS}; +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::fmt; +use std::sync::OnceLock; -/// Walk the registered types and find the named type's shape. Used by both -/// graph-check and runtime merge resolution. -pub(crate) fn resolve_type_shape(name: &str) -> Option { - for entry in TYPES { - if entry.name == name { - return Some((entry.shape_fn)()); - } - } - None +/// A structural fingerprint of a type, with every reference resolved through +/// to the shape it names. Two types are interchangeable exactly when their +/// fingerprints are equal, so comparison is one derived `==` instead of a +/// pairwise walk over both shape enums. +#[derive(PartialEq)] +enum Canonical { + Record(Vec), + Aliased(Box), + NamedEnum(Vec<&'static str>), + Primitive(&'static str), + List(Box), + Optional(Box), + InlineEnum(Vec<&'static str>), + Union(Vec), } -/// Merge-compatibility on named types. A mutation return `value` can -/// splice into a context slot `slot` when any of three shapes hold — -/// matches Python's `types_match_for_merge`: -/// * direct: `slot` shape equals `value` shape → replace -/// * upsert: `slot` is `list[T]`, `value` is `T` → upsert by id -/// * list-replace: `slot` is `list[T]`, `value` is `list[T]` +#[derive(PartialEq)] +struct CanonicalField { + name: &'static str, + required: bool, + shape: Canonical, +} + +fn canonical_named(named: &NamedType) -> Canonical { + match named { + NamedType::Struct(fields) => Canonical::Record( + fields + .iter() + .map(|f| CanonicalField { + name: f.name, + required: f.required, + shape: canonical_shape(&f.shape), + }) + .collect(), + ), + NamedType::Alias(inner) => Canonical::Aliased(Box::new(canonical_shape(inner))), + NamedType::Enum(variants) => Canonical::NamedEnum(variants.clone()), + } +} + +fn canonical_shape(shape: &TypeShape) -> Canonical { + match shape { + TypeShape::Primitive(p) => Canonical::Primitive(p.name()), + TypeShape::Ref { shape, .. } => canonical_named(&shape()), + TypeShape::List(inner) => Canonical::List(Box::new(canonical_shape(inner))), + TypeShape::Optional(inner) => Canonical::Optional(Box::new(canonical_shape(inner))), + TypeShape::Enum(variants) => Canonical::InlineEnum(variants.clone()), + TypeShape::Union(branches) => { + Canonical::Union(branches.iter().map(canonical_shape).collect()) + } + } +} + +/// Merge-compatibility on named types. A mutation return `value` can splice +/// into a context slot `slot` when either shape holds: +/// * direct: `slot` and `value` have the same fingerprint → replace +/// * upsert: `slot` is `list[T]` and `value` is `T` → upsert by id /// -/// The first argument is the slot (context member's output type); the -/// second is the value (mutation's output type). -pub(crate) fn types_match(slot: &NamedType, value: &NamedType) -> bool { - if named_shapes_equal(slot, value) { +/// The first argument is the slot (context member's output type); the second +/// is the value (mutation's output type). +fn types_match(slot: &NamedType, value: &NamedType) -> bool { + let value_form = canonical_named(value); + if canonical_named(slot) == value_form { return true; } - // Upsert: slot is `Alias(List(T))`, value is `T`-shaped. - if let NamedType::Alias(TypeShape::List(elem)) = slot { - if shape_matches_named(elem, value) { - return true; - } - } - false -} - -fn named_shapes_equal(a: &NamedType, b: &NamedType) -> bool { - match (a, b) { - (NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb), - (NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb), - (NamedType::Enum(va), NamedType::Enum(vb)) => va == vb, - _ => false, + match slot { + NamedType::Alias(inner) => match inner { + TypeShape::List(elem) => canonical_shape(elem) == value_form, + TypeShape::Primitive(_) + | TypeShape::Ref { .. } + | TypeShape::Optional(_) + | TypeShape::Enum(_) + | TypeShape::Union(_) => false, + }, + NamedType::Struct(_) | NamedType::Enum(_) => false, } } -/// True when a `TypeShape` (the slot's list-element) describes the same -/// shape as a `NamedType` (the mutation's full output). -fn shape_matches_named(shape: &TypeShape, named: &NamedType) -> bool { - match shape { - TypeShape::Ref(name) => { - if let Some(referenced) = resolve_type_shape(name) { - named_shapes_equal(&referenced, named) - } else { - false +/// One `merge` declaration read off the registry and resolved: the mutation +/// that declares it, the context it names, and the context member whose output +/// the mutation's return value splices into. +pub(crate) struct ResolvedMerge { + pub function: &'static str, + pub context: &'static str, + pub slot: &'static str, +} + +/// The context members whose output a mutation's return value can splice into, +/// accumulated one candidate at a time. A `merge` declaration carries a usable +/// slot exactly when the walk ends on `Unique`. +enum SlotMatch { + Absent, + Unique(&'static str), + Ambiguous(Vec<&'static str>), +} + +impl SlotMatch { + fn with(self, candidate: &'static str) -> Self { + match self { + SlotMatch::Absent => SlotMatch::Unique(candidate), + SlotMatch::Unique(first) => SlotMatch::Ambiguous(vec![first, candidate]), + SlotMatch::Ambiguous(mut members) => { + members.push(candidate); + SlotMatch::Ambiguous(members) } } - _ => false, } } -fn fields_match(a: &[StructField], b: &[StructField]) -> bool { - if a.len() != b.len() { - return false; - } - a.iter().zip(b.iter()).all(|(fa, fb)| { - fa.name == fb.name && fa.required == fb.required && shapes_match(&fa.shape, &fb.shape) - }) +/// The ways a registered graph fails to hold together. +enum GraphDefect { + NoMergeSlot { + function: &'static str, + context: &'static str, + output_type: &'static str, + }, + AmbiguousMergeSlot { + function: &'static str, + context: &'static str, + output_type: &'static str, + members: Vec<&'static str>, + }, + DivergentParamType { + context: &'static str, + param: &'static str, + first_fn: &'static str, + first_type: &'static str, + second_fn: &'static str, + second_type: &'static str, + }, } -fn shapes_match(a: &TypeShape, b: &TypeShape) -> bool { - match (a, b) { - (TypeShape::Primitive(pa), TypeShape::Primitive(pb)) => { - std::mem::discriminant(pa) == std::mem::discriminant(pb) +impl fmt::Display for GraphDefect { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GraphDefect::NoMergeSlot { + function, + context, + output_type, + } => write!( + f, + "function `{function}` declares `merge = \"{context}\"` but no member of that \ + context has output type `{output_type}`. Add a context member returning \ + `{output_type}`, or declare `affects` for plain refetch." + ), + GraphDefect::AmbiguousMergeSlot { + function, + context, + output_type, + members, + } => write!( + f, + "function `{function}` declares `merge = \"{context}\"` but members ({}) all \ + share output type `{output_type}`. Merge resolution needs exactly one match. \ + Distinguish the outputs, or declare `affects` for plain refetch.", + members.join(", ") + ), + GraphDefect::DivergentParamType { + context, + param, + first_fn, + first_type, + second_fn, + second_type, + } => write!( + f, + "context `{context}` has a parameter `{param}` whose type diverges across \ + members. Function `{first_fn}` declares it as `{first_type}`, function \ + `{second_fn}` declares it as `{second_type}`. A shared param has one type \ + across the whole context." + ), } - (TypeShape::Ref(na), TypeShape::Ref(nb)) => { - // Refs match iff the named types they reference match. - match (resolve_type_shape(na), resolve_type_shape(nb)) { - (Some(ta), Some(tb)) => types_match(&ta, &tb), - _ => na == nb, - } - } - (TypeShape::List(ia), TypeShape::List(ib)) => shapes_match(ia, ib), - (TypeShape::Optional(ia), TypeShape::Optional(ib)) => shapes_match(ia, ib), - (TypeShape::Enum(va), TypeShape::Enum(vb)) => va == vb, - (TypeShape::Union(ba), TypeShape::Union(bb)) => { - ba.len() == bb.len() && ba.iter().zip(bb.iter()).all(|(x, y)| shapes_match(x, y)) - } - _ => false, } } -/// Panic with a structured message if the registered function graph is -/// inconsistent. Called from `build_ir()`. -pub fn verify_invariants() { - check_affects_targets(); - check_merge_targets(); - check_shared_param_types(); +/// Every defect on its own bulleted line, under one heading. +struct GraphReport<'a>(&'a [GraphDefect]); + +impl fmt::Display for GraphReport<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!( + f, + "Mizan graph-check: the registered function graph is inconsistent." + )?; + for defect in self.0 { + writeln!(f, " - {defect}")?; + } + Ok(()) + } } -fn check_affects_targets() { +/// Every `merge` declaration that resolved to exactly one slot, plus every way +/// the graph failed to hold together. +struct GraphAnalysis { + merges: Vec, + defects: Vec, +} + +static ANALYSIS: OnceLock = OnceLock::new(); + +/// `FUNCTIONS` and `CONTEXTS` are link-time data, so the walk yields the same +/// answer for every caller and runs once. +fn analysis() -> &'static GraphAnalysis { + ANALYSIS.get_or_init(analyze) +} + +fn analyze() -> GraphAnalysis { + let mut merges = Vec::new(); + let mut defects = Vec::new(); for fn_spec in FUNCTIONS { - for affect in fn_spec.affects() { - if let AffectTarget::Context(name) = affect { - if lookup_context(name).is_none() { - panic!( - "Mizan graph-check: function `{}` declares `affects = \"{}\"` but no context with that name is registered. \ - Either register a context with that name (via `#[mizan::context(\"{}\")]`) or remove the affects target.", - fn_spec.name(), - name, - name, - ); - } + let mutation_shape = fn_spec.output_shape(); + for &context in fn_spec.merge() { + match match_slot(context, &mutation_shape) { + SlotMatch::Unique(slot) => merges.push(ResolvedMerge { + function: fn_spec.name(), + context, + slot, + }), + SlotMatch::Absent => defects.push(GraphDefect::NoMergeSlot { + function: fn_spec.name(), + context, + output_type: fn_spec.output_type(), + }), + SlotMatch::Ambiguous(members) => defects.push(GraphDefect::AmbiguousMergeSlot { + function: fn_spec.name(), + context, + output_type: fn_spec.output_type(), + members, + }), } } } + defects.extend(divergent_param_types()); + GraphAnalysis { merges, defects } } -fn check_merge_targets() { - for fn_spec in FUNCTIONS { - for merge_target in fn_spec.merge() { - let ctx_entry = match lookup_context(merge_target) { - Some(c) => c, - None => panic!( - "Mizan graph-check: function `{}` declares `merge = \"{}\"` but no context with that name is registered.", - fn_spec.name(), - merge_target, - ), - }; - - let mutation_output = fn_spec.output_type(); - let mutation_shape = match resolve_type_shape(mutation_output) { - Some(s) => s, - None => panic!( - "Mizan graph-check: function `{}` has output type `{}` but no such named type is registered.", - fn_spec.name(), mutation_output, - ), - }; - let mut matches: Vec<&'static str> = Vec::new(); - for candidate in FUNCTIONS { - if candidate.context() != Some(ctx_entry.name) { - continue; - } - if let Some(candidate_shape) = resolve_type_shape(candidate.output_type()) { - if types_match(&candidate_shape, &mutation_shape) { - matches.push(candidate.name()); - } - } - } - - if matches.is_empty() { - panic!( - "Mizan graph-check: function `{}` declares `merge = \"{}\"` but no member of that context has output type `{}`. \ - Add a context member returning `{}`, or remove the merge declaration in favor of `affects` for plain refetch.", - fn_spec.name(), merge_target, mutation_output, mutation_output, - ); - } - if matches.len() > 1 { - panic!( - "Mizan graph-check: function `{}` declares `merge = \"{}\"` but multiple members ({}) share output type `{}`. \ - Merge resolution requires exactly one match. Distinguish the outputs or use `affects` for refetch.", - fn_spec.name(), merge_target, matches.join(", "), mutation_output, - ); - } +/// The members of `context_name` whose output type a value of `mutation_shape` +/// splices into. +fn match_slot(context_name: &'static str, mutation_shape: &NamedType) -> SlotMatch { + let mut matched = SlotMatch::Absent; + for candidate in FUNCTIONS { + if candidate.context() != Some(context_name) { + continue; + } + if types_match(&candidate.output_shape(), mutation_shape) { + matched = matched.with(candidate.name()); } } + matched } -fn check_shared_param_types() { +/// Params that one context's members declare under the same name but with +/// different primitives. +fn divergent_param_types() -> Vec { + let mut defects = Vec::new(); for ctx in CONTEXTS { - let mut by_name: std::collections::HashMap<&'static str, (crate::ir::Primitive, &'static str)> - = std::collections::HashMap::new(); + let mut by_name: HashMap<&'static str, (Primitive, &'static str)> = HashMap::new(); for fn_spec in FUNCTIONS { if fn_spec.context() != Some(ctx.name) { continue; } for p in fn_spec.input_params() { - if let Some((prev_primitive, prev_fn)) = by_name.get(p.name) { - if std::mem::discriminant(prev_primitive) - != std::mem::discriminant(&p.primitive) - { - panic!( - "Mizan graph-check: context `{}` has a parameter `{}` whose type diverges across members. \ - Function `{}` declares it as `{}`, function `{}` declares it as `{}`. \ - Shared params must have one type across the whole context.", - ctx.name, p.name, - prev_fn, prev_primitive.name(), - fn_spec.name(), p.primitive.name(), - ); + match by_name.entry(p.name) { + Entry::Occupied(seen) => { + let (first_primitive, first_fn) = *seen.get(); + if first_primitive != p.primitive { + defects.push(GraphDefect::DivergentParamType { + context: ctx.name, + param: p.name, + first_fn, + first_type: first_primitive.name(), + second_fn: fn_spec.name(), + second_type: p.primitive.name(), + }); + } + } + Entry::Vacant(slot) => { + slot.insert((p.primitive, fn_spec.name())); } - } else { - by_name.insert(p.name, (p.primitive, fn_spec.name())); } } } } + defects +} + +/// Panic with the full defect report when the registered function graph is +/// inconsistent. +pub fn verify_invariants() { + let defects = &analysis().defects; + if !defects.is_empty() { + panic!("{}", GraphReport(defects)); + } +} + +/// The merges `function` declares. Reading them verifies the graph first, so a +/// declaration that resolved to no slot is reported rather than passed over. +pub(crate) fn merges_for(function: &str) -> impl Iterator + '_ { + verify_invariants(); + analysis() + .merges + .iter() + .filter(move |resolved| resolved.function == function) } diff --git a/cores/mizan-rust/src/ir.rs b/cores/mizan-rust/src/ir.rs index 108ab52..1f86437 100644 --- a/cores/mizan-rust/src/ir.rs +++ b/cores/mizan-rust/src/ir.rs @@ -1,13 +1,10 @@ -//! IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` 1:1. -//! -//! The IR is the contract. Backends emit it; codegen consumes it. The Rust -//! side produces byte-equivalent KDL to the Python emitter against the same -//! function registry. +//! The IR data model the KDL emitter walks: named types, inline type shapes, +//! and the descriptors a registered function or channel carries. /// A named type that appears in the IR's `type "" { ... }` section. #[derive(Debug, Clone)] pub enum NamedType { - /// `type "X" { struct { field ... } }` — a Pydantic-model-shaped record. + /// `type "X" { struct { field ... } }` — a record. Struct(Vec), /// `type "X" { alias { } }` — a named wrapper around an /// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`. @@ -21,14 +18,20 @@ pub enum NamedType { #[derive(Debug, Clone)] pub enum TypeShape { Primitive(Primitive), - Ref(&'static str), + /// A reference to a named type. `shape` is the referent's own shape + /// constructor, so resolving a reference never consults a registry and + /// never fails. + Ref { + name: &'static str, + shape: fn() -> NamedType, + }, List(Box), Optional(Box), Enum(Vec<&'static str>), Union(Vec), } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Primitive { Integer, Number, @@ -64,8 +67,8 @@ pub enum DefaultValue { Null, } -/// One descriptor of what a mutation `affects`. Mirrors Python's -/// `_normalize_affects` shape — either a named context or a named function. +/// One descriptor of what a mutation `affects` — either a named context or a +/// named function. #[derive(Debug, Clone)] pub enum AffectTarget { Context(&'static str), @@ -75,6 +78,37 @@ pub enum AffectTarget { }, } +/// One payload slot of a channel. Direction is named from the client's point +/// of view: a `ClientMessage` travels client → server, a `ServerMessage` +/// travels server → client. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelSlotKind { + Params, + ClientMessage, + ServerMessage, +} + +impl ChannelSlotKind { + /// The KDL child-node name the slot emits under. + pub fn node_name(self) -> &'static str { + match self { + ChannelSlotKind::Params => "params", + ChannelSlotKind::ClientMessage => "client-message", + ChannelSlotKind::ServerMessage => "server-message", + } + } + + /// The suffix appended to the channel's Pascal stem to name the slot's + /// emitted type. + pub fn type_suffix(self) -> &'static str { + match self { + ChannelSlotKind::Params => "Params", + ChannelSlotKind::ClientMessage => "ClientMessage", + ChannelSlotKind::ServerMessage => "ServerMessage", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Transport { Http, diff --git a/cores/mizan-rust/src/kdl.rs b/cores/mizan-rust/src/kdl.rs index c67dcd1..37708a4 100644 --- a/cores/mizan-rust/src/kdl.rs +++ b/cores/mizan-rust/src/kdl.rs @@ -1,60 +1,141 @@ -//! KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`. -//! -//! The Python emitter is the spec; this is the second implementation under -//! the same contract. Any divergence is a bug here, not a contract change. +//! KDL emitter — collects the registries (named types, functions, contexts, +//! channels) into a KDL node tree and renders it through +//! `templates/ir.kdl.jinja`. -use crate::ir::{DefaultValue, NamedType, Primitive, StructField, TypeShape}; -use crate::registry::{CONTEXTS, FUNCTIONS, TYPES}; +use crate::ir::{ + AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, TypeShape, +}; +use crate::registry::{CHANNELS, CONTEXTS, FUNCTIONS, TYPES}; use crate::traits::FunctionSpec; +use minijinja::value::ViaDeserialize; +use minijinja::{context, Environment}; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; const INDENT: &str = " "; +const IR_TEMPLATE: &str = include_str!("../templates/ir.kdl.jinja"); -/// Escape a string for KDL — same escape set as the Python emitter. -fn kdl_string(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - other => out.push(other), +/// A KDL scalar, carried structurally so the template's `kdl` filter — not +/// the node builders — decides its written form. +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "kind", content = "v")] +enum KdlValue { + Str(String), + Bool(bool), + Integer(i64), + Number(f64), + Null, +} + +impl KdlValue { + fn str(s: &str) -> Self { + KdlValue::Str(s.to_string()) + } + + fn of_default(v: &DefaultValue) -> Self { + match v { + DefaultValue::Null => KdlValue::Null, + DefaultValue::Boolean(b) => KdlValue::Bool(*b), + DefaultValue::Integer(i) => KdlValue::Integer(*i), + DefaultValue::Number(f) => KdlValue::Number(*f), + DefaultValue::String(s) => KdlValue::str(s), } } - out.push('"'); - out } -fn kdl_bool(b: bool) -> &'static str { - if b { - "#true" - } else { - "#false" +#[derive(Serialize)] +struct KdlProp { + name: &'static str, + value: KdlValue, +} + +/// One KDL node: its own line, plus a brace-delimited child block when +/// `block` is set. `indent` is the literal prefix its line carries. +#[derive(Serialize)] +struct KdlNode { + indent: String, + name: &'static str, + args: Vec, + props: Vec, + block: bool, + children: Vec, +} + +impl KdlNode { + fn new(depth: usize, name: &'static str) -> Self { + Self { + indent: INDENT.repeat(depth), + name, + args: Vec::new(), + props: Vec::new(), + block: false, + children: Vec::new(), + } + } + + fn arg(mut self, value: KdlValue) -> Self { + self.args.push(value); + self + } + + fn args(mut self, values: impl IntoIterator) -> Self { + self.args.extend(values); + self + } + + fn prop(mut self, name: &'static str, value: KdlValue) -> Self { + self.props.push(KdlProp { name, value }); + self + } + + fn block(mut self, children: Vec) -> Self { + self.block = true; + self.children = children; + self } } -fn kdl_default(v: &DefaultValue) -> String { - match v { - DefaultValue::Null => "#null".into(), - DefaultValue::Boolean(b) => kdl_bool(*b).into(), - DefaultValue::Integer(i) => i.to_string(), - DefaultValue::Number(f) => { - // Match Python's `repr(float)` for whole-number-equal-but-float - // values: e.g. 1.0 → "1.0", not "1". +/// The `kdl` template filter — writes one scalar in KDL surface syntax. +fn render_kdl_value(value: ViaDeserialize) -> String { + match &*value { + KdlValue::Str(s) => { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + other => out.push(other), + } + } + out.push('"'); + out + } + KdlValue::Bool(b) => { + if *b { + "#true".to_string() + } else { + "#false".to_string() + } + } + KdlValue::Integer(i) => i.to_string(), + KdlValue::Number(f) => { + // A whole-valued float still writes with its fractional part, so + // `1.0` does not collapse into the integer spelling `1`. if f.fract() == 0.0 && f.is_finite() { format!("{f:.1}") } else { f.to_string() } } - DefaultValue::String(s) => kdl_string(s), + KdlValue::Null => "#null".to_string(), } } -/// Convert snake_case to camelCase. Matches Python's `_snake_to_camel`. +/// Convert snake_case to camelCase. pub fn snake_to_camel(name: &str) -> String { let normalized = name.replace('.', "_").replace('-', "_"); let mut parts = normalized.split('_'); @@ -75,208 +156,159 @@ pub fn snake_to_camel(name: &str) -> String { out } -struct Emitter<'a> { - lines: Vec, - /// Types whose references should be substituted with their inline - /// shape at the use site (and which don't emit as their own - /// `type "X" { ... }` entries). Populated from `IrSnapshot::inlines`. +/// The PascalCase stem every emitted type name for `wire_name` is built on: +/// split on `[._-]`, then title-case each part, where a character is +/// uppercased only when the character before it is not a letter. +pub fn wire_to_pascal(wire_name: &str) -> String { + let mut out = String::with_capacity(wire_name.len()); + for part in wire_name.split(['.', '_', '-']) { + let mut prev_is_letter = false; + for c in part.chars() { + if prev_is_letter { + out.extend(c.to_lowercase()); + } else { + out.extend(c.to_uppercase()); + } + prev_is_letter = c.is_alphabetic(); + } + } + out +} + +/// Builds the node tree for one document. +struct NodeBuilder<'a> { + /// Types whose references are substituted with their inline shape at the + /// use site, and which emit no `type "X" { ... }` entry of their own. inlines: &'a BTreeMap<&'static str, TypeShape>, } -impl<'a> Emitter<'a> { - fn new(inlines: &'a BTreeMap<&'static str, TypeShape>) -> Self { - Self { - lines: Vec::new(), - inlines, - } - } - - fn prefix(&self, indent: usize) -> String { - INDENT.repeat(indent) - } - - fn leaf(&mut self, indent: usize, parts: &[&str]) { - let mut line = self.prefix(indent); - line.push_str(&parts.join(" ")); - self.lines.push(line); - } - - fn open(&mut self, indent: usize, parts: &[&str]) { - let mut line = self.prefix(indent); - line.push_str(&parts.join(" ")); - line.push_str(" {"); - self.lines.push(line); - } - - fn close(&mut self, indent: usize) { - let mut line = self.prefix(indent); - line.push('}'); - self.lines.push(line); - } - - fn blank(&mut self) { - self.lines.push(String::new()); - } - - fn emit_type_child(&mut self, indent: usize, shape: &TypeShape) { +impl NodeBuilder<'_> { + fn type_child(&self, depth: usize, shape: &TypeShape) -> KdlNode { match shape { TypeShape::Primitive(p) => { - let name = kdl_string(p.name()); - self.leaf(indent, &["primitive", &name]); - } - TypeShape::Ref(name) => { - // Inline-substitute when the referenced type is a - // primitive-alias or string-enum. Matches Python's - // Pydantic Literal/alias inlining. - if let Some(inline_shape) = self.inlines.get(name).cloned() { - self.emit_type_child(indent, &inline_shape); - return; - } - let n = kdl_string(name); - self.leaf(indent, &["ref", &n]); + KdlNode::new(depth, "primitive").arg(KdlValue::str(p.name())) } + TypeShape::Ref { name, .. } => match self.inlines.get(name) { + Some(inline_shape) => self.type_child(depth, &inline_shape.clone()), + None => KdlNode::new(depth, "ref").arg(KdlValue::str(name)), + }, TypeShape::List(inner) => { - self.open(indent, &["list"]); - self.emit_type_child(indent + 1, inner); - self.close(indent); + KdlNode::new(depth, "list").block(vec![self.type_child(depth + 1, inner)]) } TypeShape::Optional(inner) => { - self.open(indent, &["optional"]); - self.emit_type_child(indent + 1, inner); - self.close(indent); + KdlNode::new(depth, "optional").block(vec![self.type_child(depth + 1, inner)]) } TypeShape::Enum(variants) => { - let mut parts: Vec = vec!["enum".into()]; - for v in variants { - parts.push(kdl_string(v)); - } - let line: Vec<&str> = parts.iter().map(String::as_str).collect(); - self.leaf(indent, &line); - } - TypeShape::Union(branches) => { - self.open(indent, &["union"]); - for b in branches { - self.emit_type_child(indent + 1, b); - } - self.close(indent); + KdlNode::new(depth, "enum").args(variants.iter().map(|v| KdlValue::str(v))) } + TypeShape::Union(branches) => KdlNode::new(depth, "union").block( + branches + .iter() + .map(|b| self.type_child(depth + 1, b)) + .collect(), + ), } } - fn emit_named_type(&mut self, indent: usize, name: &str, body: &NamedType) { - let name_lit = kdl_string(name); - self.open(indent, &["type", &name_lit]); - match body { - NamedType::Struct(fields) => { - self.open(indent + 1, &["struct"]); - for field in fields { - self.emit_struct_field(indent + 2, field); - } - self.close(indent + 1); - } - NamedType::Alias(inner) => { - self.open(indent + 1, &["alias"]); - self.emit_type_child(indent + 2, inner); - self.close(indent + 1); + fn named_type(&self, depth: usize, name: &str, body: &NamedType) -> KdlNode { + let inner = match body { + NamedType::Struct(fields) => KdlNode::new(depth + 1, "struct").block( + fields + .iter() + .map(|field| self.struct_field(depth + 2, field)) + .collect(), + ), + NamedType::Alias(shape) => { + KdlNode::new(depth + 1, "alias").block(vec![self.type_child(depth + 2, shape)]) } NamedType::Enum(variants) => { - let mut parts: Vec = vec!["enum".into()]; - for v in variants { - parts.push(kdl_string(v)); - } - let line: Vec<&str> = parts.iter().map(String::as_str).collect(); - self.leaf(indent + 1, &line); + KdlNode::new(depth + 1, "enum").args(variants.iter().map(|v| KdlValue::str(v))) } - } - self.close(indent); + }; + KdlNode::new(depth, "type") + .arg(KdlValue::str(name)) + .block(vec![inner]) } - fn emit_struct_field(&mut self, indent: usize, field: &StructField) { - let name = kdl_string(field.name); - let mut header: Vec = vec!["field".into(), name]; + fn struct_field(&self, depth: usize, field: &StructField) -> KdlNode { + let mut node = KdlNode::new(depth, "field").arg(KdlValue::str(field.name)); if !field.required { - header.push(format!("required={}", kdl_bool(false))); + node = node.prop("required", KdlValue::Bool(false)); if let Some(default) = &field.default { - header.push(format!("default={}", kdl_default(default))); + node = node.prop("default", KdlValue::of_default(default)); } } - let line_parts: Vec<&str> = header.iter().map(String::as_str).collect(); - self.open(indent, &line_parts); - self.emit_type_child(indent + 1, &field.shape); - self.close(indent); + node.block(vec![self.type_child(depth + 1, &field.shape)]) } - fn emit_function(&mut self, indent: usize, fn_spec: &dyn FunctionSpec) { - let name = kdl_string(fn_spec.name()); - self.open(indent, &["function", &name]); - - let camel = kdl_string(fn_spec.camel_name()); - self.leaf(indent + 1, &["camel", &camel]); - - self.leaf(indent + 1, &["has-input", kdl_bool(fn_spec.has_input())]); + fn function(&self, depth: usize, fn_spec: &dyn FunctionSpec) -> KdlNode { + let inner = depth + 1; + let mut children = vec![ + KdlNode::new(inner, "camel").arg(KdlValue::str(fn_spec.camel_name())), + KdlNode::new(inner, "has-input").arg(KdlValue::Bool(fn_spec.has_input())), + ]; if let Some(input_type) = fn_spec.input_type() { - let lit = kdl_string(input_type); - self.leaf(indent + 1, &["input", &lit]); + children.push(KdlNode::new(inner, "input").arg(KdlValue::str(input_type))); } - let output_lit = kdl_string(fn_spec.output_type()); - self.leaf(indent + 1, &["output", &output_lit]); + children.push(KdlNode::new(inner, "output").arg(KdlValue::str(fn_spec.output_type()))); if fn_spec.output_nullable() { - self.leaf(indent + 1, &["output-nullable", kdl_bool(true)]); + children.push(KdlNode::new(inner, "output-nullable").arg(KdlValue::Bool(true))); } - let transport_lit = kdl_string(fn_spec.transport().name()); - self.leaf(indent + 1, &["transport", &transport_lit]); + children + .push(KdlNode::new(inner, "transport").arg(KdlValue::str(fn_spec.transport().name()))); if let Some(ctx) = fn_spec.context() { - let lit = kdl_string(ctx); - self.leaf(indent + 1, &["context", &lit]); + children.push(KdlNode::new(inner, "context").arg(KdlValue::str(ctx))); } for affect in fn_spec.affects() { - // Mirror Python's behavior: only context-typed affects make it - // into the KDL `affects` leaf. Function-typed affects are - // reserved for a future IR extension. - if let crate::ir::AffectTarget::Context(name) = affect { - let lit = kdl_string(name); - self.leaf(indent + 1, &["affects", &lit]); + match affect { + // The `affects` leaf names a context; a function-typed target + // has no leaf in the document. + AffectTarget::Context(name) => { + children.push(KdlNode::new(inner, "affects").arg(KdlValue::str(name))); + } + AffectTarget::Function { .. } => {} } } for merge in fn_spec.merge() { - let lit = kdl_string(merge); - self.leaf(indent + 1, &["merge", &lit]); + children.push(KdlNode::new(inner, "merge").arg(KdlValue::str(merge))); } if fn_spec.is_form() { - self.leaf(indent + 1, &["is-form", kdl_bool(true)]); + children.push(KdlNode::new(inner, "is-form").arg(KdlValue::Bool(true))); if let Some(form_name) = fn_spec.form_name() { - let lit = kdl_string(form_name); - self.leaf(indent + 1, &["form-name", &lit]); + children.push(KdlNode::new(inner, "form-name").arg(KdlValue::str(form_name))); } if let Some(form_role) = fn_spec.form_role() { - let lit = kdl_string(form_role); - self.leaf(indent + 1, &["form-role", &lit]); + children.push(KdlNode::new(inner, "form-role").arg(KdlValue::str(form_role))); } } - self.close(indent); + KdlNode::new(depth, "function") + .arg(KdlValue::str(fn_spec.name())) + .block(children) } - fn emit_context(&mut self, indent: usize, ctx_name: &str, members: &[&'static dyn FunctionSpec]) { - let name_lit = kdl_string(ctx_name); - self.open(indent, &["context", &name_lit]); + fn context( + &self, + depth: usize, + ctx_name: &str, + members: &[&'static dyn FunctionSpec], + ) -> KdlNode { + let inner = depth + 1; + let mut children: Vec = members + .iter() + .map(|fn_spec| KdlNode::new(inner, "function").arg(KdlValue::str(fn_spec.name()))) + .collect(); - // Function membership in registration order. - for fn_spec in members { - let lit = kdl_string(fn_spec.name()); - self.leaf(indent + 1, &["function", &lit]); - } - - // Param info — collect across every member, then emit alphabetized - // by param name to match Python. + // Params collected across every member, keyed so they emit + // alphabetized by param name. struct ParamSlot { primitive: Primitive, shared_by: Vec<&'static str>, @@ -295,120 +327,163 @@ impl<'a> Emitter<'a> { let member_count = members.len(); for (param_name, slot) in params.iter() { - let name_lit = kdl_string(param_name); - self.open(indent + 1, &["param", &name_lit]); - let type_lit = kdl_string(slot.primitive.name()); - self.leaf(indent + 2, &["type", &type_lit]); - let required = slot.shared_by.len() == member_count; - self.leaf(indent + 2, &["required", kdl_bool(required)]); + let mut param_children = vec![ + KdlNode::new(inner + 1, "type").arg(KdlValue::str(slot.primitive.name())), + KdlNode::new(inner + 1, "required") + .arg(KdlValue::Bool(slot.shared_by.len() == member_count)), + ]; for sharer in &slot.shared_by { - let lit = kdl_string(sharer); - self.leaf(indent + 2, &["shared-by", &lit]); + param_children + .push(KdlNode::new(inner + 1, "shared-by").arg(KdlValue::str(sharer))); } - self.close(indent + 1); + children.push( + KdlNode::new(inner, "param") + .arg(KdlValue::str(param_name)) + .block(param_children), + ); } - self.close(indent); + KdlNode::new(depth, "context") + .arg(KdlValue::str(ctx_name)) + .block(children) } - fn into_string(mut self) -> String { - // Trim trailing blanks, then add a single terminating newline. - while matches!(self.lines.last(), Some(s) if s.is_empty()) { - self.lines.pop(); + fn channel(&self, depth: usize, channel: &ChannelRecord) -> KdlNode { + let inner = depth + 1; + let mut children = + vec![KdlNode::new(inner, "pascal-name").arg(KdlValue::str(&channel.pascal_name))]; + for slot in &channel.slots { + children.push( + KdlNode::new(inner, slot.kind.node_name()).arg(KdlValue::str(&slot.type_name)), + ); } - let mut out = self.lines.join("\n"); - out.push('\n'); - out + KdlNode::new(depth, "channel") + .arg(KdlValue::str(channel.name)) + .block(children) } } +/// One channel as the document carries it: the wire name, the Pascal stem its +/// slot type names are built on, and the slots it declares. +pub(crate) struct ChannelRecord { + pub name: &'static str, + pub pascal_name: String, + pub slots: Vec, +} + +pub(crate) struct ChannelSlotRecord { + pub kind: ChannelSlotKind, + pub type_name: String, +} + /// Collected typed registries view used by `build_ir`. pub(crate) struct IrSnapshot { - pub types: BTreeMap<&'static str, NamedType>, + pub types: BTreeMap, pub functions: Vec<&'static dyn FunctionSpec>, pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>, - /// Types that inline to a `TypeShape` at every reference site rather - /// than emitting as their own `type "X" { ... }` entry. Populated from - /// `Alias(Primitive(_))` and `Enum` named types — both are - /// information-zero indirections that the codegen consumer doesn't - /// gain anything from naming. Matches the Python emitter's behavior - /// (Pydantic `FigureId = str` and `Literal["..."]` inline; they don't - /// materialize as named types). + pub channels: Vec, + /// Types that inline to a `TypeShape` at every reference site rather than + /// emitting a `type "X" { ... }` entry: `Alias(Primitive(_))` and `Enum`, + /// both of which carry no structure a named entry would add. pub inlines: BTreeMap<&'static str, TypeShape>, } impl IrSnapshot { pub(crate) fn collect() -> Self { - // Types: alphabetized for byte-equivalence with Python's `sorted(named_types)`. + // Types: alphabetized, which is the document's canonical ordering. let mut all_types: BTreeMap<&'static str, NamedType> = BTreeMap::new(); for entry in TYPES { all_types.insert(entry.name, (entry.shape_fn)()); } - // Partition into emit-candidate types vs inlines. An inline is a - // named type whose shape collapses to a single `TypeShape` at the - // field site — primitive aliases and string enums. + // Partition into emit-candidate types vs inlines. let mut candidates: BTreeMap<&'static str, NamedType> = BTreeMap::new(); let mut inlines: BTreeMap<&'static str, TypeShape> = BTreeMap::new(); for (name, body) in all_types { - match &body { - NamedType::Alias(TypeShape::Primitive(p)) => { - inlines.insert(name, TypeShape::Primitive(*p)); - } + match body { NamedType::Enum(variants) => { - inlines.insert(name, TypeShape::Enum(variants.clone())); + inlines.insert(name, TypeShape::Enum(variants)); } - _ => { - candidates.insert(name, body); + NamedType::Alias(TypeShape::Primitive(p)) => { + inlines.insert(name, TypeShape::Primitive(p)); + } + NamedType::Alias(shape) => { + candidates.insert(name, NamedType::Alias(shape)); + } + NamedType::Struct(fields) => { + candidates.insert(name, NamedType::Struct(fields)); } } } - // Tree-shake: keep only types reachable from a registered function's - // input/output. The function macro registers canonical-named - // entries (e.g. `userPrefsOutput`); derive registers original-named - // entries (`UserPrefs`, `BrushSettings`, …). Only those reached - // via Ref-walk from a function's input/output names belong in the - // emitted IR. Mirrors Python's `_collect_named_types`. + // Channels: alphabetical by wire name, each declared slot's type + // named ``. The slot shapes enter the type section + // directly, so they are emitted whether or not a function reaches + // them. + let mut channel_entries: Vec<&'static crate::registry::ChannelEntry> = + CHANNELS.iter().collect(); + channel_entries.sort_by_key(|c| c.name); + let mut channels: Vec = Vec::new(); + let mut channel_types: Vec<(String, NamedType)> = Vec::new(); + for entry in channel_entries { + let pascal_name = wire_to_pascal(entry.name); + let mut slots: Vec = Vec::new(); + for slot in entry.slots { + let type_name = format!("{pascal_name}{}", slot.kind.type_suffix()); + channel_types.push((type_name.clone(), (slot.shape_fn)())); + slots.push(ChannelSlotRecord { + kind: slot.kind, + type_name, + }); + } + channels.push(ChannelRecord { + name: entry.name, + pascal_name, + slots, + }); + } + + // Roots of the tree-shake: every non-private function's input and + // output name, plus every name a channel slot's shape refs. let mut reachable: std::collections::HashSet<&'static str> = std::collections::HashSet::new(); - let mut frontier: Vec<&'static str> = Vec::new(); for fn_spec in FUNCTIONS { if fn_spec.private() { continue; } if let Some(input_name) = fn_spec.input_type() { - if reachable.insert(input_name) { - frontier.push(input_name); - } - } - let output_name = fn_spec.output_type(); - if reachable.insert(output_name) { - frontier.push(output_name); + reachable.insert(input_name); } + reachable.insert(fn_spec.output_type()); } - while let Some(name) = frontier.pop() { - // Inlines don't carry refs we care about (Primitive/Enum); skip. - if inlines.contains_key(name) { - continue; - } - let body = match candidates.get(name) { - Some(b) => b.clone(), - None => continue, - }; - collect_refs(&body, &mut |r| { - if reachable.insert(r) { - frontier.push(r); - } + for (_, body) in &channel_types { + collect_refs(body, &mut |r| { + reachable.insert(r); }); } - let types: BTreeMap<&'static str, NamedType> = candidates + // Grow the set until a pass adds nothing: a candidate contributes the + // names it refs once it is itself reachable. + loop { + let mut grew = false; + for (name, body) in &candidates { + if reachable.contains(name) { + collect_refs(body, &mut |r| { + grew |= reachable.insert(r); + }); + } + } + if !grew { + break; + } + } + let mut types: BTreeMap = candidates .into_iter() .filter(|(name, _)| reachable.contains(name)) + .map(|(name, body)| (name.to_string(), body)) .collect(); + types.extend(channel_types); - // Functions: alphabetical by wire name (canonical IR ordering, - // matches the Python emitter's `sorted(functions)`). Skip `private`. + // Functions: alphabetical by wire name. Skip `private`. let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS .iter() .copied() @@ -416,8 +491,8 @@ impl IrSnapshot { .collect(); functions.sort_by_key(|f| f.name()); - // Contexts: alphabetical by name (canonical IR ordering), each with - // its members sorted alphabetically too. + // Contexts: alphabetical by name, each with its members sorted + // alphabetically too. let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect(); context_names.sort(); let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new(); @@ -437,6 +512,7 @@ impl IrSnapshot { types, functions, contexts, + channels, inlines, } } @@ -457,7 +533,7 @@ fn collect_refs(body: &NamedType, visit: &mut F) { fn walk_shape_refs(shape: &TypeShape, visit: &mut F) { match shape { - TypeShape::Ref(name) => visit(name), + TypeShape::Ref { name, .. } => visit(name), TypeShape::List(inner) | TypeShape::Optional(inner) => walk_shape_refs(inner, visit), TypeShape::Union(branches) => { for b in branches { @@ -468,41 +544,41 @@ fn walk_shape_refs(shape: &TypeShape, visit: &mut F) { } } -/// Build the Mizan IR for every registered type/function/context. Returns KDL. +/// Build the Mizan IR for every registered type, function, context and +/// channel. Returns KDL. pub fn build_ir() -> String { crate::graph_check::verify_invariants(); let snap = IrSnapshot::collect(); - let mut em = Emitter::new(&snap.inlines); + let builder = NodeBuilder { + inlines: &snap.inlines, + }; - // Type definitions - let types_emitted = !snap.types.is_empty(); - for (name, body) in &snap.types { - em.emit_named_type(0, name, body); - } - if types_emitted { - em.blank(); - } + let sections: Vec> = [ + snap.types + .iter() + .map(|(name, body)| builder.named_type(0, name, body)) + .collect::>(), + snap.functions + .iter() + .map(|fn_spec| builder.function(0, *fn_spec)) + .collect(), + snap.contexts + .iter() + .map(|(ctx_name, members)| builder.context(0, ctx_name, members)) + .collect(), + snap.channels + .iter() + .map(|channel| builder.channel(0, channel)) + .collect(), + ] + .into_iter() + .filter(|section: &Vec| !section.is_empty()) + .collect(); - // Functions - let fns_emitted = !snap.functions.is_empty(); - for fn_spec in &snap.functions { - em.emit_function(0, *fn_spec); - } - if fns_emitted { - em.blank(); - } - - // Contexts - let ctxs_emitted = !snap.contexts.is_empty(); - for (ctx_name, members) in &snap.contexts { - em.emit_context(0, ctx_name, members); - } - if ctxs_emitted { - em.blank(); - } - - // Future: channels — once channel registry lands on the Rust side. - - em.into_string() + let mut env = Environment::new(); + env.add_filter("kdl", render_kdl_value); + env.template_from_named_str("ir.kdl", IR_TEMPLATE) + .expect("compile templates/ir.kdl.jinja") + .render(context! { sections }) + .expect("render templates/ir.kdl.jinja") } - diff --git a/cores/mizan-rust/src/lib.rs b/cores/mizan-rust/src/lib.rs index ae0ab97..591eb81 100644 --- a/cores/mizan-rust/src/lib.rs +++ b/cores/mizan-rust/src/lib.rs @@ -1,15 +1,14 @@ -//! Mizan server-side IR substrate. Rust analog of `cores/mizan-python/src/mizan_core/`. +//! Mizan server-side IR substrate. //! //! Three load-bearing concerns: //! -//! 1. **IR data model + KDL emitter.** `build_ir()` produces byte-equivalent -//! KDL to the Python emitter. Both backends emit the same contract. +//! 1. **IR data model + KDL emitter.** `build_ir()` renders the registries as +//! one Mizan IR document. //! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate -//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`) at the -//! consumer crate's expansion sites. +//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`, `CHANNELS`) +//! at the consumer crate's expansion sites. //! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` / -//! `lookup_function` ported from `mizan-fastapi`'s executor; the HTTP -//! adapter calls these per request. +//! `function_named` / `context_members`, which the adapters call per request. //! //! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at //! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`. @@ -22,12 +21,13 @@ pub mod runtime; pub mod traits; pub use ir::{ - AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape, + AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, Transport, + TypeShape, }; -pub use kdl::{build_ir, snake_to_camel}; +pub use kdl::{build_ir, snake_to_camel, wire_to_pascal}; pub use registry::{ - context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS, - FUNCTIONS, TYPES, + context_members, function_named, ChannelEntry, ChannelSlot, ContextEntry, TypeEntry, CHANNELS, + CONTEXTS, FUNCTIONS, TYPES, }; pub use runtime::{ compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError, @@ -35,21 +35,20 @@ pub use runtime::{ }; pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType}; -// Re-export proc macros so consumers depend on one crate. -pub use mizan_macros::{client, context, Mizan}; +pub use mizan_macros::{channel, client, context, Mizan}; pub mod prelude { pub use crate::ir::{ - AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape, + AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, Transport, + TypeShape, }; - pub use crate::registry::{ContextEntry, TypeEntry}; + pub use crate::registry::{ChannelEntry, ChannelSlot, ContextEntry, TypeEntry}; pub use crate::runtime::{MizanError, RequestHandle}; pub use crate::traits::{ContextMarker, FunctionSpec, InputParam, MizanType}; pub use mizan_macros::Mizan; } -/// Internal re-exports used by `mizan-macros`-generated code. Not part of -/// the public API — consumers must not depend on names under `__priv`. +/// The crates `mizan-macros` expansions name by absolute path. #[doc(hidden)] pub mod __priv { pub use linkme; diff --git a/cores/mizan-rust/src/registry.rs b/cores/mizan-rust/src/registry.rs index b7f7b83..ec4996a 100644 --- a/cores/mizan-rust/src/registry.rs +++ b/cores/mizan-rust/src/registry.rs @@ -2,7 +2,7 @@ //! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]` //! statics that land here at link time. -use crate::ir::NamedType; +use crate::ir::{ChannelSlotKind, NamedType}; use crate::traits::FunctionSpec; use linkme::distributed_slice; @@ -17,6 +17,21 @@ pub struct ContextEntry { pub name: &'static str, } +/// One declared payload slot of a channel. `shape_fn` yields the shape the +/// slot's type emits under its derived name. +pub struct ChannelSlot { + pub kind: ChannelSlotKind, + pub shape_fn: fn() -> NamedType, +} + +/// One channel registration. Emitted by `#[mizan::channel]`. `slots` carries +/// only the slots the channel declares, ordered params, client-message, +/// server-message. +pub struct ChannelEntry { + pub name: &'static str, + pub slots: &'static [ChannelSlot], +} + #[distributed_slice] pub static TYPES: [TypeEntry] = [..]; @@ -26,18 +41,21 @@ pub static CONTEXTS: [ContextEntry] = [..]; #[distributed_slice] pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..]; -/// Find a registered function by wire name. Used by the HTTP adapter. -pub fn lookup_function(name: &str) -> Option<&'static dyn FunctionSpec> { - FUNCTIONS.iter().copied().find(|f| f.name() == name) +#[distributed_slice] +pub static CHANNELS: [ChannelEntry] = [..]; + +/// The functions registered under `name`. Order matches `FUNCTIONS` iteration +/// order — i.e., registration order. +pub fn function_named(name: &str) -> Vec<&'static dyn FunctionSpec> { + FUNCTIONS + .iter() + .copied() + .filter(|f| f.name() == name) + .collect() } -/// Find a registered context by name. Used by graph_check. -pub fn lookup_context(name: &str) -> Option<&'static ContextEntry> { - CONTEXTS.iter().find(|c| c.name == name) -} - -/// All functions that declare a given context as their `context` membership. -/// Order matches `FUNCTIONS` iteration order — i.e., registration order. +/// The functions that declare `ctx_name` as their `context` membership. Order +/// matches `FUNCTIONS` iteration order — i.e., registration order. pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> { FUNCTIONS .iter() diff --git a/cores/mizan-rust/src/runtime.rs b/cores/mizan-rust/src/runtime.rs index c1a01fd..692c66c 100644 --- a/cores/mizan-rust/src/runtime.rs +++ b/cores/mizan-rust/src/runtime.rs @@ -1,40 +1,41 @@ -//! Runtime helpers — error envelope, request handle, invalidation/merge -//! resolution. Ports `compute_invalidation` / `compute_merges` / -//! `_resolve_merge_slot` / `_scoped_params` from -//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`. +//! Runtime helpers — error envelope, request handle, and the per-response +//! invalidation / merge resolution the adapters call after a dispatch. use crate::registry::context_members; use crate::traits::FunctionSpec; use serde_json::Value; use std::any::Any; -/// Type-erased handle to the framework's request object. The HTTP adapter -/// stuffs its native `Request` here; user code casts back via the adapter's -/// helper types. +/// A borrow of the request object a hosting framework owns. +/// +/// `FUNCTIONS` is a non-generic `distributed_slice`, so `FunctionSpec` has to +/// be object-safe and no type parameter can reach this handle. The reference +/// therefore rides erased, and the crate that names the framework's own type +/// is the one that casts back to it. #[derive(Clone)] pub struct RequestHandle<'a> { - pub inner: &'a (dyn Any + Send + Sync), + inner: &'a (dyn Any + Send + Sync), } impl<'a> RequestHandle<'a> { - /// Wrap a typed reference. The most common path — handlers downcast back - /// to `T` via `downcast::()`. + /// Wrap a typed reference. pub fn new(req: &'a T) -> Self { Self { inner: req } } - /// Wrap an already-erased `dyn Any` reference. Used by HTTP adapters - /// that thread an `Arc` app state in. + /// Wrap a reference the caller has already erased. pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self { Self { inner: req } } - pub fn downcast(&self) -> Option<&'a T> { - self.inner.downcast_ref::() + /// The reference the adapter installed. + pub fn installed(&self) -> &'a (dyn Any + Send + Sync) { + self.inner } } -/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum. +/// Mizan's standard error envelope — the closed set of failures an adapter +/// renders onto the wire. #[derive(Debug, Clone)] pub enum MizanError { NotFound(String), @@ -186,59 +187,28 @@ pub fn compute_invalidation( .collect() } -/// Build the `merge` list from a function's `merge` metadata. Each entry -/// names the slot inside the context bundle the return value lands in. +/// Build the `merge` list from the function's already-resolved merge entries. +/// Each names the slot inside the context bundle the return value lands in. pub fn compute_merges( fn_spec: &dyn FunctionSpec, args: &serde_json::Map, result: &Value, ) -> Vec { - let targets = fn_spec.merge(); - if targets.is_empty() { - return Vec::new(); - } - let mutation_output = fn_spec.output_type(); - let mut out = Vec::new(); - for ctx_name in targets { - let slot = match resolve_merge_slot(ctx_name, mutation_output) { - Some(s) => s, - None => continue, - }; - let scoped = scoped_params(ctx_name, args); - out.push(MergeEntry { - context: (*ctx_name).into(), - slot, - value: result.clone(), - params: if scoped.is_empty() { - None - } else { - Some(scoped) - }, - }); - } - out -} - -/// Find the unique function-name slot whose Output type matches the -/// mutation's Output type. Matches Python's `types_match_for_merge` — -/// structural shape comparison, not name comparison. Returns None on no -/// match or ambiguous match. -fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option { - let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?; - let mut matches: Vec<&'static str> = Vec::new(); - for fn_spec in context_members(context_name) { - if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type()) - { - if crate::graph_check::types_match(&candidate_shape, &mutation_shape) { - matches.push(fn_spec.name()); + crate::graph_check::merges_for(fn_spec.name()) + .map(|resolved| { + let scoped = scoped_params(resolved.context, args); + MergeEntry { + context: resolved.context.into(), + slot: resolved.slot.into(), + value: result.clone(), + params: if scoped.is_empty() { + None + } else { + Some(scoped) + }, } - } - } - if matches.len() == 1 { - Some(matches[0].into()) - } else { - None - } + }) + .collect() } /// Match input args against the context's declared Input field names. @@ -258,3 +228,36 @@ fn scoped_params( .collect() } +#[cfg(test)] +mod tests { + use super::RequestHandle; + use std::any::Any; + + fn installed_addr(handle: &RequestHandle<'_>) -> *const () { + handle.installed() as *const (dyn Any + Send + Sync) as *const () + } + + #[test] + fn a_handle_installs_the_very_reference_it_was_built_over() { + let state = String::from("app-state"); + let source = &state as *const String as *const (); + assert_eq!(installed_addr(&RequestHandle::new(&state)), source); + } + + #[test] + fn an_erased_handle_installs_what_a_typed_one_does() { + let state = String::from("app-state"); + assert_eq!( + installed_addr(&RequestHandle::from_dyn(&state)), + installed_addr(&RequestHandle::new(&state)) + ); + } + + #[test] + fn the_installed_reference_keeps_the_type_it_was_built_over() { + let state = String::from("app-state"); + let handle = RequestHandle::new(&state); + assert!(handle.installed().is::()); + assert!(!handle.installed().is::()); + } +} diff --git a/cores/mizan-rust/src/traits.rs b/cores/mizan-rust/src/traits.rs index 38f8b65..8020bfe 100644 --- a/cores/mizan-rust/src/traits.rs +++ b/cores/mizan-rust/src/traits.rs @@ -1,4 +1,4 @@ -//! Surface traits the proc macros implement. +//! The traits a registered Mizan type, context and function implement. use crate::ir::{AffectTarget, NamedType, Transport}; use crate::runtime::{MizanError, RequestHandle}; @@ -6,11 +6,10 @@ use serde_json::Value; use std::future::Future; use std::pin::Pin; -/// A type that participates in the Mizan IR. Generated by `#[derive(Mizan)]`. +/// A type that participates in the Mizan IR. /// -/// `TYPE_NAME` is a `const` (not a function) so it's usable in `static` -/// initializers — TypeEntry's `name` field reads it directly without an -/// init-time function call. +/// `TYPE_NAME` is a `const` rather than a function so it can be named from a +/// `static` initializer. pub trait MizanType { const TYPE_NAME: &'static str; fn shape() -> NamedType; @@ -20,21 +19,22 @@ pub trait MizanType { } } -/// A marker type for a Mizan context. Generated by `#[mizan::context]`. +/// A marker type carrying one context's wire name. pub trait ContextMarker { const NAME: &'static str; } -/// One Mizan-registered function. Generated by `#[mizan(...)]` on async fns. -/// -/// Everything here is plain data except `dispatch`, which is the type-erased -/// runtime entry point used by the HTTP adapter. +/// One Mizan-registered function: plain data throughout except `dispatch`. pub trait FunctionSpec: Send + Sync { fn name(&self) -> &'static str; fn camel_name(&self) -> &'static str; fn has_input(&self) -> bool; fn input_type(&self) -> Option<&'static str>; fn output_type(&self) -> &'static str; + + /// The shape registered under `output_type()`. + fn output_shape(&self) -> NamedType; + fn output_nullable(&self) -> bool { false } @@ -63,16 +63,14 @@ pub trait FunctionSpec: Send + Sync { None } - /// Field-shape description of this function's Input parameters, used by - /// the context builder to compute shared-param elevation. Empty when - /// `has_input()` is false. + /// This function's Input parameters. Empty when `has_input()` is false. fn input_params(&self) -> &'static [InputParam] { &[] } - /// Type-erased dispatch. The HTTP adapter calls this with deserialized - /// JSON arguments; the macro-generated impl deserializes into the - /// function's typed input, awaits the body, and serializes the result. + /// Deserializes `args` into this function's typed input, awaits the body, + /// and serializes the result — the whole call with its types erased behind + /// JSON. fn dispatch<'a>( &'a self, req: RequestHandle<'a>, @@ -80,10 +78,7 @@ pub trait FunctionSpec: Send + Sync { ) -> Pin> + Send + 'a>>; } -/// One parameter of a function's synthesized Input. The macro emits a static -/// slice of these so the context builder can find shared params across -/// context members and produce the `context { param ... shared-by ... }` -/// section of the IR. +/// One parameter of a function's synthesized Input. #[derive(Debug, Clone, Copy)] pub struct InputParam { pub name: &'static str, diff --git a/cores/mizan-rust/templates/ir.kdl.jinja b/cores/mizan-rust/templates/ir.kdl.jinja new file mode 100644 index 0000000..1586312 --- /dev/null +++ b/cores/mizan-rust/templates/ir.kdl.jinja @@ -0,0 +1,6 @@ +{% macro node(n) %}{{ n.indent }}{{ n.name }}{% for a in n.args %} {{ a|kdl }}{% endfor %}{% for p in n.props %} {{ p.name }}={{ p.value|kdl }}{% endfor %}{% if n.block %} { +{% for c in n.children %}{{ node(c) }}{% endfor %}{{ n.indent }}} +{% else %} +{% endif %}{% endmacro %} +{%- for section in sections %}{% for n in section %}{{ node(n) }}{% endfor %}{% if not loop.last %} +{% endif %}{% endfor %} diff --git a/cores/mizan-rust/tests/afi_parity.rs b/cores/mizan-rust/tests/afi_parity.rs index a2ae42f..10a9132 100644 --- a/cores/mizan-rust/tests/afi_parity.rs +++ b/cores/mizan-rust/tests/afi_parity.rs @@ -1,11 +1,8 @@ -//! Byte-equivalence: the Rust KDL emitter (driven by the proc macros) -//! against `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl` (canonical -//! Python-emitted reference). -//! -//! This is the Phase-2 verifier — the AFI fixture is authored against the -//! real consumer surface (`#[derive(Mizan)] / #[mizan::context] / -//! #[mizan::client]`), not hand-built static specs. +//! `build_ir()` renders the proc-macro-populated registries; the emitted KDL +//! is parsed by the `kdl` crate and then compared byte for byte with +//! `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl`. +use kdl::{KdlDocument, KdlNode}; use mizan_core as mizan; use mizan_core::prelude::*; use mizan_core::RequestHandle; @@ -46,7 +43,17 @@ pub struct StatusOutput { #[mizan::context("user")] pub struct UserCtx; -// ─── Fixture functions (mirroring tests/afi/fixture.py) ──────────────────── +// ─── Fixture handlers ─────────────────────────────────────────────────────── + +/// `(order id, owning user id, total)` — the store the order handlers read. +const ORDERS: &[(i64, i64, i64)] = &[(10, 1, 4200), (11, 1, 1750), (12, 2, 990)]; + +fn profile_of(user_id: i64) -> ProfileOutput { + ProfileOutput { + user_id, + name: format!("user-{user_id}"), + } +} #[mizan::client] pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput { @@ -65,29 +72,39 @@ pub async fn whoami(_req: &RequestHandle<'_>) -> WhoamiOutput { #[mizan::client(context = UserCtx)] pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { - ProfileOutput { - user_id, - name: "placeholder".into(), - } + profile_of(user_id) } #[mizan::client(context = UserCtx)] -pub async fn user_orders(_req: &RequestHandle<'_>, _user_id: i64) -> Vec { - vec![] +pub async fn user_orders(_req: &RequestHandle<'_>, user_id: i64) -> Vec { + ORDERS + .iter() + .filter(|(_, owner, _)| *owner == user_id) + .map(|(id, owner, total)| OrderOutput { + id: *id, + user_id: *owner, + total: *total, + }) + .collect() } #[mizan::client(affects = UserCtx)] pub async fn update_profile( _req: &RequestHandle<'_>, - _user_id: i64, - _name: String, + user_id: i64, + name: String, ) -> StatusOutput { - StatusOutput { ok: true } + StatusOutput { + ok: user_id > 0 && !name.trim().is_empty(), + } } #[mizan::client] -pub async fn find_user(_req: &RequestHandle<'_>, _user_id: i64) -> Option { - None +pub async fn find_user(_req: &RequestHandle<'_>, user_id: i64) -> Option { + ORDERS + .iter() + .any(|(_, owner, _)| *owner == user_id) + .then(|| profile_of(user_id)) } #[mizan::client(merge = UserCtx)] @@ -99,20 +116,96 @@ pub async fn rename_user( ProfileOutput { user_id, name } } -// ─── The byte-equivalence test ────────────────────────────────────────────── +// ─── Reading the parsed document ──────────────────────────────────────────── fn canonical_kdl_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../protocol/mizan-codegen/tests/fixtures/afi_ir.kdl") } +/// The node's first string argument, or the empty string when it has none. +fn label(node: &KdlNode) -> String { + for entry in node.entries() { + if let Some(s) = entry.value().as_string() { + return s.to_string(); + } + } + String::new() +} + +/// `(node name, first string argument)` for every node at one level. +fn index(nodes: &[KdlNode]) -> Vec<(String, String)> { + nodes + .iter() + .map(|node| (node.name().value().to_string(), label(node))) + .collect() +} + +/// The child nodes of the first `kind "name"` node in `doc`, or an empty slice +/// when the document has no such node or it carries no child block. +fn children_of<'a>(doc: &'a KdlDocument, kind: &str, name: &str) -> &'a [KdlNode] { + for node in doc.nodes() { + if node.name().value() == kind && label(node) == name { + return match node.children() { + Some(block) => block.nodes(), + None => &[], + }; + } + } + &[] +} + #[test] fn build_ir_matches_canonical_afi_kdl() { - let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL"); - let actual = mizan_core::build_ir(); + let emitted = mizan_core::build_ir(); - if actual != expected { - for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() { + // Parsing before comparing means a malformed emission fails here rather + // than as a confusing textual diff. + let parsed: KdlDocument = emitted + .parse() + .expect("build_ir() output is a well-formed KDL document"); + let top = index(parsed.nodes()); + + assert!( + top.contains(&("function".to_string(), "user_orders".to_string())), + "parsed document is missing the user_orders function node: {top:?}", + ); + assert!( + top.contains(&("context".to_string(), "user".to_string())), + "parsed document is missing the user context node: {top:?}", + ); + assert_eq!( + index(children_of(&parsed, "function", "user_orders")), + vec![ + ("camel".to_string(), "userOrders".to_string()), + ("has-input".to_string(), String::new()), + ("input".to_string(), "userOrdersInput".to_string()), + ("output".to_string(), "userOrdersOutput".to_string()), + ("transport".to_string(), "http".to_string()), + ("context".to_string(), "user".to_string()), + ], + ); + assert_eq!( + index(children_of(&parsed, "context", "user")), + vec![ + ("function".to_string(), "user_orders".to_string()), + ("function".to_string(), "user_profile".to_string()), + ("param".to_string(), "user_id".to_string()), + ], + ); + + let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL"); + let canonical: KdlDocument = expected + .parse() + .expect("the canonical fixture is a well-formed KDL document"); + assert_eq!( + index(parsed.nodes()), + index(canonical.nodes()), + "emitted and canonical documents declare different top-level nodes", + ); + + if emitted != expected { + for (lineno, (a, b)) in emitted.lines().zip(expected.lines()).enumerate() { if a != b { panic!( "KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}", @@ -122,7 +215,7 @@ fn build_ir_matches_canonical_afi_kdl() { } panic!( "KDL diverges in length: actual_len={} expected_len={}", - actual.len(), + emitted.len(), expected.len(), ); } diff --git a/cores/mizan-rust/tests/merge_defects.rs b/cores/mizan-rust/tests/merge_defects.rs new file mode 100644 index 0000000..c540559 --- /dev/null +++ b/cores/mizan-rust/tests/merge_defects.rs @@ -0,0 +1,68 @@ +//! `verify_invariants()` over a graph where one `merge` declaration matches +//! two members of the context it names and another matches none. + +use mizan_core as mizan; +use mizan_core::graph_check::verify_invariants; +use mizan_core::prelude::*; +use mizan_core::RequestHandle; +use serde::{Deserialize, Serialize}; + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct Profile { + pub user_id: i64, + pub name: String, +} + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct Status { + pub ok: bool, +} + +#[mizan::context("user")] +pub struct UserCtx; + +#[mizan::client(context = UserCtx)] +pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile { + Profile { + user_id, + name: format!("user-{user_id}"), + } +} + +/// Same output shape as `user_profile`. +#[mizan::client(context = UserCtx)] +pub async fn user_card(_req: &RequestHandle<'_>, user_id: i64) -> Profile { + Profile { + user_id, + name: format!("card-{user_id}"), + } +} + +#[mizan::client(merge = UserCtx)] +pub async fn rename_user(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Profile { + Profile { user_id, name } +} + +/// No member of `user` returns this shape. +#[mizan::client(merge = UserCtx)] +pub async fn mark_seen(_req: &RequestHandle<'_>, user_id: i64) -> Status { + Status { ok: user_id > 0 } +} + +#[test] +#[should_panic(expected = "Merge resolution needs exactly one match")] +fn a_merge_matching_several_members_is_ambiguous() { + verify_invariants(); +} + +#[test] +#[should_panic(expected = "user_card")] +fn an_ambiguous_merge_names_every_candidate_member() { + verify_invariants(); +} + +#[test] +#[should_panic(expected = "no member of that context has output type")] +fn a_merge_matching_no_member_has_no_slot() { + verify_invariants(); +} diff --git a/cores/mizan-rust/tests/merge_resolution.rs b/cores/mizan-rust/tests/merge_resolution.rs new file mode 100644 index 0000000..32f5d6d --- /dev/null +++ b/cores/mizan-rust/tests/merge_resolution.rs @@ -0,0 +1,113 @@ +//! `compute_merges` over a graph registered through `#[derive(Mizan)]`, +//! `#[mizan::context]` and `#[mizan::client]`. + +use mizan_core as mizan; +use mizan_core::prelude::*; +use mizan_core::{compute_merges, RequestHandle, FUNCTIONS}; +use serde::{Deserialize, Serialize}; + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct ProfileOutput { + pub user_id: i64, + pub name: String, +} + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct StatusOutput { + pub ok: bool, +} + +#[mizan::context("user")] +pub struct UserCtx; + +#[mizan::client(context = UserCtx)] +pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { + ProfileOutput { + user_id, + name: format!("user-{user_id}"), + } +} + +#[mizan::client(merge = UserCtx)] +pub async fn rename_user( + _req: &RequestHandle<'_>, + user_id: i64, + name: String, +) -> ProfileOutput { + ProfileOutput { user_id, name } +} + +#[mizan::client(affects = UserCtx)] +pub async fn touch_user(_req: &RequestHandle<'_>, user_id: i64) -> StatusOutput { + StatusOutput { ok: user_id > 0 } +} + +/// The handlers above register into `FUNCTIONS` inside this test binary, so a +/// name they declare always lands. +fn spec(name: &str) -> &'static dyn FunctionSpec { + for fn_spec in FUNCTIONS.iter().copied() { + if fn_spec.name() == name { + return fn_spec; + } + } + panic!("no registered function named `{name}`"); +} + +/// `user_id` is a declared param of the `user` context; `name` is not. +fn args() -> serde_json::Map { + let mut args = serde_json::Map::new(); + args.insert("user_id".to_string(), serde_json::Value::from(7)); + args.insert("name".to_string(), serde_json::Value::from("Renamed")); + args +} + +fn renamed() -> serde_json::Value { + serde_json::json!({ "user_id": 7, "name": "Renamed" }) +} + +#[test] +fn a_merge_declaration_resolves_to_the_context_member_sharing_its_output() { + let result = renamed(); + let merges = compute_merges(spec("rename_user"), &args(), &result); + let [entry] = merges.as_slice() else { + panic!( + "rename_user declares one merge; got {} entries", + merges.len() + ); + }; + assert_eq!(entry.context, "user"); + assert_eq!(entry.slot, "user_profile"); + assert_eq!(entry.value, result); +} + +#[test] +fn a_merge_entry_is_scoped_by_the_contexts_declared_params_alone() { + let result = renamed(); + let merges = compute_merges(spec("rename_user"), &args(), &result); + let [entry] = merges.as_slice() else { + panic!( + "rename_user declares one merge; got {} entries", + merges.len() + ); + }; + let mut expected = serde_json::Map::new(); + expected.insert("user_id".to_string(), serde_json::Value::from(7)); + assert_eq!(entry.params, Some(expected)); +} + +#[test] +fn a_function_declaring_only_affects_produces_no_merge_entries() { + let result = serde_json::json!({ "ok": true }); + assert!(compute_merges(spec("touch_user"), &args(), &result).is_empty()); +} + +#[test] +fn merge_resolution_answers_identically_across_calls() { + let result = renamed(); + let first = compute_merges(spec("rename_user"), &args(), &result); + let second = compute_merges(spec("rename_user"), &args(), &result); + let slots: Vec<&str> = first.iter().map(|e| e.slot.as_str()).collect(); + let again: Vec<&str> = second.iter().map(|e| e.slot.as_str()).collect(); + assert_eq!(slots, again); + assert_eq!(slots, vec!["user_profile"]); +} diff --git a/examples/django-react-desktop-app/app.py b/examples/django-react-desktop-app/app.py index 0492959..931efc5 100644 --- a/examples/django-react-desktop-app/app.py +++ b/examples/django-react-desktop-app/app.py @@ -1,10 +1,5 @@ #!/usr/bin/env python -""" -mizan Desktop — PyWebView + Django local RPC. - -Starts a local Django ASGI server and opens a native desktop window. -All communication between the UI and backend uses mizan server functions. -""" +"""Launcher: runs the Django ASGI server locally and opens it in a native window.""" import os import sys @@ -13,19 +8,18 @@ import time os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") -# Work around Qt WebEngine GPU crashes on some systems +# Qt WebEngine crashes on some GPU/driver combinations unless it renders on CPU. os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu") def start_server(host: str, port: int): - """Start the Django ASGI server in a background thread.""" import django django.setup() - # Run migrations on first launch from django.core.management import call_command + # --run-syncdb builds the tables on a first launch with no migration files. call_command("migrate", "--run-syncdb", verbosity=0) import uvicorn @@ -38,51 +32,49 @@ def start_server(host: str, port: int): ) -def wait_for_server(url: str, timeout: float = 10.0): - """Poll until the server responds.""" +def wait_for_server(url: str, timeout: float = 10.0) -> None: + """Poll until the server answers, or raise carrying the last refusal.""" from urllib.request import urlopen - from urllib.error import URLError deadline = time.time() + timeout + last_error: OSError | None = None while time.time() < deadline: try: urlopen(url, timeout=1) - return True - except (URLError, OSError): + return + except OSError as e: + last_error = e time.sleep(0.1) - return False + + raise TimeoutError( + f"Django server did not answer {url} within {timeout}s" + ) from last_error def main(): host = "127.0.0.1" port = 8765 - # Start Django in a daemon thread server = threading.Thread(target=start_server, args=(host, port), daemon=True) server.start() base_url = f"http://{host}:{port}" - - if not wait_for_server(f"{base_url}/api/mizan/session/"): - print("ERROR: Django server failed to start", file=sys.stderr) - sys.exit(1) + wait_for_server(f"{base_url}/api/mizan/session/") print(f"Backend running at {base_url}") - # Check if --headless flag is passed (for testing) if "--headless" in sys.argv: print("Headless mode — server running. Press Ctrl+C to stop.") try: while True: time.sleep(1) except KeyboardInterrupt: - pass + print("Interrupted — stopping the server.", file=sys.stderr) return - # Open native window import webview - window = webview.create_window( + webview.create_window( title="mizan Desktop", url=base_url, width=1024, diff --git a/examples/django-react-desktop-app/backend/clients.py b/examples/django-react-desktop-app/backend/clients.py index 80e7993..3b2de4b 100644 --- a/examples/django-react-desktop-app/backend/clients.py +++ b/examples/django-react-desktop-app/backend/clients.py @@ -1,14 +1,4 @@ -""" -Desktop RPC server functions. - -Tests mizan's appropriateness for desktop apps: -- Local file system access -- SQLite CRUD -- System introspection -- Real-time channels (file watcher, app status) -- No auth required (single-user desktop) -""" - +import logging import os import platform import shutil @@ -21,11 +11,14 @@ from django.http import HttpRequest from pydantic import BaseModel from mizan.client import client -from mizan.channels import ReactChannel -from mizan.setup.registry import register +from mizan.channels import Channel +from mizan.setup import register from mizan.channels import register as register_channel +logger = logging.getLogger(__name__) + + # ============================================================================= # System Info # ============================================================================= @@ -119,17 +112,21 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput: ): try: stat = entry.stat() - entries.append( - FileEntry( - name=entry.name, - path=str(entry), - is_dir=entry.is_dir(), - size=stat.st_size if not entry.is_dir() else 0, - modified=datetime.fromtimestamp(stat.st_mtime).isoformat(), - ) - ) - except (PermissionError, OSError): + except (PermissionError, OSError) as e: + # A broken symlink or an unreadable entry drops out of the + # listing rather than failing the whole directory. + logger.warning("Skipping %s: %s", entry, e) continue + + entries.append( + FileEntry( + name=entry.name, + path=str(entry), + is_dir=entry.is_dir(), + size=stat.st_size if not entry.is_dir() else 0, + modified=datetime.fromtimestamp(stat.st_mtime).isoformat(), + ) + ) except PermissionError: raise PermissionError(f"Cannot read directory: {dir_path}") @@ -161,7 +158,7 @@ def read_file(request: HttpRequest, path: str) -> FileContentOutput: stat = file_path.stat() - # Safety: limit to 1MB text files + # Reads are capped at 1MB so a large binary cannot be pulled into memory. if stat.st_size > 1_048_576: raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)") @@ -190,7 +187,6 @@ class WriteFileOutput(BaseModel): def write_file(request: HttpRequest, path: str, content: str) -> WriteFileOutput: file_path = Path(path).expanduser().resolve() - # Safety: only allow writing within home directory home = Path.home() if not str(file_path).startswith(str(home)): raise PermissionError(f"Can only write files within home directory: {home}") @@ -339,11 +335,14 @@ def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput: try: note = Note.objects.get(pk=id) - note.delete() - return DeleteNoteOutput(id=id, deleted=True) except Note.DoesNotExist: + # Deleting an absent note is reported, not raised. + logger.info("delete_note: note %s is already absent", id) return DeleteNoteOutput(id=id, deleted=False) + note.delete() + return DeleteNoteOutput(id=id, deleted=True) + register(delete_note, "delete_note") @@ -353,17 +352,18 @@ register(delete_note, "delete_note") # ============================================================================= -class AppStatusChannel(ReactChannel): +class AppStatusChannel(Channel): """Push app status updates to the UI (uptime, memory, etc.).""" - class DjangoMessage(BaseModel): + class ServerMessage(BaseModel): uptime_seconds: float memory_mb: float note_count: int timestamp: str def authorize(self, params=None): - return True # Desktop app, no auth needed + # One local user owns the whole process; there is no identity to check. + return True def group(self, params=None): return "app_status" @@ -372,15 +372,16 @@ class AppStatusChannel(ReactChannel): register_channel(AppStatusChannel, "app_status") -class NotesChannel(ReactChannel): +class NotesChannel(Channel): """Push notifications when notes are modified.""" - class DjangoMessage(BaseModel): + class ServerMessage(BaseModel): action: str # "created", "updated", "deleted" note_id: int title: str def authorize(self, params=None): + # One local user owns the whole process; there is no identity to check. return True def group(self, params=None): diff --git a/examples/django-react-desktop-app/frontend/index.html b/examples/django-react-desktop-app/frontend/index.html index 66412a7..4135653 100644 --- a/examples/django-react-desktop-app/frontend/index.html +++ b/examples/django-react-desktop-app/frontend/index.html @@ -4,10 +4,6 @@ mizan Desktop -
diff --git a/examples/django-react-desktop-app/frontend/package.json b/examples/django-react-desktop-app/frontend/package.json index 62d826b..2b6e657 100644 --- a/examples/django-react-desktop-app/frontend/package.json +++ b/examples/django-react-desktop-app/frontend/package.json @@ -12,9 +12,11 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.0.0", + "tailwindcss": "^4.3.3", "typescript": "^5.7.0", "vite": "^6.0.0" } diff --git a/examples/django-react-desktop-app/frontend/src/App.tsx b/examples/django-react-desktop-app/frontend/src/App.tsx index 3399980..26533f1 100644 --- a/examples/django-react-desktop-app/frontend/src/App.tsx +++ b/examples/django-react-desktop-app/frontend/src/App.tsx @@ -1,47 +1,46 @@ import { useState, useEffect, useCallback } from 'react' import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan' -// ─── System Info ──────────────────────────────────────────────────────────── +type SystemFacts = Record +type NoteList = { notes: Note[] } +type FileListing = { directory: string; entries: FileEntry[]; parent: string | null } function SystemInfo() { const { call } = useMizan() - const [info, setInfo] = useState | null>(null) + const [info, setInfo] = useState(null) + const [error, setError] = useState(null) useEffect(() => { - call('system_info').then(setInfo).catch(() => {}) + call('system_info').then(setInfo).catch(setError) }, [call]) - if (!info) return
Loading system info...
- return ( -
-

System

- - - {Object.entries(info).map(([k, v]) => ( - - - - - ))} - -
{k}{String(v)}
+
+

System

+ {error &&
{error.message}
} + {!info && !error &&
Loading system info...
} + {info && ( + + + {Object.entries(info).map(([k, v]) => ( + + + + + ))} + +
{k}{String(v)}
+ )}
) } -// ─── Connection Status ────────────────────────────────────────────────────── - function StatusBar() { const status = useMizanStatus() - return ( -
- {status} -
- ) -} + const tone = status === 'connected' ? 'status-bar--online' : 'status-bar--offline' -// ─── Notes ────────────────────────────────────────────────────────────────── + return
{status}
+} type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string } @@ -51,34 +50,41 @@ function Notes() { const [selected, setSelected] = useState(null) const [title, setTitle] = useState('') const [content, setContent] = useState('') + const [error, setError] = useState(null) const refresh = useCallback(() => { - call<{ notes: Note[] }>('list_notes').then(d => setNotes(d.notes)).catch(() => {}) + call('list_notes').then(d => setNotes(d.notes)).catch(setError) }, [call]) useEffect(() => { refresh() }, [refresh]) - const create = async () => { - if (!title.trim()) return - await call('create_note', { title, content }) - setTitle('') - setContent('') - refresh() - } - - const save = async () => { - if (!selected) return - await call('update_note', { id: selected.id, title, content }) + const clearDraft = () => { setSelected(null) setTitle('') setContent('') - refresh() } - const remove = async (id: number) => { - await call('delete_note', { id }) - if (selected?.id === id) { setSelected(null); setTitle(''); setContent('') } - refresh() + const create = () => { + if (!title.trim()) return + call('create_note', { title, content }) + .then(() => { clearDraft(); refresh() }) + .catch(setError) + } + + const save = () => { + if (!selected) return + call('update_note', { id: selected.id, title, content }) + .then(() => { clearDraft(); refresh() }) + .catch(setError) + } + + const remove = (id: number) => { + call('delete_note', { id }) + .then(() => { + if (selected?.id === id) clearDraft() + refresh() + }) + .catch(setError) } const select = (n: Note) => { @@ -88,44 +94,50 @@ function Notes() { } return ( -
-

Notes ({notes.length})

-
-
+
+

Notes ({notes.length})

+ {error &&
{error.message}
} +
+
{notes.map(n => (
select(n)} - style={{ - ...styles.noteItem, - borderLeft: selected?.id === n.id ? '3px solid #6cf' : '3px solid transparent', - }} + className={ + 'note-item flex items-center justify-between py-2 px-3 mb-0.5 ' + + (selected?.id === n.id ? 'note-item--selected' : '') + } > {n.pinned ? '\u{1f4cc} ' : ''}{n.title} - +
))} - {notes.length === 0 &&
No notes yet
} + {notes.length === 0 &&
No notes yet
}
-
+
setTitle(e.target.value)} placeholder="Title" - style={styles.input} + className="field w-full py-2 px-3 mb-2" />