Repository: juhaku/utoipa (Rust)

Issue:
Allow const static str's to be used as operation_id
**TL;DR** It would be useful if the `operation_id` option on the `#[path]` macro could be a const variable instead of just a static string like so:

```rust
const MY_ID: &str = "some_id";

#[utoipa::path(
    ...
    operation_id = MY_ID
)]
fn foo(...) { ... }
```

**Why**
I'm currently writing an API that runs on an AWS Lambda behind API Gateway. There's this really nice feature of API Gateways where you give it an openapi spec and when it invokes your lambda, it tells you the operation id that corresponds to this request. So you can easily write a router like so:

```rust

match request_context.operation_name.as_deref() {
   route::MY_ID => foo(...)
}
```

Currently, I have to keep the string id in sync between my const definition and the `#[path]` macro invocation. It would be nice to just have a single source of truth.


Is this something you'd be open to a PR for?


Discussion:
Sure any contributions are welcome :slightly_smiling_face: 



Patch:
diff --git a/utoipa-gen/src/component/serde.rs b/utoipa-gen/src/component/serde.rs
index a21609bf..0c7610e2 100644
--- a/utoipa-gen/src/component/serde.rs
+++ b/utoipa-gen/src/component/serde.rs
@@ -37,7 +37,9 @@ impl SerdeValue {
             let mut rest = *cursor;
             while let Some((tt, next)) = rest.token_tree() {
                 match tt {
-                    TokenTree::Ident(ident) if ident == "skip" || ident == "skip_serializing" => value.skip = true,
+                    TokenTree::Ident(ident) if ident == "skip" || ident == "skip_serializing" => {
+                        value.skip = true
+                    }
                     TokenTree::Ident(ident) if ident == "flatten" => value.flatten = true,
                     TokenTree::Ident(ident) if ident == "rename" => {
                         if let Some((literal, _)) = parse_next_lit_str(next) {
diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs
index 1f6a3672..917a55e2 100644
--- a/utoipa-gen/src/lib.rs
+++ b/utoipa-gen/src/lib.rs
@@ -618,7 +618,9 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream {
 ///
 /// * `path = "..."` Must be OpenAPI format compatible str with arguments withing curly braces. E.g _`{id}`_
 ///
-/// * `operation_id = "..."` Unique operation id for the endpoint. By default this is mapped to function name.
+/// * `operation_id = ...` Unique operation id for the endpoint. By default this is mapped to function name.
+///   The operation_id can be any valid expression (e.g. string literals, macro invocations, variables) so long
+///   as its result can be converted to a `String` using `String::from`.
 ///
 /// * `context_path = "..."` Can add optional scope for **path**. The **context_path** will be prepended to beginning of **path**.
 ///   This is particularly useful when **path** does not contain the full path to the endpoint. For example if web framework
diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs
index 9545a153..8b8be9a9 100644
--- a/utoipa-gen/src/path.rs
+++ b/utoipa-gen/src/path.rs
@@ -4,11 +4,12 @@ use std::{io::Error, str::FromStr};
 
 use proc_macro2::{Ident, Span, TokenStream as TokenStream2};
 use proc_macro_error::abort;
-use quote::{format_ident, quote, ToTokens};
+use quote::{format_ident, quote, quote_spanned, ToTokens};
 use syn::punctuated::Punctuated;
+use syn::spanned::Spanned;
 use syn::token::Paren;
 use syn::{parenthesized, parse::Parse, Token};
-use syn::{LitStr, Type};
+use syn::{Expr, ExprLit, Lit, LitStr, Type};
 
 use crate::component::{GenericType, TypeTree};
 use crate::{parse_utils, Deprecated};
@@ -77,7 +78,7 @@ pub struct PathAttr<'p> {
     request_body: Option<RequestBodyAttr<'p>>,
     responses: Vec<Response<'p>>,
     pub(super) path: Option<String>,
-    operation_id: Option<String>,
+    operation_id: Option<Expr>,
     tag: Option<String>,
     params: Vec<Parameter<'p>>,
     security: Option<Array<'p, SecurityRequirementAttr>>,
@@ -183,7 +184,8 @@ impl Parse for PathAttr<'_> {
 
             match attribute_name {
                 "operation_id" => {
-                    path_attr.operation_id = Some(parse_utils::parse_next_literal_str(input)?);
+                    path_attr.operation_id =
+                        Some(parse_utils::parse_next(input, || Expr::parse(input))?);
                 }
                 "path" => {
                     path_attr.path = Some(parse_utils::parse_next_literal_str(input)?);
@@ -362,11 +364,14 @@ impl<'p> Path<'p> {
 impl<'p> ToTokens for Path<'p> {
     fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
         let path_struct = format_ident!("{}{}", PATH_STRUCT_PREFIX, self.fn_name);
-        let operation_id: &String = self
+        let operation_id = self
             .path_attr
             .operation_id
-            .as_ref()
-            .or(Some(&self.fn_name))
+            .clone()
+            .or(Some(ExprLit {
+                attrs: vec![],
+                lit: Lit::Str(LitStr::new(&self.fn_name, Span::call_site()))
+            }.into()))
             .unwrap_or_else(|| {
                 abort! {
                     Span::call_site(), "operation id is not defined for path";
@@ -469,7 +474,7 @@ impl<'p> ToTokens for Path<'p> {
 
 #[cfg_attr(feature = "debug", derive(Debug))]
 struct Operation<'a> {
-    operation_id: &'a String,
+    operation_id: Expr,
     summary: Option<&'a String>,
     description: Option<&'a Vec<String>>,
     deprecated: &'a Option<bool>,
@@ -498,8 +503,8 @@ impl ToTokens for Operation<'_> {
                 .securities(Some(#security_requirements))
             })
         }
-        let operation_id = self.operation_id;
-        tokens.extend(quote! {
+        let operation_id = &self.operation_id;
+        tokens.extend(quote_spanned! { operation_id.span() =>
             .operation_id(Some(#operation_id))
         });
 


Test patch:
diff --git a/utoipa-gen/tests/path_derive.rs b/utoipa-gen/tests/path_derive.rs
index 88c23c1c..9641ca7c 100644
--- a/utoipa-gen/tests/path_derive.rs
+++ b/utoipa-gen/tests/path_derive.rs
@@ -1224,6 +1224,29 @@ fn derive_path_params_into_params_with_raw_identifier() {
     )
 }
 
+#[test]
+fn arbitrary_expr_in_operation_id() {
+    #[utoipa::path(
+        get,
+        path = "foo",
+        operation_id=format!("{}", 3+5),
+        responses(
+            (status = 200, description = "success response")
+        ),
+    )]
+    #[allow(unused)]
+    fn get_foo() {}
+
+    #[derive(OpenApi, Default)]
+    #[openapi(paths(get_foo))]
+    struct ApiDoc;
+
+    let doc = serde_json::to_value(ApiDoc::openapi()).unwrap();
+    let operation_id = doc.pointer("/paths/foo/get/operationId").unwrap();
+
+    assert_json_eq!(operation_id, json!("8"))
+}
+
 #[test]
 fn derive_path_with_validation_attributes() {
     #[derive(IntoParams)]
diff --git a/utoipa-gen/tests/utoipa_gen_test.rs b/utoipa-gen/tests/utoipa_gen_test.rs
index 9e2a5394..652a3fac 100644
--- a/utoipa-gen/tests/utoipa_gen_test.rs
+++ b/utoipa-gen/tests/utoipa_gen_test.rs
@@ -46,11 +46,14 @@ struct Pet {
 mod pet_api {
     use super::*;
 
+    const ID: &str = "get_pet";
+
     /// Get pet by id
     ///
     /// Get pet from database by pet database id
     #[utoipa::path(
         get,
+        operation_id = ID,
         path = "/pets/{id}",
         responses(
             (status = 200, description = "Pet found successfully", body = Pet),