Add private=True, route=, methods= to @client decorator
private=True: server-internal functions (webhooks, cron) that emit invalidation but are not client-callable. Rejected from POST /call/ with 403. No codegen. Appears in manifest for Edge. @client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST']) def stripe_webhook(request) -> HttpResponse: ... route=: Mizan-owned URL pattern for view-path functions. Registered during autodiscovery. Populates page_routes in the manifest for Edge/PSR to resolve during invalidation. methods=: HTTP methods for the route. Defaults to ['GET'] for context functions, ['POST'] for mutations. Extended Edge manifest with: - mutations section: affects, auto_scoped_params, private, route - render_strategy: "psr" (no user params) or "dynamic_cached" (user-scoped) - user_scoped: derived from param names matching common identity params - page_routes: from route= on view-path functions + external view_urls 323 Django tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -335,8 +335,15 @@ def execute_function(
|
|||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check auth requirement BEFORE executing
|
# Reject private functions from RPC dispatch
|
||||||
meta = getattr(view_class, "_meta", {})
|
meta = getattr(view_class, "_meta", {})
|
||||||
|
if meta.get("private"):
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
message="Function is not client-callable",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check auth requirement BEFORE executing
|
||||||
auth_requirement = meta.get("auth")
|
auth_requirement = meta.get("auth")
|
||||||
auth_error = _check_auth_requirement(request, auth_requirement)
|
auth_error = _check_auth_requirement(request, auth_requirement)
|
||||||
if auth_error is not None:
|
if auth_error is not None:
|
||||||
|
|||||||
@@ -256,6 +256,9 @@ def client(
|
|||||||
*,
|
*,
|
||||||
context: ContextMode = False,
|
context: ContextMode = False,
|
||||||
affects: AffectsMode = None,
|
affects: AffectsMode = None,
|
||||||
|
private: bool = False,
|
||||||
|
route: str | None = None,
|
||||||
|
methods: list[str] | None = None,
|
||||||
websocket: bool = False,
|
websocket: bool = False,
|
||||||
auth: bool | str | Callable[[Any], bool] | None = None,
|
auth: bool | str | Callable[[Any], bool] | None = None,
|
||||||
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
||||||
@@ -269,21 +272,26 @@ def client(
|
|||||||
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
|
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
|
||||||
|
|
||||||
affects: Declare which contexts or functions this mutation invalidates.
|
affects: Declare which contexts or functions this mutation invalidates.
|
||||||
- A ReactContext instance or context name string: invalidates entire context
|
|
||||||
- A function name string: invalidates just that function within its context
|
|
||||||
- A list of the above: invalidates multiple targets
|
|
||||||
Mutually exclusive with context=.
|
Mutually exclusive with context=.
|
||||||
|
Scoping is automatic via argument name matching.
|
||||||
|
|
||||||
Scoping is automatic: if the mutation's arguments overlap with the
|
private: If True, the function is not client-callable.
|
||||||
context's params by name, the invalidation is scoped to those values.
|
- 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).
|
websocket: Enable WebSocket RPC transport (default: False).
|
||||||
|
|
||||||
auth: Authentication requirement.
|
auth: Authentication requirement.
|
||||||
- None (default): No auth required
|
|
||||||
- True or 'required': Must be authenticated
|
|
||||||
- 'staff'/'superuser': role-based
|
|
||||||
- callable(request) -> bool: Custom check
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
UserContext = ReactContext('user')
|
UserContext = ReactContext('user')
|
||||||
@@ -291,17 +299,16 @@ def client(
|
|||||||
@client(context=UserContext)
|
@client(context=UserContext)
|
||||||
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||||
|
|
||||||
# Broad invalidation — all UserContext instances
|
|
||||||
@client(affects=UserContext)
|
|
||||||
def reset_all_profiles(request) -> dict: ...
|
|
||||||
|
|
||||||
# Auto-scoped — user_id matches, only invalidates UserContext(user_id=5)
|
|
||||||
@client(affects=UserContext)
|
@client(affects=UserContext)
|
||||||
def update_profile(request, user_id: int, name: str) -> dict: ...
|
def update_profile(request, user_id: int, name: str) -> dict: ...
|
||||||
|
|
||||||
# Function-level — only user_profile refetches, not user_orders
|
# View with route — Mizan owns the URL
|
||||||
@client(affects='user_profile')
|
@client(context=UserContext, route='/profile/<user_id>/')
|
||||||
def update_name(request, user_id: int, name: str) -> dict: ...
|
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:
|
Returns:
|
||||||
A ServerFunction class that wraps the function
|
A ServerFunction class that wraps the function
|
||||||
@@ -328,6 +335,7 @@ def client(
|
|||||||
def decorator(fn: Callable) -> type[ServerFunction]:
|
def decorator(fn: Callable) -> type[ServerFunction]:
|
||||||
return _create_server_function(
|
return _create_server_function(
|
||||||
fn, context=resolved_context, affects=affects,
|
fn, context=resolved_context, affects=affects,
|
||||||
|
private=private, route=route, methods=methods,
|
||||||
websocket=websocket, auth=auth,
|
websocket=websocket, auth=auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -335,6 +343,7 @@ def client(
|
|||||||
if fn is not None:
|
if fn is not None:
|
||||||
return _create_server_function(
|
return _create_server_function(
|
||||||
fn, context=resolved_context, affects=affects,
|
fn, context=resolved_context, affects=affects,
|
||||||
|
private=private, route=route, methods=methods,
|
||||||
websocket=websocket, auth=auth,
|
websocket=websocket, auth=auth,
|
||||||
)
|
)
|
||||||
return decorator
|
return decorator
|
||||||
@@ -373,6 +382,9 @@ def _create_server_function(
|
|||||||
*,
|
*,
|
||||||
context: str | Literal[False] = False,
|
context: str | Literal[False] = False,
|
||||||
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
|
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,
|
websocket: bool = False,
|
||||||
auth: bool | str | None = None,
|
auth: bool | str | None = None,
|
||||||
) -> type[ServerFunction]:
|
) -> type[ServerFunction]:
|
||||||
@@ -480,6 +492,15 @@ def _create_server_function(
|
|||||||
if is_view_path:
|
if is_view_path:
|
||||||
meta["view_path"] = True
|
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)
|
# Context name (any non-empty string)
|
||||||
if context:
|
if context:
|
||||||
meta["context"] = context
|
meta["context"] = context
|
||||||
|
|||||||
@@ -384,16 +384,20 @@ def generate_edge_manifest(
|
|||||||
"""
|
"""
|
||||||
from pydantic import BaseModel as PydanticBaseModel
|
from pydantic import BaseModel as PydanticBaseModel
|
||||||
|
|
||||||
|
# Common user identity param names for user_scoped detection
|
||||||
|
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||||
|
|
||||||
groups = get_context_groups()
|
groups = get_context_groups()
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
all_functions = registry.get("functions", {})
|
all_functions = registry.get("functions", {})
|
||||||
|
|
||||||
manifest: dict[str, Any] = {"contexts": {}}
|
manifest: dict[str, Any] = {"contexts": {}, "mutations": {}}
|
||||||
|
|
||||||
for ctx_name, fn_names in groups.items():
|
for ctx_name, fn_names in groups.items():
|
||||||
# Collect params from all functions in this context
|
# 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] = []
|
||||||
|
|
||||||
for fn_name in fn_names:
|
for fn_name in fn_names:
|
||||||
fn_cls = all_functions.get(fn_name)
|
fn_cls = all_functions.get(fn_name)
|
||||||
@@ -412,23 +416,86 @@ def generate_edge_manifest(
|
|||||||
):
|
):
|
||||||
param_names.update(input_cls.model_fields.keys())
|
param_names.update(input_cls.model_fields.keys())
|
||||||
|
|
||||||
functions_meta.append({
|
fn_entry: dict[str, Any] = {
|
||||||
"name": fn_name,
|
"name": fn_name,
|
||||||
"path": "view" if is_view else "rpc",
|
"path": "view" if is_view else "rpc",
|
||||||
})
|
}
|
||||||
|
|
||||||
|
# Collect routes from view-path functions
|
||||||
|
fn_route = meta.get("route")
|
||||||
|
if fn_route:
|
||||||
|
fn_entry["route"] = fn_route
|
||||||
|
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||||
|
page_routes.append(fn_route)
|
||||||
|
|
||||||
|
functions_meta.append(fn_entry)
|
||||||
|
|
||||||
|
sorted_params = sorted(param_names)
|
||||||
|
user_scoped = bool(param_names & _USER_SCOPED_PARAMS)
|
||||||
|
|
||||||
ctx_entry: dict[str, Any] = {
|
ctx_entry: dict[str, Any] = {
|
||||||
"functions": functions_meta,
|
"functions": functions_meta,
|
||||||
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||||
"params": sorted(param_names),
|
"params": sorted_params,
|
||||||
|
"user_scoped": user_scoped,
|
||||||
|
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add view URLs if provided
|
# Add page routes from view-path functions with route=
|
||||||
|
if page_routes:
|
||||||
|
ctx_entry["page_routes"] = page_routes
|
||||||
|
|
||||||
|
# Add externally-declared view URLs
|
||||||
if view_urls and ctx_name in view_urls:
|
if view_urls and ctx_name in view_urls:
|
||||||
ctx_entry["views"] = 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():
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
affects = meta.get("affects")
|
||||||
|
if not affects:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resolve context names from affects targets
|
||||||
|
affected_contexts = []
|
||||||
|
for target in affects:
|
||||||
|
if target["type"] == "context":
|
||||||
|
affected_contexts.append(target["name"])
|
||||||
|
elif target["type"] == "function" and target.get("context"):
|
||||||
|
affected_contexts.append(target["context"])
|
||||||
|
affected_contexts = list(dict.fromkeys(affected_contexts))
|
||||||
|
|
||||||
|
# Determine which params auto-scope
|
||||||
|
auto_scoped = []
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls and input_cls is not PydanticBaseModel and hasattr(input_cls, "model_fields"):
|
||||||
|
fn_params = set(input_cls.model_fields.keys())
|
||||||
|
for ctx_name in affected_contexts:
|
||||||
|
ctx_params = set()
|
||||||
|
for ctx_fn_name in groups.get(ctx_name, []):
|
||||||
|
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||||
|
if ctx_fn_cls:
|
||||||
|
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||||
|
if ctx_input and ctx_input is not PydanticBaseModel and hasattr(ctx_input, "model_fields"):
|
||||||
|
ctx_params.update(ctx_input.model_fields.keys())
|
||||||
|
auto_scoped.extend(sorted(fn_params & ctx_params))
|
||||||
|
auto_scoped = list(dict.fromkeys(auto_scoped))
|
||||||
|
|
||||||
|
mutation_entry: dict[str, Any] = {
|
||||||
|
"affects": affected_contexts,
|
||||||
|
}
|
||||||
|
if auto_scoped:
|
||||||
|
mutation_entry["auto_scoped_params"] = auto_scoped
|
||||||
|
if meta.get("private"):
|
||||||
|
mutation_entry["private"] = True
|
||||||
|
if meta.get("route"):
|
||||||
|
mutation_entry["route"] = meta["route"]
|
||||||
|
mutation_entry["methods"] = meta.get("methods", ["POST"])
|
||||||
|
|
||||||
|
manifest["mutations"][fn_name] = mutation_entry
|
||||||
|
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2538,7 +2538,7 @@ class EdgeManifestTests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
user_ctx = manifest["contexts"]["user"]
|
user_ctx = manifest["contexts"]["user"]
|
||||||
self.assertEqual(user_ctx["views"], ["/profile/:user_id/", "/u/:user_id/"])
|
self.assertEqual(user_ctx["page_routes"], ["/profile/:user_id/", "/u/:user_id/"])
|
||||||
|
|
||||||
def test_manifest_custom_base_url(self):
|
def test_manifest_custom_base_url(self):
|
||||||
"""Manifest respects custom base URL."""
|
"""Manifest respects custom base URL."""
|
||||||
@@ -2604,3 +2604,192 @@ class EdgeManifestTests(TestCase):
|
|||||||
parsed = json.loads(json1)
|
parsed = json.loads(json1)
|
||||||
context_keys = list(parsed["contexts"].keys())
|
context_keys = list(parsed["contexts"].keys())
|
||||||
self.assertEqual(context_keys, sorted(context_keys))
|
self.assertEqual(context_keys, sorted(context_keys))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Private Functions + Route Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateAndRouteTests(TestCase):
|
||||||
|
"""Tests for private=True, route=, and methods= on @client."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
def test_private_meta_flag(self):
|
||||||
|
"""private=True sets the flag in _meta."""
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
@client(affects="subscription", private=True)
|
||||||
|
def stripe_webhook(request: HttpRequest) -> HttpResponse:
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
self.assertTrue(stripe_webhook._meta.get("private"))
|
||||||
|
|
||||||
|
def test_private_rejected_from_rpc(self):
|
||||||
|
"""Private functions cannot be called via POST /api/mizan/call/."""
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
@client(affects="subscription", private=True)
|
||||||
|
def stripe_webhook(request: HttpRequest) -> HttpResponse:
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
register(stripe_webhook, "stripe_webhook")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/mizan/call/",
|
||||||
|
data=json.dumps({"fn": "stripe_webhook", "args": {}}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
data = response.json()
|
||||||
|
self.assertTrue(data["error"])
|
||||||
|
self.assertEqual(data["code"], "FORBIDDEN")
|
||||||
|
|
||||||
|
def test_route_stored_in_meta(self):
|
||||||
|
"""route= and methods= are stored in _meta."""
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
@client(
|
||||||
|
affects="subscription",
|
||||||
|
private=True,
|
||||||
|
route="/webhooks/stripe/",
|
||||||
|
methods=["POST"],
|
||||||
|
)
|
||||||
|
def stripe_webhook(request: HttpRequest) -> HttpResponse:
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
self.assertEqual(stripe_webhook._meta["route"], "/webhooks/stripe/")
|
||||||
|
self.assertEqual(stripe_webhook._meta["methods"], ["POST"])
|
||||||
|
|
||||||
|
def test_route_default_methods(self):
|
||||||
|
"""Default methods: GET for context, POST for mutation."""
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
|
@client(context=UserCtx, route="/profile/<user_id>/")
|
||||||
|
def profile_page(request: HttpRequest, user_id: int) -> HttpResponse:
|
||||||
|
return HttpResponse("<html>profile</html>")
|
||||||
|
|
||||||
|
@client(affects=UserCtx, route="/profile/<user_id>/update/")
|
||||||
|
def update_profile(request: HttpRequest, user_id: int) -> HttpResponse:
|
||||||
|
return HttpResponse(status=302)
|
||||||
|
|
||||||
|
self.assertEqual(profile_page._meta["methods"], ["GET"])
|
||||||
|
self.assertEqual(update_profile._meta["methods"], ["POST"])
|
||||||
|
|
||||||
|
def test_private_in_manifest(self):
|
||||||
|
"""Private functions appear in manifest mutations section."""
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
SubCtx = ReactContext("subscription")
|
||||||
|
|
||||||
|
@client(context=SubCtx)
|
||||||
|
def subscription_info(request: HttpRequest) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
@client(
|
||||||
|
affects=SubCtx,
|
||||||
|
private=True,
|
||||||
|
route="/webhooks/stripe/",
|
||||||
|
methods=["POST"],
|
||||||
|
)
|
||||||
|
def stripe_webhook(request: HttpRequest) -> HttpResponse:
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
register(subscription_info, "subscription_info")
|
||||||
|
register(stripe_webhook, "stripe_webhook")
|
||||||
|
|
||||||
|
manifest = generate_edge_manifest()
|
||||||
|
|
||||||
|
# In mutations section
|
||||||
|
self.assertIn("stripe_webhook", manifest["mutations"])
|
||||||
|
mut = manifest["mutations"]["stripe_webhook"]
|
||||||
|
self.assertTrue(mut["private"])
|
||||||
|
self.assertEqual(mut["route"], "/webhooks/stripe/")
|
||||||
|
self.assertEqual(mut["methods"], ["POST"])
|
||||||
|
self.assertEqual(mut["affects"], ["subscription"])
|
||||||
|
|
||||||
|
def test_manifest_render_strategy_user_scoped(self):
|
||||||
|
"""Contexts with user_id params are user_scoped + dynamic_cached."""
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
|
||||||
|
manifest = generate_edge_manifest()
|
||||||
|
ctx = manifest["contexts"]["user"]
|
||||||
|
self.assertTrue(ctx["user_scoped"])
|
||||||
|
self.assertEqual(ctx["render_strategy"], "dynamic_cached")
|
||||||
|
|
||||||
|
def test_manifest_render_strategy_psr(self):
|
||||||
|
"""Contexts without user-scoped params are PSR."""
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
ProductCtx = ReactContext("products")
|
||||||
|
|
||||||
|
@client(context=ProductCtx)
|
||||||
|
def product_detail(request: HttpRequest, product_id: int) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(product_detail, "product_detail")
|
||||||
|
|
||||||
|
manifest = generate_edge_manifest()
|
||||||
|
ctx = manifest["contexts"]["products"]
|
||||||
|
self.assertFalse(ctx["user_scoped"])
|
||||||
|
self.assertEqual(ctx["render_strategy"], "psr")
|
||||||
|
|
||||||
|
def test_manifest_auto_scoped_params(self):
|
||||||
|
"""Mutations with matching params show auto_scoped_params in manifest."""
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
@client(affects=UserCtx)
|
||||||
|
def update_profile(request: HttpRequest, user_id: int, name: str) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
register(update_profile, "update_profile")
|
||||||
|
|
||||||
|
manifest = generate_edge_manifest()
|
||||||
|
mut = manifest["mutations"]["update_profile"]
|
||||||
|
self.assertIn("user_id", mut["auto_scoped_params"])
|
||||||
|
|
||||||
|
def test_manifest_page_routes_from_decorator(self):
|
||||||
|
"""View-path functions with route= populate page_routes in manifest."""
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
@client(context=UserCtx, route="/profile/<user_id>/")
|
||||||
|
def profile_page(request: HttpRequest, user_id: int) -> HttpResponse:
|
||||||
|
return HttpResponse("<html>profile</html>")
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
register(profile_page, "profile_page")
|
||||||
|
|
||||||
|
manifest = generate_edge_manifest()
|
||||||
|
ctx = manifest["contexts"]["user"]
|
||||||
|
self.assertIn("/profile/<user_id>/", ctx["page_routes"])
|
||||||
|
|||||||
Reference in New Issue
Block a user