Repository: netwo-io/apistos (rust)

Issue:
`.build()` Loading only the last component definitions
I have an axtic-web API that is supposed to handle the uploading of files. The API has the following routes:

- GET `/ping` - Check to see if the service is available and also return the version
- POST `/files/upload` - Upload a new file
- GET `/files/{id}/stream` - Download a file
- DELETE `/files/{id}` - Delete a file

The `/ping` route looks something like this

```rust
#[derive(Serialize, ApiComponent, JsonSchema)]
enum Status {
    Available,
}

#[derive(Serialize, ApiComponent, JsonSchema)]
struct PingResponse<'a> {
    status: Status,
    version: &'a str,
}

#[api_operation(
    summary = "Route for applications to call to see if this service is available",
    description = "Route for applications to call to see if this service is available",
    produces = "application/json"
)]
pub async fn ping() -> Json<PingResponse<'static>> {
    let version = match LOGGER.get() {
        None => "Unknown",
        Some(l) => match &l.config {
            None => "Unknown",
            Some(c) => &c.app_version,
        },
    };

    return Json(PingResponse {
        status: Status::Available,
        version,
    });
}
```

This is how i am building the app with apistos:

```rust
// ...
let app = app
    .document(spec)
    .service(resource("/ping").route(web::get().to(ping)))
    .service(
        scope("/files")
            .service(resource("/upload").route(web::post().to(upload::<
                FilesystemContext,
                FilesystemContext,
                ClientSession,
                Auth,
                FilesCollection,
                UsersCollection,
                Storage,
            >)))
            .service(resource("/{object_id}/stream").route(web::post().to(stream::<
                FilesystemContext,
                FilesystemContext,
                ClientSession,
                Auth,
                FilesCollection,
                Storage,
            >)))
            .service(resource("/{object_id}").route(web::delete().to(delete::<
                FilesystemContext,
                FilesystemContext,
                ClientSession,
                Auth,
                FilesCollection,
                FilesDatabase,
                Storage,
            >))),
    )
    .build("/spec");
// ...
```

When i now call the `/spec` route to get the spec i only get the following schemas with the PingResponse not being included.

