Skip to content
42 changes: 38 additions & 4 deletions dropshot/src/api_description.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::Extractor;
use crate::HttpErrorResponseBody;
use crate::CONTENT_TYPE_JSON;
use crate::CONTENT_TYPE_OCTET_STREAM;
use crate::CONTENT_TYPE_URL_ENCODED;

use http::Method;
use http::StatusCode;
Expand All @@ -39,6 +40,7 @@ pub struct ApiEndpoint<Context: ServerContext> {
pub method: Method,
pub path: String,
pub parameters: Vec<ApiEndpointParameter>,
pub body_content_type: ApiEndpointBodyContentType,
pub response: ApiEndpointResponse,
pub summary: Option<String>,
pub description: Option<String>,
Expand All @@ -52,21 +54,26 @@ impl<'a, Context: ServerContext> ApiEndpoint<Context> {
operation_id: String,
handler: HandlerType,
method: Method,
content_type: &'a str,
path: &'a str,
) -> Self
where
HandlerType: HttpHandlerFunc<Context, FuncParams, ResponseType>,
FuncParams: Extractor + 'static,
ResponseType: HttpResponse + Send + Sync + 'static,
{
let func_parameters = FuncParams::metadata();
let body_content_type =
ApiEndpointBodyContentType::from_mime_type(content_type)
.expect("unsupported mime type");
let func_parameters = FuncParams::metadata(body_content_type.clone());
let response = ResponseType::response_metadata();
ApiEndpoint {
operation_id,
handler: HttpRouteHandler::new(handler),
method,
path: path.to_string(),
parameters: func_parameters.parameters,
body_content_type,
response,
summary: None,
description: None,
Expand Down Expand Up @@ -171,13 +178,31 @@ pub enum ApiEndpointBodyContentType {
Bytes,
/** application/json */
Json,
/** application/x-www-form-urlencoded */
UrlEncoded,
Comment on lines +181 to +182
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we care that the user says "application/x-www-form-urlencoded" or could we just let them give us any string and have the variant be Yolo(String)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In all but the UntypedBody case, we do care because we need to know how to decode the body.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry: I know we care what it is for decoding, but can we stuff that into a string rather than having Json and UrlEncoded as the two enumerated options?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm, we could, but I'm not sure I see the advantage. We could certainly add another Yolo variant for arbitrary types, but since these are the ones that we handle specially I think it makes sense to have variants for them. Otherwise why not just use strings for content type everywhere?

}

impl Default for ApiEndpointBodyContentType {
fn default() -> Self {
Self::Json
}
}

impl ApiEndpointBodyContentType {
fn mime_type(&self) -> &str {
pub fn mime_type(&self) -> &str {
match self {
ApiEndpointBodyContentType::Bytes => CONTENT_TYPE_OCTET_STREAM,
ApiEndpointBodyContentType::Json => CONTENT_TYPE_JSON,
Self::Bytes => CONTENT_TYPE_OCTET_STREAM,
Self::Json => CONTENT_TYPE_JSON,
Self::UrlEncoded => CONTENT_TYPE_URL_ENCODED,
}
}

pub fn from_mime_type(mime_type: &str) -> Result<Self, String> {
match mime_type {
CONTENT_TYPE_OCTET_STREAM => Ok(Self::Bytes),
CONTENT_TYPE_JSON => Ok(Self::Json),
CONTENT_TYPE_URL_ENCODED => Ok(Self::UrlEncoded),
_ => Err(mime_type.to_string()),
}
}
}
Expand Down Expand Up @@ -1562,6 +1587,7 @@ mod test {
use crate::TagDetails;
use crate::TypedBody;
use crate::UntypedBody;
use crate::CONTENT_TYPE_JSON;
use http::Method;
use hyper::Body;
use hyper::Response;
Expand Down Expand Up @@ -1595,6 +1621,7 @@ mod test {
"test_badpath_handler".to_string(),
test_badpath_handler,
Method::GET,
CONTENT_TYPE_JSON,
"/",
));
assert_eq!(
Expand All @@ -1611,6 +1638,7 @@ mod test {
"test_badpath_handler".to_string(),
test_badpath_handler,
Method::GET,
CONTENT_TYPE_JSON,
"/{a}/{aa}/{b}/{bb}",
));
assert_eq!(
Expand All @@ -1626,6 +1654,7 @@ mod test {
"test_badpath_handler".to_string(),
test_badpath_handler,
Method::GET,
CONTENT_TYPE_JSON,
"/{c}/{d}",
));
assert_eq!(
Expand Down Expand Up @@ -1822,6 +1851,7 @@ mod test {
"test_badpath_handler".to_string(),
test_badpath_handler,
Method::GET,
CONTENT_TYPE_JSON,
"/{a}/{b}",
));
assert_eq!(ret, Err("At least one tag is required".to_string()));
Expand All @@ -1839,6 +1869,7 @@ mod test {
"test_badpath_handler".to_string(),
test_badpath_handler,
Method::GET,
CONTENT_TYPE_JSON,
"/{a}/{b}",
)
.tag("howdy")
Expand All @@ -1860,6 +1891,7 @@ mod test {
"test_badpath_handler".to_string(),
test_badpath_handler,
Method::GET,
CONTENT_TYPE_JSON,
"/{a}/{b}",
)
.tag("a-tag"),
Expand Down Expand Up @@ -1890,6 +1922,7 @@ mod test {
"test_badpath_handler".to_string(),
test_badpath_handler,
Method::GET,
CONTENT_TYPE_JSON,
"/xx/{a}/{b}",
)
.tag("a-tag")
Expand All @@ -1901,6 +1934,7 @@ mod test {
"test_badpath_handler".to_string(),
test_badpath_handler,
Method::GET,
CONTENT_TYPE_JSON,
"/yy/{a}/{b}",
)
.tag("b-tag")
Expand Down
95 changes: 73 additions & 22 deletions dropshot/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ pub struct RequestContext<Context: ServerContext> {
pub request: Arc<Mutex<Request<Body>>>,
/** HTTP request routing variables */
pub path_variables: VariableSet,
/** expected request body mime type */
pub body_content_type: ApiEndpointBodyContentType,
/** unique id assigned to this request */
pub request_id: String,
/** logger for this specific request */
Expand Down Expand Up @@ -190,7 +192,9 @@ pub trait Extractor: Send + Sync + Sized {
rqctx: Arc<RequestContext<Context>>,
) -> Result<Self, HttpError>;

fn metadata() -> ExtractorMetadata;
fn metadata(
body_content_type: ApiEndpointBodyContentType,
) -> ExtractorMetadata;
}

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

fn metadata() -> ExtractorMetadata {
fn metadata(_body_content_type: ApiEndpointBodyContentType) -> ExtractorMetadata {
#[allow(unused_mut)]
let mut paginated = false;
#[allow(unused_mut)]
let mut parameters = vec![];
$(
let mut metadata = $T::metadata();
let mut metadata = $T::metadata(_body_content_type.clone());
paginated = paginated | metadata.paginated;
parameters.append(&mut metadata.parameters);
)*
Expand Down Expand Up @@ -607,7 +611,9 @@ where
http_request_load_query(&request)
}

fn metadata() -> ExtractorMetadata {
fn metadata(
_body_content_type: ApiEndpointBodyContentType,
) -> ExtractorMetadata {
get_metadata::<QueryType>(&ApiEndpointParameterLocation::Query)
}
}
Expand Down Expand Up @@ -653,7 +659,9 @@ where
Ok(Path { inner: params })
}

fn metadata() -> ExtractorMetadata {
fn metadata(
_body_content_type: ApiEndpointBodyContentType,
) -> ExtractorMetadata {
get_metadata::<PathType>(&ApiEndpointParameterLocation::Path)
}
}
Expand Down Expand Up @@ -934,31 +942,72 @@ impl<BodyType: JsonSchema + DeserializeOwned + Send + Sync>
}

/**
* Given an HTTP request, attempt to read the body, parse it as JSON, and
* deserialize an instance of `BodyType` from it.
* Given an HTTP request, attempt to read the body, parse it according
* to the content type, and deserialize it to an instance of `BodyType`.
*/
async fn http_request_load_json_body<Context: ServerContext, BodyType>(
async fn http_request_load_body<Context: ServerContext, BodyType>(
rqctx: Arc<RequestContext<Context>>,
) -> Result<TypedBody<BodyType>, HttpError>
where
BodyType: JsonSchema + DeserializeOwned + Send + Sync,
{
let server = &rqctx.server;
let mut request = rqctx.request.lock().await;
let body_bytes = http_read_body(
let body = http_read_body(
request.body_mut(),
server.config.request_body_max_bytes,
)
.await?;
let value: Result<BodyType, serde_json::Error> =
serde_json::from_slice(&body_bytes);
match value {
Ok(j) => Ok(TypedBody { inner: j }),
Err(e) => Err(HttpError::for_bad_request(
None,
format!("unable to parse body: {}", e),
)),
}

// RFC 7231 §3.1.1.1: media types are case insensitive and may
// be followed by whitespace and/or a parameter (e.g., charset),
// which we currently ignore.
let content_type = request
.headers()
.get(http::header::CONTENT_TYPE)
.map(|hv| {
hv.to_str().map_err(|e| {
HttpError::for_bad_request(
None,
format!("invalid content type: {}", e),
)
})
})
.unwrap_or(Ok(CONTENT_TYPE_JSON))?;
let end = content_type.find(';').unwrap_or_else(|| content_type.len());
let mime_type = content_type[..end].trim_end().to_lowercase();
let body_content_type =
ApiEndpointBodyContentType::from_mime_type(&mime_type)
.map_err(|e| HttpError::for_bad_request(None, e))?;
let expected_content_type = rqctx.body_content_type.clone();

use ApiEndpointBodyContentType::*;
let content: BodyType = match (expected_content_type, body_content_type) {
(Json, Json) => serde_json::from_slice(&body).map_err(|e| {
HttpError::for_bad_request(
None,
format!("unable to parse JSON body: {}", e),
)
})?,
(UrlEncoded, UrlEncoded) => serde_urlencoded::from_bytes(&body)
.map_err(|e| {
HttpError::for_bad_request(
None,
format!("unable to parse URL-encoded body: {}", e),
)
})?,
(expected, requested) => {
return Err(HttpError::for_bad_request(
None,
format!(
"expected content type \"{}\", got \"{}\"",
expected.mime_type(),
requested.mime_type()
),
))
}
};
Ok(TypedBody { inner: content })
}

/*
Expand All @@ -977,12 +1026,12 @@ where
async fn from_request<Context: ServerContext>(
rqctx: Arc<RequestContext<Context>>,
) -> Result<TypedBody<BodyType>, HttpError> {
http_request_load_json_body(rqctx).await
http_request_load_body(rqctx).await
}

fn metadata() -> ExtractorMetadata {
fn metadata(content_type: ApiEndpointBodyContentType) -> ExtractorMetadata {
let body = ApiEndpointParameter::new_body(
ApiEndpointBodyContentType::Json,
content_type,
true,
ApiSchemaGenerator::Gen {
name: BodyType::schema_name,
Expand Down Expand Up @@ -1047,7 +1096,9 @@ impl Extractor for UntypedBody {
Ok(UntypedBody { content: body_bytes })
}

fn metadata() -> ExtractorMetadata {
fn metadata(
_content_type: ApiEndpointBodyContentType,
) -> ExtractorMetadata {
ExtractorMetadata {
parameters: vec![ApiEndpointParameter::new_body(
ApiEndpointBodyContentType::Bytes,
Expand Down
2 changes: 2 additions & 0 deletions dropshot/src/http_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub const CONTENT_TYPE_OCTET_STREAM: &str = "application/octet-stream";
pub const CONTENT_TYPE_JSON: &str = "application/json";
/** MIME type for newline-delimited JSON data */
pub const CONTENT_TYPE_NDJSON: &str = "application/x-ndjson";
/** MIME type for form/urlencoded data */
pub const CONTENT_TYPE_URL_ENCODED: &str = "application/x-www-form-urlencoded";

/**
* Reads the rest of the body from the request up to the given number of bytes.
Expand Down
6 changes: 4 additions & 2 deletions dropshot/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@
* an instance of type `P`. `P` must implement `serde::Deserialize` and
* `schemars::JsonSchema`.
* * [`TypedBody`]`<J>` extracts content from the request body by parsing the
* body as JSON and deserializing it into an instance of type `J`. `J` must
* implement `serde::Deserialize` and `schemars::JsonSchema`.
* body as JSON (or form/url-encoded) and deserializing it into an instance
* of type `J`. `J` must implement `serde::Deserialize` and `schemars::JsonSchema`.
* * [`UntypedBody`] extracts the raw bytes of the request body.
*
* If the handler takes a `Query<Q>`, `Path<P>`, `TypedBody<J>`, or
Expand Down Expand Up @@ -619,6 +619,7 @@ extern crate slog;

pub use api_description::ApiDescription;
pub use api_description::ApiEndpoint;
pub use api_description::ApiEndpointBodyContentType;
pub use api_description::ApiEndpointParameter;
pub use api_description::ApiEndpointParameterLocation;
pub use api_description::ApiEndpointResponse;
Expand Down Expand Up @@ -651,6 +652,7 @@ pub use handler::UntypedBody;
pub use http_util::CONTENT_TYPE_JSON;
pub use http_util::CONTENT_TYPE_NDJSON;
pub use http_util::CONTENT_TYPE_OCTET_STREAM;
pub use http_util::CONTENT_TYPE_URL_ENCODED;
pub use http_util::HEADER_REQUEST_ID;
pub use logging::ConfigLogging;
pub use logging::ConfigLoggingIfExists;
Expand Down
Loading