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()