Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
50 changes: 50 additions & 0 deletions dropshot/tests/integration-tests/api_trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self::r#type>,
) -> Result<HttpResponseUpdatedNoContent, HttpError>;

#[channel { protocol = WEBSOCKETS, path = "/await" }]
async fn r#await(
_rqctx: RequestContext<Self::r#type>,
_upgraded: dropshot::WebsocketConnection,
) -> dropshot::WebsocketChannelResult;
}

enum RawIdentImpl {}

impl RawIdentApi for RawIdentImpl {
type r#type = ();

async fn r#async(
_rqctx: RequestContext<Self::r#type>,
) -> Result<HttpResponseUpdatedNoContent, HttpError> {
Ok(HttpResponseUpdatedNoContent())
}

async fn r#await(
_rqctx: RequestContext<Self::r#type>,
_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::<RawIdentImpl>().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 = {},
Expand Down
43 changes: 40 additions & 3 deletions dropshot_endpoint/src/api_trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()=>
{
Expand All @@ -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,
Expand Down Expand Up @@ -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()=>
{
Expand Down Expand Up @@ -1695,7 +1703,10 @@ impl StripRecognizedAttrs for Vec<syn::Attribute> {
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::*;

Expand Down Expand Up @@ -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<Self::r#type>,
) -> Result<HttpResponseOk<()>, 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::<Vec<_>>(),
["endpoint_async", "r#mod", "r#type"]
);
}

#[test]
fn test_api_trait_operation_id() {
let (item, errors) = do_trait(
Expand Down
Loading