Skip to content

Commit d55a9be

Browse files
committed
feat: add support for MultiPart body (only supports single part for now...)
1 parent 1621a0b commit d55a9be

File tree

4 files changed

+206
-4
lines changed

4 files changed

+206
-4
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ Should never be used in production for obvious reasons 💀
1717
- [ ] MIME support 🎭
1818
- [x] support for file download (`HttpResponse.body` is now `Vec<u8>`)
1919
- [x] support for file upload (`HttpRequest.body` is now `Vec<u8>`)
20+
- [-] Body support
21+
- [x] Bytes body
22+
- [x] String body
23+
- [-] Multipart body
24+
- [x] Single part (useful for single file uploads)
25+
- [ ] Multi parts
2026
- [ ] HTTPS 🛡️
2127
- [ ] Improved routing 🚄 (W.I.P)
2228
- [ ] support for dynamic paths: `/foo/{:id}/bar`

src/http/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod cookie;
22
pub mod header;
33
pub mod method;
4+
pub mod multipart;
45
pub mod request;
56
pub mod request_raw;
67
pub mod response;
@@ -11,6 +12,8 @@ pub mod version;
1112
pub use self::cookie::HttpCookie;
1213
pub use self::header::HttpHeader;
1314
pub use self::method::HttpMethod;
15+
pub use self::multipart::MultipartBody;
16+
pub use self::multipart::MultipartBodyPart;
1417
pub use self::request::HttpRequest;
1518
pub use self::request_raw::HttpRequestRaw;
1619
pub use self::response::HttpResponse;

src/http/multipart.rs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
use anyhow::{bail, Context, Result};
2+
use log::{trace, warn};
3+
use std::io::{BufRead, BufReader, Cursor, Read};
4+
5+
#[derive(Debug, PartialEq, Eq)]
6+
pub struct MultipartBody {
7+
pub parts: Vec<MultipartBodyPart>,
8+
}
9+
10+
#[derive(Debug, PartialEq, Eq)]
11+
pub struct MultipartBodyPart {
12+
pub name: String,
13+
pub filename: Option<String>,
14+
pub content_type: String,
15+
pub data: Vec<u8>,
16+
}
17+
18+
impl MultipartBody {
19+
pub fn from_bytes(boundary: &str, bytes: &[u8]) -> Result<MultipartBody> {
20+
warn!("for now, only single part multipart body is supported... I know, that does not make sense");
21+
let cursor = Cursor::new(bytes);
22+
let mut reader = BufReader::new(cursor);
23+
24+
let actual_boundary = format!("--{}", boundary);
25+
26+
let mut read_boundary = String::new();
27+
let bytes_read = reader.read_line(&mut read_boundary)?;
28+
trace!("read boundary: {read_boundary:?} ({bytes_read} bytes)");
29+
if !read_boundary.trim().eq(&actual_boundary) {
30+
bail!(
31+
"boundaries do not match: expected '{actual_boundary}' but got '{read_boundary}'"
32+
);
33+
}
34+
35+
let mut content_disposition = String::new();
36+
let bytes_read = reader.read_line(&mut content_disposition)?;
37+
trace!("read content disposition: {content_disposition} ({bytes_read} bytes)");
38+
let content_disposition = ContentDispositionHeader::from_line(&content_disposition)?;
39+
trace!("parse content_disposition: {content_disposition:?}");
40+
41+
let mut content_type = String::new();
42+
let bytes_read = reader.read_line(&mut content_type)?;
43+
trace!("read content type: {content_type} ({bytes_read} bytes)");
44+
let content_type = content_type
45+
.strip_prefix("Content-Type:")
46+
.context("expected Content-Type prefix")?
47+
.replace('"', "")
48+
.trim()
49+
.to_owned();
50+
51+
let mut empty_line = String::new();
52+
let bytes_read = reader.read_line(&mut empty_line)?;
53+
trace!("read empty line: {bytes_read} bytes");
54+
if !empty_line.trim().is_empty() {
55+
bail!("expected to read an empty line but got: {empty_line}");
56+
}
57+
58+
let mut buffer = Vec::new();
59+
let bytes_read = reader.read_to_end(&mut buffer)?;
60+
trace!("read to end: {bytes_read} bytes");
61+
62+
let end_boundary = format!("{}--", actual_boundary);
63+
buffer.truncate(buffer.len() - end_boundary.len());
64+
65+
let res = MultipartBody {
66+
parts: vec![MultipartBodyPart {
67+
name: content_disposition.form_name,
68+
filename: content_disposition.filename,
69+
content_type,
70+
data: buffer,
71+
}],
72+
};
73+
74+
Ok(res)
75+
}
76+
}
77+
78+
#[derive(Debug)]
79+
pub struct ContentDispositionHeader {
80+
pub form_name: String,
81+
pub filename: Option<String>,
82+
}
83+
84+
impl ContentDispositionHeader {
85+
pub fn from_line(line: &str) -> Result<ContentDispositionHeader> {
86+
let data = line
87+
.strip_prefix("Content-Disposition:")
88+
.context("expected Content-Disposition prefix")?;
89+
90+
let directives: Vec<_> = data.split(';').map(|d| d.trim()).collect();
91+
let form_data_dir = *(directives
92+
.first()
93+
.context("directives should not be empty")?);
94+
if !form_data_dir.eq("form-data") {
95+
bail!(
96+
"expected first directive to be form-data but got: {}",
97+
form_data_dir
98+
);
99+
}
100+
101+
let name_dir = directives
102+
.iter()
103+
.find(|d| d.starts_with("name="))
104+
.context("expected the name= directive")?;
105+
let name = name_dir
106+
.split_once('=')
107+
.context("name= directive should have a value")?
108+
.1
109+
.replace('"', "");
110+
111+
let filename_dir = directives.iter().find(|d| d.starts_with("filename="));
112+
113+
let filename = match filename_dir {
114+
Some(val) => Some(
115+
val.split_once('=')
116+
.context("filename= directive should have a value")?
117+
.1
118+
.replace('"', "")
119+
.to_owned(),
120+
),
121+
None => None,
122+
};
123+
124+
Ok(ContentDispositionHeader {
125+
form_name: name.to_owned(),
126+
filename,
127+
})
128+
}
129+
}
130+
131+
#[cfg(test)]
132+
mod tests {
133+
use super::*;
134+
135+
#[test]
136+
fn test_multipart_body_single_text_part_ok() {
137+
let boundary = "ExampleBoundaryString";
138+
let body = "--ExampleBoundaryString
139+
Content-Disposition: form-data; name=\"description\"
140+
Content-Type: text/html
141+
142+
This is a description
143+
--ExampleBoundaryString--"
144+
.as_bytes();
145+
146+
let actual = MultipartBody::from_bytes(boundary, &body).unwrap();
147+
let expected = MultipartBody {
148+
parts: vec![MultipartBodyPart {
149+
name: "description".to_owned(),
150+
filename: None,
151+
content_type: "text/html".to_owned(),
152+
data: "This is a description\n".as_bytes().to_vec(),
153+
}],
154+
};
155+
156+
assert_eq!(expected, actual);
157+
}
158+
159+
#[test]
160+
// TODO: This test checks for err because Multipart support right now is only with single part
161+
// In normal situation, the body in this function would denote a valid Multipart body
162+
fn test_mutlipart_body_multiple_parts_is_err() {
163+
let boundary = "--delimiter123";
164+
let body = "
165+
--delimiter123
166+
Content-Disposition: form-data; name=\"field1\"
167+
168+
value1
169+
--delimiter123
170+
Content-Disposition: form-data; name=\"field2\"; filename=\"example.txt\"
171+
172+
value2
173+
--delimiter123--"
174+
.as_bytes();
175+
176+
assert!(MultipartBody::from_bytes(boundary, &body).is_err());
177+
}
178+
}

