diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 47166f0c9..e98e34098 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -16,6 +16,7 @@ https://github.com/oxidecomputer/dropshot/compare/v0.17.1\...HEAD[Full list of commits] * The minimum supported Rust version is now 1.88. +* Endpoint and channel methods within API traits can now have raw identifier names (such as `r#async`). Previously this caused a proc-macro panic. == 0.17.1 (released 2026-06-02) diff --git a/dropshot/tests/integration-tests/api_trait.rs b/dropshot/tests/integration-tests/api_trait.rs index bcb20ac4d..48fe07d7d 100644 --- a/dropshot/tests/integration-tests/api_trait.rs +++ b/dropshot/tests/integration-tests/api_trait.rs @@ -108,6 +108,56 @@ async fn test_api_trait_basic() { testctx.teardown().await; } +// The trait is allowed to have raw identifiers. +#[dropshot::api_description { context = "r#type", module = "r#mod" }] +#[allow(non_camel_case_types)] +trait RawIdentApi { + type r#type; + + #[endpoint { method = GET, path = "/async" }] + async fn r#async( + _rqctx: RequestContext, + ) -> Result; + + #[channel { protocol = WEBSOCKETS, path = "/await" }] + async fn r#await( + _rqctx: RequestContext, + _upgraded: dropshot::WebsocketConnection, + ) -> dropshot::WebsocketChannelResult; +} + +enum RawIdentImpl {} + +impl RawIdentApi for RawIdentImpl { + type r#type = (); + + async fn r#async( + _rqctx: RequestContext, + ) -> Result { + Ok(HttpResponseUpdatedNoContent()) + } + + async fn r#await( + _rqctx: RequestContext, + _upgraded: dropshot::WebsocketConnection, + ) -> dropshot::WebsocketChannelResult { + Ok(()) + } +} + +#[test] +fn test_api_trait_raw_idents() { + r#mod::stub_api_description().unwrap(); + + let api = r#mod::api_description::().unwrap(); + let spec = api + .openapi("Raw identifiers", semver::Version::new(1, 0, 0)) + .json() + .unwrap(); + assert!(spec["paths"]["/async"]["get"].is_object(), "{spec:#}"); + assert!(spec["paths"]["/await"]["get"].is_object(), "{spec:#}"); +} + #[dropshot::api_description { tag_config = { tags = {}, diff --git a/dropshot_endpoint/src/api_trait.rs b/dropshot_endpoint/src/api_trait.rs index a9cdfa18f..8d1c5ef3a 100644 --- a/dropshot_endpoint/src/api_trait.rs +++ b/dropshot_endpoint/src/api_trait.rs @@ -1387,7 +1387,7 @@ impl<'ast> ApiEndpoint<'ast> { // // Note that there isn't any possible variable name collision here, // since all names are prefixed with "endpoint_". - let endpoint_name = format_ident!("endpoint_{}", name_str); + let endpoint_name = endpoint_var_name(&name_str); quote_spanned! {self.attr.span()=> { @@ -1400,6 +1400,14 @@ impl<'ast> ApiEndpoint<'ast> { } } +/// Returns the name of the local variable holding an endpoint's `ApiEndpoint`. +/// +/// Raw identifiers must have their `r#` prefix stripped (`format_ident!` only +/// does this for `syn::Ident`, not for strings). +fn endpoint_var_name(name_str: &str) -> syn::Ident { + format_ident!("endpoint_{}", name_str.trim_start_matches("r#")) +} + fn parse_channel_metadata( name_str: &str, attr: &syn::Attribute, @@ -1550,7 +1558,7 @@ impl<'ast> ApiChannel<'ast> { // // Note that there isn't any possible variable name collision here, // since all names are prefixed with "endpoint_". - let endpoint_name = format_ident!("endpoint_{}", name_str); + let endpoint_name = endpoint_var_name(&name_str); quote_spanned! {self.attr.span()=> { @@ -1695,7 +1703,10 @@ impl StripRecognizedAttrs for Vec { mod tests { use expectorate::assert_contents; - use crate::{test_util::assert_banned_idents, util::DROPSHOT}; + use crate::{ + test_util::{assert_banned_idents, find_idents}, + util::DROPSHOT, + }; use super::*; @@ -1818,6 +1829,32 @@ mod tests { ); } + /// Raw identifiers are valid. + #[test] + fn test_api_trait_raw_idents() { + let (item, errors) = do_trait( + quote! { context = "r#type", module = "r#mod" }, + quote! { + trait r#trait { + type r#type; + + #[endpoint { method = GET, path = "/async" }] + async fn r#async( + rqctx: RequestContext, + ) -> Result, HttpError>; + } + }, + ); + + assert!(errors.is_empty(), "no errors: {errors:#?}"); + let file: syn::File = parse_quote! { #item }; + let found = find_idents(&file, ["r#mod", "r#type", "endpoint_async"]); + assert_eq!( + found.into_iter().collect::>(), + ["endpoint_async", "r#mod", "r#type"] + ); + } + #[test] fn test_api_trait_operation_id() { let (item, errors) = do_trait(