scalar_api_reference/
lib.rs

1use rust_embed::RustEmbed;
2use serde_json::Value;
3
4/// Embedded UI assets
5#[derive(RustEmbed)]
6#[folder = "ui/"]
7struct Assets;
8
9/// Get a static asset by path
10pub fn get_asset(path: &str) -> Option<Vec<u8>> {
11    Assets::get(path).map(|d| d.data.into())
12}
13
14/// Get a static asset with MIME type information
15pub fn get_asset_with_mime(path: &str) -> Option<(String, Vec<u8>)> {
16    let asset = Assets::get(path)?;
17    let mime_type = get_mime_type(path);
18    Some((mime_type, asset.data.into()))
19}
20
21/// Determine MIME type based on file extension
22pub fn get_mime_type(path: &str) -> String {
23    match path.split('.').next_back() {
24        Some("html") => "text/html".to_string(),
25        Some("js") => "application/javascript".to_string(),
26        Some("css") => "text/css".to_string(),
27        Some("json") => "application/json".to_string(),
28        Some("png") => "image/png".to_string(),
29        Some("svg") => "image/svg+xml".to_string(),
30        Some("ico") => "image/x-icon".to_string(),
31        _ => "application/octet-stream".to_string(),
32    }
33}
34
35/// Render Scalar HTML with embedded configuration and optional JS bundle URL
36pub fn render_scalar(config_json: &str, js_bundle_url: Option<&str>) -> String {
37    let html_template = include_str!("../ui/index.html");
38    let js_url = js_bundle_url.unwrap_or("https://cdn.jsdelivr.net/npm/@scalar/api-reference");
39    html_template
40        .replace("__CONFIGURATION__", config_json)
41        .replace("__JS_BUNDLE_URL__", js_url)
42}
43
44/// Return the Scalar HTML with embedded config and optional JS bundle URL
45pub fn scalar_html(config: &Value, js_bundle_url: Option<&str>) -> String {
46    render_scalar(&config.to_string(), js_bundle_url)
47}
48
49/// Return the Scalar HTML with embedded config using CDN JS bundle URL
50pub fn scalar_html_default(config: &Value) -> String {
51    scalar_html(config, None)
52}
53
54/// Return the Scalar HTML with embedded config from a JSON string and optional JS bundle URL
55pub fn scalar_html_from_json(
56    config_json: &str,
57    js_bundle_url: Option<&str>,
58) -> Result<String, serde_json::Error> {
59    let config: Value = serde_json::from_str(config_json)?;
60    Ok(scalar_html(&config, js_bundle_url))
61}
62
63/// Return the Scalar HTML with embedded config from a JSON string using CDN JS bundle URL
64pub fn scalar_html_from_json_default(config_json: &str) -> Result<String, serde_json::Error> {
65    scalar_html_from_json(config_json, None)
66}
67
68#[cfg(feature = "axum")]
69pub mod axum {
70    use super::{get_asset_with_mime, scalar_html};
71    use axum::{
72        body::Body, http::StatusCode, response::Html, response::Response, routing::get, Router,
73    };
74    use serde_json::Value;
75
76    /// Create an Axum HTML response with Scalar documentation
77    pub fn scalar_response(config: &Value, js_bundle_url: Option<&str>) -> Html<String> {
78        Html(scalar_html(config, js_bundle_url))
79    }
80
81    /// Create an Axum HTML response from JSON string
82    pub fn scalar_response_from_json(
83        config_json: &str,
84        js_bundle_url: Option<&str>,
85    ) -> Result<Html<String>, serde_json::Error> {
86        let config: Value = serde_json::from_str(config_json)?;
87        Ok(scalar_response(&config, js_bundle_url))
88    }
89
90    /// Create a complete Axum router with both Scalar documentation and asset serving
91    pub fn router(path: &str, config: &Value) -> Router {
92        let js_path = format!("{}/scalar.js", path);
93        let config_clone = config.clone();
94        let js_path_clone = js_path.clone();
95
96        Router::new()
97            .route(
98                path,
99                get(move || {
100                    let config = config_clone.clone();
101                    let js_path = js_path_clone.clone();
102                    async move { scalar_response(&config, Some(&js_path)) }
103                }),
104            )
105            .route(
106                &js_path,
107                get(|| async {
108                    match get_asset_with_mime("scalar.js") {
109                        Some((mime_type, content)) => Response::builder()
110                            .status(StatusCode::OK)
111                            .header("content-type", mime_type)
112                            .body(Body::from(content))
113                            .unwrap(),
114                        None => Response::builder()
115                            .status(StatusCode::NOT_FOUND)
116                            .body(Body::from("Not found"))
117                            .unwrap(),
118                    }
119                }),
120            )
121    }
122
123    /// Create separate routes for Scalar documentation and asset serving
124    pub fn routes(path: &str, config: &Value) -> (Router, Router) {
125        let js_path = format!("{}/scalar.js", path);
126        let config_clone = config.clone();
127        let js_path_clone = js_path.clone();
128
129        let scalar_route = Router::new().route(
130            path,
131            get(move || {
132                let config = config_clone.clone();
133                let js_path = js_path_clone.clone();
134                async move { scalar_response(&config, Some(&js_path)) }
135            }),
136        );
137
138        let asset_route = Router::new().route(
139            &js_path,
140            get(|| async {
141                match get_asset_with_mime("scalar.js") {
142                    Some((mime_type, content)) => Response::builder()
143                        .status(StatusCode::OK)
144                        .header("content-type", mime_type)
145                        .body(Body::from(content))
146                        .unwrap(),
147                    None => Response::builder()
148                        .status(StatusCode::NOT_FOUND)
149                        .body(Body::from("Not found"))
150                        .unwrap(),
151                }
152            }),
153        );
154
155        (scalar_route, asset_route)
156    }
157}
158
159#[cfg(feature = "actix-web")]
160pub mod actix_web {
161    use super::{get_asset_with_mime, scalar_html};
162    use actix_web::web::ServiceConfig;
163    use actix_web::{http::header::ContentType, web, HttpResponse, Result};
164    use serde_json::Value;
165
166    /// Create an Actix-web HttpResponse with Scalar documentation
167    pub fn scalar_response(config: &Value, js_bundle_url: Option<&str>) -> HttpResponse {
168        HttpResponse::Ok()
169            .content_type(ContentType::html())
170            .body(scalar_html(config, js_bundle_url))
171    }
172
173    /// Create an Actix-web HttpResponse from JSON string
174    pub fn scalar_response_from_json(
175        config_json: &str,
176        js_bundle_url: Option<&str>,
177    ) -> Result<HttpResponse, serde_json::Error> {
178        let config: Value = serde_json::from_str(config_json)?;
179        Ok(scalar_response(&config, js_bundle_url))
180    }
181
182    /// Create a ServiceConfig that adds both Scalar documentation and asset serving routes
183    pub fn config(path: &str, config: &Value) -> impl Fn(&mut ServiceConfig) {
184        let scalar_path = path.to_string();
185        let js_path = format!("{}/scalar.js", path);
186        let config_clone = config.clone();
187        let js_path_clone = js_path.clone();
188
189        move |cfg: &mut ServiceConfig| {
190            let scalar_path = scalar_path.clone();
191            let js_path = js_path_clone.clone();
192            let config = config_clone.clone();
193            let js_path_for_asset = js_path.clone();
194
195            cfg.route(
196                &scalar_path,
197                web::get().to(move || {
198                    let config = config.clone();
199                    let js_path = js_path.clone();
200                    async move { scalar_response(&config, Some(&js_path)) }
201                }),
202            )
203            .route(
204                &js_path_for_asset,
205                web::get().to(|| async {
206                    match get_asset_with_mime("scalar.js") {
207                        Some((mime_type, content)) => {
208                            HttpResponse::Ok().content_type(mime_type).body(content)
209                        }
210                        None => HttpResponse::NotFound().body("Not found"),
211                    }
212                }),
213            );
214        }
215    }
216}
217
218#[cfg(feature = "warp")]
219pub mod warp {
220    use super::{get_asset_with_mime, scalar_html};
221    use serde_json::Value;
222    use warp::{reply::html, Filter, Reply};
223
224    /// Create a Warp HTML reply with Scalar documentation
225    pub fn scalar_reply(config: &Value, js_bundle_url: Option<&str>) -> impl warp::Reply {
226        html(scalar_html(config, js_bundle_url))
227    }
228
229    /// Create a Warp HTML reply from JSON string
230    pub fn scalar_reply_from_json(
231        config_json: &str,
232        js_bundle_url: Option<&str>,
233    ) -> Result<impl warp::Reply, serde_json::Error> {
234        let config: Value = serde_json::from_str(config_json)?;
235        Ok(scalar_reply(&config, js_bundle_url))
236    }
237
238    /// Create a complete Warp filter with both Scalar documentation and asset serving
239    /// Note: For Warp, the path should not include leading slashes (e.g., use "scalar" instead of "/scalar")
240    pub fn routes(
241        path: &'static str,
242        config: &Value,
243    ) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
244        let config_clone = config.clone();
245
246        // For Warp, we need to handle paths without leading slashes
247        let clean_path = path.trim_start_matches('/');
248
249        // Create asset route first (more specific) to avoid conflicts
250        let asset_route = create_asset_route(clean_path);
251
252        // Create scalar route that only matches exact path (not subpaths)
253        let scalar_route = warp::path(clean_path).and(warp::path::end()).map(move || {
254            let config = config_clone.clone();
255            let js_path = format!("{}/scalar.js", clean_path);
256            scalar_reply(&config, Some(&js_path))
257        });
258
259        // Asset route should be checked first since it's more specific
260        asset_route.or(scalar_route)
261    }
262
263    // Helper function to create asset route scoped to the specified path
264    fn create_asset_route(
265        path: &'static str,
266    ) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
267        // Use the specific path instead of dynamic path parameter
268        warp::path(path)
269            .and(warp::path("scalar.js"))
270            .and_then(move || async move {
271                match get_asset_with_mime("scalar.js") {
272                    Some((mime_type, content)) => {
273                        Ok::<_, warp::Rejection>(warp::reply::with_header(
274                            warp::reply::with_status(content, warp::http::StatusCode::OK),
275                            "content-type",
276                            mime_type,
277                        ))
278                    }
279                    None => Ok::<_, warp::Rejection>(warp::reply::with_header(
280                        warp::reply::with_status(
281                            Vec::<u8>::new(),
282                            warp::http::StatusCode::NOT_FOUND,
283                        ),
284                        "content-type",
285                        "text/plain",
286                    )),
287                }
288            })
289    }
290
291    /// Create separate filters for Scalar documentation and asset serving
292    /// Note: For Warp, the path should not include leading slashes (e.g., use "scalar" instead of "/scalar")
293    pub fn separate_routes(
294        path: &'static str,
295        config: &Value,
296    ) -> (
297        impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone,
298        impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone,
299    ) {
300        let config_clone = config.clone();
301
302        // For Warp, we need to handle paths without leading slashes
303        let clean_path = path.trim_start_matches('/');
304
305        // Create asset route scoped to the configured base path
306        let asset_route = warp::path(clean_path)
307            .and(warp::path("scalar.js"))
308            .and_then(move || async move {
309                match get_asset_with_mime("scalar.js") {
310                    Some((mime_type, content)) => {
311                        Ok::<_, warp::Rejection>(warp::reply::with_header(
312                            warp::reply::with_status(content, warp::http::StatusCode::OK),
313                            "content-type",
314                            mime_type,
315                        ))
316                    }
317                    None => Ok::<_, warp::Rejection>(warp::reply::with_header(
318                        warp::reply::with_status(
319                            Vec::<u8>::new(),
320                            warp::http::StatusCode::NOT_FOUND,
321                        ),
322                        "content-type",
323                        "text/plain",
324                    )),
325                }
326            });
327
328        // Create scalar route that only matches exact path (not subpaths)
329        let scalar_route = warp::path(clean_path).and(warp::path::end()).map(move || {
330            let config = config_clone.clone();
331            let js_path = format!("{}/scalar.js", clean_path);
332            scalar_reply(&config, Some(&js_path))
333        });
334
335        (scalar_route, asset_route)
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use crate::{
342        get_asset, get_asset_with_mime, get_mime_type, scalar_html, scalar_html_default,
343        scalar_html_from_json, scalar_html_from_json_default,
344    };
345    use serde_json::json;
346
347    #[test]
348    fn test_scalar_html_generation() {
349        let config = json!({
350            "url": "/openapi.json",
351            "theme": "purple"
352        });
353
354        // Test with custom JS bundle URL
355        let html1 = scalar_html(&config, Some("/custom-scalar.js"));
356        assert!(html1.contains("/openapi.json"));
357        assert!(html1.contains("purple"));
358        assert!(html1.contains("/custom-scalar.js"));
359        assert!(html1.contains("<html"));
360        assert!(html1.contains("</html>"));
361
362        // Test with default CDN URL
363        let html2 = scalar_html(&config, None);
364        assert!(html2.contains("/openapi.json"));
365        assert!(html2.contains("purple"));
366        assert!(html2.contains("https://cdn.jsdelivr.net/npm/@scalar/api-reference"));
367        assert!(html2.contains("<html"));
368        assert!(html2.contains("</html>"));
369    }
370
371    #[test]
372    fn test_scalar_html_from_json() {
373        let config_json = r#"{"url": "/api.json", "theme": "purple"}"#;
374
375        // Test with custom JS bundle URL
376        let html1 = scalar_html_from_json(config_json, Some("/bundle.js")).unwrap();
377        assert!(html1.contains("/api.json"));
378        assert!(html1.contains("purple"));
379        assert!(html1.contains("/bundle.js"));
380
381        // Test with default CDN URL
382        let html2 = scalar_html_from_json(config_json, None).unwrap();
383        assert!(html2.contains("/api.json"));
384        assert!(html2.contains("purple"));
385        assert!(html2.contains("https://cdn.jsdelivr.net/npm/@scalar/api-reference"));
386    }
387
388    #[test]
389    fn test_convenience_functions() {
390        let config = json!({
391            "url": "/test.json",
392            "theme": "kepler"
393        });
394
395        // Test scalar_html_default
396        let html1 = scalar_html_default(&config);
397        assert!(html1.contains("/test.json"));
398        assert!(html1.contains("kepler"));
399        assert!(html1.contains("https://cdn.jsdelivr.net/npm/@scalar/api-reference"));
400
401        // Test scalar_html_from_json_default
402        let config_json = r#"{"url": "/test2.json", "theme": "purple"}"#;
403        let html2 = scalar_html_from_json_default(config_json).unwrap();
404        assert!(html2.contains("/test2.json"));
405        assert!(html2.contains("purple"));
406        assert!(html2.contains("https://cdn.jsdelivr.net/npm/@scalar/api-reference"));
407    }
408
409    #[test]
410    fn test_get_asset() {
411        // Test that we can get the HTML template
412        let html_asset = get_asset("index.html");
413        assert!(html_asset.is_some());
414
415        // Test that we can get the JS file
416        let js_asset = get_asset("scalar.js");
417        assert!(js_asset.is_some());
418
419        // Test non-existent asset
420        let non_existent = get_asset("non-existent.txt");
421        assert!(non_existent.is_none());
422    }
423
424    #[test]
425    fn test_get_asset_with_mime() {
426        let (mime_type, content) = get_asset_with_mime("index.html").unwrap();
427        assert_eq!(mime_type, "text/html");
428        assert!(!content.is_empty());
429
430        let (mime_type, content) = get_asset_with_mime("scalar.js").unwrap();
431        assert_eq!(mime_type, "application/javascript");
432        assert!(!content.is_empty());
433    }
434
435    #[test]
436    fn test_mime_type_detection() {
437        assert_eq!(get_mime_type("index.html"), "text/html");
438        assert_eq!(get_mime_type("style.css"), "text/css");
439        assert_eq!(get_mime_type("script.js"), "application/javascript");
440        assert_eq!(get_mime_type("data.json"), "application/json");
441        assert_eq!(get_mime_type("image.png"), "image/png");
442        assert_eq!(get_mime_type("icon.svg"), "image/svg+xml");
443        assert_eq!(get_mime_type("favicon.ico"), "image/x-icon");
444        assert_eq!(get_mime_type("unknown.xyz"), "application/octet-stream");
445    }
446
447    #[test]
448    fn test_error_handling() {
449        // Test invalid JSON
450        let invalid_json = r#"{"url": "/api.json", "theme": "purple""#; // Missing closing brace
451        let result = scalar_html_from_json(invalid_json, None);
452        assert!(result.is_err());
453
454        // Test empty JSON
455        let empty_json = r#"{}"#;
456        let result = scalar_html_from_json(empty_json, None);
457        assert!(result.is_ok());
458        let html = result.unwrap();
459        assert!(html.contains("https://cdn.jsdelivr.net/npm/@scalar/api-reference"));
460    }
461
462    #[test]
463    fn test_edge_cases() {
464        // Test empty config
465        let empty_config = json!({});
466        let html = scalar_html(&empty_config, None);
467        assert!(html.contains("https://cdn.jsdelivr.net/npm/@scalar/api-reference"));
468
469        // Test config with special characters
470        let special_config = json!({
471            "url": "/api/v1/test?param=value&other=test",
472            "theme": "purple",
473            "description": "API with special chars: <>&\"'"
474        });
475        let html = scalar_html(&special_config, None);
476        assert!(html.contains("/api/v1/test?param=value&other=test"));
477        assert!(html.contains("purple"));
478
479        // Test paths with special characters
480        let config_with_special_path = json!({
481            "url": "/api/test",
482            "theme": "purple"
483        });
484        let html = scalar_html(&config_with_special_path, Some("/custom/path/scalar.js"));
485        assert!(html.contains("/custom/path/scalar.js"));
486    }
487}
488
489#[cfg(all(test, feature = "axum"))]
490mod axum_tests {
491    use crate::axum::{router, routes, scalar_response, scalar_response_from_json};
492    use serde_json::json;
493
494    #[test]
495    fn test_scalar_response() {
496        let config = json!({
497            "url": "/openapi.json",
498            "theme": "purple"
499        });
500
501        // Test with custom JS bundle URL
502        let response = scalar_response(&config, Some("/custom-scalar.js"));
503        let html = response.0;
504        assert!(html.contains("/openapi.json"));
505        assert!(html.contains("purple"));
506        assert!(html.contains("/custom-scalar.js"));
507
508        // Test with default CDN URL
509        let response = scalar_response(&config, None);
510        let html = response.0;
511        assert!(html.contains("/openapi.json"));
512        assert!(html.contains("purple"));
513        assert!(html.contains("https://cdn.jsdelivr.net/npm/@scalar/api-reference"));
514    }
515
516    #[test]
517    fn test_scalar_response_from_json() {
518        let config_json = r#"{"url": "/api.json", "theme": "purple"}"#;
519
520        // Test with custom JS bundle URL
521        let response = scalar_response_from_json(config_json, Some("/bundle.js")).unwrap();
522        let html = response.0;
523        assert!(html.contains("/api.json"));
524        assert!(html.contains("purple"));
525        assert!(html.contains("/bundle.js"));
526
527        // Test with default CDN URL
528        let response = scalar_response_from_json(config_json, None).unwrap();
529        let html = response.0;
530        assert!(html.contains("/api.json"));
531        assert!(html.contains("purple"));
532        assert!(html.contains("https://cdn.jsdelivr.net/npm/@scalar/api-reference"));
533
534        // Test invalid JSON
535        let invalid_json = r#"{"url": "/api.json", "theme": "purple""#;
536        let result = scalar_response_from_json(invalid_json, None);
537        assert!(result.is_err());
538    }
539
540    #[test]
541    fn test_router_creation() {
542        let config = json!({
543            "url": "/openapi.json",
544            "theme": "purple"
545        });
546
547        // Test that router can be created without errors
548        let _app = router("/scalar", &config);
549        // Router creation is successful if we get here
550    }
551
552    #[test]
553    fn test_routes_creation() {
554        let config = json!({
555            "url": "/openapi.json",
556            "theme": "purple"
557        });
558
559        // Test that routes can be created without errors
560        let (_scalar_route, _asset_route) = routes("/scalar", &config);
561        // Routes creation is successful if we get here
562        // Note: We can't easily test the actual HTTP requests without more complex setup
563    }
564}
565
566#[cfg(all(test, feature = "actix-web"))]
567mod actix_tests {
568    use crate::actix_web::{config, scalar_response, scalar_response_from_json};
569    use serde_json::json;
570
571    #[test]
572    fn test_scalar_response() {
573        let config = json!({
574            "url": "/openapi.json",
575            "theme": "purple"
576        });
577
578        // Test with custom JS bundle URL
579        let _response = scalar_response(&config, Some("/custom-scalar.js"));
580        // Test that response can be created without errors
581
582        // Test with default CDN URL
583        let _response = scalar_response(&config, None);
584        // Test that response can be created without errors
585    }
586
587    #[test]
588    fn test_scalar_response_from_json() {
589        let config_json = r#"{"url": "/api.json", "theme": "purple"}"#;
590
591        // Test with custom JS bundle URL
592        let _response = scalar_response_from_json(config_json, Some("/bundle.js")).unwrap();
593        // Test that response can be created without errors
594
595        // Test with default CDN URL
596        let _response = scalar_response_from_json(config_json, None).unwrap();
597        // Test that response can be created without errors
598
599        // Test invalid JSON
600        let invalid_json = r#"{"url": "/api.json", "theme": "purple""#;
601        let result = scalar_response_from_json(invalid_json, None);
602        assert!(result.is_err());
603    }
604
605    #[test]
606    fn test_config_creation() {
607        let config_json = json!({
608            "url": "/openapi.json",
609            "theme": "purple"
610        });
611
612        // Test that config function can be created without errors
613        let _config_fn = config("/scalar", &config_json);
614        // Config creation is successful if we get here
615    }
616}
617
618#[cfg(all(test, feature = "warp"))]
619mod warp_tests {
620    use crate::warp::{routes, scalar_reply, scalar_reply_from_json, separate_routes};
621    use serde_json::json;
622
623    #[test]
624    fn test_scalar_reply() {
625        let config = json!({
626            "url": "/openapi.json",
627            "theme": "purple"
628        });
629
630        // Test with custom JS bundle URL
631        let _reply = scalar_reply(&config, Some("/custom-scalar.js"));
632        // Test that reply can be created without errors
633        // Note: We can't easily test the actual response content without more complex setup
634
635        // Test with default CDN URL
636        let _reply = scalar_reply(&config, None);
637        // Test that reply can be created without errors
638    }
639
640    #[test]
641    fn test_scalar_reply_from_json() {
642        let config_json = r#"{"url": "/api.json", "theme": "purple"}"#;
643
644        // Test with custom JS bundle URL
645        let _reply = scalar_reply_from_json(config_json, Some("/bundle.js")).unwrap();
646        // Test that reply can be created without errors
647
648        // Test with default CDN URL
649        let _reply = scalar_reply_from_json(config_json, None).unwrap();
650        // Test that reply can be created without errors
651
652        // Test invalid JSON
653        let invalid_json = r#"{"url": "/api.json", "theme": "purple""#;
654        let result = scalar_reply_from_json(invalid_json, None);
655        assert!(result.is_err());
656    }
657
658    #[test]
659    fn test_routes_creation() {
660        let config = json!({
661            "url": "/openapi.json",
662            "theme": "purple"
663        });
664
665        // Test that routes can be created without errors
666        let _filter = routes("scalar", &config);
667        // Routes creation is successful if we get here
668    }
669
670    #[test]
671    fn test_separate_routes_creation() {
672        let config = json!({
673            "url": "/openapi.json",
674            "theme": "purple"
675        });
676
677        // Test that separate routes can be created without errors
678        let (_scalar_filter, _asset_filter) = separate_routes("scalar", &config);
679        // Routes creation is successful if we get here
680    }
681}