src/http/request.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
use anyhow::{anyhow, Context, Result};
2+
use log::trace;
23
use serde::{Deserialize, Serialize};
34
use std::{collections::HashMap, net::TcpStream, str::FromStr};
45

5-
use crate::http::request_raw::HttpRequestRaw;
6-
7-
use super::{HttpCookie, HttpHeader, HttpMethod, HttpVersion};
6+
use super::{HttpCookie, HttpHeader, HttpMethod, HttpRequestRaw, HttpVersion, MultipartBody};
87

98
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
109
pub struct HttpRequest {
@@ -76,10 +75,26 @@ impl HttpRequest {
7675
&self.method
7776
}
7877

79-
pub fn str_body(&self) -> Result<String> {
78+
pub fn get_str_body(&self) -> Result<String> {
8079
Ok(String::from_utf8(self.body.clone())?)
8180
}
8281

82+
pub fn get_multipart_body(&self) -> Result<MultipartBody> {
83+
let content_type = self
84+
.headers
85+
.get("Content-Type")
86+
.context("cannot process multipart body because Content-Type header is missing")?;
87+
88+
let multipart_boundary = content_type
89+
.value
90+
.strip_prefix("multipart/form-data; boundary=")
91+
.context("boundary is required with multipart body")?;
92+
93+
trace!("header boundary: {multipart_boundary}");
94+
95+
MultipartBody::from_bytes(multipart_boundary, &self.body)
96+
}
97+
8398
pub fn parse_request_line(start_line: &str) -> Result<(HttpMethod, String, HttpVersion)> {
8499
let mut parts = start_line.split(" ");
85100

0 commit comments

Comments
 (0)