Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions bindings/csharp/FusionFramework/FusionBaseTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,36 @@ public abstract class FusionBaseTemplate : FusionBaseApi
public static string TemplateAddress { get; set; } = "";
public static string TemplatesDir { get; set; } = "";

/// <summary>Sync template variables (override in subclasses).</summary>
public virtual Dictionary<string, JsonNode?> Context() => new();

/// <summary>Async template variables; default wraps <see cref="Context"/>.</summary>
public virtual Task<Dictionary<string, JsonNode?>> ContextAsync() =>
Task.FromResult(Context());

/// <summary>
/// Default GET — HTML or JSON context. Uses <see cref="ContextAsync"/> so
/// subclasses can override that for DB/API-backed pages (Python async context parity).
/// </summary>
public virtual object Get()
{
var task = ContextAsync();
if (task.IsCompletedSuccessfully)
return FinishGet(task.Result);
return FinishGetAsync(task);
}

async Task<object> FinishGetAsync(Task<Dictionary<string, JsonNode?>> task)
{
var ctx = await task.ConfigureAwait(false);
return FinishGet(ctx);
}

object FinishGet(Dictionary<string, JsonNode?> ctx)
{
if (WantsJson())
return Context();
return Render();
return ctx;
return HtmlResponse(ctx);
}

public virtual string TemplateName()
Expand All @@ -41,12 +64,42 @@ public virtual object Render(
IDictionary<string, JsonNode?>? context = null,
string? templateName = null)
{
var ctx = new Dictionary<string, JsonNode?>(Context(), StringComparer.Ordinal);
var task = ContextAsync();
if (!task.IsCompletedSuccessfully)
return RenderAsync(task, status, headers, context, templateName);

var ctx = new Dictionary<string, JsonNode?>(task.Result, StringComparer.Ordinal);
if (context != null)
{
foreach (var kv in context)
ctx[kv.Key] = kv.Value;
}
return HtmlResponse(ctx, status, headers, templateName);
}

async Task<object> RenderAsync(
Task<Dictionary<string, JsonNode?>> task,
int status,
IDictionary<string, string>? headers,
IDictionary<string, JsonNode?>? context,
string? templateName)
{
var ctx = new Dictionary<string, JsonNode?>(await task.ConfigureAwait(false), StringComparer.Ordinal);
if (context != null)
{
foreach (var kv in context)
ctx[kv.Key] = kv.Value;
}
return HtmlResponse(ctx, status, headers, templateName);
}

/// <summary>Render an already-resolved context dictionary to an HTML envelope.</summary>
protected object HtmlResponse(
IDictionary<string, JsonNode?> ctx,
int status = 200,
IDictionary<string, string>? headers = null,
string? templateName = null)
{
var html = Templates.Render(
templateName ?? TemplateName(),
ctx,
Expand Down
7 changes: 4 additions & 3 deletions crates/fusion-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,17 @@ export class FusionBaseTemplate extends FusionBaseApi {
static template: string
static templateAddress: string
static templatesDir: string
context(): Record<string, unknown>
get(): FusionResponse
/** Sync object or Promise (async context). */
context(): Record<string, unknown> | Promise<Record<string, unknown>>
get(): FusionResponse | Promise<FusionResponse>
templateName(): string
templatesRoot(): string
render(options?: {
status?: number
headers?: Record<string, string>
context?: Record<string, unknown>
templateName?: string
}): FusionResponse
}): FusionResponse | Promise<FusionResponse>
}

