diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index c75e7f85b..b72848709 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -26,6 +26,26 @@ Defining the old config option will produce an error, guiding you to perform the ** `rqctx.path_variables` is now `rqctx.endpoint.variables`. ** `rqctx.body_content_type` is now `rqctx.endpoint.body_content_type`. +=== Other notable changes + +* Dropshot now supports per-endpoint size limits, via the `request_body_max_bytes` parameter to `#[endpoint]`. For example, to set a limit of 1 MiB on an endpoint: ++ +```rust +#[endpoint { + method = POST, + path = "/upload-bundle", + request_body_max_bytes = 1 * 1024 * 1024, +}] +async fn upload_bundle( + rqctx: RequestContext, // or RequestContext with API traits + body: UntypedBody, +) -> /* ... */ { + // ... +} +``` ++ +If not specified, the limit defaults to the server configuration's `default_request_body_max_bytes`. + == 0.13.0 (released 2024-11-13) https://github.com/oxidecomputer/dropshot/compare/v0.12.0\...v0.13.0[Full list of commits] diff --git a/README.adoc b/README.adoc index c46c27cc7..cb9e59245 100644 --- a/README.adoc +++ b/README.adoc @@ -50,6 +50,8 @@ include: |No |Specifies the maximum number of bytes allowed in a request body. Larger requests will receive a 400 error. Defaults to 1024. +Can be overridden per-endpoint via the `request_body_max_bytes` parameter to `#[endpoint { ... }]`. + |`tls.type` |`"AsFile"` |No diff --git a/dropshot/src/api_description.rs b/dropshot/src/api_description.rs index f7f7893a1..2fadf4bc7 100644 --- a/dropshot/src/api_description.rs +++ b/dropshot/src/api_description.rs @@ -52,6 +52,10 @@ pub struct ApiEndpoint { pub path: String, pub parameters: Vec, pub body_content_type: ApiEndpointBodyContentType, + /// An override for the maximum allowed size of the request body. + /// + /// `None` means that the server default is used. + pub request_body_max_bytes: Option, pub response: ApiEndpointResponse, pub summary: Option, pub description: Option, @@ -88,6 +92,7 @@ impl<'a, Context: ServerContext> ApiEndpoint { path: path.to_string(), parameters: func_parameters.parameters, body_content_type, + request_body_max_bytes: None, response, summary: None, description: None, @@ -109,6 +114,11 @@ impl<'a, Context: ServerContext> ApiEndpoint { self } + pub fn request_body_max_bytes(mut self, max_bytes: usize) -> Self { + self.request_body_max_bytes = Some(max_bytes); + self + } + pub fn tag(mut self, tag: T) -> Self { self.tags.push(tag.to_string()); self @@ -188,6 +198,7 @@ impl<'a> ApiEndpoint { path: path.to_string(), parameters: func_parameters.parameters, body_content_type, + request_body_max_bytes: None, response, summary: None, description: None, diff --git a/dropshot/src/handler.rs b/dropshot/src/handler.rs index 5e2bde4dc..cc90c86d0 100644 --- a/dropshot/src/handler.rs +++ b/dropshot/src/handler.rs @@ -174,8 +174,14 @@ impl RequestContext { } /// Returns the maximum request body size. + /// + /// This is typically the same as + /// `self.server.config.request_body_max_bytes`, but can be overridden on a + /// per-endpoint basis. pub fn request_body_max_bytes(&self) -> usize { - self.server.config.default_request_body_max_bytes + self.endpoint + .request_body_max_bytes + .unwrap_or(self.server.config.default_request_body_max_bytes) } /// Returns the appropriate count of items to return for a paginated request @@ -218,6 +224,9 @@ pub struct RequestEndpointMetadata { /// The expected request body MIME type. pub body_content_type: ApiEndpointBodyContentType, + + /// The maximum number of bytes allowed in the request body, if overridden. + pub request_body_max_bytes: Option, } /// Helper trait for extracting the underlying Context type from the diff --git a/dropshot/src/lib.rs b/dropshot/src/lib.rs index 319cdde18..75cc2f395 100644 --- a/dropshot/src/lib.rs +++ b/dropshot/src/lib.rs @@ -707,7 +707,7 @@ //! //! ```text //! // introduced in 1.0.0, present in all subsequent versions -//! versions = "1.0.0".. +//! versions = "1.0.0".. //! //! // removed in 2.0.0, present in all previous versions //! // (not present in 2.0.0 itself) diff --git a/dropshot/src/router.rs b/dropshot/src/router.rs index 4d1f756fe..f22d9cd45 100644 --- a/dropshot/src/router.rs +++ b/dropshot/src/router.rs @@ -520,6 +520,7 @@ impl HttpRouter { operation_id: handler.operation_id.clone(), variables, body_content_type: handler.body_content_type.clone(), + request_body_max_bytes: handler.request_body_max_bytes, }, }); } @@ -874,6 +875,7 @@ mod test { path: path.to_string(), parameters: vec![], body_content_type: ApiEndpointBodyContentType::default(), + request_body_max_bytes: None, response: ApiEndpointResponse::default(), summary: None, description: None, diff --git a/dropshot/src/websocket.rs b/dropshot/src/websocket.rs index f546c5d4c..a2fee96ec 100644 --- a/dropshot/src/websocket.rs +++ b/dropshot/src/websocket.rs @@ -403,6 +403,7 @@ mod tests { operation_id: "".to_string(), variables: Default::default(), body_content_type: Default::default(), + request_body_max_bytes: None, }, request_id: "".to_string(), log: log.clone(), diff --git a/dropshot/tests/fail/bad_channel28.rs b/dropshot/tests/fail/bad_channel28.rs new file mode 100644 index 000000000..9c080615c --- /dev/null +++ b/dropshot/tests/fail/bad_channel28.rs @@ -0,0 +1,24 @@ +// Copyright 2024 Oxide Computer Company + +#![allow(unused_imports)] + +use dropshot::channel; +use dropshot::RequestContext; +use dropshot::WebsocketUpgrade; + +// Test: request_body_max_bytes specified for channel (this parameter is only +// accepted for endpoints, not channels) + +#[channel { + protocol = WEBSOCKETS, + path = "/test", + request_body_max_bytes = 1024, +}] +async fn bad_channel( + _rqctx: RequestContext<()>, + _upgraded: WebsocketUpgrade, +) -> dropshot::WebsocketChannelResult { + Ok(()) +} + +fn main() {} diff --git a/dropshot/tests/fail/bad_channel28.stderr b/dropshot/tests/fail/bad_channel28.stderr new file mode 100644 index 000000000..a576fe24b --- /dev/null +++ b/dropshot/tests/fail/bad_channel28.stderr @@ -0,0 +1,5 @@ +error: extraneous member `request_body_max_bytes` + --> tests/fail/bad_channel28.rs:15:5 + | +15 | request_body_max_bytes = 1024, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/dropshot/tests/fail/bad_endpoint28.rs b/dropshot/tests/fail/bad_endpoint28.rs new file mode 100644 index 000000000..77c295742 --- /dev/null +++ b/dropshot/tests/fail/bad_endpoint28.rs @@ -0,0 +1,23 @@ +// Copyright 2024 Oxide Computer Company + +#![allow(unused_imports)] + +use dropshot::endpoint; +use dropshot::HttpError; +use dropshot::HttpResponseUpdatedNoContent; +use dropshot::RequestContext; + +// Test: incorrect type for request_body_max_bytes. + +#[endpoint { + method = GET, + path = "/test", + request_body_max_bytes = "not_a_number" +}] +async fn bad_endpoint( + _rqctx: RequestContext<()>, +) -> Result { + Ok(HttpResponseUpdatedNoContent()) +} + +fn main() {} diff --git a/dropshot/tests/fail/bad_endpoint28.stderr b/dropshot/tests/fail/bad_endpoint28.stderr new file mode 100644 index 000000000..66152d557 --- /dev/null +++ b/dropshot/tests/fail/bad_endpoint28.stderr @@ -0,0 +1,14 @@ +error[E0308]: mismatched types + --> tests/fail/bad_endpoint28.rs:15:30 + | +15 | request_body_max_bytes = "not_a_number" + | ^^^^^^^^^^^^^^ expected `usize`, found `&str` +16 | }] +17 | async fn bad_endpoint( + | ------------ arguments to this method are incorrect + | +note: method defined here + --> src/api_description.rs + | + | pub fn request_body_max_bytes(mut self, max_bytes: usize) -> Self { + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/dropshot/tests/fail/bad_trait_channel28.rs b/dropshot/tests/fail/bad_trait_channel28.rs new file mode 100644 index 000000000..c76234730 --- /dev/null +++ b/dropshot/tests/fail/bad_trait_channel28.rs @@ -0,0 +1,44 @@ +// Copyright 2024 Oxide Computer Company + +#![allow(unused_imports)] + +use dropshot::channel; +use dropshot::RequestContext; +use dropshot::WebsocketUpgrade; + +#[dropshot::api_description] +trait MyServer { + type Context; + + // Test: request_body_max_bytes specified for channel (this parameter is only + // accepted for endpoints, not channels) + #[channel { + protocol = WEBSOCKETS, + path = "/test", + request_body_max_bytes = 1024, + }] + async fn bad_channel( + _rqctx: RequestContext, + _upgraded: WebsocketUpgrade, + ) -> dropshot::WebsocketChannelResult; +} + +enum MyImpl {} + +// This should not produce errors about items being missing. +impl MyServer for MyImpl { + type Context = (); + + async fn bad_channel( + _rqctx: RequestContext, + _upgraded: WebsocketUpgrade, + ) -> dropshot::WebsocketChannelResult { + Ok(()) + } +} + +fn main() { + // These items should be generated and accessible. + my_server_mod::api_description::(); + my_server_mod::stub_api_description(); +} diff --git a/dropshot/tests/fail/bad_trait_channel28.stderr b/dropshot/tests/fail/bad_trait_channel28.stderr new file mode 100644 index 000000000..80f09aa7c --- /dev/null +++ b/dropshot/tests/fail/bad_trait_channel28.stderr @@ -0,0 +1,5 @@ +error: endpoint `bad_channel` has invalid attributes: extraneous member `request_body_max_bytes` + --> tests/fail/bad_trait_channel28.rs:18:9 + | +18 | request_body_max_bytes = 1024, + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/dropshot/tests/fail/bad_trait_endpoint28.rs b/dropshot/tests/fail/bad_trait_endpoint28.rs new file mode 100644 index 000000000..a96a22e8b --- /dev/null +++ b/dropshot/tests/fail/bad_trait_endpoint28.rs @@ -0,0 +1,43 @@ +// Copyright 2024 Oxide Computer Company + +#![allow(unused_imports)] + +use dropshot::endpoint; +use dropshot::HttpError; +use dropshot::HttpResponseUpdatedNoContent; +use dropshot::RequestContext; + +// Test: incorrect type for request_body_max_bytes. + +#[dropshot::api_description] +trait MyApi { + type Context; + + #[endpoint { + method = GET, + path = "/test", + request_body_max_bytes = "not_a_number", + }] + async fn bad_endpoint( + _rqctx: RequestContext, + ) -> Result; +} + +enum MyImpl {} + +// This should not produce errors about items being missing. + +impl MyApi for MyImpl { + type Context = (); + async fn bad_endpoint( + _rqctx: RequestContext, + ) -> Result { + Ok(HttpResponseUpdatedNoContent()) + } +} + +fn main() { + // These items should be generated and accessible. + my_api_mod::api_description::(); + my_api_mod::stub_api_description(); +} diff --git a/dropshot/tests/fail/bad_trait_endpoint28.stderr b/dropshot/tests/fail/bad_trait_endpoint28.stderr new file mode 100644 index 000000000..336217dbf --- /dev/null +++ b/dropshot/tests/fail/bad_trait_endpoint28.stderr @@ -0,0 +1,14 @@ +error[E0308]: mismatched types + --> tests/fail/bad_trait_endpoint28.rs:19:34 + | +16 | #[endpoint { + | - arguments to this method are incorrect +... +19 | request_body_max_bytes = "not_a_number", + | ^^^^^^^^^^^^^^ expected `usize`, found `&str` + | +note: method defined here + --> src/api_description.rs + | + | pub fn request_body_max_bytes(mut self, max_bytes: usize) -> Self { + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/dropshot/tests/integration-tests/api_trait.rs b/dropshot/tests/integration-tests/api_trait.rs index 2c0c469a8..fe95438ae 100644 --- a/dropshot/tests/integration-tests/api_trait.rs +++ b/dropshot/tests/integration-tests/api_trait.rs @@ -1,8 +1,23 @@ // Copyright 2024 Oxide Computer Company use dropshot::{ - EndpointTagPolicy, HttpError, HttpResponseUpdatedNoContent, RequestContext, + test_util::read_json, EndpointTagPolicy, HandlerTaskMode, HttpError, + HttpResponseOk, HttpResponseUpdatedNoContent, RequestContext, UntypedBody, }; +use http::{Method, StatusCode}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::common; + +// Max request body sizes are often specified as literals, but ensure that +// constants also work. +const LARGE_REQUEST_SIZE: usize = 2048; + +#[derive(Deserialize, Serialize, JsonSchema)] +struct DemoUntyped { + pub nbytes: usize, +} #[dropshot::api_description] trait BasicApi { @@ -12,6 +27,18 @@ trait BasicApi { async fn get_test( _rqctx: RequestContext, ) -> Result; + + // Test one of the custom request_body_max_bytes cases. The other cases are + // covered in `test_demo`. + #[endpoint { + method = PUT, + path = "/test/large_untyped_body", + request_body_max_bytes = LARGE_REQUEST_SIZE, + }] + async fn large_untyped_body( + _rqctx: RequestContext, + body: UntypedBody, + ) -> Result, HttpError>; } enum BasicImpl {} @@ -24,12 +51,60 @@ impl BasicApi for BasicImpl { ) -> Result { Ok(HttpResponseUpdatedNoContent()) } + + async fn large_untyped_body( + _rqctx: RequestContext, + body: UntypedBody, + ) -> Result, HttpError> { + Ok(HttpResponseOk(DemoUntyped { nbytes: body.as_bytes().len() })) + } } -#[test] -fn test_api_trait_basic() { - basic_api_mod::api_description::().unwrap(); +#[tokio::test] +async fn test_api_trait_basic() { basic_api_mod::stub_api_description().unwrap(); + + let api = basic_api_mod::api_description::().unwrap(); + let testctx = common::test_setup_with_context( + "api_trait_basic", + api, + (), + HandlerTaskMode::Detached, + ); + + // Success case: large body endpoint. + let large_body = vec![0u8; 2048]; + let mut response = testctx + .client_testctx + .make_request_with_body( + Method::PUT, + "/test/large_untyped_body", + large_body.into(), + StatusCode::OK, + ) + .await + .expect("expected success"); + let json: DemoUntyped = read_json(&mut response).await; + assert_eq!(json.nbytes, 2048); + + // Error case: large body endpoint failure. + let large_body = vec![0u8; 2049]; + let error = testctx + .client_testctx + .make_request_with_body( + Method::PUT, + "/test/large_untyped_body", + large_body.into(), + StatusCode::BAD_REQUEST, + ) + .await + .unwrap_err(); + assert_eq!( + error.message, + "request body exceeded maximum size of 2048 bytes" + ); + + testctx.teardown().await; } #[dropshot::api_description { diff --git a/dropshot/tests/integration-tests/demo.rs b/dropshot/tests/integration-tests/demo.rs index 4c692c1c7..4e3bebd58 100644 --- a/dropshot/tests/integration-tests/demo.rs +++ b/dropshot/tests/integration-tests/demo.rs @@ -73,8 +73,11 @@ fn demo_api() -> ApiDescription { api.register(demo_handler_path_param_string).unwrap(); api.register(demo_handler_path_param_uuid).unwrap(); api.register(demo_handler_path_param_u32).unwrap(); + api.register(demo_large_typed_body).unwrap(); api.register(demo_handler_untyped_body).unwrap(); + api.register(demo_handler_large_untyped_body).unwrap(); api.register(demo_handler_streaming_body).unwrap(); + api.register(demo_handler_large_streaming_body).unwrap(); api.register(demo_handler_raw_request).unwrap(); api.register(demo_handler_delete).unwrap(); api.register(demo_handler_head_get).unwrap(); @@ -648,6 +651,48 @@ async fn test_demo_path_param_u32() { testctx.teardown().await; } +// Test a `TypedBody` with a large payload. +#[tokio::test] +async fn test_large_typed_body() { + let api = demo_api(); + let testctx = common::test_setup("test_large_typed_body", api); + let client = &testctx.client_testctx; + + // This serializes to exactly 2048 bytes. + let body = DemoLargeTypedBody { body: vec![0; 1019] }; + let body_json = serde_json::to_string(&body).unwrap(); + assert_eq!(body_json.len(), 2048); + let mut response = client + .make_request_with_body( + Method::GET, + "/testing/large_typed_body", + body_json.into(), + StatusCode::OK, + ) + .await + .unwrap(); + let response_json: DemoLargeTypedBody = read_json(&mut response).await; + assert_eq!(body, response_json); + + // This serializes to 2050 bytes, which is over the limit. + let body = DemoLargeTypedBody { body: vec![0; 1020] }; + let body_json = serde_json::to_string(&body).unwrap(); + assert_eq!(body_json.len(), 2050); + let error = client + .make_request_with_body( + Method::GET, + "/testing/large_typed_body", + body_json.into(), + StatusCode::BAD_REQUEST, + ) + .await + .unwrap_err(); + assert_eq!( + error.message, + "request body exceeded maximum size of 2048 bytes" + ); +} + // Test `UntypedBody`. #[tokio::test] async fn test_untyped_body() { @@ -731,6 +776,36 @@ async fn test_untyped_body() { assert_eq!(json.nbytes, 4); assert_eq!(json.as_utf8, Some(String::from("tμv"))); + // Success case: large body endpoint. + let large_body = vec![0u8; 2048]; + let mut response = client + .make_request_with_body( + Method::PUT, + "/testing/large_untyped_body", + large_body.into(), + StatusCode::OK, + ) + .await + .unwrap(); + let json: DemoUntyped = read_json(&mut response).await; + assert_eq!(json.nbytes, 2048); + + // Error case: large body endpoint failure. + let large_body = vec![0u8; 2049]; + let error = client + .make_request_with_body( + Method::PUT, + "/testing/large_untyped_body", + large_body.into(), + StatusCode::BAD_REQUEST, + ) + .await + .unwrap_err(); + assert_eq!( + error.message, + "request body exceeded maximum size of 2048 bytes" + ); + testctx.teardown().await; } @@ -783,6 +858,38 @@ async fn test_streaming_body() { error.message, "request body exceeded maximum size of 1024 bytes" ); + + // Success case: large body endpoint. + let large_body = vec![0u8; 2048]; + let mut response = client + .make_request_with_body( + Method::PUT, + "/testing/large_streaming_body", + large_body.into(), + StatusCode::OK, + ) + .await + .unwrap(); + let json: DemoUntyped = read_json(&mut response).await; + assert_eq!(json.nbytes, 2048); + + // Error case: large body endpoint failure. + let large_body = vec![0u8; 2049]; + let error = client + .make_request_with_body( + Method::PUT, + "/testing/large_streaming_body", + large_body.into(), + StatusCode::BAD_REQUEST, + ) + .await + .unwrap_err(); + assert_eq!( + error.message, + "request body exceeded maximum size of 2048 bytes" + ); + + testctx.teardown().await; } // Test `RawRequest`. @@ -1193,6 +1300,22 @@ async fn demo_handler_path_param_u32( http_echo(&path_params.into_inner()) } +#[derive(Debug, Deserialize, Serialize, JsonSchema, Eq, PartialEq)] +pub struct DemoLargeTypedBody { + pub body: Vec, +} +#[endpoint { + method = GET, + path = "/testing/large_typed_body", + request_body_max_bytes = 2048, +}] +async fn demo_large_typed_body( + _rqctx: RequestCtx, + body: TypedBody, +) -> Result, HttpError> { + http_echo(&body.into_inner()) +} + #[derive(Deserialize, Serialize, JsonSchema)] pub struct DemoUntyped { pub nbytes: usize, @@ -1221,6 +1344,26 @@ async fn demo_handler_untyped_body( Ok(HttpResponseOk(DemoUntyped { nbytes, as_utf8 })) } +#[endpoint { + method = PUT, + path = "/testing/large_untyped_body", + request_body_max_bytes = 2048, +}] +async fn demo_handler_large_untyped_body( + _rqctx: RequestContext, + query: Query, + body: UntypedBody, +) -> Result, HttpError> { + let nbytes = body.as_bytes().len(); + let as_utf8 = if query.into_inner().parse_str.unwrap_or(false) { + Some(String::from(body.as_str()?)) + } else { + None + }; + + Ok(HttpResponseOk(DemoUntyped { nbytes, as_utf8 })) +} + #[derive(Deserialize, Serialize, JsonSchema)] pub struct DemoStreaming { pub nbytes: usize, @@ -1241,6 +1384,23 @@ async fn demo_handler_streaming_body( Ok(HttpResponseOk(DemoStreaming { nbytes })) } +#[endpoint { + method = PUT, + path = "/testing/large_streaming_body", + request_body_max_bytes = 2048, +}] +async fn demo_handler_large_streaming_body( + _rqctx: RequestContext, + body: StreamingBody, +) -> Result, HttpError> { + let nbytes = body + .into_stream() + .try_fold(0, |acc, v| futures::future::ok(acc + v.len())) + .await?; + + Ok(HttpResponseOk(DemoStreaming { nbytes })) +} + #[derive(Deserialize, Serialize, JsonSchema)] pub struct DemoRaw { pub nbytes: usize, diff --git a/dropshot_endpoint/src/lib.rs b/dropshot_endpoint/src/lib.rs index e2377d4cd..c00d65dca 100644 --- a/dropshot_endpoint/src/lib.rs +++ b/dropshot_endpoint/src/lib.rs @@ -21,27 +21,67 @@ mod syn_parsing; mod test_util; mod util; -/// This attribute transforms a handler function into a Dropshot endpoint +/// Transforms a handler function into a Dropshot endpoint /// suitable to be used as a parameter to /// [`ApiDescription::register()`](../dropshot/struct.ApiDescription.html#method.register). -/// It encodes information relevant to the operation of an API endpoint beyond -/// what is expressed by the parameter and return types of a handler function. +/// +/// The arguments to this macro encode information relevant to the operation of +/// an API endpoint beyond what is expressed by the parameter and return types +/// of a handler function. +/// +/// ## Arguments +/// +/// The `#[dropshot::endpoint]` macro accepts the following arguments: +/// +/// * `method`: The [HTTP request method] (HTTP verb) for the endpoint. Can be +/// one of `DELETE`, `HEAD`, `GET`, `OPTIONS`, `PATCH`, `POST`, or `PUT`. +/// Required. +/// * `path`: The path to the endpoint, along with path variables. Path +/// variables are enclosed in curly braces. For example, `path = +/// "/widget/{id}"`. Required. +/// * `tags`: An array of [OpenAPI tags] for the operation. Optional, defaults +/// to an empty list. +/// * `content_type`: The media type used to encode the request body. Can be one +/// of `application/json`, `application/x-www-form-urlencoded`, or +/// `multipart/form-data`. Optional, defaults to `application/json`. +/// * `deprecated`: A boolean indicating whether the operation is marked +/// deprecated in the OpenAPI document. Optional, defaults to false. +/// * `unpublished`: A boolean indicating whether the operation is omitted from +/// the OpenAPI document. Optional, defaults to false. +/// * `request_body_max_bytes`: The maximum size of the request body in bytes. +/// Accepts literals as well as constants of type `usize`. Optional, defaults +/// to the server configuration's `default_request_body_max_bytes`. +/// +/// [HTTP request method]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods +/// [OpenAPI tags]: https://swagger.io/docs/specification/v3_0/grouping-operations-with-tags/ +/// +/// ### Example: configuring an endpoint /// /// ```ignore +/// const LARGE_REQUEST_BODY_MAX_BYTES: usize = 1 * 1024 * 1024; +/// /// #[endpoint { -/// // Required fields +/// // --- Required fields --- +/// // The HTTP method for the endpoint /// method = { DELETE | HEAD | GET | OPTIONS | PATCH | POST | PUT }, +/// // The path to the endpoint, along with path variables /// path = "/path/name/with/{named}/{variables}", /// -/// // Optional tags for the operation's description +/// // --- Optional fields --- +/// // Tags for the operation's description /// tags = [ "all", "your", "OpenAPI", "tags" ], -/// // Specifies the media type used to encode the request body +/// // The media type used to encode the request body /// content_type = { "application/json" | "application/x-www-form-urlencoded" | "multipart/form-data" } -/// // A value of `true` marks the operation as deprecated +/// // True if the operation is deprecated /// deprecated = { true | false }, -/// // A value of `true` causes the operation to be omitted from the API description +/// // True causes the operation to be omitted from the API description /// unpublished = { true | false }, +/// // Maximum request body size in bytes +/// request_body_max_bytes = LARGE_REQUEST_BODY_MAX_BYTES, /// }] +/// async fn my_endpoint(/* ... */) -> Result { +/// // ... +/// } /// ``` /// /// See the dropshot documentation for diff --git a/dropshot_endpoint/src/metadata.rs b/dropshot_endpoint/src/metadata.rs index c96092021..eed91a18c 100644 --- a/dropshot_endpoint/src/metadata.rs +++ b/dropshot_endpoint/src/metadata.rs @@ -3,8 +3,9 @@ //! Code to handle metadata associated with an endpoint. use proc_macro2::{TokenStream, TokenTree}; -use quote::{format_ident, quote, quote_spanned, ToTokens}; +use quote::{format_ident, quote_spanned, ToTokens}; use serde::Deserialize; +use serde_tokenstream::ParseWrapper; use syn::{spanned::Spanned, Error}; use crate::{ @@ -12,7 +13,6 @@ use crate::{ error_store::ErrorSink, util::{get_crate, is_wildcard_path, MacroKind, ValidContentType}, }; -use serde_tokenstream::ParseWrapper; #[allow(non_snake_case)] #[derive(Deserialize, Debug)] @@ -52,6 +52,9 @@ pub(crate) struct EndpointMetadata { pub(crate) unpublished: bool, #[serde(default)] pub(crate) deprecated: bool, + // Optional expression of type `usize`. + #[serde(default)] + pub(crate) request_body_max_bytes: Option>, pub(crate) content_type: Option, pub(crate) _dropshot_crate: Option, pub(crate) versions: Option>, @@ -84,6 +87,7 @@ impl EndpointMetadata { tags, unpublished, deprecated, + request_body_max_bytes, content_type, _dropshot_crate, versions, @@ -142,6 +146,8 @@ impl EndpointMetadata { tags, unpublished, deprecated, + request_body_max_bytes: request_body_max_bytes + .map(|x| x.into_inner()), content_type, versions: versions .map(|h| h.into_inner()) @@ -161,6 +167,7 @@ pub(crate) struct ValidatedEndpointMetadata { tags: Vec, unpublished: bool, deprecated: bool, + request_body_max_bytes: Option, content_type: ValidContentType, versions: VersionRange, } @@ -186,34 +193,61 @@ impl ValidatedEndpointMetadata { self.operation_id.as_deref().unwrap_or(endpoint_name); let method_ident = format_ident!("{}", self.method.as_str()); + // Apply a span to all generated code to avoid call-site attribution. + let span = match kind { + ApiEndpointKind::Regular(endpoint_fn) => endpoint_fn.span(), + ApiEndpointKind::Stub { attr, .. } => { + // We need to point at the closest possible span to the actual + // error, but we can't point at something nice like the + // function name. That's because if we do, rust-analyzer will + // produce a lot of irrelevant results when ctrl-clicking on + // the function name. + // + // So we point at the `#`, which seems out-of-the-way enough + // for successful generation while being close by for errors. + // Seems pretty unobjectionable. + attr.pound_token.span() + } + }; + + // Set the span for all of the bits and pieces. Most of these fields are + // unlikely to produce compile errors or warnings, but some of them + // (like request_body_max_bytes) can do so. let summary = doc.summary.as_ref().map(|summary| { - quote! { .summary(#summary) } + quote_spanned! {span=> .summary(#summary) } }); let description = doc.description.as_ref().map(|description| { - quote! { .description(#description) } + quote_spanned! {span=> .description(#description) } }); let tags = self .tags .iter() .map(|tag| { - quote! { .tag(#tag) } + quote_spanned! {span=> .tag(#tag) } }) .collect::>(); let visible = self.unpublished.then(|| { - quote! { .visible(false) } + quote_spanned! {span=> .visible(false) } }); let deprecated = self.deprecated.then(|| { - quote! { .deprecated(true) } + quote_spanned! {span=> .deprecated(true) } }); + let request_body_max_bytes = + self.request_body_max_bytes.as_ref().map(|max_bytes| { + quote_spanned! {span=> .request_body_max_bytes(#max_bytes) } + }); + let versions = match &self.versions { - VersionRange::All => quote! { #dropshot::ApiEndpointVersions::All }, + VersionRange::All => { + quote_spanned! {span=> #dropshot::ApiEndpointVersions::All } + } VersionRange::From(x) => { let (major, minor, patch) = semver_parts(&x); - quote! { + quote_spanned! {span=> #dropshot::ApiEndpointVersions::From( semver::Version::new(#major, #minor, #patch) ) @@ -221,7 +255,7 @@ impl ValidatedEndpointMetadata { } VersionRange::Until(y) => { let (major, minor, patch) = semver_parts(&y); - quote! { + quote_spanned! {span=> #dropshot::ApiEndpointVersions::Until( semver::Version::new(#major, #minor, #patch) ) @@ -230,7 +264,7 @@ impl ValidatedEndpointMetadata { VersionRange::FromUntil(x, y) => { let (xmajor, xminor, xpatch) = semver_parts(&x); let (ymajor, yminor, ypatch) = semver_parts(&y); - quote! { + quote_spanned! {span=> #dropshot::ApiEndpointVersions::from_until( semver::Version::new(#xmajor, #xminor, #xpatch), semver::Version::new(#ymajor, #yminor, #ypatch), @@ -241,7 +275,7 @@ impl ValidatedEndpointMetadata { let fn_call = match kind { ApiEndpointKind::Regular(endpoint_fn) => { - quote_spanned! {endpoint_fn.span()=> + quote_spanned! {span=> #dropshot::ApiEndpoint::new( #operation_id.to_string(), #endpoint_fn, @@ -252,17 +286,8 @@ impl ValidatedEndpointMetadata { ) } } - ApiEndpointKind::Stub { attr, extractor_types, ret_ty } => { - // We need to point at the closest possible span to the actual - // error, but we can't point at something nice like the - // function name. That's because if we do, rust-analyzer will - // produce a lot of irrelevant results when ctrl-clicking on - // the function name. - // - // So we point at the `#`, which seems out-of-the-way enough - // for successful generation while being close by for errors. - // Seems pretty unobjectionable. - quote_spanned! {attr.pound_token.span()=> + ApiEndpointKind::Stub { attr: _, extractor_types, ret_ty } => { + quote_spanned! {span=> #dropshot::ApiEndpoint::new_for_types::<(#(#extractor_types,)*), #ret_ty>( #operation_id.to_string(), #dropshot::Method::#method_ident, @@ -274,13 +299,14 @@ impl ValidatedEndpointMetadata { } }; - quote! { + quote_spanned! {span=> #fn_call #summary #description #(#tags)* #visible #deprecated + #request_body_max_bytes } } } @@ -457,6 +483,9 @@ impl ChannelMetadata { versions: versions .map(|h| h.into_inner()) .unwrap_or(VersionRange::All), + // Channels are arbitrary-length and don't have a limit on + // request body size. + request_body_max_bytes: None, }; Some(ValidatedChannelMetadata { inner })