Skip to main content

lino_rest_api/
middleware.rs

1//! The request and response halves of the specification, expressed as plain
2//! functions: body decoding and content negotiation (section 2.1), entity tags
3//! (section 7) and problem details (section 5).
4//!
5//! [`crate::app::LinoApp`] is built from these helpers, and any other HTTP stack
6//! can be wired to the same behaviour by calling them directly.
7
8use lino_objects_codec::LinoValue;
9
10use crate::codec::{decode_from, encode_for};
11use crate::etag::{compute_etag, evaluate_preconditions};
12use crate::headers::{Headers, append_list_value};
13use crate::media_type::{
14    LINO_CONTENT_TYPE, is_decodable_media_type, negotiate_media_type, parse_content_type,
15    problem_media_type, supported_media_types, with_charset,
16};
17use crate::problem::LinoHttpError;
18use crate::response::Body;
19use crate::value::{array, string};
20
21/// Largest request body accepted by default, in bytes.
22pub const DEFAULT_MAX_BODY_BYTES: usize = 1024 * 1024;
23
24/// A response ready to be written to the wire.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ResponseParts {
27    /// HTTP status code.
28    pub status: u16,
29    /// Response headers.
30    pub headers: Headers,
31    /// Encoded body; empty for `204` and `304`.
32    pub body: String,
33}
34
35impl ResponseParts {
36    /// A response with a status, headers and a body.
37    pub fn new(status: u16, headers: Headers, body: impl Into<String>) -> Self {
38        Self {
39            status,
40            headers,
41            body: body.into(),
42        }
43    }
44}
45
46/// Add a field name to `Vary` without repeating it.
47///
48/// Both content negotiation and CORS extend `Vary`; a plain assignment from
49/// either of them would silently drop the other one's contribution.
50pub fn append_vary(headers: &mut Headers, field_name: &str) {
51    append_list_value(headers, "Vary", field_name);
52}
53
54/// Select the representation to produce for a request.
55///
56/// # Errors
57///
58/// A `406` when no supported representation is acceptable, carrying the list of
59/// the ones that are.
60pub fn negotiate_request(
61    headers: &Headers,
62    supported: Option<&[String]>,
63) -> Result<String, LinoHttpError> {
64    let candidates = supported
65        .map(<[String]>::to_vec)
66        .unwrap_or_else(supported_media_types);
67    let accept = headers.get("accept");
68    negotiate_media_type(accept, &candidates).ok_or_else(|| {
69        LinoHttpError::new(
70            406,
71            Some(&format!(
72                "No acceptable representation for Accept: {}",
73                accept.unwrap_or("")
74            )),
75        )
76        .with_extension("supported", array(candidates.iter().cloned().map(string)))
77    })
78}
79
80/// A decoded request body, together with the media type it arrived in.
81#[derive(Debug, Clone, PartialEq)]
82pub struct DecodedBody {
83    /// The value, or [`None`] when the request carried no body.
84    pub value: Option<LinoValue>,
85    /// The media type the body arrived in, when it carried one.
86    pub media_type: Option<String>,
87}
88
89/// Decode a request body according to its `Content-Type`.
90///
91/// # Errors
92///
93/// A `413` when the body is too large, a `415` when its media type is not one
94/// this API reads, and a `400` when it is malformed.
95pub fn decode_request_body(
96    raw: &[u8],
97    content_type: Option<&str>,
98    max_bytes: usize,
99) -> Result<DecodedBody, LinoHttpError> {
100    if raw.len() > max_bytes {
101        return Err(LinoHttpError::new(
102            413,
103            Some(&format!("Request body exceeds {max_bytes} bytes")),
104        )
105        .with_header("Connection", "close"));
106    }
107
108    let media_type = parse_content_type(content_type);
109    if media_type.is_empty() {
110        return Ok(DecodedBody {
111            value: None,
112            media_type: None,
113        });
114    }
115
116    if !is_decodable_media_type(&media_type) {
117        return Err(LinoHttpError::new(
118            415,
119            Some(&format!("Unsupported request media type: {media_type}")),
120        )
121        .with_extension(
122            "supported",
123            array(supported_media_types().into_iter().map(string)),
124        ));
125    }
126
127    let text = String::from_utf8_lossy(raw);
128    if text.trim().is_empty() {
129        return Ok(DecodedBody {
130            value: None,
131            media_type: Some(media_type),
132        });
133    }
134
135    match decode_from(&text, &media_type) {
136        Ok(value) => Ok(DecodedBody {
137            value: Some(value),
138            media_type: Some(media_type),
139        }),
140        Err(error) => Err(LinoHttpError::new(
141            400,
142            Some(&format!("Malformed {media_type} request body: {error}")),
143        )),
144    }
145}
146
147/// How a response should treat entity tags and conditional requests.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct EtagPolicy {
150    /// Whether to emit an `ETag`.
151    pub etag: bool,
152    /// Whether to evaluate conditional headers; [`None`] means safe methods only.
153    pub preconditions: Option<bool>,
154    /// Whether an `If-Match` is required on unsafe methods.
155    pub require_precondition: bool,
156}
157
158impl Default for EtagPolicy {
159    fn default() -> Self {
160        Self {
161            etag: true,
162            preconditions: None,
163            require_precondition: false,
164        }
165    }
166}
167
168/// Encode a value as the negotiated representation.
169///
170/// # Errors
171///
172/// A `412` or a `428` when a precondition fails, and a `500` when the value
173/// cannot be encoded as the negotiated representation.
174pub fn build_response(
175    body: &Body,
176    status: u16,
177    media_type: &str,
178    headers: &Headers,
179    request_headers: &Headers,
180    method: &str,
181    policy: EtagPolicy,
182) -> Result<ResponseParts, LinoHttpError> {
183    let mut response_headers = headers.clone();
184    append_vary(&mut response_headers, "Accept");
185
186    if status == 204 || matches!(body, Body::Empty) {
187        let status = if status == 200 { 204 } else { status };
188        return Ok(ResponseParts::new(status, response_headers, ""));
189    }
190
191    let encoded = match body {
192        Body::Raw(text) => text.clone(),
193        Body::Value(value) => encode_for(value, media_type)
194            .map_err(|error| LinoHttpError::new(500, Some(&error.to_string())))?,
195        Body::Empty => unreachable!("handled above"),
196    };
197
198    if policy.etag {
199        let tag = compute_etag(&encoded);
200        response_headers.insert("ETag", tag.clone());
201
202        // Preconditions on unsafe methods have to be evaluated against the
203        // *current* representation before the change is applied, which only the
204        // route handler can do; here the body is already the new representation.
205        let safe = method == "GET" || method == "HEAD";
206        let evaluate = policy.preconditions.unwrap_or(safe);
207        if evaluate
208            && evaluate_preconditions(request_headers, method, &tag, policy.require_precondition)?
209                .not_modified
210        {
211            return Ok(ResponseParts::new(304, response_headers, ""));
212        }
213    }
214
215    response_headers.insert("Content-Type", with_charset(media_type));
216    Ok(ResponseParts::new(status, response_headers, encoded))
217}
218
219/// Render an error as problem details (specification section 5).
220///
221/// # Examples
222///
223/// ```
224/// use lino_rest_api::middleware::build_problem_response;
225/// use lino_rest_api::problem::LinoHttpError;
226///
227/// let parts = build_problem_response(
228///     &LinoHttpError::new(404, Some("No task 7")),
229///     "text/lino",
230///     Some("/tasks/7"),
231/// );
232/// assert_eq!(parts.status, 404);
233/// assert_eq!(
234///     parts.headers.get("content-type"),
235///     Some("application/problem+lino; charset=utf-8"),
236/// );
237/// ```
238pub fn build_problem_response(
239    error: &LinoHttpError,
240    media_type: &str,
241    instance: Option<&str>,
242) -> ResponseParts {
243    let problem = error.to_problem(instance);
244    let mut headers = Headers::new();
245    for (name, value) in &error.headers {
246        headers.insert(name.clone(), value.clone());
247    }
248    append_vary(&mut headers, "Accept");
249    headers.insert("Content-Type", with_charset(problem_media_type(media_type)));
250
251    // The problem itself must reach the client even when the negotiated
252    // representation is the one that failed, so encoding falls back to LINO.
253    let body = encode_for(&problem, media_type)
254        .or_else(|_| encode_for(&problem, LINO_CONTENT_TYPE))
255        .unwrap_or_else(|_| String::new());
256
257    ResponseParts::new(error.status, headers, body)
258}