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:
151
backends/mizan-fastapi/tests/test_dispatch.py
Normal file
151
backends/mizan-fastapi/tests/test_dispatch.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""End-to-end dispatch tests against a real FastAPI app + TestClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.registry import clear_registry, register
|
||||
from mizan_fastapi import router as mizan_router
|
||||
|
||||
|
||||
# ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class SumOutput(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class UserOutput(BaseModel):
|
||||
email: str
|
||||
authenticated: bool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Build a fresh FastAPI app + Mizan router with a few @client functions."""
|
||||
clear_registry()
|
||||
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=f"echo: {text}")
|
||||
|
||||
@client
|
||||
def add(request, a: int, b: int) -> SumOutput:
|
||||
return SumOutput(total=a + b)
|
||||
|
||||
@client(context="user")
|
||||
def current_user(request) -> UserOutput:
|
||||
return UserOutput(email="anon@example.com", authenticated=False)
|
||||
|
||||
@client(context="user")
|
||||
def user_count(request) -> SumOutput:
|
||||
return SumOutput(total=42)
|
||||
|
||||
@client(affects="user")
|
||||
def update_email(request, email: str) -> EchoOutput:
|
||||
return EchoOutput(message=f"updated: {email}")
|
||||
|
||||
register(echo, "echo")
|
||||
register(add, "add")
|
||||
register(current_user, "current_user")
|
||||
register(user_count, "user_count")
|
||||
register(update_email, "update_email")
|
||||
|
||||
fastapi_app = FastAPI()
|
||||
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
|
||||
|
||||
yield fastapi_app
|
||||
|
||||
clear_registry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http(app):
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ─── RPC dispatch ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FunctionCallTests:
|
||||
def test_simple_call_returns_result(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "hi"}})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"]["message"] == "echo: hi"
|
||||
assert body["invalidate"] == []
|
||||
|
||||
def test_call_with_typed_input(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": 2, "b": 3}})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["result"]["total"] == 5
|
||||
|
||||
def test_unknown_function_returns_not_found(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "ghost"})
|
||||
assert r.status_code == 404
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
def test_validation_error_returns_422(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": "not-int", "b": 3}})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
def test_missing_required_input_returns_validation_error(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {}})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
def test_missing_fn_field_returns_400(self, http):
|
||||
r = http.post("/api/mizan/call/", json={})
|
||||
assert r.status_code == 400
|
||||
assert r.json()["error"]["code"] == "BAD_REQUEST"
|
||||
|
||||
def test_invalid_json_returns_400(self, http):
|
||||
r = http.post("/api/mizan/call/", content=b"not json", headers={"content-type": "application/json"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_response_carries_no_store(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "x"}})
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
|
||||
|
||||
# ─── Context bundling ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ContextFetchTests:
|
||||
def test_context_returns_bundled_results(self, http):
|
||||
r = http.get("/api/mizan/ctx/user/")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "current_user" in body
|
||||
assert "user_count" in body
|
||||
assert body["current_user"]["email"] == "anon@example.com"
|
||||
assert body["user_count"]["total"] == 42
|
||||
|
||||
def test_unknown_context_returns_not_found(self, http):
|
||||
r = http.get("/api/mizan/ctx/ghost/")
|
||||
assert r.status_code == 404
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class InvalidationTests:
|
||||
def test_mutation_emits_invalidate_list(self, http):
|
||||
r = http.post(
|
||||
"/api/mizan/call/",
|
||||
json={"fn": "update_email", "args": {"email": "new@example.com"}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# affects='user' is a context-name string → invalidate list contains 'user'
|
||||
assert "user" in body["invalidate"]
|
||||
Reference in New Issue
Block a user