diff --git a/bindings/csharp/FusionFramework/Swagger.cs b/bindings/csharp/FusionFramework/Swagger.cs index 51f9bd4..88f6e95 100644 --- a/bindings/csharp/FusionFramework/Swagger.cs +++ b/bindings/csharp/FusionFramework/Swagger.cs @@ -224,6 +224,7 @@ static void FillPaths(JsonObject paths, string? versionFilter) foreach (var entry in Route.Snapshot()) { if (!MatchesVersion(entry.Version, versionFilter)) continue; + if (typeof(FusionBaseTemplate).IsAssignableFrom(entry.ApiClass)) continue; foreach (var slot in entry.Slots) { diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 035bf99..1a08ec8 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -367,6 +367,7 @@ function prefersJsonFallback(accept, formatQuery) { } class FusionBaseTemplate extends FusionBaseApi { + static __fusion_template__ = true static template = '' static templateAddress = '' static templatesDir = '' @@ -969,6 +970,15 @@ function applySwaggerOpenApi(openapi, swagger) { return openapi } +function isTemplateClass(ApiClass) { + let current = ApiClass + while (current && current !== Function.prototype) { + if (current === FusionBaseTemplate || current.__fusion_template__) return true + current = Object.getPrototypeOf(current) + } + return false +} + function fillOpenApiPaths(openapi, versionFilter = null) { const parsePathParams = (pattern) => { return String(pattern) @@ -980,6 +990,7 @@ function fillOpenApiPaths(openapi, versionFilter = null) { for (const item of registry) { if (!routeMatchesVersion(item, versionFilter)) continue const { ApiClass, swagger: routeSwagger } = item + if (isTemplateClass(ApiClass)) continue const slots = item.slots || [] for (const slot of slots) { diff --git a/crates/fusion-py/python/fusion_framework/template.py b/crates/fusion-py/python/fusion_framework/template.py index db3da0f..27d0636 100644 --- a/crates/fusion-py/python/fusion_framework/template.py +++ b/crates/fusion-py/python/fusion_framework/template.py @@ -29,11 +29,15 @@ class FusionBaseTemplate(FusionBaseApi): HTML for browsers and returns ``context()`` as JSON when the client sends ``Accept: application/json`` or ``?format=json``. + Template routes are mounted as HTTP handlers but are excluded from Swagger/OpenAPI. + Built-in UI components are defined in ``fusion/macros.html`` (Tera 2 components):: {{}} """ + __fusion_template__ = True + template: ClassVar[str] = "" template_address: ClassVar[str] = "" templates_dir: ClassVar[str] = "" diff --git a/crates/fusion-py/python/fusion_framework/test_swagger.py b/crates/fusion-py/python/fusion_framework/test_swagger.py index 5a73b92..b9d267f 100644 --- a/crates/fusion-py/python/fusion_framework/test_swagger.py +++ b/crates/fusion-py/python/fusion_framework/test_swagger.py @@ -60,6 +60,30 @@ def get(self): assert "/health" in combined["paths"] +def test_template_routes_excluded_from_openapi(): + from fusion_framework.template import FusionBaseTemplate + + @route("/pages/home") + class HomePage(FusionBaseTemplate): + template = "home/index.html" + + def context(self): + return {"title": "Home"} + + @route("/api/items", version="v1", tags=["items"]) + class ItemsApi(FusionBaseApi): + def get(self): + return {"items": []} + + spec = openapi_spec() + assert "/pages/home" not in spec["paths"] + assert "/api/items" not in spec["paths"] # versioned + + v1 = openapi_spec("v1") + assert "/pages/home" not in v1["paths"] + assert "/v1/api/items" in v1["paths"] + + def test_swagger_asset_urls(): from fusion_framework.app import _swagger_asset_url, _SWAGGER_ASSETS diff --git a/crates/fusion-py/src/api_types.rs b/crates/fusion-py/src/api_types.rs index c95cb10..8c9febb 100644 --- a/crates/fusion-py/src/api_types.rs +++ b/crates/fusion-py/src/api_types.rs @@ -861,6 +861,30 @@ fn annotation_kind(py: Python<'_>, annotation: &Bound<'_, PyAny>) -> PyResult, api_cls: &Bound<'_, PyType>) -> bool { + if api_cls + .getattr("__fusion_template__") + .ok() + .and_then(|v| v.extract::().ok()) + .unwrap_or(false) + { + return true; + } + let Ok(template_mod) = py.import("fusion_framework.template") else { + return false; + }; + let Ok(base) = template_mod.getattr("FusionBaseTemplate") else { + return false; + }; + let Ok(issubclass) = py.import("builtins").and_then(|m| m.getattr("issubclass")) else { + return false; + }; + issubclass + .call1((api_cls, base)) + .and_then(|v| v.extract()) + .unwrap_or(false) +} + pub fn openapi_spec() -> serde_json::Value { openapi_spec_for(None) } @@ -887,6 +911,12 @@ pub fn openapi_spec_for(version: Option<&str>) -> serde_json::Value { continue; } + // HTML template pages are not REST API operations — omit from Swagger. + let is_template = Python::with_gil(|py| is_template_class(py, &r.api_cls.bind(py))); + if is_template { + continue; + } + for slot in &r.slots { let resolved_path = if slot.path.starts_with('/') { slot.path.clone()