diff --git a/packages/mizan-django/src/mizan/client/executor.py b/packages/mizan-django/src/mizan/client/executor.py index 0738ae8..84d96d8 100644 --- a/packages/mizan-django/src/mizan/client/executor.py +++ b/packages/mizan-django/src/mizan/client/executor.py @@ -161,10 +161,14 @@ def _check_auth_requirement( def _resolve_invalidation( view_class: type | None, + request: HttpRequest | None = None, ) -> list[str | dict[str, Any]] | None: """ Resolve the invalidation targets from a function's affects metadata. + If affects_params is declared, calls it with the request to produce + scoped invalidation entries. + Returns a list suitable for both JSON body and header serialization: - Simple: ["user", "notifications"] - Scoped: [{"context": "user", "params": {"user_id": 5}}] @@ -180,18 +184,35 @@ def _resolve_invalidation( if not affects: return None - contexts = [] + # Resolve context names from affects targets + context_names = [] for target in affects: if target["type"] == "context": - contexts.append(target["name"]) + context_names.append(target["name"]) elif target["type"] == "function" and target.get("context"): - contexts.append(target["context"]) + context_names.append(target["context"]) - if not contexts: + if not context_names: return None # Dedupe while preserving order - return list(dict.fromkeys(contexts)) + context_names = list(dict.fromkeys(context_names)) + + # If affects_params is declared, produce scoped entries + affects_params_fn = meta.get("affects_params") + if affects_params_fn and request is not None: + try: + params = affects_params_fn(request) + if params and isinstance(params, dict): + return [ + {"context": name, "params": params} + for name in context_names + ] + except Exception as e: + logger.warning(f"affects_params callable failed: {e}") + # Fall through to broad invalidation + + return context_names def _format_invalidate_header( @@ -547,7 +568,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse: # Build response with server-driven invalidation (both transports) view_class = get_function(fn_name) response_data = {"result": result.data} - invalidate_contexts = _resolve_invalidation(view_class) + invalidate_contexts = _resolve_invalidation(view_class, request) if invalidate_contexts: response_data["invalidate"] = invalidate_contexts diff --git a/packages/mizan-django/src/mizan/client/function.py b/packages/mizan-django/src/mizan/client/function.py index 104d17a..f39a041 100644 --- a/packages/mizan-django/src/mizan/client/function.py +++ b/packages/mizan-django/src/mizan/client/function.py @@ -251,51 +251,50 @@ def client( *, context: ContextMode = False, affects: AffectsMode = None, + affects_params: Callable[[Any], dict[str, Any]] | None = None, websocket: bool = False, auth: bool | str | Callable[[Any], bool] | None = None, ) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]: """ Register a function as a server function. - Type annotations define the schema - just like Django Ninja/FastAPI. - Function parameters become input fields automatically. - 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. - - Raw string: also accepted (e.g., 'user'), but ReactContext preferred. affects: Declare which contexts this mutation invalidates. - - A ReactContext instance - - A list of ReactContext instances - - Also accepts strings or function references for backwards compat + - A ReactContext instance or list of them Mutually exclusive with context=. + affects_params: Callable that extracts scoped invalidation params. + Called with the request after function execution. + Returns a dict of params that scope the invalidation. + Produces: invalidate: [{context: "user", params: {user_id: 5}}] + And header: X-Mizan-Invalidate: user;user_id=5 + Requires affects= to be set. + websocket: Enable WebSocket RPC transport (default: False). auth: Authentication requirement. - None (default): No auth required - True or 'required': Must be authenticated - - 'staff': Must have is_staff=True - - 'superuser': Must have is_superuser=True - - callable(request) -> bool: Custom check function + - 'staff'/'superuser': role-based + - callable(request) -> bool: Custom check Usage: UserContext = ReactContext('user') - @client(context=GlobalContext) - def current_user(request) -> UserOutput: ... - @client(context=UserContext) def user_profile(request, user_id: int) -> ProfileOutput: ... @client(affects=UserContext) def edit_profile(request, name: str) -> dict: ... - @client(affects=[UserContext, OrderContext]) - def change_plan(request) -> dict: ... + # Scoped: only invalidate user context for this specific user + @client(affects=UserContext, affects_params=lambda req: {'user_id': req.user.pk}) + def update_avatar(request, url: str) -> dict: ... Returns: A ServerFunction class that wraps the function @@ -311,6 +310,12 @@ def client( "A function cannot be both a context reader and a mutation." ) + # Validate affects_params + if affects_params is not None and affects is None: + raise ValueError( + "affects_params= requires affects= to be set." + ) + # Validate auth parameter if auth is not None: if isinstance(auth, str) and auth not in _VALID_AUTH_STRINGS: @@ -321,13 +326,15 @@ def client( def decorator(fn: Callable) -> type[ServerFunction]: return _create_server_function( - fn, context=resolved_context, affects=affects, websocket=websocket, auth=auth + fn, context=resolved_context, affects=affects, affects_params=affects_params, + websocket=websocket, auth=auth, ) # Support both @client and @client(...) if fn is not None: return _create_server_function( - fn, context=resolved_context, affects=affects, websocket=websocket, auth=auth + fn, context=resolved_context, affects=affects, affects_params=affects_params, + websocket=websocket, auth=auth, ) return decorator @@ -365,6 +372,7 @@ def _create_server_function( *, context: str | Literal[False] = False, affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None, + affects_params: Callable | None = None, websocket: bool = False, auth: bool | str | None = None, ) -> type[ServerFunction]: @@ -468,6 +476,8 @@ def _create_server_function( normalized_affects = _normalize_affects(affects) if normalized_affects: meta["affects"] = normalized_affects + if affects_params is not None: + meta["affects_params"] = affects_params # WebSocket: enable WebSocket transport if websocket: diff --git a/packages/mizan-django/src/mizan/tests/test_core.py b/packages/mizan-django/src/mizan/tests/test_core.py index 2ba9160..6b24e6d 100644 --- a/packages/mizan-django/src/mizan/tests/test_core.py +++ b/packages/mizan-django/src/mizan/tests/test_core.py @@ -829,6 +829,56 @@ class ServerDrivenInvalidationTests(TestCase): self.assertEqual(data["invalidate"], ["user", "notifications"]) self.assertEqual(response["X-Mizan-Invalidate"], "user, notifications") + def test_scoped_invalidation_with_affects_params(self): + """affects_params produces scoped invalidation in body and header.""" + from mizan.client.executor import function_call_view + from django.contrib.auth.models import User + + UserCtx = ReactContext("user") + + @client( + affects=UserCtx, + affects_params=lambda req: {"user_id": getattr(req.user, "pk", 0)}, + auth=True, + ) + def update_avatar(request: HttpRequest, url: str) -> ValidOutput: + return ValidOutput(valid=True) + + register(update_avatar, "update_avatar") + + request = self.factory.post( + "/api/mizan/call/", + json.dumps({"fn": "update_avatar", "args": {"url": "https://example.com/pic.jpg"}}), + content_type="application/json", + ) + # Create a mock user with pk + user = MagicMock() + user.pk = 42 + user.is_authenticated = True + request.user = user + request._dont_enforce_csrf_checks = True + + response = function_call_view(request) + data = json.loads(response.content) + + # Scoped invalidation in JSON body + self.assertEqual(len(data["invalidate"]), 1) + self.assertEqual(data["invalidate"][0]["context"], "user") + self.assertEqual(data["invalidate"][0]["params"]["user_id"], 42) + + # Scoped invalidation in header + self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=42") + + def test_affects_params_without_affects_raises(self): + """affects_params without affects raises ValueError.""" + with self.assertRaises(ValueError) as cm: + + @client(affects_params=lambda req: {"user_id": 1}) + def bad(request: HttpRequest) -> ValidOutput: + return ValidOutput(valid=True) + + self.assertIn("requires affects", str(cm.exception)) + def test_mutation_without_affects_has_no_invalidate(self): """Mutation without affects= returns result only.""" from mizan.client.executor import function_call_view