export function renderTemplate(
Expand Down
35 changes: 32 additions & 3 deletions crates/fusion-node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -372,13 +372,28 @@ class FusionBaseTemplate extends FusionBaseApi {
static templateAddress = ''
static templatesDir = ''

/** Template variables; may return a Promise (async context). */
context() {
return {}
}

get() {
if (this.wantsJson()) return this.context()
return this.render()
const raw = this.context()
if (raw && typeof raw.then === 'function') {
return this._getAsync(raw)
}
return this._finishGet(raw)
}

async _getAsync(raw) {
const ctx = await raw
return this._finishGet(ctx)
}

_finishGet(ctx) {
const data = { ...(ctx || {}) }
if (this.wantsJson()) return data
return this._htmlResponse(data)
}

templateName() {
Expand All @@ -400,7 +415,21 @@ class FusionBaseTemplate extends FusionBaseApi {
context = null,
templateName = null,
} = {}) {
const ctx = { ...this.context(), ...(context || {}) }
const raw = this.context()
if (raw && typeof raw.then === 'function') {
return this._renderAsync(raw, { status, headers, context, templateName })
}
const ctx = { ...(raw || {}), ...(context || {}) }
return this._htmlResponse(ctx, { status, headers, templateName })
}

async _renderAsync(raw, { status = 200, headers = {}, context = null, templateName = null } = {}) {
const base = await raw
const ctx = { ...(base || {}), ...(context || {}) }
return this._htmlResponse(ctx, { status, headers, templateName })
}

_htmlResponse(ctx, { status = 200, headers = {}, templateName = null } = {}) {
const html = renderTemplate(
templateName || this.templateName(),
ctx,
Expand Down
95 changes: 83 additions & 12 deletions crates/fusion-py/python/fusion_framework/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

from __future__ import annotations

import inspect
from pathlib import Path
from typing import Any, ClassVar, Mapping, Optional
from typing import Any, ClassVar, Mapping, Optional, Union

from fusion_framework._fusion import render_template as _render_template
from fusion_framework.api import FusionBaseApi
Expand All @@ -25,9 +26,9 @@ class FusionBaseTemplate(FusionBaseApi):
"""Class-based HTML handler using Tera templates.

Set ``template`` (or ``template_address``) to the file path under the templates
directory. Override ``context()`` to pass variables. The default ``get()`` renders
HTML for browsers and returns ``context()`` as JSON when the client sends
``Accept: application/json`` or ``?format=json``.
directory. Override ``context()`` to pass variables — sync or ``async def``.
The default ``get()`` renders 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.

Expand All @@ -42,15 +43,32 @@ class FusionBaseTemplate(FusionBaseApi):
template_address: ClassVar[str] = ""
templates_dir: ClassVar[str] = ""

def context(self) -> dict[str, Any]:
"""Template variables (override in subclasses)."""
def context(self) -> Union[dict[str, Any], Any]:
"""Template variables (override in subclasses; may be ``async def``)."""
return {}

def get(self) -> dict[str, Any]:
"""Default GET — HTML page, or ``context()`` JSON when client wants JSON."""
def get(self) -> Any:
"""Default GET — HTML page, or ``context()`` JSON when client wants JSON.

Supports sync or async ``context()``; async returns an awaitable for the
framework event loop.
"""
raw = self.context()
if inspect.isawaitable(raw):
return self._get_async(raw)
return self._finish_get(raw)

async def _get_async(self, raw: Any) -> Any:
"""Await async ``context()`` then finish the GET response."""
ctx = await raw
return self._finish_get(ctx)

def _finish_get(self, ctx: Any) -> dict[str, Any]:
"""Build JSON or HTML from an already-resolved context mapping."""
data = dict(ctx or {})
if self.wants_json():
return self.context()
return self.render()
return data
return self._html_response(data)

def template_name(self) -> str:
"""Resolved template path (override for dynamic templates)."""
Expand All @@ -75,11 +93,64 @@ def render(
context: Mapping[str, Any] | None = None,
template_name: str | None = None,
**extra: str,
) -> Any:
"""Render template and return an HTML response envelope.

If ``context()`` is async and ``context=`` is omitted, returns an awaitable.
"""
raw = self.context()
if inspect.isawaitable(raw):
return self._render_async(
raw,
status=status,
headers=headers,
context=context,
template_name=template_name,
**extra,
)
ctx = dict(raw or {})
if context:
ctx.update(context)
return self._html_response(
ctx,
status=status,
headers=headers,
template_name=template_name,
**extra,
)

async def _render_async(
self,
raw: Any,
*,
status: int = 200,
headers: Mapping[str, str] | None = None,
context: Mapping[str, Any] | None = None,
template_name: str | None = None,
**extra: str,
) -> dict[str, Any]:
"""Render template and return an HTML response envelope."""
ctx = dict(self.context())
"""Await async ``context()`` then render HTML."""
ctx = dict(await raw)
if context:
ctx.update(context)
return self._html_response(
ctx,
status=status,
headers=headers,
template_name=template_name,
**extra,
)

def _html_response(
self,
ctx: Mapping[str, Any],
status: int = 200,
headers: Mapping[str, str] | None = None,
*,
template_name: str | None = None,
**extra: str,
) -> dict[str, Any]:
"""Render ``ctx`` into an HTML response envelope."""
html = render_template(
template_name or self.template_name(),
ctx,
Expand Down
41 changes: 41 additions & 0 deletions tests/python/unit/test_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import asyncio
import inspect
from pathlib import Path

import pytest
Expand Down Expand Up @@ -78,6 +80,45 @@ def context(self):
assert page.get() == {"name": "Fusion"}


def test_async_context_renders_html(tmp_path: Path):
"""async def context() is awaited by get()/render()."""

class Page(FusionBaseTemplate):
template = "hello.html"

async def context(self):
return {"name": "AsyncFusion"}

(tmp_path / "hello.html").write_text("<p>Hello {{ name }}!</p>", encoding="utf-8")
page = Page({"method": "GET", "path": "/"})
page.templates_dir = str(tmp_path)

out = page.get()
assert inspect.isawaitable(out)
resolved = asyncio.run(out)
assert resolved["status"] == 200
assert "Hello AsyncFusion!" in resolved["body"]


def test_async_context_json_accept():
class Page(FusionBaseTemplate):
template = "hello.html"

async def context(self):
return {"title": "from-db"}

page = Page(
{
"method": "GET",
"path": "/",
"headers": {"accept": "application/json"},
}
)
out = page.get()
assert inspect.isawaitable(out)
assert asyncio.run(out) == {"title": "from-db"}


def test_template_get_returns_html_for_browser_accept(tmp_path: Path):
class Page(FusionBaseTemplate):
template = "hello.html"
Expand Down
Loading