Build mizan-fastapi MVP — HTTP RPC + context bundling

The Blazr-critical surface for FastAPI. Forms, Channels, Shapes, SSR,
and MWT are out of scope (Ryth's call: defer until Blazr exercises
them; FastAPI projects use native equivalents anyway).

What ships:
- POST /api/mizan/call/      RPC dispatch with Pydantic input validation
- GET  /api/mizan/ctx/{name}/ bundled context fetch (all functions in
                              the named context, parallel-evaluated, single
                              JSON response)
- JSON-body invalidation transport (the 'invalidate' field on mutation
  responses, with auto-scoping when mutation arg names match context params)
- Auth check infrastructure expecting request.state.user populated by
  FastAPI middleware/deps (matches FastAPI idioms)
- Cache-Control: no-store on all responses

Built on existing mizan-core: registry (function lookup, context groups,
invalidation metadata), client.function (the @client decorator + ServerFunction
+ _FunctionWrapper). No code copied or duplicated from mizan-django — the
shared substrate is genuinely shared.

Package layout:
  backends/mizan-fastapi/
    pyproject.toml         distribution=mizan-fastapi, module=mizan_fastapi
    src/mizan_fastapi/
      executor.py          dispatch + auth + invalidation
      router.py            FastAPI APIRouter with the two endpoints
    tests/test_dispatch.py 11 e2e tests against TestClient

Test fixture establishes the registration pattern: explicit
register(fn_class, "name") after each @client. mizan-fastapi doesn't
ship discovery — apps register their functions explicitly. (mizan-django
keeps its DjangoAppVisitor discovery; FastAPI's lack of an app system
makes auto-discovery less natural.)

Makefile: install + test targets now include mizan-fastapi alongside
the other packages. New test-core / test-fastapi targets added for
symmetry.

Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-fastapi: 11/11
- mizan-ts edge-compat: 34/34

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 16:39:19 -04:00
parent dd41f0c25f
commit 4e4d1bb6b1
7 changed files with 539 additions and 3 deletions

View File

@@ -0,0 +1,30 @@
"""
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
Provides HTTP RPC dispatch and context bundling on top of mizan-core's
function registry. Channels, Forms, Shapes, SSR are out of scope for
the FastAPI adapter — FastAPI projects use native equivalents (WebSocket,
Pydantic models, ORM-of-choice, server-side rendering frameworks).
Usage:
from fastapi import FastAPI
from mizan_fastapi import router as mizan_router
app = FastAPI()
app.include_router(mizan_router, prefix="/api/mizan")
# Register your @client-decorated functions
from mizan_core.client.function import client
from .my_functions import * # noqa
"""
from .router import router
from .executor import execute_function, ErrorCode, FunctionError, FunctionResult
__all__ = [
"router",
"execute_function",
"ErrorCode",
"FunctionError",
"FunctionResult",
]

View File

