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:
2026-05-17 19:14:47 -04:00
parent 7fb0c4a400
commit 9900f8a36f
86 changed files with 2231 additions and 2272 deletions

View File

@@ -1,380 +1,30 @@
"""
mizan OpenAPI Schema Generator
Mizan Edge Manifest Generator.
Generates OpenAPI 3.0 compatible schema from registered server functions.
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion.
This schema is consumed by the frontend generator which uses openapi-typescript
for robust type generation.
NOTE: Schema export is only available via management command for security.
HTTP endpoint has been removed to prevent function enumeration.
Generates the Edge manifest — a static JSON mapping contexts to URL
patterns and params, consumed by Mizan Edge at deploy time for CDN
cache invalidation. Independent from the Mizan IR; the IR drives
codegen, the manifest drives CDN purging.
Usage:
python manage.py export_mizan_schema
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
"""
from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING, Any
from typing import Any
# Lazy imports to avoid Django settings access at module load time
# (asgi.py imports mizan before Django is fully configured)
if TYPE_CHECKING:
from django import forms
from ninja import NinjaAPI
from mizan_core.registry import get_registry, get_schema, get_context_groups, get_function
from mizan_core.type_utils import extract_list_element, extract_optional
from mizan_core.registry import get_context_groups, get_registry
__all__ = [
"get_schema",
"generate_openapi_schema",
"generate_openapi_json",
"generate_edge_manifest",
"generate_edge_manifest_json",
]
def _extract_form_fields(form_class: type) -> list[dict[str, Any]]:
"""
Extract field definitions with constraints from a Django Form class.
Returns a list of field metadata suitable for Zod schema generation:
- name: field name
- zodType: base Zod type ("string", "number", "boolean", "array")
- required: whether field is required
- constraints: dict of Zod-compatible constraints
Constraints include:
- min/max: for string length or number range
- email/url: for format validation
- regex: for pattern validation
- choices: for enum validation
"""
try:
# Try to instantiate form to get bound fields
form = form_class()
fields_dict = form.fields
except TypeError:
# Form requires extra args - use base_fields
fields_dict = getattr(form_class, "base_fields", {})
result = []
for name, field in fields_dict.items():
field_meta = _extract_field_constraints(name, field)
result.append(field_meta)
return result
def _extract_field_constraints(name: str, field: "forms.Field") -> dict[str, Any]:
"""
Extract Zod-compatible constraints from a single Django form field.
"""
from django import forms # Lazy import
meta: dict[str, Any] = {
"name": name,
"required": field.required,
"constraints": {},
}
# Determine base Zod type
if isinstance(field, forms.BooleanField):
meta["zodType"] = "boolean"
elif isinstance(field, (forms.IntegerField, forms.FloatField, forms.DecimalField)):
meta["zodType"] = "number"
if isinstance(field, forms.IntegerField):
meta["constraints"]["int"] = True
elif isinstance(field, forms.MultipleChoiceField):
meta["zodType"] = "array"
meta["constraints"]["items"] = "string"
elif isinstance(field, forms.FileField):
meta["zodType"] = "file"
else:
# Default to string (CharField, EmailField, URLField, etc.)
meta["zodType"] = "string"
# Extract string constraints
if hasattr(field, "max_length") and field.max_length is not None:
meta["constraints"]["max"] = field.max_length
if hasattr(field, "min_length") and field.min_length is not None:
meta["constraints"]["min"] = field.min_length
# Extract number constraints
if hasattr(field, "max_value") and field.max_value is not None:
meta["constraints"]["max"] = field.max_value
if hasattr(field, "min_value") and field.min_value is not None:
meta["constraints"]["min"] = field.min_value
# Email/URL format
if isinstance(field, forms.EmailField):
meta["constraints"]["email"] = True
elif isinstance(field, forms.URLField):
meta["constraints"]["url"] = True
# Choices (for enum validation)
if hasattr(field, "choices") and field.choices:
# Extract choice values (not labels)
choices = []
for choice in field.choices:
if isinstance(choice, (list, tuple)) and len(choice) >= 1:
# Skip empty/blank choices
if choice[0] != "":
choices.append(str(choice[0]))
else:
choices.append(str(choice))
if choices:
meta["constraints"]["choices"] = choices
# Regex validators
for validator in field.validators:
if hasattr(validator, "regex"):
# RegexValidator - extract pattern
pattern = validator.regex.pattern
meta["constraints"]["regex"] = pattern
if hasattr(validator, "message"):
meta["constraints"]["regexMessage"] = validator.message
break # Only use first regex validator
return meta
def snake_to_camel(name: str) -> str:
"""Convert snake_case or dotted.name to camelCase.
Examples:
- login -> login
- login.schema -> loginSchema
- activate_totp -> activateTotp
- activate_totp.schema -> activateTotpSchema
"""
# Split on both underscores and dots
components = re.split(r"[._]", name)
return components[0] + "".join(x.title() for x in components[1:])
def _register_schema_endpoint(
api: "NinjaAPI",
path: str,
operation_id: str,
summary: str,
input_cls: type | None,
output_cls: type,
) -> None:
"""
Register a dummy endpoint on the API for schema generation.
Sets __annotations__ directly to avoid closure capture issues
and exec() security concerns.
"""
if input_cls is not None:
def endpoint(request, data):
pass
# Set annotations directly to the actual type objects (not strings)
endpoint.__annotations__ = {"data": input_cls}
else:
def endpoint(request):
pass
# Register with Ninja
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
endpoint
)
def generate_openapi_schema() -> dict[str, Any]:
"""
Generate OpenAPI 3.0 schema for all registered mizan functions.
Uses Django Ninja's schema generation internally to ensure proper
Pydantic→OpenAPI conversion (handling $refs, nested types, etc.).
Returns a complete OpenAPI document that can be processed by openapi-typescript.
"""
from ninja import NinjaAPI # Lazy import
from pydantic import BaseModel, RootModel, create_model # Lazy import
registry = get_registry()
functions = registry.get("functions", {})
# Create a temporary Ninja API for schema generation only
# This is NOT exposed as an HTTP endpoint - purely for leveraging Ninja's
# battle-tested Pydantic→OpenAPI conversion
schema_api = NinjaAPI(
title="mizan Server Functions",
version="1.0.0",
description="Auto-generated schema for mizan server functions",
docs_url=None, # No docs endpoint
openapi_url=None, # No openapi endpoint
)
function_metadata: list[dict[str, Any]] = []
# Store dynamically created classes so they persist for schema generation
schema_classes: dict[str, type] = {}
for name, fn_class in functions.items():
camel_name = snake_to_camel(name)
meta = getattr(fn_class, "_meta", {})
# Get Input/Output classes
input_cls = getattr(fn_class, "Input", None)
output_cls = getattr(fn_class, "Output", None) or BaseModel
# Check if input_cls is a valid Pydantic model with fields
has_input = (
input_cls is not None
and input_cls is not BaseModel
and hasattr(input_cls, "model_fields")
and bool(input_cls.model_fields)
)
# Determine type names for metadata
input_type_name = f"{camel_name}Input" if has_input else None
output_type_name = f"{camel_name}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)
# Create renamed Pydantic classes for cleaner schema names
# Store them in schema_classes so they persist beyond loop scope
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_cls = schema_classes[output_type_name]
if output_nullable:
response_cls = response_cls | None
# Register endpoint using helper to avoid closure capture issues
_register_schema_endpoint(
api=schema_api,
path=f"/mizan/{name}",
operation_id=camel_name,
summary=fn_class.__doc__ or f"Call {name}",
input_cls=schema_classes.get(input_type_name),
output_cls=response_cls,
)
# Collect function metadata for provider generation
fn_meta_entry: dict[str, Any] = {
"name": name,
"camelName": camel_name,
"hasInput": has_input,
"inputType": input_type_name,
"outputType": output_type_name,
"transport": "websocket" if meta.get("websocket") else "http",
"isContext": meta.get("context", False),
# Form metadata
"isForm": meta.get("form", False),
"formName": meta.get("form_name"),
"formRole": meta.get("form_role"), # "schema", "validate", "submit"
}
# Affects metadata (mutation invalidation)
if meta.get("affects"):
fn_meta_entry["affects"] = meta["affects"]
if meta.get("merge"):
fn_meta_entry["merge"] = meta["merge"]
# For form schema functions, extract field definitions for Zod generation
if meta.get("form") and meta.get("form_role") == "schema":
form_class = meta.get("form_class")
if form_class is not None:
try:
fn_meta_entry["formFields"] = _extract_form_fields(form_class)
except Exception as e:
# Don't fail schema generation if field extraction fails
fn_meta_entry["formFields"] = []
fn_meta_entry["formFieldsError"] = str(e)
function_metadata.append(fn_meta_entry)
# Get the OpenAPI schema from Ninja (handles all Pydantic conversion properly)
schema = schema_api.get_openapi_schema(path_prefix="")
# Add custom extension with function metadata for provider generation
schema["x-mizan-functions"] = function_metadata
# Add x-mizan-contexts: grouped context metadata with param elevation
context_groups = get_context_groups()
if context_groups:
contexts_meta: dict[str, Any] = {}
for ctx_name, fn_names in context_groups.items():
# Analyze params across all functions in the context
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 input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
for field_name, field_info in input_cls.model_fields.items():
if field_name not in param_info:
annotation = field_info.annotation
# Map Python types to JSON schema types
type_name = "string"
if annotation in (int,):
type_name = "integer"
elif annotation in (float,):
type_name = "number"
elif annotation in (bool,):
type_name = "boolean"
param_info[field_name] = {
"type": type_name,
"sharedBy": [],
}
param_info[field_name]["sharedBy"].append(fn_name)
# A param is required if ALL functions in the context declare it
for p_name, p_meta in param_info.items():
p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)
contexts_meta[ctx_name] = {
"functions": fn_names,
"params": param_info,
}
schema["x-mizan-contexts"] = contexts_meta
# Add x-mizan metadata to each operation
for fn_meta in function_metadata:
path = f"/mizan/{fn_meta['name']}"
if path in schema.get("paths", {}):
schema["paths"][path]["post"]["x-mizan"] = {
"transport": fn_meta["transport"],
"isContext": fn_meta["isContext"],
}
return schema
def generate_openapi_json(indent: int = 2) -> str:
"""Generate OpenAPI schema as formatted JSON string."""
schema = generate_openapi_schema()
return json.dumps(schema, indent=indent)
def generate_edge_manifest(
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
@@ -394,14 +44,10 @@ def generate_edge_manifest(
view_urls: Optional mapping of context names to URL patterns for
view-path functions. These are URLs that Edge should
also purge when a context is invalidated.
Example: {"user": ["/profile/:user_id/"]}
Returns:
Manifest dict suitable for JSON serialization.
"""
from pydantic import BaseModel as PydanticBaseModel
# Common user identity param names for user_scoped detection
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
groups = get_context_groups()
@@ -411,7 +57,6 @@ def generate_edge_manifest(
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
for ctx_name, fn_names in groups.items():
# Collect params and routes from all functions in this context
param_names: set[str] = set()
functions_meta: list[dict[str, Any]] = []
page_routes: list[str] = []
@@ -421,40 +66,31 @@ def generate_edge_manifest(
if fn_cls is None:
continue
meta = getattr(fn_cls, "_meta", {})
is_view = meta.get("view_path", False)
# Collect param names from Input schema
input_cls = getattr(fn_cls, "Input", None)
if (
input_cls
and input_cls is not PydanticBaseModel
and hasattr(input_cls, "model_fields")
):
param_names.update(input_cls.model_fields.keys())
if input_cls is not None and hasattr(input_cls, "model_fields"):
for param_name in input_cls.model_fields:
param_names.add(param_name)
meta = getattr(fn_cls, "_meta", {})
route = meta.get("route")
view_path = meta.get("view_path")
fn_entry: dict[str, Any] = {
"name": fn_name,
"path": "view" if is_view else "rpc",
"path": "view" if view_path else "rpc",
}
# Collect routes from view-path functions
fn_route = meta.get("route")
if fn_route:
fn_entry["route"] = fn_route
if route:
fn_entry["route"] = route
fn_entry["methods"] = meta.get("methods", ["GET"])
page_routes.append(fn_route)
# Cache protocol metadata
if "rev" in meta:
page_routes.append(route)
if meta.get("rev"):
fn_entry["rev"] = meta["rev"]
if "cache" in meta:
if meta.get("cache") is not None and meta.get("cache") is not True:
fn_entry["cache"] = meta["cache"]
functions_meta.append(fn_entry)
sorted_params = sorted(param_names)
user_scoped = bool(param_names & _USER_SCOPED_PARAMS)
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
ctx_entry: dict[str, Any] = {
"functions": functions_meta,
@@ -464,69 +100,57 @@ def generate_edge_manifest(
"render_strategy": "dynamic_cached" if user_scoped else "psr",
}
# Add page routes from view-path functions with route=
if page_routes:
ctx_entry["page_routes"] = page_routes
# Add externally-declared view URLs
if view_urls and ctx_name in view_urls:
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
manifest["contexts"][ctx_name] = ctx_entry
# Mutations section — all functions with affects=
for fn_name, fn_cls in all_functions.items():
meta = getattr(fn_cls, "_meta", {})
affects = meta.get("affects")
if not affects:
if not meta.get("affects"):
continue
# Resolve context names from affects targets
affected_contexts = []
for target in affects:
if target["type"] == "context":
affected_contexts.append(target["name"])
elif target["type"] == "function" and target.get("context"):
affected_contexts.append(target["context"])
affected_contexts = list(dict.fromkeys(affected_contexts))
affected_contexts = list({a["name"] for a in meta["affects"]})
mutation: dict[str, Any] = {"affects": affected_contexts}
# Determine which params auto-scope
auto_scoped = []
# Auto-scoped params — function params that match context params
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not PydanticBaseModel and hasattr(input_cls, "model_fields"):
if input_cls is not None and hasattr(input_cls, "model_fields"):
fn_params = set(input_cls.model_fields.keys())
auto_scoped: list[str] = []
for ctx_name in affected_contexts:
ctx_params = set()
for ctx_fn_name in groups.get(ctx_name, []):
ctx_param_names: set[str] = set()
ctx_fns = groups.get(ctx_name, [])
for ctx_fn_name in ctx_fns:
ctx_fn_cls = all_functions.get(ctx_fn_name)
if ctx_fn_cls:
ctx_input = getattr(ctx_fn_cls, "Input", None)
if ctx_input and ctx_input is not PydanticBaseModel and hasattr(ctx_input, "model_fields"):
ctx_params.update(ctx_input.model_fields.keys())
auto_scoped.extend(sorted(fn_params & ctx_params))
auto_scoped = list(dict.fromkeys(auto_scoped))
if ctx_fn_cls is None:
continue
ctx_input = getattr(ctx_fn_cls, "Input", None)
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
ctx_param_names.update(ctx_input.model_fields.keys())
for p in fn_params:
if p in ctx_param_names and p not in auto_scoped:
auto_scoped.append(p)
if auto_scoped:
mutation["auto_scoped_params"] = sorted(auto_scoped)
mutation_entry: dict[str, Any] = {
"affects": affected_contexts,
}
if auto_scoped:
mutation_entry["auto_scoped_params"] = auto_scoped
if meta.get("private"):
mutation_entry["private"] = True
mutation["private"] = True
if meta.get("route"):
mutation_entry["route"] = meta["route"]
mutation_entry["methods"] = meta.get("methods", ["POST"])
mutation["route"] = meta["route"]
mutation["methods"] = meta.get("methods", ["POST"])
manifest["mutations"][fn_name] = mutation_entry
manifest["mutations"][fn_name] = mutation
return manifest
def generate_edge_manifest_json(
indent: int = 2,
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
indent: int = 2,
) -> str:
"""Generate Edge manifest as formatted JSON string."""
manifest = generate_edge_manifest(base_url=base_url, view_urls=view_urls)
return json.dumps(manifest, indent=indent, sort_keys=True)
"""JSON-serialize the Edge manifest."""
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)

View File

@@ -0,0 +1,28 @@
"""
Mizan IR (KDL) export — Django management command.
Usage:
python manage.py export_mizan_ir
Triggers Mizan client discovery to populate the registry, then writes
the canonical Mizan IR as KDL to stdout. The Rust codegen binary
consumes this directly.
"""
from __future__ import annotations
from django.core.management.base import BaseCommand
from mizan_core.ir import build_ir
class Command(BaseCommand):
help = "Export every registered @client function as Mizan IR (KDL)."
def handle(self, *args, **options) -> None:
# Load every project-side @client function so the registry is
# populated before we emit. Conventionally apps/*/clients.py.
from mizan.setup.discovery import mizan_clients
mizan_clients("apps")
self.stdout.write(build_ir(), ending="")

View File

@@ -1,49 +0,0 @@
"""
Export mizan Schema
Management command to export the mizan OpenAPI schema for TypeScript code generation.
The schema is consumed by openapi-typescript for robust type generation.
Usage:
python manage.py export_mizan_schema # Output to stdout
python manage.py export_mizan_schema --output schema.json # Output to file
"""
import json
from pathlib import Path
from django.core.management.base import BaseCommand
from mizan.export import generate_openapi_schema
class Command(BaseCommand):
help = "Export mizan OpenAPI schema for TypeScript code generation"
def add_arguments(self, parser):
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path. If not specified, outputs to stdout.",
)
parser.add_argument(
"--indent",
type=int,
default=2,
help="JSON indentation level (0 for compact output)",
)
def handle(self, *args, **options):
schema = generate_openapi_schema()
indent = options["indent"] if options["indent"] > 0 else None
json_output = json.dumps(schema, indent=indent)
if options["output"]:
output_path = Path(options["output"])
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json_output)
self.stdout.write(self.style.SUCCESS(f"Schema written to {output_path}"))
else:
self.stdout.write(json_output)

View File

@@ -8,7 +8,7 @@ HTTP endpoints:
Security:
- Schema export is NOT exposed over HTTP to prevent API enumeration
- Use the management command instead: python manage.py export_mizan_schema
- Use the management command instead: python manage.py export_mizan_ir
"""
from django.http import JsonResponse

View File

@@ -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",

View File

@@ -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())

View 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())

