Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reload templates when they have been changed on disk #537

Closed
wants to merge 28 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6f1ca6e
template reload: Store template initialization callback inside Contex…
jebrosen Jan 10, 2018
06a356f
template reload: Before each request in development mode, reload temp…
jebrosen Jan 11, 2018
d9ca78f
template reload: properly handle failure case when instantiating a fi…
jebrosen Jan 13, 2018
34cca3f
template reload: split TemplateFairing and ManagedContext into a sepa…
jebrosen Apr 9, 2018
7c4d09c
template reload: clarify and document definition of ManagedContext
jebrosen Apr 9, 2018
5771e94
template reload: Keep previous templates and engines if there is a fa…
jebrosen Apr 9, 2018
10b0c33
mostly revert Context: move the callback into the fairing, move the T…
jebrosen Apr 12, 2018
91a4e38
rename ManagedContext to ContextManager
jebrosen Apr 13, 2018
1a517cc
remove Send+Sync+'static bounds where they are not needed
jebrosen Apr 13, 2018
ef6159d
register the on_request part of TemplateFairing only in debug_assertions
jebrosen Apr 13, 2018
c301c13
refactor based on feedback
jebrosen Apr 13, 2018
8b54c86
move TemplateWatcher functionality directly into ManagedContext
jebrosen Apr 14, 2018
5340c5a
merge watcher and corresponding receive queue into same Option field
jebrosen Apr 19, 2018
4d3a0ff
minor style fixes
jebrosen Apr 19, 2018
9807844
split the long warning lines
jebrosen Apr 19, 2018
970056c
template reload: simplest test of a single template reload cycle
jebrosen May 7, 2018
d94c4ba
switch watcher to raw_watcher
jebrosen Jul 2, 2018
0f3daa1
explicitly sync writes to files in the test; likely necessary on some…
jebrosen Jul 8, 2018
8b28f80
unbreak #676 when combined with ContextManager
jebrosen Jul 12, 2018
91204ae
fully qualify Data, Request to avoid unused warnings in release mode
jebrosen Jul 12, 2018
d37ed0f
template_reload: attempt several times to observe filesystem changes
jebrosen Jul 14, 2018
f4176df
refactor loop based on feedback. don't delete template file becuase i…
jebrosen Jul 14, 2018
2585897
template reload tweaks and documentation
jebrosen Jul 21, 2018
d75d1ec
style changes, documentation additions/updates
jebrosen Jul 21, 2018
e13036e
update note about reload being disabled in release & add to guide
jebrosen Jul 28, 2018
85f2a5d
include template reload test file so it does not appear as an untrack…
jebrosen Jul 28, 2018
dc34ab6
missing 'of'
jebrosen Jul 28, 2018
3ecc02a
remove an unnecessary clone()
jebrosen Aug 9, 2018
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
3 changes: 3 additions & 0 deletions contrib/lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions contrib/lib/src/templates/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ 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 {
Expand Down
167 changes: 167 additions & 0 deletions contrib/lib/src/templates/fairing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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};

#[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.");
};
}
});
}
}
}

pub use self::context::ContextManager;

/// 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 for better performance.
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);
}
}
10 changes: 5 additions & 5 deletions contrib/lib/src/templates/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}

Expand All @@ -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()`.");
Expand Down
41 changes: 16 additions & 25 deletions contrib/lib/src/templates/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ 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;

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};
Expand All @@ -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";

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -329,12 +320,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)
Expand Down
57 changes: 57 additions & 0 deletions contrib/lib/tests/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,62 @@ 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()) {
// TODO: deleting the file can break concurrent tests,
// but not deleting the file will leave it as an untracked file in git
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what to do about this file. Two things I thought of were to add it to a gitignore, or to commit what the file should contain after a successful test run (in this case "reload").

return;
}

thread::sleep(Duration::from_millis(250));
}

panic!("failed to reload modified template in 1.5s");
}
}
}
1 change: 1 addition & 0 deletions contrib/lib/tests/templates/hbs/reload.txt.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
reload
Loading