AFI conformance test suite

Substrate-level gate: same @client fixture registered in both backends
emits equivalent schemas, therefore the codegen produces equivalent
TypeScript regardless of which backend the frontend is generated against.
Catches adapter symmetry problems (Pydantic→OpenAPI converter divergence,
metadata leakage, ordering non-determinism) without docker, browser, or
Playwright.

What ships:

backends/mizan-fastapi/src/mizan_fastapi/schema.py — build_schema():
- Builds OpenAPI 3.0 from registered Mizan functions, mirroring the
  shape mizan-django's export emits.
- Drives FastAPI's native OpenAPI generation by registering a stub POST
  endpoint per function with its Input/Output Pydantic models, then
  appends x-mizan-functions and x-mizan-contexts extensions.
- Param-elevation logic mirrors mizan-django/src/mizan/export/__init__.py
  exactly (sharedBy tracking, required iff every function in context has
  the param).
- snake_to_camel and metadata field shapes match Django for byte-equality
  on the AFI surface.

tests/afi/ — the conformance harness:
- fixture.py: 5 @client functions covering the protocol axes (plain,
  context, mutation+affects). No channels/forms — those aren't AFI-common.
- django_app/: minimal Django project (settings, urls, AppConfig.ready
  registers the fixture). manage.py adds tests/afi/ to sys.path so both
  backends import the same fixture module.
- fastapi_app.py: thin make_app() that registers fixture and mounts router.
- schema_normalizer.py: drops backend-specific framing — Ninja-vs-FastAPI
  envelope differences (info/servers/tags), Django-only function fields
  (form metadata), x-mizan-channels. Plus afi_subset() and
  function_io_schemas() helpers for narrower comparisons.
- test_codegen_parity.py: three gates
  1. x-mizan-functions match across backends
  2. x-mizan-contexts match across backends
  3. Per-function Input/Output OpenAPI schemas match (what codegen feeds
     to openapi-typescript for type generation)

The full normalized OpenAPI envelopes do diverge — FastAPI adds
HTTPValidationError, the two converters wrap things slightly differently
in non-AFI-essential ways. That's not in the test scope. The codegen
only consumes x-mizan-functions, x-mizan-contexts, and the per-function
type schemas; those are what the test gates.

Makefile: test-afi target added; rolls into the test aggregate.

Verified: 3/3 conformance tests pass. Other surfaces unaffected —
mizan-core 15/15, mizan-django 348 pass, mizan-fastapi 11/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:59:01 -04:00
parent aaaf80cdbf
commit 0a95f3c860
16 changed files with 584 additions and 2 deletions

View File

@@ -1,9 +1,10 @@
.PHONY: install test test-core test-django test-fastapi test-react test-integration docker-up docker-down clean
.PHONY: install test test-core test-django test-fastapi test-react test-afi test-integration docker-up docker-down clean
CORE = cores/mizan-python
DJANGO = backends/mizan-django
FASTAPI = backends/mizan-fastapi
REACT = frontends/mizan-react
AFI = tests/afi
# ─── Setup ───────────────────────────────────────────────────────────────────
@@ -15,7 +16,7 @@ install:
# ─── Unit Tests ──────────────────────────────────────────────────────────────
test: test-core test-django test-fastapi test-react
test: test-core test-django test-fastapi test-react test-afi
test-core:
cd $(CORE) && uv run --extra dev pytest
@@ -29,6 +30,11 @@ test-fastapi:
test-react:
cd $(REACT) && npm test
# AFI conformance — verifies mizan-django and mizan-fastapi emit equivalent
# schemas for the same @client fixture. Substrate-level gate, not e2e.
test-afi:
cd $(AFI) && uv run pytest
# ─── Integration Tests ──────────────────────────────────────────────────────
test-integration: docker-up

View File

