use std::borrow::Cow;
use http::{HeaderValue, StatusCode, header::LOCATION};
use percent_encoding::{CONTROLS, utf8_percent_encode};
use topcoat_core::{
context::Cx,
error::{Error, Result},
};
use crate::response::{IntoResponse, Response};
#[must_use]
pub fn redirect(uri: impl AsRef<str>) -> RedirectError {
RedirectError::new(StatusCode::TEMPORARY_REDIRECT, uri.as_ref())
}
#[must_use]
pub fn redirect_permanent(uri: impl AsRef<str>) -> RedirectError {
RedirectError::new(StatusCode::PERMANENT_REDIRECT, uri.as_ref())
}
#[derive(Debug, Clone)]
pub struct RedirectError {
status: StatusCode,
location: HeaderValue,
}
impl RedirectError {
fn new(status: StatusCode, uri: &str) -> Self {
Self {
status,
location: location(uri),
}
}
#[cfg(test)]
pub(crate) fn location(&self) -> &HeaderValue {
&self.location
}
}
impl std::fmt::Display for RedirectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("redirect")
}
}
impl std::error::Error for RedirectError {}
impl IntoResponse for RedirectError {
fn into_response(self, cx: &Cx) -> Result<Response> {
(self.status, ([(LOCATION, self.location)], ())).into_response(cx)
}
}
#[must_use]
pub fn see_other(uri: impl AsRef<str>) -> SeeOther {
SeeOther::new(uri.as_ref())
}
#[derive(Debug, Clone)]
pub struct SeeOther {
location: HeaderValue,
}
impl SeeOther {
fn new(uri: &str) -> Self {
Self {
location: location(uri),
}
}
#[cfg(test)]
pub(crate) fn location(&self) -> &HeaderValue {
&self.location
}
}
impl std::fmt::Display for SeeOther {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("see other")
}
}
impl std::error::Error for SeeOther {}
impl IntoResponse for SeeOther {
fn into_response(self, cx: &Cx) -> Result<Response> {
(StatusCode::SEE_OTHER, ([(LOCATION, self.location)], ())).into_response(cx)
}
}
pub(crate) fn redirect_location(error: Error) -> Result<HeaderValue, Error> {
let error = match error.downcast_cloned::<RedirectError>() {
Ok(redirect) => return Ok(redirect.location),
Err(error) => error,
};
error
.downcast_cloned::<SeeOther>()
.map(|redirect| redirect.location)
}
fn location(uri: &str) -> HeaderValue {
let uri: Cow<'_, str> = utf8_percent_encode(uri, CONTROLS).into();
HeaderValue::try_from(&*uri).expect("percent-encoded uri is a valid header value")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_targets_are_kept_as_is() {
assert_eq!(
redirect("/users?page=2#top").location(),
"/users?page=2#top"
);
}
#[test]
fn non_ascii_targets_are_percent_encoded() {
assert_eq!(redirect("/caf\u{e9}").location(), "/caf%C3%A9");
assert_eq!(
redirect_permanent("https://\u{4f8b}\u{3048}.jp/").location(),
"https://%E4%BE%8B%E3%81%88.jp/"
);
assert_eq!(see_other("/caf\u{e9}").location(), "/caf%C3%A9");
}
#[test]
fn control_characters_are_percent_encoded() {
assert_eq!(redirect("/a\r\nb\x7f").location(), "/a%0D%0Ab%7F");
}
#[test]
fn encoded_targets_are_not_encoded_twice() {
assert_eq!(
redirect("/caf%C3%A9?q=a%20b").location(),
"/caf%C3%A9?q=a%20b"
);
}
#[test]
fn the_location_converts_back_to_a_str() {
assert!(redirect("/caf\u{e9} \u{4f8b}").location().to_str().is_ok());
}
#[test]
fn the_location_of_a_redirect_error_is_extracted() {
assert_eq!(
redirect_location(redirect("/target").into()).unwrap(),
"/target"
);
assert_eq!(
redirect_location(see_other("/target").into()).unwrap(),
"/target"
);
}
#[test]
fn other_errors_are_handed_back() {
let error = redirect_location(std::fmt::Error.into()).unwrap_err();
assert!(error.downcast_ref::<std::fmt::Error>().is_some());
}
}