diff --git a/backends/mizan-django/src/mizan/export/__init__.py b/backends/mizan-django/src/mizan/export/__init__.py index 7c2d7ed..cb9ec2c 100644 --- a/backends/mizan-django/src/mizan/export/__init__.py +++ b/backends/mizan-django/src/mizan/export/__init__.py @@ -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) diff --git a/backends/mizan-django/src/mizan/management/commands/export_mizan_ir.py b/backends/mizan-django/src/mizan/management/commands/export_mizan_ir.py new file mode 100644 index 0000000..c62a37d --- /dev/null +++ b/backends/mizan-django/src/mizan/management/commands/export_mizan_ir.py @@ -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="") diff --git a/backends/mizan-django/src/mizan/management/commands/export_mizan_schema.py b/backends/mizan-django/src/mizan/management/commands/export_mizan_schema.py deleted file mode 100644 index e755b4b..0000000 --- a/backends/mizan-django/src/mizan/management/commands/export_mizan_schema.py +++ /dev/null @@ -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) diff --git a/backends/mizan-django/src/mizan/urls.py b/backends/mizan-django/src/mizan/urls.py index fad95b9..d2317a3 100644 --- a/backends/mizan-django/src/mizan/urls.py +++ b/backends/mizan-django/src/mizan/urls.py @@ -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 diff --git a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py index 1f4f894..13c2bc4 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py @@ -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", diff --git a/backends/mizan-fastapi/src/mizan_fastapi/cli.py b/backends/mizan-fastapi/src/mizan_fastapi/cli.py deleted file mode 100644 index 4834a2f..0000000 --- a/backends/mizan-fastapi/src/mizan_fastapi/cli.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Schema-export CLI for codegen consumption. - -Usage: - python -m mizan_fastapi.cli - -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 ", 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()) diff --git a/backends/mizan-fastapi/src/mizan_fastapi/ir.py b/backends/mizan-fastapi/src/mizan_fastapi/ir.py new file mode 100644 index 0000000..cec73e8 --- /dev/null +++ b/backends/mizan-fastapi/src/mizan_fastapi/ir.py @@ -0,0 +1,39 @@ +""" +Mizan IR (KDL) export CLI for FastAPI backends. + +Usage: + python -m mizan_fastapi.ir + +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 ", 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()) diff --git a/backends/mizan-fastapi/src/mizan_fastapi/schema.py b/backends/mizan-fastapi/src/mizan_fastapi/schema.py deleted file mode 100644 index 34b480d..0000000 --- a/backends/mizan-fastapi/src/mizan_fastapi/schema.py +++ /dev/null @@ -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 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 diff --git a/cores/mizan-python/src/mizan_core/ir.py b/cores/mizan-python/src/mizan_core/ir.py new file mode 100644 index 0000000..f07c723 --- /dev/null +++ b/cores/mizan-python/src/mizan_core/ir.py @@ -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 "" { + struct { + field "" required=#true|#false default= { + primitive "integer|number|boolean|string" + | ref "" + | list { } + | optional { } + | enum "" "" ... + } + ... + } + | list { } + | enum "" "" ... + | alias { } + } + + function "" { + camel "" + has-input #true|#false + input "" // omitted if has-input=#false + output "" + output-nullable #true|#false // omitted when #false (default) + transport "http"|"websocket"|"both" + context "" // omitted unless context-grouped + affects "" // 0..N occurrences + merge "" // 0..N occurrences + is-form #true // omitted when #false (default) + form-name "" + form-role "" + } + + context "" { + function "" + ... + param "" { + type "integer|number|boolean|string" + required #true|#false + shared-by "" + ... + } + } + + channel "" { + pascal-name "" + params "" // omitted if no params + react-message "" // omitted if no react message + django-message "" // 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 { }`. + 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 { } }` 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. `Output = list[]`.""" + __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 (`Output = list[T]` / + `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): + # `` or `Optional[]` — 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 `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 `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"])) diff --git a/protocol/mizan-codegen/Cargo.lock b/protocol/mizan-codegen/Cargo.lock index c742feb..1c93f28 100644 --- a/protocol/mizan-codegen/Cargo.lock +++ b/protocol/mizan-codegen/Cargo.lock @@ -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" diff --git a/protocol/mizan-codegen/Cargo.toml b/protocol/mizan-codegen/Cargo.toml index 2283755..686b644 100644 --- a/protocol/mizan-codegen/Cargo.toml +++ b/protocol/mizan-codegen/Cargo.toml @@ -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" diff --git a/protocol/mizan-codegen/src/emit/channels.rs b/protocol/mizan-codegen/src/emit/channels.rs index 531125e..8b84a3d 100644 --- a/protocol/mizan-codegen/src/emit/channels.rs +++ b/protocol/mizan-codegen/src/emit/channels.rs @@ -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 = 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".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".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, + types: &IndexMap, ) -> String { let mut blocks: Vec = 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::>().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::>() + .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::>() - .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::>() + .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".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", } } diff --git a/protocol/mizan-codegen/src/emit/python.rs b/protocol/mizan-codegen/src/emit/python.rs index 77dfd41..b15e432 100644 --- a/protocol/mizan-codegen/src/emit/python.rs +++ b/protocol/mizan-codegen/src/emit/python.rs @@ -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 { - 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::>() .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::>() - .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::>().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 { - 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::>() .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::>().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::>() + .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::>() - .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") } }) diff --git a/protocol/mizan-codegen/src/emit/rust.rs b/protocol/mizan-codegen/src/emit/rust.rs index b1959dc..ba1a32e 100644 --- a/protocol/mizan-codegen/src/emit/rust.rs +++ b/protocol/mizan-codegen/src/emit/rust.rs @@ -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 = 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, - data_fields: Vec, - params: Vec, + data_fields: Vec, + params: Vec, } @@ -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 = ctx_fns.iter() + let data_fields: Vec = 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 = ctx_meta.params.iter() + let params: Vec = 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)>, - depth: usize, + hoisted: Vec<(String, Vec)>, enum_name: Option, } -fn emit_types_rs(schemas: &IndexMap) -> String { - let mut ctx = EnumCtx { hoisted: Vec::new(), depth: 0, enum_name: None }; +fn emit_types_rs(types: &IndexMap) -> 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::>() .join("\n"); @@ -344,12 +343,11 @@ fn emit_types_rs(schemas: &IndexMap) -> 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, + 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(), } } diff --git a/protocol/mizan-codegen/src/emit/stage1.rs b/protocol/mizan-codegen/src/emit/stage1.rs index a7b954e..c23bfbe 100644 --- a/protocol/mizan-codegen/src/emit/stage1.rs +++ b/protocol/mizan-codegen/src/emit/stage1.rs @@ -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 { let mut out: Vec = 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 = 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 { +fn emit_types(types: &IndexMap) -> 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::>() - .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::>().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 { - 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::>() .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::>() + .join(" | "), + TypeShape::Union(branches) => branches.iter() .map(ts_type_expression) .collect::>() - .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::>() - .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".to_string(), - Some("null") => "null".to_string(), - _ => "unknown".to_string(), - }; - - if schema.nullable { - format!("{base} | null") - } else { - base + .join(" | "), } } diff --git a/protocol/mizan-codegen/src/fetch.rs b/protocol/mizan-codegen/src/fetch.rs index b63b7da..f9bf950 100644 --- a/protocol/mizan-codegen/src/fetch.rs +++ b/protocol/mizan-codegen/src/fetch.rs @@ -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 ` -//! - 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 ` +//! - 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 { @@ -28,7 +25,7 @@ pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result { )); }; - 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 { 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 { 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 { - 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 { + 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 { - 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 { let p = p.into(); if p.is_absolute() { diff --git a/protocol/mizan-codegen/src/ir.rs b/protocol/mizan-codegen/src/ir.rs index 1187aa9..544594e 100644 --- a/protocol/mizan-codegen/src/ir.rs +++ b/protocol/mizan-codegen/src/ir.rs @@ -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, pub functions: Vec, - - #[serde(rename = "x-mizan-contexts", default)] pub contexts: IndexMap, - - /// Django-only channel registrations. FastAPI backends emit an empty list. - #[serde(rename = "x-mizan-channels", default)] pub channels: Vec, - - #[serde(default)] - pub components: Components, } -#[derive(Debug, Deserialize, Clone)] -pub struct MizanChannel { +// ─── Type system ──────────────────────────────────────────────────────────── + + +#[derive(Debug, Clone)] +pub enum NamedType { + Struct(Vec), + List(TypeShape), + Enum(Vec), + 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, - #[serde(rename = "reactMessageType", default)] - pub react_message_type: Option, - #[serde(rename = "djangoMessageType", default)] - pub django_message_type: Option, + pub required: bool, + pub default: Option, + pub shape: TypeShape, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Clone)] +pub enum TypeShape { + Primitive(Primitive), + Ref(String), + List(Box), + Optional(Box), + Enum(Vec), + /// Multi-arm union with two or more non-null branches. + Union(Vec), +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Primitive { Integer, Number, Boolean, String } + + +impl Primitive { + fn parse(s: &str) -> Result { + 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, - - #[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, - - #[serde(rename = "formRole", default)] pub form_role: Option, - - #[serde(default)] pub affects: Vec, - - /// 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, } -#[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 { + 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(de: D) -> Result - 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, } -#[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, - #[serde(default)] pub params: IndexMap, } -#[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, } -#[derive(Debug, Deserialize, Default)] -pub struct Components { - #[serde(default)] - pub schemas: IndexMap, +// ─── Channels (Django-only) ───────────────────────────────────────────────── + + +#[derive(Debug, Clone)] +pub struct MizanChannel { + pub name: String, + pub pascal_name: String, + pub params_type: Option, + pub react_message_type: Option, + pub django_message_type: Option, } -/// 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, - - #[serde(rename = "$ref", default)] - pub r#ref: Option, - - #[serde(rename = "enum", default)] - pub r#enum: Option>, - - #[serde(rename = "anyOf", default)] - pub any_of: Option>, - - #[serde(default)] - pub nullable: bool, - - #[serde(default)] - pub items: Option>, - - #[serde(default)] - pub properties: Option>, - - #[serde(default)] - pub required: Vec, - - #[serde(rename = "additionalProperties", default)] - pub additional_properties: Option, - - /// 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(flatten)] - pub extra: BTreeMap, +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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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> = 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 { + 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 = Vec::new(); + let mut merge: Vec = 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 { + 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 { + 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 { + first_string_arg(node).context(format!("{label}: requires a string argument")) +} + + +fn bool_arg(node: &KdlNode, label: &str) -> Result { + 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 { + node.entry(key).and_then(|e| e.value().as_bool()) +} + + +fn parse_string_args(node: &KdlNode) -> Vec { + 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 { + parse_ir(source) +} diff --git a/protocol/mizan-codegen/src/main.rs b/protocol/mizan-codegen/src/main.rs index 1551115..a435052 100644 --- a/protocol/mizan-codegen/src/main.rs +++ b/protocol/mizan-codegen/src/main.rs @@ -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 diff --git a/protocol/mizan-codegen/tests/channels_smoke.rs b/protocol/mizan-codegen/tests/channels_smoke.rs index 48eacf9..0eb1941 100644 --- a/protocol/mizan-codegen/tests/channels_smoke.rs +++ b/protocol/mizan-codegen/tests/channels_smoke.rs @@ -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()); diff --git a/protocol/mizan-codegen/tests/fixtures/afi_ir.kdl b/protocol/mizan-codegen/tests/fixtures/afi_ir.kdl new file mode 100644 index 0000000..4a29621 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/afi_ir.kdl @@ -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" + } +} diff --git a/protocol/mizan-codegen/tests/fixtures/afi_schema.json b/protocol/mizan-codegen/tests/fixtures/afi_schema.json deleted file mode 100644 index e7bdba7..0000000 --- a/protocol/mizan-codegen/tests/fixtures/afi_schema.json +++ /dev/null @@ -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 - } - } - } - } -} \ No newline at end of file diff --git a/protocol/mizan-codegen/tests/fixtures/js_python/__init__.py b/protocol/mizan-codegen/tests/fixtures/baselines/python/__init__.py similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_python/__init__.py rename to protocol/mizan-codegen/tests/fixtures/baselines/python/__init__.py diff --git a/protocol/mizan-codegen/tests/fixtures/js_python/client.py b/protocol/mizan-codegen/tests/fixtures/baselines/python/client.py similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_python/client.py rename to protocol/mizan-codegen/tests/fixtures/baselines/python/client.py diff --git a/protocol/mizan-codegen/tests/fixtures/js_python/types.py b/protocol/mizan-codegen/tests/fixtures/baselines/python/types.py similarity index 80% rename from protocol/mizan-codegen/tests/fixtures/js_python/types.py rename to protocol/mizan-codegen/tests/fixtures/baselines/python/types.py index 996be73..0b555ec 100644 --- a/protocol/mizan-codegen/tests/fixtures/js_python/types.py +++ b/protocol/mizan-codegen/tests/fixtures/baselines/python/types.py @@ -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 diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/contexts/user.ts b/protocol/mizan-codegen/tests/fixtures/baselines/react/contexts/user.ts similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_stage1/contexts/user.ts rename to protocol/mizan-codegen/tests/fixtures/baselines/react/contexts/user.ts diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/echo.ts b/protocol/mizan-codegen/tests/fixtures/baselines/react/functions/echo.ts similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_stage1/functions/echo.ts rename to protocol/mizan-codegen/tests/fixtures/baselines/react/functions/echo.ts diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/findUser.ts b/protocol/mizan-codegen/tests/fixtures/baselines/react/functions/findUser.ts similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_stage1/functions/findUser.ts rename to protocol/mizan-codegen/tests/fixtures/baselines/react/functions/findUser.ts diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/renameUser.ts b/protocol/mizan-codegen/tests/fixtures/baselines/react/functions/renameUser.ts similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_stage1/functions/renameUser.ts rename to protocol/mizan-codegen/tests/fixtures/baselines/react/functions/renameUser.ts diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/whoami.ts b/protocol/mizan-codegen/tests/fixtures/baselines/react/functions/whoami.ts similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_stage1/functions/whoami.ts rename to protocol/mizan-codegen/tests/fixtures/baselines/react/functions/whoami.ts diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/react/index.ts b/protocol/mizan-codegen/tests/fixtures/baselines/react/index.ts new file mode 100644 index 0000000..ae9cb24 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/react/index.ts @@ -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' diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/mutations/updateProfile.ts b/protocol/mizan-codegen/tests/fixtures/baselines/react/mutations/updateProfile.ts similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_stage1/mutations/updateProfile.ts rename to protocol/mizan-codegen/tests/fixtures/baselines/react/mutations/updateProfile.ts diff --git a/protocol/mizan-codegen/tests/fixtures/js_react/react.tsx b/protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_react/react.tsx rename to protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/react/types.ts b/protocol/mizan-codegen/tests/fixtures/baselines/react/types.ts new file mode 100644 index 0000000..6ed4cde --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/react/types.ts @@ -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 +} + diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/Cargo.toml b/protocol/mizan-codegen/tests/fixtures/baselines/rust/Cargo.toml similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/Cargo.toml rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/Cargo.toml diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/contexts/mod.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/contexts/mod.rs similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/contexts/mod.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/contexts/mod.rs diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/contexts/user.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/contexts/user.rs similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/contexts/user.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/contexts/user.rs diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/echo.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/echo.rs similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/echo.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/echo.rs diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/find_user.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/find_user.rs similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/find_user.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/find_user.rs diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/mod.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/mod.rs similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/mod.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/mod.rs diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/rename_user.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/rename_user.rs similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/rename_user.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/rename_user.rs diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/whoami.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/whoami.rs similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/whoami.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/functions/whoami.rs diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/lib.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/lib.rs similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/lib.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/lib.rs diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/mutations/mod.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/mutations/mod.rs similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/mutations/mod.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/mutations/mod.rs diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/mutations/update_profile.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/mutations/update_profile.rs similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/mutations/update_profile.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/mutations/update_profile.rs diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/types.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/types.rs similarity index 75% rename from protocol/mizan-codegen/tests/fixtures/js_rust/src/types.rs rename to protocol/mizan-codegen/tests/fixtures/baselines/rust/src/types.rs index 7067fdb..5eec511 100644 --- a/protocol/mizan-codegen/tests/fixtures/js_rust/src/types.rs +++ b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/types.rs @@ -4,11 +4,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HTTPValidationError { - pub detail: Option>, -} - #[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, - pub msg: String, - #[serde(rename = "type")] - pub r#type: String, - pub input: Option, - pub ctx: Option, -} - #[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); +pub type UserOrdersOutput = Vec; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserProfileInput { diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/contexts/user.ts b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/contexts/user.ts new file mode 100644 index 0000000..24ababc --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/contexts/user.ts @@ -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 { + return mizanFetch('user', params) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/echo.ts b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/echo.ts new file mode 100644 index 0000000..5374efe --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/echo.ts @@ -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 { + return mizanCall('echo', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/findUser.ts b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/findUser.ts new file mode 100644 index 0000000..a04ecab --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/findUser.ts @@ -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 { + return mizanCall('find_user', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/renameUser.ts b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/renameUser.ts new file mode 100644 index 0000000..3117ea6 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/renameUser.ts @@ -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 { + return mizanCall('rename_user', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/whoami.ts b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/whoami.ts new file mode 100644 index 0000000..9e2e136 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/whoami.ts @@ -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 { + return mizanCall('whoami', {}) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/index.ts b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/index.ts similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_stage1/index.ts rename to protocol/mizan-codegen/tests/fixtures/baselines/stage1/index.ts diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/mutations/updateProfile.ts b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/mutations/updateProfile.ts new file mode 100644 index 0000000..0ed94d9 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/mutations/updateProfile.ts @@ -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 { + return mizanCall('update_profile', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/types.ts b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/types.ts new file mode 100644 index 0000000..6ed4cde --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/types.ts @@ -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 +} + diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/contexts/user.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/contexts/user.ts new file mode 100644 index 0000000..24ababc --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/contexts/user.ts @@ -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 { + return mizanFetch('user', params) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/echo.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/echo.ts new file mode 100644 index 0000000..5374efe --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/echo.ts @@ -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 { + return mizanCall('echo', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/findUser.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/findUser.ts new file mode 100644 index 0000000..a04ecab --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/findUser.ts @@ -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 { + return mizanCall('find_user', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/renameUser.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/renameUser.ts new file mode 100644 index 0000000..3117ea6 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/renameUser.ts @@ -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 { + return mizanCall('rename_user', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/whoami.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/whoami.ts new file mode 100644 index 0000000..9e2e136 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/whoami.ts @@ -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 { + return mizanCall('whoami', {}) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/index.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/index.ts new file mode 100644 index 0000000..9a814ce --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/index.ts @@ -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' diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/mutations/updateProfile.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/mutations/updateProfile.ts new file mode 100644 index 0000000..0ed94d9 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/mutations/updateProfile.ts @@ -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 { + return mizanCall('update_profile', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_svelte/svelte.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/svelte.ts similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_svelte/svelte.ts rename to protocol/mizan-codegen/tests/fixtures/baselines/svelte/svelte.ts diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/types.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/types.ts new file mode 100644 index 0000000..6ed4cde --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/types.ts @@ -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 +} + diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/contexts/user.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/contexts/user.ts new file mode 100644 index 0000000..24ababc --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/contexts/user.ts @@ -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 { + return mizanFetch('user', params) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/echo.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/echo.ts new file mode 100644 index 0000000..5374efe --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/echo.ts @@ -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 { + return mizanCall('echo', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/findUser.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/findUser.ts new file mode 100644 index 0000000..a04ecab --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/findUser.ts @@ -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 { + return mizanCall('find_user', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/renameUser.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/renameUser.ts new file mode 100644 index 0000000..3117ea6 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/renameUser.ts @@ -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 { + return mizanCall('rename_user', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/whoami.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/whoami.ts new file mode 100644 index 0000000..9e2e136 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/whoami.ts @@ -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 { + return mizanCall('whoami', {}) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/index.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/index.ts new file mode 100644 index 0000000..7c0f81b --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/index.ts @@ -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' diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/mutations/updateProfile.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/mutations/updateProfile.ts new file mode 100644 index 0000000..0ed94d9 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/mutations/updateProfile.ts @@ -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 { + return mizanCall('update_profile', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/types.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/types.ts new file mode 100644 index 0000000..6ed4cde --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/types.ts @@ -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 +} + diff --git a/protocol/mizan-codegen/tests/fixtures/js_vue/vue.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/vue.ts similarity index 100% rename from protocol/mizan-codegen/tests/fixtures/js_vue/vue.ts rename to protocol/mizan-codegen/tests/fixtures/baselines/vue/vue.ts diff --git a/protocol/mizan-codegen/tests/fixtures/channels_ir.kdl b/protocol/mizan-codegen/tests/fixtures/channels_ir.kdl new file mode 100644 index 0000000..ce9ec03 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/channels_ir.kdl @@ -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" +} diff --git a/protocol/mizan-codegen/tests/fixtures/channels_schema.json b/protocol/mizan-codegen/tests/fixtures/channels_schema.json deleted file mode 100644 index 618365e..0000000 --- a/protocol/mizan-codegen/tests/fixtures/channels_schema.json +++ /dev/null @@ -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"] - } - } - } -} diff --git a/protocol/mizan-codegen/tests/ir_deserialization.rs b/protocol/mizan-codegen/tests/ir_deserialization.rs index d8dfc84..f5d5b4a 100644 --- a/protocol/mizan-codegen/tests/ir_deserialization.rs +++ b/protocol/mizan-codegen/tests/ir_deserialization.rs @@ -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 Input / 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(_) => {} + } } } diff --git a/protocol/mizan-codegen/tests/python_parity.rs b/protocol/mizan-codegen/tests/python_parity.rs index 68261a9..f6aa93c 100644 --- a/protocol/mizan-codegen/tests/python_parity.rs +++ b/protocol/mizan-codegen/tests/python_parity.rs @@ -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())) diff --git a/protocol/mizan-codegen/tests/react_parity.rs b/protocol/mizan-codegen/tests/react_parity.rs index 464698d..ba9584f 100644 --- a/protocol/mizan-codegen/tests/react_parity.rs +++ b/protocol/mizan-codegen/tests/react_parity.rs @@ -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 { diff --git a/protocol/mizan-codegen/tests/rust_parity.rs b/protocol/mizan-codegen/tests/rust_parity.rs index 9a25f30..3835196 100644 --- a/protocol/mizan-codegen/tests/rust_parity.rs +++ b/protocol/mizan-codegen/tests/rust_parity.rs @@ -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())) diff --git a/protocol/mizan-codegen/tests/stage1_parity.rs b/protocol/mizan-codegen/tests/stage1_parity.rs index 5861dc8..e179e2b 100644 --- a/protocol/mizan-codegen/tests/stage1_parity.rs +++ b/protocol/mizan-codegen/tests/stage1_parity.rs @@ -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 { 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())) diff --git a/protocol/mizan-codegen/tests/vue_svelte_parity.rs b/protocol/mizan-codegen/tests/vue_svelte_parity.rs index c60d328..99de377 100644 --- a/protocol/mizan-codegen/tests/vue_svelte_parity.rs +++ b/protocol/mizan-codegen/tests/vue_svelte_parity.rs @@ -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"); } diff --git a/protocol/mizan-generate/bin/mizan-generate-linux-x64 b/protocol/mizan-generate/bin/mizan-generate-linux-x64 index 9478707..5b1ffc4 100755 Binary files a/protocol/mizan-generate/bin/mizan-generate-linux-x64 and b/protocol/mizan-generate/bin/mizan-generate-linux-x64 differ diff --git a/tests/afi/afi_codegen_app.py b/tests/afi/afi_codegen_app.py index a57bcf3..f05a23c 100644 --- a/tests/afi/afi_codegen_app.py +++ b/tests/afi/afi_codegen_app.py @@ -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. diff --git a/tests/afi/fastapi_app.py b/tests/afi/fastapi_app.py index c171630..25cb3dd 100644 --- a/tests/afi/fastapi_app.py +++ b/tests/afi/fastapi_app.py @@ -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 diff --git a/tests/afi/schema_normalizer.py b/tests/afi/schema_normalizer.py deleted file mode 100644 index 3592c28..0000000 --- a/tests/afi/schema_normalizer.py +++ /dev/null @@ -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} diff --git a/tests/afi/test_codegen_parity.py b/tests/afi/test_codegen_parity.py index 5509573..52c519d 100644 --- a/tests/afi/test_codegen_parity.py +++ b/tests/afi/test_codegen_parity.py @@ -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." + ) diff --git a/tests/rust/fixture_client/src/types.rs b/tests/rust/fixture_client/src/types.rs index 7067fdb..5eec511 100644 --- a/tests/rust/fixture_client/src/types.rs +++ b/tests/rust/fixture_client/src/types.rs @@ -4,11 +4,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HTTPValidationError { - pub detail: Option>, -} - #[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, - pub msg: String, - #[serde(rename = "type")] - pub r#type: String, - pub input: Option, - pub ctx: Option, -} - #[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); +pub type UserOrdersOutput = Vec; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserProfileInput { diff --git a/tests/rust/src/drive_emitted.rs b/tests/rust/src/drive_emitted.rs index d221cd0..ae80117 100644 --- a/tests/rust/src/drive_emitted.rs +++ b/tests/rust/src/drive_emitted.rs @@ -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; } }