diff --git a/Makefile b/Makefile index 5671344..7531560 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py index 13c2bc4..1f4f894 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py @@ -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", diff --git a/backends/mizan-fastapi/src/mizan_fastapi/schema.py b/backends/mizan-fastapi/src/mizan_fastapi/schema.py new file mode 100644 index 0000000..dc4c041 --- /dev/null +++ b/backends/mizan-fastapi/src/mizan_fastapi/schema.py @@ -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 diff --git a/tests/afi/__init__.py b/tests/afi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/afi/django_app/__init__.py b/tests/afi/django_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/afi/django_app/afi_app/__init__.py b/tests/afi/django_app/afi_app/__init__.py new file mode 100644 index 0000000..b86a93e --- /dev/null +++ b/tests/afi/django_app/afi_app/__init__.py @@ -0,0 +1 @@ +default_app_config = "afi_app.apps.AfiAppConfig" diff --git a/tests/afi/django_app/afi_app/apps.py b/tests/afi/django_app/afi_app/apps.py new file mode 100644 index 0000000..c14035b --- /dev/null +++ b/tests/afi/django_app/afi_app/apps.py @@ -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() diff --git a/tests/afi/django_app/manage.py b/tests/afi/django_app/manage.py new file mode 100644 index 0000000..de5dcd3 --- /dev/null +++ b/tests/afi/django_app/manage.py @@ -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() diff --git a/tests/afi/django_app/project/__init__.py b/tests/afi/django_app/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/afi/django_app/project/settings.py b/tests/afi/django_app/project/settings.py new file mode 100644 index 0000000..8d94bf7 --- /dev/null +++ b/tests/afi/django_app/project/settings.py @@ -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" diff --git a/tests/afi/django_app/project/urls.py b/tests/afi/django_app/project/urls.py new file mode 100644 index 0000000..a94c45d --- /dev/null +++ b/tests/afi/django_app/project/urls.py @@ -0,0 +1,3 @@ +"""Empty URLconf — the AFI fixture only exercises the schema export.""" + +urlpatterns: list = [] diff --git a/tests/afi/fastapi_app.py b/tests/afi/fastapi_app.py new file mode 100644 index 0000000..c171630 --- /dev/null +++ b/tests/afi/fastapi_app.py @@ -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 diff --git a/tests/afi/fixture.py b/tests/afi/fixture.py new file mode 100644 index 0000000..410dc9f --- /dev/null +++ b/tests/afi/fixture.py @@ -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") diff --git a/tests/afi/pyproject.toml b/tests/afi/pyproject.toml new file mode 100644 index 0000000..e05be14 --- /dev/null +++ b/tests/afi/pyproject.toml @@ -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_*"] diff --git a/tests/afi/schema_normalizer.py b/tests/afi/schema_normalizer.py new file mode 100644 index 0000000..3592c28 --- /dev/null +++ b/tests/afi/schema_normalizer.py @@ -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} diff --git a/tests/afi/test_codegen_parity.py b/tests/afi/test_codegen_parity.py new file mode 100644 index 0000000..5509573 --- /dev/null +++ b/tests/afi/test_codegen_parity.py @@ -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)