Repository: netwo-io/apistos
Pull request #117 (2024-07-12 12:02:17)

Handle multiple generic parameter in operations


Patch:
diff --git a/Cargo.toml b/Cargo.toml
index 6c4048f..92cc4d5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,7 +22,7 @@ documentation = "https://docs.rs/apistos/"
 license = "MIT OR Apache-2.0"
 rust-version = "1.75"
 publish = true
-version = "0.3.3"
+version = "0.3.4"
 
 [workspace.dependencies]
 actix-service = "2"
diff --git a/apistos-core/Cargo.toml b/apistos-core/Cargo.toml
index ceb6a5b..56e511a 100644
--- a/apistos-core/Cargo.toml
+++ b/apistos-core/Cargo.toml
@@ -29,7 +29,7 @@ serde_qs = { workspace = true, features = ["actix4"], optional = true }
 uuid = { workspace = true, optional = true }
 url = { workspace = true, optional = true }
 
-apistos-models = { path = "../apistos-models", version = "0.3.3", features = ["deserialize"] }
+apistos-models = { path = "../apistos-models", version = "0.3.4", features = ["deserialize"] }
 
 [dev-dependencies]
 assert-json-diff = { workspace = true }
diff --git a/apistos-gen/src/lib.rs b/apistos-gen/src/lib.rs
index be10525..6b0da23 100644
--- a/apistos-gen/src/lib.rs
+++ b/apistos-gen/src/lib.rs
@@ -14,9 +14,10 @@ use convert_case::{Case, Casing};
 use darling::ast::NestedMeta;
 use darling::Error;
 use proc_macro::TokenStream;
+use proc_macro2::Span;
 use proc_macro_error::{abort, proc_macro_error, OptionExt};
-use quote::quote;
-use syn::{DeriveInput, Ident, ItemFn};
+use quote::{format_ident, quote};
+use syn::{DeriveInput, GenericParam, Ident, ItemFn};
 
 mod internal;
 mod openapi_cookie_attr;
