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:
@@ -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)
|
||||
|
||||
@@ -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,19 +415,28 @@ 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
|
||||
# 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
|
||||
# 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)
|
||||
@@ -436,7 +450,6 @@ def _create_server_function(
|
||||
output_cls = output_type
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<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
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user