|
|
|
|
@@ -1,870 +0,0 @@
|
|
|
|
|
"""
|
|
|
|
|
mizan Server Functions - Core Primitive
|
|
|
|
|
|
|
|
|
|
Server functions are the core primitive. Everything else builds on them.
|
|
|
|
|
|
|
|
|
|
Two styles supported:
|
|
|
|
|
|
|
|
|
|
1. Function-based (recommended, Django Ninja style):
|
|
|
|
|
@client("update-profile")
|
|
|
|
|
def update_profile(request, input: UpdateProfileInput) -> UpdateProfileOutput:
|
|
|
|
|
return UpdateProfileOutput(success=True)
|
|
|
|
|
|
|
|
|
|
2. Class-based (for complex cases):
|
|
|
|
|
class UpdateProfile(ServerFunction):
|
|
|
|
|
def call(self, input: UpdateProfileInput) -> UpdateProfileOutput:
|
|
|
|
|
return UpdateProfileOutput(success=True)
|
|
|
|
|
register(UpdateProfile, 'update-profile')
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import inspect
|
|
|
|
|
import warnings
|
|
|
|
|
from abc import ABC, abstractmethod
|
|
|
|
|
from typing import (
|
|
|
|
|
Any,
|
|
|
|
|
Callable,
|
|
|
|
|
ClassVar,
|
|
|
|
|
Generic,
|
|
|
|
|
Literal,
|
|
|
|
|
TypeVar,
|
|
|
|
|
Union,
|
|
|
|
|
get_args,
|
|
|
|
|
get_origin,
|
|
|
|
|
get_type_hints,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
from django.http import HttpRequest
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# REACT CONTEXT - Named context marker
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ReactContext:
|
|
|
|
|
"""
|
|
|
|
|
A named context that groups server functions into one provider and one fetch.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
UserContext = ReactContext('user')
|
|
|
|
|
|
|
|
|
|
@client(context=UserContext)
|
|
|
|
|
def user_profile(request, user_id: int) -> ProfileShape: ...
|
|
|
|
|
|
|
|
|
|
@client(context=UserContext)
|
|
|
|
|
def user_orders(request, user_id: int) -> list[OrderShape]: ...
|
|
|
|
|
|
|
|
|
|
@client(affects=UserContext)
|
|
|
|
|
def edit_profile(request, name: str) -> dict: ...
|
|
|
|
|
|
|
|
|
|
@client(affects=[UserContext, OrderContext])
|
|
|
|
|
def change_plan(request) -> dict: ...
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, name: str):
|
|
|
|
|
if not name or not isinstance(name, str):
|
|
|
|
|
raise ValueError("ReactContext name must be a non-empty string")
|
|
|
|
|
self.name = name
|
|
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
|
|
|
|
return f"ReactContext({self.name!r})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Built-in global context (auto-mounted at root, SSR-hydrated)
|
|
|
|
|
GlobalContext = ReactContext("global")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Context parameter type: a ReactContext instance, a raw string, or False
|
|
|
|
|
ContextMode = ReactContext | str | Literal[False]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
TInput = TypeVar("TInput", bound=BaseModel)
|
|
|
|
|
TOutput = TypeVar("TOutput", bound=BaseModel)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# SERVER FUNCTION - The Core Primitive
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ServerFunction(ABC, Generic[TInput, TOutput]):
|
|
|
|
|
"""
|
|
|
|
|
Class-based server function (for complex cases).
|
|
|
|
|
|
|
|
|
|
For simple functions, use the @client decorator instead.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
class UpdateProfile(ServerFunction):
|
|
|
|
|
def call(self, input: UpdateProfileInput) -> UpdateProfileOutput:
|
|
|
|
|
self.user.name = input.name
|
|
|
|
|
self.user.save()
|
|
|
|
|
return UpdateProfileOutput(success=True)
|
|
|
|
|
|
|
|
|
|
register(UpdateProfile, 'update-profile')
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
# Registration name (set by register())
|
|
|
|
|
name: ClassVar[str]
|
|
|
|
|
|
|
|
|
|
# Metadata for code generation
|
|
|
|
|
_meta: ClassVar[dict[str, Any]] = {}
|
|
|
|
|
|
|
|
|
|
# Schema classes (set automatically from type hints or explicitly)
|
|
|
|
|
Input: ClassVar[type[BaseModel]] = BaseModel
|
|
|
|
|
Output: ClassVar[type[BaseModel]] = BaseModel
|
|
|
|
|
|
|
|
|
|
def __init__(self, request: HttpRequest):
|
|
|
|
|
"""Initialize with the Django request."""
|
|
|
|
|
self.request = request
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def user(self):
|
|
|
|
|
"""Shortcut to request.user."""
|
|
|
|
|
return self.request.user
|
|
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
|
def call(self, input: TInput) -> TOutput:
|
|
|
|
|
"""
|
|
|
|
|
Execute the function.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
input: Validated input data
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Output instance
|
|
|
|
|
"""
|
|
|
|
|
raise NotImplementedError(f"{self.__class__.__name__} must implement call()")
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def get_schema_export(cls) -> dict[str, Any]:
|
|
|
|
|
"""Export schema for TypeScript generation."""
|
|
|
|
|
export = {
|
|
|
|
|
"name": getattr(cls, "name", cls.__name__),
|
|
|
|
|
"type": "function",
|
|
|
|
|
"meta": getattr(cls, "_meta", {}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Get Input/Output from class attributes
|
|
|
|
|
input_cls = getattr(cls, "Input", BaseModel)
|
|
|
|
|
output_cls = getattr(cls, "Output", BaseModel)
|
|
|
|
|
|
|
|
|
|
# Check if Input has fields
|
|
|
|
|
input_schema = input_cls.model_json_schema()
|
|
|
|
|
has_input = bool(input_schema.get("properties"))
|
|
|
|
|
if has_input:
|
|
|
|
|
export["input"] = input_schema
|
|
|
|
|
export["has_input"] = has_input
|
|
|
|
|
|
|
|
|
|
export["output"] = output_cls.model_json_schema()
|
|
|
|
|
|
|
|
|
|
return export
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# FUNCTION DECORATOR - Django Ninja Style
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FunctionWrapper(ServerFunction):
|
|
|
|
|
"""Internal wrapper that makes a plain function behave like a ServerFunction."""
|
|
|
|
|
|
|
|
|
|
# Will be set per-wrapper instance
|
|
|
|
|
_wrapped_fn: ClassVar[Callable]
|
|
|
|
|
_input_cls: ClassVar[type[BaseModel] | None]
|
|
|
|
|
_output_cls: ClassVar[type[BaseModel]]
|
|
|
|
|
_param_names: ClassVar[list[str]] = []
|
|
|
|
|
_is_primitive_output: ClassVar[bool] = False
|
|
|
|
|
|
|
|
|
|
def call(self, input):
|
|
|
|
|
"""Execute the wrapped function, unpacking input into individual args."""
|
|
|
|
|
if input is not None and self._param_names:
|
|
|
|
|
# Unpack validated model into keyword arguments
|
|
|
|
|
kwargs = {name: getattr(input, name) for name in self._param_names}
|
|
|
|
|
result = self._wrapped_fn(self.request, **kwargs)
|
|
|
|
|
else:
|
|
|
|
|
result = self._wrapped_fn(self.request)
|
|
|
|
|
|
|
|
|
|
# View path — return HttpResponse directly (no serialization)
|
|
|
|
|
from django.http import HttpResponseBase
|
|
|
|
|
if isinstance(result, HttpResponseBase):
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
# Wrap primitive returns in the generated output model
|
|
|
|
|
if self._is_primitive_output:
|
|
|
|
|
return self._output_cls(result=result)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def get_schema_export(cls) -> dict[str, Any]:
|
|
|
|
|
"""Export schema for TypeScript generation."""
|
|
|
|
|
export = {
|
|
|
|
|
"name": getattr(cls, "name", cls.__name__),
|
|
|
|
|
"type": "function",
|
|
|
|
|
"meta": getattr(cls, "_meta", {}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Use stored schema classes
|
|
|
|
|
if cls._input_cls is not None:
|
|
|
|
|
input_schema = cls._input_cls.model_json_schema()
|
|
|
|
|
has_input = bool(input_schema.get("properties"))
|
|
|
|
|
if has_input:
|
|
|
|
|
export["input"] = input_schema
|
|
|
|
|
export["has_input"] = has_input
|
|
|
|
|
else:
|
|
|
|
|
export["has_input"] = False
|
|
|
|
|
|
|
|
|
|
export["output"] = cls._output_cls.model_json_schema()
|
|
|
|
|
|
|
|
|
|
return export
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Valid string values for auth parameter
|
|
|
|
|
_VALID_AUTH_STRINGS = frozenset({"required", "staff", "superuser"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_context(context: ContextMode) -> str | Literal[False]:
|
|
|
|
|
"""Resolve a context parameter to its name string."""
|
|
|
|
|
if context is False:
|
|
|
|
|
return False
|
|
|
|
|
if isinstance(context, ReactContext):
|
|
|
|
|
return context.name
|
|
|
|
|
if isinstance(context, str):
|
|
|
|
|
if not context.strip():
|
|
|
|
|
raise ValueError("context must be a non-empty string, ReactContext, or False.")
|
|
|
|
|
if context == "local":
|
|
|
|
|
warnings.warn(
|
|
|
|
|
"context='local' is deprecated. Use ReactContext('name') instead.",
|
|
|
|
|
DeprecationWarning,
|
|
|
|
|
stacklevel=3,
|
|
|
|
|
)
|
|
|
|
|
return context
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"context must be a ReactContext, a string, or False. Got {type(context).__name__}."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Affects parameter type
|
|
|
|
|
AffectsTarget = ReactContext | str | type["ServerFunction"]
|
|
|
|
|
AffectsMode = AffectsTarget | list[AffectsTarget] | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def client(
|
|
|
|
|
fn: Callable = None,
|
|
|
|
|
*,
|
|
|
|
|
context: ContextMode = False,
|
|
|
|
|
affects: AffectsMode = None,
|
|
|
|
|
private: bool = False,
|
|
|
|
|
route: str | None = None,
|
|
|
|
|
methods: list[str] | None = None,
|
|
|
|
|
websocket: bool = False,
|
|
|
|
|
auth: bool | str | Callable[[Any], bool] | None = None,
|
|
|
|
|
rev: int = 0,
|
|
|
|
|
cache: int | bool = True,
|
|
|
|
|
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
|
|
|
|
"""
|
|
|
|
|
Register a function as a server function.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
context: Named context for React state management.
|
|
|
|
|
- False (default): Not a context, just a callable function.
|
|
|
|
|
- ReactContext instance: groups functions into a named context.
|
|
|
|
|
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
|
|
|
|
|
|
|
|
|
|
affects: Declare which contexts or functions this mutation invalidates.
|
|
|
|
|
Mutually exclusive with context=.
|
|
|
|
|
Scoping is automatic via argument name matching.
|
|
|
|
|
|
|
|
|
|
private: If True, the function is not client-callable.
|
|
|
|
|
- Not exposed as an RPC endpoint
|
|
|
|
|
- No generated TypeScript
|
|
|
|
|
- Still participates in the invalidation graph
|
|
|
|
|
- Use for webhooks, cron jobs, internal mutations
|
|
|
|
|
|
|
|
|
|
route: URL route pattern for view-path functions.
|
|
|
|
|
Mizan registers this route during autodiscovery.
|
|
|
|
|
Example: '/profile/<user_id>/', '/webhooks/stripe/'
|
|
|
|
|
|
|
|
|
|
methods: HTTP methods allowed for the route.
|
|
|
|
|
Default: ['GET'] for context functions, ['POST'] for mutations.
|
|
|
|
|
Example: ['POST'], ['GET', 'POST']
|
|
|
|
|
|
|
|
|
|
websocket: Enable WebSocket RPC transport (default: False).
|
|
|
|
|
|
|
|
|
|
auth: Authentication requirement.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
UserContext = ReactContext('user')
|
|
|
|
|
|
|
|
|
|
@client(context=UserContext)
|
|
|
|
|
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
|
|
|
|
|
|
|
|
|
@client(affects=UserContext)
|
|
|
|
|
def update_profile(request, user_id: int, name: str) -> dict: ...
|
|
|
|
|
|
|
|
|
|
# View with route — Mizan owns the URL
|
|
|
|
|
@client(context=UserContext, route='/profile/<user_id>/')
|
|
|
|
|
def profile_page(request, user_id: int) -> HttpResponse: ...
|
|
|
|
|
|
|
|
|
|
# Private webhook — not client-callable, emits invalidation
|
|
|
|
|
@client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
|
|
|
|
|
def stripe_webhook(request) -> HttpResponse: ...
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
A ServerFunction class that wraps the function
|
|
|
|
|
"""
|
|
|
|
|
# Resolve context to name string
|
|
|
|
|
resolved_context = _resolve_context(context)
|
|
|
|
|
|
|
|
|
|
# Validate affects parameter
|
|
|
|
|
if affects is not None:
|
|
|
|
|
if resolved_context is not False:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"context= and affects= are mutually exclusive. "
|
|
|
|
|
"A function cannot be both a context reader and a mutation."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Validate auth parameter
|
|
|
|
|
if auth is not None:
|
|
|
|
|
if isinstance(auth, str) and auth not in _VALID_AUTH_STRINGS:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"Invalid auth value '{auth}'. "
|
|
|
|
|
f"Must be one of: {', '.join(sorted(_VALID_AUTH_STRINGS))}, True, or a callable."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def decorator(fn: Callable) -> type[ServerFunction]:
|
|
|
|
|
return _create_server_function(
|
|
|
|
|
fn, context=resolved_context, affects=affects,
|
|
|
|
|
private=private, route=route, methods=methods,
|
|
|
|
|
websocket=websocket, auth=auth, rev=rev, cache=cache,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Support both @client and @client(...)
|
|
|
|
|
if fn is not None:
|
|
|
|
|
return _create_server_function(
|
|
|
|
|
fn, context=resolved_context, affects=affects,
|
|
|
|
|
private=private, route=route, methods=methods,
|
|
|
|
|
websocket=websocket, auth=auth, rev=rev, cache=cache,
|
|
|
|
|
)
|
|
|
|
|
return decorator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_affects(affects: AffectsMode) -> list[dict[str, str]] | None:
|
|
|
|
|
"""Normalize the affects parameter into a list of target descriptors."""
|
|
|
|
|
if affects is None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
items = affects if isinstance(affects, list) else [affects]
|
|
|
|
|
result = []
|
|
|
|
|
for item in items:
|
|
|
|
|
if isinstance(item, ReactContext):
|
|
|
|
|
result.append({"type": "context", "name": item.name})
|
|
|
|
|
elif isinstance(item, str):
|
|
|
|
|
result.append({"type": "context", "name": item})
|
|
|
|
|
elif isinstance(item, type) and issubclass(item, ServerFunction):
|
|
|
|
|
fn_meta = getattr(item, "_meta", {})
|
|
|
|
|
fn_ctx = fn_meta.get("context")
|
|
|
|
|
result.append({
|
|
|
|
|
"type": "function",
|
|
|
|
|
"name": getattr(item, "__name__", str(item)),
|
|
|
|
|
"context": fn_ctx or None,
|
|
|
|
|
})
|
|
|
|
|
else:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"affects items must be ReactContext instances, context name strings, "
|
|
|
|
|
f"or @client function references. Got {type(item)}"
|
|
|
|
|
)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_server_function(
|
|
|
|
|
fn: Callable,
|
|
|
|
|
*,
|
|
|
|
|
context: str | Literal[False] = False,
|
|
|
|
|
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
|
|
|
|
|
private: bool = False,
|
|
|
|
|
route: str | None = None,
|
|
|
|
|
methods: list[str] | None = None,
|
|
|
|
|
websocket: bool = False,
|
|
|
|
|
auth: bool | str | None = None,
|
|
|
|
|
rev: int = 0,
|
|
|
|
|
cache: int | bool = True,
|
|
|
|
|
) -> type[ServerFunction]:
|
|
|
|
|
"""Internal helper that creates a ServerFunction from a decorated function."""
|
|
|
|
|
from pydantic import create_model
|
|
|
|
|
|
|
|
|
|
# Use function name directly
|
|
|
|
|
name = fn.__name__
|
|
|
|
|
|
|
|
|
|
# Extract type hints and signature
|
|
|
|
|
hints = get_type_hints(fn)
|
|
|
|
|
sig = inspect.signature(fn)
|
|
|
|
|
params = list(sig.parameters.items())
|
|
|
|
|
|
|
|
|
|
# Skip 'request' parameter (first param)
|
|
|
|
|
input_params = params[1:] if params else []
|
|
|
|
|
|
|
|
|
|
# Build input schema from function parameters
|
|
|
|
|
if input_params:
|
|
|
|
|
# Build field definitions for create_model
|
|
|
|
|
# Format: {field_name: (type, default) or (type, ...)}
|
|
|
|
|
fields = {}
|
|
|
|
|
for param_name, param in input_params:
|
|
|
|
|
param_type = hints.get(param_name, Any)
|
|
|
|
|
|
|
|
|
|
if param.default is inspect.Parameter.empty:
|
|
|
|
|
# Required field
|
|
|
|
|
fields[param_name] = (param_type, ...)
|
|
|
|
|
else:
|
|
|
|
|
# Optional field with default
|
|
|
|
|
fields[param_name] = (param_type, param.default)
|
|
|
|
|
|
|
|
|
|
# Create dynamic Pydantic model
|
|
|
|
|
input_cls = create_model(f"{fn.__name__}_Input", **fields)
|
|
|
|
|
else:
|
|
|
|
|
input_cls = None
|
|
|
|
|
|
|
|
|
|
# Get output type from return annotation
|
|
|
|
|
output_type = hints.get("return")
|
|
|
|
|
if output_type is None:
|
|
|
|
|
raise TypeError(f"Server function '{name}' must have a return type annotation")
|
|
|
|
|
|
|
|
|
|
# Detect view path: function returns HttpResponse (or has no return annotation
|
|
|
|
|
# that maps to a model — view functions often just have -> HttpResponse)
|
|
|
|
|
from django.http import HttpResponseBase
|
|
|
|
|
is_view_path = (
|
|
|
|
|
isinstance(output_type, type) and issubclass(output_type, HttpResponseBase)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if is_view_path:
|
|
|
|
|
# View path — no Pydantic output wrapping needed
|
|
|
|
|
output_cls = BaseModel # placeholder, never used for serialization
|
|
|
|
|
is_primitive_output = False
|
|
|
|
|
else:
|
|
|
|
|
# RPC path — resolve output type
|
|
|
|
|
import types
|
|
|
|
|
|
|
|
|
|
def is_basemodel_type(t: Any) -> bool:
|
|
|
|
|
"""Check if type is a BaseModel subclass, handling Optional/Union."""
|
|
|
|
|
if isinstance(t, type) and issubclass(t, BaseModel):
|
|
|
|
|
return True
|
|
|
|
|
origin = get_origin(t)
|
|
|
|
|
if origin is Union or isinstance(t, types.UnionType):
|
|
|
|
|
args = get_args(t)
|
|
|
|
|
for arg in args:
|
|
|
|
|
if (
|
|
|
|
|
arg is not type(None)
|
|
|
|
|
and isinstance(arg, type)
|
|
|
|
|
and issubclass(arg, BaseModel)
|
|
|
|
|
):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if is_basemodel_type(output_type):
|
|
|
|
|
output_cls = output_type
|
|
|
|
|
is_primitive_output = False
|
|
|
|
|
else:
|
|
|
|
|
output_cls = create_model(f"{fn.__name__}_Output", result=(output_type, ...))
|
|
|
|
|
is_primitive_output = True
|
|
|
|
|
|
|
|
|
|
# Store param names for unpacking validated input
|
|
|
|
|
param_names = [p[0] for p in input_params]
|
|
|
|
|
|
|
|
|
|
# Create a unique wrapper class for this function
|
|
|
|
|
class FunctionWrapper(_FunctionWrapper):
|
|
|
|
|
_param_names: ClassVar[list[str]] = param_names
|
|
|
|
|
|
|
|
|
|
FunctionWrapper.__name__ = fn.__name__
|
|
|
|
|
FunctionWrapper.__doc__ = fn.__doc__
|
|
|
|
|
FunctionWrapper.__module__ = fn.__module__ # Critical for discovery
|
|
|
|
|
FunctionWrapper._wrapped_fn = staticmethod(fn)
|
|
|
|
|
FunctionWrapper._input_cls = input_cls
|
|
|
|
|
FunctionWrapper._output_cls = output_cls
|
|
|
|
|
FunctionWrapper._is_primitive_output = is_primitive_output
|
|
|
|
|
|
|
|
|
|
# Set Input/Output class attributes for compatibility
|
|
|
|
|
if input_cls is not None:
|
|
|
|
|
FunctionWrapper.Input = input_cls
|
|
|
|
|
FunctionWrapper.Output = output_cls
|
|
|
|
|
|
|
|
|
|
# Build metadata
|
|
|
|
|
meta = {}
|
|
|
|
|
|
|
|
|
|
# View path flag (function returns HttpResponse, no codegen)
|
|
|
|
|
if is_view_path:
|
|
|
|
|
meta["view_path"] = True
|
|
|
|
|
|
|
|
|
|
# Private flag (not client-callable, no codegen, no RPC endpoint)
|
|
|
|
|
if private:
|
|
|
|
|
meta["private"] = True
|
|
|
|
|
|
|
|
|
|
# Route (Mizan-owned URL pattern for view-path functions)
|
|
|
|
|
if route:
|
|
|
|
|
meta["route"] = route
|
|
|
|
|
meta["methods"] = methods or (["GET"] if context else ["POST"])
|
|
|
|
|
|
|
|
|
|
# Context name (any non-empty string)
|
|
|
|
|
if context:
|
|
|
|
|
meta["context"] = context
|
|
|
|
|
|
|
|
|
|
# Affects: mutation invalidation targets
|
|
|
|
|
normalized_affects = _normalize_affects(affects)
|
|
|
|
|
if normalized_affects:
|
|
|
|
|
meta["affects"] = normalized_affects
|
|
|
|
|
|
|
|
|
|
# WebSocket: enable WebSocket transport
|
|
|
|
|
if websocket:
|
|
|
|
|
meta["websocket"] = True
|
|
|
|
|
|
|
|
|
|
# Auth requirement
|
|
|
|
|
if auth is not None:
|
|
|
|
|
if auth is True:
|
|
|
|
|
meta["auth"] = "required"
|
|
|
|
|
elif callable(auth):
|
|
|
|
|
meta["auth"] = auth
|
|
|
|
|
else:
|
|
|
|
|
meta["auth"] = auth
|
|
|
|
|
|
|
|
|
|
# Revision: bumped by developer when function logic changes.
|
|
|
|
|
# Part of the HMAC cache key — old entries become unreachable orphans.
|
|
|
|
|
if rev != 0:
|
|
|
|
|
meta["rev"] = rev
|
|
|
|
|
|
|
|
|
|
# Cache policy: True=forever (default), False=no-store, int=TTL seconds
|
|
|
|
|
if cache is not True:
|
|
|
|
|
meta["cache"] = cache
|
|
|
|
|
|
|
|
|
|
# Always assign a fresh dict to prevent shared-dict mutation across classes
|
|
|
|
|
FunctionWrapper._meta = {**meta}
|
|
|
|
|
|
|
|
|
|
# Note: Registration happens via discovery (mizan_clients), not here.
|
|
|
|
|
# This allows the decorator to be used without import-time side effects.
|
|
|
|
|
|
|
|
|
|
return FunctionWrapper
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# COMPOSE - Combine multiple contexts into a single provider
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ComposedContext:
|
|
|
|
|
"""
|
|
|
|
|
Marker class for composed contexts.
|
|
|
|
|
|
|
|
|
|
Stores metadata about the composition for schema export.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
name: str
|
|
|
|
|
_meta: dict[str, Any]
|
|
|
|
|
_children: list[type[ServerFunction] | "ComposedContext"]
|
|
|
|
|
_leaves: list[type[ServerFunction]]
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
name: str,
|
|
|
|
|
children: list,
|
|
|
|
|
leaves: list,
|
|
|
|
|
on_server: bool,
|
|
|
|
|
websocket: bool,
|
|
|
|
|
):
|
|
|
|
|
self.name = name
|
|
|
|
|
self._children = children
|
|
|
|
|
self._leaves = leaves
|
|
|
|
|
self._meta = {
|
|
|
|
|
"compose": True,
|
|
|
|
|
"on_server": on_server,
|
|
|
|
|
"websocket": websocket,
|
|
|
|
|
"children": [c.name for c in children],
|
|
|
|
|
"leaves": [leaf.name for leaf in leaves],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def get_schema_export(cls) -> dict[str, Any]:
|
|
|
|
|
"""Export schema for TypeScript generation."""
|
|
|
|
|
return {
|
|
|
|
|
"name": cls.name,
|
|
|
|
|
"type": "compose",
|
|
|
|
|
"meta": cls._meta,
|
|
|
|
|
"children": cls._meta.get("children", []),
|
|
|
|
|
"leaves": cls._meta.get("leaves", []),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_leaves(item) -> list[type[ServerFunction]]:
|
|
|
|
|
"""Recursively collect all leaf contexts from a context or composition."""
|
|
|
|
|
if isinstance(item, type) and issubclass(item, ServerFunction):
|
|
|
|
|
return [item]
|
|
|
|
|
elif isinstance(item, ComposedContext):
|
|
|
|
|
return item._leaves.copy()
|
|
|
|
|
elif hasattr(item, "_leaves"):
|
|
|
|
|
# Duck typing for composed contexts
|
|
|
|
|
return item._leaves.copy()
|
|
|
|
|
else:
|
|
|
|
|
raise TypeError(f"Expected ServerFunction or ComposedContext, got {type(item)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_context_enabled(item) -> bool:
|
|
|
|
|
"""Check if an item is a context-enabled function or composition."""
|
|
|
|
|
if isinstance(item, ComposedContext) or hasattr(item, "_leaves"):
|
|
|
|
|
return True
|
|
|
|
|
if isinstance(item, type) and issubclass(item, ServerFunction):
|
|
|
|
|
meta = getattr(item, "_meta", {})
|
|
|
|
|
return bool(meta.get("context"))
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compose(
|
|
|
|
|
*children,
|
|
|
|
|
on_server: bool = False,
|
|
|
|
|
websocket: bool = False,
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Compose multiple contexts into a single provider.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
*children: Context functions (@client with a context name)
|
|
|
|
|
or other @compose functions. All must be unique after flattening.
|
|
|
|
|
|
|
|
|
|
on_server: Bundle all calls into a single server request (default: False).
|
|
|
|
|
- False: Frontend makes individual calls (mixed HTTP/WS OK)
|
|
|
|
|
- True: Single bundled call. Requires transport consistency:
|
|
|
|
|
all children must be HTTP-only XOR all must be websocket=True.
|
|
|
|
|
|
|
|
|
|
websocket: Transport for bundled call when on_server=True (default: False).
|
|
|
|
|
- False: Bundled call over HTTP. All children must be HTTP-only.
|
|
|
|
|
- True: Bundled call over WebSocket. All children must have websocket=True.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
@client(context='local')
|
|
|
|
|
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
|
|
|
|
|
|
|
|
|
@client(context='local')
|
|
|
|
|
def user_posts(request, user_id: int) -> PostsOutput: ...
|
|
|
|
|
|
|
|
|
|
@compose(user_profile, user_posts)
|
|
|
|
|
def user_page():
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Frontend generates:
|
|
|
|
|
# <UserPageProvider user_id={123}>
|
|
|
|
|
# <App />
|
|
|
|
|
# </UserPageProvider>
|
|
|
|
|
|
|
|
|
|
Nesting:
|
|
|
|
|
@compose(ctx_a, ctx_b)
|
|
|
|
|
def ab(): pass
|
|
|
|
|
|
|
|
|
|
@compose(ab, ctx_c) # Flattens to [ctx_a, ctx_b, ctx_c]
|
|
|
|
|
def abc(): pass
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
A ComposedContext that can be used in other compositions.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def decorator(fn: Callable) -> ComposedContext:
|
|
|
|
|
from mizan_core.registry import register_compose
|
|
|
|
|
|
|
|
|
|
name = fn.__name__
|
|
|
|
|
|
|
|
|
|
# Validate: all children must be context-enabled
|
|
|
|
|
for i, child in enumerate(children):
|
|
|
|
|
if not _is_context_enabled(child):
|
|
|
|
|
child_name = getattr(
|
|
|
|
|
child, "name", getattr(child, "__name__", str(child))
|
|
|
|
|
)
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"@compose argument {i} ({child_name}) is not context-enabled. "
|
|
|
|
|
f"All children must have @client(context=...) or be @compose."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Flatten to collect all leaves
|
|
|
|
|
leaves = []
|
|
|
|
|
for child in children:
|
|
|
|
|
leaves.extend(_get_leaves(child))
|
|
|
|
|
|
|
|
|
|
# Validate: no duplicate leaves (by identity)
|
|
|
|
|
seen = set()
|
|
|
|
|
for leaf in leaves:
|
|
|
|
|
if id(leaf) in seen:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"Duplicate context '{leaf.name}' in @compose({name}). "
|
|
|
|
|
f"Each context can only appear once. Use named kwargs for reuse (future feature)."
|
|
|
|
|
)
|
|
|
|
|
seen.add(id(leaf))
|
|
|
|
|
|
|
|
|
|
# Validate transport consistency when on_server=True
|
|
|
|
|
if on_server:
|
|
|
|
|
has_websocket = [
|
|
|
|
|
getattr(leaf, "_meta", {}).get("websocket", False) for leaf in leaves
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if websocket:
|
|
|
|
|
# All must have websocket=True
|
|
|
|
|
if not all(has_websocket):
|
|
|
|
|
non_ws = [
|
|
|
|
|
leaf.name for leaf, ws in zip(leaves, has_websocket) if not ws
|
|
|
|
|
]
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"@compose({name}, on_server=True, websocket=True) requires all children "
|
|
|
|
|
f"to have websocket=True. These are HTTP-only: {non_ws}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# All must be HTTP-only
|
|
|
|
|
if any(has_websocket):
|
|
|
|
|
ws_enabled = [
|
|
|
|
|
leaf.name for leaf, ws in zip(leaves, has_websocket) if ws
|
|
|
|
|
]
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"@compose({name}, on_server=True, websocket=False) requires all children "
|
|
|
|
|
f"to be HTTP-only. These have websocket=True: {ws_enabled}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Create composed context
|
|
|
|
|
composed = ComposedContext(
|
|
|
|
|
name=name,
|
|
|
|
|
children=list(children),
|
|
|
|
|
leaves=leaves,
|
|
|
|
|
on_server=on_server,
|
|
|
|
|
websocket=websocket,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Make it a class-like object for consistency
|
|
|
|
|
composed.__name__ = name
|
|
|
|
|
composed.__doc__ = fn.__doc__
|
|
|
|
|
|
|
|
|
|
# Register the composition
|
|
|
|
|
register_compose(composed, name)
|
|
|
|
|
|
|
|
|
|
return composed
|
|
|
|
|
|
|
|
|
|
return decorator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# FORM HELPERS - Output types used by form server functions
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FormValidationOutput(BaseModel):
|
|
|
|
|
"""Standard output for form validation."""
|
|
|
|
|
|
|
|
|
|
valid: bool
|
|
|
|
|
errors: dict[str, list[str]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FormSchemaField(BaseModel):
|
|
|
|
|
"""Schema for a single form field."""
|
|
|
|
|
|
|
|
|
|
name: str
|
|
|
|
|
type: str
|
|
|
|
|
required: bool
|
|
|
|
|
label: str
|
|
|
|
|
help_text: str | None = None
|
|
|
|
|
choices: list[tuple[str, str]] | None = None
|
|
|
|
|
initial: Any = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FormSchemaOutput(BaseModel):
|
|
|
|
|
"""Standard output for form schema."""
|
|
|
|
|
|
|
|
|
|
fields: list[FormSchemaField]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_form_functions(
|
|
|
|
|
form_class: type,
|
|
|
|
|
name: str,
|
|
|
|
|
submit_handler: Callable[[HttpRequest, dict], BaseModel] | None = None,
|
|
|
|
|
) -> tuple[type[ServerFunction], type[ServerFunction], type[ServerFunction] | None]:
|
|
|
|
|
"""
|
|
|
|
|
Generate server functions for a Django Form.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
form_class: Django Form class
|
|
|
|
|
name: Base name for the functions
|
|
|
|
|
submit_handler: Optional handler for form submission
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Tuple of (SchemaFunction, ValidateFunction, SubmitFunction or None)
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
SchemaFn, ValidateFn, SubmitFn = create_form_functions(
|
|
|
|
|
ContactForm,
|
|
|
|
|
'contact',
|
|
|
|
|
submit_handler=lambda req, data: ContactSubmitOutput(success=True),
|
|
|
|
|
)
|
|
|
|
|
register(SchemaFn, 'contact-schema')
|
|
|
|
|
register(ValidateFn, 'contact-validate')
|
|
|
|
|
register(SubmitFn, 'contact-submit')
|
|
|
|
|
|
|
|
|
|
Or use the helper:
|
|
|
|
|
register_form(ContactForm, 'contact', submit_handler=...)
|
|
|
|
|
"""
|
|
|
|
|
from mizan.forms.schema_utils import build_form_schema
|
|
|
|
|
|
|
|
|
|
# Schema function - returns field definitions
|
|
|
|
|
class FormSchema(ServerFunction):
|
|
|
|
|
class Output(FormSchemaOutput):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def call(self, input):
|
|
|
|
|
schema = build_form_schema(form_class)
|
|
|
|
|
fields = [
|
|
|
|
|
FormSchemaField(
|
|
|
|
|
name=field.name,
|
|
|
|
|
type=field.type,
|
|
|
|
|
required=field.required,
|
|
|
|
|
label=field.label or field.name,
|
|
|
|
|
help_text=field.help_text or None,
|
|
|
|
|
choices=[(c.value, c.label) for c in field.choices]
|
|
|
|
|
if field.choices
|
|
|
|
|
else None,
|
|
|
|
|
initial=field.initial,
|
|
|
|
|
)
|
|
|
|
|
for field in schema.fields
|
|
|
|
|
]
|
|
|
|
|
return self.Output(fields=fields)
|
|
|
|
|
|
|
|
|
|
FormSchema.__name__ = f"{name.title().replace('-', '')}Schema"
|
|
|
|
|
FormSchema._meta = {"form": True, "form_name": name, "form_role": "schema"}
|
|
|
|
|
|
|
|
|
|
# Validation function
|
|
|
|
|
class FormDataInput(BaseModel):
|
|
|
|
|
data: dict[str, Any]
|
|
|
|
|
|
|
|
|
|
class FormValidate(ServerFunction):
|
|
|
|
|
Input = FormDataInput
|
|
|
|
|
|
|
|
|
|
class Output(FormValidationOutput):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def call(self, input):
|
|
|
|
|
form = form_class(data=input.data)
|
|
|
|
|
if form.is_valid():
|
|
|
|
|
return self.Output(valid=True, errors={})
|
|
|
|
|
return self.Output(valid=False, errors=dict(form.errors))
|
|
|
|
|
|
|
|
|
|
FormValidate.__name__ = f"{name.title().replace('-', '')}Validate"
|
|
|
|
|
FormValidate._meta = {"form": True, "form_name": name, "form_role": "validate"}
|
|
|
|
|
|
|
|
|
|
# Submit function (optional)
|
|
|
|
|
FormSubmit = None
|
|
|
|
|
if submit_handler:
|
|
|
|
|
|
|
|
|
|
class FormSubmit(ServerFunction):
|
|
|
|
|
Input = FormDataInput
|
|
|
|
|
|
|
|
|
|
def call(self, input):
|
|
|
|
|
# Validate first
|
|
|
|
|
form = form_class(data=input.data)
|
|
|
|
|
if not form.is_valid():
|
|
|
|
|
raise ValueError("Form validation failed")
|
|
|
|
|
# Call handler
|
|
|
|
|
return submit_handler(self.request, form.cleaned_data)
|
|
|
|
|
|
|
|
|
|
FormSubmit.__name__ = f"{name.title().replace('-', '')}Submit"
|
|
|
|
|
FormSubmit._meta = {"form": True, "form_name": name, "form_role": "submit"}
|
|
|
|
|
|
|
|
|
|
return FormSchema, FormValidate, FormSubmit
|