1pub mod docs;
14
15use rustlavel_core::{Config, Json};
16use rustlavel_http::{Request, Response, Route, Router};
17
18#[derive(Debug, Clone)]
20pub struct Info {
21 pub title: String,
22 pub version: String,
23 pub description: Option<String>,
24 pub server: Option<String>,
26 pub prefix: String,
31}
32
33impl Default for Info {
34 fn default() -> Self {
35 Info {
36 title: "API".into(),
37 version: "1.0.0".into(),
38 description: None,
39 server: None,
40 prefix: "/api".into(),
41 }
42 }
43}
44
45impl Info {
46 pub fn from_config(config: &Config) -> Info {
47 Info {
48 title: config.string("openapi.title", &config.string("app.name", "API")),
49 version: config.string("openapi.version", "1.0.0"),
50 description: non_empty(config.string("openapi.description", "")),
51 server: non_empty(config.string("app.url", "")),
52 prefix: config.string("openapi.prefix", "/api"),
53 }
54 }
55}
56
57fn non_empty(value: String) -> Option<String> {
58 (!value.is_empty()).then_some(value)
59}
60
61pub fn document(router: &Router, info: &Info) -> Json {
63 let mut paths: std::collections::BTreeMap<String, Json> = std::collections::BTreeMap::new();
64
65 for route in router.routes() {
66 if !route.pattern.starts_with(&info.prefix) {
67 continue;
68 }
69 if route.pattern.contains(":*") {
72 continue;
73 }
74
75 let entry = paths.entry(route.pattern.clone()).or_insert_with(|| Json::Object(Default::default()));
76 if let Json::Object(operations) = entry {
77 operations.insert(route.method.as_str().to_lowercase(), operation(route));
78 }
79 }
80
81 let mut root = vec![
82 ("openapi", Json::from("3.1.0")),
83 (
84 "info",
85 Json::object(
86 [
87 Some(("title", Json::from(info.title.as_str()))),
88 Some(("version", Json::from(info.version.as_str()))),
89 info.description.as_ref().map(|d| ("description", Json::from(d.as_str()))),
90 ]
91 .into_iter()
92 .flatten()
93 .collect::<Vec<_>>(),
94 ),
95 ),
96 ("paths", Json::Object(paths.into_iter().collect())),
97 ];
98
99 if let Some(server) = &info.server {
100 root.push(("servers", Json::Array(vec![Json::object([("url", Json::from(server.as_str()))])])));
101 }
102
103 Json::object(root)
104}
105
106fn operation(route: &Route) -> Json {
107 let mut fields = vec![(
108 "responses",
109 responses(route),
110 )];
111
112 if let Some(summary) = &route.summary {
113 fields.push(("summary", Json::from(summary.as_str())));
114 }
115 if let Some(name) = &route.name {
116 fields.push(("operationId", Json::from(name.as_str())));
119 }
120 if let Some(tag) = &route.tag {
121 fields.push(("tags", Json::Array(vec![Json::from(tag.as_str())])));
122 }
123 if route.deprecated {
124 fields.push(("deprecated", Json::from(true)));
125 }
126
127 let parameters = parameters(route);
128 if !parameters.is_empty() {
129 fields.push(("parameters", Json::Array(parameters)));
130 }
131
132 Json::object(fields)
133}
134
135fn parameters(route: &Route) -> Vec<Json> {
136 let described = |name: &str| {
137 route
138 .parameters
139 .iter()
140 .find(|(parameter, _)| parameter == name)
141 .map(|(_, description)| description.clone())
142 };
143
144 let path_names = route.parameter_names();
145 let mut out: Vec<Json> = path_names
146 .iter()
147 .map(|name| {
148 parameter(name, "path", true, described(name))
149 })
150 .collect();
151
152 for (name, description) in &route.parameters {
154 if path_names.iter().any(|path_name| path_name == name) {
155 continue;
156 }
157 out.push(parameter(name, "query", false, Some(description.clone())));
158 }
159
160 out
161}
162
163fn parameter(name: &str, location: &str, required: bool, description: Option<String>) -> Json {
164 let mut fields = vec![
165 ("name", Json::from(name)),
166 ("in", Json::from(location)),
167 ("required", Json::from(required)),
168 ("schema", Json::object([("type", Json::from("string"))])),
169 ];
170 if let Some(description) = description {
171 fields.push(("description", Json::from(description)));
172 }
173 Json::object(fields)
174}
175
176fn responses(route: &Route) -> Json {
177 if route.responses.is_empty() {
178 return Json::object([(
181 "200",
182 Json::object([("description", Json::from("Successful response"))]),
183 )]);
184 }
185
186 Json::Object(
187 route
188 .responses
189 .iter()
190 .map(|(status, description)| {
191 (
192 status.to_string(),
193 Json::object([("description", Json::from(description.as_str()))]),
194 )
195 })
196 .collect(),
197 )
198}
199
200pub fn mount(router: &mut Router, info: &Info, path: &str) {
207 let body = document(router, info).to_string();
208 let page = docs::page(info, path);
209
210 let document_path = path.to_string();
211 router.get(&document_path, move |_request: Request| {
212 let body = body.clone();
213 async move {
214 Response::ok().with_header("content-type", "application/json").with_body(body)
215 }
216 });
217
218 let page_path = match document_path.strip_suffix(".json") {
220 Some(stem) => stem.to_string(),
221 None => format!("{document_path}/docs"),
222 };
223 router.get(&page_path, move |_request: Request| {
224 let page = page.clone();
225 async move { Response::html(page) }
226 });
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use rustlavel_http::Request;
233
234 async fn ok(_req: Request) -> &'static str {
235 "ok"
236 }
237
238 fn router() -> Router {
239 let mut router = Router::new();
240 router.get("/", ok).describe("The home page");
241 router
242 .get("/api/users", ok)
243 .name("users.index")
244 .describe("List users")
245 .tag("Users")
246 .param("page", "Which page to return")
247 .responds(200, "A page of users");
248 router
249 .get("/api/users/{id}", ok)
250 .name("users.show")
251 .describe("Fetch one user")
252 .tag("Users")
253 .param("id", "The user's id")
254 .responds(200, "The user")
255 .responds(404, "No such user");
256 router.post("/api/users", ok).name("users.store").tag("Users").responds(201, "Created");
257 router.get("/api/legacy", ok).deprecated();
258 router.get("/api/files/{path:*}", ok);
259 router.finalize();
260 router
261 }
262
263 fn info() -> Info {
264 Info { title: "Orders API".into(), version: "2.1".into(), ..Info::default() }
265 }
266
267 #[test]
268 fn documents_only_the_api_prefix() {
269 let document = document(&router(), &info());
270 let paths = document.get("paths").unwrap().as_object().unwrap();
271
272 assert!(paths.contains_key("/api/users"));
273 assert!(!paths.contains_key("/"), "a browser page is not an API");
274 }
275
276 #[test]
277 fn a_wildcard_route_is_left_out() {
278 let document = document(&router(), &info());
279 let paths = document.get("paths").unwrap().as_object().unwrap();
280
281 assert!(paths.keys().all(|path| !path.contains(":*")));
284 }
285
286 #[test]
287 fn methods_on_one_path_share_an_entry() {
288 let document = document(&router(), &info());
289 let users = document.get("paths./api/users").unwrap().as_object().unwrap();
290
291 assert!(users.contains_key("get"));
292 assert!(users.contains_key("post"));
293 }
294
295 #[test]
296 fn a_route_name_becomes_the_operation_id() {
297 let document = document(&router(), &info());
298
299 assert_eq!(
300 document.get("paths./api/users/{id}.get.operationId").unwrap().as_str(),
301 Some("users.show")
302 );
303 }
304
305 #[test]
306 fn path_parameters_are_required_and_query_parameters_are_not() {
307 let document = document(&router(), &info());
308
309 let show = document.get("paths./api/users/{id}.get.parameters").unwrap().as_array().unwrap();
310 assert_eq!(show[0].get("name").unwrap().as_str(), Some("id"));
311 assert_eq!(show[0].get("in").unwrap().as_str(), Some("path"));
312 assert_eq!(show[0].get("required").unwrap().as_bool(), Some(true));
313 assert_eq!(show[0].get("description").unwrap().as_str(), Some("The user's id"));
314
315 let index = document.get("paths./api/users.get.parameters").unwrap().as_array().unwrap();
316 assert_eq!(index[0].get("in").unwrap().as_str(), Some("query"));
317 assert_eq!(index[0].get("required").unwrap().as_bool(), Some(false));
318 }
319
320 #[test]
321 fn documented_responses_are_carried_over() {
322 let document = document(&router(), &info());
323 let responses = document.get("paths./api/users/{id}.get.responses").unwrap();
324
325 assert_eq!(responses.get("200.description").unwrap().as_str(), Some("The user"));
326 assert_eq!(responses.get("404.description").unwrap().as_str(), Some("No such user"));
327 }
328
329 #[test]
330 fn an_undocumented_route_still_produces_a_valid_operation() {
331 let document = document(&router(), &info());
332 let legacy = document.get("paths./api/legacy.get").unwrap();
333
334 assert!(legacy.get("responses.200").is_some());
336 assert_eq!(legacy.get("deprecated").unwrap().as_bool(), Some(true));
337 }
338
339 #[test]
340 fn the_document_carries_the_api_identity() {
341 let document = document(&router(), &info());
342
343 assert_eq!(document.get("openapi").unwrap().as_str(), Some("3.1.0"));
344 assert_eq!(document.get("info.title").unwrap().as_str(), Some("Orders API"));
345 assert_eq!(document.get("info.version").unwrap().as_str(), Some("2.1"));
346 }
347
348 #[tokio::test]
349 async fn the_document_and_the_page_are_served() {
350 use rustlavel_http::TestClient;
351
352 let mut router = router();
353 mount(&mut router, &info(), "/openapi.json");
354
355 let client = TestClient::new(router);
356
357 client
358 .get("/openapi.json")
359 .await
360 .assert_ok()
361 .assert_header("content-type", "application/json")
362 .assert_json("info.title", "Orders API");
363
364 client.get("/openapi").await.assert_ok().assert_see("Orders API");
365 }
366
367 #[test]
368 fn configuration_supplies_the_identity() {
369 let config = Config::new();
370 config.set("app.name", "Shop");
371 config.set("app.url", "https://shop.example.com");
372 config.set("openapi.version", "3.4");
373
374 let info = Info::from_config(&config);
375 assert_eq!(info.title, "Shop");
376 assert_eq!(info.version, "3.4");
377
378 let document = document(&router(), &info);
379 assert_eq!(
380 document.get("servers.0.url").unwrap().as_str(),
381 Some("https://shop.example.com")
382 );
383 }
384}