diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index aabbe99f0..66cd9ab0c 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -473,7 +473,7 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { } Ok(Operation { - marker_ident: format_ident!("{}", to_pascal_case(&method.sig.ident)), + marker_ident: operation_marker_ident(&method.sig.ident), method_ident: method.sig.ident.clone(), operation_id, method: http_method, @@ -926,6 +926,21 @@ fn ident_text(ident: &Ident) -> String { value.strip_prefix("r#").unwrap_or(&value).to_owned() } +fn operation_marker_ident(method: &Ident) -> Ident { + let candidate = to_pascal_case(method); + if let Ok(mut marker) = syn::parse_str::(&candidate) { + marker.set_span(method.span()); + return marker; + } + + let encoded = ident_text(method) + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ident::new(&format!("Operation{encoded}"), method.span()) +} + fn to_pascal_case(ident: &Ident) -> String { ident_text(ident) .split('_') diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index c7431e3b2..006dd6e4b 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -51,8 +51,9 @@ //! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`], //! plus a `widget_api_operations` module. That module contains one marker per Rust method //! (`Create`, `Get`, ...), each implementing [`Operation`] and connecting metadata to concrete -//! body types at compile time. The `ApiContract::OPERATIONS` inventory is sorted by operation -//! ID, so its ordering is independent of source method order. +//! body types at compile time. Unusual method names which have no valid PascalCase form use the +//! deterministic `Operation` fallback. The `ApiContract::OPERATIONS` inventory is +//! sorted by operation ID, so its ordering is independent of source method order. //! //! Only empty and JSON bodies are accepted by this first contract. [`WireKind`] reserves //! explicit variants for future transport work; accepting one requires a deliberate macro and diff --git a/crates/api-macros/tests/expansion.rs b/crates/api-macros/tests/expansion.rs index 3f8c027a3..a52ae4a78 100644 --- a/crates/api-macros/tests/expansion.rs +++ b/crates/api-macros/tests/expansion.rs @@ -41,6 +41,15 @@ pub trait RawIdentifierApi { async fn r#type(&self) -> Widget; } +#[api] +pub trait UnusualIdentifierApi { + #[get("/underscores", operation_id = "unusual.underscores")] + async fn __(&self) -> Widget; + + #[get("/underscore-digit", operation_id = "unusual.underscore_digit")] + async fn _0(&self) -> Widget; +} + fn assert_operation_types() where O: Operation< @@ -60,6 +69,11 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() { assert_named_wrapper::(); let raw = ::METADATA; assert_eq!(raw.operation_id, "raw.type"); + let underscores = ::METADATA; + assert_eq!(underscores.operation_id, "unusual.underscores"); + let underscore_digit = + ::METADATA; + assert_eq!(underscore_digit.operation_id, "unusual.underscore_digit"); let operations = ::OPERATIONS; assert_eq!(operations.len(), 3);