Skip to main content

topcoat_cookie/
router.rs

1use http::HeaderMap;
2use topcoat_core::context::Cx;
3use topcoat_router::{
4    Body, Layer, LayerFuture, Next, Path, RouterBuilder, response::response_headers,
5};
6
7use crate::{CookieJarCell, write_cookies};
8
9/// A router layer that makes cookies available for the current request and
10/// writes pending cookie changes onto the response.
11///
12/// The changes reach the client whether the handler returns a response or an
13/// error: a cookie added before a redirect or an unauthorized error is set by
14/// the redirect or error response.
15#[derive(Debug, Clone, Copy, Default)]
16pub struct CookieLayer;
17
18impl CookieLayer {
19    /// Creates a cookie layer.
20    #[must_use]
21    pub const fn new() -> Self {
22        Self
23    }
24}
25
26impl Layer for CookieLayer {
27    fn path(&self) -> Option<&Path> {
28        Some(Path::new("/"))
29    }
30
31    fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
32        Box::pin(async move {
33            let cx = cx.with(CookieJarCell::new());
34
35            match next.run(&cx, body).await {
36                Ok(mut response) => {
37                    write_cookies(&cx, response.headers_mut());
38                    Ok(response)
39                }
40                // The error's response does not exist yet, so the cookies
41                // wait in the router's slot and land on it once it is built.
42                Err(error) => {
43                    let mut headers = HeaderMap::new();
44                    write_cookies(&cx, &mut headers);
45                    response_headers(&cx).extend(headers);
46                    Err(error)
47                }
48            }
49        })
50    }
51}
52
53/// Installs cookie support on a [`RouterBuilder`].
54///
55/// Register it after other same-path layers that should be able to call
56/// [`cookies`](crate::cookies), because the most recently registered root
57/// layer runs first.
58pub trait RouterBuilderCookieExt {
59    /// Registers the root cookie layer.
60    ///
61    /// The layer stores the request's cookie jar in request context, parses the
62    /// incoming `Cookie` headers on first access, and appends pending changes as
63    /// `Set-Cookie` headers before the response is sent.
64    #[must_use]
65    fn cookies(self) -> Self;
66}
67
68impl RouterBuilderCookieExt for RouterBuilder {
69    fn cookies(self) -> Self {
70        self.layer(CookieLayer::new())
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use std::sync::{Arc, Mutex};
77
78    use http::{Method, Request, StatusCode, header};
79    use topcoat_core::{context::Cx, error::Result};
80    use topcoat_router::{
81        Body, Methods, Path, Route, RouteFuture, RouteId, Router,
82        error::{redirect, unauthorized},
83        response::Response,
84    };
85
86    use crate::{Cookies, RouterBuilderCookieExt, cookies};
87
88    /// What the route returns after adding its cookie.
89    #[derive(Clone, Copy)]
90    enum Outcome {
91        Response,
92        Redirect,
93        Unauthorized,
94    }
95
96    /// Adds a cookie, then ends the request with the configured outcome.
97    struct AddCookie(Outcome);
98
99    impl Route for AddCookie {
100        fn id(&self) -> RouteId {
101            static ID: std::sync::LazyLock<RouteId> = std::sync::LazyLock::new(RouteId::new);
102            *ID
103        }
104
105        fn methods(&self) -> Methods<'_> {
106            Methods::Only(&[Method::GET])
107        }
108
109        fn path(&self) -> &Path {
110            Path::new("/")
111        }
112
113        fn handle<'cx>(&'cx self, cx: &'cx Cx, _body: Body) -> RouteFuture<'cx> {
114            Box::pin(async move {
115                cookies(cx).add(("theme", "dark"));
116                match self.0 {
117                    Outcome::Response => Ok(Response::new(Body::empty())),
118                    Outcome::Redirect => Err(redirect("/users").into()),
119                    Outcome::Unauthorized => Err(unauthorized().into()),
120                }
121            })
122        }
123    }
124
125    /// Sends a `GET /` through a router serving `route` behind the cookie
126    /// layer and returns the status and `Set-Cookie` header of the response.
127    async fn send(route: AddCookie) -> (StatusCode, Option<String>) {
128        let router = Router::builder().route(route).cookies().build();
129        let request = Request::builder()
130            .uri("/")
131            .body(Body::empty())
132            .expect("request should build");
133
134        let response = router.handle(request).await;
135
136        let set_cookie = response
137            .headers()
138            .get(header::SET_COOKIE)
139            .and_then(|value| value.to_str().ok())
140            .map(str::to_owned);
141        (response.status(), set_cookie)
142    }
143
144    #[tokio::test]
145    async fn layer_writes_pending_cookies() -> Result<()> {
146        let (status, set_cookie) = send(AddCookie(Outcome::Response)).await;
147
148        assert_eq!(status, StatusCode::OK);
149        assert_eq!(set_cookie.as_deref(), Some("theme=dark"));
150        Ok(())
151    }
152
153    /// A redirect travels as an error, and its response is only built after
154    /// the layer has run. The cookie has to be on that response all the same,
155    /// or a handler cannot set a cookie and redirect in one step.
156    #[tokio::test]
157    async fn a_redirect_error_keeps_the_pending_cookies() -> Result<()> {
158        let (status, set_cookie) = send(AddCookie(Outcome::Redirect)).await;
159
160        assert_eq!(status, StatusCode::TEMPORARY_REDIRECT);
161        assert_eq!(set_cookie.as_deref(), Some("theme=dark"));
162        Ok(())
163    }
164
165    /// Any error response keeps the cookies, not only a redirect: a 401 that
166    /// clears a stale session cookie is the case this guards.
167    #[tokio::test]
168    async fn an_error_response_keeps_the_pending_cookies() -> Result<()> {
169        let (status, set_cookie) = send(AddCookie(Outcome::Unauthorized)).await;
170
171        assert_eq!(status, StatusCode::UNAUTHORIZED);
172        assert_eq!(set_cookie.as_deref(), Some("theme=dark"));
173        Ok(())
174    }
175
176    /// Hands an owned context handle back to the test, standing in for work
177    /// that outlives the handler, such as a streaming body or a WebSocket task.
178    /// The handler fails when `error` is set, so the test can check that the
179    /// error path seals the jar too.
180    struct Detach {
181        handle: Arc<Mutex<Option<Cx>>>,
182        error: bool,
183    }
184
185    impl Route for Detach {
186        fn id(&self) -> RouteId {
187            static ID: std::sync::LazyLock<RouteId> = std::sync::LazyLock::new(RouteId::new);
188            *ID
189        }
190
191        fn methods(&self) -> Methods<'_> {
192            Methods::Only(&[Method::GET])
193        }
194
195        fn path(&self) -> &Path {
196            Path::new("/")
197        }
198
199        fn handle<'cx>(&'cx self, cx: &'cx Cx, _body: Body) -> RouteFuture<'cx> {
200            Box::pin(async move {
201                *self.handle.lock().expect("lock should not be poisoned") = Some(cx.clone());
202                if self.error {
203                    return Err(unauthorized().into());
204                }
205                Ok(Response::new(Body::empty()))
206            })
207        }
208    }
209
210    /// Runs a detaching route to completion and returns the handle it kept.
211    async fn detach(error: bool) -> Cx {
212        let handle = Arc::new(Mutex::new(None));
213        let router = Router::builder()
214            .route(Detach {
215                handle: Arc::clone(&handle),
216                error,
217            })
218            .cookies()
219            .build();
220        let request = Request::builder()
221            .uri("/")
222            .body(Body::empty())
223            .expect("request should build");
224
225        let _ = router.handle(request).await;
226        let cx = handle.lock().expect("lock should not be poisoned").take();
227        cx.expect("route should have stored a handle")
228    }
229
230    #[tokio::test]
231    #[should_panic(expected = "cannot add a cookie after the response")]
232    async fn writing_once_the_layer_is_done_panics() {
233        // The layer has written its `Set-Cookie` headers by the time `handle`
234        // returns, so this cookie could never reach the client.
235        let cx = detach(false).await;
236        cookies(&cx).add(("theme", "dark"));
237    }
238
239    #[tokio::test]
240    #[should_panic(expected = "cannot add a cookie after the response")]
241    async fn writing_once_the_layer_is_done_with_an_error_panics() {
242        // The error path hands its cookies over just the same, so a write
243        // after it would go nowhere as well.
244        let cx = detach(true).await;
245        cookies(&cx).add(("theme", "dark"));
246    }
247}