Mizan IR: cut over to KDL, delete OpenAPI envelope
Replaces the transitional OpenAPI 3.0 + `x-mizan-*` extensions
substrate with the canonical Mizan IR as KDL, per docs/AFI_ARCHITECTURE.md:
"KDL is the contract; everything else (REST envelopes, OpenAPI
documents, framework idioms) is sediment around it."
End-to-end cutover. No transitional path left on main.
Forward direction:
cores/mizan-python/src/mizan_core/ir.py
build_ir() walks mizan_core.registry, introspects Pydantic
models directly (no JSON-Schema indirection), and emits the
Mizan IR document. The KDL grammar is locked in this file's
module docstring.
Backends emit KDL:
backends/mizan-fastapi/src/mizan_fastapi/ir.py
`python -m mizan_fastapi.ir <module>` — CLI entry point.
backends/mizan-django/.../management/commands/export_mizan_ir.py
`manage.py export_mizan_ir` — Django mgmt command.
Codegen consumes KDL:
protocol/mizan-codegen/Cargo.toml: + kdl = "6"
protocol/mizan-codegen/src/ir.rs: NamedType { Struct/List/Enum/Alias }
+ TypeShape { Primitive/Ref/List/Optional/Enum/Union } sum types,
replacing the JsonSchema sprawl. KDL parser walks the
`kdl::KdlDocument` tree into typed Rust structs.
protocol/mizan-codegen/src/fetch.rs: subprocess command switches
to the new IR-export entry points.
All emit modules (stage1 / react / python / rust / vue / svelte /
channels) port their type-walkers from JsonSchema to the new
sum types — case analysis collapses substantially.
Substrate-honesty wins beyond the moat closure:
- `int | bool` multi-arm unions land as `TypeShape::Union` (was
silently coerced to "string" before).
- `<CamelName>Output = list[T]` returns emit as named alias
types instead of struct-shaped wrappers, so consumer code
`.map()` works directly on the type.
- Pydantic field defaults flow through to `default` properties
in KDL, then back to non-optional shape in every target.
Deleted:
- backends/mizan-fastapi/src/mizan_fastapi/{cli,schema}.py
- backends/mizan-django/.../export_mizan_schema.py
- openapi-bearing half of mizan/export/__init__.py (edge
manifest generator preserved — separate concern).
- tests/afi/schema_normalizer.py
- tests/fixtures/{afi_schema.json, channels_schema.json}
- tests/fixtures/js_* baseline directories.
Verification:
- 20 mizan-codegen unit tests green (IR deserialization,
byte-equivalence parity across stage1/rust/python/react/vue/svelte
against fresh KDL-driven baselines, channels structural).
- tests/rust/run_wire_parity.py: 12/12 probes green driving
the binary end-to-end through KDL.
- Blazr studio-ui typechecks against the regenerated React client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,6 @@ from .executor import (
|
||||
execute_function,
|
||||
)
|
||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||
from .schema import build_schema
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
@@ -43,7 +42,6 @@ __all__ = [
|
||||
"mizan_validation_handler",
|
||||
"execute_function",
|
||||
"compute_invalidation",
|
||||
"build_schema",
|
||||
"ErrorCode",
|
||||
"MizanError",
|
||||
"NotFound",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
Schema-export CLI for codegen consumption.
|
||||
|
||||
Usage:
|
||||
python -m mizan_fastapi.cli <module>
|
||||
|
||||
Imports the named module (whose import side effects must register every
|
||||
@client function with mizan_core.registry — typically by `@client` plus
|
||||
`register(...)` calls at module top level), then prints the OpenAPI
|
||||
schema to stdout as JSON.
|
||||
|
||||
Mirrors mizan-django's `manage.py export_mizan_schema` so the codegen
|
||||
CLI can fetch from either backend the same subprocess way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
from .schema import build_schema
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if len(args) != 1:
|
||||
print("usage: python -m mizan_fastapi.cli <module>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
module_name = args[0]
|
||||
try:
|
||||
importlib.import_module(module_name)
|
||||
except Exception as e:
|
||||
print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
schema = build_schema()
|
||||
json.dump(schema, sys.stdout)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
39
backends/mizan-fastapi/src/mizan_fastapi/ir.py
Normal file
39
backends/mizan-fastapi/src/mizan_fastapi/ir.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Mizan IR (KDL) export CLI for FastAPI backends.
|
||||
|
||||
Usage:
|
||||
python -m mizan_fastapi.ir <module>
|
||||
|
||||
Imports the named module (whose import side effects must register every
|
||||
@client function with `mizan_core.registry`), then writes the canonical
|
||||
Mizan IR as KDL to stdout. The Rust codegen binary consumes this
|
||||
directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
from mizan_core.ir import build_ir
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if len(args) != 1:
|
||||
print("usage: python -m mizan_fastapi.ir <module>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
module_name = args[0]
|
||||
try:
|
||||
importlib.import_module(module_name)
|
||||
except Exception as e:
|
||||
print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
sys.stdout.write(build_ir())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,235 +0,0 @@
|
||||
"""
|
||||
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, RootModel, create_model
|
||||
|
||||
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||
|
||||
|
||||
__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)
|
||||
|
||||
output_cls = getattr(fn_class, "Output", None)
|
||||
_, output_nullable = extract_optional(output_cls) if output_cls is not None else (None, False)
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"name": name,
|
||||
"camelName": camel,
|
||||
"hasInput": has_input,
|
||||
"inputType": f"{camel}Input" if has_input else None,
|
||||
"outputType": f"{camel}Output",
|
||||
# Nullability of the response model — Pydantic `T | None` returns. Carried
|
||||
# on the function entry rather than the schema class because OpenAPI emits
|
||||
# `anyOf: [{$ref}, {type:null}]` at the response level, which strict
|
||||
# deserializers (Rust serde) won't decode as Option<T> without this hint.
|
||||
"outputNullable": output_nullable,
|
||||
"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"]
|
||||
if meta.get("merge"):
|
||||
entry["merge"] = meta["merge"]
|
||||
|
||||
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"
|
||||
|
||||
# Strip Optional so the rename gets a concrete base — nullability is
|
||||
# carried on the response declaration, not the schema class itself.
|
||||
output_inner, output_nullable = extract_optional(output_cls)
|
||||
|
||||
if has_input:
|
||||
schema_classes[input_type_name] = create_model(
|
||||
input_type_name, __base__=input_cls,
|
||||
)
|
||||
if extract_list_element(output_inner) is not None:
|
||||
# list[T] — RootModel makes the rename emit `type: array` rather
|
||||
# than wrapping the list in a property.
|
||||
schema_classes[output_type_name] = type(
|
||||
output_type_name, (RootModel[output_inner],), {},
|
||||
)
|
||||
else:
|
||||
schema_classes[output_type_name] = create_model(
|
||||
output_type_name, __base__=output_inner,
|
||||
)
|
||||
|
||||
response_model = schema_classes[output_type_name]
|
||||
if output_nullable:
|
||||
response_model = response_model | None
|
||||
|
||||
# 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=response_model,
|
||||
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
|
||||
Reference in New Issue
Block a user