1use serde::{Deserialize, Serialize};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
33#[serde(rename_all = "lowercase")]
34pub enum ScalarTheme {
35 #[default]
37 Dark,
38 Light,
40 Auto,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "lowercase")]
47pub enum ScalarLayout {
48 Classic,
50 #[default]
52 Modern,
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct ScalarConfig {
58 pub spec_url: String,
60 pub theme: ScalarTheme,
62 pub show_sidebar: bool,
64 pub layout: ScalarLayout,
66 pub custom_css: Option<String>,
68 pub hide_download_button: bool,
70 pub hide_models: bool,
72 pub cdn_url: String,
74 pub sri_hash: Option<String>,
76 pub fallback_cdn_url: Option<String>,
78 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 pub fn new() -> Self {
103 Self::default()
104 }
105
106 pub fn spec_url(mut self, url: impl Into<String>) -> Self {
108 self.spec_url = url.into();
109 self
110 }
111
112 pub fn theme(mut self, theme: ScalarTheme) -> Self {
114 self.theme = theme;
115 self
116 }
117
118 pub fn show_sidebar(mut self, show: bool) -> Self {
120 self.show_sidebar = show;
121 self
122 }
123
124 pub fn layout(mut self, layout: ScalarLayout) -> Self {
126 self.layout = layout;
127 self
128 }
129
130 pub fn custom_css(mut self, css: impl Into<String>) -> Self {
132 self.custom_css = Some(css.into());
133 self
134 }
135
136 pub fn hide_download_button(mut self, hide: bool) -> Self {
138 self.hide_download_button = hide;
139 self
140 }
141
142 pub fn hide_models(mut self, hide: bool) -> Self {
144 self.hide_models = hide;
145 self
146 }
147
148 pub fn cdn_url(mut self, url: impl Into<String>) -> Self {
159 self.cdn_url = url.into();
160 self
161 }
162
163 pub fn sri_hash(mut self, hash: impl Into<String>) -> Self {
174 self.sri_hash = Some(hash.into());
175 self
176 }
177
178 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 pub fn proxy_url(mut self, url: impl Into<String>) -> Self {
207 self.proxy_url = Some(url.into());
208 self
209 }
210
211 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 if let Some(ref proxy) = self.proxy_url {
223 config["proxy"] = serde_json::Value::String(proxy.clone());
224 }
225
226 config
227 }
228}
229
230pub 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 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 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 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}