Skip to content

Commit

Permalink
feat: add scalar integration (alternative to Redoc) (#104)
Browse files Browse the repository at this point in the history
* wip: add scalar docs

* feat: add rust theme

* fix: uncomment example code
  • Loading branch information
marclave authored Jan 15, 2024
1 parent 079a891 commit 1a20278
Show file tree
Hide file tree
Showing 7 changed files with 393 additions and 1 deletion.
1 change: 1 addition & 0 deletions crates/aide/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jwt-authorizer = { version = "0.13", default-features = false, optional = true }
[features]
macros = ["dep:aide-macros"]
redoc = []
scalar = []
skip_serializing_defaults = []

axum = ["dep:axum", "bytes", "http", "dep:tower-layer", "dep:tower-service", "serde_qs?/axum"]
Expand Down
138 changes: 138 additions & 0 deletions crates/aide/res/scalar/rust-theme.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
root {
--theme-font: "Inter", var(--system-fonts);
}
/* basic theme */
.light-mode {
--theme-color-1: rgb(9, 9, 11);
--theme-color-2: rgb(113, 113, 122);
--theme-color-3: rgba(25, 25, 28, 0.5);
--theme-color-accent: var(--theme-color-1);

--theme-background-1: #fff;
--theme-background-2: #f4f4f5;
--theme-background-3: #e3e3e6;
--theme-background-accent: #8ab4f81f;

--theme-border-color: rgb(228, 228, 231);
}
.dark-mode {
--theme-color-1: #fafafa;
--theme-color-2: rgb(161, 161, 170);
--theme-color-3: rgba(255, 255, 255, 0.533);
--theme-color-accent: var(--theme-color-1);

--theme-background-1: #09090b;
--theme-background-2: #18181b;
--theme-background-3: #2c2c30;
--theme-background-accent: #8ab4f81f;

--theme-border-color: rgba(255, 255, 255, 0.12);
}
/* Document header */
.light-mode .t-doc__header {
--header-background-1: var(--theme-background-1);
--header-border-color: var(--theme-border-color);
--header-color-1: var(--theme-color-1);
--header-color-2: var(--theme-color-2);
--header-background-toggle: var(--theme-color-3);
--header-call-to-action-color: var(--theme-color-accent);
}

.dark-mode .t-doc__header {
--header-background-1: var(--theme-background-1);
--header-border-color: var(--theme-border-color);
--header-color-1: var(--theme-color-1);
--header-color-2: var(--theme-color-2);
--header-background-toggle: var(--theme-color-3);
--header-call-to-action-color: var(--theme-color-accent);
}
/* Document Sidebar */
.light-mode .t-doc__sidebar {
--sidebar-background-1: var(--theme-background-1);
--sidebar-item-hover-color: currentColor;
--sidebar-item-hover-background: var(--theme-background-2);
--sidebar-item-active-background: #09090b;
--sidebar-border-color: var(--theme-border-color);
--sidebar-color-1: var(--theme-color-1);
--sidebar-color-2: var(--theme-color-2);
--sidebar-color-active: var(--theme-background-1);
--sidebar-search-background: transparent;
--sidebar-search-border-color: var(--theme-border-color);
--sidebar-search--color: var(--theme-color-3);
}

.dark-mode .sidebar {
--sidebar-background-1: var(--theme-background-1);
--sidebar-item-hover-color: currentColor;
--sidebar-item-hover-background: var(--theme-background-2);
--sidebar-item-active-background: var(--theme-background-3);
--sidebar-border-color: var(--theme-border-color);
--sidebar-color-1: var(--theme-color-1);
--sidebar-color-2: var(--theme-color-2);
--sidebar-color-active: var(--theme-color-accent);
--sidebar-search-background: transparent;
--sidebar-search-border-color: var(--theme-border-color);
--sidebar-search--color: var(--theme-color-3);
}
/* advanced */
.light-mode {
--theme-button-1: rgb(49 53 56);
--theme-button-1-color: #fff;
--theme-button-1-hover: rgb(28 31 33);

--theme-color-green: #069061;
--theme-color-red: #ef0006;
--theme-color-yellow: #edbe20;
--theme-color-blue: #0082d0;
--theme-color-orange: #fb892c;
--theme-color-purple: #5203d1;

--theme-scrollbar-color: rgba(0, 0, 0, 0.18);
--theme-scrollbar-color-active: rgba(0, 0, 0, 0.36);
}
.dark-mode {
--theme-button-1: #f6f6f6;
--theme-button-1-color: #000;
--theme-button-1-hover: #e7e7e7;

--theme-color-green: #00b648;
--theme-color-red: #dc1b19;
--theme-color-yellow: #ffc90d;
--theme-color-blue: #4eb3ec;
--theme-color-orange: #ff8d4d;
--theme-color-purple: #b191f9;

--theme-scrollbar-color: rgba(255, 255, 255, 0.24);
--theme-scrollbar-color-active: rgba(255, 255, 255, 0.48);
}
/* Adv customization */
.introduction-cards .scalar-card:first-of-type {
overflow: visible;
}
.examples .scalar-card-footer {
--theme-background-3: transparent;
padding-top: 0;
}
.show-api-client-button:before {
background: white !important;
}
.show-api-client-button span,
.show-api-client-button svg {
color: black !important;
}
.download-cta,
.references-rendered .markdown a {
text-decoration: underline !important;
}
.introduction-cards .scalar-card:first-of-type:before {
content: "";
width: 140px;
height: 140px;
position: absolute;
right: -12px;
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHN0eWxlPSJmaWxsLXJ1bGU6ZXZlbm9kZDtjbGlwLXJ1bGU6ZXZlbm9kZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6MS40MTQyMSIgdmlld0JveD0iMCAwIDEyMDAgODAwIj48cGF0aCBkPSJtMC03LTEyLTI1di0zYzAtMjgtMjktNTUtNzktNzV2MTdsLTE1LTZ2LThjLTcxLTI1LTE3Ny00Mi0yOTQtNDItMTAzIDAtMTk3IDEzLTI2NiAzM3YyOC0zNGMtNzEgMjMtMTE1IDU0LTExNSA4OWwzIDE2LTcgMTNzLTYgNyA0IDIzYTIxMzggMjEzOCAwIDAgMCA5NiAxMjBjMS0xLTctMjktMjctNjAtOS0xNy0yMS00MC0yNy01NiAxOSAxMiAxMzAgNjggMzU3IDY4Qy0xNTAgOTAtNDcgMTgtMzYgOGMtNCAxNi0xNyA0OC0yOCA3MS0xOCAzMi0yNCA2MC0yMyA2MWwxNS0yM0MtNTEgODctMTIgMzEtNCAxNyA1IDAgMC03IDAtNyIgc3R5bGU9ImZpbGw6I2E1MmIwMDtmaWxsLXJ1bGU6bm9uemVybyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTAwOSA1MDYpIi8+PHBhdGggZD0ibTAgMjA0LTc5LTU4LTMtOCAyNi0zOGMzLTQgNC05IDItMTNzLTYtOC0xMC04bC00NC04LTUtMTAgMTgtNDJjMi01IDItMTAtMS0xNC0yLTQtNy02LTExLTZsLTQ1IDItNy05IDEwLTQ2YzEtNCAwLTktMy0xMi0zLTQtOC01LTEzLTRsLTQzIDExLTktOCAyLTQ2YzAtNS0yLTktNi0xMnMtOC0zLTEzLTFsLTQwIDE5LTEwLTUtNy00NmMtMS01LTQtOS04LTExcy05LTEtMTMgMmwtMzYgMjctMTEtMy0xNi00NGMtMS00LTUtOC05LTktNS0xLTkgMS0xMiA0bC0zMSAzNS0xMS0yLTIzLTM5Yy0zLTQtNy03LTEyLTctNCAwLTggMy0xMSA3bC0yMyAzOS0xMSAyLTMxLTM1YTEzIDEzIDAgMCAwLTIyIDVsLTE1IDQ0LTExIDMtMzYtMjdjLTQtMy05LTQtMTMtMnMtNyA2LTggMTFsLTcgNDYtMTAgNS00MS0xOWMtNC0yLTktMi0xMiAxLTQgMy02IDctNiAxMmwxIDQ2LTggOC00NC0xMWMtNC0xLTkgMC0xMiA0LTMgMy01IDgtMyAxMmwxMCA0Ni03IDktNDUtMmMtNSAwLTkgMi0xMSA2LTMgNC0zIDktMSAxNGwxOCA0Mi01IDEwLTQ0IDhjLTUgMC04IDQtMTAgOHMtMSA5IDEgMTNsMjYgMzh2MmwtNzQgNzhzLTExIDkgNSAzMGMxNSAxOCA4OSA5MSAxMjggMTI5IDE3IDE5IDI4IDMxIDMwIDMwcy04LTM2LTQxLTc4Yy0yNS0zNy01OC05NC01MC0xMDAgMCAwIDgtMTEgMjYtMTkgMSAwLTEtMSAwIDAgMCAwIDM3MCAxNzAgNzEyIDIgNDAtNyA2MyAxNCA2MyAxNCA4IDUtMTMgNjQtMzAgMTAzLTI0IDQ0LTI3IDc4LTI1IDc5IDEgMSAxMC0xMiAyMi0zMyAzMC00MiA4OC0xMjMgOTgtMTQyIDEyLTIzIDAtMjkgMC0yOSIgc3R5bGU9ImZpbGw6I2Y3NGMwMDtmaWxsLXJ1bGU6bm9uemVybyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTA3OSAyOTUpIi8+PHBhdGggZD0iTTAgMjMyczUzLTIgMTIzLTczbDEwLTIyUzI1IDI5IDE5My00NWMwIDAtMTUgMjQtMTAgMTE3IDAgMCA1MC0xOCA2NS0xMDAgMCAwIDc0IDk4LTgwIDE4OSAwIDAtNzMgMTE3LTE3NSAxMDZsNy0zNVoiIHN0eWxlPSJmaWxsOiNmNzRjMDA7ZmlsbC1ydWxlOm5vbnplcm8iIHRyYW5zZm9ybT0idHJhbnNsYXRlKDkxOCAyNDUpIi8+PHBhdGggZD0iTTAtNzhzMzctNDEgNzQgMGMwIDAgMjkgNTQgMCA4MSAwIDAtNDggMzctNzQgMCAwIDAtMzItMzAgMC04MSIgc3R5bGU9ImZpbGwtcnVsZTpub256ZXJvIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2NzcgNDg4KSIvPjxwYXRoIGQ9Ik0wIDBjMCAxNi05IDI5LTIxIDI5LTExIDAtMjAtMTMtMjAtMjlzOS0yOSAyMC0yOUMtOS0yOSAwLTE2IDAgMCIgc3R5bGU9ImZpbGw6I2ZmZjtmaWxsLXJ1bGU6bm9uemVybyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNzIwIDQyNSkiLz48cGF0aCBkPSJNMC04NHM2My0yOCA4MSAzNWMwIDAgMTggNzMtNTIgNzcgMCAwLTkwLTE3LTI5LTExMiIgc3R5bGU9ImZpbGwtcnVsZTpub256ZXJvIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1MTIgNDgzKSIvPjxwYXRoIGQ9Ik0wIDBjMCAxNi0xMCAyOS0yMSAyOS0xMiAwLTIyLTEzLTIyLTI5czEwLTI5IDIyLTI5Qy0xMC0yOSAwLTE2IDAgMCIgc3R5bGU9ImZpbGw6I2ZmZjtmaWxsLXJ1bGU6bm9uemVybyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNTQ0IDQyNikiLz48cGF0aCBkPSJtMC00MCA4MS03cy0zIDEzLTEzIDIzYzAgMC0yOCAzMC02MC0zIDAgMC01LTUtOC0xMyIgc3R5bGU9ImZpbGwtcnVsZTpub256ZXJvIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1OTMgNTc3KSIvPjxwYXRoIGQ9Ik0wIDE5MWgtMmMtMjYtNi0xMDAtMjUtMTE5LTU3IDAgMCA5OS0xMTItNTYtMTg1bDE4IDEyNXMtNDgtMzItNTgtMTAxYzAgMC0xMTcgODQgNTEgMTY1IDAgMCAxNSA1OCAxNjEgMTAzYTI1IDI1IDAgMCAwIDMwLTI1YzAtMTQtMTEtMjUtMjUtMjUiIHN0eWxlPSJmaWxsOiNmNzRjMDA7ZmlsbC1ydWxlOm5vbnplcm8iIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI3MCAyNzEpIi8+PC9zdmc+);
background-size: 100%;
background-repeat: no-repeat;
bottom: 0;
background-position: center 70px;
}
32 changes: 32 additions & 0 deletions crates/aide/res/scalar/scalar.standalone.min.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions crates/aide/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ mod helpers;
#[cfg(feature = "redoc")]
pub mod redoc;