@@ -632,9 +633,28 @@ pub fn api_operation(attr: TokenStream, item: TokenStream) -> TokenStream {
   let mut generics_call = quote!();
   let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
   let openapi_struct_def = if !generics.params.is_empty() {
+    let mut generic_types_idents = vec![];
+    for param in &generics.params {
+      match param {
+        GenericParam::Lifetime(_) => {}
+        GenericParam::Const(_) => {}
+        GenericParam::Type(_type) => generic_types_idents.push(_type.ident.clone()),
+      }
+    }
     let turbofish = ty_generics.as_turbofish();
-    generics_call = quote!(#turbofish { p: std::marker::PhantomData });
-    quote!(struct #openapi_struct #impl_generics #where_clause { p: std::marker::PhantomData #ty_generics } )
+    let mut phantom_params = quote!();
+    let mut phantom_params_names = quote!();
+    for generic_types_ident in generic_types_idents {
+      let param_name = Ident::new(
+        &format_ident!("p_{}", generic_types_ident).to_string().to_lowercase(),
+        Span::call_site(),
+      );
+      phantom_params_names.extend(quote!(#param_name: std::marker::PhantomData,));
+      phantom_params.extend(quote!(#param_name: std::marker::PhantomData < #generic_types_ident >,))
+    }
+    generics_call = quote!(#turbofish { #phantom_params_names });
+
+    quote!(struct #openapi_struct #impl_generics #where_clause { #phantom_params })
   } else {
     quote!(struct #openapi_struct;)
   };
diff --git a/apistos-rapidoc/Cargo.toml b/apistos-rapidoc/Cargo.toml
index b552730..56909da 100644
--- a/apistos-rapidoc/Cargo.toml
+++ b/apistos-rapidoc/Cargo.toml
@@ -14,7 +14,7 @@ rust-version.workspace = true
 license.workspace = true
 
 [dependencies]
-apistos-plugins = { path = "../apistos-plugins", version = "0.3.3" }
+apistos-plugins = { path = "../apistos-plugins", version = "0.3.4" }
 
 [lints]
 workspace = true
diff --git a/apistos-redoc/Cargo.toml b/apistos-redoc/Cargo.toml
index b5e91a3..5ccd124 100644
--- a/apistos-redoc/Cargo.toml
+++ b/apistos-redoc/Cargo.toml
@@ -14,7 +14,7 @@ rust-version.workspace = true
 license.workspace = true
 
 [dependencies]
-apistos-plugins = { path = "../apistos-plugins", version = "0.3.3" }
+apistos-plugins = { path = "../apistos-plugins", version = "0.3.4" }
 
 [lints]
 workspace = true
diff --git a/apistos-scalar/Cargo.toml b/apistos-scalar/Cargo.toml
index 4a2bc7f..03cfab0 100644
--- a/apistos-scalar/Cargo.toml
+++ b/apistos-scalar/Cargo.toml
@@ -14,7 +14,7 @@ rust-version.workspace = true
 license.workspace = true
 
 [dependencies]
-apistos-plugins = { path = "../apistos-plugins", version = "0.3.3" }
+apistos-plugins = { path = "../apistos-plugins", version = "0.3.4" }
 
 [lints]
 workspace = true
diff --git a/apistos-shuttle/Cargo.toml b/apistos-shuttle/Cargo.toml
index 5857207..9c7d1e7 100644
--- a/apistos-shuttle/Cargo.toml
+++ b/apistos-shuttle/Cargo.toml
@@ -18,7 +18,7 @@ actix-web = { workspace = true }
 num_cpus = { workspace = true }
 shuttle-runtime = { workspace = true }
 
-apistos = { path = "../apistos", version = "0.3.3" }
+apistos = { path = "../apistos", version = "0.3.4" }
 
 [lints]
 workspace = true
diff --git a/apistos-swagger-ui/Cargo.toml b/apistos-swagger-ui/Cargo.toml
index b3484de..6cc841c 100644
--- a/apistos-swagger-ui/Cargo.toml
+++ b/apistos-swagger-ui/Cargo.toml
@@ -14,7 +14,7 @@ rust-version.workspace = true
 license.workspace = true
 
 [dependencies]
-apistos-plugins = { path = "../apistos-plugins", version = "0.3.3" }
+apistos-plugins = { path = "../apistos-plugins", version = "0.3.4" }
 
 [lints]
 workspace = true
diff --git a/apistos/Cargo.toml b/apistos/Cargo.toml
index a30ba4f..2940949 100644
--- a/apistos/Cargo.toml
+++ b/apistos/Cargo.toml
@@ -25,25 +25,25 @@ schemars = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
 
-apistos-core = { path = "../apistos-core", version = "0.3.3" }
-apistos-gen = { path = "../apistos-gen", version = "0.3.3" }
-apistos-models = { path = "../apistos-models", version = "0.3.3" }
-apistos-plugins = { path = "../apistos-plugins", version = "0.3.3" }
-apistos-rapidoc = { path = "../apistos-rapidoc", version = "0.3.3", optional = true }
-apistos-redoc = { path = "../apistos-redoc", version = "0.3.3", optional = true }
-apistos-scalar = { path = "../apistos-scalar", version = "0.3.3", optional = true }
-apistos-swagger-ui = { path = "../apistos-swagger-ui", version = "0.3.3", optional = true }
+apistos-core = { path = "../apistos-core", version = "0.3.4" }
+apistos-gen = { path = "../apistos-gen", version = "0.3.4" }
+apistos-models = { path = "../apistos-models", version = "0.3.4" }
+apistos-plugins = { path = "../apistos-plugins", version = "0.3.4" }
+apistos-rapidoc = { path = "../apistos-rapidoc", version = "0.3.4", optional = true }
+apistos-redoc = { path = "../apistos-redoc", version = "0.3.4", optional = true }
+apistos-scalar = { path = "../apistos-scalar", version = "0.3.4", optional = true }
+apistos-swagger-ui = { path = "../apistos-swagger-ui", version = "0.3.4", optional = true }
 
 
 [dev-dependencies]
 actix-web-lab = { workspace = true }
 garde-actix-web = { workspace = true }
 
-apistos-models = { path = "../apistos-models", version = "0.3.3", features = ["deserialize"] }
-apistos-rapidoc = { path = "../apistos-rapidoc", version = "0.3.3" }
-apistos-redoc = { path = "../apistos-redoc", version = "0.3.3" }
-apistos-scalar = { path = "../apistos-scalar", version = "0.3.3" }
-apistos-swagger-ui = { path = "../apistos-swagger-ui", version = "0.3.3" }
+apistos-models = { path = "../apistos-models", version = "0.3.4", features = ["deserialize"] }
+apistos-rapidoc = { path = "../apistos-rapidoc", version = "0.3.4" }
+apistos-redoc = { path = "../apistos-redoc", version = "0.3.4" }
+apistos-scalar = { path = "../apistos-scalar", version = "0.3.4" }
+apistos-swagger-ui = { path = "../apistos-swagger-ui", version = "0.3.4" }
 
 [lints]
 workspace = true


Test patch:
diff --git a/apistos-gen-test/Cargo.toml b/apistos-gen-test/Cargo.toml
index 6ac73e5..265fab3 100644
--- a/apistos-gen-test/Cargo.toml
+++ b/apistos-gen-test/Cargo.toml
@@ -21,8 +21,8 @@ assert-json-diff = { workspace = true }
 chrono = { workspace = true, features = ["serde"] }
 futures-core = { workspace = true }
 apistos = { path = "../apistos", features = ["multipart", "uuid"] }
-apistos-core = { path = "../apistos-core", version = "0.3.3", features = ["actix-web-grants"] }
-apistos-gen = { path = "../apistos-gen", version = "0.3.3" }
+apistos-core = { path = "../apistos-core", version = "0.3.4", features = ["actix-web-grants"] }
+apistos-gen = { path = "../apistos-gen", version = "0.3.4" }
 # we use the "preserve_order" feature from schemars here following https://github.com/netwo-io/apistos/pull/78
 schemars = { workspace = true, features = ["preserve_order"] }
 serde = { workspace = true, features = ["derive"] }
diff --git a/apistos/tests/operations.rs b/apistos/tests/operations.rs
index 03158c5..be7b724 100644
--- a/apistos/tests/operations.rs
+++ b/apistos/tests/operations.rs
@@ -189,6 +189,80 @@ async fn operation_skip_args() {
   assert!(operation3.parameters.is_empty());
 }
 
+#[actix_web::test]
+async fn operation_generics() {
+  #[derive(Serialize, Deserialize, Debug, Clone, ApiErrorComponent)]
+  #[openapi_error(status(code = 405, description = "Invalid input"))]
+  pub(crate) enum ErrorResponse {
+    MethodNotAllowed(String),
+  }
+
+  impl Display for ErrorResponse {
+    fn fmt(&self, _f: &mut Formatter<'_>) -> std::fmt::Result {
+      panic!()
+    }
+  }
+
+  impl ResponseError for ErrorResponse {
+    fn status_code(&self) -> StatusCode {
+      panic!()
+    }
+  }
+
+  #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, ApiComponent)]
+  struct Test {
+    id_number: u32,
+    id_string: String,
+  }
+
+  #[api_operation(tag = "pet", skip_args = "_params")]
+  pub(crate) async fn test<T, U>(_params: Path<(u32, String)>, _test: Json<Test>) -> Result<Json<Test>, ErrorResponse> {
+    panic!()
+  }
+
+  let openapi_path = "/test.json";
+  let operation_path = "/test/{plop_id}/{clap_name}";
+
+  let info = Info {
+    title: "A well documented API".to_string(),
+    description: Some("Really well document I mean it".to_string()),
+    terms_of_service: Some("https://terms.com".to_string()),
+    ..Default::default()
+  };
+  let tags = vec![Tag {
+    name: "A super tag".to_owned(),
+    ..Default::default()
+  }];
+  let spec = Spec {
+    info: info.clone(),
+    tags: tags.clone(),
+    ..Default::default()
+  };
+  let app = App::new()
+    .document(spec)
+    .service(scope("test").service(resource("/{plop_id}/{clap_name}").route(get().to(test::<String, String>))))
+    .build(openapi_path);
+  let app = init_service(app).await;
+
+  let req = TestRequest::get().uri(openapi_path).to_request();
+  let resp = call_service(&app, req).await;
+  assert!(resp.status().is_success());
+
+  let body: OpenApi = try_read_body_json(resp).await.expect("Unable to read body");
+  let paths = body.paths.paths;
+
+  let operation = paths.get(&operation_path.to_string()).cloned();
+  assert!(operation.is_some());
+  let operation = operation
+    .unwrap_or_default()
+    .operations
+    .get(&OperationType::Get)
+    .cloned()
+    .unwrap_or_default();
+  assert!(operation.request_body.is_some());
+  assert!(operation.parameters.is_empty());
+}
+
 // Imports bellow aim at making clippy happy. Those dependencies are necessary for integration-test.
 use actix_service as _;
 use actix_web_lab as _;