diff --git a/Cargo.toml b/Cargo.toml index dc6ef9f..f18c469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,8 @@ license = "MIT" iron = "*" url = "*" sequence_trie = "*" + +[dev-dependencies] +stainless = "*" +iron-test = "*" + diff --git a/src/lib.rs b/src/lib.rs index 4b35636..a7bde91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,10 @@ #![crate_name = "mount"] + #![deny(missing_docs)] #![cfg_attr(test, deny(warnings))] -#![feature(core, path)] + +#![feature(core, path, collections)] +#![cfg_attr(test, feature(plugin, io, test))] //! `Mount` provides mounting middleware for the Iron framework. @@ -9,7 +12,19 @@ extern crate iron; extern crate url; extern crate sequence_trie; -pub use mount::{Mount, OriginalUrl}; +#[cfg(test)] #[plugin] +extern crate stainless; + +#[cfg(test)] +extern crate test; + +#[cfg(test)] +extern crate "iron-test" as itest; + +pub use mount::{Mount, VirtualRoot, OriginalUrl, NoMatch}; mod mount; +#[cfg(test)] +mod tests; + diff --git a/src/mount.rs b/src/mount.rs index 7a3c7a8..cc6a7ee 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -5,10 +5,15 @@ use sequence_trie::SequenceTrie; use std::fmt; /// Exposes the original, unmodified path to be stored in `Request::extensions`. -#[derive(Copy)] +#[derive(Debug, Copy)] pub struct OriginalUrl; impl typemap::Key for OriginalUrl { type Value = Url; } +/// Exposes the mounting path, so a request can know its own relative address. +#[derive(Debug, Copy)] +pub struct VirtualRoot; +impl typemap::Key for VirtualRoot { type Value = Url; } + /// `Mount` is a simple mounting middleware. /// /// Mounting allows you to install a handler on a route and have it receive requests as if they @@ -25,7 +30,7 @@ pub struct Mount { } struct Match { - handler: Box, + handler: Box, length: usize } @@ -57,40 +62,43 @@ impl Mount { /// For a given request, the *most specific* handler will be selected. /// /// Existing handlers on the same route will be overwritten. - pub fn mount(&mut self, route: &str, handler: H) -> &mut Mount { + pub fn on(&mut self, route: &str, handler: H) -> &mut Mount { // Parse the route into a list of strings. The unwrap is safe because strs are UTF-8. - let key: Vec = Path::new(route).str_components().map(|s| s.unwrap().to_string()).collect(); + let key = Path::new(route).str_components() + .map(|s| s.unwrap().to_string()).collect::>(); // Insert a match struct into the trie. self.inner.insert(key.as_slice(), Match { - handler: Box::new(handler) as Box, + handler: Box::new(handler) as Box, length: key.len() }); self } + + /// The old way to mount handlers. + #[deprecated = "use .on instead"] + pub fn mount(&mut self, route: &str, handler: H) -> &mut Mount { + self.on(route, handler) + } } impl Handler for Mount { fn handle(&self, req: &mut Request) -> IronResult { + let original = req.url.path.clone(); + + // If present, remove the trailing empty string (which represents a trailing slash). + // If it isn't removed the path will never match anything, because + // Path::str_components ignores trailing slashes and will never create routes + // ending in "". + let mut root = original.as_slice(); + while root.last().map(|s| &**s) == Some("") { + root = &root[..root.len() - 1]; + } + // Find the matching handler. - let matched = { - // Extract the request path. - let path = req.url.path.as_slice(); - - // If present, remove the trailing empty string (which represents a trailing slash). - // If it isn't removed the path will never match anything, because - // Path::str_components ignores trailing slashes and will never create routes - // ending in "". - let key = match path.last() { - Some(s) if s.is_empty() => &path[..path.len() - 1], - _ => path - }; - - // Search the Trie for the nearest most specific match. - match self.inner.get_ancestor(key) { - Some(matched) => matched, - None => return Err(IronError::new(NoMatch, status::NotFound)) - } + let matched = match self.inner.get_ancestor(root) { + Some(matched) => matched, + None => return Err(IronError::new(NoMatch, status::NotFound)) }; // We have a match, so fire off the child. @@ -98,27 +106,40 @@ impl Handler for Mount { // into the extensions as the "original url". let is_outer_mount = !req.extensions.contains::(); if is_outer_mount { + let mut root_url = req.url.clone(); + root_url.path = root.to_vec(); + req.extensions.insert::(req.url.clone()); + req.extensions.insert::(root_url); + } else { + req.extensions.get_mut::().map(|old| { + old.path.push_all(root); + }); } - // Remove the prefix from the request's path before passing it to the mounted handler. - // If the prefix is entirely removed and no trailing slash was present, the new path - // will be the empty list. For the purposes of redirection, conveying that the path - // did not include a trailing slash is more important than providing a non-empty list. + // Remove the prefix from the request's path before passing it to the mounted + // handler. If the prefix is entirely removed and no trailing slash was present, + // the new path will be the empty list. + // + // For the purposes of redirection, conveying that the path did not include + // a trailing slash is more important than providing a non-empty list. req.url.path = req.url.path.as_slice()[matched.length..].to_vec(); let res = matched.handler.handle(req); // Reverse the URL munging, for future middleware. - req.url = match req.extensions.get::() { - Some(original) => original.clone(), - None => panic!("OriginalUrl unexpectedly removed from req.extensions.") - }; + req.url.path = original.clone(); // If this mount middleware is the outermost mount middleware, // remove the original url from the extensions map to prevent leakage. if is_outer_mount { req.extensions.remove::(); + req.extensions.remove::(); + } else { + req.extensions.get_mut::().map(|old| { + let old_len = old.path.len(); + old.path.truncate(old_len - root.len()); + }); } res diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..8e03e7f --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,77 @@ +pub use iron::prelude::*; +pub use iron::Handler; +pub use iron::{status, method}; + +// FIXME(reem): Write tests for OriginalUrl and VirtualRoot +pub use {Mount, OriginalUrl, VirtualRoot, NoMatch}; + +pub fn at(mount: &Mount, url: &str) -> Result { + use std::old_io::util::NullReader; + use itest::mock::request; + use iron::Url; + + let url = Url::parse(&format!("http://localhost:3000{}", url)).unwrap(); + let rdr = &mut NullReader; + let mut req = request::new(method::Get, url, rdr); + + mount.handle(&mut req).map(|res| { + res.body.unwrap_or(Box::new(NullReader)).read_to_string().unwrap() + }) +} + +describe! mount { + before_each { + let mut mount = Mount::new(); + + mount.on("/hello", |_: &mut Request| { + Ok(Response::with((status::Ok, "hello"))) + }); + + mount.on("/intercept", |_: &mut Request| { + Ok(Response::with((status::Ok, "intercepted"))) + }); + + mount.on("/trailing_slashes///", |_: &mut Request| { + Ok(Response::with((status::Ok, "trailing slashes"))) + }); + + mount.on("/trailing_slash/", |_: &mut Request| { + Ok(Response::with((status::Ok, "trailing slash"))) + }); + } + + it "should mount handlers" { + assert_eq!(&*at(&mount, "/hello").unwrap(), "hello"); + assert_eq!(&*at(&mount, "/hello/and/more").unwrap(), "hello"); + + assert_eq!(&*at(&mount, "/intercept").unwrap(), "intercepted"); + assert_eq!(&*at(&mount, "/intercept/with/more").unwrap(), "intercepted"); + } + + it "should work with trailing slashes" { + assert_eq!(&*at(&mount, "/hello/").unwrap(), "hello"); + assert_eq!(&*at(&mount, "/hello//").unwrap(), "hello"); + assert_eq!(&*at(&mount, "/hello//and/more").unwrap(), "hello"); + + assert_eq!(&*at(&mount, "/trailing_slash").unwrap(), "trailing slash"); + assert_eq!(&*at(&mount, "/trailing_slash///").unwrap(), "trailing slash"); + assert_eq!(&*at(&mount, "/trailing_slash/with_more").unwrap(), "trailing slash"); + assert_eq!(&*at(&mount, "/trailing_slash//crazy/with_more").unwrap(), "trailing slash"); + + assert_eq!(&*at(&mount, "/trailing_slashes").unwrap(), "trailing slashes"); + assert_eq!(&*at(&mount, "/trailing_slashes/").unwrap(), "trailing slashes"); + assert_eq!(&*at(&mount, "/trailing_slashes///").unwrap(), "trailing slashes"); + assert_eq!( + &*at(&mount, "/trailing_slashes///with_extra/crazy").unwrap(), + "trailing slashes" + ); + } + + it "should throw when no match is found" { + let err = at(&mount, "/notfound").unwrap_err(); + + assert_eq!(err.response.status, Some(status::NotFound)); + err.error.downcast::().unwrap(); + } +} +