Edge manifest: static JSON for CDN cache invalidation routing
generate_edge_manifest() compiles the decorator registry into a
static JSON artifact that Edge reads at deploy time:
{
"contexts": {
"user": {
"functions": [
{"name": "user_profile", "path": "rpc"},
{"name": "profile_page", "path": "view"}
],
"endpoints": ["/api/mizan/ctx/user/"],
"params": ["user_id"],
"views": ["/profile/:user_id/"]
}
}
}
When Edge receives X-Mizan-Invalidate: user;user_id=5, it:
1. Looks up "user" in the manifest
2. Resolves URL patterns: /profile/:user_id/ → /profile/5/
3. Purges /profile/5/ and /api/mizan/ctx/user/?user_id=5
Features:
- Distinguishes view-path vs RPC-path functions
- Accepts optional view_urls mapping from developer
- Custom base URL support
- Deterministic JSON output (sorted keys)
- Management command: python manage.py export_edge_manifest
314 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,13 @@ if TYPE_CHECKING:
|
|||||||
from mizan.setup.registry import get_registry, get_schema, get_context_groups, get_function
|
from mizan.setup.registry import get_registry, get_schema, get_context_groups, get_function
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["get_schema", "generate_openapi_schema", "generate_openapi_json"]
|
__all__ = [
|
||||||
|
"get_schema",
|
||||||
|
"generate_openapi_schema",
|
||||||
|
"generate_openapi_json",
|
||||||
|
"generate_edge_manifest",
|
||||||
|
"generate_edge_manifest_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _extract_form_fields(form_class: type) -> list[dict[str, Any]]:
|
def _extract_form_fields(form_class: type) -> list[dict[str, Any]]:
|
||||||
@@ -350,3 +356,87 @@ def generate_openapi_json(indent: int = 2) -> str:
|
|||||||
"""Generate OpenAPI schema as formatted JSON string."""
|
"""Generate OpenAPI schema as formatted JSON string."""
|
||||||
schema = generate_openapi_schema()
|
schema = generate_openapi_schema()
|
||||||
return json.dumps(schema, indent=indent)
|
return json.dumps(schema, indent=indent)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest(
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate the Edge manifest — a static JSON mapping contexts to URL
|
||||||
|
patterns and params for CDN cache purging.
|
||||||
|
|
||||||
|
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
||||||
|
receives X-Mizan-Invalidate: user;user_id=5, it:
|
||||||
|
1. Looks up 'user' in the manifest
|
||||||
|
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
||||||
|
3. Purges the resolved URLs + the context API endpoint
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: The Mizan API mount point (default: /api/mizan)
|
||||||
|
view_urls: Optional mapping of context names to URL patterns for
|
||||||
|
view-path functions. These are URLs that Edge should
|
||||||
|
also purge when a context is invalidated.
|
||||||
|
Example: {"user": ["/profile/:user_id/"]}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Manifest dict suitable for JSON serialization.
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel as PydanticBaseModel
|
||||||
|
|
||||||
|
groups = get_context_groups()
|
||||||
|
registry = get_registry()
|
||||||
|
all_functions = registry.get("functions", {})
|
||||||
|
|
||||||
|
manifest: dict[str, Any] = {"contexts": {}}
|
||||||
|
|
||||||
|
for ctx_name, fn_names in groups.items():
|
||||||
|
# Collect params from all functions in this context
|
||||||
|
param_names: set[str] = set()
|
||||||
|
functions_meta: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = all_functions.get(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
is_view = meta.get("view_path", False)
|
||||||
|
|
||||||
|
# Collect param names from Input schema
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if (
|
||||||
|
input_cls
|
||||||
|
and input_cls is not PydanticBaseModel
|
||||||
|
and hasattr(input_cls, "model_fields")
|
||||||
|
):
|
||||||
|
param_names.update(input_cls.model_fields.keys())
|
||||||
|
|
||||||
|
functions_meta.append({
|
||||||
|
"name": fn_name,
|
||||||
|
"path": "view" if is_view else "rpc",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx_entry: dict[str, Any] = {
|
||||||
|
"functions": functions_meta,
|
||||||
|
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||||
|
"params": sorted(param_names),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add view URLs if provided
|
||||||
|
if view_urls and ctx_name in view_urls:
|
||||||
|
ctx_entry["views"] = view_urls[ctx_name]
|
||||||
|
|
||||||
|
manifest["contexts"][ctx_name] = ctx_entry
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest_json(
|
||||||
|
indent: int = 2,
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Generate Edge manifest as formatted JSON string."""
|
||||||
|
manifest = generate_edge_manifest(base_url=base_url, view_urls=view_urls)
|
||||||
|
return json.dumps(manifest, indent=indent, sort_keys=True)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Export Edge Manifest
|
||||||
|
|
||||||
|
Generates the static JSON manifest that Mizan Edge reads at deploy time
|
||||||
|
to configure CDN cache rules and invalidation routing.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py export_edge_manifest
|
||||||
|
python manage.py export_edge_manifest --output mizan-manifest.json
|
||||||
|
python manage.py export_edge_manifest --base-url /api/mizan
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Export Edge manifest for CDN cache invalidation"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Output file path. If not specified, outputs to stdout.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--indent",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="JSON indentation level (0 for compact output)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-url",
|
||||||
|
type=str,
|
||||||
|
default="/api/mizan",
|
||||||
|
help="Mizan API mount point (default: /api/mizan)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
manifest = generate_edge_manifest(base_url=options["base_url"])
|
||||||
|
indent = options["indent"] if options["indent"] > 0 else None
|
||||||
|
json_output = json.dumps(manifest, indent=indent, sort_keys=True)
|
||||||
|
|
||||||
|
if options["output"]:
|
||||||
|
output_path = Path(options["output"])
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(json_output)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Manifest written to {output_path}"))
|
||||||
|
else:
|
||||||
|
self.stdout.write(json_output)
|
||||||
@@ -2450,3 +2450,157 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
self.assertNotIn("invalidate", data)
|
self.assertNotIn("invalidate", data)
|
||||||
self.assertNotIn("X-Mizan-Invalidate", response)
|
self.assertNotIn("X-Mizan-Invalidate", response)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Edge Manifest Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class EdgeManifestTests(TestCase):
|
||||||
|
"""Tests for the Edge manifest generator."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
def test_basic_manifest(self):
|
||||||
|
"""Manifest lists contexts with their functions, endpoints, and params."""
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_orders(request: HttpRequest, user_id: int, page: int = 1) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
register(user_orders, "user_orders")
|
||||||
|
|
||||||
|
manifest = generate_edge_manifest()
|
||||||
|
|
||||||
|
self.assertIn("contexts", manifest)
|
||||||
|
self.assertIn("user", manifest["contexts"])
|
||||||
|
|
||||||
|
user_ctx = manifest["contexts"]["user"]
|
||||||
|
self.assertEqual(user_ctx["endpoints"], ["/api/mizan/ctx/user/"])
|
||||||
|
self.assertIn("user_id", user_ctx["params"])
|
||||||
|
self.assertIn("page", user_ctx["params"])
|
||||||
|
self.assertEqual(len(user_ctx["functions"]), 2)
|
||||||
|
|
||||||
|
def test_manifest_distinguishes_view_and_rpc(self):
|
||||||
|
"""Manifest marks functions as view or rpc path."""
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def profile_page(request: HttpRequest, user_id: int) -> HttpResponse:
|
||||||
|
return HttpResponse("<html>profile</html>")
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
register(profile_page, "profile_page")
|
||||||
|
|
||||||
|
manifest = generate_edge_manifest()
|
||||||
|
functions = manifest["contexts"]["user"]["functions"]
|
||||||
|
|
||||||
|
rpc_fn = next(f for f in functions if f["name"] == "user_profile")
|
||||||
|
view_fn = next(f for f in functions if f["name"] == "profile_page")
|
||||||
|
|
||||||
|
self.assertEqual(rpc_fn["path"], "rpc")
|
||||||
|
self.assertEqual(view_fn["path"], "view")
|
||||||
|
|
||||||
|
def test_manifest_with_view_urls(self):
|
||||||
|
"""Manifest includes view URLs when provided."""
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
|
||||||
|
manifest = generate_edge_manifest(
|
||||||
|
view_urls={"user": ["/profile/:user_id/", "/u/:user_id/"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
user_ctx = manifest["contexts"]["user"]
|
||||||
|
self.assertEqual(user_ctx["views"], ["/profile/:user_id/", "/u/:user_id/"])
|
||||||
|
|
||||||
|
def test_manifest_custom_base_url(self):
|
||||||
|
"""Manifest respects custom base URL."""
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
@client(context=ReactContext("data"))
|
||||||
|
def some_data(request: HttpRequest) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(some_data, "some_data")
|
||||||
|
|
||||||
|
manifest = generate_edge_manifest(base_url="/v2/api")
|
||||||
|
self.assertEqual(
|
||||||
|
manifest["contexts"]["data"]["endpoints"],
|
||||||
|
["/v2/api/ctx/data/"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manifest_multiple_contexts(self):
|
||||||
|
"""Manifest handles multiple contexts correctly."""
|
||||||
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
UserCtx = ReactContext("user")
|
||||||
|
NotifCtx = ReactContext("notifications")
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
@client(context=NotifCtx)
|
||||||
|
def notifications(request: HttpRequest) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
register(notifications, "notifications")
|
||||||
|
|
||||||
|
manifest = generate_edge_manifest()
|
||||||
|
|
||||||
|
self.assertIn("user", manifest["contexts"])
|
||||||
|
self.assertIn("notifications", manifest["contexts"])
|
||||||
|
self.assertEqual(manifest["contexts"]["user"]["params"], ["user_id"])
|
||||||
|
self.assertEqual(manifest["contexts"]["notifications"]["params"], [])
|
||||||
|
|
||||||
|
def test_manifest_json_deterministic(self):
|
||||||
|
"""Manifest JSON output is deterministic (sorted keys)."""
|
||||||
|
from mizan.export import generate_edge_manifest_json
|
||||||
|
|
||||||
|
@client(context=ReactContext("b_ctx"))
|
||||||
|
def b_func(request: HttpRequest) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
@client(context=ReactContext("a_ctx"))
|
||||||
|
def a_func(request: HttpRequest) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(b_func, "b_func")
|
||||||
|
register(a_func, "a_func")
|
||||||
|
|
||||||
|
json1 = generate_edge_manifest_json()
|
||||||
|
json2 = generate_edge_manifest_json()
|
||||||
|
self.assertEqual(json1, json2)
|
||||||
|
|
||||||
|
# Keys should be sorted
|
||||||
|
parsed = json.loads(json1)
|
||||||
|
context_keys = list(parsed["contexts"].keys())
|
||||||
|
self.assertEqual(context_keys, sorted(context_keys))
|
||||||
|
|||||||
Reference in New Issue
Block a user