diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index 90c7b7e2..cc6c133e 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Add `HttpRequest::url_for_map()` that accepts a map of elements by parameter names, an alternative to `HttpRequest::url_for()` (which takes an iterator over elements). + ## 4.8.0 ### Added diff --git a/actix-web/src/request.rs b/actix-web/src/request.rs index 47b3e3d8..d16cc495 100644 --- a/actix-web/src/request.rs +++ b/actix-web/src/request.rs @@ -1,6 +1,10 @@ use std::{ + borrow, cell::{Ref, RefCell, RefMut}, - fmt, net, + collections::HashMap, + fmt, + hash::{BuildHasher, Hash}, + net, rc::Rc, str, }; @@ -230,7 +234,7 @@ impl HttpRequest { /// /// let app = App::new() /// .service(web::resource("/test/{one}/{two}/{three}") - /// .name("foo") // <- set resource name so it can be used in `url_for` + /// .name("foo") // <- set resource name, so it can be used in `url_for` /// .route(web::get().to(|| HttpResponse::Ok())) /// ); /// ``` @@ -239,7 +243,46 @@ impl HttpRequest { U: IntoIterator, I: AsRef, { - self.resource_map().url_for(self, name, elements) + self.resource_map().url_for_iter(self, name, elements) + } + + /// Generates URL for a named resource. + /// + /// This substitutes all URL parameters that appear in the resource itself and in + /// parent [scopes](crate::web::scope), if any, with their associated value from the given map. + /// + /// It is worth noting that the characters `['/', '%']` are not escaped and therefore a single + /// URL parameter may expand into multiple path segments and `elements` can be percent-encoded + /// beforehand without worrying about double encoding. Any other character that is not valid in + /// a URL path context is escaped using percent-encoding. + /// + /// # Examples + /// ``` + /// # use std::collections::HashMap; + /// # use actix_web::{web, App, HttpRequest, HttpResponse}; + /// fn index(req: HttpRequest) -> HttpResponse { + /// let parameter_map = HashMap::from([("one", "1"), ("two", "2"), ("three", "3")]); + /// let url = req.url_for_map("foo", ¶meter_map); // <- generate URL for "foo" resource + /// HttpResponse::Ok().into() + /// } + /// + /// let app = App::new() + /// .service(web::resource("/test/{one}/{two}/{three}") + /// .name("foo") // <- set resource name, so it can be used in `url_for` + /// .route(web::get().to(|| HttpResponse::Ok())) + /// ); + /// ``` + pub fn url_for_map( + &self, + name: &str, + elements: &HashMap, + ) -> Result + where + K: borrow::Borrow + Eq + Hash, + V: AsRef, + S: BuildHasher, + { + self.resource_map().url_for_map(self, name, elements) } /// Generate URL for named resource @@ -621,17 +664,30 @@ mod tests { .rmap(rmap) .to_http_request(); + let input_map = HashMap::from([("non-existing", "test")]); assert_eq!( req.url_for("unknown", ["test"]), Err(UrlGenerationError::ResourceNotFound) ); + assert_eq!( + req.url_for_map("unknown", &input_map), + Err(UrlGenerationError::ResourceNotFound) + ); + assert_eq!( req.url_for("index", ["test"]), Err(UrlGenerationError::NotEnoughElements) ); - let url = req.url_for("index", ["test", "html"]); assert_eq!( - url.ok().unwrap().as_str(), + req.url_for_map("index", &input_map), + Err(UrlGenerationError::NotEnoughElements) + ); + + let input_map = HashMap::from([("name", "test"), ("ext", "html")]); + let url = req.url_for("index", ["test", "html"]); + assert_eq!(url, req.url_for_map("index", &input_map)); + assert_eq!( + url.unwrap().as_str(), "http://www.rust-lang.org/user/test.html" ); } @@ -685,9 +741,11 @@ mod tests { rmap.add(&mut rdef, None); let req = TestRequest::default().rmap(rmap).to_http_request(); + let input_map = HashMap::from([("video_id", "oHg5SJYRHA0")]); let url = req.url_for("youtube", ["oHg5SJYRHA0"]); + assert_eq!(url, req.url_for_map("youtube", &input_map)); assert_eq!( - url.ok().unwrap().as_str(), + url.unwrap().as_str(), "https://youtube.com/watch/oHg5SJYRHA0" ); } diff --git a/actix-web/src/rmap.rs b/actix-web/src/rmap.rs index 462f3b31..531d76ad 100644 --- a/actix-web/src/rmap.rs +++ b/actix-web/src/rmap.rs @@ -1,7 +1,9 @@ use std::{ - borrow::Cow, + borrow::{Borrow, Cow}, cell::RefCell, + collections::HashMap, fmt::Write as _, + hash::{BuildHasher, Hash}, rc::{Rc, Weak}, }; @@ -114,10 +116,10 @@ impl ResourceMap { } } - /// Generate URL for named resource. + /// Generate URL for named resource with an iterator over elements. /// /// Check [`HttpRequest::url_for`] for detailed information. - pub fn url_for( + pub fn url_for_iter( &self, req: &HttpRequest, name: &str, @@ -128,16 +130,48 @@ impl ResourceMap { I: AsRef, { let mut elements = elements.into_iter(); + self.url_for(req, name, |mut acc, node: &ResourceMap| { + node.pattern + .resource_path_from_iter(&mut acc, &mut elements) + .then_some(acc) + }) + } + /// Generate URL for named resource with a map of elements by parameter names. + /// + /// Check [`HttpRequest::url_for_map`] for detailed information. + pub fn url_for_map( + &self, + req: &HttpRequest, + name: &str, + elements: &HashMap, + ) -> Result + where + K: Borrow + Eq + Hash, + V: AsRef, + S: BuildHasher, + { + self.url_for(req, name, |mut acc, node: &ResourceMap| { + node.pattern + .resource_path_from_map(&mut acc, elements) + .then_some(acc) + }) + } + + fn url_for( + &self, + req: &HttpRequest, + name: &str, + map_fn: F, + ) -> Result + where + F: FnMut(String, &ResourceMap) -> Option, + { let path = self .named .get(name) .ok_or(UrlGenerationError::ResourceNotFound)? - .root_rmap_fn(String::with_capacity(AVG_PATH_LEN), |mut acc, node| { - node.pattern - .resource_path_from_iter(&mut acc, &mut elements) - .then_some(acc) - }) + .root_rmap_fn(String::with_capacity(AVG_PATH_LEN), map_fn) .ok_or(UrlGenerationError::NotEnoughElements)?; let (base, path): (Cow<'_, _>, _) = if path.starts_with('/') { @@ -448,13 +482,23 @@ mod tests { req.set_server_hostname("localhost:8888"); let req = req.to_http_request(); + const OUTPUT: &str = "http://localhost:8888/user/u123/post/foobar"; + let url = rmap - .url_for(&req, "post", ["u123", "foobar"]) + .url_for_iter(&req, "post", ["u123", "foobar"]) .unwrap() .to_string(); - assert_eq!(url, "http://localhost:8888/user/u123/post/foobar"); + assert_eq!(url, OUTPUT); - assert!(rmap.url_for(&req, "missing", ["u123"]).is_err()); + let input_map = HashMap::from([("user_id", "u123"), ("sub_id", "foobar")]); + let url = rmap + .url_for_map(&req, "post", &input_map) + .unwrap() + .to_string(); + assert_eq!(url, OUTPUT); + + assert!(rmap.url_for_iter(&req, "missing", ["u123"]).is_err()); + assert!(rmap.url_for_map(&req, "missing", &input_map).is_err()); } #[test] @@ -480,17 +524,32 @@ mod tests { req.set_server_hostname("localhost:8888"); let req = req.to_http_request(); - const INPUT: &[&str] = &["a/../quick brown%20fox/%nan?query#frag"]; + const INPUT: &str = "a/../quick brown%20fox/%nan?query#frag"; + const ITERABLE_INPUT: &[&str] = &[INPUT]; + let map_input = HashMap::from([("var", INPUT), ("extra", "")]); + const OUTPUT: &str = "/quick%20brown%20fox/%nan%3Fquery%23frag"; - let url = rmap.url_for(&req, "internal", INPUT).unwrap(); + let url = rmap.url_for_iter(&req, "internal", ITERABLE_INPUT).unwrap(); + assert_eq!(url.path(), OUTPUT); + let url = rmap.url_for_map(&req, "internal", &map_input).unwrap(); assert_eq!(url.path(), OUTPUT); - let url = rmap.url_for(&req, "external.1", INPUT).unwrap(); + let url = rmap + .url_for_iter(&req, "external.1", ITERABLE_INPUT) + .unwrap(); + assert_eq!(url.path(), OUTPUT); + let url = rmap.url_for_map(&req, "external.1", &map_input).unwrap(); assert_eq!(url.path(), OUTPUT); - assert!(rmap.url_for(&req, "external.2", INPUT).is_err()); - assert!(rmap.url_for(&req, "external.2", [""]).is_err()); + assert!(rmap + .url_for_iter(&req, "external.2", ITERABLE_INPUT) + .is_err()); + assert!(rmap.url_for_map(&req, "external.2", &map_input).is_err()); + + let empty_map: HashMap<&str, &str> = HashMap::new(); + assert!(rmap.url_for_iter(&req, "external.2", [""]).is_err()); + assert!(rmap.url_for_map(&req, "external.2", &empty_map).is_err()); } #[test] @@ -523,10 +582,22 @@ mod tests { req.set_server_hostname("localhost:8888"); let req = req.to_http_request(); + const OUTPUT: &str = "https://duck.com/abcd"; + assert_eq!( - rmap.url_for(&req, "duck", ["abcd"]).unwrap().to_string(), - "https://duck.com/abcd" + rmap.url_for_iter(&req, "duck", ["abcd"]) + .unwrap() + .to_string(), + OUTPUT ); + + let input_map = HashMap::from([("query", "abcd")]); + assert_eq!( + rmap.url_for_map(&req, "duck", &input_map) + .unwrap() + .to_string(), + OUTPUT + ) } #[test] @@ -552,9 +623,22 @@ mod tests { let req = crate::test::TestRequest::default().to_http_request(); - let url = rmap.url_for(&req, "nested", [""; 0]).unwrap().to_string(); - assert_eq!(url, "http://localhost:8080/bar/nested"); + const OUTPUT: &str = "http://localhost:8080/bar/nested"; - assert!(rmap.url_for(&req, "missing", ["u123"]).is_err()); + let url = rmap + .url_for_iter(&req, "nested", [""; 0]) + .unwrap() + .to_string(); + assert_eq!(url, OUTPUT); + + let empty_map: HashMap<&str, &str> = HashMap::new(); + let url = rmap + .url_for_map(&req, "nested", &empty_map) + .unwrap() + .to_string(); + assert_eq!(url, OUTPUT); + + assert!(rmap.url_for_iter(&req, "missing", ["u123"]).is_err()); + assert!(rmap.url_for_map(&req, "missing", &empty_map).is_err()); } }