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#[derive(Debug, Clone, Copy, Default)]
16pub struct CookieLayer;
17
18impl CookieLayer {
19 #[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 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
53pub trait RouterBuilderCookieExt {
59 #[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 #[derive(Clone, Copy)]
90 enum Outcome {
91 Response,
92 Redirect,
93 Unauthorized,
94 }
95
96 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 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 #[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 #[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 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 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 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 let cx = detach(true).await;
245 cookies(&cx).add(("theme", "dark"));
246 }
247}