1use std::cell::RefCell;
23
24use crate::property_store::PropertyStore;
25
26pub const METHOD_SIGNAL: &str = "request.method";
28
29pub const PATH_SIGNAL: &str = "request.path";
32
33pub const QUERY_SIGNAL: &str = "request.query";
36
37pub const HASH_SIGNAL: &str = "request.hash";
42
43pub const SECURE_SIGNAL: &str = "request.secure";
45
46#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct RequestContext {
52 pub method: String,
54 pub path: String,
56 pub query: String,
58 pub hash: String,
60 pub secure: bool,
62 pub headers: Vec<(String, String)>,
64 pub cookies: Vec<(String, String)>,
66 pub body: String,
68}
69
70impl RequestContext {
71 pub fn get(path: impl Into<String>) -> Self {
73 Self {
74 method: "GET".to_string(),
75 path: path.into(),
76 ..Self::default()
77 }
78 }
79
80 pub fn header(&self, name: &str) -> Option<&str> {
83 self.headers
84 .iter()
85 .find(|(key, _)| key.eq_ignore_ascii_case(name))
86 .map(|(_, value)| value.as_str())
87 }
88
89 pub fn cookie(&self, name: &str) -> Option<&str> {
91 self.cookies
92 .iter()
93 .find(|(key, _)| key.eq_ignore_ascii_case(name))
94 .map(|(_, value)| value.as_str())
95 }
96
97 pub fn publish(&self, store: &mut PropertyStore) {
102 store.set_global_str(METHOD_SIGNAL, self.method.as_str());
103 store.set_global_str(PATH_SIGNAL, self.path.as_str());
104 store.set_global_str(QUERY_SIGNAL, self.query.as_str());
105 store.set_global_str(HASH_SIGNAL, self.hash.as_str());
106 store.set_global_bool(SECURE_SIGNAL, self.secure);
107 }
108}
109
110pub fn parse_cookies(header: &str) -> Vec<(String, String)> {
115 header
116 .split(';')
117 .filter_map(|pair| {
118 let (name, value) = pair.split_once('=')?;
119 let name = name.trim();
120 if name.is_empty() {
121 return None;
122 }
123 Some((name.to_string(), value.trim().to_string()))
124 })
125 .collect()
126}
127
128thread_local! {
129 static CURRENT: RefCell<Option<RequestContext>> = const { RefCell::new(None) };
133}
134
135#[derive(Debug)]
141pub struct Scope(Option<RequestContext>);
142
143impl Drop for Scope {
144 fn drop(&mut self) {
145 let previous = self.0.take();
146 CURRENT.with(|current| *current.borrow_mut() = previous);
147 }
148}
149
150pub fn enter(context: RequestContext) -> Scope {
152 Scope(install(context))
153}
154
155pub fn install(context: RequestContext) -> Option<RequestContext> {
163 CURRENT.with(|current| current.borrow_mut().replace(context))
164}
165
166fn with_current<T>(read: impl FnOnce(&RequestContext) -> T) -> Option<T> {
168 CURRENT.with(|current| current.borrow().as_ref().map(read))
169}
170
171pub fn current() -> Option<RequestContext> {
173 with_current(Clone::clone)
174}
175
176pub fn header(name: &str) -> String {
179 with_current(|request| request.header(name).unwrap_or_default().to_string()).unwrap_or_default()
180}
181
182pub fn cookie(name: &str) -> String {
184 with_current(|request| request.cookie(name).unwrap_or_default().to_string()).unwrap_or_default()
185}
186
187pub fn body() -> String {
189 with_current(|request| request.body.clone()).unwrap_or_default()
190}
191
192pub fn query() -> String {
195 with_current(|request| request.query.clone()).unwrap_or_default()
196}
197
198pub fn hash() -> String {
200 with_current(|request| request.hash.clone()).unwrap_or_default()
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 fn context() -> RequestContext {
208 RequestContext {
209 method: "POST".to_string(),
210 path: "/user/42".to_string(),
211 query: "tab=posts".to_string(),
212 hash: String::new(),
213 secure: true,
214 headers: vec![("accept-language".to_string(), "en-GB".to_string())],
215 cookies: vec![("session".to_string(), "abc".to_string())],
216 body: "{\"name\":\"ada\"}".to_string(),
217 }
218 }
219
220 #[test]
221 fn the_reserved_cells_carry_the_address() {
222 let mut store = PropertyStore::default();
223 context().publish(&mut store);
224 assert_eq!(
225 store.get_global_str(PATH_SIGNAL).as_deref(),
226 Some("/user/42")
227 );
228 assert_eq!(
229 store.get_global_str(QUERY_SIGNAL).as_deref(),
230 Some("tab=posts")
231 );
232 assert_eq!(store.get_global_str(METHOD_SIGNAL).as_deref(), Some("POST"));
233 assert_eq!(store.get_global_bool(SECURE_SIGNAL), Some(true));
234 }
235
236 #[test]
237 fn a_header_is_found_whatever_case_it_is_asked_for_in() {
238 let request = context();
239 assert_eq!(request.header("Accept-Language"), Some("en-GB"));
240 assert_eq!(request.header("authorization"), None);
241 }
242
243 #[test]
244 fn a_cookie_jar_splits_into_pairs() {
245 let jar = parse_cookies("session=abc; theme=dark; broken");
246 assert_eq!(
247 jar,
248 vec![
249 ("session".to_string(), "abc".to_string()),
250 ("theme".to_string(), "dark".to_string())
251 ]
252 );
253 }
254
255 #[test]
256 fn nothing_is_readable_outside_a_render() {
257 assert_eq!(header("accept-language"), "");
258 assert_eq!(cookie("session"), "");
259 assert_eq!(body(), "");
260 assert_eq!(query(), "");
261 assert!(current().is_none());
262 }
263
264 #[test]
265 fn a_scope_reaches_the_render_and_ends_with_it() {
266 {
267 let _scope = enter(context());
268 assert_eq!(header("accept-language"), "en-GB");
269 assert_eq!(cookie("session"), "abc");
270 assert_eq!(body(), "{\"name\":\"ada\"}");
271 assert_eq!(query(), "tab=posts");
272 assert_eq!(hash(), "");
273 }
274 assert_eq!(header("accept-language"), "");
275 }
276
277 #[test]
278 fn an_inner_render_leaves_the_outer_one_in_place() {
279 let _outer = enter(RequestContext::get("/outer"));
280 {
281 let _inner = enter(RequestContext::get("/inner"));
282 assert_eq!(current().map(|r| r.path), Some("/inner".to_string()));
283 }
284 assert_eq!(current().map(|r| r.path), Some("/outer".to_string()));
285 }
286}