use std::borrow::Cow;
use std::fmt::{self, Debug, Formatter};
use std::str::FromStr;
use salvo_core::handler::Skipper;
use salvo_core::http::uri::{PathAndQuery, Uri};
use salvo_core::http::{ParseError, ResBody};
use salvo_core::prelude::*;
#[derive(Eq, PartialEq, Clone, Debug, Copy)]
pub enum TrailingSlashAction {
Remove,
Add,
}
pub fn default_remove_skipper(req: &mut Request, _depot: &Depot) -> bool {
if let Some((_, name)) = req.uri().path().trim_end_matches('/').rsplit_once('/') {
!name.contains('.')
} else {
false
}
}
pub fn default_add_skipper(req: &mut Request, _depot: &Depot) -> bool {
if let Some((_, name)) = req.uri().path().rsplit_once('/') {
name.contains('.')
} else {
false
}
}
#[non_exhaustive]
pub struct TrailingSlash {
pub action: TrailingSlashAction,
pub skipper: Box<dyn Skipper>,
pub redirect_code: StatusCode,
}
impl Debug for TrailingSlash {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("TrailingSlash")
.field("action", &self.action)
.field("redirect_code", &self.redirect_code)
.finish()
}
}
impl TrailingSlash {
#[inline]
pub fn new(action: TrailingSlashAction) -> Self {
Self {
action,
skipper: match action {
TrailingSlashAction::Add => Box::new(default_add_skipper),
TrailingSlashAction::Remove => Box::new(default_remove_skipper),
},
redirect_code: StatusCode::MOVED_PERMANENTLY,
}
}
#[inline]
#[must_use]
pub fn new_add() -> Self {
Self::new(TrailingSlashAction::Add)
}
#[inline]
#[must_use]
pub fn new_remove() -> Self {
Self::new(TrailingSlashAction::Remove)
}
#[inline]
#[must_use]
pub fn skipper(mut self, skipper: impl Skipper) -> Self {
self.skipper = Box::new(skipper);
self
}
#[inline]
#[must_use]
pub fn redirect_code(mut self, redirect_code: StatusCode) -> Self {
self.redirect_code = redirect_code;
self
}
}
#[async_trait]
impl Handler for TrailingSlash {
async fn handle(
&self,
req: &mut Request,
depot: &mut Depot,
res: &mut Response,
ctrl: &mut FlowCtrl,
) {
if self.skipper.skipped(req, depot) {
return;
}
let original_path = req.uri().path();
if !original_path.is_empty() && original_path != "/" {
let ends_with_slash = original_path.ends_with('/');
let new_uri = if self.action == TrailingSlashAction::Add && !ends_with_slash {
replace_uri_path(req.uri(), &format!("{original_path}/")).ok()
} else if self.action == TrailingSlashAction::Remove && ends_with_slash {
replace_uri_path(req.uri(), original_path.trim_end_matches('/')).ok()
} else {
None
};
if let Some(new_uri) = new_uri {
ctrl.skip_rest();
res.body(ResBody::None);
match Redirect::with_status_code(self.redirect_code, new_uri.to_string()) {
Ok(redirect) => {
res.render(redirect);
}
Err(e) => {
tracing::error!(error = ?e, "redirect failed");
}
}
}
}
}
}
fn replace_uri_path(original_uri: &Uri, new_path: &str) -> Result<Uri, ParseError> {
let mut uri_parts = original_uri.clone().into_parts();
let path = match original_uri.query() {
Some(query) => Cow::from(format!("{new_path}?{query}")),
None => Cow::from(new_path),
};
uri_parts.path_and_query = Some(PathAndQuery::from_str(path.as_ref())?);
Ok(Uri::from_parts(uri_parts)?)
}
#[inline]
#[must_use]
pub fn add_slash() -> TrailingSlash {
TrailingSlash::new(TrailingSlashAction::Add)
}
#[inline]
#[must_use]
pub fn remove_slash() -> TrailingSlash {
TrailingSlash::new(TrailingSlashAction::Remove)
}
#[cfg(test)]
mod tests {
use salvo_core::http::StatusCode;
use salvo_core::prelude::*;
use salvo_core::test::TestClient;
use super::*;
#[handler]
async fn hello() -> &'static str {
"Hello World"
}
#[tokio::test]
async fn test_add_slash() {
let router = Router::with_hoop(add_slash())
.push(Router::with_path("hello").get(hello))
.push(Router::with_path("hello.world").get(hello));
let service = Service::new(router);
let res = TestClient::get("http://127.0.0.1:8698/hello")
.send(&service)
.await;
assert_eq!(res.status_code.unwrap(), StatusCode::MOVED_PERMANENTLY);
let res = TestClient::get("http://127.0.0.1:8698/hello/")
.send(&service)
.await;
assert_eq!(res.status_code.unwrap(), StatusCode::OK);
let res = TestClient::get("http://127.0.0.1:8698/hello.world")
.send(&service)
.await;
assert_eq!(res.status_code.unwrap(), StatusCode::OK);
}
#[tokio::test]
async fn test_remove_slash() {
let router =
Router::with_hoop(remove_slash().redirect_code(StatusCode::TEMPORARY_REDIRECT))
.push(Router::with_path("hello").get(hello))
.push(Router::with_path("hello.world").get(hello));
let service = Service::new(router);
let res = TestClient::get("http://127.0.0.1:8698/hello/")
.send(&service)
.await;
assert_eq!(res.status_code.unwrap(), StatusCode::OK);
let res = TestClient::get("http://127.0.0.1:8698/hello.world/")
.send(&service)
.await;
assert_eq!(res.status_code.unwrap(), StatusCode::TEMPORARY_REDIRECT);
let res = TestClient::get("http://127.0.0.1:8698/hello.world")
.send(&service)
.await;
assert_eq!(res.status_code.unwrap(), StatusCode::OK);
}
}