connect2axum_scalar/
lib.rs1use 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
17pub const SCALAR_VERSION: &str = env!("CONNECT2AXUM_SCALAR_VERSION");
19
20#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct ScalarOptions {
23 pub docs_path: SharedStr,
25 pub spec_path: SharedStr,
27 pub js_path: SharedStr,
29 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
44pub 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
52pub 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('&', "&")
136 .replace('<', "<")
137 .replace('>', ">")
138}
139
140fn escape_html_attr(value: &str) -> String {
141 escape_html(value).replace('"', """)
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="<&>"
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}