axum_htmx/responders/
location.rs

1use axum_core::response::{IntoResponseParts, ResponseParts};
2use http::HeaderValue;
3
4use crate::{HxError, headers};
5
6/// The `HX-Location` header.
7///
8/// This response header can be used to trigger a client side redirection
9/// without reloading the whole page. If you intend to redirect to a specific
10/// target on the page, you must enable the `serde` feature flag and specify
11/// [`LocationOptions`].
12///
13/// Will fail if the supplied uri contains characters that are not visible ASCII
14/// (32-127).
15///
16/// See <https://htmx.org/headers/hx-location/> for more information.
17#[derive(Debug, Clone)]
18pub struct HxLocation {
19    /// Uri of the new location.
20    pub uri: String,
21    /// Extra options.
22    #[cfg(feature = "serde")]
23    #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
24    pub options: LocationOptions,
25}
26
27impl HxLocation {
28    /// Parses `uri` and sets it as location.
29    #[allow(clippy::should_implement_trait)]
30    pub fn from_str(uri: impl AsRef<str>) -> Self {
31        Self {
32            #[cfg(feature = "serde")]
33            options: LocationOptions::default(),
34            uri: uri.as_ref().to_string(),
35        }
36    }
37
38    /// Parses `uri` and sets it as location with additional options.
39    #[cfg(feature = "serde")]
40    #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
41    pub fn from_str_with_options(uri: impl AsRef<str>, options: LocationOptions) -> Self {
42        Self {
43            options,
44            uri: uri.as_ref().to_string(),
45        }
46    }
47
48    #[cfg(feature = "serde")]
49    fn into_header_with_options(self) -> Result<String, HxError> {
50        if self.options.is_default() {
51            return Ok(self.uri.to_string());
52        }
53
54        #[derive(::serde::Serialize)]
55        struct LocWithOpts {
56            path: String,
57            #[serde(flatten)]
58            opts: LocationOptions,
59        }
60
61        let loc_with_opts = LocWithOpts {
62            path: self.uri.to_string(),
63            opts: self.options,
64        };
65
66        Ok(serde_json::to_string(&loc_with_opts)?)
67    }
68}
69
70impl<'a> From<&'a str> for HxLocation {
71    fn from(uri: &'a str) -> Self {
72        Self::from_str(uri)
73    }
74}
75
76#[cfg(feature = "serde")]
77#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
78impl<'a> From<(&'a str, LocationOptions)> for HxLocation {
79    fn from((uri, options): (&'a str, LocationOptions)) -> Self {
80        Self::from_str_with_options(uri, options)
81    }
82}
83
84impl IntoResponseParts for HxLocation {
85    type Error = HxError;
86
87    fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
88        #[cfg(feature = "serde")]
89        let header = self.into_header_with_options()?;
90        #[cfg(not(feature = "serde"))]
91        let header = self.uri.to_string();
92
93        res.headers_mut().insert(
94            headers::HX_LOCATION,
95            HeaderValue::from_maybe_shared(header)?,
96        );
97
98        Ok(res)
99    }
100}
101
102/// More options for `HX-Location` header.
103///
104/// - `source` - the source element of the request
105/// - `event` - an event that “triggered” the request
106/// - `handler` - a callback that will handle the response HTML
107/// - `target` - the target to swap the response into
108/// - `swap` - how the response will be swapped in relative to the target
109/// - `values` - values to submit with the request
110/// - `headers` - headers to submit with the request
111/// - `select` - allows you to select the content you want swapped from a
112///   response
113#[cfg(feature = "serde")]
114#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
115#[derive(Debug, Clone, serde::Serialize, Default)]
116pub struct LocationOptions {
117    /// The source element of the request.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub source: Option<String>,
120    /// An event that "triggered" the request.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub event: Option<String>,
123    /// A callback that will handle the response HTML.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub handler: Option<String>,
126    /// The target to swap the response into.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub target: Option<String>,
129    /// How the response will be swapped in relative to the target.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub swap: Option<crate::SwapOption>,
132    /// Values to submit with the request.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub values: Option<serde_json::Value>,
135    /// Headers to submit with the request.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub headers: Option<serde_json::Value>,
138    // Hacky way of making this struct non-exhaustive.
139    // See <https://rust-lang.github.io/rfcs/2008-non-exhaustive.html> and <https://github.com/robertwayne/axum-htmx/issues/29> for reasoning.
140    #[serde(skip)]
141    pub non_exhaustive: (),
142}
143
144#[cfg(feature = "serde")]
145#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
146impl LocationOptions {
147    pub(super) fn is_default(&self) -> bool {
148        let Self {
149            source: None,
150            event: None,
151            handler: None,
152            target: None,
153            swap: None,
154            values: None,
155            headers: None,
156            non_exhaustive: (),
157        } = self
158        else {
159            return false;
160        };
161
162        true
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    #[cfg(feature = "serde")]
172    fn test_serialize_location() {
173        use crate::SwapOption;
174
175        let loc = HxLocation::from("/foo");
176        assert_eq!(loc.into_header_with_options().unwrap(), "/foo");
177
178        let loc = HxLocation::from_str_with_options(
179            "/foo",
180            LocationOptions {
181                event: Some("click".into()),
182                swap: Some(SwapOption::InnerHtml),
183                ..Default::default()
184            },
185        );
186        assert_eq!(
187            loc.into_header_with_options().unwrap(),
188            r#"{"path":"/foo","event":"click","swap":"innerHTML"}"#
189        );
190    }
191}