1use crate::{OpenApiError, Result};
7use async_trait::async_trait;
8use silent::{Handler, MiddleWareHandler, Next, Request, Response, StatusCode};
9use utoipa::openapi::OpenApi;
10
11const REDOC_VERSION: &str = "2.1.5";
12
13fn generate_redoc_html(api_doc_url: &str) -> String {
15 format!(
16 r#"<!DOCTYPE html>
17<html lang="zh-CN">
18<head>
19 <meta charset="UTF-8">
20 <meta name="viewport" content="width=device-width, initial-scale=1.0">
21 <title>API Documentation - ReDoc</title>
22 <style>
23 body {{ margin: 0; padding: 0; }}
24 </style>
25</head>
26<body>
27 <redoc spec-url='{api_doc_url}'></redoc>
28 <script src="https://unpkg.com/redoc@{REDOC_VERSION}/bundles/redoc.standalone.js"></script>
29</body>
30</html>"#
31 )
32}
33
34#[derive(Clone)]
39pub struct ReDocHandler {
40 ui_path: String,
41 api_doc_path: String,
42 openapi_json: String,
43}
44
45impl ReDocHandler {
46 pub fn new(ui_path: &str, openapi: OpenApi) -> Result<Self> {
53 let api_doc_path = format!("{}/openapi.json", ui_path.trim_end_matches('/'));
54 let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
55
56 Ok(Self {
57 ui_path: ui_path.to_string(),
58 api_doc_path,
59 openapi_json,
60 })
61 }
62
63 pub fn with_custom_api_doc_path(
65 ui_path: &str,
66 api_doc_path: &str,
67 openapi: OpenApi,
68 ) -> Result<Self> {
69 let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
70
71 Ok(Self {
72 ui_path: ui_path.to_string(),
73 api_doc_path: api_doc_path.to_string(),
74 openapi_json,
75 })
76 }
77
78 fn matches_path(&self, path: &str) -> bool {
79 path == self.ui_path
80 || path.starts_with(&format!("{}/", self.ui_path))
81 || path == self.api_doc_path
82 }
83
84 pub fn into_route(self) -> silent::prelude::Route {
86 use silent::prelude::{HandlerGetter, Method, Route};
87 use std::sync::Arc;
88
89 let mount = self.ui_path.trim_start_matches('/');
90
91 let base = Route::new(mount)
92 .insert_handler(Method::GET, Arc::new(self.clone()))
93 .insert_handler(Method::HEAD, Arc::new(self.clone()))
94 .append(
95 Route::new("<path:**>")
96 .insert_handler(Method::GET, Arc::new(self.clone()))
97 .insert_handler(Method::HEAD, Arc::new(self)),
98 );
99
100 Route::new("").append(base)
101 }
102}
103
104impl silent::prelude::RouterAdapt for ReDocHandler {
105 fn into_router(self) -> silent::prelude::Route {
106 self.into_route()
107 }
108}
109
110#[async_trait]
111impl Handler for ReDocHandler {
112 async fn call(&self, req: Request) -> silent::Result<Response> {
113 let path = req.uri().path();
114
115 if !self.matches_path(path) {
116 return Err(silent::SilentError::NotFound);
117 }
118
119 if path == self.api_doc_path {
120 let mut response = Response::empty();
121 response.set_status(StatusCode::OK);
122 response.set_header(
123 http::header::CONTENT_TYPE,
124 http::HeaderValue::from_static("application/json; charset=utf-8"),
125 );
126 response.set_body(self.openapi_json.clone().into());
127 Ok(response)
128 } else if path == self.ui_path {
129 let mut response = Response::empty();
130 response.set_status(StatusCode::MOVED_PERMANENTLY);
131 response.set_header(
132 http::header::LOCATION,
133 http::HeaderValue::from_str(&format!("{}/", self.ui_path))
134 .unwrap_or_else(|_| http::HeaderValue::from_static("/")),
135 );
136 Ok(response)
137 } else {
138 let html = generate_redoc_html(&self.api_doc_path);
139 let mut response = Response::empty();
140 response.set_status(StatusCode::OK);
141 response.set_header(
142 http::header::CONTENT_TYPE,
143 http::HeaderValue::from_static("text/html; charset=utf-8"),
144 );
145 response.set_body(html.into());
146 Ok(response)
147 }
148 }
149}
150
151#[derive(Clone)]
156pub struct ReDocMiddleware {
157 ui_path: String,
158 api_doc_path: String,
159 openapi_json: String,
160}
161
162impl ReDocMiddleware {
163 pub fn new(ui_path: &str, openapi: OpenApi) -> Result<Self> {
165 let api_doc_path = format!("{}/openapi.json", ui_path.trim_end_matches('/'));
166 let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
167
168 Ok(Self {
169 ui_path: ui_path.to_string(),
170 api_doc_path,
171 openapi_json,
172 })
173 }
174
175 pub fn with_custom_api_doc_path(
177 ui_path: &str,
178 api_doc_path: &str,
179 openapi: OpenApi,
180 ) -> Result<Self> {
181 let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
182
183 Ok(Self {
184 ui_path: ui_path.to_string(),
185 api_doc_path: api_doc_path.to_string(),
186 openapi_json,
187 })
188 }
189
190 fn matches_path(&self, path: &str) -> bool {
191 path == self.ui_path
192 || path.starts_with(&format!("{}/", self.ui_path))
193 || path == self.api_doc_path
194 }
195}
196
197#[async_trait]
198impl MiddleWareHandler for ReDocMiddleware {
199 async fn handle(&self, req: Request, next: &Next) -> silent::Result<Response> {
200 let path = req.uri().path();
201
202 if !self.matches_path(path) {
203 return next.call(req).await;
204 }
205
206 if path == self.api_doc_path {
207 let mut response = Response::empty();
208 response.set_status(StatusCode::OK);
209 response.set_header(
210 http::header::CONTENT_TYPE,
211 http::HeaderValue::from_static("application/json; charset=utf-8"),
212 );
213 response.set_header(
214 http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
215 http::HeaderValue::from_static("*"),
216 );
217 response.set_body(self.openapi_json.clone().into());
218 Ok(response)
219 } else if path == self.ui_path {
220 let mut response = Response::empty();
221 response.set_status(StatusCode::MOVED_PERMANENTLY);
222 response.set_header(
223 http::header::LOCATION,
224 http::HeaderValue::from_str(&format!("{}/", self.ui_path))
225 .unwrap_or_else(|_| http::HeaderValue::from_static("/")),
226 );
227 Ok(response)
228 } else {
229 let html = generate_redoc_html(&self.api_doc_path);
230 let mut response = Response::empty();
231 response.set_status(StatusCode::OK);
232 response.set_header(
233 http::header::CONTENT_TYPE,
234 http::HeaderValue::from_static("text/html; charset=utf-8"),
235 );
236 response.set_header(
237 http::header::CACHE_CONTROL,
238 http::HeaderValue::from_static("no-cache, no-store, must-revalidate"),
239 );
240 response.set_body(html.into());
241 Ok(response)
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use utoipa::OpenApi;
250
251 #[derive(OpenApi)]
252 #[openapi(
253 info(title = "Test API", version = "1.0.0"),
254 paths(),
255 components(schemas())
256 )]
257 struct TestApiDoc;
258
259 #[test]
260 fn test_redoc_handler_creation() {
261 let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi());
262 assert!(handler.is_ok());
263 let handler = handler.unwrap();
264 assert_eq!(handler.ui_path, "/redoc");
265 assert_eq!(handler.api_doc_path, "/redoc/openapi.json");
266 }
267
268 #[test]
269 fn test_redoc_handler_path_matching() {
270 let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi()).unwrap();
271 assert!(handler.matches_path("/redoc"));
272 assert!(handler.matches_path("/redoc/"));
273 assert!(handler.matches_path("/redoc/openapi.json"));
274 assert!(!handler.matches_path("/api/users"));
275 }
276
277 #[test]
278 fn test_redoc_handler_custom_api_doc_path() {
279 let handler = ReDocHandler::with_custom_api_doc_path(
280 "/redoc",
281 "/docs/openapi.json",
282 TestApiDoc::openapi(),
283 )
284 .unwrap();
285 assert_eq!(handler.api_doc_path, "/docs/openapi.json");
286 assert!(handler.matches_path("/docs/openapi.json"));
287 }
288
289 #[tokio::test]
290 async fn test_redoc_handler_openapi_json() {
291 let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi()).unwrap();
292 let mut req = Request::empty();
293 *req.uri_mut() = http::Uri::from_static("http://localhost/redoc/openapi.json");
294 let resp = handler.call(req).await.unwrap();
295 assert!(
296 resp.headers()
297 .get(http::header::CONTENT_TYPE)
298 .map(|v| v.to_str().unwrap_or("").contains("application/json"))
299 .unwrap_or(false)
300 );
301 }
302
303 #[tokio::test]
304 async fn test_redoc_handler_redirect() {
305 let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi()).unwrap();
306 let mut req = Request::empty();
307 *req.uri_mut() = http::Uri::from_static("http://localhost/redoc");
308 let resp = handler.call(req).await.unwrap();
309 assert!(resp.headers().get(http::header::LOCATION).is_some());
310 }
311
312 #[tokio::test]
313 async fn test_redoc_handler_html_page() {
314 let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi()).unwrap();
315 let mut req = Request::empty();
316 *req.uri_mut() = http::Uri::from_static("http://localhost/redoc/");
317 let resp = handler.call(req).await.unwrap();
318 assert!(
319 resp.headers()
320 .get(http::header::CONTENT_TYPE)
321 .map(|v| v.to_str().unwrap_or("").contains("text/html"))
322 .unwrap_or(false)
323 );
324 }
325
326 #[test]
327 fn test_redoc_middleware_creation() {
328 let mw = ReDocMiddleware::new("/redoc", TestApiDoc::openapi());
329 assert!(mw.is_ok());
330 }
331
332 #[test]
333 fn test_redoc_middleware_path_matching() {
334 let mw = ReDocMiddleware::new("/redoc", TestApiDoc::openapi()).unwrap();
335 assert!(mw.matches_path("/redoc"));
336 assert!(mw.matches_path("/redoc/"));
337 assert!(!mw.matches_path("/other"));
338 }
339
340 #[test]
341 fn test_generate_redoc_html() {
342 let html = generate_redoc_html("/api/openapi.json");
343 assert!(html.contains("/api/openapi.json"));
344 assert!(html.contains("redoc"));
345 assert!(html.contains("redoc.standalone.js"));
346 }
347
348 #[tokio::test]
349 async fn test_redoc_handler_into_route() {
350 let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi()).unwrap();
351 let route = handler.into_route();
352 let mut req = Request::empty();
353 *req.uri_mut() = http::Uri::from_static("http://localhost/redoc/openapi.json");
354 let resp = route.call(req).await.unwrap();
355 assert!(resp.headers().get(http::header::CONTENT_TYPE).is_some());
356 }
357}