Return-type branching: one decorator, two paths

@client now handles both RPC and view functions based on return type:

  @client(affects=UserContext)
  def update_name(request, user_id: int, name: str) -> dict:
      ...  # RPC path: JSON response with invalidate key

  @client(affects=UserContext)
  def update_profile(request, user_id: int) -> HttpResponse:
      ...  # View path: HttpResponse with X-Mizan-Invalidate header

Detection: isinstance(result, HttpResponseBase) after execution.

RPC path (data return):
  - Serialized via Pydantic model_dump()
  - Wrapped in {"result": ..., "invalidate": [...]}
  - Invalidation in JSON body + X-Mizan-Invalidate header

View path (HttpResponse return):
  - Response passed through directly (redirect, HTML, etc.)
  - X-Mizan-Invalidate header added automatically
  - Cache-Control: no-store added
  - No codegen (view_path=True in _meta)
  - Registered in invalidation graph (for Edge manifest)

Auto-scoping works on both paths: if mutation args overlap
with context params, invalidation is scoped automatically.

308 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 23:20:51 -04:00
parent 89196a02c6
commit b4c7e783bd
3 changed files with 222 additions and 28 deletions

View File

@@ -431,7 +431,18 @@ def execute_function(
else None, 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: if output is None:
return FunctionResult(data=None) return FunctionResult(data=None)
return FunctionResult(data=output.model_dump()) return FunctionResult(data=output.model_dump())
@@ -610,6 +621,11 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
# Execute the function # Execute the function
result = execute_function(request, fn_name, input_data) 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 # Return appropriate response
if isinstance(result, FunctionError): if isinstance(result, FunctionError):
status = { status = {
@@ -623,7 +639,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
}.get(result.code, 400) }.get(result.code, 400)
return result.to_response(status=status) 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) view_class = get_function(fn_name)
response_data = {"result": result.data} response_data = {"result": result.data}
invalidate_contexts = _resolve_invalidation(view_class, input_data) invalidate_contexts = _resolve_invalidation(view_class, input_data)

View File

@@ -187,6 +187,11 @@ class _FunctionWrapper(ServerFunction):
else: else:
result = self._wrapped_fn(self.request) 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 # Wrap primitive returns in the generated output model
if self._is_primitive_output: if self._is_primitive_output:
return self._output_cls(result=result) return self._output_cls(result=result)
@@ -410,19 +415,28 @@ def _create_server_function(
if output_type is None: if output_type is None:
raise TypeError(f"Server function '{name}' must have a return type annotation") raise TypeError(f"Server function '{name}' must have a return type annotation")
# Support primitive return types by wrapping in a model with 'result' field # Detect view path: function returns HttpResponse (or has no return annotation
# Also handle Optional[X] / X | None by extracting the non-None type # 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 import types
def is_basemodel_type(t: Any) -> bool: def is_basemodel_type(t: Any) -> bool:
"""Check if type is a BaseModel subclass, handling Optional/Union.""" """Check if type is a BaseModel subclass, handling Optional/Union."""
if isinstance(t, type) and issubclass(t, BaseModel): if isinstance(t, type) and issubclass(t, BaseModel):
return True return True
# Handle Union types: typing.Union (Optional[X]) and types.UnionType (X | None)
origin = get_origin(t) origin = get_origin(t)
if origin is Union or isinstance(t, types.UnionType): if origin is Union or isinstance(t, types.UnionType):
args = get_args(t) args = get_args(t)
# Check if any non-None arg is a BaseModel
for arg in args: for arg in args:
if ( if (
arg is not type(None) arg is not type(None)
@@ -436,7 +450,6 @@ def _create_server_function(
output_cls = output_type output_cls = output_type
is_primitive_output = False is_primitive_output = False
else: else:
# Create model wrapper for primitive types (int, str, list, etc.)
output_cls = create_model(f"{fn.__name__}_Output", result=(output_type, ...)) output_cls = create_model(f"{fn.__name__}_Output", result=(output_type, ...))
is_primitive_output = True is_primitive_output = True
@@ -463,6 +476,10 @@ def _create_server_function(
# Build metadata # Build metadata
meta = {} meta = {}
# View path flag (function returns HttpResponse, no codegen)
if is_view_path:
meta["view_path"] = True
# Context name (any non-empty string) # Context name (any non-empty string)
if context: if context:
meta["context"] = context meta["context"] = context

View File

@@ -1913,6 +1913,167 @@ class HTTPIntegrationTests(TestCase):
self.assertEqual(response.status_code, 405) 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("<html>profile</html>")
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 # Edge Compatibility Tests — Prove CDN caching works before Edge exists
# ============================================================================= # =============================================================================