#[cfg(feature = "scalar")]
pub mod scalar;

pub use helpers::{no_api::NoApi, with_api::ApiOverride, with_api::WithApi, use_api::UseApi};

pub use error::Error;
Expand Down
206 changes: 206 additions & 0 deletions crates/aide/src/scalar/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
//! Generate [Scalar] API References. This feature requires the `axum` feature.
//!
//! ## Example:
//!
//! ```no_run
//! // Replace some of the `axum::` types with `aide::axum::` ones.
//! use aide::{
//! axum::{
//! routing::{get, post},
//! ApiRouter, IntoApiResponse,
//! },
//! openapi::{Info, OpenApi},
//! scalar::Scalar,
//! };
//! use axum::{Extension, Json};
//! use schemars::JsonSchema;
//! use serde::Deserialize;
//!
//! // We'll need to derive `JsonSchema` for
//! // all types that appear in the api documentation.
//! #[derive(Deserialize, JsonSchema)]
//! struct User {
//! name: String,
//! }
//!
//! async fn hello_user(Json(user): Json<User>) -> impl IntoApiResponse {
//! format!("hello {}", user.name)
//! }
//!
//! // Note that this clones the document on each request.
//! // To be more efficient, we could wrap it into an Arc,
//! // or even store it as a serialized string.
//! async fn serve_api(Extension(api): Extension<OpenApi>) -> impl IntoApiResponse {
//! Json(api)
//! }
//!
//! #[tokio::main]
//! async fn main() {
//! let app = ApiRouter::new()
//! // generate Scalar API References using the openapi spec route
//! .route("/scalar", Scalar::new("/api.json").axum_route())
//! // Change `route` to `api_route` for the route
//! // we'd like to expose in the documentation.
//! .api_route("/hello", post(hello_user))
//! // We'll serve our generated document here.
//! .route("/api.json", get(serve_api));
//!
//! let mut api = OpenApi {
//! info: Info {
//! description: Some("an example API".to_string()),
//! ..Info::default()
//! },
//! ..OpenApi::default()
//! };
//!
//! let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
//!
//! axum::serve(
//! listener,
//! app
//! // Generate the documentation.
//! .finish_api(&mut api)
//! // Expose the documentation to the handlers.
//! .layer(Extension(api))
//! .into_make_service(),
//! )
//! .await
//! .unwrap();
//! }
//! ```

