Skip to main content

lino_rest_api/
request.rs

1//! The request object handlers receive.
2//!
3//! It is intentionally small: the method, the path, the decoded body, the
4//! negotiated representation and the parsed query.
5
6use lino_objects_codec::LinoValue;
7
8use crate::headers::Headers;
9use crate::problem::LinoHttpError;
10use crate::query::{CollectionQuery, QueryParams, form_decode, parse_collection_query};
11use crate::router::PathParams;
12
13/// Page-size defaults a request inherits from its application.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct QueryDefaults {
16    /// Page size used when a request does not ask for one.
17    pub default_limit: usize,
18    /// Largest page the server will serve.
19    pub max_limit: usize,
20}
21
22impl Default for QueryDefaults {
23    fn default() -> Self {
24        Self {
25            default_limit: crate::query::DEFAULT_LIMIT,
26            max_limit: crate::query::MAX_LIMIT,
27        }
28    }
29}
30
31/// One HTTP request, decoded according to the specification.
32#[derive(Debug, Clone)]
33pub struct LinoRequest {
34    /// HTTP method, upper-cased.
35    pub method: String,
36    /// Request path, without the query string.
37    pub path: String,
38    /// Request headers, looked up without regard to case.
39    pub headers: Headers,
40    /// Path parameters captured by the matched route.
41    pub params: PathParams,
42    /// Raw query string, without its leading `?`.
43    pub query_string: String,
44    /// Decoded request body, or [`None`] when the request carried none.
45    pub body: Option<LinoValue>,
46    /// The representation the response will be encoded as.
47    pub media_type: String,
48    /// The media type the request body arrived in.
49    pub request_media_type: Option<String>,
50    /// Page-size defaults inherited from the application.
51    pub query_defaults: QueryDefaults,
52}
53
54impl LinoRequest {
55    /// A request with a method and a path, and nothing else set.
56    pub fn new<M: Into<String>, P: Into<String>>(method: M, path: P) -> Self {
57        Self {
58            method: method.into().to_uppercase(),
59            path: path.into(),
60            headers: Headers::new(),
61            params: PathParams::new(),
62            query_string: String::new(),
63            body: None,
64            media_type: crate::media_type::LINO_CONTENT_TYPE.to_string(),
65            request_media_type: None,
66            query_defaults: QueryDefaults::default(),
67        }
68    }
69
70    /// Read a request header, case insensitively.
71    pub fn header(&self, name: &str) -> Option<&str> {
72        self.headers.get(name)
73    }
74
75    /// Read a path parameter, percent-decoded.
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use lino_rest_api::request::LinoRequest;
81    ///
82    /// let mut request = LinoRequest::new("GET", "/tasks/a%20b");
83    /// request.params.insert("id".to_string(), "a%20b".to_string());
84    /// assert_eq!(request.param("id").as_deref(), Some("a b"));
85    /// ```
86    pub fn param(&self, name: &str) -> Option<String> {
87        self.params.get(name).map(|value| percent_decode(value))
88    }
89
90    /// Query parameters, with repeated parameters kept in order.
91    pub fn query(&self) -> QueryParams {
92        QueryParams::parse(&self.query_string)
93    }
94
95    /// Parse the query string as a collection query (specification section 6).
96    ///
97    /// # Errors
98    ///
99    /// A `400` when `limit` or `offset` is not a non-negative integer.
100    pub fn collection_query(&self) -> Result<CollectionQuery, LinoHttpError> {
101        parse_collection_query(
102            &self.query(),
103            self.query_defaults.default_limit,
104            self.query_defaults.max_limit,
105        )
106    }
107
108    /// The request body, or a `400` when the request carried none.
109    ///
110    /// # Errors
111    ///
112    /// A `400` when the body is absent, which is what a `POST` without one is.
113    pub fn require_body(&self) -> Result<&LinoValue, LinoHttpError> {
114        self.body
115            .as_ref()
116            .ok_or_else(|| LinoHttpError::new(400, Some("A request body is required")))
117    }
118
119    /// The path together with its query string, as the `instance` of a problem.
120    pub fn instance(&self) -> String {
121        if self.query_string.is_empty() {
122            self.path.clone()
123        } else {
124            format!("{}?{}", self.path, self.query_string)
125        }
126    }
127}
128
129/// Percent-decode a path segment, leaving `+` alone.
130///
131/// A path is not a form, so `+` there is a plus sign, not a space.
132pub fn percent_decode(text: &str) -> String {
133    form_decode(&text.replace('+', "%2B"))
134}
135
136/// Percent-encode a path, leaving the segment separator alone.
137///
138/// This is what Python's `urllib.parse.quote` does with its default `safe="/"`,
139/// so an identifier appears in a `Location` header the same way in every
140/// implementation.
141///
142/// # Examples
143///
144/// ```
145/// use lino_rest_api::request::percent_encode;
146///
147/// assert_eq!(percent_encode("a b/c"), "a%20b/c");
148/// ```
149pub fn percent_encode(text: &str) -> String {
150    let mut encoded = String::with_capacity(text.len());
151    for byte in text.as_bytes() {
152        match byte {
153            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
154                encoded.push(*byte as char)
155            }
156            other => encoded.push_str(&format!("%{other:02X}")),
157        }
158    }
159    encoded
160}