@@ -35,6 +35,7 @@ from .executor import (
execute_function,
)
from .router import router, mizan_exception_handler, mizan_validation_handler
from .schema import build_schema
__all__ = [
"router",
@@ -42,6 +43,7 @@ __all__ = [
"mizan_validation_handler",
"execute_function",
"compute_invalidation",
"build_schema",
"ErrorCode",
"MizanError",
"NotFound",

View File

@@ -0,0 +1,209 @@
"""
Mizan schema export for FastAPI backends.
Builds an OpenAPI 3.0 document from the registered Mizan functions, mirroring
the shape mizan-django emits via Django Ninja so the codegen consumes either
backend identically.
Usage:
from mizan_fastapi.schema import build_schema
schema = build_schema() # uses globally registered functions
"""
from __future__ import annotations
import re
from typing import Any
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from pydantic import BaseModel, create_model
from mizan_core.registry import get_all_functions, get_context_groups, get_function
__all__ = ["build_schema", "snake_to_camel"]
# Common user identity param names — mirrors mizan-django's _USER_SCOPED_PARAMS
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
def snake_to_camel(name: str) -> str:
"""Convert snake_case or dotted.name to camelCase. Mirrors mizan-django."""
components = re.split(r"[._]", name)
return components[0] + "".join(c.title() for c in components[1:])
def _has_input(input_cls: Any) -> bool:
return (
input_cls is not None
and input_cls is not BaseModel
and hasattr(input_cls, "model_fields")
and bool(input_cls.model_fields)
)
def _annotation_to_jsonschema_type(annotation: Any) -> str:
if annotation is int:
return "integer"
if annotation is float:
return "number"
if annotation is bool:
return "boolean"
return "string"
def _function_metadata(name: str, fn_class: Any) -> dict[str, Any]:
"""Build one entry of x-mizan-functions. Mirrors Django's shape exactly."""
camel = snake_to_camel(name)
meta = getattr(fn_class, "_meta", {})
input_cls = getattr(fn_class, "Input", None)
has_input = _has_input(input_cls)
entry: dict[str, Any] = {
"name": name,
"camelName": camel,
"hasInput": has_input,
"inputType": f"{camel}Input" if has_input else None,
"outputType": f"{camel}Output",
"transport": "websocket" if meta.get("websocket") else "http",
"isContext": meta.get("context", False),
# Form metadata — always emitted so the schema shape matches Django's,
# even for FastAPI projects that don't use forms (these stay False/None).
"isForm": meta.get("form", False),
"formName": meta.get("form_name"),
"formRole": meta.get("form_role"),
}
if meta.get("affects"):
entry["affects"] = meta["affects"]
return entry
def _context_metadata(context_groups: dict[str, list[str]]) -> dict[str, Any]:
"""Build x-mizan-contexts. Mirrors Django's param-elevation logic."""
out: dict[str, Any] = {}
for ctx_name, fn_names in context_groups.items():
param_info: dict[str, dict[str, Any]] = {}
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if not _has_input(input_cls):
continue
for field_name, field_info in input_cls.model_fields.items():
if field_name not in param_info:
param_info[field_name] = {
"type": _annotation_to_jsonschema_type(field_info.annotation),
"sharedBy": [],
}
param_info[field_name]["sharedBy"].append(fn_name)
# A param is required iff every function in the context declares it.
for p_meta in param_info.values():
p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)
out[ctx_name] = {
"functions": list(fn_names),
"params": param_info,
}
return out
def build_schema() -> dict[str, Any]:
"""
Build an OpenAPI 3.0 schema for all registered Mizan functions.
Drives FastAPI's native OpenAPI generation by registering a stub endpoint
per function with the function's Input/Output Pydantic models, then
appends the protocol's `x-mizan-functions` and `x-mizan-contexts`
extensions.
Returns a dict in the same shape mizan-django's schema export emits, so
the same codegen pipeline consumes either.
"""
functions = get_all_functions()
context_groups = get_context_groups()
schema_app = FastAPI(
title="mizan Server Functions",
version="1.0.0",
description="Auto-generated schema for mizan server functions",
)
# Per-function endpoints + renamed Pydantic models so component names are
# camelCase + "Input"/"Output" rather than the user's original class names.
schema_classes: dict[str, type[BaseModel]] = {}
function_metadata: list[dict[str, Any]] = []
for name, fn_class in functions.items():
camel = snake_to_camel(name)
input_cls = getattr(fn_class, "Input", None)
output_cls = getattr(fn_class, "Output", None) or BaseModel
has_input = _has_input(input_cls)
input_type_name = f"{camel}Input" if has_input else None
output_type_name = f"{camel}Output"
if has_input:
schema_classes[input_type_name] = create_model(
input_type_name, __base__=input_cls,
)
schema_classes[output_type_name] = create_model(
output_type_name, __base__=output_cls,
)
# Stub endpoint — only exists so FastAPI walks Pydantic types into
# components.schemas. Never invoked. Annotations are set explicitly
# rather than via closures so forward-ref resolution doesn't trip on
# locally-bound type names.
if has_input:
async def stub(payload):
return None
stub.__annotations__ = {"payload": schema_classes[input_type_name]}
else:
async def stub():
return None
schema_app.post(
f"/mizan/{name}",
response_model=schema_classes[output_type_name],
operation_id=camel,
summary=fn_class.__doc__ or f"Call {name}",
)(stub)
function_metadata.append(_function_metadata(name, fn_class))
schema = get_openapi(
title=schema_app.title,
version=schema_app.version,
description=schema_app.description,
routes=schema_app.routes,
)
schema["x-mizan-functions"] = function_metadata
if context_groups:
schema["x-mizan-contexts"] = _context_metadata(context_groups)
# Attach x-mizan operation metadata, mirroring Django.
paths = schema.get("paths", {})
for fn_meta in function_metadata:
op = paths.get(f"/mizan/{fn_meta['name']}", {}).get("post")
if op is not None:
op["x-mizan"] = {
"transport": fn_meta["transport"],
"isContext": fn_meta["isContext"],
}
return schema

0
tests/afi/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1 @@
default_app_config = "afi_app.apps.AfiAppConfig"

View File

@@ -0,0 +1,13 @@
"""Django app config that registers the AFI fixture on Django's ready() hook."""
from django.apps import AppConfig
class AfiAppConfig(AppConfig):
name = "afi_app"
def ready(self) -> None:
# tests/afi/ is on sys.path (added by manage.py), so `fixture` resolves
# to tests/afi/fixture.py — the same module the FastAPI side imports.
from fixture import register_fixture
register_fixture()

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python
"""Minimal Django manage.py for the AFI conformance fixture."""
import os
import sys
def main() -> None:
here = os.path.dirname(os.path.abspath(__file__))
# tests/afi/ must be on sys.path so apps.py can `from fixture import …`.
sys.path.insert(0, os.path.dirname(here))
sys.path.insert(0, here)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,20 @@
"""Minimal Django settings for the AFI conformance fixture."""
SECRET_KEY = "afi-conformance-test-only"
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.auth",
"mizan",
"afi_app",
]
DATABASES = {
"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"},
}
ROOT_URLCONF = "project.urls"
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

