Rocket

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 491b04cf5abed3d23bb9cefa7d94a4777cd90288
parent 83cead775f684950ac92f4e14f4f3a15e253fed5
Author: jeb <jeb@jebrosen.com>
Date:   Wed, 10 Jan 2018 12:27:51 -0700

Implement template auto-reload.

Resolves #163.

Diffstat:
Mcontrib/lib/Cargo.toml | 3+++
Mcontrib/lib/src/templates/context.rs | 12+++++++-----
Acontrib/lib/src/templates/fairing.rs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontrib/lib/src/templates/metadata.rs | 10+++++-----
Mcontrib/lib/src/templates/mod.rs | 44+++++++++++++++++++-------------------------
Mcontrib/lib/tests/templates.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontrib/lib/tests/templates/hbs/reload.txt.hbs | 2++
Msite/guide/responses.md | 5+++++
8 files changed, 265 insertions(+), 35 deletions(-)

diff --git a/contrib/lib/Cargo.toml b/contrib/lib/Cargo.toml @@ -40,5 +40,8 @@ tera = { version = "0.11", optional = true } [dev-dependencies] rocket_codegen = { version = "0.4.0-dev", path = "../../core/codegen" } +[target.'cfg(debug_assertions)'.dependencies] +notify = { version = "^4.0" } + [package.metadata.docs.rs] all-features = true diff --git a/contrib/lib/src/templates/context.rs b/contrib/lib/src/templates/context.rs @@ -11,11 +11,14 @@ pub struct Context { pub root: PathBuf, /// Mapping from template name to its information. pub templates: HashMap<String, TemplateInfo>, - /// Mapping from template name to its information. - pub engines: Engines + /// Loaded template engines + pub engines: Engines, } impl Context { + /// Load all of the templates at `root`, initialize them using the relevant + /// template engine, and store all of the initialized state in a `Context` + /// structure, which is returned if all goes well. pub fn initialize(root: PathBuf) -> Option<Context> { let mut templates: HashMap<String, TemplateInfo> = HashMap::new(); for ext in Engines::ENABLED_EXTENSIONS { @@ -45,9 +48,8 @@ impl Context { } } - Engines::init(&templates).map(|engines| { - Context { root, templates, engines } - }) + Engines::init(&templates) + .map(|engines| Context { root, templates, engines } ) } } diff --git a/contrib/lib/src/templates/fairing.rs b/contrib/lib/src/templates/fairing.rs @@ -0,0 +1,168 @@ +use super::DEFAULT_TEMPLATE_DIR; +use super::context::Context; +use super::engine::Engines; + +use rocket::Rocket; +use rocket::config::ConfigError; +use rocket::fairing::{Fairing, Info, Kind}; + +pub use self::context::ContextManager; + +#[cfg(not(debug_assertions))] +mod context { + use std::ops::Deref; + use super::Context; + + /// Wraps a Context. With `cfg(debug_assertions)` active, this structure + /// additionally provides a method to reload the context at runtime. + pub struct ContextManager(Context); + + impl ContextManager { + pub fn new(ctxt: Context) -> ContextManager { + ContextManager(ctxt) + } + + pub fn context<'a>(&'a self) -> impl Deref<Target=Context> + 'a { + &self.0 + } + } +} + +#[cfg(debug_assertions)] +mod context { + extern crate notify; + + use std::ops::{Deref, DerefMut}; + use std::sync::{RwLock, Mutex}; + use std::sync::mpsc::{channel, Receiver}; + + use super::{Context, Engines}; + + use self::notify::{raw_watcher, RawEvent, RecommendedWatcher, RecursiveMode, Watcher}; + + /// Wraps a Context. With `cfg(debug_assertions)` active, this structure + /// additionally provides a method to reload the context at runtime. + pub struct ContextManager { + /// The current template context, inside an RwLock so it can be updated. + context: RwLock<Context>, + /// A filesystem watcher and the receive queue for its events. + watcher: Option<(RecommendedWatcher, Mutex<Receiver<RawEvent>>)>, + } + + impl ContextManager { + pub fn new(ctxt: Context) -> ContextManager { + let (tx, rx) = channel(); + + let watcher = if let Ok(mut watcher) = raw_watcher(tx) { + if watcher.watch(ctxt.root.clone(), RecursiveMode::Recursive).is_ok() { + Some((watcher, Mutex::new(rx))) + } else { + warn!("Could not monitor the templates directory for changes."); + warn_!("Live template reload will be unavailable"); + None + } + } else { + warn!("Could not instantiate a filesystem watcher."); + warn_!("Live template reload will be unavailable"); + None + }; + + ContextManager { + watcher, + context: RwLock::new(ctxt), + } + } + + pub fn context<'a>(&'a self) -> impl Deref<Target=Context> + 'a { + self.context.read().unwrap() + } + + fn context_mut<'a>(&'a self) -> impl DerefMut<Target=Context> + 'a { + self.context.write().unwrap() + } + + /// Checks whether any template files have changed on disk. If there + /// have been changes since the last reload, all templates are + /// reinitialized from disk and the user's customization callback is run + /// again. + pub fn reload_if_needed<F: Fn(&mut Engines)>(&self, custom_callback: F) { + self.watcher.as_ref().map(|w| { + let rx = w.1.lock().expect("receive queue"); + let mut changed = false; + while let Ok(_) = rx.try_recv() { + changed = true; + } + + if changed { + info_!("Change detected: reloading templates."); + let mut ctxt = self.context_mut(); + if let Some(mut new_ctxt) = Context::initialize(ctxt.root.clone()) { + custom_callback(&mut new_ctxt.engines); + *ctxt = new_ctxt; + } else { + warn_!("An error occurred while reloading templates."); + warn_!("The previous templates will remain active."); + }; + } + }); + } + } +} + +/// The TemplateFairing initializes the template system on attach, running +/// custom_callback after templates have been loaded. In debug mode, the fairing +/// checks for modifications to templates before every request and reloads them +/// if necessary. +pub struct TemplateFairing { + /// The user-provided customization callback, allowing the use of + /// functionality specific to individual template engines. In debug mode, + /// this callback might be run multiple times as templates are reloaded. + pub(crate) custom_callback: Box<Fn(&mut Engines) + Send + Sync + 'static>, +} + +impl Fairing for TemplateFairing { + fn info(&self) -> Info { + // The on_request part of this fairing only applies in debug + // mode, so only register it in debug mode. + Info { + name: "Templates", + #[cfg(debug_assertions)] + kind: Kind::Attach | Kind::Request, + #[cfg(not(debug_assertions))] + kind: Kind::Attach, + } + } + + /// Initializes the template context. Templates will be searched for in the + /// `template_dir` config variable or the default ([DEFAULT_TEMPLATE_DIR]). + /// The user's callback, if any was supplied, is called to customize the + /// template engines. In debug mode, the `ContextManager::new` method + /// initializes a directory watcher for auto-reloading of templates. + fn on_attach(&self, rocket: Rocket) -> Result<Rocket, Rocket> { + let mut template_root = rocket.config().root_relative(DEFAULT_TEMPLATE_DIR); + match rocket.config().get_str("template_dir") { + Ok(dir) => template_root = rocket.config().root_relative(dir), + Err(ConfigError::NotFound) => { /* ignore missing configs */ } + Err(e) => { + e.pretty_print(); + warn_!("Using default templates directory '{:?}'", template_root); + } + }; + + match Context::initialize(template_root) { + Some(mut ctxt) => { + (self.custom_callback)(&mut ctxt.engines); + Ok(rocket.manage(ContextManager::new(ctxt))) + } + None => Err(rocket), + } + } + + #[cfg(debug_assertions)] + fn on_request(&self, req: &mut ::rocket::Request, _data: &::rocket::Data) { + let cm = req.guard::<::rocket::State<ContextManager>>() + .expect("Template ContextManager registered in on_attach"); + + cm.reload_if_needed(&*self.custom_callback); + } +} diff --git a/contrib/lib/src/templates/metadata.rs b/contrib/lib/src/templates/metadata.rs @@ -2,7 +2,7 @@ use rocket::{Request, State, Outcome}; use rocket::http::Status; use rocket::request::{self, FromRequest}; -use templates::Context; +use super::ContextManager; /// The `TemplateMetadata` type: implements `FromRequest`, allowing dynamic /// queries about template metadata. @@ -48,7 +48,7 @@ use templates::Context; /// } /// } /// ``` -pub struct TemplateMetadata<'a>(&'a Context); +pub struct TemplateMetadata<'a>(&'a ContextManager); impl<'a> TemplateMetadata<'a> { /// Returns `true` if the template with name `name` was loaded at start-up @@ -65,7 +65,7 @@ impl<'a> TemplateMetadata<'a> { /// } /// ``` pub fn contains_template(&self, name: &str) -> bool { - self.0.templates.contains_key(name) + self.0.context().templates.contains_key(name) } } @@ -76,9 +76,9 @@ impl<'a, 'r> FromRequest<'a, 'r> for TemplateMetadata<'a> { type Error = (); fn from_request(request: &'a Request) -> request::Outcome<Self, ()> { - request.guard::<State<Context>>() + request.guard::<State<ContextManager>>() .succeeded() - .and_then(|ctxt| Some(Outcome::Success(TemplateMetadata(ctxt.inner())))) + .and_then(|cm| Some(Outcome::Success(TemplateMetadata(cm.inner())))) .unwrap_or_else(|| { error_!("Uninitialized template context: missing fairing."); info_!("To use templates, you must attach `Template::fairing()`."); diff --git a/contrib/lib/src/templates/mod.rs b/contrib/lib/src/templates/mod.rs @@ -4,7 +4,9 @@ extern crate glob; #[cfg(feature = "tera_templates")] mod tera_templates; #[cfg(feature = "handlebars_templates")] mod handlebars_templates; + mod engine; +mod fairing; mod context; mod metadata; @@ -12,6 +14,7 @@ pub use self::engine::Engines; pub use self::metadata::TemplateMetadata; use self::engine::Engine; +use self::fairing::{TemplateFairing, ContextManager}; use self::context::Context; use self::serde::Serialize; use self::serde_json::{Value, to_value}; @@ -22,10 +25,9 @@ use std::path::PathBuf; use rocket::{Rocket, State}; use rocket::request::Request; -use rocket::fairing::{Fairing, AdHoc}; +use rocket::fairing::Fairing; use rocket::response::{self, Content, Responder}; use rocket::http::{ContentType, Status}; -use rocket::config::ConfigError; const DEFAULT_TEMPLATE_DIR: &'static str = "templates"; @@ -76,6 +78,11 @@ const DEFAULT_TEMPLATE_DIR: &'static str = "templates"; /// [Serde](https://github.com/serde-rs/json) and would serialize to an `Object` /// value. /// +/// In debug mode (without the `--release` flag passed to `cargo`), templates +/// will be automatically reloaded from disk if any changes have been made to +/// the templates directory since the previous request. In release builds, +/// template reloading is disabled to improve performance and cannot be enabled. +/// /// # Usage /// /// To use, add the `handlebars_templates` feature, the `tera_templates` @@ -205,26 +212,10 @@ impl Template { /// # ; /// } /// ``` - pub fn custom<F>(f: F) -> impl Fairing where F: Fn(&mut Engines) + Send + Sync + 'static { - AdHoc::on_attach(move |rocket| { - let mut template_root = rocket.config().root_relative(DEFAULT_TEMPLATE_DIR); - match rocket.config().get_str("template_dir") { - Ok(dir) => template_root = rocket.config().root_relative(dir), - Err(ConfigError::NotFound) => { /* ignore missing configs */ } - Err(e) => { - e.pretty_print(); - warn_!("Using default templates directory '{:?}'", template_root); - } - }; - - match Context::initialize(template_root) { - Some(mut ctxt) => { - f(&mut ctxt.engines); - Ok(rocket.manage(ctxt)) - } - None => Err(rocket) - } - }) + pub fn custom<F>(f: F) -> impl Fairing + where F: Fn(&mut Engines) + Send + Sync + 'static + { + TemplateFairing { custom_callback: Box::new(f) } } /// Render the template named `name` with the context `context`. The @@ -289,7 +280,7 @@ impl Template { pub fn show<S, C>(rocket: &Rocket, name: S, context: C) -> Option<String> where S: Into<Cow<'static, str>>, C: Serialize { - let ctxt = rocket.state::<Context>().or_else(|| { + let ctxt = rocket.state::<ContextManager>().map(ContextManager::context).or_else(|| { warn!("Uninitialized template context: missing fairing."); info!("To use templates, you must attach `Template::fairing()`."); info!("See the `Template` documentation for more information."); @@ -299,6 +290,9 @@ impl Template { Template::render(name, context).finalize(&ctxt).ok().map(|v| v.0) } + /// Aactually render this template given a template context. This method is + /// called by the `Template` `Responder` implementation as well as + /// `Template::show()`. #[inline(always)] fn finalize(self, ctxt: &Context) -> Result<(String, ContentType), Status> { let name = &*self.name; @@ -329,12 +323,12 @@ impl Template { /// rendering fails, an `Err` of `Status::InternalServerError` is returned. impl Responder<'static> for Template { fn respond_to(self, req: &Request) -> response::Result<'static> { - let ctxt = req.guard::<State<Context>>().succeeded().ok_or_else(|| { + let ctxt = req.guard::<State<ContextManager>>().succeeded().ok_or_else(|| { error_!("Uninitialized template context: missing fairing."); info_!("To use templates, you must attach `Template::fairing()`."); info_!("See the `Template` documentation for more information."); Status::InternalServerError - })?; + })?.inner().context(); let (render, content_type) = self.finalize(&ctxt)?; Content(content_type, render).respond_to(req) diff --git a/contrib/lib/tests/templates.rs b/contrib/lib/tests/templates.rs @@ -116,5 +116,61 @@ mod templates_tests { let response = client.get("/tera/test").dispatch(); assert_eq!(response.status(), Status::NotFound); } + + #[test] + #[cfg(debug_assertions)] + fn test_template_reload() { + use std::fs::File; + use std::io::Write; + use std::path::Path; + use std::thread; + use std::time::Duration; + + use rocket::local::Client; + + const RELOAD_TEMPLATE: &str = "hbs/reload"; + const INITIAL_TEXT: &str = "initial"; + const NEW_TEXT: &str = "reload"; + + fn write_file(path: &Path, text: &str) { + let mut file = File::create(path).expect("open file"); + file.write_all(text.as_bytes()).expect("write file"); + file.sync_all().expect("sync file"); + } + + let reload_path = Path::join( + Path::new(env!("CARGO_MANIFEST_DIR")), + "tests/templates/hbs/reload.txt.hbs" + ); + + // set up the template before initializing the Rocket instance so + // that it will be picked up in the initial loading of templates. + write_file(&reload_path, INITIAL_TEXT); + + let client = Client::new(rocket()).unwrap(); + + // verify that the initial content is correct + let initial_rendered = Template::show(client.rocket(), RELOAD_TEMPLATE, ()); + assert_eq!(initial_rendered, Some(INITIAL_TEXT.into())); + + // write a change to the file + write_file(&reload_path, NEW_TEXT); + + for _ in 0..6 { + // dispatch any request to trigger a template reload + client.get("/").dispatch(); + + // if the new content is correct, we are done + let new_rendered = Template::show(client.rocket(), RELOAD_TEMPLATE, ()); + if new_rendered == Some(NEW_TEXT.into()) { + return; + } + + // otherwise, retry a few times, waiting 250ms in between + thread::sleep(Duration::from_millis(250)); + } + + panic!("failed to reload modified template in 1.5s"); + } } } diff --git a/contrib/lib/tests/templates/hbs/reload.txt.hbs b/contrib/lib/tests/templates/hbs/reload.txt.hbs @@ -0,0 +1 @@ +reload+ \ No newline at end of file diff --git a/site/guide/responses.md b/site/guide/responses.md @@ -263,6 +263,11 @@ engine used to render a template depends on the template file's extension. For example, if a file ends with `.hbs`, Handlebars is used, while if a file ends with `.tera`, Tera is used. +When your application is compiled in `debug` mode (without the `--release` flag +passed to `cargo`), templates are automatically reloaded when they are modified. +This means that you don't need to rebuild your application to observe template +changes: simply refresh! In release builds, reloading is disabled. + For templates to be properly registered, the template fairing must be attached to the instance of Rocket. The [Fairings](/guide/fairings) sections of the guide provides more information on fairings. To attach the template fairing, simply