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}