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}