View File

@@ -0,0 +1,3 @@
"""Empty URLconf — the AFI fixture only exercises the schema export."""
urlpatterns: list = []

26
tests/afi/fastapi_app.py Normal file
View File

@@ -0,0 +1,26 @@
"""FastAPI test app that registers the AFI fixture and exposes build_schema()."""
from __future__ import annotations
from fastapi import FastAPI
from mizan_fastapi import (
MizanError,
mizan_exception_handler,
mizan_validation_handler,
router as mizan_router,
)
from fixture import register_fixture
def make_app() -> FastAPI:
"""Build a fresh FastAPI app with the AFI fixture registered."""
register_fixture()
app = FastAPI()
app.include_router(mizan_router, prefix="/api/mizan")
app.add_exception_handler(MizanError, mizan_exception_handler)
from fastapi.exceptions import RequestValidationError
app.add_exception_handler(RequestValidationError, mizan_validation_handler)
return app

94
tests/afi/fixture.py Normal file
View File

@@ -0,0 +1,94 @@
"""
The AFI fixture — a small set of @client-decorated functions designed to
exercise the protocol axes both backends must agree on:
- plain function with typed input
- plain function with no input
- two context functions sharing a param (proves bundling + param elevation)
- a mutation declaring `affects` on the context
No channels, no forms, no shapes — those aren't AFI-common.
`register_fixture()` registers the functions with mizan_core.registry.
Backend test apps import this module and call register_fixture() during
their setup so each backend's schema export sees the same registrations.
"""
from __future__ import annotations
from pydantic import BaseModel
from mizan_core.client.function import client
from mizan_core.registry import register
# ─── Output shapes ──────────────────────────────────────────────────────────
class EchoOutput(BaseModel):
message: str
class WhoamiOutput(BaseModel):
email: str
authenticated: bool
class ProfileOutput(BaseModel):
user_id: int
name: str
class OrderOutput(BaseModel):
id: int
user_id: int
total: int
class StatusOutput(BaseModel):
ok: bool
# ─── Fixture functions ──────────────────────────────────────────────────────
@client
def echo(request, text: str) -> EchoOutput:
"""Echoes the input back."""
return EchoOutput(message=f"echo: {text}")
@client
def whoami(request) -> WhoamiOutput:
"""Returns the current user identity."""
return WhoamiOutput(email="anon@example.com", authenticated=False)
@client(context="user")
def user_profile(request, user_id: int) -> ProfileOutput:
"""One half of the user context."""
return ProfileOutput(user_id=user_id, name="placeholder")
@client(context="user")
def user_orders(request, user_id: int) -> list[OrderOutput]:
"""Other half of the user context — same param, proves param elevation."""
return []
@client(affects="user")
def update_profile(request, user_id: int, name: str) -> StatusOutput:
"""Mutation declaring affects on the user context."""
return StatusOutput(ok=True)
# ─── Registration ───────────────────────────────────────────────────────────
def register_fixture() -> None:
"""Register every fixture function with mizan_core.registry."""
register(echo, "echo")
register(whoami, "whoami")
register(user_profile, "user_profile")
register(user_orders, "user_orders")
register(update_profile, "update_profile")

