Repository: netwo-io/apistos
Pull request #84 (2024-05-13 10:03:57)

Add minimal scalar UI
Add a minimal version of scalar for openapi visualisation.


Patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index b42f61a..48d3bfd 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -36,6 +36,7 @@ jobs:
           - apistos-plugins
           - apistos-rapidoc
           - apistos-redoc
+          - apistos-scalar
           - apistos-shuttle
           - apistos-swagger-ui
       fail-fast: true
diff --git a/Cargo.toml b/Cargo.toml
index cde15cf..aca0185 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ members = [
     "apistos-plugins",
     "apistos-rapidoc",
     "apistos-redoc",
+    "apistos-scalar",
     "apistos-shuttle",
     "apistos-swagger-ui"
 ]
diff --git a/apistos-scalar/Cargo.toml b/apistos-scalar/Cargo.toml
new file mode 100644
index 0000000..509d19a
--- /dev/null
+++ b/apistos-scalar/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "apistos-scalar"
+description = "Actix-web OpenAPI v3.0 documentation generator"
+readme = "README.md"
+keywords = ["actix", "actix-web", "openapi", "oas3", "documentation"]
+categories = ["web-programming"]
+
+version.workspace = true
+authors.workspace = true
+edition.workspace = true
+repository.workspace = true
+publish.workspace = true
+rust-version.workspace = true
+license.workspace = true
+
+[dependencies]
+apistos-plugins = { path = "../apistos-plugins", version = "0.2.2" }
+
+[lints]
+workspace = true
diff --git a/apistos-scalar/README.md b/apistos-scalar/README.md
new file mode 100644
index 0000000..70f31af
--- /dev/null
+++ b/apistos-scalar/README.md
@@ -0,0 +1,43 @@
+# Apistos &emsp; [![Documentation]][docs.rs] [![Latest Version]][crates.io] [![Build Status]][build] [![Deps Status]][deps.rs]
+
+[docs.rs]: https://docs.rs/apistos-scalar/
+
+[crates.io]: https://crates.io/crates/apistos-scalar
+
+[build]: https://github.com/netwo-io/apistos/actions/workflows/build.yaml?branch=main
+
+[Documentation]: https://img.shields.io/docsrs/apistos-scalar
+
+[Latest Version]: https://img.shields.io/crates/v/apistos-scalar.svg
+
+[Build Status]: https://github.com/netwo-io/apistos/actions/workflows/build.yaml/badge.svg?branch=main
+
+[deps.rs]: https://deps.rs/crate/apistos-scalar
+
+[Deps Status]: https://deps.rs/crate/apistos-scalar/latest/status.svg
+
+Bridge between Apistos and [Scalar](https://scalar.com/) for actix.
+
+This crate is exposed through Apistos `scalar` feature.
+
+### Installation
+
+```toml
+[dependencies]
+#schemars = "0.8"
+# sadly we currently rely on a fork to fix multiple flatten for enums, related PR can be found here: https://github.com/GREsau/schemars/pull/264
+schemars = { package = "apistos-schemars", version = "0.8" }
+apistos = { version = "0.2", feature = ["scalar"] }
+```
+
+### About us
+
+apistos is provided by [Netwo](https://www.netwo.io).
+
+We use this crate for our internal needs and therefore are committed to its maintenance, however we cannot provide any
+additional guaranty. Use it at your own risks.
+
+While we won't invest in any feature we don't need, we are open to accept any pull request you might propose.
+
+We are a France based full-remote company operating in the telecom industry. If you are interested in learning more,
+feel free to visit [our career page](https://www.netwo.io/carriere).
diff --git a/apistos-scalar/assets/index.html b/apistos-scalar/assets/index.html
new file mode 100644
index 0000000..e09e91e
--- /dev/null
+++ b/apistos-scalar/assets/index.html
@@ -0,0 +1,20 @@
+<!doctype html>
+<html>
+<head>
+    <title>Scalar API Reference</title>
+    <meta charset="utf-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1"/>
+</head>
+<body>
+<script id="api-reference" data-url="$specUrl"></script>
+<script>
+    var configuration = {
+        theme: 'purple',
+    }
+
+    document.getElementById('api-reference').dataset.configuration =
+        JSON.stringify(configuration)
+</script>
+<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
+</body>
+</html>
\ No newline at end of file
diff --git a/apistos-scalar/src/lib.rs b/apistos-scalar/src/lib.rs
new file mode 100644
index 0000000..192ae61
--- /dev/null
+++ b/apistos-scalar/src/lib.rs
@@ -0,0 +1,61 @@
+//! This crate allow to expose the generated openapi specification through [Scalar](https://scalar.com/).
+
+use apistos_plugins::ui::{UIPlugin, UIPluginConfig};
+
+const SCALAR_DEFAULT: &str = include_str!("../assets/index.html");
+
+/// Config for exposing the openapi specification through scalar
+pub struct ScalarConfig {
+  html: String,
+  path: String,
+}
+
+impl ScalarConfig {
+  /// Create a new [`ScalarConfig`] with the `path` on which to expose the scalar.
+  pub fn new<T: ToString>(path: &T) -> Self {
+    Self {
+      html: SCALAR_DEFAULT.to_string(),
+      path: path.to_string(),
+    }
+  }
+
+  /// Allow to override the default scalar html file path. The new file should contain the
+  /// `$specUrl` variable which refers the openapi specification url
+  pub fn with_html(mut self, html_path: String) -> Self {
+    self.html = html_path;
+    self
+  }
+}
+
+impl UIPluginConfig for ScalarConfig {
+  fn build(self: Box<Self>, openapi_path: &str) -> Box<dyn UIPlugin> {
+    Box::new(Scalar::new(*self, openapi_path))
+  }
+}
+
+#[doc(hidden)]
+pub struct Scalar {
+  html: String,
+  path: String,
+  openapi_path: String,
+}
+
+impl Scalar {
+  pub fn new(config: ScalarConfig, openapi_path: &str) -> Self {
+    Self {
+      html: config.html,
+      path: config.path,
+      openapi_path: openapi_path.to_string(),
+    }
+  }
+}
+
+impl UIPlugin for Scalar {
+  fn path(&self) -> String {
+    self.path.clone()
+  }
+
+  fn to_html(&self) -> String {
+    self.html.replace("$specUrl", &self.openapi_path)
+  }
+}
diff --git a/apistos/Cargo.toml b/apistos/Cargo.toml
index de7ffff..e973379 100644
--- a/apistos/Cargo.toml
+++ b/apistos/Cargo.toml
@@ -31,6 +31,7 @@ apistos-models = { path = "../apistos-models", version = "0.2.2" }
 apistos-plugins = { path = "../apistos-plugins", version = "0.2.2" }
 apistos-rapidoc = { path = "../apistos-rapidoc", version = "0.2.2", optional = true }
 apistos-redoc = { path = "../apistos-redoc", version = "0.2.2", optional = true }
+apistos-scalar = { path = "../apistos-scalar", version = "0.2.2", optional = true }
 apistos-swagger-ui = { path = "../apistos-swagger-ui", version = "0.2.2", optional = true }
 
 
@@ -41,6 +42,7 @@ garde-actix-web = { workspace = true }
 apistos-models = { path = "../apistos-models", version = "0.2.2", features = ["deserialize"] }
 apistos-rapidoc = { path = "../apistos-rapidoc", version = "0.2.2" }
 apistos-redoc = { path = "../apistos-redoc", version = "0.2.2" }
+apistos-scalar = { path = "../apistos-scalar", version = "0.2.2" }
 apistos-swagger-ui = { path = "../apistos-swagger-ui", version = "0.2.2" }
 
 [lints]
@@ -66,6 +68,7 @@ extras = ["chrono", "multipart", "rust_decimal", "uuid", "url"]
 # ui features
 rapidoc = ["dep:apistos-rapidoc"]
 redoc = ["dep:apistos-redoc"]
+scalar = ["dep:apistos-scalar"]
 swagger-ui = ["dep:apistos-swagger-ui"]
 
 default = ["query"]
diff --git a/apistos/src/app.rs b/apistos/src/app.rs
index 616e5a0..4114851 100644
--- a/apistos/src/app.rs
+++ b/apistos/src/app.rs
@@ -43,7 +43,7 @@ pub struct App<T> {
 /// use actix_web::App;
 /// use apistos::app::{BuildConfig, OpenApiWrapper};
 /// use apistos::web::scope;
-/// use apistos::{RapidocConfig, RedocConfig, SwaggerUIConfig};
+/// use apistos::{RapidocConfig, RedocConfig, ScalarConfig, SwaggerUIConfig};
 ///
 /// App::new()
 ///   .document(todo!())
@@ -53,6 +53,7 @@ pub struct App<T> {
 ///     BuildConfig::default()
 ///       .with(RapidocConfig::new(&"/rapidoc")) // with rapidoc feature enable
 ///       .with(RedocConfig::new(&"/redoc")) // with redoc feature enable
+///       .with(Scalar::new(&"/scalar")) // with scalar feature enable
 ///       .with(SwaggerUIConfig::new(&"/swagger")), // with swagger-ui feature enable
 ///   );
 /// ```
@@ -219,7 +220,7 @@ where
   /// use actix_web::App;
   /// use apistos::app::{BuildConfig, OpenApiWrapper};
   /// use apistos::web::scope;
-  /// use apistos::{RapidocConfig, RedocConfig, SwaggerUIConfig};
+  /// use apistos::{RapidocConfig, RedocConfig, ScalarConfig, SwaggerUIConfig};
   ///
   /// App::new()
   ///   .document(todo!())
@@ -229,6 +230,7 @@ where
   ///     BuildConfig::default()
   ///       .with(RapidocConfig::new(&"/rapidoc")) // with rapidoc feature enable
   ///       .with(RedocConfig::new(&"/redoc")) // with redoc feature enable
+  ///       .with(ScalarConfig::new(&"/scalar")) // with scalar feature enable
   ///       .with(SwaggerUIConfig::new(&"/swagger")), // with swagger-ui feature enable
   ///   );
   /// ```
@@ -351,6 +353,7 @@ mod test {
   use apistos_models::OpenApi;
   use apistos_rapidoc::RapidocConfig;
   use apistos_redoc::RedocConfig;
+  use apistos_scalar::ScalarConfig;
   use apistos_swagger_ui::SwaggerUIConfig;
 
   #[actix_web::test]
@@ -369,13 +372,13 @@ mod test {
   }
 
   #[actix_web::test]
-  async fn open_api_available_through_swagger() {
+  async fn open_api_available_through_rapidoc() {
     let openapi_path = "/test.json";
-    let swagger_path = "/swagger";
+    let rapidoc_path = "/rapidoc";
 
     let app = App::new().document(Spec::default()).build_with(
       openapi_path,
-      BuildConfig::default().with(SwaggerUIConfig::new(&swagger_path)),
+      BuildConfig::default().with(RapidocConfig::new(&rapidoc_path)),
     );
     let app = init_service(app).await;
 
@@ -383,19 +386,38 @@ mod test {
     let resp = call_service(&app, req).await;
     assert!(resp.status().is_success());
 
-    let req = TestRequest::get().uri(swagger_path).to_request();
+    let req = TestRequest::get().uri(rapidoc_path).to_request();
     let resp = call_service(&app, req).await;
     assert!(resp.status().is_success());
   }
 
   #[actix_web::test]
-  async fn open_api_available_through_rapidoc() {
+  async fn open_api_available_through_redoc() {
     let openapi_path = "/test.json";
-    let rapidoc_path = "/rapidoc";
+    let redoc_path = "/redoc";
+
+    let app = App::new()
+      .document(Spec::default())
+      .build_with(openapi_path, BuildConfig::default().with(RedocConfig::new(&redoc_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 req = TestRequest::get().uri(redoc_path).to_request();
+    let resp = call_service(&app, req).await;
+    assert!(resp.status().is_success());
+  }
+
+  #[actix_web::test]
+  async fn open_api_available_through_scalar() {
+    let openapi_path = "/test.json";
+    let scalar_path = "/scalar";
 
     let app = App::new().document(Spec::default()).build_with(
       openapi_path,
-      BuildConfig::default().with(RapidocConfig::new(&rapidoc_path)),
+      BuildConfig::default().with(ScalarConfig::new(&scalar_path)),
     );
     let app = init_service(app).await;
 
@@ -403,26 +425,27 @@ mod test {
     let resp = call_service(&app, req).await;
     assert!(resp.status().is_success());
 
-    let req = TestRequest::get().uri(rapidoc_path).to_request();
+    let req = TestRequest::get().uri(scalar_path).to_request();
     let resp = call_service(&app, req).await;
     assert!(resp.status().is_success());
   }
 
   #[actix_web::test]
-  async fn open_api_available_through_redoc() {
+  async fn open_api_available_through_swagger() {
     let openapi_path = "/test.json";
-    let redoc_path = "/redoc";
+    let swagger_path = "/swagger";
 
-    let app = App::new()
-      .document(Spec::default())
-      .build_with(openapi_path, BuildConfig::default().with(RedocConfig::new(&redoc_path)));
+    let app = App::new().document(Spec::default()).build_with(
+      openapi_path,
+      BuildConfig::default().with(SwaggerUIConfig::new(&swagger_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 req = TestRequest::get().uri(redoc_path).to_request();
+    let req = TestRequest::get().uri(swagger_path).to_request();
     let resp = call_service(&app, req).await;
     assert!(resp.status().is_success());
   }
diff --git a/apistos/src/lib.rs b/apistos/src/lib.rs
index 3db2547..2d5fe2f 100644
--- a/apistos/src/lib.rs
+++ b/apistos/src/lib.rs
@@ -158,6 +158,8 @@ pub use apistos_models::*;
 pub use apistos_rapidoc::RapidocConfig;
 #[cfg(feature = "redoc")]
 pub use apistos_redoc::RedocConfig;
+#[cfg(feature = "scalar")]
+pub use apistos_scalar::ScalarConfig;
 #[cfg(feature = "swagger-ui")]
 pub use apistos_swagger_ui::SwaggerUIConfig;
 
diff --git a/examples/simple-todo/Cargo.toml b/examples/simple-todo/Cargo.toml
index ea691a4..8fcf17e 100644
--- a/examples/simple-todo/Cargo.toml
+++ b/examples/simple-todo/Cargo.toml
@@ -8,7 +8,7 @@ authors = ["Netwo <oss@netwo.com>"]
 
 [dependencies]
 actix-web = "4"
-apistos = { path = "../../apistos", features = ["extras", "qs_query", "rapidoc", "redoc", "swagger-ui"] }
+apistos = { path = "../../apistos", features = ["extras", "qs_query", "rapidoc", "redoc", "scalar", "swagger-ui"] }
 schemars = { package = "apistos-schemars", version = "0.8", features = ["chrono", "uuid1", "url", "rust_decimal"] }
 serde = { version = "1.0", features = ["derive"] }
 uuid = { version = "1", features = ["serde", "v4"] }
diff --git a/examples/simple-todo/src/main.rs b/examples/simple-todo/src/main.rs
index 71c5873..cff63af 100644
--- a/examples/simple-todo/src/main.rs
+++ b/examples/simple-todo/src/main.rs
@@ -6,7 +6,7 @@ use apistos::info::Info;
 use apistos::server::Server;
 use apistos::spec::Spec;
 use apistos::web::{get, post, resource, scope};
-use apistos::{RapidocConfig, RedocConfig, SwaggerUIConfig};
+use apistos::{RapidocConfig, RedocConfig, ScalarConfig, SwaggerUIConfig};
 use std::error::Error;
 use std::net::Ipv4Addr;
 
@@ -45,6 +45,7 @@ async fn main() -> Result<(), impl Error> {
         BuildConfig::default()
           .with(RapidocConfig::new(&"/rapidoc"))
           .with(RedocConfig::new(&"/redoc"))
+          .with(ScalarConfig::new(&"/scalar"))
           .with(SwaggerUIConfig::new(&"/swagger")),
       )
   })


Test patch:
diff --git a/apistos/tests/default_parameters.rs b/apistos/tests/default_parameters.rs
index c18b2ff..508288f 100644
--- a/apistos/tests/default_parameters.rs
+++ b/apistos/tests/default_parameters.rs
@@ -180,6 +180,7 @@ use apistos_core as _;
 use apistos_plugins as _;
 use apistos_rapidoc as _;
 use apistos_redoc as _;
+use apistos_scalar as _;
 use apistos_swagger_ui as _;
 use garde_actix_web as _;
 use indexmap as _;
diff --git a/apistos/tests/operation_id.rs b/apistos/tests/operation_id.rs
index d8f20c2..c68004c 100644
--- a/apistos/tests/operation_id.rs
+++ b/apistos/tests/operation_id.rs
@@ -143,6 +143,7 @@ use apistos_core as _;
 use apistos_plugins as _;
 use apistos_rapidoc as _;
 use apistos_redoc as _;
+use apistos_scalar as _;
 use apistos_swagger_ui as _;
 use garde_actix_web as _;
 use indexmap as _;
diff --git a/apistos/tests/parametric_handlers.rs b/apistos/tests/parametric_handlers.rs
index 23b38c5..9b92b56 100644
--- a/apistos/tests/parametric_handlers.rs
+++ b/apistos/tests/parametric_handlers.rs
@@ -185,6 +185,7 @@ use apistos_core as _;
 use apistos_plugins as _;
 use apistos_rapidoc as _;
 use apistos_redoc as _;
+use apistos_scalar as _;
 use apistos_swagger_ui as _;
 use garde_actix_web as _;
 use indexmap as _;
diff --git a/apistos/tests/path_parameters.rs b/apistos/tests/path_parameters.rs
index 12173a0..c031211 100644
--- a/apistos/tests/path_parameters.rs
+++ b/apistos/tests/path_parameters.rs
@@ -135,6 +135,7 @@ use apistos_core as _;
 use apistos_plugins as _;
 use apistos_rapidoc as _;
 use apistos_redoc as _;
+use apistos_scalar as _;
 use apistos_swagger_ui as _;
 use garde_actix_web as _;
 use indexmap as _;
diff --git a/apistos/tests/query_parameters.rs b/apistos/tests/query_parameters.rs
index fe8ceda..9a5843b 100644
--- a/apistos/tests/query_parameters.rs
+++ b/apistos/tests/query_parameters.rs
@@ -279,6 +279,7 @@ use apistos_models as _;
 use apistos_plugins as _;
 use apistos_rapidoc as _;
 use apistos_redoc as _;
+use apistos_scalar as _;
 use apistos_swagger_ui as _;
 use indexmap as _;
 use log as _;
@@ -286,3 +287,8 @@ use md5 as _;
 use once_cell as _;
 use regex as _;
 use serde_json as _;
+
+#[cfg(not(feature = "lab_query"))]
+use actix_web_lab as _;
+#[cfg(not(feature = "garde"))]
+use garde_actix_web as _;
diff --git a/apistos/tests/tags.rs b/apistos/tests/tags.rs
index dea3694..99426b0 100644
--- a/apistos/tests/tags.rs
+++ b/apistos/tests/tags.rs
@@ -147,6 +147,7 @@ use apistos_core as _;
 use apistos_plugins as _;
 use apistos_rapidoc as _;
 use apistos_redoc as _;
+use apistos_scalar as _;
 use apistos_swagger_ui as _;
 use garde_actix_web as _;
 use indexmap as _;