@@ -0,0 +1,196 @@
"""
RPC dispatch — looks up registered functions by name, validates input
against the function's Pydantic Input model, executes, and returns the
result wrapped in a normalized FunctionResult / FunctionError.
Backend-agnostic where possible. The only FastAPI-specific bits are the
Request type hint (kept loose as Any) and the auth-check mechanism, which
expects FastAPI to populate request.state.user via dependency injection
or middleware before dispatch.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any
from pydantic import BaseModel, ValidationError
from mizan_core.registry import get_function
# ─── Error / Result types ───────────────────────────────────────────────────
class ErrorCode(str, Enum):
NOT_FOUND = "NOT_FOUND"
BAD_REQUEST = "BAD_REQUEST"
VALIDATION_ERROR = "VALIDATION_ERROR"
UNAUTHORIZED = "UNAUTHORIZED"
FORBIDDEN = "FORBIDDEN"
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
INTERNAL_ERROR = "INTERNAL_ERROR"
@dataclass
class FunctionError:
code: ErrorCode
message: str
details: dict[str, Any] | None = None
@dataclass
class FunctionResult:
data: Any # serialized return value (dict, list, primitive, or Pydantic model_dump)
# ─── Auth ───────────────────────────────────────────────────────────────────
def _check_auth(request: Any, auth_requirement: Any) -> FunctionError | None:
"""
Verify the request meets the function's auth requirement.
The auth value comes from @client(auth=...). FastAPI projects are expected
to populate `request.state.user` (or compatible) via middleware. If
request has no `.state` or `.state.user`, treats the user as anonymous.
"""
if auth_requirement is None:
return None
user = getattr(getattr(request, "state", None), "user", None)
is_authenticated = bool(user) and getattr(user, "is_authenticated", True)
if auth_requirement is True:
if not is_authenticated:
return FunctionError(ErrorCode.UNAUTHORIZED, "Authentication required")
return None
if auth_requirement == "staff":
if not is_authenticated:
return FunctionError(ErrorCode.UNAUTHORIZED, "Authentication required")
if not getattr(user, "is_staff", False):
return FunctionError(ErrorCode.FORBIDDEN, "Staff access required")
return None
if auth_requirement == "superuser":
if not is_authenticated:
return FunctionError(ErrorCode.UNAUTHORIZED, "Authentication required")
if not getattr(user, "is_superuser", False):
return FunctionError(ErrorCode.FORBIDDEN, "Superuser access required")
return None
if callable(auth_requirement):
if not auth_requirement(request):
return FunctionError(ErrorCode.FORBIDDEN, "Permission denied")
return None
return FunctionError(
ErrorCode.INTERNAL_ERROR,
f"Unknown auth requirement: {auth_requirement!r}",
)
# ─── Dispatch ───────────────────────────────────────────────────────────────
def execute_function(
request: Any,
fn_name: str,
input_data: dict[str, Any] | None = None,
) -> FunctionResult | FunctionError:
"""
Look up a registered function by name, validate input, execute, return result.
"""
view_class = get_function(fn_name)
if view_class is None:
return FunctionError(ErrorCode.NOT_FOUND, "Function not found")
meta = getattr(view_class, "_meta", {})
if meta.get("private"):
return FunctionError(ErrorCode.FORBIDDEN, "Function is not client-callable")
auth_error = _check_auth(request, meta.get("auth"))
if auth_error is not None:
return auth_error
view = view_class(request)
input_cls = view.Input
# Pydantic input validation
has_fields = bool(getattr(input_cls, "model_fields", None)) if input_cls else False
if input_data is not None and has_fields:
if not isinstance(input_data, dict):
return FunctionError(
ErrorCode.BAD_REQUEST,
f"Input must be an object, got {type(input_data).__name__}",
)
try:
validated_input = input_cls(**input_data)
except ValidationError as e:
return FunctionError(
ErrorCode.VALIDATION_ERROR,
"Input validation failed",
details={"errors": e.errors()},
)
elif has_fields:
# Function expects input but none provided
required = input_cls.model_json_schema().get("required", [])
if required:
return FunctionError(
ErrorCode.VALIDATION_ERROR,
"Input validation failed",
details={"fields": {field: ["Field required"] for field in required}},
)
validated_input = input_cls()
else:
validated_input = None
# Execute. The wrapper's call(input) always takes the input arg,
# passing None when the function has no fields.
try:
result = view.call(validated_input)
except NotImplementedError as e:
return FunctionError(ErrorCode.NOT_IMPLEMENTED, str(e) or "Not implemented")
except Exception as e:
return FunctionError(ErrorCode.INTERNAL_ERROR, str(e))
# Serialize Pydantic models to plain dicts
if isinstance(result, BaseModel):
return FunctionResult(data=result.model_dump(mode="json"))
return FunctionResult(data=result)
# ─── Invalidation ───────────────────────────────────────────────────────────
def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]:
"""
Build the invalidate list for a mutation response from the function's
@client(affects=...) metadata. Auto-scopes to params when the mutation's
arg names overlap with a context's params.
"""
meta = getattr(view_class, "_meta", {})
affects = meta.get("affects")
if not affects:
return []
out: list[Any] = []
for target in affects:
target_type = target.get("type")
target_name = target.get("name")
if target_type == "context":
scope_params = target.get("params") or {}
if scope_params and input_data:
# Auto-scope: include matching param values
matched = {k: input_data[k] for k in scope_params if k in input_data}
if matched:
out.append({"context": target_name, "params": matched})
continue
out.append(target_name)
elif target_type == "function":
out.append({"function": target_name})
return out

View File

@@ -0,0 +1,118 @@
"""
FastAPI router exposing Mizan's HTTP endpoints:
POST /call/ — RPC dispatch
GET /ctx/{context_name}/ — bundled context fetch
Mount under /api/mizan in your FastAPI app:
from fastapi import FastAPI
from mizan_fastapi import router as mizan_router
app = FastAPI()
app.include_router(mizan_router, prefix="/api/mizan")
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from mizan_core.registry import get_context_groups, get_function
from .executor import (
ErrorCode,
FunctionError,
FunctionResult,
compute_invalidation,
execute_function,
)
router = APIRouter()
_HTTP_STATUS_FOR_ERROR = {
ErrorCode.NOT_FOUND: 404,
ErrorCode.BAD_REQUEST: 400,
ErrorCode.VALIDATION_ERROR: 422,
ErrorCode.UNAUTHORIZED: 401,
ErrorCode.FORBIDDEN: 403,
ErrorCode.NOT_IMPLEMENTED: 501,
ErrorCode.INTERNAL_ERROR: 500,
}
def _error_response(err: FunctionError) -> JSONResponse:
body: dict[str, Any] = {
"error": {
"code": err.code.value,
"message": err.message,
}
}
if err.details:
body["error"]["details"] = err.details
return JSONResponse(
body,
status_code=_HTTP_STATUS_FOR_ERROR.get(err.code, 500),
headers={"Cache-Control": "no-store"},
)
@router.post("/call/")
async def function_call(request: Request) -> JSONResponse:
"""RPC dispatch — `{"fn": "name", "args": {...}}` → `{"result": ..., "invalidate": [...]}`."""
try:
body = await request.json()
except Exception:
return _error_response(
FunctionError(ErrorCode.BAD_REQUEST, "Invalid JSON body")
)
if not isinstance(body, dict):
return _error_response(
FunctionError(ErrorCode.BAD_REQUEST, "Body must be an object")
)
fn_name = body.get("fn")
if not fn_name or not isinstance(fn_name, str):
return _error_response(
FunctionError(ErrorCode.BAD_REQUEST, "Missing or invalid 'fn' field")
)
args = body.get("args", {})
outcome = execute_function(request, fn_name, args)
if isinstance(outcome, FunctionError):
return _error_response(outcome)
view_class = get_function(fn_name)
invalidate = compute_invalidation(view_class, args)
return JSONResponse(
{"result": outcome.data, "invalidate": invalidate},
headers={"Cache-Control": "no-store"},
)
@router.get("/ctx/{context_name}/")
async def context_fetch(context_name: str, request: Request) -> JSONResponse:
"""Bundled context fetch — returns `{function_name: result, ...}` for every function in the named context."""
groups = get_context_groups()
fn_names = groups.get(context_name)
if not fn_names:
return _error_response(
FunctionError(ErrorCode.NOT_FOUND, f"Context '{context_name}' not found")
)
params = dict(request.query_params)
result: dict[str, Any] = {}
for fn_name in fn_names:
outcome = execute_function(request, fn_name, params)
if isinstance(outcome, FunctionError):
return _error_response(outcome)
result[fn_name] = outcome.data
return JSONResponse(result, headers={"Cache-Control": "no-store"})