Skip to main content

allframe_core/router/
scalar.rs

1//! Scalar UI integration for interactive OpenAPI documentation.
2//!
3//! This module provides integration with Scalar (<https://scalar.com>), a modern
4//! OpenAPI documentation UI that offers:
5//! - Beautiful dark mode by default
6//! - Interactive "Try It" functionality
7//! - Mobile-friendly responsive design
8//! - <50KB JavaScript bundle
9//!
10//! # Example
11//!
12//! ```rust
13//! use allframe_core::router::{Router, ScalarConfig, ScalarTheme};
14//!
15//! let mut router = Router::new();
16//! router.get("/users", || async { "Users".to_string() });
17//!
18//! // Generate Scalar HTML with default config
19//! let html = router.scalar("My API", "1.0.0");
20//!
21//! // Or with custom configuration
22//! let config = ScalarConfig::new()
23//!     .theme(ScalarTheme::Auto)
24//!     .show_sidebar(true)
25//!     .custom_css("body { font-family: 'Inter'; }");
26//! let html = router.scalar_docs(config, "My API", "1.0.0");
27//! ```
28
29use serde::{Deserialize, Serialize};
30
31/// Scalar UI theme options
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
33#[serde(rename_all = "lowercase")]
34pub enum ScalarTheme {
35    /// Dark mode (default)
36    #[default]
37    Dark,
38    /// Light mode
39    Light,
40    /// Auto-detect from system preferences
41    Auto,
42}
43
44/// Scalar UI layout options
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "lowercase")]
47pub enum ScalarLayout {
48    /// Classic three-column layout
49    Classic,
50    /// Modern two-column layout (default)
51    #[default]
52    Modern,
53}
54
55/// Configuration for Scalar UI
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct ScalarConfig {
58    /// URL to OpenAPI spec (default: "/docs/openapi.json")
59    pub spec_url: String,
60    /// UI theme
61    pub theme: ScalarTheme,
62    /// Show sidebar navigation
63    pub show_sidebar: bool,
64    /// Layout style
65    pub layout: ScalarLayout,
66    /// Custom CSS to inject
67    pub custom_css: Option<String>,
68    /// Hide download button
69    pub hide_download_button: bool,
70    /// Hide models section
71    pub hide_models: bool,
72    /// CDN URL for Scalar JS (default: jsdelivr with latest version)
73    pub cdn_url: String,
74    /// SRI hash for CDN integrity verification (optional but recommended)
75    pub sri_hash: Option<String>,
76    /// Fallback CDN URL if primary fails (optional)
77    pub fallback_cdn_url: Option<String>,
78    /// Proxy URL for "Try It" requests to avoid CORS issues (optional)
79    pub proxy_url: Option<String>,
80}
81
82impl Default for ScalarConfig {
83    fn default() -> Self {
84        Self {
85            spec_url: "/docs/openapi.json".to_string(),
86            theme: ScalarTheme::Dark,
87            show_sidebar: true,
88            layout: ScalarLayout::Modern,
89            custom_css: None,
90            hide_download_button: false,
91            hide_models: false,
92            cdn_url: "https://cdn.jsdelivr.net/npm/@scalar/api-reference".to_string(),
93            sri_hash: None,
94            fallback_cdn_url: None,
95            proxy_url: None,
96        }
97    }
98}
99
100impl ScalarConfig {
101    /// Create a new ScalarConfig with default values
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Set the OpenAPI spec URL
107    pub fn spec_url(mut self, url: impl Into<String>) -> Self {
108        self.spec_url = url.into();
109        self
110    }
111
112    /// Set the UI theme
113    pub fn theme(mut self, theme: ScalarTheme) -> Self {
114        self.theme = theme;
115        self
116    }
117
118    /// Set whether to show sidebar
119    pub fn show_sidebar(mut self, show: bool) -> Self {
120        self.show_sidebar = show;
121        self
122    }
123
124    /// Set the layout style
125    pub fn layout(mut self, layout: ScalarLayout) -> Self {
126        self.layout = layout;
127        self
128    }
129
130    /// Set custom CSS
131    pub fn custom_css(mut self, css: impl Into<String>) -> Self {
132        self.custom_css = Some(css.into());
133        self
134    }
135
136    /// Set whether to hide download button
137    pub fn hide_download_button(mut self, hide: bool) -> Self {
138        self.hide_download_button = hide;
139        self
140    }
141
142    /// Set whether to hide models section
143    pub fn hide_models(mut self, hide: bool) -> Self {
144        self.hide_models = hide;
145        self
146    }
147
148    /// Set custom CDN URL for Scalar JS
149    ///
150    /// # Example
151    ///
152    /// ```rust
153    /// use allframe_core::router::ScalarConfig;
154    ///
155    /// let config = ScalarConfig::new()
156    ///     .cdn_url("https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.25.0");
157    /// ```
158    pub fn cdn_url(mut self, url: impl Into<String>) -> Self {
159        self.cdn_url = url.into();
160        self
161    }
162
163    /// Set SRI hash for CDN integrity verification
164    ///
165    /// # Example
166    ///
167    /// ```rust
168    /// use allframe_core::router::ScalarConfig;
169    ///
170    /// let config = ScalarConfig::new()
171    ///     .sri_hash("sha384-abc123...");
172    /// ```
173    pub fn sri_hash(mut self, hash: impl Into<String>) -> Self {
174        self.sri_hash = Some(hash.into());
175        self
176    }
177
178    /// Set fallback CDN URL if primary fails
179    ///
180    /// # Example
181    ///
182    /// ```rust
183    /// use allframe_core::router::ScalarConfig;
184    ///
185    /// let config = ScalarConfig::new()
186    ///     .fallback_cdn_url("https://unpkg.com/@scalar/api-reference");
187    /// ```
188    pub fn fallback_cdn_url(mut self, url: impl Into<String>) -> Self {
189        self.fallback_cdn_url = Some(url.into());
190        self
191    }
192
193    /// Set proxy URL for "Try It" requests
194    ///
195    /// A proxy is recommended to avoid CORS issues when making requests
196    /// to your API from the documentation interface.
197    ///
198    /// # Example
199    ///
200    /// ```rust
201    /// use allframe_core::router::ScalarConfig;
202    ///
203    /// let config = ScalarConfig::new()
204    ///     .proxy_url("https://proxy.scalar.com");
205    /// ```
206    pub fn proxy_url(mut self, url: impl Into<String>) -> Self {
207        self.proxy_url = Some(url.into());
208        self
209    }
210
211    /// Generate the configuration JSON for Scalar
212    pub fn to_json(&self) -> serde_json::Value {
213        let mut config = serde_json::json!({
214            "theme": self.theme,
215            "layout": self.layout,
216            "showSidebar": self.show_sidebar,
217            "hideDownloadButton": self.hide_download_button,
218            "hideModels": self.hide_models,
219        });
220
221        // Add proxy URL if provided (for "Try It" functionality)
222        if let Some(ref proxy) = self.proxy_url {
223            config["proxy"] = serde_json::Value::String(proxy.clone());
224        }
225
226        config
227    }
228}
229
230/// Generate Scalar HTML page
231///
232/// # Arguments
233///
234/// * `config` - Scalar configuration
235/// * `title` - Page title
236/// * `openapi_spec_json` - OpenAPI specification as JSON string
237///
238/// # Returns
239///
240/// Complete HTML page ready to serve
241pub fn scalar_html(config: &ScalarConfig, title: &str, openapi_spec_json: &str) -> String {
242    let configuration = config.to_json();
243
244    let custom_style = if let Some(css) = &config.custom_css {
245        format!("<style>{}</style>", css)
246    } else {
247        String::new()
248    };
249
250    // Build script tag with SRI if provided
251    let script_attrs = if let Some(sri) = &config.sri_hash {
252        format!(
253            r#"src="{}" integrity="{}" crossorigin="anonymous""#,
254            config.cdn_url, sri
255        )
256    } else {
257        format!(r#"src="{}""#, config.cdn_url)
258    };
259
260    // Build fallback script if provided
261    let fallback_script = if let Some(fallback_url) = &config.fallback_cdn_url {
262        format!(
263            r#"
264    <script>
265        // Fallback CDN loader
266        window.addEventListener('error', function(e) {{
267            if (e.target.tagName === 'SCRIPT' && e.target.src.includes('scalar')) {{
268                console.warn('Primary CDN failed, loading from fallback...');
269                var fallback = document.createElement('script');
270                fallback.src = '{}';
271                document.body.appendChild(fallback);
272            }}
273        }}, true);
274    </script>"#,
275            fallback_url
276        )
277    } else {
278        String::new()
279    };
280
281    format!(
282        r#"<!DOCTYPE html>
283<html>
284<head>
285    <title>{title} - API Documentation</title>
286    <meta charset="utf-8" />
287    <meta name="viewport" content="width=device-width, initial-scale=1" />
288    <style>
289        body {{ margin: 0; padding: 0; }}
290    </style>
291    {custom_style}{fallback_script}
292</head>
293<body>
294    <script
295        id="api-reference"
296        data-configuration='{configuration}'
297    >{openapi_spec}</script>
298    <script {script_attrs}></script>
299</body>
300</html>"#,
301        title = title,
302        custom_style = custom_style,
303        fallback_script = fallback_script,
304        configuration = configuration,
305        openapi_spec = openapi_spec_json,
306        script_attrs = script_attrs,
307    )
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_scalar_theme_default() {
316        assert_eq!(ScalarTheme::default(), ScalarTheme::Dark);
317    }
318
319    #[test]
320    fn test_scalar_layout_default() {
321        assert_eq!(ScalarLayout::default(), ScalarLayout::Modern);
322    }
323
324    #[test]
325    fn test_scalar_config_default() {
326        let config = ScalarConfig::default();
327        assert_eq!(config.spec_url, "/docs/openapi.json");
328        assert_eq!(config.theme, ScalarTheme::Dark);
329        assert_eq!(config.show_sidebar, true);
330        assert_eq!(config.layout, ScalarLayout::Modern);
331        assert_eq!(config.custom_css, None);
332        assert_eq!(config.hide_download_button, false);
333        assert_eq!(config.hide_models, false);
334        assert_eq!(
335            config.cdn_url,
336            "https://cdn.jsdelivr.net/npm/@scalar/api-reference"
337        );
338        assert_eq!(config.sri_hash, None);
339        assert_eq!(config.fallback_cdn_url, None);
340        assert_eq!(config.proxy_url, None);
341    }
342
343    #[test]
344    fn test_scalar_config_builder() {
345        let config = ScalarConfig::new()
346            .spec_url("/api/openapi.json")
347            .theme(ScalarTheme::Light)
348            .show_sidebar(false)
349            .layout(ScalarLayout::Classic)
350            .custom_css("body { color: red; }")
351            .hide_download_button(true)
352            .hide_models(true);
353
354        assert_eq!(config.spec_url, "/api/openapi.json");
355        assert_eq!(config.theme, ScalarTheme::Light);
356        assert_eq!(config.show_sidebar, false);
357        assert_eq!(config.layout, ScalarLayout::Classic);
358        assert_eq!(config.custom_css, Some("body { color: red; }".to_string()));
359        assert_eq!(config.hide_download_button, true);
360        assert_eq!(config.hide_models, true);
361    }
362
363    #[test]
364    fn test_scalar_config_to_json() {
365        let config = ScalarConfig::new()
366            .theme(ScalarTheme::Auto)
367            .layout(ScalarLayout::Classic)
368            .show_sidebar(false);
369
370        let json = config.to_json();
371        assert_eq!(json["theme"], "auto");
372        assert_eq!(json["layout"], "classic");
373        assert_eq!(json["showSidebar"], false);
374    }
375
376    #[test]
377    fn test_scalar_html_contains_title() {
378        let config = ScalarConfig::default();
379        let spec = r#"{"openapi":"3.1.0"}"#;
380        let html = scalar_html(&config, "Test API", spec);
381
382        assert!(html.contains("<title>Test API - API Documentation</title>"));
383    }
384
385    #[test]
386    fn test_scalar_html_contains_script_tag() {
387        let config = ScalarConfig::default();
388        let spec = r#"{"openapi":"3.1.0"}"#;
389        let html = scalar_html(&config, "Test API", spec);
390
391        assert!(html.contains(r#"id="api-reference""#));
392        assert!(html.contains(r#"https://cdn.jsdelivr.net/npm/@scalar/api-reference"#));
393    }
394
395    #[test]
396    fn test_scalar_html_contains_configuration() {
397        let config = ScalarConfig::new()
398            .theme(ScalarTheme::Light)
399            .show_sidebar(false);
400        let spec = r#"{"openapi":"3.1.0"}"#;
401        let html = scalar_html(&config, "Test API", spec);
402
403        assert!(html.contains(r#"data-configuration='"#));
404        assert!(html.contains(r#""theme":"light""#));
405        assert!(html.contains(r#""showSidebar":false"#));
406    }
407
408    #[test]
409    fn test_scalar_html_contains_openapi_spec() {
410        let config = ScalarConfig::default();
411        let spec = r#"{"openapi":"3.1.0","info":{"title":"Test"}}"#;
412        let html = scalar_html(&config, "Test API", spec);
413
414        assert!(html.contains(spec));
415    }
416
417    #[test]
418    fn test_scalar_html_with_custom_css() {
419        let config = ScalarConfig::new().custom_css("body { font-family: 'Inter'; }");
420        let spec = r#"{"openapi":"3.1.0"}"#;
421        let html = scalar_html(&config, "Test API", spec);
422
423        assert!(html.contains("<style>body { font-family: 'Inter'; }</style>"));
424    }
425
426    #[test]
427    fn test_scalar_html_without_custom_css() {
428        let config = ScalarConfig::default();
429        let spec = r#"{"openapi":"3.1.0"}"#;
430        let html = scalar_html(&config, "Test API", spec);
431
432        // Should not contain empty style tag
433        assert!(!html.contains("<style></style>"));
434    }
435
436    #[test]
437    fn test_scalar_config_with_cdn_url() {
438        let config = ScalarConfig::new()
439            .cdn_url("https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.25.0");
440
441        assert_eq!(
442            config.cdn_url,
443            "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.25.0"
444        );
445    }
446
447    #[test]
448    fn test_scalar_config_with_sri_hash() {
449        let config = ScalarConfig::new().sri_hash("sha384-abc123def456");
450
451        assert_eq!(config.sri_hash, Some("sha384-abc123def456".to_string()));
452    }
453
454    #[test]
455    fn test_scalar_config_with_fallback_cdn() {
456        let config =
457            ScalarConfig::new().fallback_cdn_url("https://unpkg.com/@scalar/api-reference");
458
459        assert_eq!(
460            config.fallback_cdn_url,
461            Some("https://unpkg.com/@scalar/api-reference".to_string())
462        );
463    }
464
465    #[test]
466    fn test_scalar_html_with_sri() {
467        let config = ScalarConfig::new()
468            .cdn_url("https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.25.0")
469            .sri_hash("sha384-abc123");
470        let spec = r#"{"openapi":"3.1.0"}"#;
471        let html = scalar_html(&config, "Test API", spec);
472
473        assert!(html.contains("integrity=\"sha384-abc123\""));
474        assert!(html.contains("crossorigin=\"anonymous\""));
475    }
476
477    #[test]
478    fn test_scalar_html_with_fallback() {
479        let config =
480            ScalarConfig::new().fallback_cdn_url("https://unpkg.com/@scalar/api-reference");
481        let spec = r#"{"openapi":"3.1.0"}"#;
482        let html = scalar_html(&config, "Test API", spec);
483
484        assert!(html.contains("Fallback CDN loader"));
485        assert!(html.contains("https://unpkg.com/@scalar/api-reference"));
486        assert!(html.contains("window.addEventListener('error'"));
487    }
488
489    #[test]
490    fn test_scalar_html_without_fallback() {
491        let config = ScalarConfig::default();
492        let spec = r#"{"openapi":"3.1.0"}"#;
493        let html = scalar_html(&config, "Test API", spec);
494
495        assert!(!html.contains("Fallback CDN loader"));
496        assert!(!html.contains("window.addEventListener('error'"));
497    }
498
499    #[test]
500    fn test_scalar_config_with_proxy() {
501        let config = ScalarConfig::new().proxy_url("https://proxy.scalar.com");
502
503        assert_eq!(
504            config.proxy_url,
505            Some("https://proxy.scalar.com".to_string())
506        );
507    }
508
509    #[test]
510    fn test_scalar_config_to_json_with_proxy() {
511        let config = ScalarConfig::new()
512            .proxy_url("https://proxy.scalar.com")
513            .show_sidebar(false);
514
515        let json = config.to_json();
516        assert_eq!(json["proxy"], "https://proxy.scalar.com");
517        assert_eq!(json["showSidebar"], false);
518    }
519
520    #[test]
521    fn test_scalar_config_to_json_without_proxy() {
522        let config = ScalarConfig::default();
523        let json = config.to_json();
524
525        assert!(json.get("proxy").is_none());
526    }
527}