diff --git a/packages/mizan-django/src/mizan/client/executor.py b/packages/mizan-django/src/mizan/client/executor.py index b2c215e..aabf05a 100644 --- a/packages/mizan-django/src/mizan/client/executor.py +++ b/packages/mizan-django/src/mizan/client/executor.py @@ -431,7 +431,18 @@ def execute_function( else None, ) - # Serialize output (handle None for Optional return types) + # Return-type branching: HttpResponse (view path) vs data (RPC path) + from django.http import HttpResponseBase + + if isinstance(output, HttpResponseBase): + # View path — add invalidation header, pass through the response + invalidate = _resolve_invalidation(view_class, input_data) + if invalidate: + output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate) + output["Cache-Control"] = "no-store" + return output + + # RPC path — serialize output if output is None: return FunctionResult(data=None) return FunctionResult(data=output.model_dump()) @@ -610,6 +621,11 @@ def function_call_view(request: HttpRequest) -> JsonResponse: # Execute the function result = execute_function(request, fn_name, input_data) + # View path — function returned an HttpResponse directly + from django.http import HttpResponseBase + if isinstance(result, HttpResponseBase): + return result + # Return appropriate response if isinstance(result, FunctionError): status = { @@ -623,7 +639,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse: }.get(result.code, 400) return result.to_response(status=status) - # Build response with server-driven invalidation (both transports) + # RPC path — build response with server-driven invalidation view_class = get_function(fn_name) response_data = {"result": result.data} invalidate_contexts = _resolve_invalidation(view_class, input_data) diff --git a/packages/mizan-django/src/mizan/client/function.py b/packages/mizan-django/src/mizan/client/function.py index 6e768dc..dcd8d3c 100644 --- a/packages/mizan-django/src/mizan/client/function.py +++ b/packages/mizan-django/src/mizan/client/function.py @@ -187,6 +187,11 @@ class _FunctionWrapper(ServerFunction): 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) @@ -410,35 +415,43 @@ def _create_server_function( if output_type is None: raise TypeError(f"Server function '{name}' must have a return type annotation") - # Support primitive return types by wrapping in a model with 'result' field - # Also handle Optional[X] / X | None by extracting the non-None type - import types + # 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) + ) - 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 - # Handle Union types: typing.Union (Optional[X]) and types.UnionType (X | None) - origin = get_origin(t) - if origin is Union or isinstance(t, types.UnionType): - args = get_args(t) - # Check if any non-None arg is a BaseModel - 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 + if is_view_path: + # View path — no Pydantic output wrapping needed + output_cls = BaseModel # placeholder, never used for serialization is_primitive_output = False else: - # Create model wrapper for primitive types (int, str, list, etc.) - output_cls = create_model(f"{fn.__name__}_Output", result=(output_type, ...)) - is_primitive_output = True + # 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] @@ -463,6 +476,10 @@ def _create_server_function( # Build metadata meta = {} + # View path flag (function returns HttpResponse, no codegen) + if is_view_path: + meta["view_path"] = True + # Context name (any non-empty string) if context: meta["context"] = context diff --git a/packages/mizan-django/src/mizan/tests/test_core.py b/packages/mizan-django/src/mizan/tests/test_core.py index e613fa3..a9c0761 100644 --- a/packages/mizan-django/src/mizan/tests/test_core.py +++ b/packages/mizan-django/src/mizan/tests/test_core.py @@ -1913,6 +1913,167 @@ class HTTPIntegrationTests(TestCase): self.assertEqual(response.status_code, 405) +# ============================================================================= +# Return-Type Branching Tests +# ============================================================================= + + +class ReturnTypeBranchingTests(TestCase): + """ + Tests that @client handles both RPC path (data return) and + view path (HttpResponse return) correctly. + """ + + def setUp(self): + clear_registry() + + def tearDown(self): + clear_registry() + + def test_view_path_mutation_adds_header(self): + """View function returning HttpResponse gets X-Mizan-Invalidate header.""" + from django.http import HttpResponse + + UserCtx = ReactContext("user") + + # Register a context function so auto-scoping can find user_id param + @client(context=UserCtx) + def user_profile(request: HttpRequest, user_id: int) -> ValidOutput: + return ValidOutput(valid=True) + + @client(affects=UserCtx) + def update_profile_view(request: HttpRequest, user_id: int) -> HttpResponse: + return HttpResponse("Profile updated", status=200) + + register(user_profile, "user_profile") + register(update_profile_view, "update_profile_view") + + response = self.client.post( + "/api/mizan/call/", + data=json.dumps({"fn": "update_profile_view", "args": {"user_id": 5}}), + content_type="application/json", + ) + + # The response IS the HttpResponse from the function, not JSON-wrapped + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"Profile updated") + self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=5") + self.assertEqual(response["Cache-Control"], "no-store") + + def test_view_path_redirect_with_invalidation(self): + """View function returning redirect gets X-Mizan-Invalidate header.""" + from django.http import HttpResponseRedirect + + UserCtx = ReactContext("user") + + @client(context=UserCtx) + def user_profile(request: HttpRequest, user_id: int) -> ValidOutput: + return ValidOutput(valid=True) + + @client(affects=UserCtx) + def update_and_redirect(request: HttpRequest, user_id: int) -> HttpResponseRedirect: + return HttpResponseRedirect(f"/profile/{user_id}/") + + register(user_profile, "user_profile") + register(update_and_redirect, "update_and_redirect") + + response = self.client.post( + "/api/mizan/call/", + data=json.dumps({"fn": "update_and_redirect", "args": {"user_id": 7}}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "/profile/7/") + self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=7") + + def test_view_path_no_affects_no_header(self): + """View function without affects= has no invalidation header.""" + from django.http import HttpResponse + + @client + def plain_view(request: HttpRequest) -> HttpResponse: + return HttpResponse("OK") + + register(plain_view, "plain_view") + + response = self.client.post( + "/api/mizan/call/", + data=json.dumps({"fn": "plain_view", "args": {}}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"OK") + self.assertNotIn("X-Mizan-Invalidate", response) + + def test_rpc_path_still_works(self): + """Data-returning functions still produce JSON response with invalidation.""" + UserCtx = ReactContext("user") + + @client(context=UserCtx) + def user_profile(request: HttpRequest, user_id: int) -> ValidOutput: + return ValidOutput(valid=True) + + @client(affects=UserCtx) + def update_name(request: HttpRequest, user_id: int, name: str) -> ValidOutput: + return ValidOutput(valid=True) + + register(user_profile, "user_profile") + register(update_name, "update_name") + + response = self.client.post( + "/api/mizan/call/", + data=json.dumps({"fn": "update_name", "args": {"user_id": 5, "name": "X"}}), + content_type="application/json", + ) + + data = response.json() + self.assertIn("result", data) + self.assertIn("invalidate", data) + + def test_view_path_meta_flag(self): + """View-path functions have view_path=True in _meta.""" + from django.http import HttpResponse + + @client + def my_view(request: HttpRequest) -> HttpResponse: + return HttpResponse("OK") + + self.assertTrue(my_view._meta.get("view_path")) + + def test_rpc_path_no_view_flag(self): + """RPC-path functions don't have view_path in _meta.""" + + @client + def my_rpc(request: HttpRequest) -> ValidOutput: + return ValidOutput(valid=True) + + self.assertNotIn("view_path", my_rpc._meta) + + def test_view_context_registered_in_graph(self): + """View-path context functions are registered for invalidation but produce no output schema.""" + from django.http import HttpResponse + + UserCtx = ReactContext("user") + + @client(context=UserCtx) + def profile_page(request: HttpRequest, user_id: int) -> HttpResponse: + return HttpResponse("profile") + + register(profile_page, "profile_page") + + # It's in the context groups (for invalidation graph) + from mizan.setup.registry import get_context_groups + groups = get_context_groups() + self.assertIn("user", groups) + self.assertIn("profile_page", groups["user"]) + + # But it's marked as view_path + fn = get_function("profile_page") + self.assertTrue(fn._meta.get("view_path")) + + # ============================================================================= # Edge Compatibility Tests — Prove CDN caching works before Edge exists # =============================================================================