1use 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#[derive(Debug, Clone)]
22pub struct MountOpts {
23 pub spec_path: String,
26
27 pub ui_path: String,
31
32 pub mount_ui: bool,
36
37 #[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 pub fn spec_path(mut self, path: impl Into<String>) -> Self {
62 self.spec_path = path.into();
63 self
64 }
65
66 pub fn ui_path(mut self, path: impl Into<String>) -> Self {
68 self.ui_path = path.into();
69 self
70 }
71
72 pub fn without_ui(mut self) -> Self {
75 self.mount_ui = false;
76 self
77 }
78
79 #[cfg(feature = "docs-scalar")]
95 pub fn scalar(mut self, cfg: crate::ui::ScalarConfig) -> Self {
96 self.scalar = cfg;
97 self
98 }
99}
100
101pub 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
130pub trait MountDocsExt<S>
134where
135 S: Clone + Send + Sync + 'static,
136{
137 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 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 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 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 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 assert!(html.contains(r#""darkMode":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#""layout":"classic""#));
399 }
400}