wiremock_multipart/
request_utils.rs

1use std::str::FromStr;
2
3use wiremock::http::HeaderName;
4use wiremock::Request;
5
6use crate::part::Part;
7
8pub trait RequestUtils {
9    fn multipart_contenttype(&self) -> Option<MultipartContentType>;
10    fn parts(&self) -> Vec<Part>;
11}
12
13impl RequestUtils for Request {
14    fn multipart_contenttype(&self) -> Option<MultipartContentType> {
15        let content_type = self
16            .headers
17            .get_all(&HeaderName::from_str("content-type").unwrap())
18            .iter()
19            .find(|value| {
20                value
21                    .to_str()
22                    .unwrap_or_default()
23                    .to_lowercase()
24                    .starts_with("multipart/")
25            });
26        match content_type {
27            None => None,
28            Some(value) => {
29                let parts = value
30                    .to_str()
31                    .unwrap_or_default()
32                    .split(";")
33                    .collect::<Vec<_>>();
34
35                let multipart_type = parts[0].split("/").nth(1).unwrap().trim();
36
37                let boundary = parts
38                    .iter()
39                    .map(|part| part.trim())
40                    .find(|part| part.starts_with("boundary="))
41                    .map(|whole| whole.split("=").nth(1).unwrap().trim());
42
43                Some(MultipartContentType {
44                    multipart_type,
45                    boundary,
46                })
47            }
48        }
49    }
50
51    fn parts(&self) -> Vec<Part> {
52        if let Some(content_type) = self.multipart_contenttype() {
53            if content_type.multipart_type == "form-data" {
54                if let Some(boundary) = content_type.boundary {
55                    let boundary = {
56                        let mut tmp: Vec<u8> = vec!['-' as u8; boundary.as_bytes().len() + 2];
57                        tmp[0] = '-' as u8;
58                        tmp[1] = '-' as u8;
59                        tmp[2..].copy_from_slice(boundary.as_bytes());
60                        tmp
61                    };
62
63                    let boundary_start_indexes = self
64                        .body
65                        .windows(boundary.len())
66                        .enumerate()
67                        .filter(|(_, window)| window == &boundary)
68                        .map(|(index, _)| index)
69                        .collect::<Vec<_>>();
70
71                    boundary_start_indexes
72                        .windows(2)
73                        .map(|w| (boundary.len() + 1 + w[0], w[1]))
74                        .map(|(start, end)| &self.body[start..end])
75                        .map(|body| trim_single_linebreak_from_start(body))
76                        .map(|body| trim_single_linebreak_from_end(body))
77                        .map(|it| Part::from(it))
78                        .collect::<Vec<_>>()
79                } else {
80                    vec![]
81                }
82            } else {
83                vec![]
84            }
85        } else {
86            vec![]
87        }
88    }
89}
90
91fn trim_single_linebreak_from_start(body: &[u8]) -> &[u8] {
92    if body.len() >= 2 {
93        match body[..2] {
94            [b'\r', b'\n'] => &body[2..],
95            [b'\n', _] => &body[1..],
96            _ => body,
97        }
98    } else if body.len() >= 1 {
99        match body[0] {
100            b'\n' => &body[1..],
101            _ => body,
102        }
103    } else {
104        body
105    }
106}
107
108fn trim_single_linebreak_from_end(body: &[u8]) -> &[u8] {
109    if body.len() >= 2 {
110        match body[(body.len() - 2)..(body.len())] {
111            [b'\r', b'\n'] => &body[..body.len() - 2],
112            [_, b'\n'] => &body[..body.len() - 1],
113            _ => body,
114        }
115    } else if body.len() >= 1 {
116        match body[(body.len() - 1)..(body.len())] {
117            [_, b'\n'] => &body[..body.len() - 1],
118            _ => body,
119        }
120    } else {
121        body
122    }
123}
124
125#[derive(PartialEq, Eq, Debug, Clone, Copy)]
126pub struct MultipartContentType<'a> {
127    pub multipart_type: &'a str,
128    pub boundary: Option<&'a str>,
129}
130
131#[cfg(test)]
132mod tests {
133    use indoc::indoc;
134    use maplit::hashmap;
135
136    use crate::test_utils::{multipart_header, name, request, requestb, values};
137
138    use super::*;
139
140    #[test]
141    fn multipart_contenttype_should_return_none_if_no_multipart_request() {
142        assert_eq!(request(hashmap! {},).multipart_contenttype(), None);
143
144        assert_eq!(
145            request(hashmap! {
146                name("accept") => values("application/json"),
147            },)
148            .multipart_contenttype(),
149            None
150        );
151
152        assert_eq!(
153            request(hashmap! {
154                name("content-type") => values("image/jpeg"),
155            },)
156            .multipart_contenttype(),
157            None
158        );
159    }
160
161    #[test]
162    fn multipart_contenttype_should_return_some_if_multipart_request() {
163        assert_eq!(
164            request(hashmap! {
165                name("content-type") => values("multipart/foo"),
166            },)
167            .multipart_contenttype(),
168            Some(MultipartContentType {
169                multipart_type: "foo",
170                boundary: None,
171            })
172        );
173
174        assert_eq!(
175            request(hashmap! {
176                name("content-type") => values("multipart/bar; boundary=xyz"),
177            },)
178            .multipart_contenttype(),
179            Some(MultipartContentType {
180                multipart_type: "bar",
181                boundary: Some("xyz"),
182            })
183        );
184    }
185
186    #[test]
187    fn parts_should_find_single_text_part() {
188        assert_eq!(
189            requestb(
190                hashmap! {
191                    name("content-type") => values("multipart/form-data; boundary=xyz"),
192                },
193                indoc! {"
194                    --xyz
195                    Content-Disposition: form-data; name=\"part1\"
196
197                    content
198                    --xyz--
199                "}
200                .as_bytes()
201                .into(),
202            )
203            .parts(),
204            vec![Part::from(
205                "Content-Disposition: form-data; name=\"part1\"\n\ncontent"
206            ),],
207        );
208    }
209
210    #[test]
211    fn parts_should_find_single_text_part_with_crnr() {
212        assert_eq!(
213            requestb(
214                multipart_header(),
215                "--xyz\r\nContent-Disposition: form-data; name=\"part1\"\r\n\r\ncontent\r\n--xyz--"
216                    .as_bytes()
217                    .into()
218            )
219            .parts(),
220            vec![Part::from(
221                "Content-Disposition: form-data; name=\"part1\"\r\n\r\ncontent"
222            ),],
223        );
224    }
225
226    #[test]
227    fn parts_should_find_two_text_parts() {
228        assert_eq!(
229            requestb(
230                hashmap! {
231                    name("content-type") => values("multipart/form-data; boundary=xyz"),
232                },
233                indoc! {r#"
234                    --xyz
235                    Content-Disposition: form-data; name="part1"
236
237                    content
238                    --xyz
239                    Content-Disposition: form-data; name="file"; filename="Cargo.toml"
240                    Content-Type: plain/text
241
242                    [workspace]
243                    members = [
244                        "fhttp",
245                        "fhttp-core",
246                    ]
247
248                    --xyz--
249                "#}
250                .as_bytes()
251                .into(),
252            )
253            .parts(),
254            vec![
255                Part::from("Content-Disposition: form-data; name=\"part1\"\n\ncontent"),
256                Part::from(indoc! {r#"
257                    Content-Disposition: form-data; name="file"; filename="Cargo.toml"
258                    Content-Type: plain/text
259
260                    [workspace]
261                    members = [
262                        "fhttp",
263                        "fhttp-core",
264                    ]
265                "#}),
266            ],
267        );
268    }
269
270    #[test]
271    fn parts_should_find_two_text_parts_with_crnl() {
272        let part2 = {
273            let mut tmp = String::new();
274            tmp += "Content-Disposition: form-data; name=\"file\"; filename=\"Cargo.toml\"";
275            tmp += "\r\nContent-Type: plain/text";
276            tmp += "\r\n\r\n";
277            tmp += "[workspace]\nmembers = [\n    \"fhttp\",\n    \"fhttp-core\",\n]\n";
278
279            tmp
280        };
281        let mut body = String::new();
282        body += "--xyz";
283        body += "\r\nContent-Disposition: form-data; name=\"part1\"";
284        body += "\r\n\r\ncontent";
285        body += "\r\n--xyz\r\n";
286        body += &part2;
287        body += "\r\n--xyz--\r\n";
288
289        assert_eq!(
290            requestb(multipart_header(), body.as_bytes().into(),).parts(),
291            vec![
292                Part::from("Content-Disposition: form-data; name=\"part1\"\r\n\r\ncontent"),
293                Part::from(&part2),
294            ],
295        );
296    }
297}