AFI parity: close all 35 gaps — every adapter wires every AFI-common capability
The conformance board (tests/afi/test_capability_parity.py) is now fully green: 90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The gaps the prose table used to launder as "Django-only" / "out of scope" are wired, against the pinned-spec model (single-authored spec, byte-identical conformance across languages) — never per-language reimplementation. FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest), WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes (SQLAlchemy projection, same declaration surface as django-readers), Forms (Pydantic schema/validate/submit). Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth= enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT mint+verify and cache-key derivation byte-pinned to the Python reference (cache_keys_pin, token_pin, invalidate_header_pin). TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS backend can feed the codegen — the largest gap), multipart upload, session-init, WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms. Verified in the merged tree: core 25, fastapi 74, django 353/21-skip, mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,14 +5,20 @@ HTTP RPC dispatch and context bundling on top of mizan-core's function
|
||||
registry, sharing the auth / invalidation / cache / upload core with the
|
||||
Django adapter.
|
||||
|
||||
WebSocket, Forms, Shapes, and the SSR bridge are AFI-common capabilities
|
||||
this adapter does not wire yet — open gaps on the capability-parity board
|
||||
(`tests/afi/`), not out-of-scope. "Use FastAPI's native WebSocket / an ORM
|
||||
of choice" is the non-goal: the AFI exists precisely to wire those to the
|
||||
generated typed client through one decorator, so an adapter that defers to
|
||||
the native primitive isn't yet a complete AFI adapter. The SSR bridge in
|
||||
particular is framework-agnostic (`mizan.ssr.bridge.SSRBridge` has no Django
|
||||
coupling) and is mountable here directly.
|
||||
The full AFI-common surface is wired here over FastAPI-native primitives,
|
||||
each riding the shared core:
|
||||
|
||||
- WebSocket RPC — `router`'s `/ws/` route dispatches `@client(websocket=True)`
|
||||
functions through the same `mizan_core.dispatch` as `POST /call/`.
|
||||
- SSR — `SSRRenderer` (`mizan_fastapi.ssr`) renders React via the shared
|
||||
`mizan_core.ssr.SSRBridge` Bun subprocess.
|
||||
- Edge manifest / PSR — `edge_manifest` (and the `mizan-fastapi-edge-manifest`
|
||||
console entry) emit the manifest derived in `mizan_core.manifest`, including
|
||||
each context's `render_strategy`.
|
||||
- Shapes — `mizan_fastapi.shapes.Shape` is the typed query projection bound to
|
||||
SQLAlchemy (same declaration surface as the Django `django-readers` binding).
|
||||
- Forms — `mizan_fastapi.forms.mizanForm` exposes schema / validate / submit
|
||||
role functions over Pydantic.
|
||||
|
||||
Usage:
|
||||
from fastapi import FastAPI
|
||||
@@ -42,11 +48,30 @@ from .executor import (
|
||||
compute_invalidation,
|
||||
execute_function,
|
||||
)
|
||||
# Register the FastAPI/Starlette response base so view-path detection works in
|
||||
# mizan_core.client.function (a @client function returning a Response is a
|
||||
# view-path function — header-only invalidation, "view" in the edge manifest).
|
||||
# Must run before any @client-decorated code is evaluated.
|
||||
from starlette.responses import Response as _Response
|
||||
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
||||
_set_response_base(_Response)
|
||||
|
||||
from . import shapes, forms
|
||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||
from .auth import MizanAuthMiddleware, mizan_auth
|
||||
from .config import MizanConfig, from_env
|
||||
from .manifest import edge_manifest, generate_edge_manifest, render_strategies
|
||||
from .ssr import SSRRenderer
|
||||
from mizan_core.upload import File, Upload, UploadedFile
|
||||
|
||||
# Shapes (SQLAlchemy query projection) and Forms (Pydantic schema/validate/submit)
|
||||
# are submodule bindings; expose their public primitives at the package root.
|
||||
Shape = shapes.Shape
|
||||
Diff = shapes.Diff
|
||||
NestedDiff = shapes.NestedDiff
|
||||
mizanForm = forms.mizanForm
|
||||
FormConfig = forms.FormConfig
|
||||
|
||||
__all__ = [
|
||||
"Upload",
|
||||
"File",
|
||||
@@ -60,6 +85,17 @@ __all__ = [
|
||||
"mizan_validation_handler",
|
||||
"execute_function",
|
||||
"compute_invalidation",
|
||||
"edge_manifest",
|
||||
"generate_edge_manifest",
|
||||
"render_strategies",
|
||||
"SSRRenderer",
|
||||
"shapes",
|
||||
"forms",
|
||||
"Shape",
|
||||
"Diff",
|
||||
"NestedDiff",
|
||||
"mizanForm",
|
||||
"FormConfig",
|
||||
"ErrorCode",
|
||||
"MizanError",
|
||||
"NotFound",
|
||||
|
||||
245
backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py
Normal file
245
backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Forms — the Pydantic binding (schema / validate / submit roles).
|
||||
|
||||
A Mizan form is exposed as three server functions — `{name}.schema`,
|
||||
`{name}.validate`, `{name}.submit` — carrying `_meta["form_role"]` of
|
||||
`"schema"`, `"validate"`, `"submit"`. That role contract is AFI-common and
|
||||
identical to the Django adapter's (`mizan.forms`); only the *binding* differs:
|
||||
Django wraps a `forms.Form`, this wraps a Pydantic `BaseModel`.
|
||||
|
||||
from mizan_fastapi.forms import mizanForm, FormConfig
|
||||
|
||||
class ContactForm(mizanForm):
|
||||
mizan = FormConfig(name="contact", title="Contact Us", submit_label="Send")
|
||||
|
||||
name: str
|
||||
email: EmailStr
|
||||
message: str
|
||||
|
||||
def on_submit_success(self, request) -> dict:
|
||||
send_email(self.model_dump())
|
||||
return {"sent": True}
|
||||
|
||||
Subclassing registers the three role functions automatically (parity with the
|
||||
Django `mizanFormMixin.__init_subclass__` auto-registration):
|
||||
|
||||
contact.schema → field definitions (FormSchema)
|
||||
contact.validate → structured field errors (FormValidation)
|
||||
contact.submit → validate, then on_submit_success / on_submit_failure
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar, get_args, get_origin
|
||||
|
||||
from pydantic import BaseModel, ValidationError, create_model
|
||||
|
||||
from mizan_core.client.function import ServerFunction
|
||||
from mizan_core.registry import get_all_functions, register
|
||||
|
||||
from .schemas import (
|
||||
FieldError,
|
||||
FieldErrorList,
|
||||
FieldSchema,
|
||||
FormMeta,
|
||||
FormSchema,
|
||||
FormSubmitFail,
|
||||
FormSubmitPass,
|
||||
FormValidation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FormConfig",
|
||||
"mizanForm",
|
||||
"get_forms",
|
||||
"FormSchema",
|
||||
"FormValidation",
|
||||
"FormSubmitPass",
|
||||
"FormSubmitFail",
|
||||
]
|
||||
|
||||
|
||||
# Pydantic annotation → the (type, widget) the frontend renders. Mirrors the
|
||||
# Django binding's `_django_field_to_python_type` intent: hand the client a real
|
||||
# field type instead of a generic string.
|
||||
_TYPE_WIDGET = {
|
||||
bool: ("checkbox", "CheckboxInput"),
|
||||
int: ("number", "NumberInput"),
|
||||
float: ("number", "NumberInput"),
|
||||
str: ("text", "TextInput"),
|
||||
}
|
||||
|
||||
|
||||
class FormConfig(BaseModel):
|
||||
"""Form metadata + frontend behavior (parity with `mizanFormMeta`)."""
|
||||
|
||||
name: str
|
||||
title: str | None = None
|
||||
subtitle: str | None = None
|
||||
submit_label: str = "Submit"
|
||||
live_validation: bool = True
|
||||
live_form_errors: bool = False
|
||||
refetch_schema_on_validate: bool = False
|
||||
|
||||
|
||||
def _unwrap_optional(annotation: Any) -> Any:
|
||||
"""`X | None` / `Optional[X]` → `X`; otherwise the annotation unchanged."""
|
||||
if get_origin(annotation) in (None,):
|
||||
return annotation
|
||||
args = [a for a in get_args(annotation) if a is not type(None)]
|
||||
if len(args) == 1 and type(None) in get_args(annotation):
|
||||
return args[0]
|
||||
return annotation
|
||||
|
||||
|
||||
def _field_type_widget(annotation: Any) -> tuple[str, str]:
|
||||
base = _unwrap_optional(annotation)
|
||||
return _TYPE_WIDGET.get(base, ("text", "TextInput"))
|
||||
|
||||
|
||||
def _humanize(name: str) -> str:
|
||||
return name.replace("_", " ").title()
|
||||
|
||||
|
||||
def build_form_schema(form_cls: type["mizanForm"]) -> FormSchema:
|
||||
"""Derive a `FormSchema` from a Pydantic form's fields + config."""
|
||||
cfg = form_cls.mizan
|
||||
fields: list[FieldSchema] = []
|
||||
for field_name, info in form_cls.model_fields.items():
|
||||
type_str, widget = _field_type_widget(info.annotation)
|
||||
required = info.is_required()
|
||||
initial = None if required else info.get_default(call_default_factory=False)
|
||||
if initial is None and info.default is not None and info.default is not ...:
|
||||
initial = info.default
|
||||
meta = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
||||
fields.append(
|
||||
FieldSchema(
|
||||
name=field_name,
|
||||
label=str(info.title or _humanize(field_name)),
|
||||
type=type_str,
|
||||
widget=widget,
|
||||
required=required,
|
||||
disabled=bool(meta.get("disabled", False)),
|
||||
help_text=str(info.description or ""),
|
||||
initial=initial if initial is not ... else None,
|
||||
max_length=getattr(info, "max_length", None),
|
||||
min_length=getattr(info, "min_length", None),
|
||||
choices=None,
|
||||
)
|
||||
)
|
||||
return FormSchema(
|
||||
name=cfg.name,
|
||||
title=cfg.title or _humanize(form_cls.__name__.removesuffix("Form")),
|
||||
subtitle=cfg.subtitle,
|
||||
submit_label=cfg.submit_label,
|
||||
fields=fields,
|
||||
meta=FormMeta(
|
||||
refetch_schema_on_validate=cfg.refetch_schema_on_validate,
|
||||
live_validation=cfg.live_validation,
|
||||
live_form_errors=cfg.live_form_errors,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _validation_from_error(exc: ValidationError) -> FormValidation:
|
||||
"""Group a Pydantic `ValidationError` into the `FormValidation` wire shape."""
|
||||
by_field: dict[str, list[FieldError]] = {}
|
||||
for err in exc.errors():
|
||||
loc = err.get("loc", ())
|
||||
field = str(loc[0]) if loc else "__all__"
|
||||
by_field.setdefault(field, []).append(
|
||||
FieldError(message=err.get("msg", "Invalid value"), code=err.get("type"))
|
||||
)
|
||||
return FormValidation(
|
||||
errors=[FieldErrorList(field=f, errors=errs) for f, errs in by_field.items()]
|
||||
)
|
||||
|
||||
|
||||
def _validate(form_cls: type["mizanForm"], data: dict[str, Any]) -> tuple["mizanForm | None", FormValidation]:
|
||||
"""Validate `data`; return `(instance|None, validation)` — instance None on failure."""
|
||||
try:
|
||||
instance = form_cls(**(data or {}))
|
||||
return instance, FormValidation(errors=[])
|
||||
except ValidationError as exc:
|
||||
return None, _validation_from_error(exc)
|
||||
|
||||
|
||||
class mizanForm(BaseModel):
|
||||
"""Base for a Pydantic-backed Mizan form.
|
||||
|
||||
Subclass with field annotations and a `mizan = FormConfig(...)`. Subclassing
|
||||
auto-registers the schema/validate/submit role functions. Override
|
||||
`on_submit_success` / `on_submit_failure` for submit-time behavior.
|
||||
"""
|
||||
|
||||
mizan: ClassVar[FormConfig]
|
||||
|
||||
def on_submit_success(self, request: Any) -> dict | None:
|
||||
"""Handle a validated submission. Override; returns optional result data."""
|
||||
return None
|
||||
|
||||
def on_submit_failure(self, request: Any, errors: FormValidation) -> None:
|
||||
"""Handle a failed submission (logging, etc.). Override."""
|
||||
return None
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
cfg = cls.__dict__.get("mizan")
|
||||
if isinstance(cfg, FormConfig):
|
||||
_register_form(cls)
|
||||
|
||||
|
||||
def _register_form(form_cls: type[mizanForm]) -> None:
|
||||
"""Register `{name}.schema/.validate/.submit` for a Pydantic form class."""
|
||||
cfg = form_cls.mizan
|
||||
name = cfg.name
|
||||
pascal = "".join(w.capitalize() for w in name.replace(".", "_").replace("-", "_").split("_"))
|
||||
|
||||
schema_input = create_model(f"{pascal}SchemaInput", data=(dict[str, Any], {}))
|
||||
validate_input = create_model(f"{pascal}ValidateInput", data=(dict[str, Any], ...))
|
||||
submit_input = create_model(f"{pascal}SubmitInput", data=(dict[str, Any], ...))
|
||||
|
||||
class SchemaFunction(ServerFunction):
|
||||
Input = schema_input
|
||||
Output = FormSchema
|
||||
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "schema"}
|
||||
|
||||
def call(self, input) -> FormSchema:
|
||||
return build_form_schema(form_cls)
|
||||
|
||||
class ValidateFunction(ServerFunction):
|
||||
Input = validate_input
|
||||
Output = FormValidation
|
||||
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "validate"}
|
||||
|
||||
def call(self, input) -> FormValidation:
|
||||
_, validation = _validate(form_cls, input.data)
|
||||
return validation
|
||||
|
||||
class SubmitFunction(ServerFunction):
|
||||
Input = submit_input
|
||||
Output = FormSubmitPass
|
||||
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "submit"}
|
||||
|
||||
def call(self, input) -> FormSubmitPass | FormSubmitFail:
|
||||
instance, validation = _validate(form_cls, input.data)
|
||||
if instance is not None:
|
||||
return FormSubmitPass(success=True, data=instance.on_submit_success(self.request))
|
||||
instance_for_failure = form_cls.model_construct(**(input.data or {}))
|
||||
instance_for_failure.on_submit_failure(self.request, validation)
|
||||
return FormSubmitFail(success=False, errors=validation)
|
||||
|
||||
for fn, role in ((SchemaFunction, "schema"), (ValidateFunction, "validate"), (SubmitFunction, "submit")):
|
||||
fn.__name__ = f"{name}_{role}"
|
||||
fn.__qualname__ = fn.__name__
|
||||
register(fn, f"{name}.{role}")
|
||||
|
||||
|
||||
def get_forms() -> dict[str, list]:
|
||||
"""Group registered form role functions by form name (parity helper)."""
|
||||
forms: dict[str, list] = {}
|
||||
for _, cls in get_all_functions().items():
|
||||
meta = getattr(cls, "_meta", {})
|
||||
if meta.get("form"):
|
||||
forms.setdefault(meta.get("form_name"), []).append(cls)
|
||||
return forms
|
||||
77
backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py
Normal file
77
backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Form role output schemas — the wire shapes the schema/validate/submit roles emit.
|
||||
|
||||
These mirror the Django adapter's `mizan.forms.schemas` field-for-field (FormMeta,
|
||||
FieldSchema, FormSchema, FormValidation, FormSubmitPass/Fail) so the generated
|
||||
client is identical regardless of which backend authored the form. The only
|
||||
difference is the source: Django builds these from `forms.Field` introspection;
|
||||
this builds them from Pydantic `FieldInfo`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class FormMeta(BaseModel):
|
||||
"""Frontend behavior flags (parity with the Django adapter)."""
|
||||
|
||||
refetch_schema_on_validate: bool = False
|
||||
live_validation: bool = True
|
||||
live_form_errors: bool = False
|
||||
|
||||
|
||||
class FieldChoice(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
class FieldError(BaseModel):
|
||||
message: str
|
||||
code: Optional[str] = None
|
||||
|
||||
|
||||
class FieldErrorList(BaseModel):
|
||||
field: str
|
||||
errors: list[FieldError]
|
||||
|
||||
|
||||
class FieldSchema(BaseModel):
|
||||
name: str
|
||||
label: str
|
||||
type: str
|
||||
widget: str
|
||||
required: bool
|
||||
disabled: bool
|
||||
help_text: str
|
||||
initial: Any = None
|
||||
max_length: Optional[int] = None
|
||||
min_length: Optional[int] = None
|
||||
choices: Optional[list[FieldChoice]] = None
|
||||
|
||||
|
||||
class FormSchema(BaseModel):
|
||||
"""Schema returned by the `.schema` role: form metadata + field definitions."""
|
||||
|
||||
name: str
|
||||
title: str
|
||||
subtitle: Optional[str] = None
|
||||
submit_label: str
|
||||
fields: list[FieldSchema]
|
||||
meta: FormMeta = FormMeta()
|
||||
|
||||
|
||||
class FormValidation(BaseModel):
|
||||
errors: list[FieldErrorList]
|
||||
|
||||
|
||||
class FormSubmitPass(BaseModel):
|
||||
success: bool
|
||||
data: Optional[dict] = None
|
||||
|
||||
|
||||
class FormSubmitFail(BaseModel):
|
||||
success: bool
|
||||
errors: FormValidation
|
||||
98
backends/mizan-fastapi/src/mizan_fastapi/manifest.py
Normal file
98
backends/mizan-fastapi/src/mizan_fastapi/manifest.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Edge manifest — FastAPI adapter surface.
|
||||
|
||||
The manifest derivation is AFI-common (`mizan_core.manifest.generate_edge_manifest`);
|
||||
this module exposes it over FastAPI's surface as a callable and a console entry
|
||||
(`mizan-fastapi-edge-manifest`), mirroring Django's `export_edge_manifest`
|
||||
management command.
|
||||
|
||||
The `render_strategy` field each context carries — `"psr"` when the context has
|
||||
no user-scoped param, `"dynamic_cached"` when it does — is the PSR signal Edge
|
||||
reads to decide between one shared pre-rendered artifact and a per-user cached
|
||||
one. It is derived in the core from the same registry metadata, so FastAPI and
|
||||
Django emit byte-identical manifests for an identical registry.
|
||||
|
||||
CLI:
|
||||
mizan-fastapi-edge-manifest myproject.app
|
||||
mizan-fastapi-edge-manifest myproject.app:app --base-url /api/mizan -o edge.json
|
||||
|
||||
The positional argument is an import target (``module`` or ``module:attr``); it
|
||||
is imported for its registration side effects (importing the module runs the
|
||||
`@client` decorators and `register(...)` calls that populate the registry)
|
||||
before the manifest is derived.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
|
||||
|
||||
|
||||
__all__ = ["edge_manifest", "generate_edge_manifest", "render_strategies", "main"]
|
||||
|
||||
|
||||
def edge_manifest(base_url: str = "/api/mizan") -> dict[str, Any]:
|
||||
"""The Edge manifest for the current registry.
|
||||
|
||||
Call after the app's `@client` functions are imported/registered. The
|
||||
returned dict carries each context's ``render_strategy`` (PSR vs.
|
||||
dynamic_cached) and the mutation→context invalidation routing.
|
||||
"""
|
||||
return generate_edge_manifest(base_url=base_url)
|
||||
|
||||
|
||||
def render_strategies(base_url: str = "/api/mizan") -> dict[str, str]:
|
||||
"""Map each context to its ``render_strategy`` — ``"psr"`` or ``"dynamic_cached"``.
|
||||
|
||||
PSR (Preemptive Static Rendering) is the per-context decision Edge needs: a
|
||||
context with no user-scoped param renders one shared artifact (``psr``) that
|
||||
is re-rendered on mutation; a user-scoped context renders per-user
|
||||
(``dynamic_cached``). This surfaces that decision directly so a PSR driver can
|
||||
enumerate which contexts to pre-render without re-deriving it.
|
||||
"""
|
||||
contexts = edge_manifest(base_url)["contexts"]
|
||||
return {name: entry["render_strategy"] for name, entry in contexts.items()}
|
||||
|
||||
|
||||
def _import_target(target: str) -> None:
|
||||
"""Import a ``module`` or ``module:attr`` target for its registration effects."""
|
||||
module_name = target.split(":", 1)[0]
|
||||
importlib.import_module(module_name)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Console entry: import the app target, emit the Edge manifest as JSON."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="mizan-fastapi-edge-manifest",
|
||||
description="Export the Mizan Edge manifest for a FastAPI app.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"app",
|
||||
help="Import target whose @client functions to register "
|
||||
"(e.g. 'myproject.app' or 'myproject.app:app').",
|
||||
)
|
||||
parser.add_argument("--base-url", default="/api/mizan", help="Mizan API mount point.")
|
||||
parser.add_argument("-o", "--output", default=None, help="Write to file instead of stdout.")
|
||||
parser.add_argument("--indent", type=int, default=2, help="JSON indent (0 = compact).")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
sys.path.insert(0, "")
|
||||
_import_target(args.app)
|
||||
|
||||
indent = args.indent if args.indent > 0 else None
|
||||
text = generate_edge_manifest_json(base_url=args.base_url, indent=indent)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(text, encoding="utf-8")
|
||||
else:
|
||||
sys.stdout.write(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
@@ -25,7 +25,7 @@ from starlette.datastructures import UploadFile
|
||||
|
||||
from mizan_core.auth import INVALID, authenticate
|
||||
from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context
|
||||
from mizan_core.errors import BadRequest, ErrorCode, MizanError, Unauthorized
|
||||
from mizan_core.errors import BadRequest, ErrorCode, Forbidden, MizanError, NotFound, Unauthorized
|
||||
from mizan_core.registry import get_function
|
||||
from mizan_core.upload import UploadedFile, bind_uploads
|
||||
|
||||
@@ -150,6 +150,108 @@ async def context_fetch(context_name: str, request: Request) -> Response:
|
||||
return Response(content=res.body_bytes, media_type="application/json", headers=headers)
|
||||
|
||||
|
||||
# ─── WebSocket RPC transport ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ws_identity(websocket: WebSocket, cfg: MizanConfig):
|
||||
"""Identity for a WebSocket RPC: a host-set `websocket.state.user`, else a
|
||||
token decode from the handshake headers. A present-but-invalid token rejects.
|
||||
|
||||
Mirrors the HTTP `_identity` path so a function's `auth=` guard enforces
|
||||
identically over either transport.
|
||||
"""
|
||||
existing = getattr(getattr(websocket, "state", None), "user", None)
|
||||
if existing is not None:
|
||||
return existing
|
||||
ident = authenticate(websocket.headers, cfg.auth)
|
||||
if ident is INVALID:
|
||||
raise Unauthorized("Invalid or expired token")
|
||||
return ident
|
||||
|
||||
|
||||
def _error_frame(request_id: Any, exc: MizanError) -> dict[str, Any]:
|
||||
err: dict[str, Any] = {"code": exc.code.value, "message": exc.message}
|
||||
if exc.details:
|
||||
err["details"] = exc.details
|
||||
return {"id": request_id, "ok": False, "error": err}
|
||||
|
||||
|
||||
@router.websocket("/ws/")
|
||||
async def websocket_rpc(websocket: WebSocket) -> None:
|
||||
"""WebSocket RPC transport for `@client(websocket=True)` functions.
|
||||
|
||||
Frame protocol (parity with mizan-django's Channels consumer):
|
||||
|
||||
→ {"action": "rpc", "id": "<req>", "fn": "<name>", "args": {...}}
|
||||
← {"id": "<req>", "ok": true, "data": <result>, "invalidate": [...], "merge"?: [...]}
|
||||
← {"id": "<req>", "ok": false, "error": {"code", "message", "details"?}}
|
||||
|
||||
Each call runs through the SAME `mizan_core.dispatch.dispatch_call` as
|
||||
`POST /call/`, so input validation, `auth=` enforcement, invalidation, merge,
|
||||
and origin-cache purge are identical across transports. Only functions that
|
||||
declared `websocket=True` are callable here; an HTTP-only function returns a
|
||||
`FORBIDDEN` frame rather than executing.
|
||||
"""
|
||||
cfg = get_config(websocket)
|
||||
await websocket.accept()
|
||||
try:
|
||||
identity = _ws_identity(websocket, cfg)
|
||||
except Unauthorized as exc:
|
||||
await websocket.send_json(_error_frame(None, exc))
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
content = await websocket.receive_json()
|
||||
await _handle_ws_rpc(websocket, content, identity, cfg)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
|
||||
async def _handle_ws_rpc(websocket: WebSocket, content: dict[str, Any], identity, cfg: MizanConfig) -> None:
|
||||
"""Dispatch one WS RPC frame through the shared dispatch core."""
|
||||
if content.get("action") != "rpc":
|
||||
await websocket.send_json({"error": f"Unknown action: {content.get('action')}"})
|
||||
return
|
||||
|
||||
request_id = content.get("id")
|
||||
fn_name = content.get("fn")
|
||||
args = content.get("args", {})
|
||||
|
||||
if not fn_name:
|
||||
await websocket.send_json(_error_frame(request_id, BadRequest("Missing 'fn' field")))
|
||||
return
|
||||
|
||||
fn_class = get_function(fn_name)
|
||||
if fn_class is None:
|
||||
await websocket.send_json(_error_frame(request_id, NotFound(f"Function '{fn_name}' not found")))
|
||||
return
|
||||
if not getattr(fn_class, "_meta", {}).get("websocket"):
|
||||
await websocket.send_json(
|
||||
_error_frame(
|
||||
request_id,
|
||||
Forbidden("This function is HTTP-only. Use POST /api/mizan/call/ instead."),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
res = await dispatch_call(
|
||||
DispatchRequest(identity=identity, args=args, native_request=websocket),
|
||||
fn_name, cfg.cache,
|
||||
)
|
||||
except MizanError as exc:
|
||||
await websocket.send_json(_error_frame(request_id, exc))
|
||||
return
|
||||
|
||||
frame: dict[str, Any] = {"id": request_id, "ok": True, "data": res.data,
|
||||
"invalidate": res.invalidate or []}
|
||||
if res.merge:
|
||||
frame["merge"] = res.merge
|
||||
await websocket.send_json(frame)
|
||||
|
||||
|
||||
# ─── Exception handler ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
307
backends/mizan-fastapi/src/mizan_fastapi/shapes.py
Normal file
307
backends/mizan-fastapi/src/mizan_fastapi/shapes.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Typed query projection (Shapes) — the SQLAlchemy binding.
|
||||
|
||||
A Shape is a Pydantic model that declares *which* fields and relationships of an
|
||||
ORM model to project. The declaration surface is identical to the Django
|
||||
adapter's `mizan.shapes` (`django-readers` binding):
|
||||
|
||||
class AuthorShape(Shape[Author]):
|
||||
id: int
|
||||
name: str
|
||||
books: list[BookShape] = [] # nested relationship
|
||||
|
||||
AuthorShape.query(session, lambda s: s.where(Author.name == "Ann"))
|
||||
|
||||
Only the ORM binding differs: where the Django Shape lowers its spec to
|
||||
`django-readers` pairs (queryset prepare + instance project), this lowers it to a
|
||||
SQLAlchemy `select(Model)` with `selectinload(...)` eager-loading for each nested
|
||||
relationship (the projection-load that keeps the query count flat), then projects
|
||||
each loaded instance into the Pydantic shape. `.diff()` / `.diff_many()` compare a
|
||||
constructed shape against current DB rows, mirroring the Django semantics.
|
||||
|
||||
The one surface difference SQLAlchemy forces is an explicit `session` argument to
|
||||
`query` / `diff` / `diff_many` — Django models carry an implicit `objects`
|
||||
manager; a SQLAlchemy mapped class does not. That is the ORM binding, not the
|
||||
Shape declaration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
from typing import Any, ClassVar, Generic, TypeVar, Union, get_type_hints
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.inspection import inspect as sa_inspect
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
_M = TypeVar("_M")
|
||||
_S = TypeVar("_S", bound="Shape")
|
||||
|
||||
|
||||
def _extract_shape_class(hint) -> type[Shape] | None:
|
||||
"""The nested Shape a field annotation projects, if any.
|
||||
|
||||
Handles `SomeShape`, `list[SomeShape]`, and `SomeShape | None` / Optional —
|
||||
the same forms the Django binding's `_extract_shape_class` accepts.
|
||||
"""
|
||||
origin = getattr(hint, "__origin__", None)
|
||||
args = getattr(hint, "__args__", ())
|
||||
|
||||
if origin is list and args and isinstance(args[0], type) and issubclass(args[0], Shape):
|
||||
return args[0]
|
||||
|
||||
if isinstance(hint, type) and issubclass(hint, Shape) and hint is not Shape:
|
||||
return hint
|
||||
|
||||
if origin is Union or isinstance(hint, types.UnionType):
|
||||
for arg in args:
|
||||
if arg is type(None):
|
||||
continue
|
||||
if isinstance(arg, type) and issubclass(arg, Shape) and arg is not Shape:
|
||||
return arg
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_model(cls) -> Any | None:
|
||||
"""The mapped model a Shape subclass is parameterized on (`Shape[Model]`)."""
|
||||
for base in cls.__bases__:
|
||||
meta = getattr(base, "__pydantic_generic_metadata__", None) or {}
|
||||
if meta.get("origin") is Shape and (args := meta.get("args")):
|
||||
return args[0]
|
||||
return None
|
||||
|
||||
|
||||
class Shape(BaseModel, Generic[_M]):
|
||||
"""Typed projection over a SQLAlchemy mapped model.
|
||||
|
||||
Subclass as `Shape[Model]`; annotate the fields/relationships to project.
|
||||
Scalar annotations become columns to read; annotations referencing another
|
||||
Shape become relationships to eager-load and project recursively.
|
||||
"""
|
||||
|
||||
_model: ClassVar[Any]
|
||||
_nested: ClassVar[dict[str, type[Shape]]]
|
||||
_field_names: ClassVar[list[str]]
|
||||
_pk_field: ClassVar[str]
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
if not (model := _resolve_model(cls)):
|
||||
return
|
||||
|
||||
mapper = sa_inspect(model)
|
||||
cls._model = model
|
||||
cls._nested = {}
|
||||
pk_cols = mapper.primary_key
|
||||
cls._pk_field = pk_cols[0].key if pk_cols else "id"
|
||||
|
||||
hints = get_type_hints(cls, include_extras=False, localns={cls.__name__: cls}) or cls.__annotations__
|
||||
field_names: list[str] = []
|
||||
for name, hint in hints.items():
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
if shape_cls := _extract_shape_class(hint):
|
||||
cls._nested[name] = shape_cls
|
||||
else:
|
||||
field_names.append(name)
|
||||
cls._field_names = field_names
|
||||
|
||||
# ─── Loading + projection ────────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def _loader_options(cls) -> list[Any]:
|
||||
"""`selectinload(...)` chains for every nested relationship (recursive).
|
||||
|
||||
This is the SQLAlchemy analogue of django-readers' prefetch wiring: each
|
||||
nested Shape contributes a `selectinload` on its relationship attribute,
|
||||
with the child Shape's own loader options nested beneath it, so the whole
|
||||
projection loads in O(depth) queries rather than N+1.
|
||||
"""
|
||||
options: list[Any] = []
|
||||
for name, shape_cls in cls._nested.items():
|
||||
attr = getattr(cls._model, name)
|
||||
child = shape_cls._loader_options()
|
||||
loader = selectinload(attr)
|
||||
options.append(loader.options(*child) if child else loader)
|
||||
return options
|
||||
|
||||
@classmethod
|
||||
def _project(cls: type[_S], instance: Any) -> _S:
|
||||
"""Project a loaded ORM instance into this Shape (recursively for nested)."""
|
||||
data: dict[str, Any] = {name: getattr(instance, name) for name in cls._field_names}
|
||||
for name, shape_cls in cls._nested.items():
|
||||
related = getattr(instance, name)
|
||||
if related is None:
|
||||
data[name] = None
|
||||
elif isinstance(related, (list, set, tuple)) or hasattr(related, "__iter__") and not isinstance(related, (str, bytes)):
|
||||
data[name] = [shape_cls._project(child) for child in related]
|
||||
else:
|
||||
data[name] = shape_cls._project(related)
|
||||
return cls.model_validate(data)
|
||||
|
||||
@classmethod
|
||||
def query(cls: type[_S], session: Session, *stmt_fns, **relation_stmt) -> list[_S]:
|
||||
"""Project the model into a list of shapes.
|
||||
|
||||
Args:
|
||||
session: An open SQLAlchemy `Session`.
|
||||
*stmt_fns: Callables `(select) -> select` applied in order to the base
|
||||
`select(Model)` — filters/ordering/limits (the SQLAlchemy analogue
|
||||
of the Django binding's queryset functions).
|
||||
**relation_stmt: Per-relationship callables `(select) -> select` whose
|
||||
criteria scope a nested relationship's load (e.g.
|
||||
``books=lambda s: s.where(Book.is_published.is_(True))``).
|
||||
|
||||
Returns:
|
||||
A list of projected shape instances.
|
||||
"""
|
||||
stmt = select(cls._model)
|
||||
|
||||
loaders = cls._loader_options_scoped(relation_stmt)
|
||||
if loaders:
|
||||
stmt = stmt.options(*loaders)
|
||||
|
||||
for fn in stmt_fns:
|
||||
stmt = fn(stmt)
|
||||
|
||||
rows = session.execute(stmt).unique().scalars().all()
|
||||
return [cls._project(obj) for obj in rows]
|
||||
|
||||
@classmethod
|
||||
def _loader_options_scoped(cls, relation_stmt: dict[str, Any]) -> list[Any]:
|
||||
"""`_loader_options`, but with caller-supplied criteria applied per relation."""
|
||||
if not relation_stmt:
|
||||
return cls._loader_options()
|
||||
options: list[Any] = []
|
||||
for name, shape_cls in cls._nested.items():
|
||||
attr = getattr(cls._model, name)
|
||||
loader = selectinload(attr)
|
||||
child = shape_cls._loader_options()
|
||||
if child:
|
||||
loader = loader.options(*child)
|
||||
scope = relation_stmt.get(name)
|
||||
if scope is not None:
|
||||
# `selectinload(...).and_(...)` filters the related rows loaded.
|
||||
criteria = scope(select(shape_cls._model)).whereclause
|
||||
if criteria is not None:
|
||||
loader = selectinload(attr.and_(criteria))
|
||||
if child:
|
||||
loader = loader.options(*child)
|
||||
options.append(loader)
|
||||
return options
|
||||
|
||||
@classmethod
|
||||
def _get_pk(cls, instance) -> Any | None:
|
||||
return getattr(instance, cls._pk_field, None)
|
||||
|
||||
# ─── Diff ────────────────────────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def diff_many(cls: type[_S], session: Session, items: list[_S]) -> list[tuple[_S, "Diff"]]:
|
||||
"""Diff a batch of shapes against current DB state in one fetch.
|
||||
|
||||
New items (no PK) diff against `None`; existing items batch-fetch by PK.
|
||||
Raises if an item declares a PK that no row matches.
|
||||
"""
|
||||
pk_field = cls._pk_field
|
||||
pk_map: dict[Any, _S] = {}
|
||||
new_items: list[_S] = []
|
||||
for item in items:
|
||||
pk = cls._get_pk(item)
|
||||
(pk_map.__setitem__(pk, item) if pk is not None else new_items.append(item))
|
||||
|
||||
current_map: dict[Any, _S] = {}
|
||||
if pk_map:
|
||||
pk_col = getattr(cls._model, pk_field)
|
||||
current = cls.query(session, lambda s, _c=pk_col: s.where(_c.in_(list(pk_map.keys()))))
|
||||
current_map = {cls._get_pk(c): c for c in current}
|
||||
|
||||
results: list[tuple[_S, Diff]] = []
|
||||
for item in new_items:
|
||||
results.append((item, cls._diff_one(item, None)))
|
||||
for pk, item in pk_map.items():
|
||||
current = current_map.get(pk)
|
||||
if current is None:
|
||||
raise LookupError(f"{cls._model.__name__} with {pk_field}={pk} does not exist")
|
||||
results.append((item, cls._diff_one(item, current)))
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def _diff_one(cls, incoming: _S, current: _S | None) -> "Diff":
|
||||
pk_field = cls._pk_field
|
||||
changed = (
|
||||
{k: getattr(incoming, k) for k in cls._field_names
|
||||
if k != pk_field and getattr(incoming, k) != getattr(current, k)}
|
||||
if current
|
||||
else {k: getattr(incoming, k) for k in cls._field_names if k != pk_field}
|
||||
)
|
||||
|
||||
nested: dict[str, NestedDiff] = {}
|
||||
for name, shape_cls in cls._nested.items():
|
||||
incoming_items = getattr(incoming, name, None) or []
|
||||
current_items = (getattr(current, name, None) or []) if current else []
|
||||
if not isinstance(incoming_items, list):
|
||||
incoming_items = [incoming_items]
|
||||
if not isinstance(current_items, list):
|
||||
current_items = [current_items]
|
||||
|
||||
current_by_pk = {shape_cls._get_pk(c): c for c in current_items if shape_cls._get_pk(c) is not None}
|
||||
incoming_by_pk = {shape_cls._get_pk(c): c for c in incoming_items if shape_cls._get_pk(c) is not None}
|
||||
|
||||
nested[name] = NestedDiff(
|
||||
created=[c for c in incoming_items if shape_cls._get_pk(c) is None],
|
||||
updated=[c for pk, c in incoming_by_pk.items() if pk in current_by_pk and c != current_by_pk[pk]],
|
||||
deleted=[pk for pk in current_by_pk if pk not in incoming_by_pk],
|
||||
)
|
||||
|
||||
return Diff(is_new=current is None, changed=changed, _nested=nested)
|
||||
|
||||
def diff(self, session: Session) -> "Diff":
|
||||
"""Diff this shape against its current DB row (or `None` if new)."""
|
||||
cls = type(self)
|
||||
pk = cls._get_pk(self)
|
||||
if pk is not None:
|
||||
pk_col = getattr(cls._model, cls._pk_field)
|
||||
results = cls.query(session, lambda s: s.where(pk_col == pk))
|
||||
if not results:
|
||||
raise LookupError(f"{cls._model.__name__} with {cls._pk_field}={pk} does not exist")
|
||||
current = results[0]
|
||||
else:
|
||||
current = None
|
||||
return cls._diff_one(self, current)
|
||||
|
||||
|
||||
class NestedDiff:
|
||||
__slots__ = ("created", "updated", "deleted")
|
||||
|
||||
def __init__(self, created=(), updated=(), deleted=()):
|
||||
self.created = list(created)
|
||||
self.updated = list(updated)
|
||||
self.deleted = list(deleted)
|
||||
|
||||
|
||||
class Diff:
|
||||
__slots__ = ("is_new", "changed", "_nested")
|
||||
|
||||
def __init__(self, is_new: bool, changed: dict[str, Any], _nested: dict[str, NestedDiff]):
|
||||
self.is_new = is_new
|
||||
self.changed = changed
|
||||
self._nested = _nested
|
||||
|
||||
def nested(self, name: str) -> NestedDiff:
|
||||
"""Strict access to a nested diff. Raises `KeyError` for an unknown name."""
|
||||
if name not in self._nested:
|
||||
valid = ", ".join(sorted(self._nested)) or "(none)"
|
||||
raise KeyError(f"No nested diff for '{name}'. Valid nested shapes: {valid}")
|
||||
return self._nested[name]
|
||||
|
||||
def __getattr__(self, name: str) -> NestedDiff:
|
||||
if name.startswith("_"):
|
||||
raise AttributeError(name)
|
||||
if name not in self._nested:
|
||||
valid = ", ".join(sorted(self._nested)) or "(none)"
|
||||
raise AttributeError(f"No nested diff for '{name}'. Valid nested shapes: {valid}")
|
||||
return self._nested[name]
|
||||
80
backends/mizan-fastapi/src/mizan_fastapi/ssr.py
Normal file
80
backends/mizan-fastapi/src/mizan_fastapi/ssr.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
SSR render path — FastAPI adapter surface over the shared Bun bridge.
|
||||
|
||||
The SSR subprocess lifecycle and JSON-RPC wire protocol live in
|
||||
`mizan_core.ssr.SSRBridge` (framework-agnostic). FastAPI has no template-engine
|
||||
backend, so instead of Django's `MizanTemplates` veneer this exposes an
|
||||
`SSRRenderer` whose `.render(...)` calls the same bridge — `renderToString` runs
|
||||
in the persistent Bun worker — and returns an `HTMLResponse` with the rendered
|
||||
markup plus the hydration payload the client reads on mount.
|
||||
|
||||
Usage:
|
||||
from mizan_fastapi.ssr import SSRRenderer
|
||||
|
||||
ssr = SSRRenderer(worker="path/to/mizan-ssr/src/worker.tsx", dirs=["frontend"])
|
||||
|
||||
@app.get("/profile/{user_id}")
|
||||
async def profile(user_id: int):
|
||||
return ssr.render("components/Profile.tsx", {"user_id": user_id})
|
||||
|
||||
`render` resolves the template name to an absolute file path against `dirs`
|
||||
(parity with Django's `DIRS`), then renders the component's default export. The
|
||||
hydration wrapping matches the Django backend byte-for-byte so the same client
|
||||
bundle hydrates either server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from mizan_core.ssr import SSRBridge
|
||||
|
||||
|
||||
class SSRRenderer:
|
||||
"""Render React `.tsx`/`.jsx` files via the shared Bun SSR bridge.
|
||||
|
||||
One renderer owns one persistent `SSRBridge`. Thread-safe (the bridge
|
||||
serializes worker I/O); a single renderer can be shared across the app.
|
||||
"""
|
||||
|
||||
def __init__(self, worker: str, dirs: list[str] | None = None, timeout: float = 5.0) -> None:
|
||||
self._dirs = list(dirs or [])
|
||||
self._bridge = SSRBridge(worker_path=worker, timeout=timeout)
|
||||
|
||||
def _resolve(self, template_name: str) -> str:
|
||||
"""Resolve a template name to an absolute file path against `dirs`.
|
||||
|
||||
An already-absolute, existing path is used directly; otherwise each `dirs`
|
||||
entry is tried in order (parity with Django's `DIRS` resolution).
|
||||
"""
|
||||
if os.path.isabs(template_name) and os.path.isfile(template_name):
|
||||
return template_name
|
||||
for dir_path in self._dirs:
|
||||
candidate = os.path.join(dir_path, template_name)
|
||||
if os.path.isfile(candidate):
|
||||
return os.path.abspath(candidate)
|
||||
raise FileNotFoundError(
|
||||
f"SSR component '{template_name}' not found in dirs={self._dirs!r}"
|
||||
)
|
||||
|
||||
def render_to_string(self, template_name: str, props: dict[str, Any] | None = None) -> str:
|
||||
"""Render the component to an HTML string (markup + hydration script)."""
|
||||
props = dict(props or {})
|
||||
result = self._bridge.render(self._resolve(template_name), props)
|
||||
hydration_json = json.dumps(props, sort_keys=True, default=str)
|
||||
return (
|
||||
f'<div id="mizan-root">{result.html}</div>'
|
||||
f"<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>"
|
||||
)
|
||||
|
||||
def render(self, template_name: str, props: dict[str, Any] | None = None, status_code: int = 200) -> HTMLResponse:
|
||||
"""Render the component and return a FastAPI `HTMLResponse`."""
|
||||
return HTMLResponse(self.render_to_string(template_name, props), status_code=status_code)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the underlying Bun subprocess."""
|
||||
self._bridge.shutdown()
|
||||
Reference in New Issue
Block a user