Skip to main content

sova_openapi/
plugin.rs

1use crate::build::{build_document, BuildOptions};
2use crate::doc::Doc;
3use sova_core::extend::{RouteTable, RouteValue};
4use sova_core::{App, Plugin, Request, Response};
5use std::path::PathBuf;
6use std::sync::Arc;
7
8impl RouteValue for Doc {}
9
10/// Attach [`Doc`] metadata to the last registered HTTP route.
11pub trait OpenApiDocExt {
12    fn doc(&mut self, doc: Doc) -> &mut Self;
13}
14
15impl OpenApiDocExt for App {
16    fn doc(&mut self, doc: Doc) -> &mut Self {
17        self.with(doc);
18        self
19    }
20}
21
22impl OpenApiDocExt for sova_core::Router {
23    fn doc(&mut self, doc: Doc) -> &mut Self {
24        self.with(doc);
25        self
26    }
27}
28
29/// Serves Scalar UI and OpenAPI JSON from the compiled [`RouteTable`].
30pub struct OpenApi {
31    title: String,
32    title_explicit: bool,
33    version: String,
34    version_explicit: bool,
35    servers: Vec<String>,
36    mount: String,
37    mount_explicit: bool,
38    local_assets: Option<PathBuf>,
39}
40
41impl OpenApi {
42    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
43        Self {
44            title: title.into(),
45            title_explicit: true,
46            version: version.into(),
47            version_explicit: true,
48            servers: Vec::new(),
49            mount: "/docs".into(),
50            mount_explicit: false,
51            local_assets: None,
52        }
53    }
54
55    pub fn server(mut self, url: impl Into<String>) -> Self {
56        self.servers.push(url.into());
57        self
58    }
59
60    pub fn mount(mut self, path: impl Into<String>) -> Self {
61        self.mount = path.into();
62        self.mount_explicit = true;
63        self
64    }
65
66    /// Prefer a local `scalar.js` next to the HTML (offline). CDN used when unset.
67    pub fn local_assets(mut self, dir: impl Into<PathBuf>) -> Self {
68        self.local_assets = Some(dir.into());
69        self
70    }
71}
72
73#[derive(Clone)]
74struct OpenApiState {
75    title: String,
76    version: String,
77    servers: Vec<String>,
78    mount: String,
79    script_src: String,
80}
81
82impl Plugin for OpenApi {
83    fn id(&self) -> &'static str {
84        "openapi"
85    }
86
87    fn meta(&self) -> sova_core::PluginMeta {
88        sova_core::PluginMeta::new("OpenAPI")
89            .description("OpenAPI 3.1 document + Scalar UI at mount path")
90            .version(env!("CARGO_PKG_VERSION"))
91    }
92
93    fn install(mut self, app: &mut App) {
94        if let Some(doc) = app.config_doc() {
95            if let Some(section) = doc.section("openapi") {
96                // `OpenApi::new(title, version)` marks those explicit; only fill mount / extras.
97                if !self.title_explicit {
98                    if let Some(t) = section.get("title").and_then(|v| v.as_str()) {
99                        self.title = t.to_string();
100                    }
101                }
102                if !self.version_explicit {
103                    if let Some(v) = section.get("version").and_then(|v| v.as_str()) {
104                        self.version = v.to_string();
105                    }
106                }
107                if !self.mount_explicit {
108                    if let Some(m) = section.get("mount").and_then(|v| v.as_str()) {
109                        self.mount = m.to_string();
110                    }
111                }
112                if self.servers.is_empty() {
113                    if let Some(s) = section.get("server").and_then(|v| v.as_str()) {
114                        self.servers.push(s.to_string());
115                    }
116                }
117            }
118        }
119        let mount = if self.mount.ends_with('/') && self.mount.len() > 1 {
120            self.mount.trim_end_matches('/').to_string()
121        } else {
122            self.mount.clone()
123        };
124        let openapi_path = format!("{mount}/openapi.json");
125        let script_src = match &self.local_assets {
126            Some(dir) => {
127                // Path relative to mount — apps can static-serve `dir` at `{mount}/`.
128                let file = dir.join("scalar.js");
129                format!("{mount}/{}", file.file_name().and_then(|s| s.to_str()).unwrap_or("scalar.js"))
130            }
131            None => "https://cdn.jsdelivr.net/npm/@scalar/api-reference".to_string(),
132        };
133        let state = Arc::new(OpenApiState {
134            title: self.title,
135            version: self.version,
136            servers: self.servers,
137            mount: mount.clone(),
138            script_src,
139        });
140
141        let json_state = Arc::clone(&state);
142        app.get(&openapi_path, move |req: Request| {
143            let json_state = Arc::clone(&json_state);
144            async move {
145                let table = req.state::<RouteTable>();
146                let doc = build_document(
147                    table.as_ref(),
148                    &BuildOptions {
149                        title: &json_state.title,
150                        version: &json_state.version,
151                        servers: &json_state.servers,
152                        docs_prefix: &json_state.mount,
153                    },
154                );
155                Response::json(&doc)
156            }
157        })
158        .doc(Doc::skip());
159
160        let html_state = Arc::clone(&state);
161        app.get(&mount, move |_req: Request| {
162            let html_state = Arc::clone(&html_state);
163            async move {
164                let spec_url = format!("{}/openapi.json", html_state.mount);
165                Response::html(scalar_html(&spec_url, &html_state.script_src))
166            }
167        })
168        .doc(Doc::skip());
169
170        let mount_check = mount.clone();
171        app.register_audit("openapi", move |state| {
172            let mount = mount_check.clone();
173            async move {
174                let Some(table) = state.get::<RouteTable>() else {
175                    return Ok(());
176                };
177                let missing = crate::undocumented_from_table(table.as_ref(), &mount);
178                if !missing.is_empty() {
179                    return Err(sova_core::Error::Internal(format!(
180                        "undocumented routes: {}",
181                        missing.join(", ")
182                    )));
183                }
184                Ok(())
185            }
186        });
187    }
188}
189
190fn scalar_html(spec_url: &str, script_src: &str) -> String {
191    format!(
192        r#"<!DOCTYPE html>
193<html>
194<head>
195  <title>API Reference</title>
196  <meta charset="utf-8" />
197  <meta name="viewport" content="width=device-width, initial-scale=1" />
198</head>
199<body>
200  <script id="api-reference" data-url="{spec_url}"></script>
201  <script src="{script_src}"></script>
202</body>
203</html>"#
204    )
205}