Skip to content

Commit e75844e

Browse files
authored
Decode application/x-www-form-urlencoded bodies (#363)
On an endpoint marked with `content_type = "application/x-www-form-urlencoded"`, a `TypedBody` parameter will be decoded using `serde_urlencoded` instead of `serde_json`. Does not currently handle nested structs; see #382
1 parent 3f10086 commit e75844e

14 files changed

+510
-36
lines changed

dropshot/src/api_description.rs

+38-4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use crate::Extractor;
1717
use crate::HttpErrorResponseBody;
1818
use crate::CONTENT_TYPE_JSON;
1919
use crate::CONTENT_TYPE_OCTET_STREAM;
20+
use crate::CONTENT_TYPE_URL_ENCODED;
2021

2122
use http::Method;
2223
use http::StatusCode;
@@ -39,6 +40,7 @@ pub struct ApiEndpoint<Context: ServerContext> {
3940
pub method: Method,
4041
pub path: String,
4142
pub parameters: Vec<ApiEndpointParameter>,
43+
pub body_content_type: ApiEndpointBodyContentType,
4244
pub response: ApiEndpointResponse,
4345
pub summary: Option<String>,
4446
pub description: Option<String>,
@@ -52,21 +54,26 @@ impl<'a, Context: ServerContext> ApiEndpoint<Context> {
5254
operation_id: String,
5355
handler: HandlerType,
5456
method: Method,
57+
content_type: &'a str,
5558
path: &'a str,
5659
) -> Self
5760
where
5861
HandlerType: HttpHandlerFunc<Context, FuncParams, ResponseType>,
5962
FuncParams: Extractor + 'static,
6063
ResponseType: HttpResponse + Send + Sync + 'static,
6164
{
62-
let func_parameters = FuncParams::metadata();
65+
let body_content_type =
66+
ApiEndpointBodyContentType::from_mime_type(content_type)
67+
.expect("unsupported mime type");
68+
let func_parameters = FuncParams::metadata(body_content_type.clone());
6369
let response = ResponseType::response_metadata();
6470
ApiEndpoint {
6571
operation_id,
6672
handler: HttpRouteHandler::new(handler),
6773
method,
6874
path: path.to_string(),
6975
parameters: func_parameters.parameters,
76+
body_content_type,
7077
response,
7178
summary: None,
7279
description: None,
@@ -171,13 +178,31 @@ pub enum ApiEndpointBodyContentType {
171178
Bytes,
172179
/** application/json */
173180
Json,
181+
/** application/x-www-form-urlencoded */
182+
UrlEncoded,
183+
}
184+
185+
impl Default for ApiEndpointBodyContentType {
186+
fn default() -> Self {
187+
Self::Json
188+
}
174189
}
175190

176191
impl ApiEndpointBodyContentType {
177-
fn mime_type(&self) -> &str {
192+
pub fn mime_type(&self) -> &str {
178193
match self {
179-
ApiEndpointBodyContentType::Bytes => CONTENT_TYPE_OCTET_STREAM,
180-
ApiEndpointBodyContentType::Json => CONTENT_TYPE_JSON,
194+
Self::Bytes => CONTENT_TYPE_OCTET_STREAM,
195+
Self::Json => CONTENT_TYPE_JSON,
196+
Self::UrlEncoded => CONTENT_TYPE_URL_ENCODED,
197+
}
198+
}
199+
200+
pub fn from_mime_type(mime_type: &str) -> Result<Self, String> {
201+
match mime_type {
202+
CONTENT_TYPE_OCTET_STREAM => Ok(Self::Bytes),
203+
CONTENT_TYPE_JSON => Ok(Self::Json),
204+
CONTENT_TYPE_URL_ENCODED => Ok(Self::UrlEncoded),
205+
_ => Err(mime_type.to_string()),
181206
}
182207
}
183208
}
@@ -1562,6 +1587,7 @@ mod test {
15621587
use crate::TagDetails;
15631588
use crate::TypedBody;
15641589
use crate::UntypedBody;
1590+
use crate::CONTENT_TYPE_JSON;
15651591
use http::Method;
15661592
use hyper::Body;
15671593
use hyper::Response;
@@ -1595,6 +1621,7 @@ mod test {
15951621
"test_badpath_handler".to_string(),
15961622
test_badpath_handler,
15971623
Method::GET,
1624+
CONTENT_TYPE_JSON,
15981625
"/",
15991626
));
16001627
assert_eq!(
@@ -1611,6 +1638,7 @@ mod test {
16111638
"test_badpath_handler".to_string(),
16121639
test_badpath_handler,
16131640
Method::GET,
1641+
CONTENT_TYPE_JSON,
16141642
"/{a}/{aa}/{b}/{bb}",
16151643
));
16161644
assert_eq!(
@@ -1626,6 +1654,7 @@ mod test {
16261654
"test_badpath_handler".to_string(),
16271655
test_badpath_handler,
16281656
Method::GET,
1657+
CONTENT_TYPE_JSON,
16291658
"/{c}/{d}",
16301659
));
16311660
assert_eq!(
@@ -1822,6 +1851,7 @@ mod test {
18221851
"test_badpath_handler".to_string(),
18231852
test_badpath_handler,
18241853
Method::GET,
1854+
CONTENT_TYPE_JSON,
18251855
"/{a}/{b}",
18261856
));
18271857
assert_eq!(ret, Err("At least one tag is required".to_string()));
@@ -1839,6 +1869,7 @@ mod test {
18391869
"test_badpath_handler".to_string(),
18401870
test_badpath_handler,
18411871
Method::GET,
1872+
CONTENT_TYPE_JSON,
18421873
"/{a}/{b}",
18431874
)
18441875
.tag("howdy")
@@ -1860,6 +1891,7 @@ mod test {
18601891
"test_badpath_handler".to_string(),
18611892
test_badpath_handler,
18621893
Method::GET,
1894+
CONTENT_TYPE_JSON,
18631895
"/{a}/{b}",
18641896
)
18651897
.tag("a-tag"),
@@ -1890,6 +1922,7 @@ mod test {
18901922
"test_badpath_handler".to_string(),
18911923
test_badpath_handler,
18921924
Method::GET,
1925+
CONTENT_TYPE_JSON,
18931926
"/xx/{a}/{b}",
18941927
)
18951928
.tag("a-tag")
@@ -1901,6 +1934,7 @@ mod test {
19011934
"test_badpath_handler".to_string(),
19021935
test_badpath_handler,
19031936
Method::GET,
1937+
CONTENT_TYPE_JSON,
19041938
"/yy/{a}/{b}",
19051939
)
19061940
.tag("b-tag")

dropshot/src/handler.rs

+73-22
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ pub struct RequestContext<Context: ServerContext> {
100100
pub request: Arc<Mutex<Request<Body>>>,
101101
/** HTTP request routing variables */
102102
pub path_variables: VariableSet,
103+
/** expected request body mime type */
104+
pub body_content_type: ApiEndpointBodyContentType,
103105
/** unique id assigned to this request */
104106
pub request_id: String,
105107
/** logger for this specific request */
@@ -190,7 +192,9 @@ pub trait Extractor: Send + Sync + Sized {
190192
rqctx: Arc<RequestContext<Context>>,
191193
) -> Result<Self, HttpError>;
192194

193-
fn metadata() -> ExtractorMetadata;
195+
fn metadata(
196+
body_content_type: ApiEndpointBodyContentType,
197+
) -> ExtractorMetadata;
194198
}
195199

196200
/**
@@ -217,13 +221,13 @@ macro_rules! impl_extractor_for_tuple {
217221
futures::try_join!($($T::from_request(Arc::clone(&_rqctx)),)*)
218222
}
219223

220-
fn metadata() -> ExtractorMetadata {
224+
fn metadata(_body_content_type: ApiEndpointBodyContentType) -> ExtractorMetadata {
221225
#[allow(unused_mut)]
222226
let mut paginated = false;
223227
#[allow(unused_mut)]
224228
let mut parameters = vec![];
225229
$(
226-
let mut metadata = $T::metadata();
230+
let mut metadata = $T::metadata(_body_content_type.clone());
227231
paginated = paginated | metadata.paginated;
228232
parameters.append(&mut metadata.parameters);
229233
)*
@@ -607,7 +611,9 @@ where
607611
http_request_load_query(&request)
608612
}
609613

610-
fn metadata() -> ExtractorMetadata {
614+
fn metadata(
615+
_body_content_type: ApiEndpointBodyContentType,
616+
) -> ExtractorMetadata {
611617
get_metadata::<QueryType>(&ApiEndpointParameterLocation::Query)
612618
}
613619
}
@@ -653,7 +659,9 @@ where
653659
Ok(Path { inner: params })
654660
}
655661

656-
fn metadata() -> ExtractorMetadata {
662+
fn metadata(
663+
_body_content_type: ApiEndpointBodyContentType,
664+
) -> ExtractorMetadata {
657665
get_metadata::<PathType>(&ApiEndpointParameterLocation::Path)
658666
}
659667
}
@@ -934,31 +942,72 @@ impl<BodyType: JsonSchema + DeserializeOwned + Send + Sync>
934942
}
935943

936944
/**
937-
* Given an HTTP request, attempt to read the body, parse it as JSON, and
938-
* deserialize an instance of `BodyType` from it.
945+
* Given an HTTP request, attempt to read the body, parse it according
946+
* to the content type, and deserialize it to an instance of `BodyType`.
939947
*/
940-
async fn http_request_load_json_body<Context: ServerContext, BodyType>(
948+
async fn http_request_load_body<Context: ServerContext, BodyType>(
941949
rqctx: Arc<RequestContext<Context>>,
942950
) -> Result<TypedBody<BodyType>, HttpError>
943951
where
944952
BodyType: JsonSchema + DeserializeOwned + Send + Sync,
945953
{
946954
let server = &rqctx.server;
947955
let mut request = rqctx.request.lock().await;
948-
let body_bytes = http_read_body(
956+
let body = http_read_body(
949957
request.body_mut(),
950958
server.config.request_body_max_bytes,
951959
)
952960
.await?;
953-
let value: Result<BodyType, serde_json::Error> =
954-
serde_json::from_slice(&body_bytes);
955-
match value {
956-
Ok(j) => Ok(TypedBody { inner: j }),
957-
Err(e) => Err(HttpError::for_bad_request(
958-
None,
959-
format!("unable to parse body: {}", e),
960-
)),
961-
}
961+
962+
// RFC 7231 §3.1.1.1: media types are case insensitive and may
963+
// be followed by whitespace and/or a parameter (e.g., charset),
964+
// which we currently ignore.
965+
let content_type = request
966+
.headers()
967+
.get(http::header::CONTENT_TYPE)
968+
.map(|hv| {
969+
hv.to_str().map_err(|e| {
970+
HttpError::for_bad_request(
971+
None,
972+
format!("invalid content type: {}", e),
973+
)
974+
})
975+
})
976+
.unwrap_or(Ok(CONTENT_TYPE_JSON))?;
977+
let end = content_type.find(';').unwrap_or_else(|| content_type.len());
978+
let mime_type = content_type[..end].trim_end().to_lowercase();
979+
let body_content_type =
980+
ApiEndpointBodyContentType::from_mime_type(&mime_type)
981+
.map_err(|e| HttpError::for_bad_request(None, e))?;
982+
let expected_content_type = rqctx.body_content_type.clone();
983+
984+
use ApiEndpointBodyContentType::*;
985+
let content: BodyType = match (expected_content_type, body_content_type) {
986+
(Json, Json) => serde_json::from_slice(&body).map_err(|e| {
987+
HttpError::for_bad_request(
988+
None,
989+
format!("unable to parse JSON body: {}", e),
990+
)
991+
})?,
992+
(UrlEncoded, UrlEncoded) => serde_urlencoded::from_bytes(&body)
993+
.map_err(|e| {
994+
HttpError::for_bad_request(
995+
None,
996+
format!("unable to parse URL-encoded body: {}", e),
997+
)
998+
})?,
999+
(expected, requested) => {
1000+
return Err(HttpError::for_bad_request(
1001+
None,
1002+
format!(
1003+
"expected content type \"{}\", got \"{}\"",
1004+
expected.mime_type(),
1005+
requested.mime_type()
1006+
),
1007+
))
1008+
}
1009+
};
1010+
Ok(TypedBody { inner: content })
9621011
}
9631012

9641013
/*
@@ -977,12 +1026,12 @@ where
9771026
async fn from_request<Context: ServerContext>(
9781027
rqctx: Arc<RequestContext<Context>>,
9791028
) -> Result<TypedBody<BodyType>, HttpError> {
980-
http_request_load_json_body(rqctx).await
1029+
http_request_load_body(rqctx).await
9811030
}
9821031

983-
fn metadata() -> ExtractorMetadata {
1032+
fn metadata(content_type: ApiEndpointBodyContentType) -> ExtractorMetadata {
9841033
let body = ApiEndpointParameter::new_body(
985-
ApiEndpointBodyContentType::Json,
1034+
content_type,
9861035
true,
9871036
ApiSchemaGenerator::Gen {
9881037
name: BodyType::schema_name,
@@ -1047,7 +1096,9 @@ impl Extractor for UntypedBody {
10471096
Ok(UntypedBody { content: body_bytes })
10481097
}
10491098

1050-
fn metadata() -> ExtractorMetadata {
1099+
fn metadata(
1100+
_content_type: ApiEndpointBodyContentType,
1101+
) -> ExtractorMetadata {
10511102
ExtractorMetadata {
10521103
parameters: vec![ApiEndpointParameter::new_body(
10531104
ApiEndpointBodyContentType::Bytes,

dropshot/src/http_util.rs

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ pub const CONTENT_TYPE_OCTET_STREAM: &str = "application/octet-stream";
2020
pub const CONTENT_TYPE_JSON: &str = "application/json";
2121
/** MIME type for newline-delimited JSON data */
2222
pub const CONTENT_TYPE_NDJSON: &str = "application/x-ndjson";
23+
/** MIME type for form/urlencoded data */
24+
pub const CONTENT_TYPE_URL_ENCODED: &str = "application/x-www-form-urlencoded";
2325

2426
/**
2527
* Reads the rest of the body from the request up to the given number of bytes.

dropshot/src/lib.rs

+4-2
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,8 @@
234234
* an instance of type `P`. `P` must implement `serde::Deserialize` and
235235
* `schemars::JsonSchema`.
236236
* * [`TypedBody`]`<J>` extracts content from the request body by parsing the
237-
* body as JSON and deserializing it into an instance of type `J`. `J` must
238-
* implement `serde::Deserialize` and `schemars::JsonSchema`.
237+
* body as JSON (or form/url-encoded) and deserializing it into an instance
238+
* of type `J`. `J` must implement `serde::Deserialize` and `schemars::JsonSchema`.
239239
* * [`UntypedBody`] extracts the raw bytes of the request body.
240240
*
241241
* If the handler takes a `Query<Q>`, `Path<P>`, `TypedBody<J>`, or
@@ -619,6 +619,7 @@ extern crate slog;
619619

620620
pub use api_description::ApiDescription;
621621
pub use api_description::ApiEndpoint;
622+
pub use api_description::ApiEndpointBodyContentType;
622623
pub use api_description::ApiEndpointParameter;
623624
pub use api_description::ApiEndpointParameterLocation;
624625
pub use api_description::ApiEndpointResponse;
@@ -651,6 +652,7 @@ pub use handler::UntypedBody;
651652
pub use http_util::CONTENT_TYPE_JSON;
652653
pub use http_util::CONTENT_TYPE_NDJSON;
653654
pub use http_util::CONTENT_TYPE_OCTET_STREAM;
655+
pub use http_util::CONTENT_TYPE_URL_ENCODED;
654656
pub use http_util::HEADER_REQUEST_ID;
655657
pub use logging::ConfigLogging;
656658
pub use logging::ConfigLoggingIfExists;

0 commit comments

Comments
 (0)