unpoly/
lib.rs

1#[cfg(feature = "axum")]
2mod axum;
3mod headers;
4use std::collections::HashSet;
5
6use derive_more::{Display, From};
7use http::HeaderMap;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, From, Display)]
11pub enum Error {
12    #[from]
13    InvalidJson(serde_json::Error),
14    EventIsNotSerializableAsObject,
15}
16
17/// The mode of a layer
18///
19/// See <https://unpoly.com/layer-terminology>
20#[derive(Debug, Serialize, Deserialize, Default, PartialEq)]
21#[serde(rename_all = "lowercase")]
22pub enum LayerMode {
23    #[default]
24    /// The initial page
25    ROOT,
26    /// A modal dialog box
27    MODAL,
28    /// A drawer sliding in from the side
29    DRAWER,
30    /// A popup menu anchored to a link
31    POPUP,
32    ///An overlay covering the entire screen
33    COVER,
34}
35
36impl LayerMode {
37    /// Returns true if the layer is the root layer.
38    pub fn is_root(&self) -> bool {
39        self == &LayerMode::ROOT
40    }
41
42    /// Returns true if the layer is an overlay (ie is not the root layer).
43    pub fn is_overlay(&self) -> bool {
44        self != &LayerMode::ROOT
45    }
46}
47
48/// Method to match a layer relative to the current layer
49///
50/// See <https://unpoly.com/layer-option#matching-relative-to-the-current-layer>
51#[derive(Debug, Serialize, Deserialize, PartialEq)]
52#[serde(rename_all = "lowercase")]
53pub enum MatchingLayer {
54    /// The current layer
55    CURRENT,
56    /// The layer that opened the current layer
57    PARENT,
58    /// The current layer or any ancestor, preferring closer layers
59    CLOSEST,
60    /// Any overlay
61    OVERLAY,
62    /// Any ancestor layer of the current layer
63    ANCESTOR,
64    /// The child layer of the current layer
65    CHILD,
66    /// Any descendant of the current layer
67    DESCENDANT,
68    /// The current layer and its descendants
69    SUBTREE,
70    /// The layer at the given index, where 0 is the root layer
71    INDEX(u32),
72}
73
74/// An Unpoly object to process the request headers and set the response headers
75///
76/// When a request header is accessed, it is automatically added to the `Vary` response header.
77///
78/// Typical usages:
79///
80///```
81/// use axum::response::IntoResponse;
82/// use axum::extract;
83/// use serde::Deserialize;
84/// use serde_json::json;
85///
86/// ///  Omitting content that isn't targeted
87/// /// https://unpoly.com/optimizing-responses#omitting-content-that-isnt-targeted
88/// fn handler_target(mut unpoly: unpoly::Unpoly) -> impl IntoResponse {
89///     let target = unpoly.target();
90///     let html = todo!("render content for target only");
91///     (unpoly.get_headers().unwrap(), html)
92/// }
93///
94/// ///  Rendering different content for overlays
95/// /// https://unpoly.com/optimizing-responses#rendering-different-content-for-overlays
96/// fn handler_mode_target(mut unpoly: unpoly::Unpoly) -> impl IntoResponse {
97///     let mode = unpoly.mode();
98///     let target = unpoly.target();
99///     let html = todo!("render content for target in mode only");
100///     (unpoly.get_headers().unwrap(), html)
101/// }
102///
103/// /// Rendering different content for unpoly requests
104/// /// https://unpoly.com/optimizing-responses#rendering-different-content-for-unpoly-requests
105/// fn handler_full_or_fragment(mut unpoly: unpoly::Unpoly) -> impl IntoResponse {
106///     let html: String = if unpoly.is_up() {
107///         todo!("render for fragment update")
108///     } else {
109///         todo!("render for full page load")
110///     };
111///     (unpoly.get_headers().unwrap(), html)
112/// }
113///
114/// /// Rendering content that depends on layer context
115/// /// https://unpoly.com/optimizing-responses#rendering-content-that-depends-on-layer-context
116/// fn handler_context(mut unpoly: unpoly::Unpoly) -> impl IntoResponse {
117///     let context = unpoly.context();
118///     let html = todo!("render html for context");
119///     (unpoly.get_headers().unwrap(), html)
120/// }
121///
122/// /// Set the title of the page via a fragment update
123/// fn handler_title(mut unpoly: unpoly::Unpoly) -> impl IntoResponse {
124///     unpoly.set_title("My App");
125///     let html = todo!();
126///     (unpoly.get_headers().unwrap(), html)
127/// }
128///
129/// /// Send events to the frontend
130/// fn handler_emit_event(mut unpoly: unpoly::Unpoly) -> impl IntoResponse {
131///     unpoly.emit_event("user:created", json!({"id": 152}));
132///     // or for a specific layer
133///     unpoly.emit_event_layer("user:created", json!({"id": 152}), unpoly::MatchingLayer::CURRENT);
134///     let html = todo!();
135///     (unpoly.get_headers().unwrap(), html)
136/// }
137///
138/// /// Expire cache
139/// fn handler_cache(mut unpoly: unpoly::Unpoly) -> impl IntoResponse {
140///     unpoly.set_expire_cache("/path/to/expire/*");
141///     let html = todo!();
142///     (unpoly.get_headers().unwrap(), html)
143/// }
144///
145///
146/// /// Validating a form
147/// /// https://unpoly.com/up-validate
148/// #[derive(Deserialize)]
149///     struct SampleForm{
150///     name: String,
151///     email: String,
152/// }
153/// fn handler_validate(mut unpoly: unpoly::Unpoly, extract::Form(form): extract::Form<SampleForm>) -> impl IntoResponse {
154///     if !unpoly.validate().is_empty() {
155///         todo!("Validate form");
156///         let html = todo!("render form with optional errors");
157///         (unpoly.get_headers().unwrap(), html)
158///     } else {
159///         todo!("Process form");
160///         let html = todo!("render form with optional errors");
161///         (unpoly.get_headers().unwrap(), html)
162///     }
163/// }
164/// ```
165#[derive(Debug, Default)]
166pub struct Unpoly {
167    success: Option<bool>,
168    request_version: Option<String>,
169    request_context: Option<serde_json::Value>,
170    request_fail_context: Option<serde_json::Value>,
171    request_fail_mode: LayerMode,
172    request_mode: LayerMode,
173    request_target: Option<String>,
174    request_fail_target: Option<String>,
175    request_validate: Vec<String>,
176    response_context: Option<serde_json::Value>,
177    response_accept_layer: Option<serde_json::Value>,
178    response_dismiss_layer: Option<serde_json::Value>,
179    response_events: Vec<serde_json::Value>,
180    response_evict_cache: Option<String>,
181    response_expire_cache: Option<String>,
182    response_location: Option<String>,
183    response_method: Option<String>,
184    response_target: Option<String>,
185    response_title: Option<String>,
186    response_vary: HashSet<String>,
187}
188
189use serde_json::Value;
190
191impl Unpoly {
192    /// Returns true if the request is from an Unpoly client
193    ///
194    /// A request is from an Unpoly client if the `X-Up-Version` header is present
195    pub fn is_up(&mut self) -> bool {
196        if self.request_version.is_some() {
197            self.response_vary.insert("X-Up-Version".to_string());
198            true
199        } else {
200            false
201        }
202    }
203
204    /// Returns:
205    /// - Some(true) if we handle a success case
206    /// - Some(false) if we handle a failure case
207    /// - None if the success status is not known yet
208    pub fn success(&mut self) -> Option<bool> {
209        self.success
210    }
211
212    /// Set the status to success or fail
213    ///
214    /// This will also set
215    /// - `X-Up-Target` to the same value as `X-Up-[Fail]-Target`
216    /// - `mode()` will give the `X-Up[Fail]-Mode` value
217    pub fn set_success(&mut self, success: bool) {
218        self.success = Some(success);
219        if success {
220            self.response_vary.insert("X-Up-Target".to_string());
221            self.response_target = self.request_target.clone();
222        } else {
223            self.response_vary.insert("X-Up-Fail-Target".to_string());
224            self.response_target = self.request_fail_target.clone();
225        }
226    }
227
228    /// Returns the current mode
229    ///
230    /// This will return the X-Up-Mode unless success is false, in which case it will return the X-Up-Fail-Mode
231    pub fn mode(&mut self) -> &LayerMode {
232        if let Some(false) = self.success {
233            self.response_vary.insert("X-Up-Fail-Mode".to_string());
234            &self.request_fail_mode
235        } else {
236            self.response_vary.insert("X-Up-Mode".to_string());
237            &self.request_mode
238        }
239    }
240
241    pub fn emit_event_layer<S: Serialize>(
242        &mut self,
243        type_: impl Into<String>,
244        event: S,
245        matching_layer: MatchingLayer,
246    ) -> Result<(), Error> {
247        let mut event = serde_json::to_value(event)?;
248        if !event.is_object() {
249            return Err(Error::EventIsNotSerializableAsObject);
250        }
251
252        event.as_object_mut().unwrap().insert(
253            "layer".to_string(),
254            match matching_layer {
255                MatchingLayer::INDEX(index) => Value::Number(index.into()),
256                other => serde_json::to_value(other).unwrap(),
257            },
258        );
259
260        self.emit_event(type_, event)?;
261        Ok(())
262    }
263
264    pub fn accept_layer<S: Serialize>(&mut self, value: S) -> Result<(), Error> {
265        self.response_accept_layer = Some(serde_json::to_value(value)?);
266        self.response_dismiss_layer = None;
267        Ok(())
268    }
269
270    pub fn accept_layer_without_value(&mut self) -> Result<(), Error> {
271        self.accept_layer("null")?;
272        Ok(())
273    }
274
275    pub fn dismiss_layer<S: Serialize>(&mut self, value: S) -> Result<(), Error> {
276        self.response_dismiss_layer = Some(serde_json::to_value(value).unwrap());
277        self.response_accept_layer = None;
278        Ok(())
279    }
280
281    pub fn dismiss_layer_without_value(&mut self) -> Result<(), Error> {
282        self.dismiss_layer("null")?;
283        Ok(())
284    }
285
286    /// Get the X-Up-Context response header when set (via `set_context()``), or the X-Up-[Fail-]Context request header
287    /// when the response header is not set
288    pub fn context(&mut self) -> Option<&Value> {
289        if self.response_context.is_some() {
290            return self.response_context.as_ref();
291        }
292        if Some(false) == self.success {
293            if self.request_fail_context.is_some() && self.is_up() {
294                self.response_vary.insert("X-Up-Fail-Context".to_string());
295            }
296            self.request_fail_context.as_ref()
297        } else {
298            if self.request_context.is_some() && self.is_up() {
299                self.response_vary.insert("X-Up-Context".to_string());
300            }
301            self.request_context.as_ref()
302        }
303    }
304
305    pub fn set_context<S: Serialize>(&mut self, layer: S) {
306        self.response_context = Some(serde_json::to_value(layer).unwrap());
307    }
308
309    pub fn target(&mut self) -> Option<&str> {
310        if self.response_target.is_some() {
311            return self.response_target.as_deref();
312        }
313        if let Some(false) = self.success {
314            self.response_vary.insert("X-Up-Fail-Target".to_string());
315            self.request_fail_target.as_deref()
316        } else {
317            self.response_vary.insert("X-Up-Target".to_string());
318            self.request_target.as_deref()
319        }
320    }
321
322    pub fn set_target(&mut self, target: impl Into<String>) {
323        self.response_target = Some(target.into());
324    }
325
326    pub fn validate(&mut self) -> &Vec<String> {
327        if !self.request_validate.is_empty() && self.is_up() {
328            self.response_vary.insert("X-Up-Validate".to_string());
329        }
330        &self.request_validate
331    }
332
333    pub fn title(&self) -> Option<&str> {
334        self.response_title.as_deref()
335    }
336
337    pub fn set_title(&mut self, title: impl Into<String>) {
338        self.response_title = Some(title.into());
339    }
340
341    pub fn location(&self) -> Option<&str> {
342        self.response_location.as_deref()
343    }
344
345    pub fn set_location(&mut self, location: impl Into<String>) {
346        self.response_location = Some(location.into());
347    }
348
349    pub fn method(&mut self) -> Option<&str> {
350        self.response_method.as_deref()
351    }
352
353    pub fn set_method(&mut self, method: impl Into<String>) {
354        self.response_method = Some(method.into());
355    }
356
357    pub fn emit_event<S: Serialize>(
358        &mut self,
359        type_: impl Into<String>,
360        event: S,
361    ) -> Result<(), Error> {
362        let mut event = serde_json::to_value(event)?;
363        if !event.is_object() {
364            return Err(Error::EventIsNotSerializableAsObject);
365        }
366
367        let type_: String = type_.into();
368
369        event
370            .as_object_mut()
371            .unwrap()
372            .insert("type".to_string(), Value::String(type_));
373
374        self.response_events.push(event);
375        Ok(())
376    }
377
378    pub fn set_evict_cache(&mut self, cache: impl Into<String>) {
379        self.response_evict_cache = Some(cache.into());
380    }
381
382    pub fn set_expire_cache(&mut self, cache: impl Into<String>) {
383        self.response_expire_cache = Some(cache.into());
384    }
385
386    pub fn get_headers(&self) -> Result<HeaderMap, Error> {
387        let mut headers = HeaderMap::new();
388        if let Some(title) = &self.response_title {
389            headers.insert(headers::TITLE, title.parse().unwrap());
390        }
391        if let Some(location) = &self.response_location {
392            headers.insert(headers::LOCATION, location.parse().unwrap());
393        }
394        if let Some(accept_layer) = &self.response_accept_layer {
395            headers.insert(
396                headers::ACCEPT_LAYER,
397                serde_json::to_string(accept_layer)?.parse().unwrap(),
398            );
399        }
400        if let Some(dismiss_layer) = &self.response_dismiss_layer {
401            headers.insert(
402                headers::DISMISS_LAYER,
403                serde_json::to_string(dismiss_layer)?.parse().unwrap(),
404            );
405        }
406        if let Some(context) = &self.response_context {
407            headers.insert(
408                headers::CONTEXT,
409                serde_json::to_string(context)?.parse().unwrap(),
410            );
411        }
412        if let Some(target) = &self.response_target {
413            headers.insert(headers::TARGET, target.parse().unwrap());
414        }
415        if let Some(method) = &self.response_method {
416            headers.insert(headers::METHOD, method.parse().unwrap());
417        }
418        if let Some(evict_cache) = &self.response_evict_cache {
419            headers.insert(headers::EVICT_CACHE, evict_cache.parse().unwrap());
420        }
421        if let Some(expire_cache) = &self.response_expire_cache {
422            headers.insert(headers::EXPIRE_CACHE, expire_cache.parse().unwrap());
423        }
424        if !self.response_events.is_empty() {
425            let events = serde_json::to_value(&self.response_events)?;
426            headers.insert(
427                headers::EVENTS,
428                serde_json::to_string(&events)?.parse().unwrap(),
429            );
430        }
431        if !self.response_vary.is_empty() {
432            let mut vary: Vec<&String> = self.response_vary.iter().collect();
433            vary.sort();
434            let vary = vary.iter().fold(
435                "".to_string(),
436                |a, b| if !a.is_empty() { a + "," } else { a } + b,
437            );
438            headers.insert(headers::VARY, vary.parse().unwrap());
439        }
440        Ok(headers)
441    }
442}