/// A wrapper to embed [Scalar](https://github.com/scalar/scalar) in your app.
#[must_use]
pub struct Scalar {
title: String,
spec_url: String,
}

impl Scalar {
/// Create a new [`Scalar`] wrapper with the given spec url.
pub fn new(spec_url: impl Into<String>) -> Self {
Self {
title: "Scalar".into(),
spec_url: spec_url.into(),
}
}

/// Set the title of the Scalar page.
pub fn with_title(mut self, title: &str) -> Self {
self.title = title.into();
self
}

/// Build the Scalar API References html page.
#[must_use]
pub fn html(&self) -> String {
format!(
r#"<!DOCTYPE html>
<!doctype html>
<html>
<head>
<title>{title}</title>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1" />
<style>
body {{
margin: 0;
}}
</style>
</head>
<body>
<script
id="api-reference"></script>
<script>
var configuration = {{
theme: 'purple',
customCss: `{scalar_css}`,
spec: {{
url: '{spec_url}'
}}
}}
var apiReference = document.getElementById('api-reference')
apiReference.dataset.configuration = JSON.stringify(configuration)
</script>
<script>
{scalar_js}
</script>
</body>
</html>
"#,
scalar_js = include_str!("../../res/scalar/scalar.standalone.min.js"),
scalar_css = include_str!("../../res/scalar/rust-theme.css"),
title = self.title,
spec_url = self.spec_url
)
}
}