![image](https://github.com/user-attachments/assets/b1f9c051-378a-44c2-834a-c7dcbe60308d)

I already tried to group the two services in a new `scope` with the path `""` which made the `/spec` route unreachable

```rust
// ...
 .service(
    scope("")
        .service(resource("/ping").route(web::get().to(ping)))
        .service(
            scope("/files")
                .service(resource("/upload").route(web::post().to(upload::<
                    FilesystemContext,
                    FilesystemContext,
                    ClientSession,
                    Auth,
                    FilesCollection,
                    UsersCollection,
                    Storage,
                >)))
                .service(resource("/{object_id}/stream").route(web::post().to(stream::<
                    FilesystemContext,
                    FilesystemContext,
                    ClientSession,
                    Auth,
                    FilesCollection,
                    Storage,
                >)))
                .service(resource("/{object_id}").route(web::delete().to(delete::<
                    FilesystemContext,
                    FilesystemContext,
                    ClientSession,
                    Auth,
                    FilesCollection,
                    FilesDatabase,
                    Storage,
                >))),
            ),
    )
   .build("/spec");
// ...
```

I also tried adding a `/` to the top level scope and removing it from the children, but that also didn't work.
How can i have these two routes add their models to the component definitions?




Pull request description:
Fix #137
Fix definition holder overriding components when multiple are defined at root level.

Patch:
diff --git a/apistos/src/app.rs b/apistos/src/app.rs
index 09f8ee7..42f1cd4 100644
--- a/apistos/src/app.rs
+++ b/apistos/src/app.rs
@@ -251,12 +251,18 @@ where
   #[allow(clippy::unwrap_used)]
   fn update_from_def_holder<D: DefinitionHolder>(&mut self, definition_holder: &mut D) {
     let mut open_api_spec = self.open_api_spec.write().unwrap();
-    let mut components = definition_holder.components().into_iter().reduce(|mut acc, component| {
-      acc.schemas.extend(component.schemas);
-      acc.responses.extend(component.responses);
-      acc.security_schemes.extend(component.security_schemes);
-      acc
-    });
+    let components = mem::take(&mut open_api_spec.components);
+
+    let mut components = definition_holder
+      .components()
+      .into_iter()
+      .chain(components)
+      .reduce(|mut acc, component| {
+        acc.schemas.extend(component.schemas);
+        acc.responses.extend(component.responses);
+        acc.security_schemes.extend(component.security_schemes);
+        acc
+      });
     definition_holder.update_path_items(&mut open_api_spec.paths.paths);
     let mut paths = IndexMap::new();
     for (path, mut item) in mem::take(&mut open_api_spec.paths.paths) {


Test patch:
diff --git a/apistos/tests/routing.rs b/apistos/tests/routing.rs
index cbf0730..bef7b79 100644
--- a/apistos/tests/routing.rs
+++ b/apistos/tests/routing.rs
@@ -2,12 +2,16 @@ use actix_web::middleware::Logger;
 use actix_web::web::{Json, Path};
 use actix_web::{App, Error};
 
+use actix_web::test::{call_service, init_service, try_read_body_json, TestRequest};
 use apistos::app::OpenApiWrapper;
 use apistos::spec::Spec;
 use apistos::web::{delete, get, patch, post, put, resource, scope, tagged_resource, tagged_scope, ServiceConfig};
-use apistos_gen::api_operation;
+use apistos_gen::{api_operation, ApiComponent};
 use apistos_models::info::Info;
 use apistos_models::tag::Tag;
+use apistos_models::OpenApi;
+use schemars::JsonSchema;
+use serde::Serialize;
 
 #[actix_web::test]
 async fn actix_routing() {
@@ -97,12 +101,79 @@ async fn actix_routing() {
   );
 }
 
+#[actix_web::test]
+async fn actix_routing_multiple_root_definition_holder() {
+  #[derive(ApiComponent, JsonSchema, Serialize)]
+  pub(crate) struct TestResponse {
+    pub(crate) value: String,
+  }
+
+  #[derive(ApiComponent, JsonSchema, Serialize)]
+  pub(crate) struct TestResponse2 {
+    pub(crate) value: String,
+  }
+
+  #[api_operation(tag = "pet")]
+  pub(crate) async fn test(_params: Path<(u32, String)>) -> Result<Json<TestResponse>, Error> {
+    Ok(Json(TestResponse {
+      value: "plop".to_string(),
+    }))
+  }
+
+  #[api_operation(tag = "pet")]
+  pub(crate) async fn test2(_params: Path<String>) -> Result<Json<TestResponse2>, Error> {
+    Ok(Json(TestResponse2 {
+      value: "plop".to_string(),
+    }))
+  }
+
+  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: "pet".to_owned(),
+      ..Default::default()
+    },
+    Tag {
+      name: "A super tag".to_owned(),
+      ..Default::default()
+    },
+    Tag {
+      name: "Another 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))))
+    .service(scope("test2").service(resource("/{clap_name}").route(get().to(test2))))
+    .build("/openapi.json");
+  let app = init_service(app).await;
+
+  let req = TestRequest::get().uri("/openapi.json").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 components = body.components.expect("Unable to get components");
+
+  assert_eq!(components.schemas.len(), 2);
+}
+
 // Imports bellow aim at making clippy happy. Those dependencies are necessary for integration-test.
 use actix_service as _;
-use actix_web::test::{call_service, init_service, try_read_body_json, TestRequest};
 use actix_web_lab as _;
 use apistos_core as _;
-use apistos_models::OpenApi;
 use apistos_plugins as _;
 use apistos_rapidoc as _;
 use apistos_redoc as _;
@@ -115,6 +186,4 @@ use log as _;
 use md5 as _;
 use once_cell as _;
 use regex as _;
-use schemars as _;
-use serde as _;
 use serde_json as _;