diff --git a/dropshot/src/api_description.rs b/dropshot/src/api_description.rs index f53290fe8..77c126674 100644 --- a/dropshot/src/api_description.rs +++ b/dropshot/src/api_description.rs @@ -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; @@ -39,6 +40,7 @@ pub struct ApiEndpoint { pub method: Method, pub path: String, pub parameters: Vec, + pub body_content_type: ApiEndpointBodyContentType, pub response: ApiEndpointResponse, pub summary: Option, pub description: Option, @@ -52,6 +54,7 @@ impl<'a, Context: ServerContext> ApiEndpoint { operation_id: String, handler: HandlerType, method: Method, + content_type: &'a str, path: &'a str, ) -> Self where @@ -59,7 +62,10 @@ impl<'a, Context: ServerContext> ApiEndpoint { 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, @@ -67,6 +73,7 @@ impl<'a, Context: ServerContext> ApiEndpoint { method, path: path.to_string(), parameters: func_parameters.parameters, + body_content_type, response, summary: None, description: None, @@ -171,13 +178,31 @@ pub enum ApiEndpointBodyContentType { Bytes, /** application/json */ Json, + /** application/x-www-form-urlencoded */ + UrlEncoded, +} + +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 { + 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()), } } } @@ -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; @@ -1595,6 +1621,7 @@ mod test { "test_badpath_handler".to_string(), test_badpath_handler, Method::GET, + CONTENT_TYPE_JSON, "/", )); assert_eq!( @@ -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!( @@ -1626,6 +1654,7 @@ mod test { "test_badpath_handler".to_string(), test_badpath_handler, Method::GET, + CONTENT_TYPE_JSON, "/{c}/{d}", )); assert_eq!( @@ -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())); @@ -1839,6 +1869,7 @@ mod test { "test_badpath_handler".to_string(), test_badpath_handler, Method::GET, + CONTENT_TYPE_JSON, "/{a}/{b}", ) .tag("howdy") @@ -1860,6 +1891,7 @@ mod test { "test_badpath_handler".to_string(), test_badpath_handler, Method::GET, + CONTENT_TYPE_JSON, "/{a}/{b}", ) .tag("a-tag"), @@ -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") @@ -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") diff --git a/dropshot/src/handler.rs b/dropshot/src/handler.rs index 783b24518..88669bbe2 100644 --- a/dropshot/src/handler.rs +++ b/dropshot/src/handler.rs @@ -100,6 +100,8 @@ pub struct RequestContext { pub request: Arc>>, /** 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 */ @@ -190,7 +192,9 @@ pub trait Extractor: Send + Sync + Sized { rqctx: Arc>, ) -> Result; - fn metadata() -> ExtractorMetadata; + fn metadata( + body_content_type: ApiEndpointBodyContentType, + ) -> ExtractorMetadata; } /** @@ -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); )* @@ -607,7 +611,9 @@ where http_request_load_query(&request) } - fn metadata() -> ExtractorMetadata { + fn metadata( + _body_content_type: ApiEndpointBodyContentType, + ) -> ExtractorMetadata { get_metadata::(&ApiEndpointParameterLocation::Query) } } @@ -653,7 +659,9 @@ where Ok(Path { inner: params }) } - fn metadata() -> ExtractorMetadata { + fn metadata( + _body_content_type: ApiEndpointBodyContentType, + ) -> ExtractorMetadata { get_metadata::(&ApiEndpointParameterLocation::Path) } } @@ -934,10 +942,10 @@ impl } /** - * 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( +async fn http_request_load_body( rqctx: Arc>, ) -> Result, HttpError> where @@ -945,20 +953,61 @@ where { 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 = - 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 }) } /* @@ -977,12 +1026,12 @@ where async fn from_request( rqctx: Arc>, ) -> Result, 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, @@ -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, diff --git a/dropshot/src/http_util.rs b/dropshot/src/http_util.rs index 68170d997..c043ecb5a 100644 --- a/dropshot/src/http_util.rs +++ b/dropshot/src/http_util.rs @@ -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. diff --git a/dropshot/src/lib.rs b/dropshot/src/lib.rs index 9a7bb1451..d758c5988 100644 --- a/dropshot/src/lib.rs +++ b/dropshot/src/lib.rs @@ -234,8 +234,8 @@ * an instance of type `P`. `P` must implement `serde::Deserialize` and * `schemars::JsonSchema`. * * [`TypedBody`]`` 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`, `Path

`, `TypedBody`, or @@ -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; @@ -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; diff --git a/dropshot/src/router.rs b/dropshot/src/router.rs index c75f72b31..73e53cbb8 100644 --- a/dropshot/src/router.rs +++ b/dropshot/src/router.rs @@ -10,6 +10,7 @@ use crate::from_map::MapError; use crate::from_map::MapValue; use crate::server::ServerContext; use crate::ApiEndpoint; +use crate::ApiEndpointBodyContentType; use http::Method; use http::StatusCode; use percent_encoding::percent_decode_str; @@ -218,14 +219,16 @@ impl MapValue for VariableValue { /** * `RouterLookupResult` represents the result of invoking - * `HttpRouter::lookup_route()`. A successful route lookup includes both the - * handler and a mapping of variables in the configured path to the - * corresponding values in the actual path. + * `HttpRouter::lookup_route()`. A successful route lookup includes + * the handler, a mapping of variables in the configured path to the + * corresponding values in the actual path, and the expected body + * content type. */ #[derive(Debug)] pub struct RouterLookupResult<'a, Context: ServerContext> { pub handler: &'a dyn RouteHandler, pub variables: VariableSet, + pub body_content_type: ApiEndpointBodyContentType, } impl HttpRouterNode { @@ -515,6 +518,7 @@ impl HttpRouter { .map(|handler| RouterLookupResult { handler: &*handler.handler, variables, + body_content_type: handler.body_content_type.clone(), }) .ok_or_else(|| { HttpError::for_status(None, StatusCode::METHOD_NOT_ALLOWED) @@ -775,6 +779,7 @@ mod test { use super::input_path_to_segments; use super::HttpRouter; use super::PathSegment; + use crate::api_description::ApiEndpointBodyContentType; use crate::from_map::from_map; use crate::router::VariableValue; use crate::ApiEndpoint; @@ -812,6 +817,7 @@ mod test { method, path: path.to_string(), parameters: vec![], + body_content_type: ApiEndpointBodyContentType::default(), response: ApiEndpointResponse::default(), summary: None, description: None, diff --git a/dropshot/src/server.rs b/dropshot/src/server.rs index 491a4a10f..ca6b1a98f 100644 --- a/dropshot/src/server.rs +++ b/dropshot/src/server.rs @@ -760,6 +760,7 @@ async fn http_request_handle( server: Arc::clone(&server), request: Arc::new(Mutex::new(request)), path_variables: lookup_result.variables, + body_content_type: lookup_result.body_content_type, request_id: request_id.to_string(), log: request_log, }; diff --git a/dropshot/src/test_util.rs b/dropshot/src/test_util.rs index c3d8ca938..12a80cf4f 100644 --- a/dropshot/src/test_util.rs +++ b/dropshot/src/test_util.rs @@ -31,6 +31,7 @@ use std::sync::atomic::Ordering; use crate::api_description::ApiDescription; use crate::config::ConfigDropshot; use crate::error::HttpErrorResponseBody; +use crate::http_util::CONTENT_TYPE_URL_ENCODED; use crate::logging::ConfigLogging; use crate::pagination::ResultsPage; use crate::server::{HttpServer, HttpServerStarter, ServerContext}; @@ -118,6 +119,8 @@ impl ClientTestContext { * - for error responses: the expected body content * - header names are in allowed list * - any other semantics that can be verified in general + * + * The body will be JSON encoded. */ pub async fn make_request( &self, @@ -134,6 +137,34 @@ impl ClientTestContext { self.make_request_with_body(method, path, body, expected_status).await } + /** + * Execute an HTTP request against the test server and perform basic + * validation of the result like [`make_request`], but with a content + * type of "application/x-www-form-urlencoded". + */ + pub async fn make_request_url_encoded< + RequestBodyType: Serialize + Debug, + >( + &self, + method: Method, + path: &str, + request_body: Option, + expected_status: StatusCode, + ) -> Result, HttpErrorResponseBody> { + let body: Body = match request_body { + None => Body::empty(), + Some(input) => serde_urlencoded::to_string(&input).unwrap().into(), + }; + + self.make_request_with_body_url_encoded( + method, + path, + body, + expected_status, + ) + .await + } + pub async fn make_request_no_body( &self, method: Method, @@ -196,6 +227,23 @@ impl ClientTestContext { self.make_request_with_request(request, expected_status).await } + pub async fn make_request_with_body_url_encoded( + &self, + method: Method, + path: &str, + body: Body, + expected_status: StatusCode, + ) -> Result, HttpErrorResponseBody> { + let uri = self.url(path); + let request = Request::builder() + .method(method) + .header(http::header::CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED) + .uri(uri) + .body(body) + .expect("attempted to construct invalid request"); + self.make_request_with_request(request, expected_status).await + } + pub async fn make_request_with_request( &self, request: Request, diff --git a/dropshot/tests/fail/bad_endpoint16.rs b/dropshot/tests/fail/bad_endpoint16.rs new file mode 100644 index 000000000..6302c7f69 --- /dev/null +++ b/dropshot/tests/fail/bad_endpoint16.rs @@ -0,0 +1,22 @@ +// Copyright 2022 Oxide Computer Company + +#![allow(unused_imports)] + +use dropshot::endpoint; +use dropshot::HttpError; +use dropshot::HttpResponseOk; +use dropshot::RequestContext; +use std::sync::Arc; + +#[endpoint { + method = GET, + path = "/test", + content_type = "foo/bar", +}] +async fn bad_endpoint( + _rqctx: Arc>, +) -> Result, HttpError> { + Ok(HttpResponseOk(())) +} + +fn main() {} diff --git a/dropshot/tests/fail/bad_endpoint16.stderr b/dropshot/tests/fail/bad_endpoint16.stderr new file mode 100644 index 000000000..73400d705 --- /dev/null +++ b/dropshot/tests/fail/bad_endpoint16.stderr @@ -0,0 +1,7 @@ +error: invalid content type for endpoint + --> tests/fail/bad_endpoint16.rs:12:5 + | +12 | / method = GET, +13 | | path = "/test", +14 | | content_type = "foo/bar", + | |_____________________________^ diff --git a/dropshot/tests/test_demo.rs b/dropshot/tests/test_demo.rs index 4c439f0b6..1a67c2d4e 100644 --- a/dropshot/tests/test_demo.rs +++ b/dropshot/tests/test_demo.rs @@ -52,6 +52,7 @@ fn demo_api() -> ApiDescription { api.register(demo_handler_args_1).unwrap(); api.register(demo_handler_args_2query).unwrap(); api.register(demo_handler_args_2json).unwrap(); + api.register(demo_handler_args_2urlencoded).unwrap(); api.register(demo_handler_args_3).unwrap(); api.register(demo_handler_path_param_string).unwrap(); api.register(demo_handler_path_param_uuid).unwrap(); @@ -247,7 +248,7 @@ async fn test_demo2json() { ) .await .expect_err("expected failure"); - assert!(error.message.starts_with("unable to parse body")); + assert!(error.message.starts_with("unable to parse JSON body")); /* Test case: invalid JSON */ let error = testctx @@ -260,7 +261,7 @@ async fn test_demo2json() { ) .await .expect_err("expected failure"); - assert!(error.message.starts_with("unable to parse body")); + assert!(error.message.starts_with("unable to parse JSON body")); /* Test case: bad type */ let json_bad_type = "{ \"test1\": \"oops\", \"test2\": \"oops\" }"; @@ -275,12 +276,99 @@ async fn test_demo2json() { .await .expect_err("expected failure"); assert!(error.message.starts_with( - "unable to parse body: invalid type: string \"oops\", expected u32" + "unable to parse JSON body: invalid type: string \"oops\", expected u32" )); testctx.teardown().await; } +/* + * Handlers may also accept form/URL-encoded bodies. Here we test such + * bodies with both valid and invalid encodings. + */ +#[tokio::test] +async fn test_demo2urlencoded() { + let api = demo_api(); + let testctx = common::test_setup("demo2urlencoded", api); + + /* Test case: optional field */ + let input = DemoJsonBody { test1: "bar".to_string(), test2: None }; + let mut response = testctx + .client_testctx + .make_request_url_encoded( + Method::GET, + "/testing/demo2urlencoded", + Some(input), + StatusCode::OK, + ) + .await + .expect("expected success"); + let json: DemoJsonBody = read_json(&mut response).await; + assert_eq!(json.test1, "bar"); + assert_eq!(json.test2, None); + + /* Test case: both fields populated */ + let input = DemoJsonBody { test1: "baz".to_string(), test2: Some(20) }; + let mut response = testctx + .client_testctx + .make_request_url_encoded( + Method::GET, + "/testing/demo2urlencoded", + Some(input), + StatusCode::OK, + ) + .await + .expect("expected success"); + let json: DemoJsonBody = read_json(&mut response).await; + assert_eq!(json.test1, "baz"); + assert_eq!(json.test2, Some(20)); + + /* Error case: wrong content type for endpoint */ + let input = DemoJsonBody { test1: "qux".to_string(), test2: Some(30) }; + let error = testctx + .client_testctx + .make_request( + Method::GET, + "/testing/demo2urlencoded", + Some(input), + StatusCode::BAD_REQUEST, + ) + .await + .expect_err("expected failure"); + assert!(error.message.starts_with( + "expected content type \"application/x-www-form-urlencoded\", \ + got \"application/json\"" + )); + + /* Error case: invalid encoding */ + let error = testctx + .client_testctx + .make_request_with_body_url_encoded( + Method::GET, + "/testing/demo2urlencoded", + "test1=oops&test2".into(), + StatusCode::BAD_REQUEST, + ) + .await + .expect_err("expected failure"); + assert!(error.message.starts_with("unable to parse URL-encoded body")); + + /* Error case: bad type */ + let error = testctx + .client_testctx + .make_request_with_body_url_encoded( + Method::GET, + "/testing/demo2urlencoded", + "test1=oops&test2=oops".into(), + StatusCode::BAD_REQUEST, + ) + .await + .expect_err("expected failure"); + assert!(error.message.starts_with( + "unable to parse URL-encoded body: invalid digit found in string" + )); +} + /* * The "demo3" handler takes both query arguments and a JSON body. This test * makes sure that both sets of parameters are received by the handler function @@ -339,7 +427,7 @@ async fn test_demo3json() { ) .await .expect_err("expected error"); - assert!(error.message.starts_with("unable to parse body")); + assert!(error.message.starts_with("unable to parse JSON body")); testctx.teardown().await; } @@ -694,6 +782,18 @@ async fn demo_handler_args_2json( http_echo(&json.into_inner()) } +#[endpoint { + method = GET, + path = "/testing/demo2urlencoded", + content_type = "application/x-www-form-urlencoded", +}] +async fn demo_handler_args_2urlencoded( + _rqctx: RequestCtx, + body: TypedBody, +) -> Result, HttpError> { + http_echo(&body.into_inner()) +} + #[derive(Deserialize, Serialize, JsonSchema)] pub struct DemoJsonAndQuery { pub query: DemoQueryArgs, diff --git a/dropshot/tests/test_openapi.json b/dropshot/tests/test_openapi.json index c4dc35d4e..c164672de 100644 --- a/dropshot/tests/test_openapi.json +++ b/dropshot/tests/test_openapi.json @@ -458,6 +458,42 @@ } } }, + "/test/urlencoded": { + "post": { + "tags": [ + "it" + ], + "operationId": "handler20", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BodyParam" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/test/woman": { "put": { "tags": [ diff --git a/dropshot/tests/test_openapi.rs b/dropshot/tests/test_openapi.rs index 04e78f2d3..2ecaa39f8 100644 --- a/dropshot/tests/test_openapi.rs +++ b/dropshot/tests/test_openapi.rs @@ -407,6 +407,19 @@ async fn handler19( Ok(HttpResponseOk(example_object_with_example())) } +#[endpoint { + method = POST, + path = "/test/urlencoded", + content_type = "application/x-www-form-urlencoded", + tags = ["it"] +}] +async fn handler20( + _rqctx: Arc>, + _body: TypedBody, +) -> Result, HttpError> { + Ok(HttpResponseCreated(Response {})) +} + fn make_api( maybe_tag_config: Option, ) -> Result, String> { @@ -435,6 +448,7 @@ fn make_api( api.register(handler17)?; api.register(handler18)?; api.register(handler19)?; + api.register(handler20)?; Ok(api) } diff --git a/dropshot/tests/test_openapi_fuller.json b/dropshot/tests/test_openapi_fuller.json index 15a7a9589..1f141dbc2 100644 --- a/dropshot/tests/test_openapi_fuller.json +++ b/dropshot/tests/test_openapi_fuller.json @@ -466,6 +466,42 @@ } } }, + "/test/urlencoded": { + "post": { + "tags": [ + "it" + ], + "operationId": "handler20", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BodyParam" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/test/woman": { "put": { "tags": [ diff --git a/dropshot_endpoint/src/lib.rs b/dropshot_endpoint/src/lib.rs index c3aad174a..1cfe4d3a2 100644 --- a/dropshot_endpoint/src/lib.rs +++ b/dropshot_endpoint/src/lib.rs @@ -53,6 +53,7 @@ struct Metadata { path: String, tags: Option>, unpublished: Option, + content_type: Option, _dropshot_crate: Option, } @@ -82,6 +83,8 @@ const USAGE: &str = "Endpoint handlers must have the following signature: /// tags = [ "all", "your", "OpenAPI", "tags" ], /// // A value of `true` causes the API to be omitted from the API description /// unpublished = { true | false }, +/// // Specifies the media type used to encode the request body +/// content_type = { "application/json" | "application/x-www-form-urlencoded" } /// }] /// ``` /// @@ -115,9 +118,19 @@ fn do_endpoint( item: TokenStream, ) -> Result<(TokenStream, Vec), Error> { let metadata = from_tokenstream::(&attr)?; - let method = metadata.method.as_str(); let path = metadata.path; + let content_type = + metadata.content_type.unwrap_or_else(|| "application/json".to_string()); + if !matches!( + content_type.as_str(), + "application/json" | "application/x-www-form-urlencoded" + ) { + return Err(Error::new_spanned( + &attr, + "invalid content type for endpoint", + )); + } let ast: ItemFnForSignature = syn::parse2(item.clone())?; @@ -395,6 +408,7 @@ fn do_endpoint( #name_str.to_string(), #name, #dropshot::Method::#method_ident, + #content_type, #path, ) #summary @@ -641,6 +655,7 @@ mod tests { "handler_xyz".to_string(), handler_xyz, dropshot::Method::GET, + "application/json", "/a/b/c", ) } @@ -728,6 +743,7 @@ mod tests { "handler_xyz".to_string(), handler_xyz, dropshot::Method::GET, + "application/json", "/a/b/c", ) } @@ -833,6 +849,7 @@ mod tests { "handler_xyz".to_string(), handler_xyz, dropshot::Method::GET, + "application/json", "/a/b/c", ) } @@ -938,6 +955,7 @@ mod tests { "handler_xyz".to_string(), handler_xyz, dropshot::Method::GET, + "application/json", "/a/b/c", ) } @@ -1031,6 +1049,7 @@ mod tests { "handler_xyz".to_string(), handler_xyz, dropshot::Method::GET, + "application/json", "/a/b/c", ) .tag("stuff") @@ -1127,6 +1146,7 @@ mod tests { "handler_xyz".to_string(), handler_xyz, dropshot::Method::GET, + "application/json", "/a/b/c", ) .summary("handle \"xyz\" requests") @@ -1246,6 +1266,101 @@ mod tests { ); } + #[test] + fn test_endpoint_content_type() { + let (item, errors) = do_endpoint( + quote! { + method = POST, + path = "/a/b/c", + content_type = "application/x-www-form-urlencoded" + }, + quote! { + pub async fn handler_xyz( + _rqctx: Arc>, + ) -> Result, HttpError> { + Ok(()) + } + }, + ) + .unwrap(); + + let expected = quote! { + const _: fn() = || { + struct NeedRequestContext( > as dropshot::RequestContextArgument>::Context) ; + }; + const _: fn() = || { + trait ResultTrait { + type T; + type E; + } + impl ResultTrait for Result + where + TT: dropshot::HttpResponse, + { + type T = TT; + type E = EE; + } + struct NeedHttpResponse( + , HttpError> as ResultTrait>::T, + ); + trait TypeEq { + type This: ?Sized; + } + impl TypeEq for T { + type This = Self; + } + fn validate_result_error_type() + where + T: ?Sized + TypeEq, + { + } + validate_result_error_type::< + , HttpError> as ResultTrait>::E, + >(); + }; + + #[allow(non_camel_case_types, missing_docs)] + #[doc = "API Endpoint: handler_xyz"] + pub struct handler_xyz {} + + #[allow(non_upper_case_globals, missing_docs)] + #[doc = "API Endpoint: handler_xyz"] + pub const handler_xyz: handler_xyz = handler_xyz {}; + + impl From + for dropshot::ApiEndpoint< + + > as dropshot::RequestContextArgument>::Context> + { + fn from(_: handler_xyz) -> Self { + pub async fn handler_xyz( + _rqctx: Arc>, + ) -> Result, HttpError> { + Ok(()) + } + + const _: fn() = || { + fn future_endpoint_must_be_send(_t: T) {} + fn check_future_bounds(arg0: Arc< RequestContext<()> >) { + future_endpoint_must_be_send(handler_xyz(arg0)); + } + }; + + dropshot::ApiEndpoint::new( + "handler_xyz".to_string(), + handler_xyz, + dropshot::Method::POST, + "application/x-www-form-urlencoded", + "/a/b/c", + ) + } + } + }; + + assert!(errors.is_empty()); + assert_eq!(expected.to_string(), item.to_string()); + } + #[test] fn test_extract_summary_description() { /**