#[cfg(feature = "axum")]
mod axum_impl {
use crate::axum::{
routing::{get, ApiMethodRouter},
AxumOperationHandler,
};
use crate::scalar::get_static_str;
use axum::response::Html;

impl super::Scalar {
/// Returns an [`ApiMethodRouter`] to expose the Scalar API References.
///
/// # Examples
///
/// ```
/// # use aide::axum::{ApiRouter, routing::get};
/// # use aide::scalar::Scalar;
/// ApiRouter::<()>::new()
/// .route("/docs", Scalar::new("/openapi.json").axum_route());
/// ```
pub fn axum_route<S>(&self) -> ApiMethodRouter<S>
where
S: Clone + Send + Sync + 'static,
{
get(self.axum_handler())
}

/// Returns an axum [`Handler`](axum::handler::Handler) that can be used
/// with API routes.
///
/// # Examples
///
/// ```
/// # use aide::axum::{ApiRouter, routing::get_with};
/// # use aide::scalar::Scalar;
/// ApiRouter::<()>::new().api_route(
/// "/docs",
/// get_with(Scalar::new("/openapi.json").axum_handler(), |op| {
/// op.description("This documentation page.")
/// }),
/// );
/// ```
#[must_use]
pub fn axum_handler<S>(
&self,
) -> impl AxumOperationHandler<(), Html<&'static str>, ((),), S> {
let html = self.html();
// This string will be used during the entire lifetime of the program
// so it's safe to leak it
// we can't use once_cell::sync::Lazy because it will cache the first access to the function,
// so you won't be able to have multiple instances of Scalar
// e.g. /v1/docs and /v2/docs
// Without caching we will have to clone whole html string on each request
// which will use 3GiBs of RAM for 200+ concurrent requests
let html: &'static str = get_static_str(html);

move || async move { Html(html) }
}
}
}

fn get_static_str(string: String) -> &'static str {
let static_str = Box::leak(string.into_boxed_str());
static_str
}
1 change: 1 addition & 0 deletions examples/example-axum/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ publish = false
[dependencies]
aide = { path = "../../crates/aide", features = [
"redoc",
"scalar",
"axum",
"axum-extra",
"macros",
Expand Down
Loading

0 comments on commit 1a20278

Please sign in to comment.