Skip to main content

lumen_core/
request.rs

1//! The request a document is being produced for.
2//!
3//! A page rendered on a server is rendered for somebody: a method, an address,
4//! a query string, the headers that came with it. This is where that arrives,
5//! in the two shapes an app reads it in.
6//!
7//! The parts every surface can read are reserved [`PropertyStore`] cells, the
8//! same mechanism [`crate::nav`] publishes the active page through. Markup
9//! binds to them by name, a derivation reads them, and every script host sees
10//! them without a builtin of its own.
11//!
12//! The parts that are too large or too sensitive to publish as signals - the
13//! headers, the cookies and the body - stay in a [`RequestContext`] the
14//! renderer installs on its own thread for the length of one render. A script
15//! asks for a named header or a named cookie and gets that one, so a page
16//! never carries the whole `Cookie` header around in its state.
17//!
18//! Nothing here decides which headers an app may see. The renderer that builds
19//! the context applies that policy, and what arrives here is what the app is
20//! allowed to read.
21
22use std::cell::RefCell;
23
24use crate::property_store::PropertyStore;
25
26/// Reserved global signal holding the request method, uppercased (`GET`).
27pub const METHOD_SIGNAL: &str = "request.method";
28
29/// Reserved global signal holding the requested path, without the query
30/// string (`/user/42`).
31pub const PATH_SIGNAL: &str = "request.path";
32
33/// Reserved global signal holding the query string, without the leading `?`
34/// (`page=2&sort=name`).
35pub const QUERY_SIGNAL: &str = "request.query";
36
37/// Reserved global signal holding the fragment, without the leading `#`.
38///
39/// A browser keeps the fragment to itself, so this is empty on a server
40/// unless the caller knows it from somewhere else.
41pub const HASH_SIGNAL: &str = "request.hash";
42
43/// Reserved global signal that is true when the request arrived over TLS.
44pub const SECURE_SIGNAL: &str = "request.secure";
45
46/// What is known about the request being rendered for.
47///
48/// Header and cookie names are compared without regard to case, which is what
49/// HTTP says they mean.
50#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct RequestContext {
52    /// Request method, uppercased.
53    pub method: String,
54    /// Requested path, without the query string.
55    pub path: String,
56    /// Query string, without the leading `?`.
57    pub query: String,
58    /// Fragment, without the leading `#`.
59    pub hash: String,
60    /// Whether the request arrived over TLS.
61    pub secure: bool,
62    /// The headers the app may read, in the order they arrived.
63    pub headers: Vec<(String, String)>,
64    /// The cookies the app may read, in the order they arrived.
65    pub cookies: Vec<(String, String)>,
66    /// The request body, empty when there was none.
67    pub body: String,
68}
69
70impl RequestContext {
71    /// A context for a `GET` of `path`.
72    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    /// The value of a header, or `None` when it did not arrive or the app is
81    /// not allowed to read it.
82    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    /// The value of a cookie, or `None` when it did not arrive.
90    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    /// Write the reserved cells, so markup and derivations can read them.
98    ///
99    /// Called before the app's first tick, which is what lets an `on_start`
100    /// branch on the address it was asked for.
101    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
110/// Split a `Cookie` header, or a browser's `document.cookie`, into pairs.
111///
112/// Both spell a cookie jar the same way: `name=value` pairs separated by
113/// `;`. A pair with no `=` is skipped rather than guessed at.
114pub 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    /// The request this thread is rendering for. One render at a time per
130    /// thread is the whole of the bookkeeping: a renderer holds a thread for
131    /// the length of a render, and a browser has one thread and one page.
132    static CURRENT: RefCell<Option<RequestContext>> = const { RefCell::new(None) };
133}
134
135/// The request installed on this thread for as long as this value lives.
136///
137/// Dropping it puts back whatever was installed before, so a nested render
138/// (a renderer used from inside a request handler) leaves the outer one in
139/// place.
140#[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
150/// Install `context` on this thread until the returned [`Scope`] is dropped.
151pub fn enter(context: RequestContext) -> Scope {
152    Scope(install(context))
153}
154
155/// Install `context` on this thread for good, and give back whatever was
156/// there.
157///
158/// This is for a surface whose thread answers one address for its whole life:
159/// a page in a browser is loaded from an address and keeps it until it is
160/// navigated away from. A renderer answering one request after another uses
161/// [`enter`] instead, so each render gets its own.
162pub fn install(context: RequestContext) -> Option<RequestContext> {
163    CURRENT.with(|current| current.borrow_mut().replace(context))
164}
165
166/// Read something out of the request installed on this thread.
167fn with_current<T>(read: impl FnOnce(&RequestContext) -> T) -> Option<T> {
168    CURRENT.with(|current| current.borrow().as_ref().map(read))
169}
170
171/// The whole request installed on this thread, if there is one.
172pub fn current() -> Option<RequestContext> {
173    with_current(Clone::clone)
174}
175
176/// The value of a request header, empty when there is none to read or the app
177/// is not allowed to read it.
178pub fn header(name: &str) -> String {
179    with_current(|request| request.header(name).unwrap_or_default().to_string()).unwrap_or_default()
180}
181
182/// The value of a request cookie, empty when there is none to read.
183pub fn cookie(name: &str) -> String {
184    with_current(|request| request.cookie(name).unwrap_or_default().to_string()).unwrap_or_default()
185}
186
187/// The request body, empty when there was none.
188pub fn body() -> String {
189    with_current(|request| request.body.clone()).unwrap_or_default()
190}
191
192/// The query string of the address being rendered for, without the leading
193/// `?`.
194pub fn query() -> String {
195    with_current(|request| request.query.clone()).unwrap_or_default()
196}
197
198/// The fragment of the address being rendered for, without the leading `#`.
199pub 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}