Repository: netwo-io/apistos (rust)

Issue:
`api_operation` wraps async fn body in a synchronous closure and renders `.awaiting` in it invalid
The following code:
```
use actix_web::Responder;
#[apistos::api_operation(
    tag = "image"
)]
async fn foo() -> impl Responder {
    bar().await;
    todo!()
}
async fn bar() {}
```
results in:
```
error[E0728]: `await` is only allowed inside `async` functions and blocks
 --> src/main.rs:6:11
  |
2 | / #[apistos::api_operation(
3 | |     tag = "image"
4 | | )]
  | |__- this is not `async`
5 |   async fn foo() -> impl Responder {
6 |       bar().await;
  |             ^^^^^ only allowed inside `async` functions and blocks
```
when the user apparently did use an asynchronous function.


There is 2 options to resolve this: 
1. Document the fact that the body of a function annotated with `api_operation` must be synchronous.
2. Fix the macro definition

---
Currently the macro expands to this:
```
fn foo() -> impl std::future::Future<
    Output = apistos::actix::ResponderWrapper<impl Responder>,
> + apistos::PathItemDefinition {
    let inner = core::future::ready(
        apistos::actix::ResponderWrapper(
            (move || {
                bar().await;
                ::core::panicking::panic("not yet implemented")
            })(),
        ),
    );
    apistos::actix::ResponseWrapper {
        inner,
        path_item: __openapi_foo,
    }
}
```
it could probably expand to
```
fn foo() -> impl std::future::Future<
    Output = apistos::actix::ResponderWrapper<impl Responder>,
> + apistos::PathItemDefinition {
    let inner = core::future::ready(
        apistos::actix::ResponderWrapper(
            async move {
                bar().await;
                ::core::panicking::panic("not yet implemented")
            }.await,
        ),
    );
    apistos::actix::ResponseWrapper {
        inner,
        path_item: __openapi_foo,
    }
}
```
or something similar and allow the use of `.await` within the body of `foo`.

Pull request:
Fix async impl Responder. Bump msrv to 1.81
Fix #130

Patch:
diff --git a/Cargo.toml b/Cargo.toml
index 8f5bc74..8afb45f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,7 +32,8 @@ actix-web-lab = "0.20"
 assert-json-diff = "2.0.2"
 convert_case = "0.6"
 darling = "0.20"
-futures-core = "0.3.28"
+futures-core = "0.3"
+futures-util = "0.3"
 indexmap = "2"
 log = "0.4.20"
 num_cpus = "1.16"
@@ -69,7 +70,6 @@ unused_import_braces = "warn"
 unused-lifetimes = "warn"
 unused_macro_rules = "warn"
 unused_qualifications = "warn"
-unused_tuple_struct_fields = "warn"
 
 [workspace.lints.clippy]
 bool_to_int_with_if = "warn"
