Skip to main content

rustango/openapi/
router.rs

1//! axum router for serving the OpenAPI spec + Swagger UI / Redoc viewers.
2//!
3//! - `GET /openapi.json` — the spec serialized as JSON.
4//! - `GET /docs` — Swagger UI (loaded from a CDN).
5//! - `GET /redoc` — Redoc (also CDN-loaded).
6//!
7//! No JS files are bundled into rustango — the viewer pages are tiny
8//! HTML shells that pull the viewer from `unpkg.com`. If you need an
9//! offline build, write the spec out at startup and self-host the
10//! viewer assets in your own static dir.
11
12use std::sync::Arc;
13
14use axum::extract::State;
15use axum::http::header;
16use axum::response::{Html, IntoResponse, Response};
17use axum::routing::get;
18use axum::Router;
19
20use super::OpenApiSpec;
21
22/// Mount the spec + viewer routes under the given router.
23#[must_use]
24pub fn openapi_router(spec: OpenApiSpec) -> Router {
25    Router::new()
26        .route("/openapi.json", get(serve_spec))
27        .route("/docs", get(swagger_ui))
28        .route("/redoc", get(redoc))
29        .with_state(Arc::new(spec))
30}
31
32async fn serve_spec(State(spec): State<Arc<OpenApiSpec>>) -> Response {
33    let body = spec.to_json();
34    (
35        [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
36        body,
37    )
38        .into_response()
39}
40
41async fn swagger_ui() -> Html<&'static str> {
42    Html(SWAGGER_HTML)
43}
44
45async fn redoc() -> Html<&'static str> {
46    Html(REDOC_HTML)
47}
48
49const SWAGGER_HTML: &str = r##"<!doctype html>
50<html lang="en">
51<head>
52<meta charset="utf-8">
53<meta name="viewport" content="width=device-width, initial-scale=1">
54<title>API Docs</title>
55<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
56</head>
57<body>
58<div id="swagger-ui"></div>
59<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
60<script>
61  window.onload = () => {
62    window.ui = SwaggerUIBundle({
63      url: "/openapi.json",
64      dom_id: "#swagger-ui",
65      deepLinking: true,
66    });
67  };
68</script>
69</body>
70</html>
71"##;
72
73const REDOC_HTML: &str = r#"<!doctype html>
74<html lang="en">
75<head>
76<meta charset="utf-8">
77<meta name="viewport" content="width=device-width, initial-scale=1">
78<title>API Docs</title>
79<style>body { margin: 0; }</style>
80</head>
81<body>
82<redoc spec-url="/openapi.json"></redoc>
83<script src="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"></script>
84</body>
85</html>
86"#;
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::openapi::{Operation, PathItem, Response as OpenApiResponse, Schema};
92    use axum::body::Body;
93    use axum::http::Request;
94    use tower::ServiceExt;
95
96    fn sample_spec() -> OpenApiSpec {
97        OpenApiSpec::new("Demo", "1.2.3")
98            .add_path(
99                "/ping",
100                PathItem::new().get(
101                    Operation::new()
102                        .summary("Ping")
103                        .response("200", OpenApiResponse::new("pong").json_content(Schema::string())),
104                ),
105            )
106    }
107
108    #[tokio::test]
109    async fn openapi_json_returns_serialized_spec() {
110        let app = openapi_router(sample_spec());
111        let resp = app
112            .oneshot(
113                Request::builder()
114                    .uri("/openapi.json")
115                    .body(Body::empty())
116                    .unwrap(),
117            )
118            .await
119            .unwrap();
120        assert_eq!(resp.status(), 200);
121        assert_eq!(
122            resp.headers()
123                .get(header::CONTENT_TYPE)
124                .unwrap()
125                .to_str()
126                .unwrap(),
127            "application/json; charset=utf-8"
128        );
129        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
130        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
131        assert_eq!(v["info"]["title"], "Demo");
132        assert_eq!(v["info"]["version"], "1.2.3");
133        assert_eq!(v["paths"]["/ping"]["get"]["summary"], "Ping");
134    }
135
136    #[tokio::test]
137    async fn docs_serves_swagger_ui_html() {
138        let app = openapi_router(sample_spec());
139        let resp = app
140            .oneshot(Request::builder().uri("/docs").body(Body::empty()).unwrap())
141            .await
142            .unwrap();
143        assert_eq!(resp.status(), 200);
144        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
145        let body = std::str::from_utf8(&bytes).unwrap();
146        assert!(body.contains("swagger-ui"));
147        assert!(body.contains("/openapi.json"));
148    }
149
150    #[tokio::test]
151    async fn redoc_serves_redoc_html() {
152        let app = openapi_router(sample_spec());
153        let resp = app
154            .oneshot(Request::builder().uri("/redoc").body(Body::empty()).unwrap())
155            .await
156            .unwrap();
157        assert_eq!(resp.status(), 200);
158        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
159        let body = std::str::from_utf8(&bytes).unwrap();
160        assert!(body.contains("redoc"));
161        assert!(body.contains("/openapi.json"));
162    }
163}