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,
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user