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
10pub 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
29pub 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 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 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 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}