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