Skip to main content

shared_framework/utils/
request_parser.rs

1//! Query, JSON, and multipart request parsing.
2//!
3//! [`RequestParser`] parses query parameters, JSON bodies, and
4//! `multipart/form-data` payloads (RFC 7578) into [`MultipartBody`] with text
5//! fields and [`UploadedFile`] entries. Multipart DTOs are conventionally
6//! decoded from the `body` form field; see [`CorrelationContext::body`](crate::logging::CorrelationContext::body).
7//!
8//! ```ignore
9//! let boundary = RequestParser::multipart_boundary(content_type).unwrap();
10//! let multipart = RequestParser::parse_multipart(&bytes, &boundary);
11//! let name: Option<&str> = multipart.field("name");
12//! ```
13use serde::de::DeserializeOwned;
14use std::collections::HashMap;
15
16/// Parsers for query strings, JSON bodies, and multipart payloads.
17pub struct RequestParser;
18
19impl RequestParser {
20    /// Parses `query` as form-urlencoded and converts the `key` value via `FromStr`.
21    /// Returns `None` when the key is absent or cannot be parsed.
22    pub fn query_param<T: std::str::FromStr>(query: &str, key: &str) -> Option<T> {
23        let params: HashMap<String, String> = serde_urlencoded::from_str(query).unwrap_or_default();
24        params.get(key).and_then(|v| v.parse().ok())
25    }
26
27    /// Deserializes a JSON body. Returns an error on invalid JSON or shape mismatch.
28    pub fn parse_body<T: DeserializeOwned>(body: &[u8]) -> Result<T, serde_json::Error> {
29        serde_json::from_slice(body)
30    }
31
32    /// Extracts the `boundary` from a `multipart/form-data; boundary=...` content type.
33    /// Returns `None` when absent, empty, or longer than 128 characters.
34    pub fn multipart_boundary(content_type: &str) -> Option<String> {
35        content_type.split(';').find_map(|part| {
36            let part = part.trim();
37            let (k, v) = part.split_once('=')?;
38            if k.trim().eq_ignore_ascii_case("boundary") {
39                let b = v.trim().trim_matches('"').trim().to_string();
40                if b.is_empty() || b.len() > 128 { None } else { Some(b) }
41            } else {
42                None
43            }
44        })
45    }
46
47    /// Parse a `multipart/form-data` payload (RFC 7578) into text fields and files.
48    /// Byte-oriented so file contents are never mangled by UTF-8 decoding.
49    pub fn parse_multipart(body: &[u8], boundary: &str) -> MultipartBody {
50        MultipartBody::parse(body, boundary)
51    }
52}
53
54/// A file received in a `multipart/form-data` part.
55#[derive(Debug, Clone)]
56pub struct UploadedFile {
57    /// The form field name (`name="..."`).
58    pub field_name: String,
59    /// The client-supplied file name, if any.
60    pub file_name: Option<String>,
61    /// The part's content type, if any.
62    pub content_type: Option<String>,
63    /// Raw file bytes.
64    pub bytes: Vec<u8>,
65}
66
67impl UploadedFile {
68    /// Returns the file size in bytes.
69    pub fn len(&self) -> usize {
70        self.bytes.len()
71    }
72
73    /// Returns true when the file holds no bytes.
74    pub fn is_empty(&self) -> bool {
75        self.bytes.is_empty()
76    }
77}
78
79/// Decoded `multipart/form-data` payload: text fields plus uploaded files.
80#[derive(Debug, Clone, Default)]
81pub struct MultipartBody {
82    /// Text fields; repeated names keep every value in order.
83    pub fields: HashMap<String, Vec<String>>,
84    /// Uploaded files in part order.
85    pub files: Vec<UploadedFile>,
86}
87
88/// Safety cap on part count per request (consistent with the framework
89/// collecting the whole body before dispatch; prevents delimiter abuse).
90const MAX_PARTS: usize = 512;
91
92impl MultipartBody {
93    /// Parses a raw multipart payload with the given boundary.
94    /// Returns an empty body for an empty boundary or payload; at most 512 parts are kept.
95    pub fn parse(body: &[u8], boundary: &str) -> Self {
96        let mut out = Self::default();
97        if boundary.is_empty() || body.is_empty() {
98            return out;
99        }
100        let delimiter = format!("--{}", boundary);
101        let delim = delimiter.as_bytes();
102        // Locate delimiter lines: the delimiter must open a line (start of body
103        // or right after `\n`, tolerating a preamble) and be followed by a line
104        // break or the close marker — so boundary-like bytes inside file
105        // contents never split apart.
106        let mut offsets = Vec::new();
107        let mut i = 0;
108        while i + delim.len() <= body.len() {
109            if &body[i..i + delim.len()] == delim
110                && (i == 0 || body[i - 1] == b'\n')
111                && follows_delimiter(&body[i + delim.len()..])
112            {
113                offsets.push(i);
114                i += delim.len();
115            } else {
116                i += 1;
117            }
118        }
119        for (idx, &start) in offsets.iter().enumerate() {
120            if idx >= MAX_PARTS {
121                break;
122            }
123            let mut seg_start = start + delim.len();
124            // Close delimiter (`--boundary--`) ends the payload.
125            if body.get(seg_start..seg_start + 2) == Some(b"--".as_slice()) {
126                break;
127            }
128            // Strip the CRLF following the delimiter line.
129            if body.get(seg_start..seg_start + 2) == Some(b"\r\n".as_slice()) {
130                seg_start += 2;
131            } else if body.get(seg_start..seg_start + 1) == Some(b"\n".as_slice()) {
132                seg_start += 1;
133            }
134            let seg_end = offsets.get(idx + 1).copied().unwrap_or(body.len());
135            if seg_end <= seg_start {
136                continue;
137            }
138            let mut segment = &body[seg_start..seg_end];
139            // Strip the CRLF preceding the next delimiter.
140            if segment.ends_with(b"\r\n") {
141                segment = &segment[..segment.len() - 2];
142            } else if segment.ends_with(b"\n") {
143                segment = &segment[..segment.len() - 1];
144            }
145            out.add_part(segment);
146        }
147        out
148    }
149
150    fn add_part(&mut self, segment: &[u8]) {
151        let split = find_subslice(segment, b"\r\n\r\n")
152            .or_else(|| find_subslice(segment, b"\n\n"));
153        let Some(at) = split else { return };
154        let sep_len = if segment[at..].starts_with(b"\r\n\r\n") { 4 } else { 2 };
155        let raw_headers = &segment[..at];
156        let content = &segment[at + sep_len..];
157        let headers = String::from_utf8_lossy(raw_headers);
158
159        let mut name: Option<String> = None;
160        let mut file_name: Option<String> = None;
161        let mut part_content_type: Option<String> = None;
162        for line in headers.split("\r\n") {
163            let line = line.trim();
164            if line.is_empty() {
165                continue;
166            }
167            if let Some((k, v)) = line.split_once(':') {
168                let key = k.trim();
169                let val = v.trim().to_string();
170                if key.eq_ignore_ascii_case("content-disposition") {
171                    let (disp_name, disp_file) = parse_disposition(&val);
172                    if disp_name.is_some() {
173                        name = disp_name;
174                    }
175                    if disp_file.is_some() {
176                        file_name = disp_file;
177                    }
178                } else if key.eq_ignore_ascii_case("content-type") {
179                    part_content_type = Some(val.split(';').next().unwrap_or("").trim().to_string());
180                }
181            }
182        }
183        let Some(name) = name.filter(|n| !n.is_empty()) else { return };
184        // A part is a file upload when it carries a filename or an explicit
185        // non-text content type. Plain `text/plain` parts stay text fields even
186        // when a client sends the content type explicitly (e.g., curl `-F`).
187        let is_file = match (&file_name, &part_content_type) {
188            (Some(f), _) if !f.is_empty() => true,
189            (_, Some(ct)) if !ct.is_empty() && ct != "text/plain" => true,
190            _ => false,
191        };
192        if is_file {
193            self.files.push(UploadedFile {
194                field_name: name,
195                file_name: file_name.filter(|f| !f.is_empty()),
196                content_type: part_content_type.filter(|c| !c.is_empty()),
197                bytes: content.to_vec(),
198            });
199        } else {
200            self.fields
201                .entry(name)
202                .or_default()
203                .push(String::from_utf8_lossy(content).into_owned());
204        }
205    }
206
207    /// Returns the first value of a text field, if present.
208    pub fn field(&self, name: &str) -> Option<&str> {
209        self.fields.get(name).and_then(|v| v.first().map(|s| s.as_str()))
210    }
211}
212
213/// Parse `form-data; name="..."`; `filename="..."` from a Content-Disposition value.
214fn parse_disposition(value: &str) -> (Option<String>, Option<String>) {
215    let mut name = None;
216    let mut filename = None;
217    for part in value.split(';').skip(1) {
218        let part = part.trim();
219        if let Some((k, v)) = part.split_once('=') {
220            let v = v.trim().trim_matches('"').to_string();
221            if k.trim().eq_ignore_ascii_case("name") {
222                name = Some(v);
223            } else if k.trim().eq_ignore_ascii_case("filename") {
224                filename = Some(v);
225            }
226        }
227    }
228    (name, filename)
229}
230
231fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
232    if needle.is_empty() || haystack.len() < needle.len() {
233        return None;
234    }
235    (0..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
236}
237
238/// Remainder after a delimiter must start a new line, close the payload, or be empty.
239fn follows_delimiter(rest: &[u8]) -> bool {
240    rest.is_empty()
241        || rest.starts_with(b"--")
242        || rest.starts_with(b"\r\n")
243        || rest.starts_with(b"\n")
244}