Server-driven invalidation + raw context response format
Mutation responses now include invalidation directives from the server:
POST /api/mizan/call/
→ {"result": {...}, "invalidate": ["user"]}
The client never hardcodes invalidation targets. The server resolves
affects= metadata and returns what to invalidate. mizan-runtime reads
the invalidate key and triggers refetches automatically.
Context fetch returns raw bundled data (not wrapped):
GET /api/mizan/ctx/user/?user_id=5
→ {"user_profile": {...}, "user_orders": [...]}
Also fixed QueryDict handling (use .dict() not dict() to avoid
list-wrapped values).
267 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -478,7 +478,25 @@ 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)
|
||||||
|
|
||||||
return result.to_response()
|
# Build response with server-driven invalidation
|
||||||
|
view_class = get_function(fn_name)
|
||||||
|
response_data = {"result": result.data}
|
||||||
|
|
||||||
|
if view_class:
|
||||||
|
meta = getattr(view_class, "_meta", {})
|
||||||
|
affects = meta.get("affects")
|
||||||
|
if affects:
|
||||||
|
invalidate = []
|
||||||
|
for target in affects:
|
||||||
|
if target["type"] == "context":
|
||||||
|
invalidate.append(target["name"])
|
||||||
|
elif target["type"] == "function" and target.get("context"):
|
||||||
|
invalidate.append(target["context"])
|
||||||
|
if invalidate:
|
||||||
|
# Dedupe while preserving order
|
||||||
|
response_data["invalidate"] = list(dict.fromkeys(invalidate))
|
||||||
|
|
||||||
|
return JsonResponse(response_data)
|
||||||
|
|
||||||
|
|
||||||
def execute_context(
|
def execute_context(
|
||||||
@@ -560,14 +578,11 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
|
|
||||||
Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1¶m2=val2
|
Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1¶m2=val2
|
||||||
|
|
||||||
Response on success:
|
Response: raw bundled data (keys are function names, values are results)
|
||||||
{
|
{
|
||||||
"error": false,
|
|
||||||
"data": {
|
|
||||||
"user_profile": { ... },
|
"user_profile": { ... },
|
||||||
"user_orders": [ ... ]
|
"user_orders": [ ... ]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
if request.method != "GET":
|
if request.method != "GET":
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -575,7 +590,7 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
message="Only GET method allowed",
|
message="Only GET method allowed",
|
||||||
).to_response(status=405)
|
).to_response(status=405)
|
||||||
|
|
||||||
params = dict(request.GET)
|
params = request.GET.dict()
|
||||||
result = execute_context(request, context_name, params)
|
result = execute_context(request, context_name, params)
|
||||||
|
|
||||||
if isinstance(result, FunctionError):
|
if isinstance(result, FunctionError):
|
||||||
@@ -590,4 +605,5 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
}.get(result.code, 400)
|
}.get(result.code, 400)
|
||||||
return result.to_response(status=status)
|
return result.to_response(status=status)
|
||||||
|
|
||||||
return result.to_response()
|
# Return raw bundled data (not wrapped in {"error": false, "data": ...})
|
||||||
|
return JsonResponse(result.data)
|
||||||
|
|||||||
@@ -756,6 +756,97 @@ class AffectsTests(TestCase):
|
|||||||
self.assertNotIn("affects", plain._meta)
|
self.assertNotIn("affects", plain._meta)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerDrivenInvalidationTests(TestCase):
|
||||||
|
"""Tests that mutation responses include invalidation directives."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
clear_registry()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
def test_mutation_response_includes_invalidate(self):
|
||||||
|
"""Mutation with affects= returns invalidate in response."""
|
||||||
|
from mizan.client.executor import function_call_view
|
||||||
|
|
||||||
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_profile(request: HttpRequest) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
@client(affects=UserCtx)
|
||||||
|
def edit_profile(request: HttpRequest, name: str) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
register(edit_profile, "edit_profile")
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/mizan/call/",
|
||||||
|
json.dumps({"fn": "edit_profile", "args": {"name": "Ryth"}}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
request._dont_enforce_csrf_checks = True
|
||||||
|
|
||||||
|
response = function_call_view(request)
|
||||||
|
data = json.loads(response.content)
|
||||||
|
|
||||||
|
self.assertIn("result", data)
|
||||||
|
self.assertIn("invalidate", data)
|
||||||
|
self.assertEqual(data["invalidate"], ["user"])
|
||||||
|
|
||||||
|
def test_mutation_without_affects_has_no_invalidate(self):
|
||||||
|
"""Mutation without affects= returns result only."""
|
||||||
|
from mizan.client.executor import function_call_view
|
||||||
|
|
||||||
|
@client
|
||||||
|
def plain_action(request: HttpRequest) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(plain_action, "plain_action")
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/mizan/call/",
|
||||||
|
json.dumps({"fn": "plain_action", "args": {}}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
request._dont_enforce_csrf_checks = True
|
||||||
|
|
||||||
|
response = function_call_view(request)
|
||||||
|
data = json.loads(response.content)
|
||||||
|
|
||||||
|
self.assertIn("result", data)
|
||||||
|
self.assertNotIn("invalidate", data)
|
||||||
|
|
||||||
|
def test_context_fetch_returns_raw_data(self):
|
||||||
|
"""Context GET returns raw bundled data, not wrapped."""
|
||||||
|
from mizan.client.executor import context_fetch_view
|
||||||
|
|
||||||
|
class Out(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@client(context="team")
|
||||||
|
def team_info(request: HttpRequest, team_id: int) -> Out:
|
||||||
|
return Out(name=f"team_{team_id}")
|
||||||
|
|
||||||
|
register(team_info, "team_info")
|
||||||
|
|
||||||
|
request = self.factory.get("/api/mizan/ctx/team/?team_id=3")
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
|
response = context_fetch_view(request, "team")
|
||||||
|
data = json.loads(response.content)
|
||||||
|
|
||||||
|
# Raw data — no "error" or "data" wrapper
|
||||||
|
self.assertNotIn("error", data)
|
||||||
|
self.assertIn("team_info", data)
|
||||||
|
self.assertEqual(data["team_info"]["name"], "team_3")
|
||||||
|
|
||||||
|
|
||||||
class ContextFetchTests(TestCase):
|
class ContextFetchTests(TestCase):
|
||||||
"""Tests for the bundled context fetch endpoint (execute_context)."""
|
"""Tests for the bundled context fetch endpoint (execute_context)."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user