Skip to main content

connect2axum_scalar/
lib.rs

1//! Small axum router for serving an embedded Scalar API reference.
2
3use axum::Router;
4use axum::body::Body;
5use axum::extract::State;
6use axum::response::{Html, IntoResponse};
7use axum::routing::get;
8use bytes::Bytes;
9use flexstr::SharedStr;
10use http::header::{CACHE_CONTROL, CONTENT_TYPE};
11
12#[cfg(docsrs)]
13const SCALAR_JS: &str = "";
14#[cfg(not(docsrs))]
15const SCALAR_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/scalar-api-reference.js"));
16
17/// The Scalar API Reference version embedded by this crate.
18pub const SCALAR_VERSION: &str = env!("CONNECT2AXUM_SCALAR_VERSION");
19
20/// Route and page options for the embedded Scalar API reference.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct ScalarOptions {
23    /// Route path for the Scalar HTML page.
24    pub docs_path: SharedStr,
25    /// Route path where the OpenAPI JSON document is served.
26    pub spec_path: SharedStr,
27    /// Route path where the embedded Scalar JavaScript bundle is served.
28    pub js_path: SharedStr,
29    /// Browser page title for the Scalar HTML page.
30    pub title: SharedStr,
31}
32
33impl Default for ScalarOptions {
34    fn default() -> Self {
35        Self {
36            docs_path: "/scalar".into(),
37            spec_path: "/openapi.json".into(),
38            js_path: "/scalar/scalar.js".into(),
39            title: "API Reference".into(),
40        }
41    }
42}
43
44/// Builds a router with `/scalar`, `/openapi.json`, and the embedded Scalar JS.
45pub fn router(spec_json: &'static str) -> Router {
46    router_with_options(
47        Bytes::from_static(spec_json.as_bytes()),
48        ScalarOptions::default(),
49    )
50}
51
52/// Builds a router with custom paths and page title.
53pub fn router_with_options(spec_json: impl Into<Bytes>, options: ScalarOptions) -> Router {
54    let ScalarOptions {
55        docs_path,
56        spec_path,
57        js_path,
58        title,
59    } = options;
60    let state = ScalarState {
61        spec_json: spec_json.into(),
62        spec_path,
63        js_path,
64        title,
65    };
66
67    Router::new()
68        .route(docs_path.as_ref(), get(scalar_docs))
69        .route(state.spec_path.as_ref(), get(openapi_json))
70        .route(state.js_path.as_ref(), get(scalar_js))
71        .with_state(state)
72}
73
74#[derive(Clone)]
75struct ScalarState {
76    spec_json: Bytes,
77    spec_path: SharedStr,
78    js_path: SharedStr,
79    title: SharedStr,
80}
81
82async fn scalar_docs(State(state): State<ScalarState>) -> Html<String> {
83    Html(html_page(&state))
84}
85
86async fn openapi_json(State(state): State<ScalarState>) -> impl IntoResponse {
87    (
88        [
89            (CONTENT_TYPE, "application/json; charset=utf-8"),
90            (CACHE_CONTROL, "no-store"),
91        ],
92        Body::from(state.spec_json.clone()),
93    )
94}
95
96async fn scalar_js() -> impl IntoResponse {
97    (
98        [
99            (CONTENT_TYPE, "application/javascript; charset=utf-8"),
100            (CACHE_CONTROL, "public, max-age=31536000, immutable"),
101        ],
102        SCALAR_JS,
103    )
104}
105
106fn html_page(state: &ScalarState) -> String {
107    let title = escape_html(state.title.as_ref());
108    let spec_path = escape_html_attr(state.spec_path.as_ref());
109    let js_path = escape_html_attr(state.js_path.as_ref());
110
111    format!(
112        r#"<!doctype html>
113<html lang="en">
114<head>
115  <meta charset="utf-8">
116  <meta name="viewport" content="width=device-width, initial-scale=1">
117  <title>{title}</title>
118  <style>
119    body {{
120      margin: 0;
121    }}
122  </style>
123</head>
124<body>
125  <script id="api-reference" data-url="{spec_path}"></script>
126  <script src="{js_path}"></script>
127</body>
128</html>
129"#
130    )
131}
132
133fn escape_html(value: &str) -> String {
134    value
135        .replace('&', "&amp;")
136        .replace('<', "&lt;")
137        .replace('>', "&gt;")
138}
139
140fn escape_html_attr(value: &str) -> String {
141    escape_html(value).replace('"', "&quot;")
142}
143
144#[cfg(test)]
145mod tests {
146    use super::{ScalarState, escape_html_attr, html_page};
147    use bytes::Bytes;
148
149    #[test]
150    fn escapes_html_attributes() {
151        assert_eq!(
152            escape_html_attr(r#"/docs?x="<&>"#),
153            "/docs?x=&quot;&lt;&amp;&gt;"
154        );
155    }
156
157    #[test]
158    fn page_points_scalar_at_spec_and_embedded_script() {
159        let page = html_page(&ScalarState {
160            spec_json: Bytes::new(),
161            spec_path: "/openapi.json".into(),
162            js_path: "/scalar/scalar.js".into(),
163            title: "Docs".into(),
164        });
165
166        assert!(page.contains(r#"data-url="/openapi.json""#));
167        assert!(page.contains(r#"src="/scalar/scalar.js""#));
168    }
169}