use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use bytes::Bytes;
use crate::builder::ApiDoc;
#[derive(Debug, Clone)]
pub struct MountOpts {
pub spec_path: String,
pub ui_path: String,
pub mount_ui: bool,
#[cfg(feature = "docs-scalar")]
pub scalar: crate::ui::ScalarConfig,
}
impl Default for MountOpts {
fn default() -> Self {
Self {
spec_path: "/openapi.json".to_string(),
ui_path: "/docs".to_string(),
mount_ui: true,
#[cfg(feature = "docs-scalar")]
scalar: crate::ui::ScalarConfig::default(),
}
}
}
impl MountOpts {
pub fn spec_path(mut self, path: impl Into<String>) -> Self {
self.spec_path = path.into();
self
}
pub fn ui_path(mut self, path: impl Into<String>) -> Self {
self.ui_path = path.into();
self
}
pub fn without_ui(mut self) -> Self {
self.mount_ui = false;
self
}
#[cfg(feature = "docs-scalar")]
pub fn scalar(mut self, cfg: crate::ui::ScalarConfig) -> Self {
self.scalar = cfg;
self
}
}
pub fn mount_docs<S>(router: Router<S>, api_doc: ApiDoc, opts: MountOpts) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
let json = mount_json(router, &api_doc, &opts);
if opts.mount_ui {
mount_ui(json, &api_doc, &opts)
} else {
json
}
}
pub trait MountDocsExt<S>
where
S: Clone + Send + Sync + 'static,
{
fn mount_docs(self, api_doc: ApiDoc, opts: MountOpts) -> Self;
}
impl<S> MountDocsExt<S> for Router<S>
where
S: Clone + Send + Sync + 'static,
{
fn mount_docs(self, api_doc: ApiDoc, opts: MountOpts) -> Self {
mount_docs(self, api_doc, opts)
}
}
fn mount_json<S>(router: Router<S>, api_doc: &ApiDoc, opts: &MountOpts) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
let spec_json: Bytes = api_doc.spec_json.clone();
router.route(
&opts.spec_path,
get(move || {
let body = spec_json.clone();
async move { json_response(body) }
}),
)
}
#[cfg(feature = "docs-scalar")]
fn mount_ui<S>(router: Router<S>, api_doc: &ApiDoc, opts: &MountOpts) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
let spec_url = opts.spec_path.clone();
let title = api_doc.openapi.info.title.clone();
let html: Bytes = Bytes::from(crate::ui::scalar::render(&spec_url, &title, &opts.scalar));
router.route(
&opts.ui_path,
get(move || {
let body = html.clone();
async move { html_response(body) }
}),
)
}
#[cfg(not(feature = "docs-scalar"))]
fn mount_ui<S>(router: Router<S>, _api_doc: &ApiDoc, opts: &MountOpts) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
tracing::debug!(
ui_path = %opts.ui_path,
"mount_docs: mount_ui requested but no UI feature is enabled at compile time"
);
router
}
fn json_response(body: Bytes) -> Response {
(
StatusCode::OK,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
)],
axum::body::Body::from(body),
)
.into_response()
}
#[cfg(feature = "docs-scalar")]
fn html_response(body: Bytes) -> Response {
(
StatusCode::OK,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
)],
axum::body::Body::from(body),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::ApiDocBuilder;
use http_body_util::BodyExt;
use tower::ServiceExt;
fn doc() -> ApiDoc {
ApiDocBuilder::new().title("test").version("0.1").build()
}
#[tokio::test]
async fn mounts_openapi_json_endpoint() {
let app: Router = mount_docs(Router::new(), doc(), MountOpts::default());
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/openapi.json")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"application/json"
);
let body = response.into_body().collect().await.unwrap().to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["info"]["title"], "test");
assert_eq!(parsed["info"]["version"], "0.1");
}
#[tokio::test]
async fn respects_custom_spec_path() {
let app: Router = mount_docs(
Router::new(),
doc(),
MountOpts::default().spec_path("/api/openapi.json"),
);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/api/openapi.json")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn mount_opts_default_values() {
let opts = MountOpts::default();
assert_eq!(opts.spec_path, "/openapi.json");
assert_eq!(opts.ui_path, "/docs");
assert!(opts.mount_ui);
}
#[test]
fn mount_opts_builder_chain_overrides_each_field() {
let opts = MountOpts::default()
.spec_path("/v2/openapi.json")
.ui_path("/v2/docs");
assert_eq!(opts.spec_path, "/v2/openapi.json");
assert_eq!(opts.ui_path, "/v2/docs");
let no_ui = MountOpts::default().without_ui();
assert!(!no_ui.mount_ui);
}
#[tokio::test]
async fn served_openapi_json_is_well_formed_openapi_3() {
let app: Router = mount_docs(Router::new(), doc(), MountOpts::default());
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/openapi.json")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(parsed["openapi"].as_str().unwrap().starts_with("3."));
assert!(parsed["info"].is_object());
assert!(parsed["paths"].is_object());
}
#[tokio::test]
async fn json_endpoint_serves_byte_identical_content_on_repeat_calls() {
let app: Router = mount_docs(Router::new(), doc(), MountOpts::default());
let mut bodies = Vec::new();
for _ in 0..3 {
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/openapi.json")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
bodies.push(response.into_body().collect().await.unwrap().to_bytes());
}
assert_eq!(bodies[0], bodies[1]);
assert_eq!(bodies[1], bodies[2]);
}
#[tokio::test]
async fn without_ui_omits_docs_route() {
let app: Router = mount_docs(Router::new(), doc(), MountOpts::default().without_ui());
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/docs")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
#[cfg(feature = "docs-scalar")]
async fn mounts_scalar_ui_at_default_path() {
let app: Router = mount_docs(Router::new(), doc(), MountOpts::default());
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/docs")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ct = response.headers().get(header::CONTENT_TYPE).unwrap();
assert!(ct.to_str().unwrap().starts_with("text/html"));
let body = response.into_body().collect().await.unwrap().to_bytes();
let html = std::str::from_utf8(&body).unwrap();
assert!(html.contains(r#"data-url="/openapi.json""#));
assert!(html.contains("<title>test</title>"));
assert!(html.contains("@scalar/api-reference"));
assert!(html.contains(r#""darkMode":true"#));
}
#[tokio::test]
#[cfg(feature = "docs-scalar")]
async fn scalar_config_override_propagates_to_html() {
use crate::ui::{ScalarConfig, ScalarLayout};
let app: Router = mount_docs(
Router::new(),
doc(),
MountOpts::default().scalar(ScalarConfig::default().layout(ScalarLayout::Classic)),
);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/docs")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let html = std::str::from_utf8(&body).unwrap();
assert!(html.contains(r#""layout":"classic""#));
}
}