22
tests/afi/pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "mizan-afi-tests"
version = "0.0.0"
description = "AFI conformance tests — verifies mizan-django and mizan-fastapi emit equivalent schemas for the same registered functions."
requires-python = ">=3.10"
dependencies = [
"mizan-core",
"mizan",
"mizan-fastapi",
"pytest>=8.0",
]
[tool.uv.sources]
mizan-core = { path = "../../cores/mizan-python", editable = true }
mizan = { path = "../../backends/mizan-django", editable = true }
mizan-fastapi = { path = "../../backends/mizan-fastapi", editable = true }
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["."]
python_classes = ["*Tests", "*Test", "Test*"]
python_functions = ["test_*"]

View File

@@ -0,0 +1,79 @@
"""
Normalize an OpenAPI schema dict so backend-specific framing doesn't make
two semantically-equivalent schemas compare unequal.
Drops:
- Top-level OpenAPI envelope fields that vary trivially (info, servers, tags)
- Django-only x-mizan-functions sub-fields (form metadata)
- Django-only top-level extensions (x-mizan-channels)
Sorts:
- x-mizan-functions by name
- components.schemas keys
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any
_DJANGO_ONLY_FUNCTION_FIELDS = {"isForm", "formName", "formRole", "formFields", "formFieldsError"}
_OPENAPI_ENVELOPE_FIELDS_TO_DROP = {"info", "servers", "tags", "openapi"}
def normalize(schema: dict[str, Any]) -> dict[str, Any]:
"""Return a canonicalized copy suitable for deep-equal comparison."""
out = deepcopy(schema)
for k in _OPENAPI_ENVELOPE_FIELDS_TO_DROP:
out.pop(k, None)
out.pop("x-mizan-channels", None)
fns = out.get("x-mizan-functions") or []
for fn in fns:
for field in _DJANGO_ONLY_FUNCTION_FIELDS:
fn.pop(field, None)
out["x-mizan-functions"] = sorted(fns, key=lambda f: f["name"])
components = out.get("components", {})
schemas = components.get("schemas")
if isinstance(schemas, dict):
components["schemas"] = {k: schemas[k] for k in sorted(schemas)}
return out
def afi_subset(schema: dict[str, Any]) -> dict[str, Any]:
"""Just the AFI-essential surface — what every adapter must agree on."""
fns = schema.get("x-mizan-functions") or []
fns_clean = [
{k: v for k, v in fn.items() if k not in _DJANGO_ONLY_FUNCTION_FIELDS}
for fn in fns
]
return {
"x-mizan-functions": sorted(fns_clean, key=lambda f: f["name"]),
"x-mizan-contexts": schema.get("x-mizan-contexts", {}),
}
def function_io_schemas(schema: dict[str, Any]) -> dict[str, Any]:
"""
Subset of components.schemas containing only the per-function Input/Output
models (what the codegen actually consumes for TypeScript type generation).
Drops backend-specific noise like HTTPValidationError, ValidationError,
that one backend emits and the other doesn't.
"""
fns = schema.get("x-mizan-functions") or []
expected_names: set[str] = set()
for fn in fns:
if fn.get("inputType"):
expected_names.add(fn["inputType"])
if fn.get("outputType"):
expected_names.add(fn["outputType"])
components = schema.get("components", {})
schemas = components.get("schemas", {})
return {name: schemas[name] for name in sorted(expected_names) if name in schemas}

View File

@@ -0,0 +1,88 @@
"""
AFI conformance — same @client fixture, same schema, both adapters.
Gates that mizan-django and mizan-fastapi emit equivalent schemas for the
same registered functions. If this passes, the codegen produces equivalent
TypeScript output regardless of which backend the frontend is generated
against (codegen is deterministic over schema input).
This is a substrate-level gate, not e2e. It catches adapter symmetry
problems — Pydantic→OpenAPI converter divergence, metadata leakage,
ordering non-determinism — without running a real frontend or backend.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).parent
DJANGO_MANAGE = HERE / "django_app" / "manage.py"
# ─── Schema fetchers ────────────────────────────────────────────────────────
def _fetch_django_schema() -> dict:
"""Spawn Django's management command and parse its stdout JSON."""
result = subprocess.run(
[sys.executable, str(DJANGO_MANAGE), "export_mizan_schema", "--indent", "0"],
capture_output=True,
text=True,
check=False,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
)
if result.returncode != 0:
pytest.fail(f"export_mizan_schema failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}")
return json.loads(result.stdout)
def _fetch_fastapi_schema() -> dict:
"""Build the FastAPI app inline (fresh registry) and call build_schema()."""
sys.path.insert(0, str(HERE))
try:
from mizan_core.registry import clear_registry
from mizan_fastapi import build_schema
from fastapi_app import make_app
clear_registry()
make_app()
return build_schema()
finally:
sys.path.remove(str(HERE))
# ─── Tests ──────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def schemas() -> tuple[dict, dict]:
return _fetch_django_schema(), _fetch_fastapi_schema()
class AFISubsetTests:
"""The AFI surface — x-mizan-functions and x-mizan-contexts — must match."""
def test_x_mizan_functions_match(self, schemas):
from schema_normalizer import afi_subset
django, fastapi = schemas
assert afi_subset(django)["x-mizan-functions"] == afi_subset(fastapi)["x-mizan-functions"]
def test_x_mizan_contexts_match(self, schemas):
from schema_normalizer import afi_subset
django, fastapi = schemas
assert afi_subset(django)["x-mizan-contexts"] == afi_subset(fastapi)["x-mizan-contexts"]
class TypeSchemasTests:
"""Per-function Input/Output Pydantic→OpenAPI schemas — codegen feeds these to openapi-typescript."""
def test_function_io_schemas_match(self, schemas):
from schema_normalizer import function_io_schemas
django, fastapi = schemas
assert function_io_schemas(django) == function_io_schemas(fastapi)