Skip to main content

rust_web_server/template/
mod.rs

1//! Tera HTML template engine integration.
2//!
3//! Requires `features = ["tera"]`.
4//!
5//! # Quick start
6//!
7//! 1. Put templates in `templates/` (e.g. `templates/index.html`).
8//! 2. Call [`init`] (or [`init_from_env`]) once at startup.
9//! 3. Call [`render`] from any handler.
10//!
11//! ```rust,no_run
12//! use rust_web_server::template::{self, Context};
13//!
14//! // At startup:
15//! template::init("templates").unwrap();
16//!
17//! // In a handler:
18//! let mut ctx = Context::new();
19//! ctx.insert("title", "Home");
20//! ctx.insert("items", &["Rust", "rws", "Tera"]);
21//! let response = template::render("index.html", &ctx).unwrap();
22//! ```
23//!
24//! Templates use [Jinja2 syntax](https://keats.github.io/tera/docs/#templates):
25//! `{{ variable }}`, `{% if cond %}`, `{% for item in list %}`, `{% extends "base.html" %}`.
26//!
27//! # Hot reload
28//!
29//! Edit a template file and it's re-read from disk on the next [`reload`] —
30//! no server restart, unlike a code change. [`reload`] is wired into the same
31//! `SIGHUP` hook (`crate::config_reload::reload`) used for CORS, rate limits,
32//! and TLS certs, so `kill -HUP $(pidof rws)` — or your own trigger calling
33//! `config_reload::reload()` — picks up edited templates too:
34//!
35//! ```rust,no_run
36//! use rust_web_server::template;
37//!
38//! template::init("templates").unwrap();
39//! // ... edit templates/index.html on disk ...
40//! template::reload().unwrap(); // re-globs the directory, replacing all templates
41//! ```
42//!
43//! Only engines created from a glob ([`init`], [`init_from_env`],
44//! [`TeraEngine::from_dir`], [`TeraEngine::from_glob`]) can reload — an
45//! engine built from [`TeraEngine::from_raw`] has no directory to re-read,
46//! so [`TeraEngine::reload`] returns `Err` for it.
47
48#[cfg(test)]
49mod tests;
50
51use std::sync::{OnceLock, RwLock};
52
53pub use tera::Context;
54pub use tera::Value;
55
56use crate::core::New;
57use crate::mime_type::MimeType;
58use crate::range::Range;
59use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
60
61static ENGINE: OnceLock<RwLock<TeraEngine>> = OnceLock::new();
62
63/// A Tera template engine instance.
64#[derive(Debug)]
65pub struct TeraEngine {
66    inner: tera::Tera,
67    /// The glob this engine was built from, remembered so [`reload`][Self::reload]
68    /// can re-read it. `None` for [`from_raw`][Self::from_raw] engines.
69    glob: Option<String>,
70}
71
72impl TeraEngine {
73    /// Load all templates matching `glob_pattern`, e.g. `"templates/**/*"`.
74    ///
75    /// The pattern is passed directly to [`tera::Tera::new`].
76    pub fn from_glob(pattern: &str) -> Result<Self, String> {
77        let tera = tera::Tera::new(pattern)
78            .map_err(|e| format!("template engine init failed: {}", e))?;
79        Ok(TeraEngine { inner: tera, glob: Some(pattern.to_string()) })
80    }
81
82    /// Load all templates from `dir`. Equivalent to `from_glob("dir/**/*")`.
83    pub fn from_dir(dir: &str) -> Result<Self, String> {
84        let pattern = format!("{}/**/*", dir.trim_end_matches('/'));
85        Self::from_glob(&pattern)
86    }
87
88    /// Build an engine from in-memory template strings — useful for testing.
89    ///
90    /// `templates` is a slice of `(name, content)` pairs. Has no glob, so
91    /// [`reload`][Self::reload] always returns `Err` for an engine built this way.
92    pub fn from_raw(templates: &[(&str, &str)]) -> Result<Self, String> {
93        let mut tera = tera::Tera::default();
94        for (name, content) in templates {
95            tera.add_raw_template(name, content)
96                .map_err(|e| format!("failed to add template '{}': {}", name, e))?;
97        }
98        Ok(TeraEngine { inner: tera, glob: None })
99    }
100
101    /// Render `template_name` with `ctx` and return the output as a `String`.
102    pub fn render(&self, template_name: &str, ctx: &Context) -> Result<String, String> {
103        self.inner
104            .render(template_name, ctx)
105            .map_err(|e| format!("render '{}' failed: {}", template_name, e))
106    }
107
108    /// Render `template_name` and wrap the output in a `200 OK` HTML [`Response`].
109    pub fn response(&self, template_name: &str, ctx: &Context) -> Result<Response, String> {
110        let html = self.render(template_name, ctx)?;
111        Ok(html_response(html))
112    }
113
114    /// Re-read every template from the glob this engine was created with,
115    /// replacing all currently-loaded templates.
116    ///
117    /// Picks up edited content in existing files *and* newly added or removed
118    /// files matching the original glob — it's a full re-glob, not a diff.
119    ///
120    /// Builds the replacement set of templates in full before swapping it in,
121    /// rather than mutating the live template set in place (which is what
122    /// [`tera::Tera::full_reload`] does, and why this doesn't just call it) —
123    /// so a syntax error in one edited file fails the whole reload atomically
124    /// and this engine keeps serving its last-known-good templates, instead
125    /// of serving a half-reloaded, partially-broken set. Returns `Err` if:
126    /// - this engine was built with [`TeraEngine::from_raw`] (no glob to
127    ///   re-read), or
128    /// - any template now fails to parse (e.g. a syntax error mid-edit) or
129    ///   an `{% extends %}`/`{% import %}` chain no longer resolves.
130    pub fn reload(&mut self) -> Result<(), String> {
131        let pattern = self.glob.as_ref().ok_or_else(|| {
132            "cannot reload: this engine was built with TeraEngine::from_raw, \
133             which has no template directory to re-read"
134                .to_string()
135        })?;
136        let fresh = tera::Tera::new(pattern)
137            .map_err(|e| format!("template reload failed, previous templates are still active: {}", e))?;
138        self.inner = fresh;
139        Ok(())
140    }
141}
142
143// ── Global singleton ──────────────────────────────────────────────────────────
144
145/// Initialize the global template engine from a directory on disk.
146///
147/// Call once at startup before any handlers run. Subsequent calls return an `Err`.
148pub fn init(dir: &str) -> Result<(), String> {
149    let engine = TeraEngine::from_dir(dir)?;
150    ENGINE
151        .set(RwLock::new(engine))
152        .map_err(|_| "template engine already initialized".to_string())?;
153    // SIGHUP / `POST /admin/config/reload` re-glob the templates.
154    crate::config_reload::on_reload(reload_if_initialized);
155    Ok(())
156}
157
158/// Initialize the global engine from `RWS_CONFIG_TEMPLATE_DIR` (default: `"templates"`).
159pub fn init_from_env() -> Result<(), String> {
160    let dir = std::env::var(crate::entry_point::Config::RWS_CONFIG_TEMPLATE_DIR)
161        .unwrap_or_else(|_| {
162            crate::entry_point::Config::RWS_CONFIG_TEMPLATE_DIR_DEFAULT_VALUE.to_string()
163        });
164    init(&dir)
165}
166
167/// Acquire a read lock on the global template engine. Held only for the
168/// duration of one render — never store the guard across `.await` points or
169/// requests.
170///
171/// # Panics
172///
173/// Panics if [`init`] has not been called yet.
174pub fn global() -> std::sync::RwLockReadGuard<'static, TeraEngine> {
175    ENGINE
176        .get()
177        .expect("template engine not initialized — call template::init() at startup")
178        .read()
179        .unwrap_or_else(|e| e.into_inner())
180}
181
182/// Render `template_name` with `ctx` using the global engine and return a `200 OK` HTML response.
183pub fn render(template_name: &str, ctx: &Context) -> Result<Response, String> {
184    global().response(template_name, ctx)
185}
186
187/// Reload the global template engine's templates from disk.
188///
189/// See the [module-level hot reload section](self#hot-reload). Wired into
190/// [`crate::config_reload::reload`] automatically, so `SIGHUP` (or your own
191/// trigger calling `config_reload::reload()`) reloads templates too — you
192/// don't need to call this directly unless you want reload on a different
193/// trigger.
194///
195/// # Errors
196///
197/// Returns `Err` if [`init`] hasn't been called yet, or if [`TeraEngine::reload`]
198/// fails (in which case the previously-loaded templates are still active and
199/// still being served — see [`TeraEngine::reload`]'s docs on why a bad edit
200/// can't corrupt the live template set).
201pub fn reload() -> Result<(), String> {
202    let lock = ENGINE
203        .get()
204        .ok_or_else(|| "template engine not initialized — call template::init() at startup".to_string())?;
205    let mut guard = lock.write().unwrap_or_else(|e| e.into_inner());
206    guard.reload()
207}
208
209/// Reload the global template engine if [`init`] has been called; silently
210/// does nothing otherwise.
211///
212/// Used by [`crate::config_reload::reload`] to safely fold template hot
213/// reload into the same `SIGHUP` hook regardless of whether the calling
214/// binary uses templates at all — most `tera`-feature-enabled binaries that
215/// never called `template::init` shouldn't see a reload error every time
216/// something else triggers a config reload.
217pub(crate) fn reload_if_initialized() {
218    if let Some(lock) = ENGINE.get() {
219        let mut guard = lock.write().unwrap_or_else(|e| e.into_inner());
220        if let Err(e) = guard.reload() {
221            eprintln!("[template] hot reload failed: {}", e);
222        }
223    }
224}
225
226// ── helpers ───────────────────────────────────────────────────────────────────
227
228fn html_response(html: String) -> Response {
229    let mut r = Response::new();
230    r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
231    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
232    r.content_range_list = vec![Range::get_content_range(
233        html.into_bytes(),
234        MimeType::TEXT_HTML.to_string(),
235    )];
236    r
237}