View File

@@ -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

View File

@@ -0,0 +1,575 @@
"""
Mizan IR — KDL emission from the live `mizan_core.registry`.
`build_ir()` walks every registered function class, introspects its
Pydantic Input/Output models directly (not via JSON-Schema), and emits
KDL — the canonical Mizan protocol IR. Every backend adapter exposes
this via a backend-specific entry point (Django management command,
FastAPI CLI, mizan-ts equivalent); every codegen target consumes this.
KDL grammar — locked contract:
type "<Name>" {
struct {
field "<name>" required=#true|#false default=<lit> {
primitive "integer|number|boolean|string"
| ref "<TypeName>"
| list { <type-child> }
| optional { <type-child> }
| enum "<v1>" "<v2>" ...
}
...
}
| list { <type-child> }
| enum "<v1>" "<v2>" ...
| alias { <type-child> }
}
function "<wire_name>" {
camel "<camelCase>"
has-input #true|#false
input "<TypeName>" // omitted if has-input=#false
output "<TypeName>"
output-nullable #true|#false // omitted when #false (default)
transport "http"|"websocket"|"both"
context "<ctx_name>" // omitted unless context-grouped
affects "<ctx_name>" // 0..N occurrences
merge "<ctx_name>" // 0..N occurrences
is-form #true // omitted when #false (default)
form-name "<name>"
form-role "<role>"
}
context "<name>" {
function "<fn_name>"
...
param "<param_name>" {
type "integer|number|boolean|string"
required #true|#false
shared-by "<fn_name>"
...
}
}
channel "<name>" {
pascal-name "<PascalCase>"
params "<TypeName>" // omitted if no params
react-message "<TypeName>" // omitted if no react message
django-message "<TypeName>" // omitted if no django message
}
Nothing else lives in the IR. OpenAPI envelope, JSON-Schema $ref dance,
the Pydantic→json-schema converter — all gone.
"""
from __future__ import annotations
import types
from typing import Any, Literal, Union, get_args, get_origin
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
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_ir"]
# Common user-identity param names; mirrors the equivalent in mizan-django /
# mizan-fastapi schema-export logic.
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
# ─── KDL value formatting ───────────────────────────────────────────────────
def _kdl_string(s: str) -> str:
"""KDL-escape a string and wrap in quotes."""
escaped = (
s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
return f'"{escaped}"'
def _kdl_bool(b: bool) -> str:
return "#true" if b else "#false"
def _kdl_value(v: Any) -> str:
"""Render a JSON-shape Python value as a KDL literal."""
if v is None:
return "#null"
if v is True or v is False:
return _kdl_bool(v)
if isinstance(v, (int, float)):
return repr(v)
if isinstance(v, str):
return _kdl_string(v)
# Fallback for compound values — defaults aren't typed in our IR.
import json
return _kdl_string(json.dumps(v))
# ─── KDL Builder ────────────────────────────────────────────────────────────
class _Block:
"""Open-children context for a KDL node. Tracks indent level."""
__slots__ = ("lines", "indent")
def __init__(self, lines: list[str], indent: int):
self.lines = lines
self.indent = indent
def _prefix(self) -> str:
return " " * self.indent
def node(self, name: str, *args: str, **props: str) -> "_OpenNode":
"""Open a node. `args` are positional KDL args; `props` are key=value pairs."""
return _OpenNode(self.lines, self.indent, name, list(args), dict(props))
def leaf(self, name: str, *args: str, **props: str) -> None:
"""Emit a leaf node — no children block."""
parts = [name]
parts.extend(args)
for k, v in props.items():
parts.append(f"{k}={v}")
self.lines.append(f"{self._prefix()}{' '.join(parts)}")
class _OpenNode:
"""A KDL node whose children are being built."""
def __init__(
self,
lines: list[str],
indent: int,
name: str,
args: list[str],
props: dict[str, str],
):
self.lines = lines
self.indent = indent
self.name = name
self.args = args
self.props = props
self._children_emitted = False
def __enter__(self) -> _Block:
parts = [self.name]
parts.extend(self.args)
for k, v in self.props.items():
parts.append(f"{k}={v}")
self.lines.append(f"{' ' * self.indent}{' '.join(parts)} {{")
self._children_emitted = True
return _Block(self.lines, self.indent + 1)
def __exit__(self, *_exc: Any) -> None:
if self._children_emitted:
self.lines.append(f"{' ' * self.indent}}}")
# ─── Type emission ──────────────────────────────────────────────────────────
def _emit_type_child(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
"""Emit the type-shape KDL for a Python annotation, recursing as needed."""
# Strip Optional[T] → emit `optional` wrapper.
inner, is_opt = extract_optional(annotation)
if is_opt:
with block.node("optional") as inner_block:
_emit_type_child(inner_block, inner, named_types)
return
# Multi-arm union (T | U) — emit `union { <each-branch> }`.
origin = get_origin(annotation)
if origin is Union or isinstance(annotation, types.UnionType):
branches = [a for a in get_args(annotation) if a is not type(None)]
if len(branches) > 1:
with block.node("union") as inner_block:
for branch in branches:
_emit_type_child(inner_block, branch, named_types)
return
# list[T] / tuple[T, ...] / set[T] / frozenset[T] → `list { ... }`
elem = extract_list_element(annotation)
if elem is not None:
with block.node("list") as inner_block:
_emit_type_child(inner_block, elem, named_types)
return
# Literal[a, b, c] → enum
if origin is Literal:
args = get_args(annotation)
if all(isinstance(a, str) for a in args):
quoted = " ".join(_kdl_string(a) for a in args)
block.lines.append(f"{block._prefix()}enum {quoted}")
return
# Pydantic model → reference by name.
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
type_name = annotation.__name__
named_types.setdefault(type_name, _StructShape(annotation))
block.leaf("ref", _kdl_string(type_name))
return
# Primitives
if annotation is int:
block.leaf("primitive", _kdl_string("integer"))
return
if annotation is float:
block.leaf("primitive", _kdl_string("number"))
return
if annotation is bool:
block.leaf("primitive", _kdl_string("boolean"))
return
if annotation is str:
block.leaf("primitive", _kdl_string("string"))
return
# Open-shape fallback (dict / Any / etc).
block.leaf("primitive", _kdl_string("string"))
def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
"""Emit `type "X" { alias { <type-child> } }` for a non-struct wrapper."""
with block.node("alias") as alias_block:
_emit_type_child(alias_block, annotation, named_types)
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
"""Emit a `struct { field ... }` block for a Pydantic model."""
with block.node("struct") as struct_block:
for field_name, field_info in model.model_fields.items():
props: dict[str, str] = {}
# `field_info.is_required()` checks both the explicit Required
# marker and the presence of a default.
required = field_info.is_required()
if not required:
props["required"] = _kdl_bool(False)
default = field_info.default
if default is not None and default is not PydanticUndefined and default is not ...:
props["default"] = _kdl_value(default)
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
_emit_type_child(field_block, field_info.annotation, named_types)
class _StructShape:
"""A Pydantic BaseModel that emits as `type "X" { struct { ... } }`."""
__slots__ = ("model",)
def __init__(self, model: type[BaseModel]):
self.model = model
class _AliasShape:
"""A named alias wrapper — e.g. `<CamelName>Output = list[<Inner>]`."""
__slots__ = ("annotation",)
def __init__(self, annotation: Any):
self.annotation = annotation
def _collect_named_types(functions: dict[str, Any]) -> dict[str, Any]:
"""First pass: collect every named type the IR's `function` section references.
Two kinds:
- Pydantic BaseModels seen anywhere in Input/Output traversal — emit
as `type "X" { struct { ... } }`.
- Function-output wrapper aliases (`<CamelName>Output = list[T]` /
`<CamelName>Output = T | None`) — emit as `type "X" { alias { ... } }`
so the consumer has a single named type to reference.
"""
seen: dict[str, Any] = {}
def visit_model(model: type[BaseModel]) -> None:
if model.__name__ in seen:
return
seen[model.__name__] = _StructShape(model)
for field_info in model.model_fields.values():
for nested in _nested_models(field_info.annotation):
visit_model(nested)
def visit_annotation(ann: Any) -> None:
for nested in _nested_models(ann):
visit_model(nested)
for fn_class in functions.values():
input_cls = getattr(fn_class, "Input", None)
if _has_input(input_cls):
input_named = _name_input_model(fn_class)
visit_model(input_named)
output_cls = getattr(fn_class, "Output", None)
if output_cls is None:
continue
camel = _snake_to_camel(fn_class.name)
output_name = f"{camel}Output"
inner, _ = extract_optional(output_cls)
elem = extract_list_element(inner)
if elem is not None:
# `list[T]` (possibly wrapped in Optional) — emit a list alias.
# Visit the element type so its struct shape gets emitted too.
visit_annotation(output_cls)
if output_name not in seen:
seen[output_name] = _AliasShape(output_cls)
elif isinstance(inner, type) and issubclass(inner, BaseModel):
# `<Model>` or `Optional[<Model>]` — emit the model under the
# canonical name (rename if necessary).
output_named = _name_output_model(fn_class, inner)
visit_model(output_named)
# If the Optional wrapper differs from the bare model, emit an
# alias under the canonical output name too.
if output_named.__name__ != output_name:
seen.setdefault(output_name, _AliasShape(output_cls))
else:
# Primitive-wrapped output (`result: int`) — emit as alias.
seen.setdefault(output_name, _AliasShape(output_cls))
return seen
def _nested_models(annotation: Any) -> list[type[BaseModel]]:
"""All Pydantic models that appear anywhere inside `annotation`."""
out: list[type[BaseModel]] = []
inner, _ = extract_optional(annotation)
elem = extract_list_element(inner)
if elem is not None:
out.extend(_nested_models(elem))
return out
if isinstance(inner, type) and issubclass(inner, BaseModel):
out.append(inner)
return out
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 _snake_to_camel(name: str) -> str:
parts = name.replace(".", "_").replace("-", "_").split("_")
return parts[0] + "".join(p.title() for p in parts[1:] if p)
def _name_input_model(fn_class: Any) -> type[BaseModel]:
"""Return a copy of the function's Input model named `<CamelName>Input`."""
from pydantic import create_model
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Input"
src = fn_class.Input
if src.__name__ == canonical:
return src
# Re-derive under the canonical name so codegen consumers see a stable name.
return create_model(canonical, __base__=src)
def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]:
"""Return a copy of the model named `<CamelName>Output`."""
from pydantic import create_model
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Output"
if base.__name__ == canonical:
return base
return create_model(canonical, __base__=base)
# ─── Function / context / channel emission ──────────────────────────────────
def _function_props(fn_class: Any, output_type_name: str, output_nullable: bool) -> dict[str, Any]:
"""Collect every value that goes inside a `function` block."""
meta = getattr(fn_class, "_meta", {})
name = fn_class.name
camel = _snake_to_camel(name)
input_cls = getattr(fn_class, "Input", None)
has_input = _has_input(input_cls)
is_context = meta.get("context")
is_form = meta.get("form", False)
return {
"name": name,
"camel": camel,
"has_input": has_input,
"input_type": f"{camel}Input" if has_input else None,
"output_type": output_type_name,
"output_nullable": output_nullable,
"transport": "websocket" if meta.get("websocket") else "http",
"context": is_context if isinstance(is_context, str) else None,
"affects": [a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"],
"merge": list(meta.get("merge") or []),
"is_form": bool(is_form),
"form_name": meta.get("form_name"),
"form_role": meta.get("form_role"),
}
def _resolve_output(fn_class: Any) -> tuple[str, bool]:
"""Return `(output_type_name, output_nullable)` for an emitted function block."""
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Output"
output_cls = getattr(fn_class, "Output", None)
if output_cls is None:
return canonical, False
_, nullable = extract_optional(output_cls)
return canonical, nullable
def _collect_channels() -> list[dict[str, Any]]:
"""Pull channel registrations from the optional `channels` registry extension."""
from mizan_core.registry import _extensions # type: ignore[attr-defined]
ext = _extensions.get("channels")
if ext is None:
return []
schema = ext.schema()
return list(schema or [])
# ─── Top-level builder ──────────────────────────────────────────────────────
def build_ir() -> str:
"""Build the Mizan IR for every registered function. Returns KDL source."""
functions = get_all_functions()
context_groups = get_context_groups()
channels = _collect_channels()
named_types = _collect_named_types(functions)
lines: list[str] = []
root = _Block(lines, indent=0)
# ── Type definitions ──
for type_name in sorted(named_types):
shape = named_types[type_name]
with root.node("type", _kdl_string(type_name)) as type_block:
if isinstance(shape, _StructShape):
_emit_struct_type(type_block, shape.model, named_types)
elif isinstance(shape, _AliasShape):
_emit_alias_type(type_block, shape.annotation, named_types)
else:
raise TypeError(f"unknown named-type shape: {type(shape).__name__}")
if named_types:
lines.append("")
# ── Functions ──
for fn_name, fn_class in functions.items():
meta = getattr(fn_class, "_meta", {})
if meta.get("private") or meta.get("view_path"):
continue
output_type_name, output_nullable = _resolve_output(fn_class)
props = _function_props(fn_class, output_type_name, output_nullable)
_emit_function(root, props)
if functions:
lines.append("")
# ── Contexts ──
for ctx_name, fn_names in context_groups.items():
_emit_context(root, ctx_name, fn_names)
if context_groups:
lines.append("")
# ── Channels ──
for channel in channels:
_emit_channel(root, channel)
# Trim trailing blanks then add a single terminating newline.
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines) + "\n"
def _emit_function(root: _Block, props: dict[str, Any]) -> None:
with root.node("function", _kdl_string(props["name"])) as block:
block.leaf("camel", _kdl_string(props["camel"]))
block.leaf("has-input", _kdl_bool(props["has_input"]))
if props["input_type"]:
block.leaf("input", _kdl_string(props["input_type"]))
block.leaf("output", _kdl_string(props["output_type"]))
if props["output_nullable"]:
block.leaf("output-nullable", _kdl_bool(True))
block.leaf("transport", _kdl_string(props["transport"]))
if props["context"]:
block.leaf("context", _kdl_string(props["context"]))
for affect_name in props["affects"]:
block.leaf("affects", _kdl_string(affect_name))
for merge_name in props["merge"]:
block.leaf("merge", _kdl_string(merge_name))
if props["is_form"]:
block.leaf("is-form", _kdl_bool(True))
if props["form_name"]:
block.leaf("form-name", _kdl_string(props["form_name"]))
if props["form_role"]:
block.leaf("form-role", _kdl_string(props["form_role"]))
def _emit_context(root: _Block, ctx_name: str, fn_names: list[str]) -> None:
# First pass: collect param info across every function in the context.
param_info: dict[str, dict[str, Any]] = {}
for fn_name in fn_names:
fn_class = get_function(fn_name)
if fn_class is None:
continue
input_cls = getattr(fn_class, "Input", None)
if not _has_input(input_cls):
continue
for param_name, field_info in input_cls.model_fields.items():
slot = param_info.setdefault(param_name, {"type": None, "shared_by": []})
slot["type"] = _annotation_to_primitive(field_info.annotation)
slot["shared_by"].append(fn_name)
# A param is required iff every function in the context declares it.
for slot in param_info.values():
slot["required"] = len(slot["shared_by"]) == len(fn_names)
with root.node("context", _kdl_string(ctx_name)) as block:
for fn_name in fn_names:
block.leaf("function", _kdl_string(fn_name))
for param_name in sorted(param_info):
slot = param_info[param_name]
with block.node("param", _kdl_string(param_name)) as param_block:
param_block.leaf("type", _kdl_string(slot["type"]))
param_block.leaf("required", _kdl_bool(slot["required"]))
for sharer in slot["shared_by"]:
param_block.leaf("shared-by", _kdl_string(sharer))
def _annotation_to_primitive(annotation: Any) -> str:
inner, _ = extract_optional(annotation)
if inner is int:
return "integer"
if inner is float:
return "number"
if inner is bool:
return "boolean"
return "string"
def _emit_channel(root: _Block, channel: dict[str, Any]) -> None:
name = channel["name"]
with root.node("channel", _kdl_string(name)) as block:
block.leaf("pascal-name", _kdl_string(channel["pascalName"]))
if channel.get("hasParams") and channel.get("paramsType"):
block.leaf("params", _kdl_string(channel["paramsType"]))
if channel.get("hasReactMessage") and channel.get("reactMessageType"):
block.leaf("react-message", _kdl_string(channel["reactMessageType"]))
if channel.get("hasDjangoMessage") and channel.get("djangoMessageType"):
block.leaf("django-message", _kdl_string(channel["djangoMessageType"]))

View File

@@ -117,6 +117,12 @@ dependencies = [
"serde",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.1"
@@ -214,6 +220,17 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kdl"
version = "6.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e"
dependencies = [
"miette",
"num",
"winnow 0.6.24",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -226,6 +243,16 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"unicode-width",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -256,6 +283,7 @@ dependencies = [
"askama",
"clap",
"indexmap",
"kdl",
"serde",
"serde_json",
"toml",
@@ -271,6 +299,70 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -412,7 +504,7 @@ dependencies = [
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
"winnow 0.7.15",
]
[[package]]
@@ -433,6 +525,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -454,6 +552,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.6.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.7.15"

View File

@@ -15,6 +15,7 @@ path = "src/lib.rs"
[dependencies]
askama = "0.12"
clap = { version = "4", features = ["derive"] }
kdl = "6"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
toml = "0.8"

View File

@@ -11,7 +11,7 @@ use indexmap::IndexMap;
use crate::config::Config;
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::ir::{JsonSchema, MizanChannel, MizanIR};
use crate::ir::{MizanChannel, MizanIR, NamedType, Primitive, StructField, TypeShape};
pub struct ChannelsTarget;
@@ -25,7 +25,7 @@ impl CodegenTarget for ChannelsTarget {
return Vec::new();
}
let schemas_block = emit_channel_schemas(&ir.channels, &ir.components.schemas);
let schemas_block = emit_channel_schemas(&ir.channels, &ir.types);
let types_content = ChannelsTypes {
channels: ir.channels.iter().map(ChannelView::from_ir).collect(),
@@ -34,9 +34,9 @@ impl CodegenTarget for ChannelsTarget {
let mut type_imports: Vec<String> = Vec::new();
for ch in &ir.channels {
if ch.has_params { if let Some(t) = &ch.params_type { type_imports.push(t.clone()); } }
if ch.has_react_message { if let Some(t) = &ch.react_message_type { type_imports.push(t.clone()); } }
if ch.has_django_message { if let Some(t) = &ch.django_message_type { type_imports.push(t.clone()); } }
if ch.has_params() { if let Some(t) = &ch.params_type { type_imports.push(t.clone()); } }
if ch.has_react_message() { if let Some(t) = &ch.react_message_type { type_imports.push(t.clone()); } }
if ch.has_django_message() { if let Some(t) = &ch.django_message_type { type_imports.push(t.clone()); } }
}
let hooks_content = ChannelsHooks {
@@ -92,12 +92,12 @@ impl<'a> ChannelView<'a> {
Self {
name: &ch.name,
pascal_name: &ch.pascal_name,
has_params: ch.has_params,
has_react_message: ch.has_react_message,
has_django_message: ch.has_django_message,
params_type_or_record: if ch.has_params { params_type.clone() } else { "Record<string, never>".to_string() },
react_msg_type_or_never: if ch.has_react_message { react_message_type.clone() } else { "never".to_string() },
django_msg_type_or_never: if ch.has_django_message { django_message_type.clone() } else { "never".to_string() },
has_params: ch.has_params(),
has_react_message: ch.has_react_message(),
has_django_message: ch.has_django_message(),
params_type_or_record: if ch.has_params() { params_type.clone() } else { "Record<string, never>".to_string() },
react_msg_type_or_never: if ch.has_react_message() { react_message_type.clone() } else { "never".to_string() },
django_msg_type_or_never: if ch.has_django_message() { django_message_type.clone() } else { "never".to_string() },
params_type,
react_message_type,
django_message_type,
@@ -108,13 +108,13 @@ impl<'a> ChannelView<'a> {
fn emit_channel_schemas(
channels: &[MizanChannel],
schemas: &IndexMap<String, JsonSchema>,
types: &IndexMap<String, NamedType>,
) -> String {
let mut blocks: Vec<String> = Vec::new();
for ch in channels {
for ty in [&ch.params_type, &ch.react_message_type, &ch.django_message_type].iter().filter_map(|t| t.as_ref()) {
if let Some(schema) = schemas.get(ty) {
blocks.push(emit_schema_as_ts(ty, schema));
if let Some(named) = types.get(ty) {
blocks.push(emit_named_type_as_ts(ty, named));
}
}
}
@@ -122,42 +122,57 @@ fn emit_channel_schemas(
}
fn emit_schema_as_ts(name: &str, schema: &JsonSchema) -> String {
if let Some(props) = &schema.properties {
let required: std::collections::HashSet<&str> =
schema.required.iter().map(String::as_str).collect();
let fields = props.iter()
.map(|(field_name, field_schema)| {
let opt = if required.contains(field_name.as_str()) { "" } else { "?" };
let ty = ts_type_expression(field_schema);
format!(" {field_name}{opt}: {ty}")
})
fn emit_named_type_as_ts(name: &str, ty: &NamedType) -> String {
match ty {
NamedType::Struct(fields) => emit_interface(name, fields),
NamedType::List(inner) => format!("export type {name} = {}[]", ts_type_expression(inner)),
NamedType::Enum(variants) => {
let union = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(" | ");
format!("export type {name} = {union}")
}
NamedType::Alias(inner) => format!("export type {name} = {}", ts_type_expression(inner)),
}
}
fn emit_interface(name: &str, fields: &[StructField]) -> String {
if fields.is_empty() {
return format!("export interface {name} {{}}");
}
let body = fields.iter()
.map(|f| {
let is_required = f.required || f.default.is_some();
let opt = if is_required { "" } else { "?" };
format!(" {}{opt}: {}", f.name, ts_type_expression(&f.shape))
})
.collect::<Vec<_>>()
.join("\n");
format!("export interface {name} {{\n{body}\n}}")
}
fn ts_type_expression(shape: &TypeShape) -> String {
match shape {
TypeShape::Ref(name) => name.clone(),
TypeShape::Primitive(p) => primitive_to_ts(*p).to_string(),
TypeShape::List(inner) => format!("{}[]", ts_type_expression(inner)),
TypeShape::Optional(inner) => format!("{} | null", ts_type_expression(inner)),
TypeShape::Enum(variants) => variants.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join("\n");
if fields.is_empty() {
format!("export interface {name} {{}}")
} else {
format!("export interface {name} {{\n{fields}\n}}")
}
} else {
format!("export type {name} = {}", ts_type_expression(schema))
.join(" | "),
TypeShape::Union(branches) => branches.iter()
.map(ts_type_expression)
.collect::<Vec<_>>()
.join(" | "),
}
}
fn ts_type_expression(schema: &JsonSchema) -> String {
if let Some(ref_name) = schema.ref_name() {
return ref_name.to_string();
}
match schema.ty.as_deref() {
Some("integer") | Some("number") => "number".to_string(),
Some("boolean") => "boolean".to_string(),
Some("string") => "string".to_string(),
Some("array") => {
let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
format!("{elem}[]")
}
Some("object") => "Record<string, unknown>".to_string(),
_ => "unknown".to_string(),
fn primitive_to_ts(p: Primitive) -> &'static str {
match p {
Primitive::Integer | Primitive::Number => "number",
Primitive::Boolean => "boolean",
Primitive::String => "string",
}
}

View File

@@ -14,7 +14,9 @@ use crate::config::Config;
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::{pascal_case, rust_ident, snake_case};
use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR};
use crate::ir::{
IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive, StructField, TypeShape,
};
pub struct PythonClient;
@@ -24,8 +26,8 @@ impl CodegenTarget for PythonClient {
fn name(&self) -> &'static str { "python" }
fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec<EmittedFile> {
let schemas_block = ir.components.schemas.iter()
.map(|(name, schema)| emit_schema_block(name, schema))
let schemas_block = ir.types.iter()
.map(|(name, ty)| emit_schema_block(name, ty))
.collect::<Vec<_>>()
.join("\n\n");
@@ -66,119 +68,67 @@ struct ClientTemplate {
// ─── types.py schema bodies ────────────────────────────────────────────────
fn emit_schema_block(raw_name: &str, schema: &JsonSchema) -> String {
fn emit_schema_block(raw_name: &str, ty: &NamedType) -> String {
let name = pascal_case(raw_name);
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
let literal = values.iter()
.filter_map(|v| v.as_str())
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(", ");
return format!("{name} = Literal[{literal}]");
match ty {
NamedType::Struct(fields) => emit_pydantic_class(&name, fields),
NamedType::List(inner) => format!("{name} = list[{}]", py_type_expression(inner)),
NamedType::Enum(variants) => {
let literal = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(", ");
format!("{name} = Literal[{literal}]")
}
NamedType::Alias(inner) => format!("{name} = {}", py_type_expression(inner)),
}
if schema.ty.as_deref() == Some("array") {
let elem = py_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
return format!("{name} = list[{elem}]");
}
if schema.ty.as_deref() == Some("object") {
if let Some(props) = &schema.properties {
return emit_pydantic_class(&name, schema, props);
}
}
let ty = py_type_from_schema(schema);
format!("{name} = {ty}")
}
fn emit_pydantic_class(
name: &str,
schema: &JsonSchema,
properties: &IndexMap<String, JsonSchema>,
) -> String {
if properties.is_empty() {
fn emit_pydantic_class(name: &str, fields: &[StructField]) -> String {
if fields.is_empty() {
return format!("class {name}(BaseModel):\n pass");
}
let required: std::collections::HashSet<&str> =
schema.required.iter().map(String::as_str).collect();
let field_lines = properties.iter()
.map(|(field_raw, field_schema)| {
let mut ty = py_type_from_schema(field_schema);
let is_required = required.contains(field_raw.as_str())
|| field_schema.default.is_some();
let field_lines = fields.iter()
.map(|f| {
let mut ty = py_type_expression(&f.shape);
let is_required = f.required || f.default.is_some();
if !is_required {
if !ty.ends_with(" | None") {
ty = format!("{ty} | None");
}
format!(" {}: {ty} = None", rust_ident(field_raw))
format!(" {}: {ty} = None", rust_ident(&f.name))
} else {
format!(" {}: {ty}", rust_ident(field_raw))
format!(" {}: {ty}", rust_ident(&f.name))
}
})
.collect::<Vec<_>>()
.join("\n");
format!("class {name}(BaseModel):\n{field_lines}")
}
fn py_type_from_schema(schema: &JsonSchema) -> String {
if let Some(ref_name) = schema.ref_name() {
return pascal_case(ref_name);
}
if let Some(any_of) = &schema.any_of {
let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
let non_null: Vec<&JsonSchema> = any_of
.iter()
.filter(|s| s.ty.as_deref() != Some("null"))
.collect();
if has_null && non_null.len() == 1 {
return format!("{} | None", py_type_from_schema(non_null[0]));
fn py_type_expression(shape: &TypeShape) -> String {
match shape {
TypeShape::Ref(name) => pascal_case(name),
TypeShape::Primitive(p) => primitive_to_py(*p).to_string(),
TypeShape::List(inner) => format!("list[{}]", py_type_expression(inner)),
TypeShape::Optional(inner) => format!("{} | None", py_type_expression(inner)),
TypeShape::Enum(variants) => {
let parts = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(", ");
format!("Literal[{parts}]")
}
}
let nullable = schema.nullable;
let inner = inner_py_type(schema);
if nullable {
format!("{inner} | None")
} else {
inner
TypeShape::Union(branches) => branches.iter()
.map(py_type_expression)
.collect::<Vec<_>>()
.join(" | "),
}
}
fn inner_py_type(schema: &JsonSchema) -> String {
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
let parts = values.iter()
.filter_map(|v| v.as_str())
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(", ");
return format!("Literal[{parts}]");
}
}
match schema.ty.as_deref() {
Some("integer") => "int".to_string(),
Some("number") => "float".to_string(),
Some("boolean") => "bool".to_string(),
Some("string") => "str".to_string(),
Some("array") => {
let elem = py_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
format!("list[{elem}]")
}
Some("object") => {
if schema.properties.is_some() { "Any".to_string() }
else { "dict[str, Any]".to_string() }
}
_ => "Any".to_string(),
fn primitive_to_py(p: Primitive) -> &'static str {
match p {
Primitive::Integer => "int",
Primitive::Number => "float",
Primitive::Boolean => "bool",
Primitive::String => "str",
}
}
@@ -216,12 +166,12 @@ fn build_client_template(ir: &MizanIR) -> ClientTemplate {
}
fn py_arg_type(json_ty: &str) -> &'static str {
match json_ty {
"integer" => "int",
"number" => "float",
"boolean" => "bool",
_ => "str",
fn py_arg_type(p: Primitive) -> &'static str {
match p {
Primitive::Integer => "int",
Primitive::Number => "float",
Primitive::Boolean => "bool",
Primitive::String => "str",
}
}
@@ -231,7 +181,7 @@ fn emit_fetch_method(ctx_name: &str, ctx_meta: &MizanContext) -> String {
let param_args = ctx_meta.params.iter()
.map(|(n, m)| {
let ident = rust_ident(n);
let ty = py_arg_type(&m.ty);
let ty = py_arg_type(m.ty);
if m.required { format!("{ident}: {ty}") }
else { format!("{ident}: {ty} | None = None") }
})
@@ -259,7 +209,7 @@ fn emit_subscribe_method(ctx_name: &str, ctx_meta: &MizanContext) -> String {
let param_args = ctx_meta.params.iter()
.map(|(n, m)| {
let ident = rust_ident(n);
let ty = py_arg_type(&m.ty);
let ty = py_arg_type(m.ty);
if m.required { format!("{ident}: {ty}") }
else { format!("{ident}: {ty} | None = None") }
})

View File

@@ -10,7 +10,10 @@ use crate::config::{Config, RustKernelSpec};
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::{pascal_case, rust_ident, rust_type_ident, snake_case};
use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR};
use crate::ir::{
IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive,
StructField as IrStructField, TypeShape,
};
pub struct RustCrate;
@@ -35,7 +38,7 @@ impl CodegenTarget for RustCrate {
.render().expect("Cargo.toml renders"),
));
out.push(EmittedFile::new("src/types.rs", emit_types_rs(&ir.components.schemas)));
out.push(EmittedFile::new("src/types.rs", emit_types_rs(&ir.types)));
let mut context_modules: Vec<String> = Vec::new();
for (ctx_name, ctx_meta) in &ir.contexts {
@@ -119,8 +122,8 @@ struct ContextTemplate<'a> {
snake: String,
ctx_name: &'a str,
type_imports: Vec<String>,
data_fields: Vec<StructField>,
params: Vec<StructField>,
data_fields: Vec<RustField>,
params: Vec<RustField>,
}
@@ -144,7 +147,10 @@ struct TypesTemplate {
}
struct StructField {
/// Renderer-side view of a single Rust struct field. Distinct from
/// `ir::StructField` (the IR shape) because the renderer carries
/// already-rendered identifiers and rename flags.
struct RustField {
raw_name: String,
ident: String,
ty: String,
@@ -212,10 +218,10 @@ fn emit_context_file(
ctx_fns.iter().map(|f| rust_type_ident(&f.output_type)),
);
let data_fields: Vec<StructField> = ctx_fns.iter()
let data_fields: Vec<RustField> = ctx_fns.iter()
.map(|f| {
let ident = rust_ident(&f.name);
StructField {
RustField {
has_rename: ident != f.name,
raw_name: f.name.clone(),
ident,
@@ -224,12 +230,12 @@ fn emit_context_file(
})
.collect();
let params: Vec<StructField> = ctx_meta.params.iter()
let params: Vec<RustField> = ctx_meta.params.iter()
.map(|(p_name, p_meta)| {
let ident = rust_ident(p_name);
let base = param_rust_type(&p_meta.ty);
let base = param_rust_type(p_meta.ty);
let ty = if p_meta.required { base.to_string() } else { format!("Option<{base}>") };
StructField {
RustField {
has_rename: ident != *p_name,
raw_name: p_name.clone(),
ident,
@@ -249,12 +255,12 @@ fn emit_context_file(
}
fn param_rust_type(json_ty: &str) -> &'static str {
match json_ty {
"integer" => "i64",
"number" => "f64",
"boolean" => "bool",
_ => "String",
fn param_rust_type(p: Primitive) -> &'static str {
match p {
Primitive::Integer => "i64",
Primitive::Number => "f64",
Primitive::Boolean => "bool",
Primitive::String => "String",
}
}
@@ -303,33 +309,26 @@ fn emit_call_file(fn_meta: &MizanFunction) -> String {
// ─── types.rs ──────────────────────────────────────────────────────────────
/// Per-types-file context tracking enum names hoisted out of inline
/// `field { enum "a" "b" }` declarations into Rust top-level enum types.
struct EnumCtx {
hoisted: Vec<(String, Vec<serde_json::Value>)>,
depth: usize,
hoisted: Vec<(String, Vec<String>)>,
enum_name: Option<String>,
}
fn emit_types_rs(schemas: &IndexMap<String, JsonSchema>) -> String {
let mut ctx = EnumCtx { hoisted: Vec::new(), depth: 0, enum_name: None };
fn emit_types_rs(types: &IndexMap<String, NamedType>) -> String {
let mut ctx = EnumCtx { hoisted: Vec::new(), enum_name: None };
let schemas_block = schemas.iter()
.map(|(raw_name, schema)| {
let schemas_block = types.iter()
.map(|(raw_name, ty)| {
let name = rust_type_ident(raw_name);
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
return emit_string_enum(&name, values);
}
match ty {
NamedType::Struct(fields) => emit_struct_decl(&name, fields, &mut ctx),
NamedType::List(inner) => emit_transparent_array(&name, inner, &mut ctx),
NamedType::Enum(variants) => emit_string_enum(&name, variants),
NamedType::Alias(inner) => emit_type_alias(&name, inner, &mut ctx),
}
if schema.ty.as_deref() == Some("array") {
return emit_transparent_array(&name, schema, &mut ctx);
}
if schema.ty.as_deref() == Some("object") {
if let Some(props) = &schema.properties {
return emit_struct(&name, schema, props, &mut ctx);
}
}
emit_type_alias(&name, schema, &mut ctx)
})
.collect::<Vec<_>>()
.join("\n");
@@ -344,12 +343,11 @@ fn emit_types_rs(schemas: &IndexMap<String, JsonSchema>) -> String {
}
fn emit_string_enum(name: &str, variants: &[serde_json::Value]) -> String {
fn emit_string_enum(name: &str, variants: &[String]) -> String {
let body = variants.iter()
.filter_map(|v| v.as_str())
.map(|v| {
let ident = pascal_case(v);
let rename = if ident == v {
let rename = if ident == *v {
String::new()
} else {
format!(" #[serde(rename = {})]\n", json_str(v))
@@ -365,40 +363,33 @@ fn emit_string_enum(name: &str, variants: &[serde_json::Value]) -> String {
}
fn emit_transparent_array(name: &str, schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
ctx.depth = 1;
fn emit_transparent_array(name: &str, inner: &TypeShape, ctx: &mut EnumCtx) -> String {
ctx.enum_name = None;
let inner = rust_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()), ctx);
let inner_ty = rust_type_from_shape(inner, ctx);
format!(
"#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct {name}(pub Vec<{inner}>);\n",
"#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct {name}(pub Vec<{inner_ty}>);\n",
)
}
fn emit_struct(
fn emit_struct_decl(
name: &str,
schema: &JsonSchema,
properties: &IndexMap<String, JsonSchema>,
fields: &[IrStructField],
ctx: &mut EnumCtx,
) -> String {
let required: std::collections::HashSet<&str> =
schema.required.iter().map(String::as_str).collect();
let fields = properties.iter()
.map(|(field_raw, field_schema)| {
let field_name = rust_ident(field_raw);
ctx.depth = 1;
ctx.enum_name = Some(format!("{name}_{}", pascal_case(field_raw)));
let mut ty = rust_type_from_schema(field_schema, ctx);
let is_required = required.contains(field_raw.as_str())
|| field_schema.default.is_some();
let fields_body = fields.iter()
.map(|f| {
let field_name = rust_ident(&f.name);
ctx.enum_name = Some(format!("{name}_{}", pascal_case(&f.name)));
let mut ty = rust_type_from_shape(&f.shape, ctx);
let is_required = f.required || f.default.is_some();
if !is_required && !ty.starts_with("Option<") {
ty = format!("Option<{ty}>");
}
let rename = if field_name == *field_raw {
let rename = if field_name == f.name {
String::new()
} else {
format!(" #[serde(rename = \"{field_raw}\")]\n")
format!(" #[serde(rename = \"{raw}\")]\n", raw = f.name)
};
format!("{rename} pub {field_name}: {ty},")
})
@@ -406,69 +397,48 @@ fn emit_struct(
.join("\n");
format!(
"#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {name} {{\n{fields}\n}}\n",
"#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {name} {{\n{fields_body}\n}}\n",
)
}
fn emit_type_alias(name: &str, schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
ctx.depth = 0;
fn emit_type_alias(name: &str, inner: &TypeShape, ctx: &mut EnumCtx) -> String {
ctx.enum_name = Some(name.to_string());
let ty = rust_type_from_schema(schema, ctx);
let ty = rust_type_from_shape(inner, ctx);
format!("pub type {name} = {ty};\n")
}
fn rust_type_from_schema(schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
if let Some(r) = schema.ref_name() {
return rust_type_ident(r);
}
if let Some(any_of) = &schema.any_of {
let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
let non_null: Vec<&JsonSchema> = any_of
.iter()
.filter(|s| s.ty.as_deref() != Some("null"))
.collect();
if has_null && non_null.len() == 1 {
fn rust_type_from_shape(shape: &TypeShape, ctx: &mut EnumCtx) -> String {
match shape {
TypeShape::Ref(name) => rust_type_ident(name),
TypeShape::Primitive(Primitive::Integer) => "i64".to_string(),
TypeShape::Primitive(Primitive::Number) => "f64".to_string(),
TypeShape::Primitive(Primitive::Boolean) => "bool".to_string(),
TypeShape::Primitive(Primitive::String) => "String".to_string(),
TypeShape::List(inner) => {
ctx.enum_name = None;
return format!("Option<{}>", rust_type_from_schema(non_null[0], ctx));
format!("Vec<{}>", rust_type_from_shape(inner, ctx))
}
}
let nullable = schema.nullable;
let inner = inner_rust_type(schema, ctx);
if nullable {
format!("Option<{inner}>")
} else {
inner
}
}
fn inner_rust_type(schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
TypeShape::Optional(inner) => {
ctx.enum_name = None;
format!("Option<{}>", rust_type_from_shape(inner, ctx))
}
TypeShape::Enum(variants) => {
// Inline enums hoist out into top-level Rust enum types so the
// generated struct field can reference them by name.
let enum_name = ctx
.enum_name
.clone()
.unwrap_or_else(|| format!("Enum_{}", ctx.depth));
ctx.hoisted.push((enum_name.clone(), values.clone()));
return enum_name;
.unwrap_or_else(|| "Enum_inline".to_string());
ctx.hoisted.push((enum_name.clone(), variants.clone()));
enum_name
}
}
match schema.ty.as_deref() {
Some("integer") => "i64".to_string(),
Some("number") => "f64".to_string(),
Some("boolean") => "bool".to_string(),
Some("string") => "String".to_string(),
Some("array") => {
ctx.depth += 1;
ctx.enum_name = None;
let inner = rust_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()), ctx);
format!("Vec<{inner}>")
TypeShape::Union(_branches) => {
// Rust serde doesn't have a clean way to deserialize an untagged
// multi-arm union without losing type info; fall back to a JSON
// Value so the consumer can match on the runtime variant.
"serde_json::Value".to_string()
}
Some("object") => "serde_json::Value".to_string(),
_ => "serde_json::Value".to_string(),
}
}

View File

@@ -20,7 +20,9 @@ use askama::Template;
use indexmap::IndexMap;
use crate::config::Config;
use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR};
use crate::ir::{
IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive, StructField, TypeShape,
};
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::pascal_case;
@@ -94,7 +96,7 @@ impl CodegenTarget for Stage1 {
fn emit(&self, ir: &MizanIR, config: &Config) -> Vec<EmittedFile> {
let mut out: Vec<EmittedFile> = Vec::new();
out.push(EmittedFile::new("types.ts", emit_types(&ir.components.schemas)));
out.push(EmittedFile::new("types.ts", emit_types(&ir.types)));
for (ctx_name, ctx_meta) in &ir.contexts {
let content = emit_context_file(ctx_name, ctx_meta, &ir.functions);
@@ -157,7 +159,7 @@ fn emit_context_file(
let params: Vec<ContextParamField> = ctx_meta.params.iter()
.map(|(name, meta)| ContextParamField {
name,
ts_type: json_ty_to_ts(&meta.ty),
ts_type: primitive_to_ts(meta.ty),
required: meta.required,
})
.collect();
@@ -174,11 +176,11 @@ fn emit_context_file(
}
fn json_ty_to_ts(json_ty: &str) -> &'static str {
match json_ty {
"integer" | "number" => "number",
"boolean" => "boolean",
_ => "string",
fn primitive_to_ts(p: Primitive) -> &'static str {
match p {
Primitive::Integer | Primitive::Number => "number",
Primitive::Boolean => "boolean",
Primitive::String => "string",
}
}
@@ -239,131 +241,60 @@ fn emit_stage1_index(ir: &MizanIR, config: &Config) -> String {
// ─── types.ts ──────────────────────────────────────────────────────────────
fn emit_types(schemas: &IndexMap<String, JsonSchema>) -> String {
fn emit_types(types: &IndexMap<String, NamedType>) -> String {
let mut out = String::new();
out.push_str("// AUTO-GENERATED by mizan — do not edit\n\n");
for (raw_name, schema) in schemas {
out.push_str(&emit_schema_decl(raw_name, schema));
for (name, ty) in types {
out.push_str(&emit_named_type(name, ty));
out.push('\n');
}
out
}
fn emit_schema_decl(name: &str, schema: &JsonSchema) -> String {
// String enum → union of string literals.
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
let union = values
.iter()
.filter_map(|v| v.as_str())
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(" | ");
return format!("export type {name} = {union}\n");
fn emit_named_type(name: &str, ty: &NamedType) -> String {
match ty {
NamedType::Struct(fields) => emit_interface(name, fields),
NamedType::List(inner) => format!("export type {name} = {}[]\n", ts_type_expression(inner)),
NamedType::Enum(variants) => {
let union = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(" | ");
format!("export type {name} = {union}\n")
}
NamedType::Alias(inner) => format!("export type {name} = {}\n", ts_type_expression(inner)),
}
// Top-level array → array alias.
if schema.ty.as_deref() == Some("array") {
let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
return format!("export type {name} = {elem}[]\n");
}
// Object with properties → interface declaration.
if schema.ty.as_deref() == Some("object") {
if let Some(props) = &schema.properties {
return emit_interface(name, schema, props);
}
}
// Fallback — alias to a structural expression.
let expr = ts_type_expression(schema);
format!("export type {name} = {expr}\n")
}
fn emit_interface(
name: &str,
schema: &JsonSchema,
properties: &IndexMap<String, JsonSchema>,
) -> String {
let required: std::collections::HashSet<&str> =
schema.required.iter().map(String::as_str).collect();
let fields = properties
.iter()
.map(|(field_name, field_schema)| {
// Fields are non-optional if they're explicitly required OR
// if they carry a default value (server always populates).
let is_required = required.contains(field_name.as_str())
|| field_schema.default.is_some();
fn emit_interface(name: &str, fields: &[StructField]) -> String {
if fields.is_empty() {
return format!("export interface {name} {{}}\n");
}
let body = fields.iter()
.map(|f| {
// Field is non-optional if required OR has a default (server always populates).
let is_required = f.required || f.default.is_some();
let opt = if is_required { "" } else { "?" };
let ty = ts_type_expression(field_schema);
format!(" {field_name}{opt}: {ty}")
format!(" {}{opt}: {}", f.name, ts_type_expression(&f.shape))
})
.collect::<Vec<_>>()
.join("\n");
if fields.is_empty() {
format!("export interface {name} {{}}\n")
} else {
format!("export interface {name} {{\n{fields}\n}}\n")
}
format!("export interface {name} {{\n{body}\n}}\n")
}
fn ts_type_expression(schema: &JsonSchema) -> String {
// `$ref` → bare type name reference into components.schemas.
if let Some(ref_name) = schema.ref_name() {
return ref_name.to_string();
}
// `anyOf` with a null variant → `T | null`.
if let Some(any_of) = &schema.any_of {
let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
let non_null: Vec<&JsonSchema> = any_of
.iter()
.filter(|s| s.ty.as_deref() != Some("null"))
.collect();
if has_null && non_null.len() == 1 {
return format!("{} | null", ts_type_expression(non_null[0]));
}
let union = any_of
.iter()
fn ts_type_expression(shape: &TypeShape) -> String {
match shape {
TypeShape::Ref(name) => name.clone(),
TypeShape::Primitive(p) => primitive_to_ts(*p).to_string(),
TypeShape::List(inner) => format!("{}[]", ts_type_expression(inner)),
TypeShape::Optional(inner) => format!("{} | null", ts_type_expression(inner)),
TypeShape::Enum(variants) => variants.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(" | "),
TypeShape::Union(branches) => branches.iter()
.map(ts_type_expression)
.collect::<Vec<_>>()
.join(" | ");
return union;
}
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
return values
.iter()
.filter_map(|v| v.as_str())
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(" | ");
}
}
let base = match schema.ty.as_deref() {
Some("integer") | Some("number") => "number".to_string(),
Some("boolean") => "boolean".to_string(),
Some("string") => "string".to_string(),
Some("array") => {
let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
format!("{elem}[]")
}
Some("object") => "Record<string, unknown>".to_string(),
Some("null") => "null".to_string(),
_ => "unknown".to_string(),
};
if schema.nullable {
format!("{base} | null")
} else {
base
.join(" | "),
}
}

View File

@@ -1,12 +1,9 @@
//! Schema fetching — spawns the configured backend's schema-export command
//! and deserializes its stdout into a typed `MizanIR`.
//! Schema fetching — spawns the configured backend's IR-export command
//! and parses the KDL it writes to stdout.
//!
//! Two backends recognized today:
//! - FastAPI: `python -m mizan_fastapi.cli <module>`
//! - Django: `python manage.py export_mizan_schema --indent 0`
//!
//! The fetcher reads stdout, skips any banner text before the first `{`,
//! and parses the remainder as JSON.
//! Backends:
//! - FastAPI: `python -m mizan_fastapi.ir <module>`
//! - Django: `python manage.py export_mizan_ir`
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -14,7 +11,7 @@ use std::process::Command;
use anyhow::{anyhow, Context, Result};
use crate::config::{Config, DjangoSource, FastapiSource};
use crate::ir::MizanIR;
use crate::ir::{parse_ir, MizanIR};
pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
@@ -28,7 +25,7 @@ pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
));
};
parse_ir(&raw)
parse_ir(&raw).context("parsing Mizan IR from backend KDL output")
}
@@ -41,11 +38,11 @@ fn run_fastapi(src: &FastapiSource, config_dir: &Path) -> Result<String> {
let (program, mut args) = resolve_command(&src.command, &src.python);
args.extend([
"-m".to_string(),
"mizan_fastapi.cli".to_string(),
"mizan_fastapi.ir".to_string(),
src.module.clone(),
]);
run_subprocess(&program, &args, &cwd, &src.env, "FastAPI schema export")
run_subprocess(&program, &args, &cwd, &src.env, "FastAPI IR export")
}
@@ -58,22 +55,14 @@ fn run_django(src: &DjangoSource, config_dir: &Path) -> Result<String> {
let (program, mut args) = resolve_command(&src.command, &src.python);
// If the user supplied an explicit command (e.g. `uv run python`), they
// expect to invoke from the manage_dir without a path prefix on manage.py.
// Otherwise we pass the absolute manage_path so the python interpreter
// doesn't depend on cwd.
if src.command.is_some() {
args.push("manage.py".to_string());
} else {
args.push(manage_path.to_string_lossy().into_owned());
}
args.extend([
"export_mizan_schema".to_string(),
"--indent".to_string(),
"0".to_string(),
]);
args.push("export_mizan_ir".to_string());
run_subprocess(&program, &args, &manage_dir, &src.env, "Django schema export")
run_subprocess(&program, &args, &manage_dir, &src.env, "Django IR export")
}
@@ -122,23 +111,13 @@ fn run_subprocess(
}
fn parse_ir(raw: &str) -> Result<MizanIR> {
let json_start = raw
.find('{')
.ok_or_else(|| anyhow!("no JSON object found in schema-export output"))?;
serde_json::from_str(&raw[json_start..]).context("deserializing Mizan IR from schema JSON")
/// Library helper: parse a KDL IR from a string.
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
parse_ir(source)
}
/// Library helper for tests: deserialize an IR from a pre-fetched JSON string
/// (no subprocess). Mirrors `parse_ir` but exposed for crate-external callers.
pub fn parse_ir_from_str(json: &str) -> Result<MizanIR> {
parse_ir(json)
}
/// Library helper: resolve a path relative to the config directory, returning
/// an absolute path. Consumers may want this when constructing output paths.
/// Library helper: resolve a path relative to the config directory.
pub fn resolve_path(config_dir: &Path, p: impl Into<PathBuf>) -> PathBuf {
let p = p.into();
if p.is_absolute() {

View File

@@ -1,122 +1,121 @@
//! Mizan IR — strongly-typed deserialization of the backends' schema export.
//!
//! Every Mizan backend (Django, FastAPI, mizan-ts) emits the same OpenAPI
//! document with three load-bearing extension fields:
//! - `x-mizan-functions` — array of function entries
//! - `x-mizan-contexts` — map of context groups
//! - `components.schemas` — OpenAPI Pydantic→JSONSchema per Input/Output
//!
//! The structs here deserialize that JSON envelope into typed Rust values
//! the emit targets walk. The OpenAPI document body (paths, info, etc.) is
//! intentionally not modeled — the codegen consumes only the extensions.
use std::collections::BTreeMap;
//! Mizan IR — the canonical KDL document every backend adapter emits and
//! every codegen target consumes. See `docs/AFI_ARCHITECTURE.md` and
//! `cores/mizan-python/src/mizan_core/ir.py` for the locked grammar.
use anyhow::{anyhow, bail, Context, Result};
use indexmap::IndexMap;
use serde::Deserialize;
use kdl::{KdlDocument, KdlNode, KdlValue};
#[derive(Debug, Deserialize)]
#[derive(Debug, Default)]
pub struct MizanIR {
#[serde(rename = "x-mizan-functions", default)]
pub types: IndexMap<String, NamedType>,
pub functions: Vec<MizanFunction>,
#[serde(rename = "x-mizan-contexts", default)]
pub contexts: IndexMap<String, MizanContext>,
/// Django-only channel registrations. FastAPI backends emit an empty list.
#[serde(rename = "x-mizan-channels", default)]
pub channels: Vec<MizanChannel>,
#[serde(default)]
pub components: Components,
}
#[derive(Debug, Deserialize, Clone)]
pub struct MizanChannel {
// ─── Type system ────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum NamedType {
Struct(Vec<StructField>),
List(TypeShape),
Enum(Vec<String>),
Alias(TypeShape),
}
#[derive(Debug, Clone)]
pub struct StructField {
pub name: String,
#[serde(rename = "pascalName")]
pub pascal_name: String,
#[serde(rename = "hasParams", default)]
pub has_params: bool,
#[serde(rename = "hasReactMessage", default)]
pub has_react_message: bool,
#[serde(rename = "hasDjangoMessage", default)]
pub has_django_message: bool,
#[serde(rename = "paramsType", default)]
pub params_type: Option<String>,
#[serde(rename = "reactMessageType", default)]
pub react_message_type: Option<String>,
#[serde(rename = "djangoMessageType", default)]
pub django_message_type: Option<String>,
pub required: bool,
pub default: Option<DefaultValue>,
pub shape: TypeShape,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Clone)]
pub enum TypeShape {
Primitive(Primitive),
Ref(String),
List(Box<TypeShape>),
Optional(Box<TypeShape>),
Enum(Vec<String>),
/// Multi-arm union with two or more non-null branches.
Union(Vec<TypeShape>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Primitive { Integer, Number, Boolean, String }
impl Primitive {
fn parse(s: &str) -> Result<Self> {
match s {
"integer" => Ok(Primitive::Integer),
"number" => Ok(Primitive::Number),
"boolean" => Ok(Primitive::Boolean),
"string" => Ok(Primitive::String),
other => bail!("unknown primitive {other:?}"),
}
}
}
#[derive(Debug, Clone)]
pub enum DefaultValue {
Integer(i64),
Number(f64),
Boolean(bool),
String(String),
Null,
}
// ─── Functions ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct MizanFunction {
pub name: String,
#[serde(rename = "camelName")]
pub camel_name: String,
#[serde(rename = "hasInput")]
pub has_input: bool,
#[serde(rename = "inputType")]
pub input_type: Option<String>,
#[serde(rename = "outputType")]
pub output_type: String,
#[serde(rename = "outputNullable", default)]
pub output_nullable: bool,
pub transport: Transport,
#[serde(rename = "isContext", default)]
pub is_context: IsContext,
#[serde(rename = "isForm", default)]
pub is_form: bool,
#[serde(rename = "formName", default)]
pub form_name: Option<String>,
#[serde(rename = "formRole", default)]
pub form_role: Option<String>,
#[serde(default)]
pub affects: Vec<AffectTarget>,
/// Names of contexts whose state is patched by this function's return
/// body via the kernel's `splice_slot` merger. Empty when the function
/// is not a merge target.
#[serde(default)]
pub merge: Vec<String>,
}
#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Transport {
#[default]
Http,
Websocket,
Both,
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport { Http, Websocket, Both }
impl Transport {
fn parse(s: &str) -> Result<Self> {
match s {
"http" => Ok(Transport::Http),
"websocket" => Ok(Transport::Websocket),
"both" => Ok(Transport::Both),
other => bail!("unknown transport {other:?}"),
}
}
}
/// IR-level `isContext` value. The backends emit `false` for non-context
/// functions and a string (`"global"`, `"user"`, …) for context-grouped
/// functions. Custom Deserialize bridges the boolean/string union into a
/// typed Rust enum.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum IsContext {
#[default]
No,
Yes(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IsContext { No, Yes(String) }
impl IsContext {
pub fn as_str(&self) -> Option<&str> {
@@ -127,122 +126,343 @@ impl IsContext {
}
}
impl<'de> Deserialize<'de> for IsContext {
fn deserialize<D>(de: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = serde_json::Value::deserialize(de)?;
match v {
serde_json::Value::Bool(false) => Ok(IsContext::No),
serde_json::Value::Bool(true) => Err(serde::de::Error::custom(
"isContext: bare `true` is not a valid context name",
)),
serde_json::Value::String(s) => Ok(IsContext::Yes(s)),
serde_json::Value::Null => Ok(IsContext::No),
other => Err(serde::de::Error::custom(format!(
"isContext: expected `false` or string, got {other:?}"
))),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Clone)]
pub struct AffectTarget {
#[serde(rename = "type")]
pub kind: AffectKind,
pub name: String,
#[serde(default)]
pub context: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AffectKind {
Context,
Function,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AffectKind { Context, Function }
#[derive(Debug, Deserialize, Default, Clone)]
// ─── Contexts ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default)]
pub struct MizanContext {
#[serde(default)]
pub functions: Vec<String>,
#[serde(default)]
pub params: IndexMap<String, ContextParam>,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Clone)]
pub struct ContextParam {
#[serde(rename = "type")]
pub ty: String,
pub ty: Primitive,
pub required: bool,
#[serde(rename = "sharedBy", default)]
pub shared_by: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct Components {
#[serde(default)]
pub schemas: IndexMap<String, JsonSchema>,
// ─── Channels (Django-only) ─────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct MizanChannel {
pub name: String,
pub pascal_name: String,
pub params_type: Option<String>,
pub react_message_type: Option<String>,
pub django_message_type: Option<String>,
}
/// JSON Schema subset used by the emit targets. Mirrors the surface the
/// existing JS adapters traverse (`$ref`, `anyOf`, `enum`, `type`, `items`,
/// `properties`, `required`, `nullable`). Unknown fields are stashed in
/// `extra` so backends can include schema annotations the codegen ignores.
#[derive(Debug, Deserialize, Default, Clone)]
pub struct JsonSchema {
#[serde(rename = "type", default)]
pub ty: Option<String>,
#[serde(rename = "$ref", default)]
pub r#ref: Option<String>,
#[serde(rename = "enum", default)]
pub r#enum: Option<Vec<serde_json::Value>>,
#[serde(rename = "anyOf", default)]
pub any_of: Option<Vec<JsonSchema>>,
#[serde(default)]
pub nullable: bool,
#[serde(default)]
pub items: Option<Box<JsonSchema>>,
#[serde(default)]
pub properties: Option<IndexMap<String, JsonSchema>>,
#[serde(default)]
pub required: Vec<String>,
#[serde(rename = "additionalProperties", default)]
pub additional_properties: Option<serde_json::Value>,
/// Presence of this field means the schema has a default — the server
/// always populates it. Consumers can treat the field as non-optional
/// even if it's absent from `required`.
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
impl MizanChannel {
pub fn has_params(&self) -> bool { self.params_type.is_some() }
pub fn has_react_message(&self) -> bool { self.react_message_type.is_some() }
pub fn has_django_message(&self) -> bool { self.django_message_type.is_some() }
}
impl JsonSchema {
/// `$ref: "#/components/schemas/Foo"` → `Some("Foo")`.
pub fn ref_name(&self) -> Option<&str> {
self.r#ref
.as_deref()
.and_then(|s| s.strip_prefix("#/components/schemas/"))
// ─── KDL parsing ────────────────────────────────────────────────────────────
pub fn parse_ir(source: &str) -> Result<MizanIR> {
let doc: KdlDocument = source.parse()
.map_err(|e| anyhow!("KDL parse error: {e}"))?;
let mut ir = MizanIR::default();
for node in doc.nodes() {
match node.name().value() {
"type" => {
let (name, ty) = parse_named_type(node)?;
ir.types.insert(name, ty);
}
"function" => ir.functions.push(parse_function(node)?),
"context" => {
let (name, ctx) = parse_context(node)?;
ir.contexts.insert(name, ctx);
}
"channel" => ir.channels.push(parse_channel(node)?),
other => bail!("unknown top-level KDL node {other:?}"),
}
}
Ok(ir)
}
fn parse_named_type(node: &KdlNode) -> Result<(String, NamedType)> {
let name = first_string_arg(node)
.context("`type` requires a name as its first argument")?;
let children = node.children()
.ok_or_else(|| anyhow!("type {name:?}: missing children block"))?;
let kind_node = single_child(children, &format!("type {name:?}"))?;
let kind = match kind_node.name().value() {
"struct" => NamedType::Struct(parse_struct_fields(kind_node)?),
"list" => NamedType::List(type_child_of(kind_node, &format!("type {name:?} list"))?),
"enum" => NamedType::Enum(parse_string_args(kind_node)),
"alias" => NamedType::Alias(type_child_of(kind_node, &format!("type {name:?} alias"))?),
other => bail!("type {name:?}: unknown shape node {other:?}"),
};
Ok((name, kind))
}
fn parse_struct_fields(struct_node: &KdlNode) -> Result<Vec<StructField>> {
let mut fields = Vec::new();
let Some(children) = struct_node.children() else { return Ok(fields); };
for child in children.nodes() {
if child.name().value() != "field" {
bail!("struct: unexpected node {:?}", child.name().value());
}
fields.push(parse_struct_field(child)?);
}
Ok(fields)
}
fn parse_struct_field(field_node: &KdlNode) -> Result<StructField> {
let name = first_string_arg(field_node).context("`field` requires a name")?;
let required = bool_prop(field_node, "required").unwrap_or(true);
let default = field_node.entry("default")
.map(|e| parse_default_value(e.value()))
.transpose()?;
let shape = type_child_of(field_node, &format!("field {name:?}"))?;
Ok(StructField { name, required, default, shape })
}
fn parse_default_value(v: &KdlValue) -> Result<DefaultValue> {
if v.is_null() { return Ok(DefaultValue::Null); }
if let Some(b) = v.as_bool() { return Ok(DefaultValue::Boolean(b)); }
if let Some(i) = v.as_integer() { return Ok(DefaultValue::Integer(i as i64)); }
if let Some(f) = v.as_float() { return Ok(DefaultValue::Number(f)); }
if let Some(s) = v.as_string() { return Ok(DefaultValue::String(s.to_string())); }
bail!("unsupported default literal: {v:?}")
}
fn type_child_of(parent: &KdlNode, label: &str) -> Result<TypeShape> {
let children = parent.children()
.ok_or_else(|| anyhow!("{label}: missing children for type-shape"))?;
let nodes = children.nodes();
if nodes.len() != 1 {
bail!("{label}: expected exactly one type-shape child, got {}", nodes.len());
}
parse_type_shape(&nodes[0])
}
fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
match node.name().value() {
"primitive" => Ok(TypeShape::Primitive(Primitive::parse(&first_string_arg(node)?)?)),
"ref" => Ok(TypeShape::Ref(first_string_arg(node)?)),
"list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
"enum" => Ok(TypeShape::Enum(parse_string_args(node))),
"union" => {
let children = node.children()
.ok_or_else(|| anyhow!("union: missing children"))?;
let branches: Result<Vec<TypeShape>> = children.nodes().iter()
.map(parse_type_shape).collect();
Ok(TypeShape::Union(branches?))
}
other => bail!("unknown type-shape node {other:?}"),
}
}
fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
let name = first_string_arg(node)
.context("`function` requires a name as its first argument")?;
let children = node.children()
.ok_or_else(|| anyhow!("function {name:?}: missing children"))?;
let mut camel = None;
let mut has_input = false;
let mut input_type = None;
let mut output_type = None;
let mut output_nullable = false;
let mut transport = Transport::Http;
let mut is_context = IsContext::No;
let mut is_form = false;
let mut form_name = None;
let mut form_role = None;
let mut affects: Vec<AffectTarget> = Vec::new();
let mut merge: Vec<String> = Vec::new();
for child in children.nodes() {
match child.name().value() {
"camel" => camel = Some(string_arg(child, "camel")?),
"has-input" => has_input = bool_arg(child, "has-input")?,
"input" => input_type = Some(string_arg(child, "input")?),
"output" => output_type = Some(string_arg(child, "output")?),
"output-nullable" => output_nullable = bool_arg(child, "output-nullable")?,
"transport" => transport = Transport::parse(&string_arg(child, "transport")?)?,
"context" => is_context = IsContext::Yes(string_arg(child, "context")?),
"is-form" => is_form = bool_arg(child, "is-form")?,
"form-name" => form_name = Some(string_arg(child, "form-name")?),
"form-role" => form_role = Some(string_arg(child, "form-role")?),
"affects" => affects.push(AffectTarget {
kind: AffectKind::Context,
name: string_arg(child, "affects")?,
context: None,
}),
"merge" => merge.push(string_arg(child, "merge")?),
other => bail!("function {name:?}: unknown child {other:?}"),
}
}
Ok(MizanFunction {
name: name.clone(),
camel_name: camel.ok_or_else(|| anyhow!("function {name:?}: missing `camel`"))?,
has_input,
input_type,
output_type: output_type.ok_or_else(|| anyhow!("function {name:?}: missing `output`"))?,
output_nullable,
transport,
is_context,
is_form,
form_name,
form_role,
affects,
merge,
})
}
fn parse_context(node: &KdlNode) -> Result<(String, MizanContext)> {
let name = first_string_arg(node).context("`context` requires a name")?;
let mut ctx = MizanContext::default();
let Some(children) = node.children() else { return Ok((name, ctx)); };
for child in children.nodes() {
match child.name().value() {
"function" => ctx.functions.push(string_arg(child, "function")?),
"param" => {
let (pname, param) = parse_context_param(child)?;
ctx.params.insert(pname, param);
}
other => bail!("context {name:?}: unknown child {other:?}"),
}
}
Ok((name, ctx))
}
fn parse_context_param(node: &KdlNode) -> Result<(String, ContextParam)> {
let pname = first_string_arg(node).context("`param` requires a name")?;
let children = node.children()
.ok_or_else(|| anyhow!("param {pname:?}: missing children"))?;
let mut ty = None;
let mut required = false;
let mut shared_by = Vec::new();
for child in children.nodes() {
match child.name().value() {
"type" => ty = Some(Primitive::parse(&string_arg(child, "type")?)?),
"required" => required = bool_arg(child, "required")?,
"shared-by" => shared_by.push(string_arg(child, "shared-by")?),
other => bail!("param {pname:?}: unknown child {other:?}"),
}
}
Ok((pname.clone(), ContextParam {
ty: ty.ok_or_else(|| anyhow!("param {pname:?}: missing `type`"))?,
required,
shared_by,
}))
}
fn parse_channel(node: &KdlNode) -> Result<MizanChannel> {
let name = first_string_arg(node).context("`channel` requires a name")?;
let children = node.children()
.ok_or_else(|| anyhow!("channel {name:?}: missing children"))?;
let mut pascal_name = None;
let mut params_type = None;
let mut react_message_type = None;
let mut django_message_type = None;
for child in children.nodes() {
match child.name().value() {
"pascal-name" => pascal_name = Some(string_arg(child, "pascal-name")?),
"params" => params_type = Some(string_arg(child, "params")?),
"react-message" => react_message_type = Some(string_arg(child, "react-message")?),
"django-message" => django_message_type = Some(string_arg(child, "django-message")?),
other => bail!("channel {name:?}: unknown child {other:?}"),
}
}
Ok(MizanChannel {
name: name.clone(),
pascal_name: pascal_name.ok_or_else(|| anyhow!("channel {name:?}: missing `pascal-name`"))?,
params_type,
react_message_type,
django_message_type,
})
}
// ─── KDL accessor helpers ───────────────────────────────────────────────────
fn first_string_arg(node: &KdlNode) -> Result<String> {
let entry = node.entries().iter()
.find(|e| e.name().is_none())
.ok_or_else(|| anyhow!("node {:?}: missing positional argument", node.name().value()))?;
entry.value().as_string()
.map(str::to_string)
.ok_or_else(|| anyhow!("node {:?}: positional argument is not a string", node.name().value()))
}
fn string_arg(node: &KdlNode, label: &str) -> Result<String> {
first_string_arg(node).context(format!("{label}: requires a string argument"))
}
fn bool_arg(node: &KdlNode, label: &str) -> Result<bool> {
node.entries().iter()
.find(|e| e.name().is_none())
.and_then(|e| e.value().as_bool())
.ok_or_else(|| anyhow!("{label}: missing positional bool argument"))
}
fn bool_prop(node: &KdlNode, key: &str) -> Option<bool> {
node.entry(key).and_then(|e| e.value().as_bool())
}
fn parse_string_args(node: &KdlNode) -> Vec<String> {
node.entries().iter()
.filter(|e| e.name().is_none())
.filter_map(|e| e.value().as_string().map(str::to_string))
.collect()
}
fn single_child<'a>(children: &'a KdlDocument, label: &str) -> Result<&'a KdlNode> {
let nodes = children.nodes();
if nodes.len() != 1 {
bail!("{label}: expected exactly one child node, got {}", nodes.len());
}
Ok(&nodes[0])
}
// ─── Library entry point ────────────────────────────────────────────────────
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
parse_ir(source)
}

View File

@@ -95,10 +95,10 @@ fn main() -> Result<()> {
};
eprintln!(
"[mizan] Loaded {} function(s), {} context group(s), {} schema(s)",
"[mizan] Loaded {} function(s), {} context group(s), {} type(s)",
ir.functions.len(),
ir.contexts.len(),
ir.components.schemas.len(),
ir.types.len(),
);
// Stage 1 is the framework-agnostic foundation that react/vue/svelte

View File

@@ -28,7 +28,7 @@ fn fixture_config() -> Config {
#[test]
fn channels_target_emits_expected_files() {
let raw = std::fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/channels_schema.json"),
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/channels_ir.kdl"),
).unwrap();
let ir = parse_ir_from_str(&raw).unwrap();
@@ -72,7 +72,7 @@ fn channels_target_emits_expected_files() {
fn channels_target_emits_nothing_when_empty() {
// AFI fixture has no channels — target should produce zero files.
let raw = std::fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json"),
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_ir.kdl"),
).unwrap();
let ir = parse_ir_from_str(&raw).unwrap();
let files = ChannelsTarget.emit(&ir, &fixture_config());

View File

@@ -0,0 +1,187 @@
type "OrderOutput" {
struct {
field "id" {
primitive "integer"
}
field "user_id" {
primitive "integer"
}
field "total" {
primitive "integer"
}
}
}
type "echoInput" {
struct {
field "text" {
primitive "string"
}
}
}
type "echoOutput" {
struct {
field "message" {
primitive "string"
}
}
}
type "findUserInput" {
struct {
field "user_id" {
primitive "integer"
}
}
}
type "findUserOutput" {
struct {
field "user_id" {
primitive "integer"
}
field "name" {
primitive "string"
}
}
}
type "renameUserInput" {
struct {
field "user_id" {
primitive "integer"
}
field "name" {
primitive "string"
}
}
}
type "renameUserOutput" {
struct {
field "user_id" {
primitive "integer"
}
field "name" {
primitive "string"
}
}
}
type "updateProfileInput" {
struct {
field "user_id" {
primitive "integer"
}
field "name" {
primitive "string"
}
}
}
type "updateProfileOutput" {
struct {
field "ok" {
primitive "boolean"
}
}
}
type "userOrdersInput" {
struct {
field "user_id" {
primitive "integer"
}
}
}
type "userOrdersOutput" {
alias {
list {
ref "OrderOutput"
}
}
}
type "userProfileInput" {
struct {
field "user_id" {
primitive "integer"
}
}
}
type "userProfileOutput" {
struct {
field "user_id" {
primitive "integer"
}
field "name" {
primitive "string"
}
}
}
type "whoamiOutput" {
struct {
field "email" {
primitive "string"
}
field "authenticated" {
primitive "boolean"
}
}
}
function "echo" {
camel "echo"
has-input #true
input "echoInput"
output "echoOutput"
transport "http"
}
function "whoami" {
camel "whoami"
has-input #false
output "whoamiOutput"
transport "http"
}
function "user_profile" {
camel "userProfile"
has-input #true
input "userProfileInput"
output "userProfileOutput"
transport "http"
context "user"
}
function "user_orders" {
camel "userOrders"
has-input #true
input "userOrdersInput"
output "userOrdersOutput"
transport "http"
context "user"
}
function "update_profile" {
camel "updateProfile"
has-input #true
input "updateProfileInput"
output "updateProfileOutput"
transport "http"
affects "user"
}
function "find_user" {
camel "findUser"
has-input #true
input "findUserInput"
output "findUserOutput"
output-nullable #true
transport "http"
}
function "rename_user" {
camel "renameUser"
has-input #true
input "renameUserInput"
output "renameUserOutput"
transport "http"
merge "user"
}
context "user" {
function "user_profile"
function "user_orders"
param "user_id" {
type "integer"
required #true
shared-by "user_profile"
shared-by "user_orders"
}
}

View File

@@ -1,685 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "mizan Server Functions",
"description": "Auto-generated schema for mizan server functions",
"version": "1.0.0"
},
"paths": {
"/mizan/echo": {
"post": {
"summary": "Echoes the input back.",
"operationId": "echo",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/echoInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/echoOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
},
"/mizan/whoami": {
"post": {
"summary": "Returns the current user identity.",
"operationId": "whoami",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/whoamiOutput"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
},
"/mizan/user_profile": {
"post": {
"summary": "One half of the user context.",
"operationId": "userProfile",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/userProfileInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/userProfileOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": "user"
}
}
},
"/mizan/user_orders": {
"post": {
"summary": "Other half of the user context \u2014 same param, proves param elevation.",
"operationId": "userOrders",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/userOrdersInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/userOrdersOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": "user"
}
}
},
"/mizan/update_profile": {
"post": {
"summary": "Mutation declaring affects on the user context.",
"operationId": "updateProfile",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/updateProfileInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/updateProfileOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
},
"/mizan/find_user": {
"post": {
"summary": "Optional return \u2014 exercises Pydantic `T | None` schema introspection.",
"operationId": "findUser",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/findUserInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/findUserOutput"
},
{
"type": "null"
}
],
"title": "Response Finduser"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
},
"/mizan/rename_user": {
"post": {
"summary": "Merge target \u2014 kernel splices return value into the user context.",
"operationId": "renameUser",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/renameUserInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/renameUserOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
}
},
"components": {
"schemas": {
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"OrderOutput": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"user_id": {
"type": "integer",
"title": "User Id"
},
"total": {
"type": "integer",
"title": "Total"
}
},
"type": "object",
"required": [
"id",
"user_id",
"total"
],
"title": "OrderOutput"
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
},
"input": {
"title": "Input"
},
"ctx": {
"type": "object",
"title": "Context"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"echoInput": {
"properties": {
"text": {
"type": "string",
"title": "Text"
}
},
"type": "object",
"required": [
"text"
],
"title": "echoInput"
},
"echoOutput": {
"properties": {
"message": {
"type": "string",
"title": "Message"
}
},
"type": "object",
"required": [
"message"
],
"title": "echoOutput"
},
"findUserInput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
}
},
"type": "object",
"required": [
"user_id"
],
"title": "findUserInput"
},
"findUserOutput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"user_id",
"name"
],
"title": "findUserOutput"
},
"renameUserInput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"user_id",
"name"
],
"title": "renameUserInput"
},
"renameUserOutput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"user_id",
"name"
],
"title": "renameUserOutput"
},
"updateProfileInput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"user_id",
"name"
],
"title": "updateProfileInput"
},
"updateProfileOutput": {
"properties": {
"ok": {
"type": "boolean",
"title": "Ok"
}
},
"type": "object",
"required": [
"ok"
],
"title": "updateProfileOutput"
},
"userOrdersInput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
}
},
"type": "object",
"required": [
"user_id"
],
"title": "userOrdersInput"
},
"userOrdersOutput": {
"items": {
"$ref": "#/components/schemas/OrderOutput"
},
"type": "array",
"title": "userOrdersOutput"
},
"userProfileInput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
}
},
"type": "object",
"required": [
"user_id"
],
"title": "userProfileInput"
},
"userProfileOutput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"user_id",
"name"
],
"title": "userProfileOutput"
},
"whoamiOutput": {
"properties": {
"email": {
"type": "string",
"title": "Email"
},
"authenticated": {
"type": "boolean",
"title": "Authenticated"
}
},
"type": "object",
"required": [
"email",
"authenticated"
],
"title": "whoamiOutput"
}
}
},
"x-mizan-functions": [
{
"name": "echo",
"camelName": "echo",
"hasInput": true,
"inputType": "echoInput",
"outputType": "echoOutput",
"outputNullable": false,
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "whoami",
"camelName": "whoami",
"hasInput": false,
"inputType": null,
"outputType": "whoamiOutput",
"outputNullable": false,
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "user_profile",
"camelName": "userProfile",
"hasInput": true,
"inputType": "userProfileInput",
"outputType": "userProfileOutput",
"outputNullable": false,
"transport": "http",
"isContext": "user",
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "user_orders",
"camelName": "userOrders",
"hasInput": true,
"inputType": "userOrdersInput",
"outputType": "userOrdersOutput",
"outputNullable": false,
"transport": "http",
"isContext": "user",
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "update_profile",
"camelName": "updateProfile",
"hasInput": true,
"inputType": "updateProfileInput",
"outputType": "updateProfileOutput",
"outputNullable": false,
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null,
"affects": [
{
"type": "context",
"name": "user"
}
]
},
{
"name": "find_user",
"camelName": "findUser",
"hasInput": true,
"inputType": "findUserInput",
"outputType": "findUserOutput",
"outputNullable": true,
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "rename_user",
"camelName": "renameUser",
"hasInput": true,
"inputType": "renameUserInput",
"outputType": "renameUserOutput",
"outputNullable": false,
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null,
"merge": [
"user"
]
}
],
"x-mizan-contexts": {
"user": {
"functions": [
"user_profile",
"user_orders"
],
"params": {
"user_id": {
"type": "integer",
"sharedBy": [
"user_profile",
"user_orders"
],
"required": true
}
}
}
}
}

View File

@@ -6,21 +6,11 @@ from typing import Any, Literal
from pydantic import BaseModel
class HTTPValidationError(BaseModel):
detail: list[ValidationError] | None = None
class OrderOutput(BaseModel):
id: int
user_id: int
total: int
class ValidationError(BaseModel):
loc: list[Any]
msg: str
r#type: str
input: Any | None = None
ctx: dict[str, Any] | None = None
class EchoInput(BaseModel):
text: str

View File

@@ -0,0 +1,14 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callWhoami } from './functions/whoami'
export { callUpdateProfile } from './mutations/updateProfile'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
// Stage 2 framework adapter
export * from './react'

View File

@@ -0,0 +1,64 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -4,11 +4,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HTTPValidationError {
pub detail: Option<Vec<ValidationError>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderOutput {
pub id: i64,
@@ -16,16 +11,6 @@ pub struct OrderOutput {
pub total: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationError {
pub loc: Vec<serde_json::Value>,
pub msg: String,
#[serde(rename = "type")]
pub r#type: String,
pub input: Option<serde_json::Value>,
pub ctx: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EchoInput {
pub text: String,
@@ -75,9 +60,7 @@ pub struct UserOrdersInput {
pub user_id: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserOrdersOutput(pub Vec<OrderOutput>);
pub type UserOrdersOutput = Vec<OrderOutput>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfileInput {

View File

@@ -0,0 +1,18 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { userProfileOutput, userOrdersOutput } from '../types'
export interface UserContextData {
user_profile: userProfileOutput
user_orders: userOrdersOutput
}
export interface UserContextParams {
user_id: number
}
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
return mizanFetch('user', params)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'
export function callEcho(args: echoInput): Promise<echoOutput> {
return mizanCall('echo', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { findUserInput, findUserOutput } from '../types'
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
return mizanCall('find_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { renameUserInput, renameUserOutput } from '../types'
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
return mizanCall('rename_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'
export function callWhoami(): Promise<whoamiOutput> {
return mizanCall('whoami', {})
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { updateProfileInput, updateProfileOutput } from '../types'
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
return mizanCall('update_profile', args)
}

View File

@@ -0,0 +1,64 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -0,0 +1,18 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { userProfileOutput, userOrdersOutput } from '../types'
export interface UserContextData {
user_profile: userProfileOutput
user_orders: userOrdersOutput
}
export interface UserContextParams {
user_id: number
}
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
return mizanFetch('user', params)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'
export function callEcho(args: echoInput): Promise<echoOutput> {
return mizanCall('echo', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { findUserInput, findUserOutput } from '../types'
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
return mizanCall('find_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { renameUserInput, renameUserOutput } from '../types'
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
return mizanCall('rename_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'
export function callWhoami(): Promise<whoamiOutput> {
return mizanCall('whoami', {})
}

View File

@@ -0,0 +1,14 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callWhoami } from './functions/whoami'
export { callUpdateProfile } from './mutations/updateProfile'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
// Stage 2 framework adapter
export * from './svelte'

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { updateProfileInput, updateProfileOutput } from '../types'
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
return mizanCall('update_profile', args)
}

View File

@@ -0,0 +1,64 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -0,0 +1,18 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { userProfileOutput, userOrdersOutput } from '../types'
export interface UserContextData {
user_profile: userProfileOutput
user_orders: userOrdersOutput
}
export interface UserContextParams {
user_id: number
}
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
return mizanFetch('user', params)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'
export function callEcho(args: echoInput): Promise<echoOutput> {
return mizanCall('echo', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { findUserInput, findUserOutput } from '../types'
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
return mizanCall('find_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { renameUserInput, renameUserOutput } from '../types'
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
return mizanCall('rename_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'
export function callWhoami(): Promise<whoamiOutput> {
return mizanCall('whoami', {})
}

View File

@@ -0,0 +1,14 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callWhoami } from './functions/whoami'
export { callUpdateProfile } from './mutations/updateProfile'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
// Stage 2 framework adapter
export * from './vue'

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { updateProfileInput, updateProfileOutput } from '../types'
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
return mizanCall('update_profile', args)
}

View File

@@ -0,0 +1,64 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -0,0 +1,46 @@
type "ChatChannelParams" {
struct {
field "room_id" {
primitive "string"
}
}
}
type "ChatReactMessage" {
struct {
field "text" {
primitive "string"
}
}
}
type "ChatDjangoMessage" {
struct {
field "text" {
primitive "string"
}
field "from_user" {
primitive "string"
}
}
}
type "NotificationsDjangoMessage" {
struct {
field "body" {
primitive "string"
}
}
}
channel "chat" {
pascal-name "Chat"
params "ChatChannelParams"
react-message "ChatReactMessage"
django-message "ChatDjangoMessage"
}
channel "notifications" {
pascal-name "Notifications"
django-message "NotificationsDjangoMessage"
}

View File

@@ -1,55 +0,0 @@
{
"x-mizan-channels": [
{
"name": "chat",
"pascalName": "Chat",
"hasParams": true,
"hasReactMessage": true,
"hasDjangoMessage": true,
"paramsType": "ChatChannelParams",
"reactMessageType": "ChatReactMessage",
"djangoMessageType": "ChatDjangoMessage"
},
{
"name": "notifications",
"pascalName": "Notifications",
"hasParams": false,
"hasReactMessage": false,
"hasDjangoMessage": true,
"djangoMessageType": "NotificationsDjangoMessage"
}
],
"components": {
"schemas": {
"ChatChannelParams": {
"type": "object",
"properties": {
"room_id": { "type": "string" }
},
"required": ["room_id"]
},
"ChatReactMessage": {
"type": "object",
"properties": {
"text": { "type": "string" }
},
"required": ["text"]
},
"ChatDjangoMessage": {
"type": "object",
"properties": {
"text": { "type": "string" },
"from_user": { "type": "string" }
},
"required": ["text", "from_user"]
},
"NotificationsDjangoMessage": {
"type": "object",
"properties": {
"body": { "type": "string" }
},
"required": ["body"]
}
}
}
}

View File

@@ -1,20 +1,20 @@
//! IR deserialization tests against the AFI fixture schema.
//! IR deserialization tests against the AFI fixture (KDL).
//!
//! The fixture is captured from the FastAPI backend's `build_schema()`
//! The fixture is captured from `cores/mizan-python/src/mizan_core/ir.py::build_ir()`
//! against `tests/afi/fixture.py`. Each test exercises a different facet
//! of the IR — function set, per-function field decoding, context-param
//! elevation, and components.schemas presence — to confirm the typed
//! Rust structs match the JSON shape the backends emit.
//! elevation, and named-type presence — to confirm the typed Rust structs
//! match the KDL shape the backend emits.
use std::path::PathBuf;
use mizan_codegen::fetch::parse_ir_from_str;
use mizan_codegen::ir::{AffectKind, IsContext, Transport};
use mizan_codegen::ir::{AffectKind, IsContext, NamedType, Primitive, Transport};
fn load_fixture() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/afi_schema.json");
.join("tests/fixtures/afi_ir.kdl");
let raw = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
parse_ir_from_str(&raw).unwrap_or_else(|e| panic!("parse IR: {e}"))
@@ -26,7 +26,6 @@ fn afi_fixture_deserializes_function_set() {
let ir = load_fixture();
let names: Vec<&str> = ir.functions.iter().map(|f| f.name.as_str()).collect();
// Seven fixture functions per tests/afi/fixture.py.
assert_eq!(ir.functions.len(), 7, "expected 7 functions, got {}: {names:?}", ir.functions.len());
for expected in [
@@ -67,6 +66,10 @@ fn afi_fixture_function_field_decode() {
assert_eq!(update_profile.affects.len(), 1);
assert_eq!(update_profile.affects[0].kind, AffectKind::Context);
assert_eq!(update_profile.affects[0].name, "user");
// Mutation with `merge="user"`.
let rename_user = ir.functions.iter().find(|f| f.name == "rename_user").unwrap();
assert_eq!(rename_user.merge, vec!["user".to_string()]);
}
@@ -77,7 +80,7 @@ fn afi_fixture_context_param_elevation() {
// Both context functions share `user_id` as a required param.
let user_id = user.params.get("user_id").expect("user_id param");
assert_eq!(user_id.ty, "integer");
assert_eq!(user_id.ty, Primitive::Integer);
assert!(user_id.required, "user_id is required (declared by every fn in the group)");
assert!(user_id.shared_by.contains(&"user_profile".to_string()));
assert!(user_id.shared_by.contains(&"user_orders".to_string()));
@@ -85,19 +88,26 @@ fn afi_fixture_context_param_elevation() {
#[test]
fn afi_fixture_components_schemas_present() {
fn afi_fixture_named_types_present() {
let ir = load_fixture();
// Each fixture function pairs with an *Input/Output schema in components.
// Every IR function references its <camelName>Input / <camelName>Output
// type by name; the IR's `type` section must declare each as a named
// type (struct, alias to a list, etc.).
for expected in [
"echoInput", "echoOutput",
"whoamiOutput",
"userProfileInput", "userProfileOutput",
"userOrdersInput",
"updateProfileInput", "updateProfileOutput",
"findUserInput", "findUserOutput",
"renameUserInput", "renameUserOutput",
] {
assert!(
ir.components.schemas.contains_key(expected),
"missing schema {expected:?}",
);
let ty = ir.types.get(expected)
.unwrap_or_else(|| panic!("missing type {expected:?}"));
// Each named type is one of the four KDL shapes — sanity-check
// we round-tripped a non-trivial declaration.
match ty {
NamedType::Struct(_) | NamedType::List(_) | NamedType::Enum(_) | NamedType::Alias(_) => {}
}
}
}

View File

@@ -10,7 +10,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
fn load_ir() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json");
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_ir.kdl");
parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
}
@@ -29,7 +29,7 @@ fn fixture_config() -> Config {
fn read_baseline(rel: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/js_python")
.join("tests/fixtures/baselines/python")
.join(rel);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))

View File

@@ -9,7 +9,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
fn load_ir() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json");
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_ir.kdl");
parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
}
@@ -34,7 +34,7 @@ fn react_target_byte_match() {
let actual = &files[0].content;
let expected_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/js_react/react.tsx");
.join("tests/fixtures/baselines/react/react.tsx");
let expected = std::fs::read_to_string(&expected_path).unwrap();
if *actual != expected {

View File

@@ -14,7 +14,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
fn load_ir() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/afi_schema.json");
.join("tests/fixtures/afi_ir.kdl");
parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
}
@@ -35,7 +35,7 @@ fn fixture_config() -> Config {
fn read_baseline(rel: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/js_rust")
.join("tests/fixtures/baselines/rust")
.join(rel);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))

View File

@@ -1,7 +1,7 @@
//! Byte-equivalence tests for the deterministic Stage 1 files (contexts,
//! mutations, functions, index). Baseline output captured from the JS
//! codegen at `protocol/mizan-generate/generator/lib/stage1.mjs` against
//! the AFI fixture schema (`tests/fixtures/afi_schema.json`).
//! the AFI fixture schema (`tests/fixtures/afi_ir.kdl`).
//!
//! `types.ts` is NOT byte-checked here — the JS codegen routes type
//! emission through openapi-typescript while the Rust substrate emits
@@ -19,7 +19,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
fn load_ir() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/afi_schema.json");
.join("tests/fixtures/afi_ir.kdl");
let raw = std::fs::read_to_string(&path).unwrap();
parse_ir_from_str(&raw).unwrap()
}
@@ -44,7 +44,7 @@ fn emit_index(files: &[EmittedFile]) -> BTreeMap<PathBuf, &str> {
fn read_baseline(rel: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/js_stage1")
.join("tests/fixtures/baselines/stage1")
.join(rel);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))

View File

@@ -10,7 +10,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
fn load_ir() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json");
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_ir.kdl");
parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
}
@@ -53,7 +53,7 @@ fn vue_target_byte_match() {
let ir = load_ir();
let files = VueAdapter.emit(&ir, &fixture_config("vue"));
assert_eq!(files.len(), 1);
assert_byte_equal(&files[0].content, "tests/fixtures/js_vue/vue.ts", "vue.ts");
assert_byte_equal(&files[0].content, "tests/fixtures/baselines/vue/vue.ts", "vue.ts");
}
@@ -62,5 +62,5 @@ fn svelte_target_byte_match() {
let ir = load_ir();
let files = SvelteAdapter.emit(&ir, &fixture_config("svelte"));
assert_eq!(files.len(), 1);
assert_byte_equal(&files[0].content, "tests/fixtures/js_svelte/svelte.ts", "svelte.ts");
assert_byte_equal(&files[0].content, "tests/fixtures/baselines/svelte/svelte.ts", "svelte.ts");
}

View File

@@ -1,7 +1,7 @@
"""
Codegen entrypoint for the AFI fixture.
`mizan_fastapi.cli` imports a module and runs `build_schema()` from a
`mizan_fastapi.ir` imports a module and runs `build_ir()` from a
populated registry. The fixture's `register_fixture()` is a function
call, not an import side effect; this thin wrapper invokes it on
import so the CLI works without modifying fixture.py's semantics.

View File

@@ -1,4 +1,4 @@
"""FastAPI test app that registers the AFI fixture and exposes build_schema()."""
"""FastAPI test app that registers the AFI fixture."""
from __future__ import annotations

View File

@@ -1,79 +0,0 @@
"""
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

@@ -1,19 +1,18 @@
"""
AFI conformance — same @client fixture, same schema, both adapters.
AFI conformance — same @client fixture, same Mizan IR (KDL), 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).
Gates that mizan-django and mizan-fastapi emit byte-equivalent IR
for the same registered functions. If this passes, the codegen
produces identical TypeScript output regardless of backend
(codegen is deterministic over IR 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.
Substrate-level gate, not e2e. Catches adapter symmetry problems —
type-introspection divergence, ordering non-determinism — without
running a real frontend or backend.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
@@ -21,68 +20,53 @@ 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."""
def _fetch_django_ir() -> str:
"""Spawn Django's management command and parse stdout as KDL."""
result = subprocess.run(
[sys.executable, str(DJANGO_MANAGE), "export_mizan_schema", "--indent", "0"],
[sys.executable, str(DJANGO_MANAGE), "export_mizan_ir"],
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)
pytest.fail(
f"export_mizan_ir failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}",
)
return result.stdout
def _fetch_fastapi_schema() -> dict:
"""Build the FastAPI app inline (fresh registry) and call build_schema()."""
def _fetch_fastapi_ir() -> str:
"""Build the FastAPI app inline (fresh registry) and call build_ir()."""
sys.path.insert(0, str(HERE))
try:
from mizan_core.registry import clear_registry
from mizan_fastapi import build_schema
from mizan_core.ir import build_ir
from fastapi_app import make_app
clear_registry()
make_app()
return build_schema()
return build_ir()
finally:
sys.path.remove(str(HERE))
# ─── Tests ──────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def schemas() -> tuple[dict, dict]:
return _fetch_django_schema(), _fetch_fastapi_schema()
def ir_pair() -> tuple[str, str]:
return _fetch_django_ir(), _fetch_fastapi_ir()
class AFISubsetTests:
"""The AFI surface — x-mizan-functions and x-mizan-contexts — must match."""
class IRParityTests:
"""The Mizan IR is the contract — both backends must emit the same KDL."""
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)
def test_ir_bytes_match(self, ir_pair):
django, fastapi = ir_pair
assert django == fastapi, (
"Django and FastAPI emit divergent Mizan IR for the same "
"registered functions. Substrate gate is now red."
)

View File

@@ -4,11 +4,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HTTPValidationError {
pub detail: Option<Vec<ValidationError>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderOutput {
pub id: i64,
@@ -16,16 +11,6 @@ pub struct OrderOutput {
pub total: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationError {
pub loc: Vec<serde_json::Value>,
pub msg: String,
#[serde(rename = "type")]
pub r#type: String,
pub input: Option<serde_json::Value>,
pub ctx: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EchoInput {
pub text: String,
@@ -75,9 +60,7 @@ pub struct UserOrdersInput {
pub user_id: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserOrdersOutput(pub Vec<OrderOutput>);
pub type UserOrdersOutput = Vec<OrderOutput>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfileInput {

View File

@@ -61,7 +61,7 @@ async fn main() -> ExitCode {
match fetch_user_context(&client, &UserContextParams { user_id: 5 }).await {
Ok(out) => println!(
"fetch_user_context(5) -> user_profile={{ user_id:{}, name:{:?} }} user_orders.len={}",
out.user_profile.user_id, out.user_profile.name, out.user_orders.0.len(),
out.user_profile.user_id, out.user_profile.name, out.user_orders.len(),
),
Err(e) => { eprintln!("fetch_user_context ERR {e}"); failures += 1; }
}