Mizan IR: cut over to KDL, delete OpenAPI envelope
Replaces the transitional OpenAPI 3.0 + `x-mizan-*` extensions
substrate with the canonical Mizan IR as KDL, per docs/AFI_ARCHITECTURE.md:
"KDL is the contract; everything else (REST envelopes, OpenAPI
documents, framework idioms) is sediment around it."
End-to-end cutover. No transitional path left on main.
Forward direction:
cores/mizan-python/src/mizan_core/ir.py
build_ir() walks mizan_core.registry, introspects Pydantic
models directly (no JSON-Schema indirection), and emits the
Mizan IR document. The KDL grammar is locked in this file's
module docstring.
Backends emit KDL:
backends/mizan-fastapi/src/mizan_fastapi/ir.py
`python -m mizan_fastapi.ir <module>` — CLI entry point.
backends/mizan-django/.../management/commands/export_mizan_ir.py
`manage.py export_mizan_ir` — Django mgmt command.
Codegen consumes KDL:
protocol/mizan-codegen/Cargo.toml: + kdl = "6"
protocol/mizan-codegen/src/ir.rs: NamedType { Struct/List/Enum/Alias }
+ TypeShape { Primitive/Ref/List/Optional/Enum/Union } sum types,
replacing the JsonSchema sprawl. KDL parser walks the
`kdl::KdlDocument` tree into typed Rust structs.
protocol/mizan-codegen/src/fetch.rs: subprocess command switches
to the new IR-export entry points.
All emit modules (stage1 / react / python / rust / vue / svelte /
channels) port their type-walkers from JsonSchema to the new
sum types — case analysis collapses substantially.
Substrate-honesty wins beyond the moat closure:
- `int | bool` multi-arm unions land as `TypeShape::Union` (was
silently coerced to "string" before).
- `<CamelName>Output = list[T]` returns emit as named alias
types instead of struct-shaped wrappers, so consumer code
`.map()` works directly on the type.
- Pydantic field defaults flow through to `default` properties
in KDL, then back to non-optional shape in every target.
Deleted:
- backends/mizan-fastapi/src/mizan_fastapi/{cli,schema}.py
- backends/mizan-django/.../export_mizan_schema.py
- openapi-bearing half of mizan/export/__init__.py (edge
manifest generator preserved — separate concern).
- tests/afi/schema_normalizer.py
- tests/fixtures/{afi_schema.json, channels_schema.json}
- tests/fixtures/js_* baseline directories.
Verification:
- 20 mizan-codegen unit tests green (IR deserialization,
byte-equivalence parity across stage1/rust/python/react/vue/svelte
against fresh KDL-driven baselines, channels structural).
- tests/rust/run_wire_parity.py: 12/12 probes green driving
the binary end-to-end through KDL.
- Blazr studio-ui typechecks against the regenerated React client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,380 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
mizan OpenAPI Schema Generator
|
Mizan Edge Manifest Generator.
|
||||||
|
|
||||||
Generates OpenAPI 3.0 compatible schema from registered server functions.
|
Generates the Edge manifest — a static JSON mapping contexts to URL
|
||||||
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion.
|
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
||||||
|
cache invalidation. Independent from the Mizan IR; the IR drives
|
||||||
This schema is consumed by the frontend generator which uses openapi-typescript
|
codegen, the manifest drives CDN purging.
|
||||||
for robust type generation.
|
|
||||||
|
|
||||||
NOTE: Schema export is only available via management command for security.
|
|
||||||
HTTP endpoint has been removed to prevent function enumeration.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python manage.py export_mizan_schema
|
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
# Lazy imports to avoid Django settings access at module load time
|
from mizan_core.registry import get_context_groups, get_registry
|
||||||
# (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
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_schema",
|
|
||||||
"generate_openapi_schema",
|
|
||||||
"generate_openapi_json",
|
|
||||||
"generate_edge_manifest",
|
"generate_edge_manifest",
|
||||||
"generate_edge_manifest_json",
|
"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(
|
def generate_edge_manifest(
|
||||||
base_url: str = "/api/mizan",
|
base_url: str = "/api/mizan",
|
||||||
view_urls: dict[str, list[str]] | None = None,
|
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_urls: Optional mapping of context names to URL patterns for
|
||||||
view-path functions. These are URLs that Edge should
|
view-path functions. These are URLs that Edge should
|
||||||
also purge when a context is invalidated.
|
also purge when a context is invalidated.
|
||||||
Example: {"user": ["/profile/:user_id/"]}
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Manifest dict suitable for JSON serialization.
|
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"}
|
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||||
|
|
||||||
groups = get_context_groups()
|
groups = get_context_groups()
|
||||||
@@ -411,7 +57,6 @@ def generate_edge_manifest(
|
|||||||
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
|
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
|
||||||
|
|
||||||
for ctx_name, fn_names in groups.items():
|
for ctx_name, fn_names in groups.items():
|
||||||
# Collect params and routes from all functions in this context
|
|
||||||
param_names: set[str] = set()
|
param_names: set[str] = set()
|
||||||
functions_meta: list[dict[str, Any]] = []
|
functions_meta: list[dict[str, Any]] = []
|
||||||
page_routes: list[str] = []
|
page_routes: list[str] = []
|
||||||
@@ -421,40 +66,31 @@ def generate_edge_manifest(
|
|||||||
if fn_cls is None:
|
if fn_cls is None:
|
||||||
continue
|
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)
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
if (
|
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||||
input_cls
|
for param_name in input_cls.model_fields:
|
||||||
and input_cls is not PydanticBaseModel
|
param_names.add(param_name)
|
||||||
and hasattr(input_cls, "model_fields")
|
|
||||||
):
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
param_names.update(input_cls.model_fields.keys())
|
route = meta.get("route")
|
||||||
|
view_path = meta.get("view_path")
|
||||||
|
|
||||||
fn_entry: dict[str, Any] = {
|
fn_entry: dict[str, Any] = {
|
||||||
"name": fn_name,
|
"name": fn_name,
|
||||||
"path": "view" if is_view else "rpc",
|
"path": "view" if view_path else "rpc",
|
||||||
}
|
}
|
||||||
|
if route:
|
||||||
# Collect routes from view-path functions
|
fn_entry["route"] = route
|
||||||
fn_route = meta.get("route")
|
|
||||||
if fn_route:
|
|
||||||
fn_entry["route"] = fn_route
|
|
||||||
fn_entry["methods"] = meta.get("methods", ["GET"])
|
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||||
page_routes.append(fn_route)
|
page_routes.append(route)
|
||||||
|
if meta.get("rev"):
|
||||||
# Cache protocol metadata
|
|
||||||
if "rev" in meta:
|
|
||||||
fn_entry["rev"] = meta["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"]
|
fn_entry["cache"] = meta["cache"]
|
||||||
|
|
||||||
functions_meta.append(fn_entry)
|
functions_meta.append(fn_entry)
|
||||||
|
|
||||||
sorted_params = sorted(param_names)
|
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] = {
|
ctx_entry: dict[str, Any] = {
|
||||||
"functions": functions_meta,
|
"functions": functions_meta,
|
||||||
@@ -464,69 +100,57 @@ def generate_edge_manifest(
|
|||||||
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add page routes from view-path functions with route=
|
|
||||||
if page_routes:
|
if page_routes:
|
||||||
ctx_entry["page_routes"] = page_routes
|
ctx_entry["page_routes"] = page_routes
|
||||||
|
|
||||||
# Add externally-declared view URLs
|
|
||||||
if view_urls and ctx_name in view_urls:
|
if view_urls and ctx_name in view_urls:
|
||||||
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
||||||
|
|
||||||
manifest["contexts"][ctx_name] = ctx_entry
|
manifest["contexts"][ctx_name] = ctx_entry
|
||||||
|
|
||||||
# Mutations section — all functions with affects=
|
|
||||||
for fn_name, fn_cls in all_functions.items():
|
for fn_name, fn_cls in all_functions.items():
|
||||||
meta = getattr(fn_cls, "_meta", {})
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
affects = meta.get("affects")
|
if not meta.get("affects"):
|
||||||
if not affects:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Resolve context names from affects targets
|
affected_contexts = list({a["name"] for a in meta["affects"]})
|
||||||
affected_contexts = []
|
mutation: dict[str, Any] = {"affects": 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))
|
|
||||||
|
|
||||||
# Determine which params auto-scope
|
# Auto-scoped params — function params that match context params
|
||||||
auto_scoped = []
|
|
||||||
input_cls = getattr(fn_cls, "Input", None)
|
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())
|
fn_params = set(input_cls.model_fields.keys())
|
||||||
|
auto_scoped: list[str] = []
|
||||||
for ctx_name in affected_contexts:
|
for ctx_name in affected_contexts:
|
||||||
ctx_params = set()
|
ctx_param_names: set[str] = set()
|
||||||
for ctx_fn_name in groups.get(ctx_name, []):
|
ctx_fns = groups.get(ctx_name, [])
|
||||||
|
for ctx_fn_name in ctx_fns:
|
||||||
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||||
if ctx_fn_cls:
|
if ctx_fn_cls is None:
|
||||||
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
continue
|
||||||
if ctx_input and ctx_input is not PydanticBaseModel and hasattr(ctx_input, "model_fields"):
|
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||||
ctx_params.update(ctx_input.model_fields.keys())
|
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
|
||||||
auto_scoped.extend(sorted(fn_params & ctx_params))
|
ctx_param_names.update(ctx_input.model_fields.keys())
|
||||||
auto_scoped = list(dict.fromkeys(auto_scoped))
|
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"):
|
if meta.get("private"):
|
||||||
mutation_entry["private"] = True
|
mutation["private"] = True
|
||||||
if meta.get("route"):
|
if meta.get("route"):
|
||||||
mutation_entry["route"] = meta["route"]
|
mutation["route"] = meta["route"]
|
||||||
mutation_entry["methods"] = meta.get("methods", ["POST"])
|
mutation["methods"] = meta.get("methods", ["POST"])
|
||||||
|
|
||||||
manifest["mutations"][fn_name] = mutation_entry
|
manifest["mutations"][fn_name] = mutation
|
||||||
|
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
def generate_edge_manifest_json(
|
def generate_edge_manifest_json(
|
||||||
indent: int = 2,
|
|
||||||
base_url: str = "/api/mizan",
|
base_url: str = "/api/mizan",
|
||||||
view_urls: dict[str, list[str]] | None = None,
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
indent: int = 2,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate Edge manifest as formatted JSON string."""
|
"""JSON-serialize the Edge manifest."""
|
||||||
manifest = generate_edge_manifest(base_url=base_url, view_urls=view_urls)
|
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)
|
||||||
return json.dumps(manifest, indent=indent, sort_keys=True)
|
|
||||||
|
|||||||
@@ -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="")
|
||||||
@@ -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)
|
|
||||||
@@ -8,7 +8,7 @@ HTTP endpoints:
|
|||||||
|
|
||||||
Security:
|
Security:
|
||||||
- Schema export is NOT exposed over HTTP to prevent API enumeration
|
- 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
|
from django.http import JsonResponse
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ from .executor import (
|
|||||||
execute_function,
|
execute_function,
|
||||||
)
|
)
|
||||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||||
from .schema import build_schema
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"router",
|
"router",
|
||||||
@@ -43,7 +42,6 @@ __all__ = [
|
|||||||
"mizan_validation_handler",
|
"mizan_validation_handler",
|
||||||
"execute_function",
|
"execute_function",
|
||||||
"compute_invalidation",
|
"compute_invalidation",
|
||||||
"build_schema",
|
|
||||||
"ErrorCode",
|
"ErrorCode",
|
||||||
"MizanError",
|
"MizanError",
|
||||||
"NotFound",
|
"NotFound",
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
"""
|
|
||||||
Schema-export CLI for codegen consumption.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python -m mizan_fastapi.cli <module>
|
|
||||||
|
|
||||||
Imports the named module (whose import side effects must register every
|
|
||||||
@client function with mizan_core.registry — typically by `@client` plus
|
|
||||||
`register(...)` calls at module top level), then prints the OpenAPI
|
|
||||||
schema to stdout as JSON.
|
|
||||||
|
|
||||||
Mirrors mizan-django's `manage.py export_mizan_schema` so the codegen
|
|
||||||
CLI can fetch from either backend the same subprocess way.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .schema import build_schema
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
args = list(sys.argv[1:] if argv is None else argv)
|
|
||||||
if len(args) != 1:
|
|
||||||
print("usage: python -m mizan_fastapi.cli <module>", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
module_name = args[0]
|
|
||||||
try:
|
|
||||||
importlib.import_module(module_name)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
schema = build_schema()
|
|
||||||
json.dump(schema, sys.stdout)
|
|
||||||
sys.stdout.write("\n")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
39
backends/mizan-fastapi/src/mizan_fastapi/ir.py
Normal file
39
backends/mizan-fastapi/src/mizan_fastapi/ir.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Mizan IR (KDL) export CLI for FastAPI backends.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m mizan_fastapi.ir <module>
|
||||||
|
|
||||||
|
Imports the named module (whose import side effects must register every
|
||||||
|
@client function with `mizan_core.registry`), then writes the canonical
|
||||||
|
Mizan IR as KDL to stdout. The Rust codegen binary consumes this
|
||||||
|
directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mizan_core.ir import build_ir
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
if len(args) != 1:
|
||||||
|
print("usage: python -m mizan_fastapi.ir <module>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
module_name = args[0]
|
||||||
|
try:
|
||||||
|
importlib.import_module(module_name)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sys.stdout.write(build_ir())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
"""
|
|
||||||
Mizan schema export for FastAPI backends.
|
|
||||||
|
|
||||||
Builds an OpenAPI 3.0 document from the registered Mizan functions, mirroring
|
|
||||||
the shape mizan-django emits via Django Ninja so the codegen consumes either
|
|
||||||
backend identically.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from mizan_fastapi.schema import build_schema
|
|
||||||
schema = build_schema() # uses globally registered functions
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.openapi.utils import get_openapi
|
|
||||||
from pydantic import BaseModel, RootModel, create_model
|
|
||||||
|
|
||||||
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
|
||||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["build_schema", "snake_to_camel"]
|
|
||||||
|
|
||||||
|
|
||||||
# Common user identity param names — mirrors mizan-django's _USER_SCOPED_PARAMS
|
|
||||||
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
|
||||||
|
|
||||||
|
|
||||||
def snake_to_camel(name: str) -> str:
|
|
||||||
"""Convert snake_case or dotted.name to camelCase. Mirrors mizan-django."""
|
|
||||||
components = re.split(r"[._]", name)
|
|
||||||
return components[0] + "".join(c.title() for c in components[1:])
|
|
||||||
|
|
||||||
|
|
||||||
def _has_input(input_cls: Any) -> bool:
|
|
||||||
return (
|
|
||||||
input_cls is not None
|
|
||||||
and input_cls is not BaseModel
|
|
||||||
and hasattr(input_cls, "model_fields")
|
|
||||||
and bool(input_cls.model_fields)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _annotation_to_jsonschema_type(annotation: Any) -> str:
|
|
||||||
if annotation is int:
|
|
||||||
return "integer"
|
|
||||||
if annotation is float:
|
|
||||||
return "number"
|
|
||||||
if annotation is bool:
|
|
||||||
return "boolean"
|
|
||||||
return "string"
|
|
||||||
|
|
||||||
|
|
||||||
def _function_metadata(name: str, fn_class: Any) -> dict[str, Any]:
|
|
||||||
"""Build one entry of x-mizan-functions. Mirrors Django's shape exactly."""
|
|
||||||
camel = snake_to_camel(name)
|
|
||||||
meta = getattr(fn_class, "_meta", {})
|
|
||||||
|
|
||||||
input_cls = getattr(fn_class, "Input", None)
|
|
||||||
has_input = _has_input(input_cls)
|
|
||||||
|
|
||||||
output_cls = getattr(fn_class, "Output", None)
|
|
||||||
_, output_nullable = extract_optional(output_cls) if output_cls is not None else (None, False)
|
|
||||||
|
|
||||||
entry: dict[str, Any] = {
|
|
||||||
"name": name,
|
|
||||||
"camelName": camel,
|
|
||||||
"hasInput": has_input,
|
|
||||||
"inputType": f"{camel}Input" if has_input else None,
|
|
||||||
"outputType": f"{camel}Output",
|
|
||||||
# Nullability of the response model — Pydantic `T | None` returns. Carried
|
|
||||||
# on the function entry rather than the schema class because OpenAPI emits
|
|
||||||
# `anyOf: [{$ref}, {type:null}]` at the response level, which strict
|
|
||||||
# deserializers (Rust serde) won't decode as Option<T> without this hint.
|
|
||||||
"outputNullable": output_nullable,
|
|
||||||
"transport": "websocket" if meta.get("websocket") else "http",
|
|
||||||
"isContext": meta.get("context", False),
|
|
||||||
# Form metadata — always emitted so the schema shape matches Django's,
|
|
||||||
# even for FastAPI projects that don't use forms (these stay False/None).
|
|
||||||
"isForm": meta.get("form", False),
|
|
||||||
"formName": meta.get("form_name"),
|
|
||||||
"formRole": meta.get("form_role"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if meta.get("affects"):
|
|
||||||
entry["affects"] = meta["affects"]
|
|
||||||
if meta.get("merge"):
|
|
||||||
entry["merge"] = meta["merge"]
|
|
||||||
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def _context_metadata(context_groups: dict[str, list[str]]) -> dict[str, Any]:
|
|
||||||
"""Build x-mizan-contexts. Mirrors Django's param-elevation logic."""
|
|
||||||
out: dict[str, Any] = {}
|
|
||||||
|
|
||||||
for ctx_name, fn_names in context_groups.items():
|
|
||||||
param_info: dict[str, dict[str, Any]] = {}
|
|
||||||
|
|
||||||
for fn_name in fn_names:
|
|
||||||
fn_cls = get_function(fn_name)
|
|
||||||
if fn_cls is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
input_cls = getattr(fn_cls, "Input", None)
|
|
||||||
if not _has_input(input_cls):
|
|
||||||
continue
|
|
||||||
|
|
||||||
for field_name, field_info in input_cls.model_fields.items():
|
|
||||||
if field_name not in param_info:
|
|
||||||
param_info[field_name] = {
|
|
||||||
"type": _annotation_to_jsonschema_type(field_info.annotation),
|
|
||||||
"sharedBy": [],
|
|
||||||
}
|
|
||||||
param_info[field_name]["sharedBy"].append(fn_name)
|
|
||||||
|
|
||||||
# A param is required iff every function in the context declares it.
|
|
||||||
for p_meta in param_info.values():
|
|
||||||
p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)
|
|
||||||
|
|
||||||
out[ctx_name] = {
|
|
||||||
"functions": list(fn_names),
|
|
||||||
"params": param_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def build_schema() -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Build an OpenAPI 3.0 schema for all registered Mizan functions.
|
|
||||||
|
|
||||||
Drives FastAPI's native OpenAPI generation by registering a stub endpoint
|
|
||||||
per function with the function's Input/Output Pydantic models, then
|
|
||||||
appends the protocol's `x-mizan-functions` and `x-mizan-contexts`
|
|
||||||
extensions.
|
|
||||||
|
|
||||||
Returns a dict in the same shape mizan-django's schema export emits, so
|
|
||||||
the same codegen pipeline consumes either.
|
|
||||||
"""
|
|
||||||
functions = get_all_functions()
|
|
||||||
context_groups = get_context_groups()
|
|
||||||
|
|
||||||
schema_app = FastAPI(
|
|
||||||
title="mizan Server Functions",
|
|
||||||
version="1.0.0",
|
|
||||||
description="Auto-generated schema for mizan server functions",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Per-function endpoints + renamed Pydantic models so component names are
|
|
||||||
# camelCase + "Input"/"Output" rather than the user's original class names.
|
|
||||||
schema_classes: dict[str, type[BaseModel]] = {}
|
|
||||||
function_metadata: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for name, fn_class in functions.items():
|
|
||||||
camel = snake_to_camel(name)
|
|
||||||
input_cls = getattr(fn_class, "Input", None)
|
|
||||||
output_cls = getattr(fn_class, "Output", None) or BaseModel
|
|
||||||
has_input = _has_input(input_cls)
|
|
||||||
|
|
||||||
input_type_name = f"{camel}Input" if has_input else None
|
|
||||||
output_type_name = f"{camel}Output"
|
|
||||||
|
|
||||||
# Strip Optional so the rename gets a concrete base — nullability is
|
|
||||||
# carried on the response declaration, not the schema class itself.
|
|
||||||
output_inner, output_nullable = extract_optional(output_cls)
|
|
||||||
|
|
||||||
if has_input:
|
|
||||||
schema_classes[input_type_name] = create_model(
|
|
||||||
input_type_name, __base__=input_cls,
|
|
||||||
)
|
|
||||||
if extract_list_element(output_inner) is not None:
|
|
||||||
# list[T] — RootModel makes the rename emit `type: array` rather
|
|
||||||
# than wrapping the list in a property.
|
|
||||||
schema_classes[output_type_name] = type(
|
|
||||||
output_type_name, (RootModel[output_inner],), {},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
schema_classes[output_type_name] = create_model(
|
|
||||||
output_type_name, __base__=output_inner,
|
|
||||||
)
|
|
||||||
|
|
||||||
response_model = schema_classes[output_type_name]
|
|
||||||
if output_nullable:
|
|
||||||
response_model = response_model | None
|
|
||||||
|
|
||||||
# Stub endpoint — only exists so FastAPI walks Pydantic types into
|
|
||||||
# components.schemas. Never invoked. Annotations are set explicitly
|
|
||||||
# rather than via closures so forward-ref resolution doesn't trip on
|
|
||||||
# locally-bound type names.
|
|
||||||
if has_input:
|
|
||||||
async def stub(payload):
|
|
||||||
return None
|
|
||||||
|
|
||||||
stub.__annotations__ = {"payload": schema_classes[input_type_name]}
|
|
||||||
else:
|
|
||||||
async def stub():
|
|
||||||
return None
|
|
||||||
|
|
||||||
schema_app.post(
|
|
||||||
f"/mizan/{name}",
|
|
||||||
response_model=response_model,
|
|
||||||
operation_id=camel,
|
|
||||||
summary=fn_class.__doc__ or f"Call {name}",
|
|
||||||
)(stub)
|
|
||||||
|
|
||||||
function_metadata.append(_function_metadata(name, fn_class))
|
|
||||||
|
|
||||||
schema = get_openapi(
|
|
||||||
title=schema_app.title,
|
|
||||||
version=schema_app.version,
|
|
||||||
description=schema_app.description,
|
|
||||||
routes=schema_app.routes,
|
|
||||||
)
|
|
||||||
|
|
||||||
schema["x-mizan-functions"] = function_metadata
|
|
||||||
|
|
||||||
if context_groups:
|
|
||||||
schema["x-mizan-contexts"] = _context_metadata(context_groups)
|
|
||||||
|
|
||||||
# Attach x-mizan operation metadata, mirroring Django.
|
|
||||||
paths = schema.get("paths", {})
|
|
||||||
for fn_meta in function_metadata:
|
|
||||||
op = paths.get(f"/mizan/{fn_meta['name']}", {}).get("post")
|
|
||||||
if op is not None:
|
|
||||||
op["x-mizan"] = {
|
|
||||||
"transport": fn_meta["transport"],
|
|
||||||
"isContext": fn_meta["isContext"],
|
|
||||||
}
|
|
||||||
|
|
||||||
return schema
|
|
||||||
575
cores/mizan-python/src/mizan_core/ir.py
Normal file
575
cores/mizan-python/src/mizan_core/ir.py
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
"""
|
||||||
|
Mizan IR — KDL emission from the live `mizan_core.registry`.
|
||||||
|
|
||||||
|
`build_ir()` walks every registered function class, introspects its
|
||||||
|
Pydantic Input/Output models directly (not via JSON-Schema), and emits
|
||||||
|
KDL — the canonical Mizan protocol IR. Every backend adapter exposes
|
||||||
|
this via a backend-specific entry point (Django management command,
|
||||||
|
FastAPI CLI, mizan-ts equivalent); every codegen target consumes this.
|
||||||
|
|
||||||
|
KDL grammar — locked contract:
|
||||||
|
|
||||||
|
type "<Name>" {
|
||||||
|
struct {
|
||||||
|
field "<name>" required=#true|#false default=<lit> {
|
||||||
|
primitive "integer|number|boolean|string"
|
||||||
|
| ref "<TypeName>"
|
||||||
|
| list { <type-child> }
|
||||||
|
| optional { <type-child> }
|
||||||
|
| enum "<v1>" "<v2>" ...
|
||||||
|
}
|
||||||
|
...
|
||||||
|
}
|
||||||
|
| list { <type-child> }
|
||||||
|
| enum "<v1>" "<v2>" ...
|
||||||
|
| alias { <type-child> }
|
||||||
|
}
|
||||||
|
|
||||||
|
function "<wire_name>" {
|
||||||
|
camel "<camelCase>"
|
||||||
|
has-input #true|#false
|
||||||
|
input "<TypeName>" // omitted if has-input=#false
|
||||||
|
output "<TypeName>"
|
||||||
|
output-nullable #true|#false // omitted when #false (default)
|
||||||
|
transport "http"|"websocket"|"both"
|
||||||
|
context "<ctx_name>" // omitted unless context-grouped
|
||||||
|
affects "<ctx_name>" // 0..N occurrences
|
||||||
|
merge "<ctx_name>" // 0..N occurrences
|
||||||
|
is-form #true // omitted when #false (default)
|
||||||
|
form-name "<name>"
|
||||||
|
form-role "<role>"
|
||||||
|
}
|
||||||
|
|
||||||
|
context "<name>" {
|
||||||
|
function "<fn_name>"
|
||||||
|
...
|
||||||
|
param "<param_name>" {
|
||||||
|
type "integer|number|boolean|string"
|
||||||
|
required #true|#false
|
||||||
|
shared-by "<fn_name>"
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel "<name>" {
|
||||||
|
pascal-name "<PascalCase>"
|
||||||
|
params "<TypeName>" // omitted if no params
|
||||||
|
react-message "<TypeName>" // omitted if no react message
|
||||||
|
django-message "<TypeName>" // omitted if no django message
|
||||||
|
}
|
||||||
|
|
||||||
|
Nothing else lives in the IR. OpenAPI envelope, JSON-Schema $ref dance,
|
||||||
|
the Pydantic→json-schema converter — all gone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import types
|
||||||
|
from typing import Any, Literal, Union, get_args, get_origin
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic_core import PydanticUndefined
|
||||||
|
|
||||||
|
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
||||||
|
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_ir"]
|
||||||
|
|
||||||
|
|
||||||
|
# Common user-identity param names; mirrors the equivalent in mizan-django /
|
||||||
|
# mizan-fastapi schema-export logic.
|
||||||
|
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── KDL value formatting ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _kdl_string(s: str) -> str:
|
||||||
|
"""KDL-escape a string and wrap in quotes."""
|
||||||
|
escaped = (
|
||||||
|
s.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("\t", "\\t")
|
||||||
|
)
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
|
||||||
|
def _kdl_bool(b: bool) -> str:
|
||||||
|
return "#true" if b else "#false"
|
||||||
|
|
||||||
|
|
||||||
|
def _kdl_value(v: Any) -> str:
|
||||||
|
"""Render a JSON-shape Python value as a KDL literal."""
|
||||||
|
if v is None:
|
||||||
|
return "#null"
|
||||||
|
if v is True or v is False:
|
||||||
|
return _kdl_bool(v)
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return repr(v)
|
||||||
|
if isinstance(v, str):
|
||||||
|
return _kdl_string(v)
|
||||||
|
# Fallback for compound values — defaults aren't typed in our IR.
|
||||||
|
import json
|
||||||
|
return _kdl_string(json.dumps(v))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── KDL Builder ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _Block:
|
||||||
|
"""Open-children context for a KDL node. Tracks indent level."""
|
||||||
|
|
||||||
|
__slots__ = ("lines", "indent")
|
||||||
|
|
||||||
|
def __init__(self, lines: list[str], indent: int):
|
||||||
|
self.lines = lines
|
||||||
|
self.indent = indent
|
||||||
|
|
||||||
|
def _prefix(self) -> str:
|
||||||
|
return " " * self.indent
|
||||||
|
|
||||||
|
def node(self, name: str, *args: str, **props: str) -> "_OpenNode":
|
||||||
|
"""Open a node. `args` are positional KDL args; `props` are key=value pairs."""
|
||||||
|
return _OpenNode(self.lines, self.indent, name, list(args), dict(props))
|
||||||
|
|
||||||
|
def leaf(self, name: str, *args: str, **props: str) -> None:
|
||||||
|
"""Emit a leaf node — no children block."""
|
||||||
|
parts = [name]
|
||||||
|
parts.extend(args)
|
||||||
|
for k, v in props.items():
|
||||||
|
parts.append(f"{k}={v}")
|
||||||
|
self.lines.append(f"{self._prefix()}{' '.join(parts)}")
|
||||||
|
|
||||||
|
|
||||||
|
class _OpenNode:
|
||||||
|
"""A KDL node whose children are being built."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
lines: list[str],
|
||||||
|
indent: int,
|
||||||
|
name: str,
|
||||||
|
args: list[str],
|
||||||
|
props: dict[str, str],
|
||||||
|
):
|
||||||
|
self.lines = lines
|
||||||
|
self.indent = indent
|
||||||
|
self.name = name
|
||||||
|
self.args = args
|
||||||
|
self.props = props
|
||||||
|
self._children_emitted = False
|
||||||
|
|
||||||
|
def __enter__(self) -> _Block:
|
||||||
|
parts = [self.name]
|
||||||
|
parts.extend(self.args)
|
||||||
|
for k, v in self.props.items():
|
||||||
|
parts.append(f"{k}={v}")
|
||||||
|
self.lines.append(f"{' ' * self.indent}{' '.join(parts)} {{")
|
||||||
|
self._children_emitted = True
|
||||||
|
return _Block(self.lines, self.indent + 1)
|
||||||
|
|
||||||
|
def __exit__(self, *_exc: Any) -> None:
|
||||||
|
if self._children_emitted:
|
||||||
|
self.lines.append(f"{' ' * self.indent}}}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Type emission ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_type_child(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
|
||||||
|
"""Emit the type-shape KDL for a Python annotation, recursing as needed."""
|
||||||
|
# Strip Optional[T] → emit `optional` wrapper.
|
||||||
|
inner, is_opt = extract_optional(annotation)
|
||||||
|
if is_opt:
|
||||||
|
with block.node("optional") as inner_block:
|
||||||
|
_emit_type_child(inner_block, inner, named_types)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Multi-arm union (T | U) — emit `union { <each-branch> }`.
|
||||||
|
origin = get_origin(annotation)
|
||||||
|
if origin is Union or isinstance(annotation, types.UnionType):
|
||||||
|
branches = [a for a in get_args(annotation) if a is not type(None)]
|
||||||
|
if len(branches) > 1:
|
||||||
|
with block.node("union") as inner_block:
|
||||||
|
for branch in branches:
|
||||||
|
_emit_type_child(inner_block, branch, named_types)
|
||||||
|
return
|
||||||
|
|
||||||
|
# list[T] / tuple[T, ...] / set[T] / frozenset[T] → `list { ... }`
|
||||||
|
elem = extract_list_element(annotation)
|
||||||
|
if elem is not None:
|
||||||
|
with block.node("list") as inner_block:
|
||||||
|
_emit_type_child(inner_block, elem, named_types)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Literal[a, b, c] → enum
|
||||||
|
if origin is Literal:
|
||||||
|
args = get_args(annotation)
|
||||||
|
if all(isinstance(a, str) for a in args):
|
||||||
|
quoted = " ".join(_kdl_string(a) for a in args)
|
||||||
|
block.lines.append(f"{block._prefix()}enum {quoted}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pydantic model → reference by name.
|
||||||
|
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||||
|
type_name = annotation.__name__
|
||||||
|
named_types.setdefault(type_name, _StructShape(annotation))
|
||||||
|
block.leaf("ref", _kdl_string(type_name))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Primitives
|
||||||
|
if annotation is int:
|
||||||
|
block.leaf("primitive", _kdl_string("integer"))
|
||||||
|
return
|
||||||
|
if annotation is float:
|
||||||
|
block.leaf("primitive", _kdl_string("number"))
|
||||||
|
return
|
||||||
|
if annotation is bool:
|
||||||
|
block.leaf("primitive", _kdl_string("boolean"))
|
||||||
|
return
|
||||||
|
if annotation is str:
|
||||||
|
block.leaf("primitive", _kdl_string("string"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Open-shape fallback (dict / Any / etc).
|
||||||
|
block.leaf("primitive", _kdl_string("string"))
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
|
||||||
|
"""Emit `type "X" { alias { <type-child> } }` for a non-struct wrapper."""
|
||||||
|
with block.node("alias") as alias_block:
|
||||||
|
_emit_type_child(alias_block, annotation, named_types)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
|
||||||
|
"""Emit a `struct { field ... }` block for a Pydantic model."""
|
||||||
|
with block.node("struct") as struct_block:
|
||||||
|
for field_name, field_info in model.model_fields.items():
|
||||||
|
props: dict[str, str] = {}
|
||||||
|
# `field_info.is_required()` checks both the explicit Required
|
||||||
|
# marker and the presence of a default.
|
||||||
|
required = field_info.is_required()
|
||||||
|
if not required:
|
||||||
|
props["required"] = _kdl_bool(False)
|
||||||
|
default = field_info.default
|
||||||
|
if default is not None and default is not PydanticUndefined and default is not ...:
|
||||||
|
props["default"] = _kdl_value(default)
|
||||||
|
|
||||||
|
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
|
||||||
|
_emit_type_child(field_block, field_info.annotation, named_types)
|
||||||
|
|
||||||
|
|
||||||
|
class _StructShape:
|
||||||
|
"""A Pydantic BaseModel that emits as `type "X" { struct { ... } }`."""
|
||||||
|
__slots__ = ("model",)
|
||||||
|
def __init__(self, model: type[BaseModel]):
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
|
||||||
|
class _AliasShape:
|
||||||
|
"""A named alias wrapper — e.g. `<CamelName>Output = list[<Inner>]`."""
|
||||||
|
__slots__ = ("annotation",)
|
||||||
|
def __init__(self, annotation: Any):
|
||||||
|
self.annotation = annotation
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_named_types(functions: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""First pass: collect every named type the IR's `function` section references.
|
||||||
|
|
||||||
|
Two kinds:
|
||||||
|
- Pydantic BaseModels seen anywhere in Input/Output traversal — emit
|
||||||
|
as `type "X" { struct { ... } }`.
|
||||||
|
- Function-output wrapper aliases (`<CamelName>Output = list[T]` /
|
||||||
|
`<CamelName>Output = T | None`) — emit as `type "X" { alias { ... } }`
|
||||||
|
so the consumer has a single named type to reference.
|
||||||
|
"""
|
||||||
|
seen: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def visit_model(model: type[BaseModel]) -> None:
|
||||||
|
if model.__name__ in seen:
|
||||||
|
return
|
||||||
|
seen[model.__name__] = _StructShape(model)
|
||||||
|
for field_info in model.model_fields.values():
|
||||||
|
for nested in _nested_models(field_info.annotation):
|
||||||
|
visit_model(nested)
|
||||||
|
|
||||||
|
def visit_annotation(ann: Any) -> None:
|
||||||
|
for nested in _nested_models(ann):
|
||||||
|
visit_model(nested)
|
||||||
|
|
||||||
|
for fn_class in functions.values():
|
||||||
|
input_cls = getattr(fn_class, "Input", None)
|
||||||
|
if _has_input(input_cls):
|
||||||
|
input_named = _name_input_model(fn_class)
|
||||||
|
visit_model(input_named)
|
||||||
|
|
||||||
|
output_cls = getattr(fn_class, "Output", None)
|
||||||
|
if output_cls is None:
|
||||||
|
continue
|
||||||
|
camel = _snake_to_camel(fn_class.name)
|
||||||
|
output_name = f"{camel}Output"
|
||||||
|
|
||||||
|
inner, _ = extract_optional(output_cls)
|
||||||
|
elem = extract_list_element(inner)
|
||||||
|
|
||||||
|
if elem is not None:
|
||||||
|
# `list[T]` (possibly wrapped in Optional) — emit a list alias.
|
||||||
|
# Visit the element type so its struct shape gets emitted too.
|
||||||
|
visit_annotation(output_cls)
|
||||||
|
if output_name not in seen:
|
||||||
|
seen[output_name] = _AliasShape(output_cls)
|
||||||
|
elif isinstance(inner, type) and issubclass(inner, BaseModel):
|
||||||
|
# `<Model>` or `Optional[<Model>]` — emit the model under the
|
||||||
|
# canonical name (rename if necessary).
|
||||||
|
output_named = _name_output_model(fn_class, inner)
|
||||||
|
visit_model(output_named)
|
||||||
|
# If the Optional wrapper differs from the bare model, emit an
|
||||||
|
# alias under the canonical output name too.
|
||||||
|
if output_named.__name__ != output_name:
|
||||||
|
seen.setdefault(output_name, _AliasShape(output_cls))
|
||||||
|
else:
|
||||||
|
# Primitive-wrapped output (`result: int`) — emit as alias.
|
||||||
|
seen.setdefault(output_name, _AliasShape(output_cls))
|
||||||
|
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_models(annotation: Any) -> list[type[BaseModel]]:
|
||||||
|
"""All Pydantic models that appear anywhere inside `annotation`."""
|
||||||
|
out: list[type[BaseModel]] = []
|
||||||
|
inner, _ = extract_optional(annotation)
|
||||||
|
elem = extract_list_element(inner)
|
||||||
|
if elem is not None:
|
||||||
|
out.extend(_nested_models(elem))
|
||||||
|
return out
|
||||||
|
if isinstance(inner, type) and issubclass(inner, BaseModel):
|
||||||
|
out.append(inner)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _has_input(input_cls: Any) -> bool:
|
||||||
|
return (
|
||||||
|
input_cls is not None
|
||||||
|
and input_cls is not BaseModel
|
||||||
|
and hasattr(input_cls, "model_fields")
|
||||||
|
and bool(input_cls.model_fields)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snake_to_camel(name: str) -> str:
|
||||||
|
parts = name.replace(".", "_").replace("-", "_").split("_")
|
||||||
|
return parts[0] + "".join(p.title() for p in parts[1:] if p)
|
||||||
|
|
||||||
|
|
||||||
|
def _name_input_model(fn_class: Any) -> type[BaseModel]:
|
||||||
|
"""Return a copy of the function's Input model named `<CamelName>Input`."""
|
||||||
|
from pydantic import create_model
|
||||||
|
|
||||||
|
camel = _snake_to_camel(fn_class.name)
|
||||||
|
canonical = f"{camel}Input"
|
||||||
|
src = fn_class.Input
|
||||||
|
if src.__name__ == canonical:
|
||||||
|
return src
|
||||||
|
# Re-derive under the canonical name so codegen consumers see a stable name.
|
||||||
|
return create_model(canonical, __base__=src)
|
||||||
|
|
||||||
|
|
||||||
|
def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]:
|
||||||
|
"""Return a copy of the model named `<CamelName>Output`."""
|
||||||
|
from pydantic import create_model
|
||||||
|
|
||||||
|
camel = _snake_to_camel(fn_class.name)
|
||||||
|
canonical = f"{camel}Output"
|
||||||
|
if base.__name__ == canonical:
|
||||||
|
return base
|
||||||
|
return create_model(canonical, __base__=base)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Function / context / channel emission ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _function_props(fn_class: Any, output_type_name: str, output_nullable: bool) -> dict[str, Any]:
|
||||||
|
"""Collect every value that goes inside a `function` block."""
|
||||||
|
meta = getattr(fn_class, "_meta", {})
|
||||||
|
name = fn_class.name
|
||||||
|
camel = _snake_to_camel(name)
|
||||||
|
input_cls = getattr(fn_class, "Input", None)
|
||||||
|
has_input = _has_input(input_cls)
|
||||||
|
is_context = meta.get("context")
|
||||||
|
is_form = meta.get("form", False)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"camel": camel,
|
||||||
|
"has_input": has_input,
|
||||||
|
"input_type": f"{camel}Input" if has_input else None,
|
||||||
|
"output_type": output_type_name,
|
||||||
|
"output_nullable": output_nullable,
|
||||||
|
"transport": "websocket" if meta.get("websocket") else "http",
|
||||||
|
"context": is_context if isinstance(is_context, str) else None,
|
||||||
|
"affects": [a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"],
|
||||||
|
"merge": list(meta.get("merge") or []),
|
||||||
|
"is_form": bool(is_form),
|
||||||
|
"form_name": meta.get("form_name"),
|
||||||
|
"form_role": meta.get("form_role"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_output(fn_class: Any) -> tuple[str, bool]:
|
||||||
|
"""Return `(output_type_name, output_nullable)` for an emitted function block."""
|
||||||
|
camel = _snake_to_camel(fn_class.name)
|
||||||
|
canonical = f"{camel}Output"
|
||||||
|
output_cls = getattr(fn_class, "Output", None)
|
||||||
|
if output_cls is None:
|
||||||
|
return canonical, False
|
||||||
|
_, nullable = extract_optional(output_cls)
|
||||||
|
return canonical, nullable
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_channels() -> list[dict[str, Any]]:
|
||||||
|
"""Pull channel registrations from the optional `channels` registry extension."""
|
||||||
|
from mizan_core.registry import _extensions # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
ext = _extensions.get("channels")
|
||||||
|
if ext is None:
|
||||||
|
return []
|
||||||
|
schema = ext.schema()
|
||||||
|
return list(schema or [])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Top-level builder ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_ir() -> str:
|
||||||
|
"""Build the Mizan IR for every registered function. Returns KDL source."""
|
||||||
|
functions = get_all_functions()
|
||||||
|
context_groups = get_context_groups()
|
||||||
|
channels = _collect_channels()
|
||||||
|
|
||||||
|
named_types = _collect_named_types(functions)
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
root = _Block(lines, indent=0)
|
||||||
|
|
||||||
|
# ── Type definitions ──
|
||||||
|
for type_name in sorted(named_types):
|
||||||
|
shape = named_types[type_name]
|
||||||
|
with root.node("type", _kdl_string(type_name)) as type_block:
|
||||||
|
if isinstance(shape, _StructShape):
|
||||||
|
_emit_struct_type(type_block, shape.model, named_types)
|
||||||
|
elif isinstance(shape, _AliasShape):
|
||||||
|
_emit_alias_type(type_block, shape.annotation, named_types)
|
||||||
|
else:
|
||||||
|
raise TypeError(f"unknown named-type shape: {type(shape).__name__}")
|
||||||
|
|
||||||
|
if named_types:
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ── Functions ──
|
||||||
|
for fn_name, fn_class in functions.items():
|
||||||
|
meta = getattr(fn_class, "_meta", {})
|
||||||
|
if meta.get("private") or meta.get("view_path"):
|
||||||
|
continue
|
||||||
|
output_type_name, output_nullable = _resolve_output(fn_class)
|
||||||
|
props = _function_props(fn_class, output_type_name, output_nullable)
|
||||||
|
_emit_function(root, props)
|
||||||
|
|
||||||
|
if functions:
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ── Contexts ──
|
||||||
|
for ctx_name, fn_names in context_groups.items():
|
||||||
|
_emit_context(root, ctx_name, fn_names)
|
||||||
|
|
||||||
|
if context_groups:
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ── Channels ──
|
||||||
|
for channel in channels:
|
||||||
|
_emit_channel(root, channel)
|
||||||
|
|
||||||
|
# Trim trailing blanks then add a single terminating newline.
|
||||||
|
while lines and not lines[-1]:
|
||||||
|
lines.pop()
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_function(root: _Block, props: dict[str, Any]) -> None:
|
||||||
|
with root.node("function", _kdl_string(props["name"])) as block:
|
||||||
|
block.leaf("camel", _kdl_string(props["camel"]))
|
||||||
|
block.leaf("has-input", _kdl_bool(props["has_input"]))
|
||||||
|
if props["input_type"]:
|
||||||
|
block.leaf("input", _kdl_string(props["input_type"]))
|
||||||
|
block.leaf("output", _kdl_string(props["output_type"]))
|
||||||
|
if props["output_nullable"]:
|
||||||
|
block.leaf("output-nullable", _kdl_bool(True))
|
||||||
|
block.leaf("transport", _kdl_string(props["transport"]))
|
||||||
|
if props["context"]:
|
||||||
|
block.leaf("context", _kdl_string(props["context"]))
|
||||||
|
for affect_name in props["affects"]:
|
||||||
|
block.leaf("affects", _kdl_string(affect_name))
|
||||||
|
for merge_name in props["merge"]:
|
||||||
|
block.leaf("merge", _kdl_string(merge_name))
|
||||||
|
if props["is_form"]:
|
||||||
|
block.leaf("is-form", _kdl_bool(True))
|
||||||
|
if props["form_name"]:
|
||||||
|
block.leaf("form-name", _kdl_string(props["form_name"]))
|
||||||
|
if props["form_role"]:
|
||||||
|
block.leaf("form-role", _kdl_string(props["form_role"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_context(root: _Block, ctx_name: str, fn_names: list[str]) -> None:
|
||||||
|
# First pass: collect param info across every function in the context.
|
||||||
|
param_info: dict[str, dict[str, Any]] = {}
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_class = get_function(fn_name)
|
||||||
|
if fn_class is None:
|
||||||
|
continue
|
||||||
|
input_cls = getattr(fn_class, "Input", None)
|
||||||
|
if not _has_input(input_cls):
|
||||||
|
continue
|
||||||
|
for param_name, field_info in input_cls.model_fields.items():
|
||||||
|
slot = param_info.setdefault(param_name, {"type": None, "shared_by": []})
|
||||||
|
slot["type"] = _annotation_to_primitive(field_info.annotation)
|
||||||
|
slot["shared_by"].append(fn_name)
|
||||||
|
|
||||||
|
# A param is required iff every function in the context declares it.
|
||||||
|
for slot in param_info.values():
|
||||||
|
slot["required"] = len(slot["shared_by"]) == len(fn_names)
|
||||||
|
|
||||||
|
with root.node("context", _kdl_string(ctx_name)) as block:
|
||||||
|
for fn_name in fn_names:
|
||||||
|
block.leaf("function", _kdl_string(fn_name))
|
||||||
|
for param_name in sorted(param_info):
|
||||||
|
slot = param_info[param_name]
|
||||||
|
with block.node("param", _kdl_string(param_name)) as param_block:
|
||||||
|
param_block.leaf("type", _kdl_string(slot["type"]))
|
||||||
|
param_block.leaf("required", _kdl_bool(slot["required"]))
|
||||||
|
for sharer in slot["shared_by"]:
|
||||||
|
param_block.leaf("shared-by", _kdl_string(sharer))
|
||||||
|
|
||||||
|
|
||||||
|
def _annotation_to_primitive(annotation: Any) -> str:
|
||||||
|
inner, _ = extract_optional(annotation)
|
||||||
|
if inner is int:
|
||||||
|
return "integer"
|
||||||
|
if inner is float:
|
||||||
|
return "number"
|
||||||
|
if inner is bool:
|
||||||
|
return "boolean"
|
||||||
|
return "string"
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_channel(root: _Block, channel: dict[str, Any]) -> None:
|
||||||
|
name = channel["name"]
|
||||||
|
with root.node("channel", _kdl_string(name)) as block:
|
||||||
|
block.leaf("pascal-name", _kdl_string(channel["pascalName"]))
|
||||||
|
if channel.get("hasParams") and channel.get("paramsType"):
|
||||||
|
block.leaf("params", _kdl_string(channel["paramsType"]))
|
||||||
|
if channel.get("hasReactMessage") and channel.get("reactMessageType"):
|
||||||
|
block.leaf("react-message", _kdl_string(channel["reactMessageType"]))
|
||||||
|
if channel.get("hasDjangoMessage") and channel.get("djangoMessageType"):
|
||||||
|
block.leaf("django-message", _kdl_string(channel["djangoMessageType"]))
|
||||||
109
protocol/mizan-codegen/Cargo.lock
generated
109
protocol/mizan-codegen/Cargo.lock
generated
@@ -117,6 +117,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
@@ -214,6 +220,17 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
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]]
|
[[package]]
|
||||||
name = "libm"
|
name = "libm"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
@@ -226,6 +243,16 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
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]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -256,6 +283,7 @@ dependencies = [
|
|||||||
"askama",
|
"askama",
|
||||||
"clap",
|
"clap",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
|
"kdl",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"toml",
|
"toml",
|
||||||
@@ -271,6 +299,70 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -412,7 +504,7 @@ dependencies = [
|
|||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime",
|
"toml_datetime",
|
||||||
"toml_write",
|
"toml_write",
|
||||||
"winnow",
|
"winnow 0.7.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -433,6 +525,12 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -454,6 +552,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.6.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.15"
|
version = "0.7.15"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ path = "src/lib.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
askama = "0.12"
|
askama = "0.12"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
kdl = "6"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1", features = ["preserve_order"] }
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use indexmap::IndexMap;
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::emit::CodegenTarget;
|
use crate::emit::CodegenTarget;
|
||||||
use crate::emit::EmittedFile;
|
use crate::emit::EmittedFile;
|
||||||
use crate::ir::{JsonSchema, MizanChannel, MizanIR};
|
use crate::ir::{MizanChannel, MizanIR, NamedType, Primitive, StructField, TypeShape};
|
||||||
|
|
||||||
|
|
||||||
pub struct ChannelsTarget;
|
pub struct ChannelsTarget;
|
||||||
@@ -25,7 +25,7 @@ impl CodegenTarget for ChannelsTarget {
|
|||||||
return Vec::new();
|
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 {
|
let types_content = ChannelsTypes {
|
||||||
channels: ir.channels.iter().map(ChannelView::from_ir).collect(),
|
channels: ir.channels.iter().map(ChannelView::from_ir).collect(),
|
||||||
@@ -34,9 +34,9 @@ impl CodegenTarget for ChannelsTarget {
|
|||||||
|
|
||||||
let mut type_imports: Vec<String> = Vec::new();
|
let mut type_imports: Vec<String> = Vec::new();
|
||||||
for ch in &ir.channels {
|
for ch in &ir.channels {
|
||||||
if ch.has_params { if let Some(t) = &ch.params_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_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_django_message() { if let Some(t) = &ch.django_message_type { type_imports.push(t.clone()); } }
|
||||||
}
|
}
|
||||||
|
|
||||||
let hooks_content = ChannelsHooks {
|
let hooks_content = ChannelsHooks {
|
||||||
@@ -92,12 +92,12 @@ impl<'a> ChannelView<'a> {
|
|||||||
Self {
|
Self {
|
||||||
name: &ch.name,
|
name: &ch.name,
|
||||||
pascal_name: &ch.pascal_name,
|
pascal_name: &ch.pascal_name,
|
||||||
has_params: ch.has_params,
|
has_params: ch.has_params(),
|
||||||
has_react_message: ch.has_react_message,
|
has_react_message: ch.has_react_message(),
|
||||||
has_django_message: ch.has_django_message,
|
has_django_message: ch.has_django_message(),
|
||||||
params_type_or_record: if ch.has_params { params_type.clone() } else { "Record<string, never>".to_string() },
|
params_type_or_record: if ch.has_params() { params_type.clone() } else { "Record<string, never>".to_string() },
|
||||||
react_msg_type_or_never: if ch.has_react_message { react_message_type.clone() } else { "never".to_string() },
|
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() },
|
django_msg_type_or_never: if ch.has_django_message() { django_message_type.clone() } else { "never".to_string() },
|
||||||
params_type,
|
params_type,
|
||||||
react_message_type,
|
react_message_type,
|
||||||
django_message_type,
|
django_message_type,
|
||||||
@@ -108,13 +108,13 @@ impl<'a> ChannelView<'a> {
|
|||||||
|
|
||||||
fn emit_channel_schemas(
|
fn emit_channel_schemas(
|
||||||
channels: &[MizanChannel],
|
channels: &[MizanChannel],
|
||||||
schemas: &IndexMap<String, JsonSchema>,
|
types: &IndexMap<String, NamedType>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut blocks: Vec<String> = Vec::new();
|
let mut blocks: Vec<String> = Vec::new();
|
||||||
for ch in channels {
|
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()) {
|
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) {
|
if let Some(named) = types.get(ty) {
|
||||||
blocks.push(emit_schema_as_ts(ty, schema));
|
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 {
|
fn emit_named_type_as_ts(name: &str, ty: &NamedType) -> String {
|
||||||
if let Some(props) = &schema.properties {
|
match ty {
|
||||||
let required: std::collections::HashSet<&str> =
|
NamedType::Struct(fields) => emit_interface(name, fields),
|
||||||
schema.required.iter().map(String::as_str).collect();
|
NamedType::List(inner) => format!("export type {name} = {}[]", ts_type_expression(inner)),
|
||||||
let fields = props.iter()
|
NamedType::Enum(variants) => {
|
||||||
.map(|(field_name, field_schema)| {
|
let union = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(" | ");
|
||||||
let opt = if required.contains(field_name.as_str()) { "" } else { "?" };
|
format!("export type {name} = {union}")
|
||||||
let ty = ts_type_expression(field_schema);
|
}
|
||||||
format!(" {field_name}{opt}: {ty}")
|
NamedType::Alias(inner) => format!("export type {name} = {}", ts_type_expression(inner)),
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn emit_interface(name: &str, fields: &[StructField]) -> String {
|
||||||
|
if fields.is_empty() {
|
||||||
|
return format!("export interface {name} {{}}");
|
||||||
|
}
|
||||||
|
let body = fields.iter()
|
||||||
|
.map(|f| {
|
||||||
|
let is_required = f.required || f.default.is_some();
|
||||||
|
let opt = if is_required { "" } else { "?" };
|
||||||
|
format!(" {}{opt}: {}", f.name, ts_type_expression(&f.shape))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
format!("export interface {name} {{\n{body}\n}}")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn ts_type_expression(shape: &TypeShape) -> String {
|
||||||
|
match shape {
|
||||||
|
TypeShape::Ref(name) => name.clone(),
|
||||||
|
TypeShape::Primitive(p) => primitive_to_ts(*p).to_string(),
|
||||||
|
TypeShape::List(inner) => format!("{}[]", ts_type_expression(inner)),
|
||||||
|
TypeShape::Optional(inner) => format!("{} | null", ts_type_expression(inner)),
|
||||||
|
TypeShape::Enum(variants) => variants.iter()
|
||||||
|
.map(|v| format!("\"{v}\""))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join(" | "),
|
||||||
if fields.is_empty() {
|
TypeShape::Union(branches) => branches.iter()
|
||||||
format!("export interface {name} {{}}")
|
.map(ts_type_expression)
|
||||||
} else {
|
.collect::<Vec<_>>()
|
||||||
format!("export interface {name} {{\n{fields}\n}}")
|
.join(" | "),
|
||||||
}
|
|
||||||
} else {
|
|
||||||
format!("export type {name} = {}", ts_type_expression(schema))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn ts_type_expression(schema: &JsonSchema) -> String {
|
fn primitive_to_ts(p: Primitive) -> &'static str {
|
||||||
if let Some(ref_name) = schema.ref_name() {
|
match p {
|
||||||
return ref_name.to_string();
|
Primitive::Integer | Primitive::Number => "number",
|
||||||
}
|
Primitive::Boolean => "boolean",
|
||||||
match schema.ty.as_deref() {
|
Primitive::String => "string",
|
||||||
Some("integer") | Some("number") => "number".to_string(),
|
|
||||||
Some("boolean") => "boolean".to_string(),
|
|
||||||
Some("string") => "string".to_string(),
|
|
||||||
Some("array") => {
|
|
||||||
let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
|
|
||||||
format!("{elem}[]")
|
|
||||||
}
|
|
||||||
Some("object") => "Record<string, unknown>".to_string(),
|
|
||||||
_ => "unknown".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ use crate::config::Config;
|
|||||||
use crate::emit::CodegenTarget;
|
use crate::emit::CodegenTarget;
|
||||||
use crate::emit::EmittedFile;
|
use crate::emit::EmittedFile;
|
||||||
use crate::emit::casing::{pascal_case, rust_ident, snake_case};
|
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;
|
pub struct PythonClient;
|
||||||
@@ -24,8 +26,8 @@ impl CodegenTarget for PythonClient {
|
|||||||
fn name(&self) -> &'static str { "python" }
|
fn name(&self) -> &'static str { "python" }
|
||||||
|
|
||||||
fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec<EmittedFile> {
|
fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec<EmittedFile> {
|
||||||
let schemas_block = ir.components.schemas.iter()
|
let schemas_block = ir.types.iter()
|
||||||
.map(|(name, schema)| emit_schema_block(name, schema))
|
.map(|(name, ty)| emit_schema_block(name, ty))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
@@ -66,119 +68,67 @@ struct ClientTemplate {
|
|||||||
// ─── types.py schema bodies ────────────────────────────────────────────────
|
// ─── 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);
|
let name = pascal_case(raw_name);
|
||||||
|
match ty {
|
||||||
if let Some(values) = &schema.r#enum {
|
NamedType::Struct(fields) => emit_pydantic_class(&name, fields),
|
||||||
if schema.ty.as_deref() == Some("string") {
|
NamedType::List(inner) => format!("{name} = list[{}]", py_type_expression(inner)),
|
||||||
let literal = values.iter()
|
NamedType::Enum(variants) => {
|
||||||
.filter_map(|v| v.as_str())
|
let literal = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(", ");
|
||||||
.map(|v| format!("\"{v}\""))
|
format!("{name} = Literal[{literal}]")
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
return 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(
|
fn emit_pydantic_class(name: &str, fields: &[StructField]) -> String {
|
||||||
name: &str,
|
if fields.is_empty() {
|
||||||
schema: &JsonSchema,
|
|
||||||
properties: &IndexMap<String, JsonSchema>,
|
|
||||||
) -> String {
|
|
||||||
if properties.is_empty() {
|
|
||||||
return format!("class {name}(BaseModel):\n pass");
|
return format!("class {name}(BaseModel):\n pass");
|
||||||
}
|
}
|
||||||
let required: std::collections::HashSet<&str> =
|
let field_lines = fields.iter()
|
||||||
schema.required.iter().map(String::as_str).collect();
|
.map(|f| {
|
||||||
|
let mut ty = py_type_expression(&f.shape);
|
||||||
let field_lines = properties.iter()
|
let is_required = f.required || f.default.is_some();
|
||||||
.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();
|
|
||||||
if !is_required {
|
if !is_required {
|
||||||
if !ty.ends_with(" | None") {
|
if !ty.ends_with(" | None") {
|
||||||
ty = format!("{ty} | None");
|
ty = format!("{ty} | None");
|
||||||
}
|
}
|
||||||
format!(" {}: {ty} = None", rust_ident(field_raw))
|
format!(" {}: {ty} = None", rust_ident(&f.name))
|
||||||
} else {
|
} else {
|
||||||
format!(" {}: {ty}", rust_ident(field_raw))
|
format!(" {}: {ty}", rust_ident(&f.name))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
format!("class {name}(BaseModel):\n{field_lines}")
|
format!("class {name}(BaseModel):\n{field_lines}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn py_type_from_schema(schema: &JsonSchema) -> String {
|
fn py_type_expression(shape: &TypeShape) -> String {
|
||||||
if let Some(ref_name) = schema.ref_name() {
|
match shape {
|
||||||
return pascal_case(ref_name);
|
TypeShape::Ref(name) => pascal_case(name),
|
||||||
}
|
TypeShape::Primitive(p) => primitive_to_py(*p).to_string(),
|
||||||
|
TypeShape::List(inner) => format!("list[{}]", py_type_expression(inner)),
|
||||||
if let Some(any_of) = &schema.any_of {
|
TypeShape::Optional(inner) => format!("{} | None", py_type_expression(inner)),
|
||||||
let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
|
TypeShape::Enum(variants) => {
|
||||||
let non_null: Vec<&JsonSchema> = any_of
|
let parts = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(", ");
|
||||||
.iter()
|
format!("Literal[{parts}]")
|
||||||
.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]));
|
|
||||||
}
|
}
|
||||||
}
|
TypeShape::Union(branches) => branches.iter()
|
||||||
|
.map(py_type_expression)
|
||||||
let nullable = schema.nullable;
|
.collect::<Vec<_>>()
|
||||||
let inner = inner_py_type(schema);
|
.join(" | "),
|
||||||
if nullable {
|
|
||||||
format!("{inner} | None")
|
|
||||||
} else {
|
|
||||||
inner
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn inner_py_type(schema: &JsonSchema) -> String {
|
fn primitive_to_py(p: Primitive) -> &'static str {
|
||||||
if let Some(values) = &schema.r#enum {
|
match p {
|
||||||
if schema.ty.as_deref() == Some("string") {
|
Primitive::Integer => "int",
|
||||||
let parts = values.iter()
|
Primitive::Number => "float",
|
||||||
.filter_map(|v| v.as_str())
|
Primitive::Boolean => "bool",
|
||||||
.map(|v| format!("\"{v}\""))
|
Primitive::String => "str",
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
return format!("Literal[{parts}]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match schema.ty.as_deref() {
|
|
||||||
Some("integer") => "int".to_string(),
|
|
||||||
Some("number") => "float".to_string(),
|
|
||||||
Some("boolean") => "bool".to_string(),
|
|
||||||
Some("string") => "str".to_string(),
|
|
||||||
Some("array") => {
|
|
||||||
let elem = py_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
|
|
||||||
format!("list[{elem}]")
|
|
||||||
}
|
|
||||||
Some("object") => {
|
|
||||||
if schema.properties.is_some() { "Any".to_string() }
|
|
||||||
else { "dict[str, Any]".to_string() }
|
|
||||||
}
|
|
||||||
_ => "Any".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,12 +166,12 @@ fn build_client_template(ir: &MizanIR) -> ClientTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn py_arg_type(json_ty: &str) -> &'static str {
|
fn py_arg_type(p: Primitive) -> &'static str {
|
||||||
match json_ty {
|
match p {
|
||||||
"integer" => "int",
|
Primitive::Integer => "int",
|
||||||
"number" => "float",
|
Primitive::Number => "float",
|
||||||
"boolean" => "bool",
|
Primitive::Boolean => "bool",
|
||||||
_ => "str",
|
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()
|
let param_args = ctx_meta.params.iter()
|
||||||
.map(|(n, m)| {
|
.map(|(n, m)| {
|
||||||
let ident = rust_ident(n);
|
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}") }
|
if m.required { format!("{ident}: {ty}") }
|
||||||
else { format!("{ident}: {ty} | None = None") }
|
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()
|
let param_args = ctx_meta.params.iter()
|
||||||
.map(|(n, m)| {
|
.map(|(n, m)| {
|
||||||
let ident = rust_ident(n);
|
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}") }
|
if m.required { format!("{ident}: {ty}") }
|
||||||
else { format!("{ident}: {ty} | None = None") }
|
else { format!("{ident}: {ty} | None = None") }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ use crate::config::{Config, RustKernelSpec};
|
|||||||
use crate::emit::CodegenTarget;
|
use crate::emit::CodegenTarget;
|
||||||
use crate::emit::EmittedFile;
|
use crate::emit::EmittedFile;
|
||||||
use crate::emit::casing::{pascal_case, rust_ident, rust_type_ident, snake_case};
|
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;
|
pub struct RustCrate;
|
||||||
@@ -35,7 +38,7 @@ impl CodegenTarget for RustCrate {
|
|||||||
.render().expect("Cargo.toml renders"),
|
.render().expect("Cargo.toml renders"),
|
||||||
));
|
));
|
||||||
|
|
||||||
out.push(EmittedFile::new("src/types.rs", emit_types_rs(&ir.components.schemas)));
|
out.push(EmittedFile::new("src/types.rs", emit_types_rs(&ir.types)));
|
||||||
|
|
||||||
let mut context_modules: Vec<String> = Vec::new();
|
let mut context_modules: Vec<String> = Vec::new();
|
||||||
for (ctx_name, ctx_meta) in &ir.contexts {
|
for (ctx_name, ctx_meta) in &ir.contexts {
|
||||||
@@ -119,8 +122,8 @@ struct ContextTemplate<'a> {
|
|||||||
snake: String,
|
snake: String,
|
||||||
ctx_name: &'a str,
|
ctx_name: &'a str,
|
||||||
type_imports: Vec<String>,
|
type_imports: Vec<String>,
|
||||||
data_fields: Vec<StructField>,
|
data_fields: Vec<RustField>,
|
||||||
params: Vec<StructField>,
|
params: Vec<RustField>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -144,7 +147,10 @@ struct TypesTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct StructField {
|
/// Renderer-side view of a single Rust struct field. Distinct from
|
||||||
|
/// `ir::StructField` (the IR shape) because the renderer carries
|
||||||
|
/// already-rendered identifiers and rename flags.
|
||||||
|
struct RustField {
|
||||||
raw_name: String,
|
raw_name: String,
|
||||||
ident: String,
|
ident: String,
|
||||||
ty: String,
|
ty: String,
|
||||||
@@ -212,10 +218,10 @@ fn emit_context_file(
|
|||||||
ctx_fns.iter().map(|f| rust_type_ident(&f.output_type)),
|
ctx_fns.iter().map(|f| rust_type_ident(&f.output_type)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let data_fields: Vec<StructField> = ctx_fns.iter()
|
let data_fields: Vec<RustField> = ctx_fns.iter()
|
||||||
.map(|f| {
|
.map(|f| {
|
||||||
let ident = rust_ident(&f.name);
|
let ident = rust_ident(&f.name);
|
||||||
StructField {
|
RustField {
|
||||||
has_rename: ident != f.name,
|
has_rename: ident != f.name,
|
||||||
raw_name: f.name.clone(),
|
raw_name: f.name.clone(),
|
||||||
ident,
|
ident,
|
||||||
@@ -224,12 +230,12 @@ fn emit_context_file(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let params: Vec<StructField> = ctx_meta.params.iter()
|
let params: Vec<RustField> = ctx_meta.params.iter()
|
||||||
.map(|(p_name, p_meta)| {
|
.map(|(p_name, p_meta)| {
|
||||||
let ident = rust_ident(p_name);
|
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}>") };
|
let ty = if p_meta.required { base.to_string() } else { format!("Option<{base}>") };
|
||||||
StructField {
|
RustField {
|
||||||
has_rename: ident != *p_name,
|
has_rename: ident != *p_name,
|
||||||
raw_name: p_name.clone(),
|
raw_name: p_name.clone(),
|
||||||
ident,
|
ident,
|
||||||
@@ -249,12 +255,12 @@ fn emit_context_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn param_rust_type(json_ty: &str) -> &'static str {
|
fn param_rust_type(p: Primitive) -> &'static str {
|
||||||
match json_ty {
|
match p {
|
||||||
"integer" => "i64",
|
Primitive::Integer => "i64",
|
||||||
"number" => "f64",
|
Primitive::Number => "f64",
|
||||||
"boolean" => "bool",
|
Primitive::Boolean => "bool",
|
||||||
_ => "String",
|
Primitive::String => "String",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,33 +309,26 @@ fn emit_call_file(fn_meta: &MizanFunction) -> String {
|
|||||||
// ─── types.rs ──────────────────────────────────────────────────────────────
|
// ─── 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 {
|
struct EnumCtx {
|
||||||
hoisted: Vec<(String, Vec<serde_json::Value>)>,
|
hoisted: Vec<(String, Vec<String>)>,
|
||||||
depth: usize,
|
|
||||||
enum_name: Option<String>,
|
enum_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn emit_types_rs(schemas: &IndexMap<String, JsonSchema>) -> String {
|
fn emit_types_rs(types: &IndexMap<String, NamedType>) -> String {
|
||||||
let mut ctx = EnumCtx { hoisted: Vec::new(), depth: 0, enum_name: None };
|
let mut ctx = EnumCtx { hoisted: Vec::new(), enum_name: None };
|
||||||
|
|
||||||
let schemas_block = schemas.iter()
|
let schemas_block = types.iter()
|
||||||
.map(|(raw_name, schema)| {
|
.map(|(raw_name, ty)| {
|
||||||
let name = rust_type_ident(raw_name);
|
let name = rust_type_ident(raw_name);
|
||||||
if let Some(values) = &schema.r#enum {
|
match ty {
|
||||||
if schema.ty.as_deref() == Some("string") {
|
NamedType::Struct(fields) => emit_struct_decl(&name, fields, &mut ctx),
|
||||||
return emit_string_enum(&name, values);
|
NamedType::List(inner) => emit_transparent_array(&name, inner, &mut ctx),
|
||||||
}
|
NamedType::Enum(variants) => emit_string_enum(&name, variants),
|
||||||
|
NamedType::Alias(inner) => emit_type_alias(&name, inner, &mut ctx),
|
||||||
}
|
}
|
||||||
if schema.ty.as_deref() == Some("array") {
|
|
||||||
return emit_transparent_array(&name, schema, &mut ctx);
|
|
||||||
}
|
|
||||||
if schema.ty.as_deref() == Some("object") {
|
|
||||||
if let Some(props) = &schema.properties {
|
|
||||||
return emit_struct(&name, schema, props, &mut ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit_type_alias(&name, schema, &mut ctx)
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
@@ -344,12 +343,11 @@ fn emit_types_rs(schemas: &IndexMap<String, JsonSchema>) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn emit_string_enum(name: &str, variants: &[serde_json::Value]) -> String {
|
fn emit_string_enum(name: &str, variants: &[String]) -> String {
|
||||||
let body = variants.iter()
|
let body = variants.iter()
|
||||||
.filter_map(|v| v.as_str())
|
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
let ident = pascal_case(v);
|
let ident = pascal_case(v);
|
||||||
let rename = if ident == v {
|
let rename = if ident == *v {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(" #[serde(rename = {})]\n", json_str(v))
|
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 {
|
fn emit_transparent_array(name: &str, inner: &TypeShape, ctx: &mut EnumCtx) -> String {
|
||||||
ctx.depth = 1;
|
|
||||||
ctx.enum_name = None;
|
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!(
|
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,
|
name: &str,
|
||||||
schema: &JsonSchema,
|
fields: &[IrStructField],
|
||||||
properties: &IndexMap<String, JsonSchema>,
|
|
||||||
ctx: &mut EnumCtx,
|
ctx: &mut EnumCtx,
|
||||||
) -> String {
|
) -> String {
|
||||||
let required: std::collections::HashSet<&str> =
|
let fields_body = fields.iter()
|
||||||
schema.required.iter().map(String::as_str).collect();
|
.map(|f| {
|
||||||
|
let field_name = rust_ident(&f.name);
|
||||||
let fields = properties.iter()
|
ctx.enum_name = Some(format!("{name}_{}", pascal_case(&f.name)));
|
||||||
.map(|(field_raw, field_schema)| {
|
let mut ty = rust_type_from_shape(&f.shape, ctx);
|
||||||
let field_name = rust_ident(field_raw);
|
let is_required = f.required || f.default.is_some();
|
||||||
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();
|
|
||||||
if !is_required && !ty.starts_with("Option<") {
|
if !is_required && !ty.starts_with("Option<") {
|
||||||
ty = format!("Option<{ty}>");
|
ty = format!("Option<{ty}>");
|
||||||
}
|
}
|
||||||
let rename = if field_name == *field_raw {
|
let rename = if field_name == f.name {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(" #[serde(rename = \"{field_raw}\")]\n")
|
format!(" #[serde(rename = \"{raw}\")]\n", raw = f.name)
|
||||||
};
|
};
|
||||||
format!("{rename} pub {field_name}: {ty},")
|
format!("{rename} pub {field_name}: {ty},")
|
||||||
})
|
})
|
||||||
@@ -406,69 +397,48 @@ fn emit_struct(
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
format!(
|
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 {
|
fn emit_type_alias(name: &str, inner: &TypeShape, ctx: &mut EnumCtx) -> String {
|
||||||
ctx.depth = 0;
|
|
||||||
ctx.enum_name = Some(name.to_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")
|
format!("pub type {name} = {ty};\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn rust_type_from_schema(schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
|
fn rust_type_from_shape(shape: &TypeShape, ctx: &mut EnumCtx) -> String {
|
||||||
if let Some(r) = schema.ref_name() {
|
match shape {
|
||||||
return rust_type_ident(r);
|
TypeShape::Ref(name) => rust_type_ident(name),
|
||||||
}
|
TypeShape::Primitive(Primitive::Integer) => "i64".to_string(),
|
||||||
|
TypeShape::Primitive(Primitive::Number) => "f64".to_string(),
|
||||||
if let Some(any_of) = &schema.any_of {
|
TypeShape::Primitive(Primitive::Boolean) => "bool".to_string(),
|
||||||
let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
|
TypeShape::Primitive(Primitive::String) => "String".to_string(),
|
||||||
let non_null: Vec<&JsonSchema> = any_of
|
TypeShape::List(inner) => {
|
||||||
.iter()
|
|
||||||
.filter(|s| s.ty.as_deref() != Some("null"))
|
|
||||||
.collect();
|
|
||||||
if has_null && non_null.len() == 1 {
|
|
||||||
ctx.enum_name = None;
|
ctx.enum_name = None;
|
||||||
return format!("Option<{}>", rust_type_from_schema(non_null[0], ctx));
|
format!("Vec<{}>", rust_type_from_shape(inner, ctx))
|
||||||
}
|
}
|
||||||
}
|
TypeShape::Optional(inner) => {
|
||||||
|
ctx.enum_name = None;
|
||||||
let nullable = schema.nullable;
|
format!("Option<{}>", rust_type_from_shape(inner, ctx))
|
||||||
let inner = inner_rust_type(schema, ctx);
|
}
|
||||||
if nullable {
|
TypeShape::Enum(variants) => {
|
||||||
format!("Option<{inner}>")
|
// Inline enums hoist out into top-level Rust enum types so the
|
||||||
} else {
|
// generated struct field can reference them by name.
|
||||||
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") {
|
|
||||||
let enum_name = ctx
|
let enum_name = ctx
|
||||||
.enum_name
|
.enum_name
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| format!("Enum_{}", ctx.depth));
|
.unwrap_or_else(|| "Enum_inline".to_string());
|
||||||
ctx.hoisted.push((enum_name.clone(), values.clone()));
|
ctx.hoisted.push((enum_name.clone(), variants.clone()));
|
||||||
return enum_name;
|
enum_name
|
||||||
}
|
}
|
||||||
}
|
TypeShape::Union(_branches) => {
|
||||||
match schema.ty.as_deref() {
|
// Rust serde doesn't have a clean way to deserialize an untagged
|
||||||
Some("integer") => "i64".to_string(),
|
// multi-arm union without losing type info; fall back to a JSON
|
||||||
Some("number") => "f64".to_string(),
|
// Value so the consumer can match on the runtime variant.
|
||||||
Some("boolean") => "bool".to_string(),
|
"serde_json::Value".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}>")
|
|
||||||
}
|
}
|
||||||
Some("object") => "serde_json::Value".to_string(),
|
|
||||||
_ => "serde_json::Value".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ use askama::Template;
|
|||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
|
|
||||||
use crate::config::Config;
|
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::CodegenTarget;
|
||||||
use crate::emit::EmittedFile;
|
use crate::emit::EmittedFile;
|
||||||
use crate::emit::casing::pascal_case;
|
use crate::emit::casing::pascal_case;
|
||||||
@@ -94,7 +96,7 @@ impl CodegenTarget for Stage1 {
|
|||||||
fn emit(&self, ir: &MizanIR, config: &Config) -> Vec<EmittedFile> {
|
fn emit(&self, ir: &MizanIR, config: &Config) -> Vec<EmittedFile> {
|
||||||
let mut out: Vec<EmittedFile> = Vec::new();
|
let mut out: Vec<EmittedFile> = Vec::new();
|
||||||
|
|
||||||
out.push(EmittedFile::new("types.ts", emit_types(&ir.components.schemas)));
|
out.push(EmittedFile::new("types.ts", emit_types(&ir.types)));
|
||||||
|
|
||||||
for (ctx_name, ctx_meta) in &ir.contexts {
|
for (ctx_name, ctx_meta) in &ir.contexts {
|
||||||
let content = emit_context_file(ctx_name, ctx_meta, &ir.functions);
|
let content = emit_context_file(ctx_name, ctx_meta, &ir.functions);
|
||||||
@@ -157,7 +159,7 @@ fn emit_context_file(
|
|||||||
let params: Vec<ContextParamField> = ctx_meta.params.iter()
|
let params: Vec<ContextParamField> = ctx_meta.params.iter()
|
||||||
.map(|(name, meta)| ContextParamField {
|
.map(|(name, meta)| ContextParamField {
|
||||||
name,
|
name,
|
||||||
ts_type: json_ty_to_ts(&meta.ty),
|
ts_type: primitive_to_ts(meta.ty),
|
||||||
required: meta.required,
|
required: meta.required,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -174,11 +176,11 @@ fn emit_context_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn json_ty_to_ts(json_ty: &str) -> &'static str {
|
fn primitive_to_ts(p: Primitive) -> &'static str {
|
||||||
match json_ty {
|
match p {
|
||||||
"integer" | "number" => "number",
|
Primitive::Integer | Primitive::Number => "number",
|
||||||
"boolean" => "boolean",
|
Primitive::Boolean => "boolean",
|
||||||
_ => "string",
|
Primitive::String => "string",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,131 +241,60 @@ fn emit_stage1_index(ir: &MizanIR, config: &Config) -> String {
|
|||||||
// ─── types.ts ──────────────────────────────────────────────────────────────
|
// ─── types.ts ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
fn emit_types(schemas: &IndexMap<String, JsonSchema>) -> String {
|
fn emit_types(types: &IndexMap<String, NamedType>) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
out.push_str("// AUTO-GENERATED by mizan — do not edit\n\n");
|
out.push_str("// AUTO-GENERATED by mizan — do not edit\n\n");
|
||||||
for (raw_name, schema) in schemas {
|
for (name, ty) in types {
|
||||||
out.push_str(&emit_schema_decl(raw_name, schema));
|
out.push_str(&emit_named_type(name, ty));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn emit_schema_decl(name: &str, schema: &JsonSchema) -> String {
|
fn emit_named_type(name: &str, ty: &NamedType) -> String {
|
||||||
// String enum → union of string literals.
|
match ty {
|
||||||
if let Some(values) = &schema.r#enum {
|
NamedType::Struct(fields) => emit_interface(name, fields),
|
||||||
if schema.ty.as_deref() == Some("string") {
|
NamedType::List(inner) => format!("export type {name} = {}[]\n", ts_type_expression(inner)),
|
||||||
let union = values
|
NamedType::Enum(variants) => {
|
||||||
.iter()
|
let union = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(" | ");
|
||||||
.filter_map(|v| v.as_str())
|
format!("export type {name} = {union}\n")
|
||||||
.map(|s| format!("\"{s}\""))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" | ");
|
|
||||||
return 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(
|
fn emit_interface(name: &str, fields: &[StructField]) -> String {
|
||||||
name: &str,
|
if fields.is_empty() {
|
||||||
schema: &JsonSchema,
|
return format!("export interface {name} {{}}\n");
|
||||||
properties: &IndexMap<String, JsonSchema>,
|
}
|
||||||
) -> String {
|
let body = fields.iter()
|
||||||
let required: std::collections::HashSet<&str> =
|
.map(|f| {
|
||||||
schema.required.iter().map(String::as_str).collect();
|
// Field is non-optional if required OR has a default (server always populates).
|
||||||
|
let is_required = f.required || f.default.is_some();
|
||||||
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();
|
|
||||||
let opt = if is_required { "" } else { "?" };
|
let opt = if is_required { "" } else { "?" };
|
||||||
let ty = ts_type_expression(field_schema);
|
format!(" {}{opt}: {}", f.name, ts_type_expression(&f.shape))
|
||||||
format!(" {field_name}{opt}: {ty}")
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
format!("export interface {name} {{\n{body}\n}}\n")
|
||||||
if fields.is_empty() {
|
|
||||||
format!("export interface {name} {{}}\n")
|
|
||||||
} else {
|
|
||||||
format!("export interface {name} {{\n{fields}\n}}\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn ts_type_expression(schema: &JsonSchema) -> String {
|
fn ts_type_expression(shape: &TypeShape) -> String {
|
||||||
// `$ref` → bare type name reference into components.schemas.
|
match shape {
|
||||||
if let Some(ref_name) = schema.ref_name() {
|
TypeShape::Ref(name) => name.clone(),
|
||||||
return ref_name.to_string();
|
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)),
|
||||||
// `anyOf` with a null variant → `T | null`.
|
TypeShape::Enum(variants) => variants.iter()
|
||||||
if let Some(any_of) = &schema.any_of {
|
.map(|v| format!("\"{v}\""))
|
||||||
let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
|
.collect::<Vec<_>>()
|
||||||
let non_null: Vec<&JsonSchema> = any_of
|
.join(" | "),
|
||||||
.iter()
|
TypeShape::Union(branches) => branches.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()
|
|
||||||
.map(ts_type_expression)
|
.map(ts_type_expression)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" | ");
|
.join(" | "),
|
||||||
return union;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(values) = &schema.r#enum {
|
|
||||||
if schema.ty.as_deref() == Some("string") {
|
|
||||||
return values
|
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.as_str())
|
|
||||||
.map(|s| format!("\"{s}\""))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" | ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let base = match schema.ty.as_deref() {
|
|
||||||
Some("integer") | Some("number") => "number".to_string(),
|
|
||||||
Some("boolean") => "boolean".to_string(),
|
|
||||||
Some("string") => "string".to_string(),
|
|
||||||
Some("array") => {
|
|
||||||
let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
|
|
||||||
format!("{elem}[]")
|
|
||||||
}
|
|
||||||
Some("object") => "Record<string, unknown>".to_string(),
|
|
||||||
Some("null") => "null".to_string(),
|
|
||||||
_ => "unknown".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if schema.nullable {
|
|
||||||
format!("{base} | null")
|
|
||||||
} else {
|
|
||||||
base
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
//! Schema fetching — spawns the configured backend's schema-export command
|
//! Schema fetching — spawns the configured backend's IR-export command
|
||||||
//! and deserializes its stdout into a typed `MizanIR`.
|
//! and parses the KDL it writes to stdout.
|
||||||
//!
|
//!
|
||||||
//! Two backends recognized today:
|
//! Backends:
|
||||||
//! - FastAPI: `python -m mizan_fastapi.cli <module>`
|
//! - FastAPI: `python -m mizan_fastapi.ir <module>`
|
||||||
//! - Django: `python manage.py export_mizan_schema --indent 0`
|
//! - Django: `python manage.py export_mizan_ir`
|
||||||
//!
|
|
||||||
//! The fetcher reads stdout, skips any banner text before the first `{`,
|
|
||||||
//! and parses the remainder as JSON.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -14,7 +11,7 @@ use std::process::Command;
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
|
||||||
use crate::config::{Config, DjangoSource, FastapiSource};
|
use crate::config::{Config, DjangoSource, FastapiSource};
|
||||||
use crate::ir::MizanIR;
|
use crate::ir::{parse_ir, MizanIR};
|
||||||
|
|
||||||
|
|
||||||
pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
|
pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
|
||||||
@@ -28,7 +25,7 @@ pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
parse_ir(&raw)
|
parse_ir(&raw).context("parsing Mizan IR from backend KDL output")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -41,11 +38,11 @@ fn run_fastapi(src: &FastapiSource, config_dir: &Path) -> Result<String> {
|
|||||||
let (program, mut args) = resolve_command(&src.command, &src.python);
|
let (program, mut args) = resolve_command(&src.command, &src.python);
|
||||||
args.extend([
|
args.extend([
|
||||||
"-m".to_string(),
|
"-m".to_string(),
|
||||||
"mizan_fastapi.cli".to_string(),
|
"mizan_fastapi.ir".to_string(),
|
||||||
src.module.clone(),
|
src.module.clone(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
run_subprocess(&program, &args, &cwd, &src.env, "FastAPI schema export")
|
run_subprocess(&program, &args, &cwd, &src.env, "FastAPI IR export")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -58,22 +55,14 @@ fn run_django(src: &DjangoSource, config_dir: &Path) -> Result<String> {
|
|||||||
|
|
||||||
let (program, mut args) = resolve_command(&src.command, &src.python);
|
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() {
|
if src.command.is_some() {
|
||||||
args.push("manage.py".to_string());
|
args.push("manage.py".to_string());
|
||||||
} else {
|
} else {
|
||||||
args.push(manage_path.to_string_lossy().into_owned());
|
args.push(manage_path.to_string_lossy().into_owned());
|
||||||
}
|
}
|
||||||
args.extend([
|
args.push("export_mizan_ir".to_string());
|
||||||
"export_mizan_schema".to_string(),
|
|
||||||
"--indent".to_string(),
|
|
||||||
"0".to_string(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
run_subprocess(&program, &args, &manage_dir, &src.env, "Django schema export")
|
run_subprocess(&program, &args, &manage_dir, &src.env, "Django IR export")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -122,23 +111,13 @@ fn run_subprocess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn parse_ir(raw: &str) -> Result<MizanIR> {
|
/// Library helper: parse a KDL IR from a string.
|
||||||
let json_start = raw
|
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
|
||||||
.find('{')
|
parse_ir(source)
|
||||||
.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 for tests: deserialize an IR from a pre-fetched JSON string
|
/// Library helper: resolve a path relative to the config directory.
|
||||||
/// (no subprocess). Mirrors `parse_ir` but exposed for crate-external callers.
|
|
||||||
pub fn parse_ir_from_str(json: &str) -> Result<MizanIR> {
|
|
||||||
parse_ir(json)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Library helper: resolve a path relative to the config directory, returning
|
|
||||||
/// an absolute path. Consumers may want this when constructing output paths.
|
|
||||||
pub fn resolve_path(config_dir: &Path, p: impl Into<PathBuf>) -> PathBuf {
|
pub fn resolve_path(config_dir: &Path, p: impl Into<PathBuf>) -> PathBuf {
|
||||||
let p = p.into();
|
let p = p.into();
|
||||||
if p.is_absolute() {
|
if p.is_absolute() {
|
||||||
|
|||||||
@@ -1,122 +1,121 @@
|
|||||||
//! Mizan IR — strongly-typed deserialization of the backends' schema export.
|
//! Mizan IR — the canonical KDL document every backend adapter emits and
|
||||||
//!
|
//! every codegen target consumes. See `docs/AFI_ARCHITECTURE.md` and
|
||||||
//! Every Mizan backend (Django, FastAPI, mizan-ts) emits the same OpenAPI
|
//! `cores/mizan-python/src/mizan_core/ir.py` for the locked grammar.
|
||||||
//! 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;
|
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::Deserialize;
|
use kdl::{KdlDocument, KdlNode, KdlValue};
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Default)]
|
||||||
pub struct MizanIR {
|
pub struct MizanIR {
|
||||||
#[serde(rename = "x-mizan-functions", default)]
|
pub types: IndexMap<String, NamedType>,
|
||||||
pub functions: Vec<MizanFunction>,
|
pub functions: Vec<MizanFunction>,
|
||||||
|
|
||||||
#[serde(rename = "x-mizan-contexts", default)]
|
|
||||||
pub contexts: IndexMap<String, MizanContext>,
|
pub contexts: IndexMap<String, MizanContext>,
|
||||||
|
|
||||||
/// Django-only channel registrations. FastAPI backends emit an empty list.
|
|
||||||
#[serde(rename = "x-mizan-channels", default)]
|
|
||||||
pub channels: Vec<MizanChannel>,
|
pub channels: Vec<MizanChannel>,
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub components: Components,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
// ─── Type system ────────────────────────────────────────────────────────────
|
||||||
pub struct MizanChannel {
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum NamedType {
|
||||||
|
Struct(Vec<StructField>),
|
||||||
|
List(TypeShape),
|
||||||
|
Enum(Vec<String>),
|
||||||
|
Alias(TypeShape),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StructField {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(rename = "pascalName")]
|
pub required: bool,
|
||||||
pub pascal_name: String,
|
pub default: Option<DefaultValue>,
|
||||||
#[serde(rename = "hasParams", default)]
|
pub shape: TypeShape,
|
||||||
pub has_params: bool,
|
|
||||||
#[serde(rename = "hasReactMessage", default)]
|
|
||||||
pub has_react_message: bool,
|
|
||||||
#[serde(rename = "hasDjangoMessage", default)]
|
|
||||||
pub has_django_message: bool,
|
|
||||||
#[serde(rename = "paramsType", default)]
|
|
||||||
pub params_type: Option<String>,
|
|
||||||
#[serde(rename = "reactMessageType", default)]
|
|
||||||
pub react_message_type: Option<String>,
|
|
||||||
#[serde(rename = "djangoMessageType", default)]
|
|
||||||
pub django_message_type: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TypeShape {
|
||||||
|
Primitive(Primitive),
|
||||||
|
Ref(String),
|
||||||
|
List(Box<TypeShape>),
|
||||||
|
Optional(Box<TypeShape>),
|
||||||
|
Enum(Vec<String>),
|
||||||
|
/// Multi-arm union with two or more non-null branches.
|
||||||
|
Union(Vec<TypeShape>),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Primitive { Integer, Number, Boolean, String }
|
||||||
|
|
||||||
|
|
||||||
|
impl Primitive {
|
||||||
|
fn parse(s: &str) -> Result<Self> {
|
||||||
|
match s {
|
||||||
|
"integer" => Ok(Primitive::Integer),
|
||||||
|
"number" => Ok(Primitive::Number),
|
||||||
|
"boolean" => Ok(Primitive::Boolean),
|
||||||
|
"string" => Ok(Primitive::String),
|
||||||
|
other => bail!("unknown primitive {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum DefaultValue {
|
||||||
|
Integer(i64),
|
||||||
|
Number(f64),
|
||||||
|
Boolean(bool),
|
||||||
|
String(String),
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Functions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct MizanFunction {
|
pub struct MizanFunction {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
||||||
#[serde(rename = "camelName")]
|
|
||||||
pub camel_name: String,
|
pub camel_name: String,
|
||||||
|
|
||||||
#[serde(rename = "hasInput")]
|
|
||||||
pub has_input: bool,
|
pub has_input: bool,
|
||||||
|
|
||||||
#[serde(rename = "inputType")]
|
|
||||||
pub input_type: Option<String>,
|
pub input_type: Option<String>,
|
||||||
|
|
||||||
#[serde(rename = "outputType")]
|
|
||||||
pub output_type: String,
|
pub output_type: String,
|
||||||
|
|
||||||
#[serde(rename = "outputNullable", default)]
|
|
||||||
pub output_nullable: bool,
|
pub output_nullable: bool,
|
||||||
|
|
||||||
pub transport: Transport,
|
pub transport: Transport,
|
||||||
|
|
||||||
#[serde(rename = "isContext", default)]
|
|
||||||
pub is_context: IsContext,
|
pub is_context: IsContext,
|
||||||
|
|
||||||
#[serde(rename = "isForm", default)]
|
|
||||||
pub is_form: bool,
|
pub is_form: bool,
|
||||||
|
|
||||||
#[serde(rename = "formName", default)]
|
|
||||||
pub form_name: Option<String>,
|
pub form_name: Option<String>,
|
||||||
|
|
||||||
#[serde(rename = "formRole", default)]
|
|
||||||
pub form_role: Option<String>,
|
pub form_role: Option<String>,
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub affects: Vec<AffectTarget>,
|
pub affects: Vec<AffectTarget>,
|
||||||
|
|
||||||
/// Names of contexts whose state is patched by this function's return
|
|
||||||
/// body via the kernel's `splice_slot` merger. Empty when the function
|
|
||||||
/// is not a merge target.
|
|
||||||
#[serde(default)]
|
|
||||||
pub merge: Vec<String>,
|
pub merge: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
pub enum Transport { Http, Websocket, Both }
|
||||||
pub enum Transport {
|
|
||||||
#[default]
|
|
||||||
Http,
|
impl Transport {
|
||||||
Websocket,
|
fn parse(s: &str) -> Result<Self> {
|
||||||
Both,
|
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
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
/// functions and a string (`"global"`, `"user"`, …) for context-grouped
|
pub enum IsContext { No, Yes(String) }
|
||||||
/// 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),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IsContext {
|
impl IsContext {
|
||||||
pub fn as_str(&self) -> Option<&str> {
|
pub fn as_str(&self) -> Option<&str> {
|
||||||
@@ -127,122 +126,343 @@ impl IsContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for IsContext {
|
|
||||||
fn deserialize<D>(de: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let v = serde_json::Value::deserialize(de)?;
|
|
||||||
match v {
|
|
||||||
serde_json::Value::Bool(false) => Ok(IsContext::No),
|
|
||||||
serde_json::Value::Bool(true) => Err(serde::de::Error::custom(
|
|
||||||
"isContext: bare `true` is not a valid context name",
|
|
||||||
)),
|
|
||||||
serde_json::Value::String(s) => Ok(IsContext::Yes(s)),
|
|
||||||
serde_json::Value::Null => Ok(IsContext::No),
|
|
||||||
other => Err(serde::de::Error::custom(format!(
|
|
||||||
"isContext: expected `false` or string, got {other:?}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
pub struct AffectTarget {
|
pub struct AffectTarget {
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub kind: AffectKind,
|
pub kind: AffectKind,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
|
||||||
pub context: Option<String>,
|
pub context: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
pub enum AffectKind { Context, Function }
|
||||||
pub enum AffectKind {
|
|
||||||
Context,
|
|
||||||
Function,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default, Clone)]
|
// ─── Contexts ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct MizanContext {
|
pub struct MizanContext {
|
||||||
#[serde(default)]
|
|
||||||
pub functions: Vec<String>,
|
pub functions: Vec<String>,
|
||||||
#[serde(default)]
|
|
||||||
pub params: IndexMap<String, ContextParam>,
|
pub params: IndexMap<String, ContextParam>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ContextParam {
|
pub struct ContextParam {
|
||||||
#[serde(rename = "type")]
|
pub ty: Primitive,
|
||||||
pub ty: String,
|
|
||||||
|
|
||||||
pub required: bool,
|
pub required: bool,
|
||||||
|
|
||||||
#[serde(rename = "sharedBy", default)]
|
|
||||||
pub shared_by: Vec<String>,
|
pub shared_by: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
// ─── Channels (Django-only) ─────────────────────────────────────────────────
|
||||||
pub struct Components {
|
|
||||||
#[serde(default)]
|
|
||||||
pub schemas: IndexMap<String, JsonSchema>,
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MizanChannel {
|
||||||
|
pub name: String,
|
||||||
|
pub pascal_name: String,
|
||||||
|
pub params_type: Option<String>,
|
||||||
|
pub react_message_type: Option<String>,
|
||||||
|
pub django_message_type: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// JSON Schema subset used by the emit targets. Mirrors the surface the
|
impl MizanChannel {
|
||||||
/// existing JS adapters traverse (`$ref`, `anyOf`, `enum`, `type`, `items`,
|
pub fn has_params(&self) -> bool { self.params_type.is_some() }
|
||||||
/// `properties`, `required`, `nullable`). Unknown fields are stashed in
|
pub fn has_react_message(&self) -> bool { self.react_message_type.is_some() }
|
||||||
/// `extra` so backends can include schema annotations the codegen ignores.
|
pub fn has_django_message(&self) -> bool { self.django_message_type.is_some() }
|
||||||
#[derive(Debug, Deserialize, Default, Clone)]
|
|
||||||
pub struct JsonSchema {
|
|
||||||
#[serde(rename = "type", default)]
|
|
||||||
pub ty: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "$ref", default)]
|
|
||||||
pub r#ref: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "enum", default)]
|
|
||||||
pub r#enum: Option<Vec<serde_json::Value>>,
|
|
||||||
|
|
||||||
#[serde(rename = "anyOf", default)]
|
|
||||||
pub any_of: Option<Vec<JsonSchema>>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub nullable: bool,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub items: Option<Box<JsonSchema>>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub properties: Option<IndexMap<String, JsonSchema>>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub required: Vec<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "additionalProperties", default)]
|
|
||||||
pub additional_properties: Option<serde_json::Value>,
|
|
||||||
|
|
||||||
/// Presence of this field means the schema has a default — the server
|
|
||||||
/// always populates it. Consumers can treat the field as non-optional
|
|
||||||
/// even if it's absent from `required`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub default: Option<serde_json::Value>,
|
|
||||||
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub extra: BTreeMap<String, serde_json::Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl JsonSchema {
|
// ─── KDL parsing ────────────────────────────────────────────────────────────
|
||||||
/// `$ref: "#/components/schemas/Foo"` → `Some("Foo")`.
|
|
||||||
pub fn ref_name(&self) -> Option<&str> {
|
|
||||||
self.r#ref
|
pub fn parse_ir(source: &str) -> Result<MizanIR> {
|
||||||
.as_deref()
|
let doc: KdlDocument = source.parse()
|
||||||
.and_then(|s| s.strip_prefix("#/components/schemas/"))
|
.map_err(|e| anyhow!("KDL parse error: {e}"))?;
|
||||||
|
|
||||||
|
let mut ir = MizanIR::default();
|
||||||
|
for node in doc.nodes() {
|
||||||
|
match node.name().value() {
|
||||||
|
"type" => {
|
||||||
|
let (name, ty) = parse_named_type(node)?;
|
||||||
|
ir.types.insert(name, ty);
|
||||||
|
}
|
||||||
|
"function" => ir.functions.push(parse_function(node)?),
|
||||||
|
"context" => {
|
||||||
|
let (name, ctx) = parse_context(node)?;
|
||||||
|
ir.contexts.insert(name, ctx);
|
||||||
|
}
|
||||||
|
"channel" => ir.channels.push(parse_channel(node)?),
|
||||||
|
other => bail!("unknown top-level KDL node {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ir)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_named_type(node: &KdlNode) -> Result<(String, NamedType)> {
|
||||||
|
let name = first_string_arg(node)
|
||||||
|
.context("`type` requires a name as its first argument")?;
|
||||||
|
let children = node.children()
|
||||||
|
.ok_or_else(|| anyhow!("type {name:?}: missing children block"))?;
|
||||||
|
let kind_node = single_child(children, &format!("type {name:?}"))?;
|
||||||
|
let kind = match kind_node.name().value() {
|
||||||
|
"struct" => NamedType::Struct(parse_struct_fields(kind_node)?),
|
||||||
|
"list" => NamedType::List(type_child_of(kind_node, &format!("type {name:?} list"))?),
|
||||||
|
"enum" => NamedType::Enum(parse_string_args(kind_node)),
|
||||||
|
"alias" => NamedType::Alias(type_child_of(kind_node, &format!("type {name:?} alias"))?),
|
||||||
|
other => bail!("type {name:?}: unknown shape node {other:?}"),
|
||||||
|
};
|
||||||
|
Ok((name, kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_struct_fields(struct_node: &KdlNode) -> Result<Vec<StructField>> {
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
let Some(children) = struct_node.children() else { return Ok(fields); };
|
||||||
|
for child in children.nodes() {
|
||||||
|
if child.name().value() != "field" {
|
||||||
|
bail!("struct: unexpected node {:?}", child.name().value());
|
||||||
|
}
|
||||||
|
fields.push(parse_struct_field(child)?);
|
||||||
|
}
|
||||||
|
Ok(fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_struct_field(field_node: &KdlNode) -> Result<StructField> {
|
||||||
|
let name = first_string_arg(field_node).context("`field` requires a name")?;
|
||||||
|
let required = bool_prop(field_node, "required").unwrap_or(true);
|
||||||
|
let default = field_node.entry("default")
|
||||||
|
.map(|e| parse_default_value(e.value()))
|
||||||
|
.transpose()?;
|
||||||
|
let shape = type_child_of(field_node, &format!("field {name:?}"))?;
|
||||||
|
Ok(StructField { name, required, default, shape })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_default_value(v: &KdlValue) -> Result<DefaultValue> {
|
||||||
|
if v.is_null() { return Ok(DefaultValue::Null); }
|
||||||
|
if let Some(b) = v.as_bool() { return Ok(DefaultValue::Boolean(b)); }
|
||||||
|
if let Some(i) = v.as_integer() { return Ok(DefaultValue::Integer(i as i64)); }
|
||||||
|
if let Some(f) = v.as_float() { return Ok(DefaultValue::Number(f)); }
|
||||||
|
if let Some(s) = v.as_string() { return Ok(DefaultValue::String(s.to_string())); }
|
||||||
|
bail!("unsupported default literal: {v:?}")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn type_child_of(parent: &KdlNode, label: &str) -> Result<TypeShape> {
|
||||||
|
let children = parent.children()
|
||||||
|
.ok_or_else(|| anyhow!("{label}: missing children for type-shape"))?;
|
||||||
|
let nodes = children.nodes();
|
||||||
|
if nodes.len() != 1 {
|
||||||
|
bail!("{label}: expected exactly one type-shape child, got {}", nodes.len());
|
||||||
|
}
|
||||||
|
parse_type_shape(&nodes[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
|
||||||
|
match node.name().value() {
|
||||||
|
"primitive" => Ok(TypeShape::Primitive(Primitive::parse(&first_string_arg(node)?)?)),
|
||||||
|
"ref" => Ok(TypeShape::Ref(first_string_arg(node)?)),
|
||||||
|
"list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
|
||||||
|
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
|
||||||
|
"enum" => Ok(TypeShape::Enum(parse_string_args(node))),
|
||||||
|
"union" => {
|
||||||
|
let children = node.children()
|
||||||
|
.ok_or_else(|| anyhow!("union: missing children"))?;
|
||||||
|
let branches: Result<Vec<TypeShape>> = children.nodes().iter()
|
||||||
|
.map(parse_type_shape).collect();
|
||||||
|
Ok(TypeShape::Union(branches?))
|
||||||
|
}
|
||||||
|
other => bail!("unknown type-shape node {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
|
||||||
|
let name = first_string_arg(node)
|
||||||
|
.context("`function` requires a name as its first argument")?;
|
||||||
|
let children = node.children()
|
||||||
|
.ok_or_else(|| anyhow!("function {name:?}: missing children"))?;
|
||||||
|
|
||||||
|
let mut camel = None;
|
||||||
|
let mut has_input = false;
|
||||||
|
let mut input_type = None;
|
||||||
|
let mut output_type = None;
|
||||||
|
let mut output_nullable = false;
|
||||||
|
let mut transport = Transport::Http;
|
||||||
|
let mut is_context = IsContext::No;
|
||||||
|
let mut is_form = false;
|
||||||
|
let mut form_name = None;
|
||||||
|
let mut form_role = None;
|
||||||
|
let mut affects: Vec<AffectTarget> = Vec::new();
|
||||||
|
let mut merge: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for child in children.nodes() {
|
||||||
|
match child.name().value() {
|
||||||
|
"camel" => camel = Some(string_arg(child, "camel")?),
|
||||||
|
"has-input" => has_input = bool_arg(child, "has-input")?,
|
||||||
|
"input" => input_type = Some(string_arg(child, "input")?),
|
||||||
|
"output" => output_type = Some(string_arg(child, "output")?),
|
||||||
|
"output-nullable" => output_nullable = bool_arg(child, "output-nullable")?,
|
||||||
|
"transport" => transport = Transport::parse(&string_arg(child, "transport")?)?,
|
||||||
|
"context" => is_context = IsContext::Yes(string_arg(child, "context")?),
|
||||||
|
"is-form" => is_form = bool_arg(child, "is-form")?,
|
||||||
|
"form-name" => form_name = Some(string_arg(child, "form-name")?),
|
||||||
|
"form-role" => form_role = Some(string_arg(child, "form-role")?),
|
||||||
|
"affects" => affects.push(AffectTarget {
|
||||||
|
kind: AffectKind::Context,
|
||||||
|
name: string_arg(child, "affects")?,
|
||||||
|
context: None,
|
||||||
|
}),
|
||||||
|
"merge" => merge.push(string_arg(child, "merge")?),
|
||||||
|
other => bail!("function {name:?}: unknown child {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(MizanFunction {
|
||||||
|
name: name.clone(),
|
||||||
|
camel_name: camel.ok_or_else(|| anyhow!("function {name:?}: missing `camel`"))?,
|
||||||
|
has_input,
|
||||||
|
input_type,
|
||||||
|
output_type: output_type.ok_or_else(|| anyhow!("function {name:?}: missing `output`"))?,
|
||||||
|
output_nullable,
|
||||||
|
transport,
|
||||||
|
is_context,
|
||||||
|
is_form,
|
||||||
|
form_name,
|
||||||
|
form_role,
|
||||||
|
affects,
|
||||||
|
merge,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_context(node: &KdlNode) -> Result<(String, MizanContext)> {
|
||||||
|
let name = first_string_arg(node).context("`context` requires a name")?;
|
||||||
|
let mut ctx = MizanContext::default();
|
||||||
|
let Some(children) = node.children() else { return Ok((name, ctx)); };
|
||||||
|
for child in children.nodes() {
|
||||||
|
match child.name().value() {
|
||||||
|
"function" => ctx.functions.push(string_arg(child, "function")?),
|
||||||
|
"param" => {
|
||||||
|
let (pname, param) = parse_context_param(child)?;
|
||||||
|
ctx.params.insert(pname, param);
|
||||||
|
}
|
||||||
|
other => bail!("context {name:?}: unknown child {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((name, ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_context_param(node: &KdlNode) -> Result<(String, ContextParam)> {
|
||||||
|
let pname = first_string_arg(node).context("`param` requires a name")?;
|
||||||
|
let children = node.children()
|
||||||
|
.ok_or_else(|| anyhow!("param {pname:?}: missing children"))?;
|
||||||
|
let mut ty = None;
|
||||||
|
let mut required = false;
|
||||||
|
let mut shared_by = Vec::new();
|
||||||
|
for child in children.nodes() {
|
||||||
|
match child.name().value() {
|
||||||
|
"type" => ty = Some(Primitive::parse(&string_arg(child, "type")?)?),
|
||||||
|
"required" => required = bool_arg(child, "required")?,
|
||||||
|
"shared-by" => shared_by.push(string_arg(child, "shared-by")?),
|
||||||
|
other => bail!("param {pname:?}: unknown child {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((pname.clone(), ContextParam {
|
||||||
|
ty: ty.ok_or_else(|| anyhow!("param {pname:?}: missing `type`"))?,
|
||||||
|
required,
|
||||||
|
shared_by,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_channel(node: &KdlNode) -> Result<MizanChannel> {
|
||||||
|
let name = first_string_arg(node).context("`channel` requires a name")?;
|
||||||
|
let children = node.children()
|
||||||
|
.ok_or_else(|| anyhow!("channel {name:?}: missing children"))?;
|
||||||
|
let mut pascal_name = None;
|
||||||
|
let mut params_type = None;
|
||||||
|
let mut react_message_type = None;
|
||||||
|
let mut django_message_type = None;
|
||||||
|
for child in children.nodes() {
|
||||||
|
match child.name().value() {
|
||||||
|
"pascal-name" => pascal_name = Some(string_arg(child, "pascal-name")?),
|
||||||
|
"params" => params_type = Some(string_arg(child, "params")?),
|
||||||
|
"react-message" => react_message_type = Some(string_arg(child, "react-message")?),
|
||||||
|
"django-message" => django_message_type = Some(string_arg(child, "django-message")?),
|
||||||
|
other => bail!("channel {name:?}: unknown child {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(MizanChannel {
|
||||||
|
name: name.clone(),
|
||||||
|
pascal_name: pascal_name.ok_or_else(|| anyhow!("channel {name:?}: missing `pascal-name`"))?,
|
||||||
|
params_type,
|
||||||
|
react_message_type,
|
||||||
|
django_message_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── KDL accessor helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
fn first_string_arg(node: &KdlNode) -> Result<String> {
|
||||||
|
let entry = node.entries().iter()
|
||||||
|
.find(|e| e.name().is_none())
|
||||||
|
.ok_or_else(|| anyhow!("node {:?}: missing positional argument", node.name().value()))?;
|
||||||
|
entry.value().as_string()
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| anyhow!("node {:?}: positional argument is not a string", node.name().value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn string_arg(node: &KdlNode, label: &str) -> Result<String> {
|
||||||
|
first_string_arg(node).context(format!("{label}: requires a string argument"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn bool_arg(node: &KdlNode, label: &str) -> Result<bool> {
|
||||||
|
node.entries().iter()
|
||||||
|
.find(|e| e.name().is_none())
|
||||||
|
.and_then(|e| e.value().as_bool())
|
||||||
|
.ok_or_else(|| anyhow!("{label}: missing positional bool argument"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn bool_prop(node: &KdlNode, key: &str) -> Option<bool> {
|
||||||
|
node.entry(key).and_then(|e| e.value().as_bool())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_string_args(node: &KdlNode) -> Vec<String> {
|
||||||
|
node.entries().iter()
|
||||||
|
.filter(|e| e.name().is_none())
|
||||||
|
.filter_map(|e| e.value().as_string().map(str::to_string))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn single_child<'a>(children: &'a KdlDocument, label: &str) -> Result<&'a KdlNode> {
|
||||||
|
let nodes = children.nodes();
|
||||||
|
if nodes.len() != 1 {
|
||||||
|
bail!("{label}: expected exactly one child node, got {}", nodes.len());
|
||||||
|
}
|
||||||
|
Ok(&nodes[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Library entry point ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
|
||||||
|
parse_ir(source)
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ fn main() -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[mizan] Loaded {} function(s), {} context group(s), {} schema(s)",
|
"[mizan] Loaded {} function(s), {} context group(s), {} type(s)",
|
||||||
ir.functions.len(),
|
ir.functions.len(),
|
||||||
ir.contexts.len(),
|
ir.contexts.len(),
|
||||||
ir.components.schemas.len(),
|
ir.types.len(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stage 1 is the framework-agnostic foundation that react/vue/svelte
|
// Stage 1 is the framework-agnostic foundation that react/vue/svelte
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ fn fixture_config() -> Config {
|
|||||||
#[test]
|
#[test]
|
||||||
fn channels_target_emits_expected_files() {
|
fn channels_target_emits_expected_files() {
|
||||||
let raw = std::fs::read_to_string(
|
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();
|
).unwrap();
|
||||||
let ir = parse_ir_from_str(&raw).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() {
|
fn channels_target_emits_nothing_when_empty() {
|
||||||
// AFI fixture has no channels — target should produce zero files.
|
// AFI fixture has no channels — target should produce zero files.
|
||||||
let raw = std::fs::read_to_string(
|
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();
|
).unwrap();
|
||||||
let ir = parse_ir_from_str(&raw).unwrap();
|
let ir = parse_ir_from_str(&raw).unwrap();
|
||||||
let files = ChannelsTarget.emit(&ir, &fixture_config());
|
let files = ChannelsTarget.emit(&ir, &fixture_config());
|
||||||
|
|||||||
187
protocol/mizan-codegen/tests/fixtures/afi_ir.kdl
vendored
Normal file
187
protocol/mizan-codegen/tests/fixtures/afi_ir.kdl
vendored
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
type "OrderOutput" {
|
||||||
|
struct {
|
||||||
|
field "id" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
field "user_id" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
field "total" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "echoInput" {
|
||||||
|
struct {
|
||||||
|
field "text" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "echoOutput" {
|
||||||
|
struct {
|
||||||
|
field "message" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "findUserInput" {
|
||||||
|
struct {
|
||||||
|
field "user_id" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "findUserOutput" {
|
||||||
|
struct {
|
||||||
|
field "user_id" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
field "name" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "renameUserInput" {
|
||||||
|
struct {
|
||||||
|
field "user_id" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
field "name" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "renameUserOutput" {
|
||||||
|
struct {
|
||||||
|
field "user_id" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
field "name" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "updateProfileInput" {
|
||||||
|
struct {
|
||||||
|
field "user_id" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
field "name" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "updateProfileOutput" {
|
||||||
|
struct {
|
||||||
|
field "ok" {
|
||||||
|
primitive "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "userOrdersInput" {
|
||||||
|
struct {
|
||||||
|
field "user_id" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "userOrdersOutput" {
|
||||||
|
alias {
|
||||||
|
list {
|
||||||
|
ref "OrderOutput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "userProfileInput" {
|
||||||
|
struct {
|
||||||
|
field "user_id" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "userProfileOutput" {
|
||||||
|
struct {
|
||||||
|
field "user_id" {
|
||||||
|
primitive "integer"
|
||||||
|
}
|
||||||
|
field "name" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type "whoamiOutput" {
|
||||||
|
struct {
|
||||||
|
field "email" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
field "authenticated" {
|
||||||
|
primitive "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function "echo" {
|
||||||
|
camel "echo"
|
||||||
|
has-input #true
|
||||||
|
input "echoInput"
|
||||||
|
output "echoOutput"
|
||||||
|
transport "http"
|
||||||
|
}
|
||||||
|
function "whoami" {
|
||||||
|
camel "whoami"
|
||||||
|
has-input #false
|
||||||
|
output "whoamiOutput"
|
||||||
|
transport "http"
|
||||||
|
}
|
||||||
|
function "user_profile" {
|
||||||
|
camel "userProfile"
|
||||||
|
has-input #true
|
||||||
|
input "userProfileInput"
|
||||||
|
output "userProfileOutput"
|
||||||
|
transport "http"
|
||||||
|
context "user"
|
||||||
|
}
|
||||||
|
function "user_orders" {
|
||||||
|
camel "userOrders"
|
||||||
|
has-input #true
|
||||||
|
input "userOrdersInput"
|
||||||
|
output "userOrdersOutput"
|
||||||
|
transport "http"
|
||||||
|
context "user"
|
||||||
|
}
|
||||||
|
function "update_profile" {
|
||||||
|
camel "updateProfile"
|
||||||
|
has-input #true
|
||||||
|
input "updateProfileInput"
|
||||||
|
output "updateProfileOutput"
|
||||||
|
transport "http"
|
||||||
|
affects "user"
|
||||||
|
}
|
||||||
|
function "find_user" {
|
||||||
|
camel "findUser"
|
||||||
|
has-input #true
|
||||||
|
input "findUserInput"
|
||||||
|
output "findUserOutput"
|
||||||
|
output-nullable #true
|
||||||
|
transport "http"
|
||||||
|
}
|
||||||
|
function "rename_user" {
|
||||||
|
camel "renameUser"
|
||||||
|
has-input #true
|
||||||
|
input "renameUserInput"
|
||||||
|
output "renameUserOutput"
|
||||||
|
transport "http"
|
||||||
|
merge "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
context "user" {
|
||||||
|
function "user_profile"
|
||||||
|
function "user_orders"
|
||||||
|
param "user_id" {
|
||||||
|
type "integer"
|
||||||
|
required #true
|
||||||
|
shared-by "user_profile"
|
||||||
|
shared-by "user_orders"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,21 +6,11 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
class HTTPValidationError(BaseModel):
|
|
||||||
detail: list[ValidationError] | None = None
|
|
||||||
|
|
||||||
class OrderOutput(BaseModel):
|
class OrderOutput(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
total: 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):
|
class EchoInput(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
14
protocol/mizan-codegen/tests/fixtures/baselines/react/index.ts
vendored
Normal file
14
protocol/mizan-codegen/tests/fixtures/baselines/react/index.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
export * from './types'
|
||||||
|
|
||||||
|
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
|
||||||
|
|
||||||
|
export { callEcho } from './functions/echo'
|
||||||
|
export { callWhoami } from './functions/whoami'
|
||||||
|
export { callUpdateProfile } from './mutations/updateProfile'
|
||||||
|
export { callFindUser } from './functions/findUser'
|
||||||
|
export { callRenameUser } from './functions/renameUser'
|
||||||
|
|
||||||
|
// Stage 2 framework adapter
|
||||||
|
export * from './react'
|
||||||
64
protocol/mizan-codegen/tests/fixtures/baselines/react/types.ts
vendored
Normal file
64
protocol/mizan-codegen/tests/fixtures/baselines/react/types.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
export interface OrderOutput {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface echoInput {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface echoOutput {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface findUserInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface findUserOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface renameUserInput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface renameUserOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface updateProfileInput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface updateProfileOutput {
|
||||||
|
ok: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface userOrdersInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type userOrdersOutput = OrderOutput[]
|
||||||
|
|
||||||
|
export interface userProfileInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface userProfileOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface whoamiOutput {
|
||||||
|
email: string
|
||||||
|
authenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,11 +4,6 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct HTTPValidationError {
|
|
||||||
pub detail: Option<Vec<ValidationError>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OrderOutput {
|
pub struct OrderOutput {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -16,16 +11,6 @@ pub struct OrderOutput {
|
|||||||
pub total: i64,
|
pub total: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ValidationError {
|
|
||||||
pub loc: Vec<serde_json::Value>,
|
|
||||||
pub msg: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub r#type: String,
|
|
||||||
pub input: Option<serde_json::Value>,
|
|
||||||
pub ctx: Option<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EchoInput {
|
pub struct EchoInput {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
@@ -75,9 +60,7 @@ pub struct UserOrdersInput {
|
|||||||
pub user_id: i64,
|
pub user_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
pub type UserOrdersOutput = Vec<OrderOutput>;
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct UserOrdersOutput(pub Vec<OrderOutput>);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct UserProfileInput {
|
pub struct UserProfileInput {
|
||||||
18
protocol/mizan-codegen/tests/fixtures/baselines/stage1/contexts/user.ts
vendored
Normal file
18
protocol/mizan-codegen/tests/fixtures/baselines/stage1/contexts/user.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanFetch } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { userProfileOutput, userOrdersOutput } from '../types'
|
||||||
|
|
||||||
|
export interface UserContextData {
|
||||||
|
user_profile: userProfileOutput
|
||||||
|
user_orders: userOrdersOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserContextParams {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
|
||||||
|
return mizanFetch('user', params)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/echo.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/echo.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { echoInput, echoOutput } from '../types'
|
||||||
|
|
||||||
|
export function callEcho(args: echoInput): Promise<echoOutput> {
|
||||||
|
return mizanCall('echo', args)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/findUser.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/findUser.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { findUserInput, findUserOutput } from '../types'
|
||||||
|
|
||||||
|
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
|
||||||
|
return mizanCall('find_user', args)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/renameUser.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/renameUser.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { renameUserInput, renameUserOutput } from '../types'
|
||||||
|
|
||||||
|
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
|
||||||
|
return mizanCall('rename_user', args)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/whoami.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/stage1/functions/whoami.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { whoamiOutput } from '../types'
|
||||||
|
|
||||||
|
export function callWhoami(): Promise<whoamiOutput> {
|
||||||
|
return mizanCall('whoami', {})
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/stage1/mutations/updateProfile.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/stage1/mutations/updateProfile.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { updateProfileInput, updateProfileOutput } from '../types'
|
||||||
|
|
||||||
|
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
|
||||||
|
return mizanCall('update_profile', args)
|
||||||
|
}
|
||||||
64
protocol/mizan-codegen/tests/fixtures/baselines/stage1/types.ts
vendored
Normal file
64
protocol/mizan-codegen/tests/fixtures/baselines/stage1/types.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
export interface OrderOutput {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface echoInput {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface echoOutput {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface findUserInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface findUserOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface renameUserInput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface renameUserOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface updateProfileInput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface updateProfileOutput {
|
||||||
|
ok: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface userOrdersInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type userOrdersOutput = OrderOutput[]
|
||||||
|
|
||||||
|
export interface userProfileInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface userProfileOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface whoamiOutput {
|
||||||
|
email: string
|
||||||
|
authenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
18
protocol/mizan-codegen/tests/fixtures/baselines/svelte/contexts/user.ts
vendored
Normal file
18
protocol/mizan-codegen/tests/fixtures/baselines/svelte/contexts/user.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanFetch } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { userProfileOutput, userOrdersOutput } from '../types'
|
||||||
|
|
||||||
|
export interface UserContextData {
|
||||||
|
user_profile: userProfileOutput
|
||||||
|
user_orders: userOrdersOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserContextParams {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
|
||||||
|
return mizanFetch('user', params)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/echo.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/echo.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { echoInput, echoOutput } from '../types'
|
||||||
|
|
||||||
|
export function callEcho(args: echoInput): Promise<echoOutput> {
|
||||||
|
return mizanCall('echo', args)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/findUser.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/findUser.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { findUserInput, findUserOutput } from '../types'
|
||||||
|
|
||||||
|
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
|
||||||
|
return mizanCall('find_user', args)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/renameUser.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/renameUser.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { renameUserInput, renameUserOutput } from '../types'
|
||||||
|
|
||||||
|
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
|
||||||
|
return mizanCall('rename_user', args)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/whoami.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/svelte/functions/whoami.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { whoamiOutput } from '../types'
|
||||||
|
|
||||||
|
export function callWhoami(): Promise<whoamiOutput> {
|
||||||
|
return mizanCall('whoami', {})
|
||||||
|
}
|
||||||
14
protocol/mizan-codegen/tests/fixtures/baselines/svelte/index.ts
vendored
Normal file
14
protocol/mizan-codegen/tests/fixtures/baselines/svelte/index.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
export * from './types'
|
||||||
|
|
||||||
|
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
|
||||||
|
|
||||||
|
export { callEcho } from './functions/echo'
|
||||||
|
export { callWhoami } from './functions/whoami'
|
||||||
|
export { callUpdateProfile } from './mutations/updateProfile'
|
||||||
|
export { callFindUser } from './functions/findUser'
|
||||||
|
export { callRenameUser } from './functions/renameUser'
|
||||||
|
|
||||||
|
// Stage 2 framework adapter
|
||||||
|
export * from './svelte'
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/svelte/mutations/updateProfile.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/svelte/mutations/updateProfile.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { updateProfileInput, updateProfileOutput } from '../types'
|
||||||
|
|
||||||
|
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
|
||||||
|
return mizanCall('update_profile', args)
|
||||||
|
}
|
||||||
64
protocol/mizan-codegen/tests/fixtures/baselines/svelte/types.ts
vendored
Normal file
64
protocol/mizan-codegen/tests/fixtures/baselines/svelte/types.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
export interface OrderOutput {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface echoInput {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface echoOutput {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface findUserInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface findUserOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface renameUserInput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface renameUserOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface updateProfileInput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface updateProfileOutput {
|
||||||
|
ok: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface userOrdersInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type userOrdersOutput = OrderOutput[]
|
||||||
|
|
||||||
|
export interface userProfileInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface userProfileOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface whoamiOutput {
|
||||||
|
email: string
|
||||||
|
authenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
18
protocol/mizan-codegen/tests/fixtures/baselines/vue/contexts/user.ts
vendored
Normal file
18
protocol/mizan-codegen/tests/fixtures/baselines/vue/contexts/user.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanFetch } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { userProfileOutput, userOrdersOutput } from '../types'
|
||||||
|
|
||||||
|
export interface UserContextData {
|
||||||
|
user_profile: userProfileOutput
|
||||||
|
user_orders: userOrdersOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserContextParams {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
|
||||||
|
return mizanFetch('user', params)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/echo.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/echo.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { echoInput, echoOutput } from '../types'
|
||||||
|
|
||||||
|
export function callEcho(args: echoInput): Promise<echoOutput> {
|
||||||
|
return mizanCall('echo', args)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/findUser.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/findUser.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { findUserInput, findUserOutput } from '../types'
|
||||||
|
|
||||||
|
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
|
||||||
|
return mizanCall('find_user', args)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/renameUser.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/renameUser.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { renameUserInput, renameUserOutput } from '../types'
|
||||||
|
|
||||||
|
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
|
||||||
|
return mizanCall('rename_user', args)
|
||||||
|
}
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/whoami.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/vue/functions/whoami.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { whoamiOutput } from '../types'
|
||||||
|
|
||||||
|
export function callWhoami(): Promise<whoamiOutput> {
|
||||||
|
return mizanCall('whoami', {})
|
||||||
|
}
|
||||||
14
protocol/mizan-codegen/tests/fixtures/baselines/vue/index.ts
vendored
Normal file
14
protocol/mizan-codegen/tests/fixtures/baselines/vue/index.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
export * from './types'
|
||||||
|
|
||||||
|
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
|
||||||
|
|
||||||
|
export { callEcho } from './functions/echo'
|
||||||
|
export { callWhoami } from './functions/whoami'
|
||||||
|
export { callUpdateProfile } from './mutations/updateProfile'
|
||||||
|
export { callFindUser } from './functions/findUser'
|
||||||
|
export { callRenameUser } from './functions/renameUser'
|
||||||
|
|
||||||
|
// Stage 2 framework adapter
|
||||||
|
export * from './vue'
|
||||||
9
protocol/mizan-codegen/tests/fixtures/baselines/vue/mutations/updateProfile.ts
vendored
Normal file
9
protocol/mizan-codegen/tests/fixtures/baselines/vue/mutations/updateProfile.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
import { mizanCall } from '@mizan/base'
|
||||||
|
|
||||||
|
import type { updateProfileInput, updateProfileOutput } from '../types'
|
||||||
|
|
||||||
|
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
|
||||||
|
return mizanCall('update_profile', args)
|
||||||
|
}
|
||||||
64
protocol/mizan-codegen/tests/fixtures/baselines/vue/types.ts
vendored
Normal file
64
protocol/mizan-codegen/tests/fixtures/baselines/vue/types.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// AUTO-GENERATED by mizan — do not edit
|
||||||
|
|
||||||
|
export interface OrderOutput {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface echoInput {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface echoOutput {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface findUserInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface findUserOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface renameUserInput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface renameUserOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface updateProfileInput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface updateProfileOutput {
|
||||||
|
ok: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface userOrdersInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type userOrdersOutput = OrderOutput[]
|
||||||
|
|
||||||
|
export interface userProfileInput {
|
||||||
|
user_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface userProfileOutput {
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface whoamiOutput {
|
||||||
|
email: string
|
||||||
|
authenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
46
protocol/mizan-codegen/tests/fixtures/channels_ir.kdl
vendored
Normal file
46
protocol/mizan-codegen/tests/fixtures/channels_ir.kdl
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
type "ChatChannelParams" {
|
||||||
|
struct {
|
||||||
|
field "room_id" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type "ChatReactMessage" {
|
||||||
|
struct {
|
||||||
|
field "text" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type "ChatDjangoMessage" {
|
||||||
|
struct {
|
||||||
|
field "text" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
field "from_user" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type "NotificationsDjangoMessage" {
|
||||||
|
struct {
|
||||||
|
field "body" {
|
||||||
|
primitive "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel "chat" {
|
||||||
|
pascal-name "Chat"
|
||||||
|
params "ChatChannelParams"
|
||||||
|
react-message "ChatReactMessage"
|
||||||
|
django-message "ChatDjangoMessage"
|
||||||
|
}
|
||||||
|
|
||||||
|
channel "notifications" {
|
||||||
|
pascal-name "Notifications"
|
||||||
|
django-message "NotificationsDjangoMessage"
|
||||||
|
}
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
//! against `tests/afi/fixture.py`. Each test exercises a different facet
|
||||||
//! of the IR — function set, per-function field decoding, context-param
|
//! of the IR — function set, per-function field decoding, context-param
|
||||||
//! elevation, and components.schemas presence — to confirm the typed
|
//! elevation, and named-type presence — to confirm the typed Rust structs
|
||||||
//! Rust structs match the JSON shape the backends emit.
|
//! match the KDL shape the backend emits.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use mizan_codegen::fetch::parse_ir_from_str;
|
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 {
|
fn load_fixture() -> mizan_codegen::ir::MizanIR {
|
||||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
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)
|
let raw = std::fs::read_to_string(&path)
|
||||||
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
|
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
|
||||||
parse_ir_from_str(&raw).unwrap_or_else(|e| panic!("parse IR: {e}"))
|
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 ir = load_fixture();
|
||||||
let names: Vec<&str> = ir.functions.iter().map(|f| f.name.as_str()).collect();
|
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());
|
assert_eq!(ir.functions.len(), 7, "expected 7 functions, got {}: {names:?}", ir.functions.len());
|
||||||
|
|
||||||
for expected in [
|
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.len(), 1);
|
||||||
assert_eq!(update_profile.affects[0].kind, AffectKind::Context);
|
assert_eq!(update_profile.affects[0].kind, AffectKind::Context);
|
||||||
assert_eq!(update_profile.affects[0].name, "user");
|
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.
|
// Both context functions share `user_id` as a required param.
|
||||||
let user_id = user.params.get("user_id").expect("user_id 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.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_profile".to_string()));
|
||||||
assert!(user_id.shared_by.contains(&"user_orders".to_string()));
|
assert!(user_id.shared_by.contains(&"user_orders".to_string()));
|
||||||
@@ -85,19 +88,26 @@ fn afi_fixture_context_param_elevation() {
|
|||||||
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn afi_fixture_components_schemas_present() {
|
fn afi_fixture_named_types_present() {
|
||||||
let ir = load_fixture();
|
let ir = load_fixture();
|
||||||
// Each fixture function pairs with an *Input/Output schema in components.
|
// Every IR function references its <camelName>Input / <camelName>Output
|
||||||
|
// type by name; the IR's `type` section must declare each as a named
|
||||||
|
// type (struct, alias to a list, etc.).
|
||||||
for expected in [
|
for expected in [
|
||||||
"echoInput", "echoOutput",
|
"echoInput", "echoOutput",
|
||||||
"whoamiOutput",
|
"whoamiOutput",
|
||||||
"userProfileInput", "userProfileOutput",
|
"userProfileInput", "userProfileOutput",
|
||||||
|
"userOrdersInput",
|
||||||
"updateProfileInput", "updateProfileOutput",
|
"updateProfileInput", "updateProfileOutput",
|
||||||
"findUserInput", "findUserOutput",
|
"findUserInput", "findUserOutput",
|
||||||
|
"renameUserInput", "renameUserOutput",
|
||||||
] {
|
] {
|
||||||
assert!(
|
let ty = ir.types.get(expected)
|
||||||
ir.components.schemas.contains_key(expected),
|
.unwrap_or_else(|| panic!("missing type {expected:?}"));
|
||||||
"missing schema {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(_) => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
|
|||||||
|
|
||||||
|
|
||||||
fn load_ir() -> mizan_codegen::ir::MizanIR {
|
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()
|
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 {
|
fn read_baseline(rel: &str) -> String {
|
||||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("tests/fixtures/js_python")
|
.join("tests/fixtures/baselines/python")
|
||||||
.join(rel);
|
.join(rel);
|
||||||
std::fs::read_to_string(&path)
|
std::fs::read_to_string(&path)
|
||||||
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))
|
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
|
|||||||
|
|
||||||
|
|
||||||
fn load_ir() -> mizan_codegen::ir::MizanIR {
|
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()
|
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 actual = &files[0].content;
|
||||||
|
|
||||||
let expected_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
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();
|
let expected = std::fs::read_to_string(&expected_path).unwrap();
|
||||||
|
|
||||||
if *actual != expected {
|
if *actual != expected {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
|
|||||||
|
|
||||||
fn load_ir() -> mizan_codegen::ir::MizanIR {
|
fn load_ir() -> mizan_codegen::ir::MizanIR {
|
||||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
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()
|
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 {
|
fn read_baseline(rel: &str) -> String {
|
||||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("tests/fixtures/js_rust")
|
.join("tests/fixtures/baselines/rust")
|
||||||
.join(rel);
|
.join(rel);
|
||||||
std::fs::read_to_string(&path)
|
std::fs::read_to_string(&path)
|
||||||
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))
|
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Byte-equivalence tests for the deterministic Stage 1 files (contexts,
|
//! Byte-equivalence tests for the deterministic Stage 1 files (contexts,
|
||||||
//! mutations, functions, index). Baseline output captured from the JS
|
//! mutations, functions, index). Baseline output captured from the JS
|
||||||
//! codegen at `protocol/mizan-generate/generator/lib/stage1.mjs` against
|
//! 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
|
//! `types.ts` is NOT byte-checked here — the JS codegen routes type
|
||||||
//! emission through openapi-typescript while the Rust substrate emits
|
//! 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 {
|
fn load_ir() -> mizan_codegen::ir::MizanIR {
|
||||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
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();
|
let raw = std::fs::read_to_string(&path).unwrap();
|
||||||
parse_ir_from_str(&raw).unwrap()
|
parse_ir_from_str(&raw).unwrap()
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ fn emit_index(files: &[EmittedFile]) -> BTreeMap<PathBuf, &str> {
|
|||||||
|
|
||||||
fn read_baseline(rel: &str) -> String {
|
fn read_baseline(rel: &str) -> String {
|
||||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("tests/fixtures/js_stage1")
|
.join("tests/fixtures/baselines/stage1")
|
||||||
.join(rel);
|
.join(rel);
|
||||||
std::fs::read_to_string(&path)
|
std::fs::read_to_string(&path)
|
||||||
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))
|
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
|
|||||||
|
|
||||||
|
|
||||||
fn load_ir() -> mizan_codegen::ir::MizanIR {
|
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()
|
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 ir = load_ir();
|
||||||
let files = VueAdapter.emit(&ir, &fixture_config("vue"));
|
let files = VueAdapter.emit(&ir, &fixture_config("vue"));
|
||||||
assert_eq!(files.len(), 1);
|
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 ir = load_ir();
|
||||||
let files = SvelteAdapter.emit(&ir, &fixture_config("svelte"));
|
let files = SvelteAdapter.emit(&ir, &fixture_config("svelte"));
|
||||||
assert_eq!(files.len(), 1);
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Codegen entrypoint for the AFI fixture.
|
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
|
populated registry. The fixture's `register_fixture()` is a function
|
||||||
call, not an import side effect; this thin wrapper invokes it on
|
call, not an import side effect; this thin wrapper invokes it on
|
||||||
import so the CLI works without modifying fixture.py's semantics.
|
import so the CLI works without modifying fixture.py's semantics.
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -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
|
Gates that mizan-django and mizan-fastapi emit byte-equivalent IR
|
||||||
same registered functions. If this passes, the codegen produces equivalent
|
for the same registered functions. If this passes, the codegen
|
||||||
TypeScript output regardless of which backend the frontend is generated
|
produces identical TypeScript output regardless of backend
|
||||||
against (codegen is deterministic over schema input).
|
(codegen is deterministic over IR input).
|
||||||
|
|
||||||
This is a substrate-level gate, not e2e. It catches adapter symmetry
|
Substrate-level gate, not e2e. Catches adapter symmetry problems —
|
||||||
problems — Pydantic→OpenAPI converter divergence, metadata leakage,
|
type-introspection divergence, ordering non-determinism — without
|
||||||
ordering non-determinism — without running a real frontend or backend.
|
running a real frontend or backend.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -21,68 +20,53 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
HERE = Path(__file__).parent
|
HERE = Path(__file__).parent
|
||||||
DJANGO_MANAGE = HERE / "django_app" / "manage.py"
|
DJANGO_MANAGE = HERE / "django_app" / "manage.py"
|
||||||
|
|
||||||
|
|
||||||
# ─── Schema fetchers ────────────────────────────────────────────────────────
|
def _fetch_django_ir() -> str:
|
||||||
|
"""Spawn Django's management command and parse stdout as KDL."""
|
||||||
|
|
||||||
def _fetch_django_schema() -> dict:
|
|
||||||
"""Spawn Django's management command and parse its stdout JSON."""
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, str(DJANGO_MANAGE), "export_mizan_schema", "--indent", "0"],
|
[sys.executable, str(DJANGO_MANAGE), "export_mizan_ir"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
pytest.fail(f"export_mizan_schema failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}")
|
pytest.fail(
|
||||||
return json.loads(result.stdout)
|
f"export_mizan_ir failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
def _fetch_fastapi_schema() -> dict:
|
def _fetch_fastapi_ir() -> str:
|
||||||
"""Build the FastAPI app inline (fresh registry) and call build_schema()."""
|
"""Build the FastAPI app inline (fresh registry) and call build_ir()."""
|
||||||
sys.path.insert(0, str(HERE))
|
sys.path.insert(0, str(HERE))
|
||||||
try:
|
try:
|
||||||
from mizan_core.registry import clear_registry
|
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
|
from fastapi_app import make_app
|
||||||
|
|
||||||
clear_registry()
|
clear_registry()
|
||||||
make_app()
|
make_app()
|
||||||
return build_schema()
|
return build_ir()
|
||||||
finally:
|
finally:
|
||||||
sys.path.remove(str(HERE))
|
sys.path.remove(str(HERE))
|
||||||
|
|
||||||
|
|
||||||
# ─── Tests ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def schemas() -> tuple[dict, dict]:
|
def ir_pair() -> tuple[str, str]:
|
||||||
return _fetch_django_schema(), _fetch_fastapi_schema()
|
return _fetch_django_ir(), _fetch_fastapi_ir()
|
||||||
|
|
||||||
|
|
||||||
class AFISubsetTests:
|
class IRParityTests:
|
||||||
"""The AFI surface — x-mizan-functions and x-mizan-contexts — must match."""
|
"""The Mizan IR is the contract — both backends must emit the same KDL."""
|
||||||
|
|
||||||
def test_x_mizan_functions_match(self, schemas):
|
def test_ir_bytes_match(self, ir_pair):
|
||||||
from schema_normalizer import afi_subset
|
django, fastapi = ir_pair
|
||||||
django, fastapi = schemas
|
assert django == fastapi, (
|
||||||
assert afi_subset(django)["x-mizan-functions"] == afi_subset(fastapi)["x-mizan-functions"]
|
"Django and FastAPI emit divergent Mizan IR for the same "
|
||||||
|
"registered functions. Substrate gate is now red."
|
||||||
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)
|
|
||||||
|
|||||||
@@ -4,11 +4,6 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct HTTPValidationError {
|
|
||||||
pub detail: Option<Vec<ValidationError>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OrderOutput {
|
pub struct OrderOutput {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -16,16 +11,6 @@ pub struct OrderOutput {
|
|||||||
pub total: i64,
|
pub total: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ValidationError {
|
|
||||||
pub loc: Vec<serde_json::Value>,
|
|
||||||
pub msg: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub r#type: String,
|
|
||||||
pub input: Option<serde_json::Value>,
|
|
||||||
pub ctx: Option<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EchoInput {
|
pub struct EchoInput {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
@@ -75,9 +60,7 @@ pub struct UserOrdersInput {
|
|||||||
pub user_id: i64,
|
pub user_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
pub type UserOrdersOutput = Vec<OrderOutput>;
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct UserOrdersOutput(pub Vec<OrderOutput>);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct UserProfileInput {
|
pub struct UserProfileInput {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ async fn main() -> ExitCode {
|
|||||||
match fetch_user_context(&client, &UserContextParams { user_id: 5 }).await {
|
match fetch_user_context(&client, &UserContextParams { user_id: 5 }).await {
|
||||||
Ok(out) => println!(
|
Ok(out) => println!(
|
||||||
"fetch_user_context(5) -> user_profile={{ user_id:{}, name:{:?} }} user_orders.len={}",
|
"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; }
|
Err(e) => { eprintln!("fetch_user_context ERR {e}"); failures += 1; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user