Skip to main content

doxa_docs/
mount.rs

1//! Mount the OpenAPI JSON endpoint and documentation UI on an existing
2//! [`axum::Router`].
3//!
4//! The JSON is served from memory via an [`Arc<str>`] held by the
5//! handler closure — no disk reads, no per-request serialization.
6
7use axum::http::{header, HeaderValue, StatusCode};
8use axum::response::{IntoResponse, Response};
9use axum::routing::get;
10use axum::Router;
11use bytes::Bytes;
12
13use crate::builder::ApiDoc;
14
15/// Configuration for [`mount_docs`].
16///
17/// All fields have sensible defaults; override only what differs from
18/// the convention. The struct is intentionally builder-only — prefer
19/// `MountOpts::default()` followed by chained setters over struct
20/// literal construction so future field additions remain additive.
21#[derive(Debug, Clone)]
22pub struct MountOpts {
23    /// Path at which the raw OpenAPI JSON is served. Defaults to
24    /// `"/openapi.json"`.
25    pub spec_path: String,
26
27    /// Path at which the documentation UI is mounted. Defaults to
28    /// `"/docs"`. Only honored when at least one UI feature is enabled
29    /// at compile time.
30    pub ui_path: String,
31
32    /// Whether to mount the documentation UI. Defaults to `true`. Set
33    /// to `false` to expose only the raw JSON without an interactive
34    /// viewer.
35    pub mount_ui: bool,
36
37    /// Scalar UI rendering options. Defaults to
38    /// [`ScalarConfig::default()`](crate::ScalarConfig), which renders
39    /// the historical out-of-the-box appearance (three-pane `modern`
40    /// layout, dark mode on, schemas index hidden, codegen sidebar
41    /// suppressed, agent / MCP integrations disabled). Only honored
42    /// when the `docs-scalar` feature is enabled at compile time.
43    #[cfg(feature = "docs-scalar")]
44    pub scalar: crate::ui::ScalarConfig,
45}
46
47impl Default for MountOpts {
48    fn default() -> Self {
49        Self {
50            spec_path: "/openapi.json".to_string(),
51            ui_path: "/docs".to_string(),
52            mount_ui: true,
53            #[cfg(feature = "docs-scalar")]
54            scalar: crate::ui::ScalarConfig::default(),
55        }
56    }
57}
58
59impl MountOpts {
60    /// Use the supplied path for the raw OpenAPI JSON endpoint.
61    pub fn spec_path(mut self, path: impl Into<String>) -> Self {
62        self.spec_path = path.into();
63        self
64    }
65
66    /// Use the supplied path for the documentation UI mount point.
67    pub fn ui_path(mut self, path: impl Into<String>) -> Self {
68        self.ui_path = path.into();
69        self
70    }
71
72    /// Disable the documentation UI mount, exposing only the JSON
73    /// endpoint.
74    pub fn without_ui(mut self) -> Self {
75        self.mount_ui = false;
76        self
77    }
78
79    /// Override the Scalar UI configuration.
80    ///
81    /// # Example
82    ///
83    /// ```
84    /// use doxa::{MountOpts, ScalarConfig, ScalarLayout, ScalarTheme};
85    ///
86    /// let opts = MountOpts::default().scalar(
87    ///     ScalarConfig::default()
88    ///         .layout(ScalarLayout::Classic)
89    ///         .theme(ScalarTheme::Solarized)
90    ///         .dark_mode(false),
91    /// );
92    /// # let _ = opts;
93    /// ```
94    #[cfg(feature = "docs-scalar")]
95    pub fn scalar(mut self, cfg: crate::ui::ScalarConfig) -> Self {
96        self.scalar = cfg;
97        self
98    }
99}
100
101/// Mount the OpenAPI JSON endpoint (and a documentation UI, if a UI
102/// feature is enabled) on the supplied router.
103///
104/// The JSON handler closes over the [`ApiDoc`]'s pre-serialized
105/// [`Bytes`] and clones it on each request — zero allocations beyond
106/// the reference count bump.
107///
108/// # Example
109///
110/// ```no_run
111/// use axum::Router;
112/// use doxa::{mount_docs, ApiDocBuilder, MountOpts};
113///
114/// let api_doc = ApiDocBuilder::new().title("test").version("0.1").build();
115/// let app: Router = mount_docs(Router::new(), api_doc, MountOpts::default());
116/// # let _ = app;
117/// ```
118pub fn mount_docs<S>(router: Router<S>, api_doc: ApiDoc, opts: MountOpts) -> Router<S>
119where
120    S: Clone + Send + Sync + 'static,
121{
122    let json = mount_json(router, &api_doc, &opts);
123    if opts.mount_ui {
124        mount_ui(json, &api_doc, &opts)
125    } else {
126        json
127    }
128}
129
130/// Extension trait providing [`mount_docs`](MountDocsExt::mount_docs) as a
131/// fluent method on [`axum::Router`]. Equivalent to the free function
132/// [`mount_docs`].
133pub trait MountDocsExt<S>
134where
135    S: Clone + Send + Sync + 'static,
136{
137    /// Mount the OpenAPI JSON endpoint and (optionally) the
138    /// documentation UI on `self`.
139    fn mount_docs(self, api_doc: ApiDoc, opts: MountOpts) -> Self;
140}
141
142impl<S> MountDocsExt<S> for Router<S>
143where
144    S: Clone + Send + Sync + 'static,
145{
146    fn mount_docs(self, api_doc: ApiDoc, opts: MountOpts) -> Self {
147        mount_docs(self, api_doc, opts)
148    }
149}
150
151fn mount_json<S>(router: Router<S>, api_doc: &ApiDoc, opts: &MountOpts) -> Router<S>
152where
153    S: Clone + Send + Sync + 'static,
154{
155    let spec_json: Bytes = api_doc.spec_json.clone();
156    router.route(
157        &opts.spec_path,
158        get(move || {
159            // Bytes::clone is a refcount bump — no allocation, no copy.
160            let body = spec_json.clone();
161            async move { json_response(body) }
162        }),
163    )
164}
165
166#[cfg(feature = "docs-scalar")]
167fn mount_ui<S>(router: Router<S>, api_doc: &ApiDoc, opts: &MountOpts) -> Router<S>
168where
169    S: Clone + Send + Sync + 'static,
170{
171    let spec_url = opts.spec_path.clone();
172    let title = api_doc.openapi.info.title.clone();
173    let html: Bytes = Bytes::from(crate::ui::scalar::render(&spec_url, &title, &opts.scalar));
174    router.route(
175        &opts.ui_path,
176        get(move || {
177            let body = html.clone();
178            async move { html_response(body) }
179        }),
180    )
181}
182
183#[cfg(not(feature = "docs-scalar"))]
184fn mount_ui<S>(router: Router<S>, _api_doc: &ApiDoc, opts: &MountOpts) -> Router<S>
185where
186    S: Clone + Send + Sync + 'static,
187{
188    // No UI features enabled — return the router unchanged. The caller
189    // asked for a UI but none is available; this is logged once at
190    // debug level so the misconfiguration is visible.
191    tracing::debug!(
192        ui_path = %opts.ui_path,
193        "mount_docs: mount_ui requested but no UI feature is enabled at compile time"
194    );
195    router
196}
197
198fn json_response(body: Bytes) -> Response {
199    (
200        StatusCode::OK,
201        [(
202            header::CONTENT_TYPE,
203            HeaderValue::from_static("application/json"),
204        )],
205        axum::body::Body::from(body),
206    )
207        .into_response()
208}
209
210#[cfg(feature = "docs-scalar")]
211fn html_response(body: Bytes) -> Response {
212    (
213        StatusCode::OK,
214        [(
215            header::CONTENT_TYPE,
216            HeaderValue::from_static("text/html; charset=utf-8"),
217        )],
218        axum::body::Body::from(body),
219    )
220        .into_response()
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::builder::ApiDocBuilder;
227    use http_body_util::BodyExt;
228    use tower::ServiceExt;
229
230    fn doc() -> ApiDoc {
231        ApiDocBuilder::new().title("test").version("0.1").build()
232    }
233
234    #[tokio::test]
235    async fn mounts_openapi_json_endpoint() {
236        let app: Router = mount_docs(Router::new(), doc(), MountOpts::default());
237        let response = app
238            .oneshot(
239                axum::http::Request::builder()
240                    .uri("/openapi.json")
241                    .body(axum::body::Body::empty())
242                    .unwrap(),
243            )
244            .await
245            .unwrap();
246        assert_eq!(response.status(), StatusCode::OK);
247        assert_eq!(
248            response.headers().get(header::CONTENT_TYPE).unwrap(),
249            "application/json"
250        );
251        let body = response.into_body().collect().await.unwrap().to_bytes();
252        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
253        assert_eq!(parsed["info"]["title"], "test");
254        assert_eq!(parsed["info"]["version"], "0.1");
255    }
256
257    #[tokio::test]
258    async fn respects_custom_spec_path() {
259        let app: Router = mount_docs(
260            Router::new(),
261            doc(),
262            MountOpts::default().spec_path("/api/openapi.json"),
263        );
264        let response = app
265            .oneshot(
266                axum::http::Request::builder()
267                    .uri("/api/openapi.json")
268                    .body(axum::body::Body::empty())
269                    .unwrap(),
270            )
271            .await
272            .unwrap();
273        assert_eq!(response.status(), StatusCode::OK);
274    }
275
276    #[test]
277    fn mount_opts_default_values() {
278        let opts = MountOpts::default();
279        assert_eq!(opts.spec_path, "/openapi.json");
280        assert_eq!(opts.ui_path, "/docs");
281        assert!(opts.mount_ui);
282    }
283
284    #[test]
285    fn mount_opts_builder_chain_overrides_each_field() {
286        let opts = MountOpts::default()
287            .spec_path("/v2/openapi.json")
288            .ui_path("/v2/docs");
289        assert_eq!(opts.spec_path, "/v2/openapi.json");
290        assert_eq!(opts.ui_path, "/v2/docs");
291        let no_ui = MountOpts::default().without_ui();
292        assert!(!no_ui.mount_ui);
293    }
294
295    #[tokio::test]
296    async fn served_openapi_json_is_well_formed_openapi_3() {
297        let app: Router = mount_docs(Router::new(), doc(), MountOpts::default());
298        let response = app
299            .oneshot(
300                axum::http::Request::builder()
301                    .uri("/openapi.json")
302                    .body(axum::body::Body::empty())
303                    .unwrap(),
304            )
305            .await
306            .unwrap();
307        let body = response.into_body().collect().await.unwrap().to_bytes();
308        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
309        // Top-level OpenAPI 3.x fields are present.
310        assert!(parsed["openapi"].as_str().unwrap().starts_with("3."));
311        assert!(parsed["info"].is_object());
312        assert!(parsed["paths"].is_object());
313    }
314
315    #[tokio::test]
316    async fn json_endpoint_serves_byte_identical_content_on_repeat_calls() {
317        // Verifies the Arc<Bytes> is reused and not re-serialized per call.
318        let app: Router = mount_docs(Router::new(), doc(), MountOpts::default());
319        let mut bodies = Vec::new();
320        for _ in 0..3 {
321            let response = app
322                .clone()
323                .oneshot(
324                    axum::http::Request::builder()
325                        .uri("/openapi.json")
326                        .body(axum::body::Body::empty())
327                        .unwrap(),
328                )
329                .await
330                .unwrap();
331            bodies.push(response.into_body().collect().await.unwrap().to_bytes());
332        }
333        assert_eq!(bodies[0], bodies[1]);
334        assert_eq!(bodies[1], bodies[2]);
335    }
336
337    #[tokio::test]
338    async fn without_ui_omits_docs_route() {
339        let app: Router = mount_docs(Router::new(), doc(), MountOpts::default().without_ui());
340        let response = app
341            .oneshot(
342                axum::http::Request::builder()
343                    .uri("/docs")
344                    .body(axum::body::Body::empty())
345                    .unwrap(),
346            )
347            .await
348            .unwrap();
349        assert_eq!(response.status(), StatusCode::NOT_FOUND);
350    }
351
352    #[tokio::test]
353    #[cfg(feature = "docs-scalar")]
354    async fn mounts_scalar_ui_at_default_path() {
355        let app: Router = mount_docs(Router::new(), doc(), MountOpts::default());
356        let response = app
357            .oneshot(
358                axum::http::Request::builder()
359                    .uri("/docs")
360                    .body(axum::body::Body::empty())
361                    .unwrap(),
362            )
363            .await
364            .unwrap();
365        assert_eq!(response.status(), StatusCode::OK);
366        let ct = response.headers().get(header::CONTENT_TYPE).unwrap();
367        assert!(ct.to_str().unwrap().starts_with("text/html"));
368        let body = response.into_body().collect().await.unwrap().to_bytes();
369        let html = std::str::from_utf8(&body).unwrap();
370        assert!(html.contains(r#"data-url="/openapi.json""#));
371        assert!(html.contains("<title>test</title>"));
372        assert!(html.contains("@scalar/api-reference"));
373        // The configuration JSON is HTML-attribute-escaped so its
374        // double-quotes appear as `&quot;` inside the rendered page.
375        assert!(html.contains(r#"&quot;darkMode&quot;:true"#));
376    }
377
378    #[tokio::test]
379    #[cfg(feature = "docs-scalar")]
380    async fn scalar_config_override_propagates_to_html() {
381        use crate::ui::{ScalarConfig, ScalarLayout};
382        let app: Router = mount_docs(
383            Router::new(),
384            doc(),
385            MountOpts::default().scalar(ScalarConfig::default().layout(ScalarLayout::Classic)),
386        );
387        let response = app
388            .oneshot(
389                axum::http::Request::builder()
390                    .uri("/docs")
391                    .body(axum::body::Body::empty())
392                    .unwrap(),
393            )
394            .await
395            .unwrap();
396        let body = response.into_body().collect().await.unwrap().to_bytes();
397        let html = std::str::from_utf8(&body).unwrap();
398        assert!(html.contains(r#"&quot;layout&quot;:&quot;classic&quot;"#));
399    }
400}