From 3c69d078b2ad35672394f00d6d8acf1985a63bb8 Mon Sep 17 00:00:00 2001 From: Rob Ede Date: Fri, 25 Nov 2022 21:44:52 +0000 Subject: [PATCH] add redirect service (#1961) --- .github/workflows/clippy-fmt.yml | 2 +- actix-web/CHANGES.md | 2 + actix-web/src/lib.rs | 1 + actix-web/src/redirect.rs | 238 +++++++++++++++++++++++++++++++ actix-web/src/web.rs | 30 +++- 5 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 actix-web/src/redirect.rs diff --git a/.github/workflows/clippy-fmt.yml b/.github/workflows/clippy-fmt.yml index 1587a0b1b..e94c4d1af 100644 --- a/.github/workflows/clippy-fmt.yml +++ b/.github/workflows/clippy-fmt.yml @@ -10,6 +10,7 @@ jobs: steps: - uses: actions/checkout@v2 - uses: dtolnay/rust-toolchain@nightly + with: { components: rustfmt } - run: cargo fmt --all -- --check clippy: @@ -21,7 +22,6 @@ jobs: with: { components: clippy } - name: Generate Cargo.lock - uses: actions-rs/cargo@v1 run: cargo generate-lockfile - name: Cache Dependencies uses: Swatinem/rust-cache@v1.2.0 diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index 06a2cccc9..6440ad693 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -5,8 +5,10 @@ - Add `ContentDisposition::attachment` constructor. [#2867] - Add `ErrorHandlers::default_handler()` (as well as `default_handler_{server, client}()`) to make registering handlers for groups of response statuses easier. [#2784] - Add `Logger::custom_response_replace()`. [#2631] +- Add rudimentary redirection service at `web::redirect()` / `web::Redirect`. [#1961] - Add `guard::Acceptable` for matching against `Accept` header mime types. [#2265] +[#1961]: https://github.com/actix/actix-web/pull/1961 [#2265]: https://github.com/actix/actix-web/pull/2265 [#2631]: https://github.com/actix/actix-web/pull/2631 [#2784]: https://github.com/actix/actix-web/pull/2784 diff --git a/actix-web/src/lib.rs b/actix-web/src/lib.rs index 8d9e2dbcd..338541208 100644 --- a/actix-web/src/lib.rs +++ b/actix-web/src/lib.rs @@ -86,6 +86,7 @@ mod helpers; pub mod http; mod info; pub mod middleware; +mod redirect; mod request; mod request_data; mod resource; diff --git a/actix-web/src/redirect.rs b/actix-web/src/redirect.rs new file mode 100644 index 000000000..ca9e23aa4 --- /dev/null +++ b/actix-web/src/redirect.rs @@ -0,0 +1,238 @@ +//! See [`Redirect`] for service/responder documentation. + +use std::borrow::Cow; + +use actix_utils::future::ready; + +use crate::{ + dev::{fn_service, AppService, HttpServiceFactory, ResourceDef, ServiceRequest}, + http::{header::LOCATION, StatusCode}, + HttpRequest, HttpResponse, Responder, +}; + +/// An HTTP service for redirecting one path to another path or URL. +/// +/// By default, the "307 Temporary Redirect" status is used when responding. See [this MDN +/// article][mdn-redirects] on why 307 is preferred over 302. +/// +/// # Examples +/// As service: +/// ``` +/// use actix_web::{web, App}; +/// +/// App::new() +/// // redirect "/duck" to DuckDuckGo +/// .service(web::redirect("/duck", "https://duck.com")) +/// .service( +/// // redirect "/api/old" to "/api/new" +/// web::scope("/api").service(web::redirect("/old", "/new")) +/// ); +/// ``` +/// +/// As responder: +/// ``` +/// use actix_web::web::Redirect; +/// +/// async fn handler() -> impl Responder { +/// // sends a permanent (308) redirect to duck.com +/// Redirect::to("https://duck.com").permanent() +/// } +/// # actix_web::web::to(handler); +/// ``` +/// +/// [mdn-redirects]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#temporary_redirections +#[derive(Debug, Clone)] +pub struct Redirect { + from: Cow<'static, str>, + to: Cow<'static, str>, + status_code: StatusCode, +} + +impl Redirect { + /// Construct a new `Redirect` service that matches a path. + /// + /// This service will match exact paths equal to `from` within the current scope. I.e., when + /// registered on the root `App`, it will match exact, whole paths. But when registered on a + /// `Scope`, it will match paths under that scope, ignoring the defined scope prefix, just like + /// a normal `Resource` or `Route`. + /// + /// The `to` argument can be path or URL; whatever is provided shall be used verbatim when + /// setting the redirect location. This means that relative paths can be used to navigate + /// relatively to matched paths. + /// + /// Prefer [`Redirect::to()`](Self::to) when using `Redirect` as a responder since `from` has + /// no meaning in that context. + /// + /// # Examples + /// ``` + /// # use actix_web::{web::Redirect, App}; + /// App::new() + /// // redirects "/oh/hi/mark" to "/oh/bye/johnny" + /// .service(Redirect::new("/oh/hi/mark", "../../bye/johnny")); + /// ``` + pub fn new(from: impl Into>, to: impl Into>) -> Self { + Self { + from: from.into(), + to: to.into(), + status_code: StatusCode::TEMPORARY_REDIRECT, + } + } + + /// Construct a new `Redirect` to use as a responder. + /// + /// Only receives the `to` argument since responders do not need to do route matching. + /// + /// # Examples + /// ``` + /// use actix_web::web::Redirect; + /// + /// async fn admin_page() -> impl Responder { + /// // sends a temporary 307 redirect to the login path + /// Redirect::to("/login") + /// } + /// # actix_web::web::to(handler); + /// ``` + pub fn to(to: impl Into>) -> Self { + Self { + from: "/".into(), + to: to.into(), + status_code: StatusCode::TEMPORARY_REDIRECT, + } + } + + /// Use the "308 Permanent Redirect" status when responding. + /// + /// See [this MDN article][mdn-redirects] on why 308 is preferred over 301. + /// + /// [mdn-redirects]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#permanent_redirections + pub fn permanent(self) -> Self { + self.using_status_code(StatusCode::PERMANENT_REDIRECT) + } + + /// Use the "307 Temporary Redirect" status when responding. + /// + /// See [this MDN article][mdn-redirects] on why 307 is preferred over 302. + /// + /// [mdn-redirects]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#temporary_redirections + pub fn temporary(self) -> Self { + self.using_status_code(StatusCode::TEMPORARY_REDIRECT) + } + + /// Use the "303 See Other" status when responding. + /// + /// This status code is semantically correct as the response to a successful login, for example. + pub fn see_other(self) -> Self { + self.using_status_code(StatusCode::SEE_OTHER) + } + + /// Allows the use of custom status codes for less common redirect types. + /// + /// In most cases, the default status ("308 Permanent Redirect") or using the `temporary` + /// method, which uses the "307 Temporary Redirect" status have more consistent behavior than + /// 301 and 302 codes, respectively. + /// + /// ``` + /// # use actix_web::{http::StatusCode, web::Redirect}; + /// // redirects would use "301 Moved Permanently" status code + /// Redirect::new("/old", "/new") + /// .using_status_code(StatusCode::MOVED_PERMANENTLY); + /// + /// // redirects would use "302 Found" status code + /// Redirect::new("/old", "/new") + /// .using_status_code(StatusCode::FOUND); + /// ``` + pub fn using_status_code(mut self, status: StatusCode) -> Self { + self.status_code = status; + self + } +} + +impl HttpServiceFactory for Redirect { + fn register(self, config: &mut AppService) { + let redirect = self.clone(); + let rdef = ResourceDef::new(self.from.into_owned()); + let redirect_factory = fn_service(move |mut req: ServiceRequest| { + let res = redirect.clone().respond_to(req.parts_mut().0); + ready(Ok(req.into_response(res.map_into_boxed_body()))) + }); + + config.register_service(rdef, None, redirect_factory, None) + } +} + +impl Responder for Redirect { + type Body = (); + + fn respond_to(self, _req: &HttpRequest) -> HttpResponse { + let mut res = HttpResponse::with_body(self.status_code, ()); + + if let Ok(hdr_val) = self.to.parse() { + res.headers_mut().insert(LOCATION, hdr_val); + } else { + log::error!( + "redirect target location can not be converted to header value: {:?}", + self.to + ); + } + + res + } +} + +#[cfg(test)] +mod tests { + use crate::{dev::Service, http::StatusCode, test, App}; + + use super::*; + + #[actix_rt::test] + async fn absolute_redirects() { + let redirector = Redirect::new("/one", "/two").permanent(); + + let svc = test::init_service(App::new().service(redirector)).await; + + let req = test::TestRequest::default().uri("/one").to_request(); + let res = svc.call(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::from_u16(308).unwrap()); + let hdr = res.headers().get(&LOCATION).unwrap(); + assert_eq!(hdr.to_str().unwrap(), "/two"); + } + + #[actix_rt::test] + async fn relative_redirects() { + let redirector = Redirect::new("/one", "two").permanent(); + + let svc = test::init_service(App::new().service(redirector)).await; + + let req = test::TestRequest::default().uri("/one").to_request(); + let res = svc.call(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::from_u16(308).unwrap()); + let hdr = res.headers().get(&LOCATION).unwrap(); + assert_eq!(hdr.to_str().unwrap(), "two"); + } + + #[actix_rt::test] + async fn temporary_redirects() { + let external_service = Redirect::new("/external", "https://duck.com"); + + let svc = test::init_service(App::new().service(external_service)).await; + + let req = test::TestRequest::default().uri("/external").to_request(); + let res = svc.call(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::from_u16(307).unwrap()); + let hdr = res.headers().get(&LOCATION).unwrap(); + assert_eq!(hdr.to_str().unwrap(), "https://duck.com"); + } + + #[actix_rt::test] + async fn as_responder() { + let responder = Redirect::to("https://duck.com"); + + let req = test::TestRequest::default().to_http_request(); + let res = responder.respond_to(&req); + + assert_eq!(res.status(), StatusCode::from_u16(307).unwrap()); + let hdr = res.headers().get(&LOCATION).unwrap(); + assert_eq!(hdr.to_str().unwrap(), "https://duck.com"); + } +} diff --git a/actix-web/src/web.rs b/actix-web/src/web.rs index f5845d7f6..0533f7f8f 100644 --- a/actix-web/src/web.rs +++ b/actix-web/src/web.rs @@ -11,10 +11,12 @@ //! - [`Bytes`]: Raw payload //! //! # Responders -//! - [`Json`]: JSON request payload -//! - [`Bytes`]: Raw request payload +//! - [`Json`]: JSON response +//! - [`Form`]: URL-encoded response +//! - [`Bytes`]: Raw bytes response +//! - [`Redirect`](Redirect::to): Convenient redirect responses -use std::future::Future; +use std::{borrow::Cow, future::Future}; use actix_router::IntoPatterns; pub use bytes::{Buf, BufMut, Bytes, BytesMut}; @@ -26,6 +28,7 @@ use crate::{ pub use crate::config::ServiceConfig; pub use crate::data::Data; +pub use crate::redirect::Redirect; pub use crate::request_data::ReqData; pub use crate::types::*; @@ -45,6 +48,7 @@ pub use crate::types::*; /// For instance, to route `GET`-requests on any route matching `/users/{userid}/{friend}` and store /// `userid` and `friend` in the exposed `Path` object: /// +/// # Examples /// ``` /// use actix_web::{web, App, HttpResponse}; /// @@ -74,6 +78,7 @@ pub fn resource(path: T) -> Resource { /// - `/{project_id}/path2` /// - `/{project_id}/path3` /// +/// # Examples /// ``` /// use actix_web::{web, App, HttpResponse}; /// @@ -183,6 +188,25 @@ pub fn service(path: T) -> WebService { WebService::new(path) } +/// Create a relative or absolute redirect. +/// +/// See [`Redirect`] docs for usage details. +/// +/// # Examples +/// ``` +/// use actix_web::{web, App}; +/// +/// let app = App::new() +/// // the client will resolve this redirect to /api/to-path +/// .service(web::redirect("/api/from-path", "to-path")); +/// ``` +pub fn redirect( + from: impl Into>, + to: impl Into>, +) -> Redirect { + Redirect::new(from, to) +} + /// Executes blocking function on a thread pool, returns future that resolves to result of the /// function execution. pub fn block(f: F) -> impl Future>