diff --git a/apistos-gen/src/internal/mod.rs b/apistos-gen/src/internal/mod.rs
index 295e780..606f77c 100644
--- a/apistos-gen/src/internal/mod.rs
+++ b/apistos-gen/src/internal/mod.rs
@@ -186,7 +186,11 @@ pub(crate) fn gen_item_ast(
 
   let block = item_ast.block;
   let inner_handler = if is_responder {
-    quote!(core::future::ready(apistos::actix::ResponderWrapper((move || #block)())))
+    quote!({
+      use apistos::FutureExt;
+      #[expect(async_yields_async)]
+      (move || { async move #block })().map(apistos::actix::ResponderWrapper)
+    })
   } else if is_impl_trait {
     quote!((move || #block)())
   } else {
diff --git a/apistos-gen/src/lib.rs b/apistos-gen/src/lib.rs
index 6b0da23..5f150b3 100644
--- a/apistos-gen/src/lib.rs
+++ b/apistos-gen/src/lib.rs
@@ -620,7 +620,7 @@ pub fn api_operation(attr: TokenStream, item: TokenStream) -> TokenStream {
 
   let operation_attribute = parse_openapi_operation_attrs(&attr_args);
 
-  let default_span = proc_macro2::Span::call_site();
+  let default_span = Span::call_site();
   let item_ast = match syn::parse::<ItemFn>(item) {
     Ok(v) => v,
     Err(e) => abort!(e.span(), format!("{e}")),
diff --git a/apistos-models/src/paths.rs b/apistos-models/src/paths.rs
index 1578ae3..593eaa6 100644
--- a/apistos-models/src/paths.rs
+++ b/apistos-models/src/paths.rs
@@ -309,7 +309,7 @@ pub struct Example {
   /// Long description for the example. [CommonMark syntax](https://spec.commonmark.org/) MAY be used for rich text representation.
   #[serde(skip_serializing_if = "Option::is_none")]
   pub description: Option<String>,
-  /// Embedded literal example. The `value` field and `externalValue field are mutually exclusive. To represent examples of media types that cannot naturally represented in JSON or YAML, use a string value to contain the example, escaping where necessary.
+  /// Embedded literal example. The `value` field and `externalValue` field are mutually exclusive. To represent examples of media types that cannot naturally represented in JSON or YAML, use a string value to contain the example, escaping where necessary.
   #[serde(flatten)]
   pub value: ExampleValue,
   /// This object MAY be extended with [Specification Extensions](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions).
@@ -445,6 +445,6 @@ pub enum AnyOrExpression {
 pub enum OperationIdentifier {
   /// A relative or absolute URI reference to an OAS operation. This field is mutually exclusive of the `operationId` field, and MUST point to an [Operation Object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#operation-object). Relative `operationRef` values MAY be used to locate an existing [Operation Object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#operation-object) in the OpenAPI definition.
   OperationRef(String),
-  /// The name of an existing, resolvable OAS operation, as defined with a unique `operationId. This field is mutually exclusive of the `operationRef` field.
+  /// The name of an existing, resolvable OAS operation, as defined with a unique `operationId`. This field is mutually exclusive of the `operationRef` field.
   OperationId(String),
 }
diff --git a/apistos-models/src/security.rs b/apistos-models/src/security.rs
index a14a065..47f1da9 100644
--- a/apistos-models/src/security.rs
+++ b/apistos-models/src/security.rs
@@ -114,7 +114,7 @@ pub struct OauthImplicit {
   /// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL.
   #[serde(skip_serializing_if = "Option::is_none")]
   pub refresh_url: Option<String>,
-  /// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty.
+  /// The available scopes for the `OAuth2` security scheme. A map between the scope name and a short description for it. The map MAY be empty.
   pub scopes: BTreeMap<String, String>,
 }
 
@@ -127,7 +127,7 @@ pub struct OauthToken {
   /// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL.
   #[serde(skip_serializing_if = "Option::is_none")]
   pub refresh_url: Option<String>,
-  /// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty.
+  /// The available scopes for the `OAuth2` security scheme. A map between the scope name and a short description for it. The map MAY be empty.
   pub scopes: BTreeMap<String, String>,
 }
 
@@ -135,6 +135,6 @@ pub struct OauthToken {
 #[cfg_attr(any(test, feature = "deserialize"), derive(serde::Deserialize, PartialEq))]
 #[serde(rename_all = "camelCase")]
 pub struct OpenIdConnect {
-  /// OpenId Connect URL to discover OAuth2 configuration values. This MUST be in the form of a URL.
+  /// `OpenId` Connect URL to discover `OAuth2` configuration values. This MUST be in the form of a URL.
   pub open_id_connect_url: String,
 }
diff --git a/apistos-shuttle/README.md b/apistos-shuttle/README.md
index 65f7f01..f780a72 100644
--- a/apistos-shuttle/README.md
+++ b/apistos-shuttle/README.md
@@ -75,7 +75,7 @@ async fn actix_web() -> ShuttleApistosActixWeb<impl FnOnce(&mut ServiceConfig) +
 > Default features are disabled for `shuttle-runtime` to avoid pulling [colored](https://github.com/colored-rs/colored)
 > which as a license considered as
 > copyleft by cargo-deny.
-> To implement your own tracing, please visit https://docs.shuttle.rs/configuration/logs
+> To implement your own tracing, please visit <https://docs.shuttle.rs/configuration/logs>
 
 ### About us
 
diff --git a/apistos/Cargo.toml b/apistos/Cargo.toml
index 6fa003e..bf797f8 100644
--- a/apistos/Cargo.toml
+++ b/apistos/Cargo.toml
@@ -16,6 +16,7 @@ license.workspace = true
 [dependencies]
 actix-service = { workspace = true }
 actix-web = { workspace = true }
+futures-util = { workspace = true }
 indexmap = { workspace = true }
 log = { workspace = true }
 md5 = { workspace = true }
diff --git a/apistos/src/lib.rs b/apistos/src/lib.rs
index 9efb24e..a10343a 100644
--- a/apistos/src/lib.rs
+++ b/apistos/src/lib.rs
@@ -163,6 +163,7 @@ pub use apistos_redoc::RedocConfig;
 pub use apistos_scalar::ScalarConfig;
 #[cfg(feature = "swagger-ui")]
 pub use apistos_swagger_ui::SwaggerUIConfig;
+pub use futures_util::future::FutureExt;
 
 mod internal;
 
diff --git a/rust-toolchain b/rust-toolchain
index 7c7053a..dbd4126 100644
--- a/rust-toolchain
+++ b/rust-toolchain
@@ -1,1 +1,1 @@
-1.75.0
+1.81.0


Test patch:
diff --git a/apistos-gen-test/src/lib.rs b/apistos-gen-test/src/lib.rs
index c2623fe..5b54c3c 100644
--- a/apistos-gen-test/src/lib.rs
+++ b/apistos-gen-test/src/lib.rs
@@ -1,7 +1,6 @@
 #![allow(deprecated)]
 #![allow(dead_code)]
 #![allow(clippy::expect_used)]
-#![allow(unused_tuple_struct_fields)]
 
 #[cfg(test)]
 pub(crate) mod tests;
diff --git a/apistos-gen-test/src/tests/api_error_derive.rs b/apistos-gen-test/src/tests/api_error_derive.rs
index d048a37..ae3661d 100644
--- a/apistos-gen-test/src/tests/api_error_derive.rs
+++ b/apistos-gen-test/src/tests/api_error_derive.rs
@@ -6,6 +6,7 @@ use std::collections::{BTreeMap, HashSet};
 #[test]
 #[allow(dead_code)]
 fn api_component_derive() {
+  #[allow(clippy::duplicated_attributes)]
   #[derive(ApiErrorComponent)]
   #[openapi_error(
     status(code = 403),
@@ -59,6 +60,7 @@ fn api_component_with_schema() {
     code: String,
   }
 
+  #[allow(clippy::duplicated_attributes)]
   #[derive(ApiErrorComponent)]
   #[openapi_error(status(code = 403), status(code = 409, description = "Too many requests"))]
   enum ErrorResponse {
diff --git a/apistos-gen-test/src/tests/api_operation.rs b/apistos-gen-test/src/tests/api_operation.rs
index eb35d16..a6daba2 100644
--- a/apistos-gen-test/src/tests/api_operation.rs
+++ b/apistos-gen-test/src/tests/api_operation.rs
@@ -74,6 +74,7 @@ mod test_models {
     }
   }
 
+  #[allow(clippy::duplicated_attributes)]
   #[derive(Serialize, Deserialize, Debug, Clone, ApiErrorComponent)]
   #[openapi_error(status(code = 401), status(code = 403), status(code = 404), status(code = 405))]
   pub(crate) enum MultipleErrorResponse {
@@ -216,6 +217,20 @@ fn api_operation_impl_responder() {
     HttpResponse::Ok()
   }
 
+  #[allow(clippy::todo, clippy::unused_async)]
+  async fn plop() {
+    todo!()
+  }
+
+  /// Add a new pet to the store
+  /// Add a new pet to the store
+  /// Plop
+  #[api_operation(tag = "pet")]
+  pub(crate) async fn test_async(_body: Json<test_models::Test>) -> impl Responder {
+    plop().await;
+    HttpResponse::Ok()
+  }
+
   let components = __openapi_test::components();
   // only one component here because: error does not have schema and Test is used both for query and response
   assert_eq!(components.len(), 1);
@@ -267,6 +282,58 @@ fn api_operation_impl_responder() {
       ]
     })
   );
+
+  let components = __openapi_test_async::components();
+  // only one component here because: error does not have schema and Test is used both for query and response
+  assert_eq!(components.len(), 1);
+  let components = serde_json::to_value(components).expect("Unable to serialize as Json");
+
+  let operation = __openapi_test_async::operation();
+  let operation = serde_json::to_value(operation).expect("Unable to serialize as Json");
+
+  assert_json_eq!(
+    components,
+    json!([
+      {
+        "schemas": {
+          "Test": {
+            "properties": {
+              "test": {
+                "type": "string"
+              }
+            },
+            "required": [
+              "test"
+            ],
+            "title": "Test",
+            "type": "object"
+          }
+        }
+      }
+    ])
+  );
+  assert_json_eq!(
+    operation,
+    json!({
+      "deprecated": false,
+      "description": "Add a new pet to the store\\\nPlop",
+      "requestBody": {
+        "content": {
+          "application/json": {
+            "schema": {
+              "$ref": "#/components/schemas/Test"
+            }
+          }
+        },
+        "required": true
+      },
+      "responses": {},
+      "summary": "Add a new pet to the store",
+      "tags": [
+        "pet"
+      ]
+    })
+  );
 }
 
 #[test]
diff --git a/apistos/tests/default_parameters.rs b/apistos/tests/default_parameters.rs
index 508288f..8f0113c 100644
--- a/apistos/tests/default_parameters.rs
+++ b/apistos/tests/default_parameters.rs
@@ -50,7 +50,7 @@ async fn default_parameters() {
     plap: String,
   }
 
-  #[allow(unused_tuple_struct_fields)]
+  #[allow(dead_code)]
   #[derive(Clone, Debug, JsonSchema, ApiHeader)]
   #[openapi_header(
     name = "X-Env-Complex",
@@ -59,7 +59,7 @@ async fn default_parameters() {
   )]
   struct SomeComplexHeader(TestHeaderStruct);
 
-  #[allow(unused_tuple_struct_fields)]
+  #[allow(dead_code)]
   #[derive(Clone, Debug, JsonSchema, ApiHeader)]
   #[openapi_header(
     name = "X-Env",
@@ -182,6 +182,7 @@ use apistos_rapidoc as _;
 use apistos_redoc as _;
 use apistos_scalar as _;
 use apistos_swagger_ui as _;
+use futures_util as _;
 use garde_actix_web as _;
 use indexmap as _;
 use log as _;
diff --git a/apistos/tests/operation_id.rs b/apistos/tests/operation_id.rs
index c68004c..3e6fdc3 100644
--- a/apistos/tests/operation_id.rs
+++ b/apistos/tests/operation_id.rs
@@ -145,6 +145,7 @@ use apistos_rapidoc as _;
 use apistos_redoc as _;
 use apistos_scalar as _;
 use apistos_swagger_ui as _;
+use futures_util as _;
 use garde_actix_web as _;
 use indexmap as _;
 use log as _;
diff --git a/apistos/tests/operations.rs b/apistos/tests/operations.rs
index 7ac22b8..44967be 100644
--- a/apistos/tests/operations.rs
+++ b/apistos/tests/operations.rs
@@ -346,6 +346,7 @@ use apistos_rapidoc as _;
 use apistos_redoc as _;
 use apistos_scalar as _;
 use apistos_swagger_ui as _;
+use futures_util as _;
 use garde_actix_web as _;
 use indexmap as _;
 use log as _;
diff --git a/apistos/tests/parametric_handlers.rs b/apistos/tests/parametric_handlers.rs
index 9b92b56..d1b61a6 100644
--- a/apistos/tests/parametric_handlers.rs
+++ b/apistos/tests/parametric_handlers.rs
@@ -187,6 +187,7 @@ use apistos_rapidoc as _;
 use apistos_redoc as _;
 use apistos_scalar as _;
 use apistos_swagger_ui as _;
+use futures_util as _;
 use garde_actix_web as _;
 use indexmap as _;
 use log as _;
diff --git a/apistos/tests/path_parameters.rs b/apistos/tests/path_parameters.rs
index c031211..0348cef 100644
--- a/apistos/tests/path_parameters.rs
+++ b/apistos/tests/path_parameters.rs
@@ -137,6 +137,7 @@ use apistos_rapidoc as _;
 use apistos_redoc as _;
 use apistos_scalar as _;
 use apistos_swagger_ui as _;
+use futures_util as _;
 use garde_actix_web as _;
 use indexmap as _;
 use log as _;
diff --git a/apistos/tests/query_parameters.rs b/apistos/tests/query_parameters.rs
index 9a5843b..7fb462d 100644
--- a/apistos/tests/query_parameters.rs
+++ b/apistos/tests/query_parameters.rs
@@ -281,6 +281,7 @@ use apistos_rapidoc as _;
 use apistos_redoc as _;
 use apistos_scalar as _;
 use apistos_swagger_ui as _;
+use futures_util as _;
 use indexmap as _;
 use log as _;
 use md5 as _;
diff --git a/apistos/tests/routing.rs b/apistos/tests/routing.rs
index 90a4480..cbf0730 100644
--- a/apistos/tests/routing.rs
+++ b/apistos/tests/routing.rs
@@ -108,6 +108,7 @@ use apistos_rapidoc as _;
 use apistos_redoc as _;
 use apistos_scalar as _;
 use apistos_swagger_ui as _;
+use futures_util as _;
 use garde_actix_web as _;
 use indexmap as _;
 use log as _;
diff --git a/apistos/tests/tags.rs b/apistos/tests/tags.rs
index 99426b0..003d4ea 100644
--- a/apistos/tests/tags.rs
+++ b/apistos/tests/tags.rs
@@ -149,6 +149,7 @@ use apistos_rapidoc as _;
 use apistos_redoc as _;
 use apistos_scalar as _;
 use apistos_swagger_ui as _;
+use futures_util as _;
 use garde_actix_web as _;
 use indexmap as _;
 use log as _;