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 if let Some(sunset) = route.sunset {
130 fields.push(("x-sunset", Json::from(rustlavel_http::date::http_date(sunset))));
131 }
132
133 let parameters = parameters(route);
134 if !parameters.is_empty() {
135 fields.push(("parameters", Json::Array(parameters)));
136 }
137
138 Json::object(fields)
139}
140
141fn parameters(route: &Route) -> Vec<Json> {
142 let described = |name: &str| {
143 route
144 .parameters
145 .iter()
146 .find(|(parameter, _)| parameter == name)
147 .map(|(_, description)| description.clone())
148 };
149
150 let path_names = route.parameter_names();
151 let mut out: Vec<Json> = path_names
152 .iter()
153 .map(|name| {
154 parameter(name, "path", true, described(name))
155 })
156 .collect();
157
158 for (name, description) in &route.parameters {
160 if path_names.iter().any(|path_name| path_name == name) {
161 continue;
162 }
163 out.push(parameter(name, "query", false, Some(description.clone())));
164 }
165
166 out
167}
168
169fn parameter(name: &str, location: &str, required: bool, description: Option<String>) -> Json {
170 let mut fields = vec![
171 ("name", Json::from(name)),
172 ("in", Json::from(location)),
173 ("required", Json::from(required)),
174 ("schema", Json::object([("type", Json::from("string"))])),
175 ];
176 if let Some(description) = description {
177 fields.push(("description", Json::from(description)));
178 }
179 Json::object(fields)
180}
181
182fn responses(route: &Route) -> Json {
183 if route.responses.is_empty() {
184 return Json::object([(
187 "200",
188 Json::object([("description", Json::from("Successful response"))]),
189 )]);
190 }
191
192 Json::Object(
193 route
194 .responses
195 .iter()
196 .map(|(status, description)| {
197 (
198 status.to_string(),
199 Json::object([("description", Json::from(description.as_str()))]),
200 )
201 })
202 .collect(),
203 )
204}
205
206pub fn mount(router: &mut Router, info: &Info, path: &str) {
213 let body = document(router, info).to_string();
214 let page = docs::page(info, path);
215
216 let document_path = path.to_string();
217 router.get(&document_path, move |_request: Request| {
218 let body = body.clone();
219 async move {
220 Response::ok().with_header("content-type", "application/json").with_body(body)
221 }
222 });
223
224 let page_path = match document_path.strip_suffix(".json") {
226 Some(stem) => stem.to_string(),
227 None => format!("{document_path}/docs"),
228 };
229 router.get(&page_path, move |_request: Request| {
230 let page = page.clone();
231 async move { Response::html(page) }
232 });
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use rustlavel_http::Request;
239
240 async fn ok(_req: Request) -> &'static str {
241 "ok"
242 }
243
244 fn router() -> Router {
245 let mut router = Router::new();
246 router.get("/", ok).describe("The home page");
247 router
248 .get("/api/users", ok)
249 .name("users.index")
250 .describe("List users")
251 .tag("Users")
252 .param("page", "Which page to return")
253 .responds(200, "A page of users");
254 router
255 .get("/api/users/{id}", ok)
256 .name("users.show")
257 .describe("Fetch one user")
258 .tag("Users")
259 .param("id", "The user's id")
260 .responds(200, "The user")
261 .responds(404, "No such user");
262 router.post("/api/users", ok).name("users.store").tag("Users").responds(201, "Created");
263 router.get("/api/legacy", ok).deprecated();
264 router.get("/api/files/{path:*}", ok);
265 router.finalize();
266 router
267 }
268
269 fn info() -> Info {
270 Info { title: "Orders API".into(), version: "2.1".into(), ..Info::default() }
271 }
272
273 #[test]
274 fn documents_only_the_api_prefix() {
275 let document = document(&router(), &info());
276 let paths = document.get("paths").unwrap().as_object().unwrap();
277
278 assert!(paths.contains_key("/api/users"));
279 assert!(!paths.contains_key("/"), "a browser page is not an API");
280 }
281
282 #[test]
283 fn a_wildcard_route_is_left_out() {
284 let document = document(&router(), &info());
285 let paths = document.get("paths").unwrap().as_object().unwrap();
286
287 assert!(paths.keys().all(|path| !path.contains(":*")));
290 }
291
292 #[test]
293 fn methods_on_one_path_share_an_entry() {
294 let document = document(&router(), &info());
295 let users = document.get("paths./api/users").unwrap().as_object().unwrap();
296
297 assert!(users.contains_key("get"));
298 assert!(users.contains_key("post"));
299 }
300
301 #[test]
302 fn a_route_name_becomes_the_operation_id() {
303 let document = document(&router(), &info());
304
305 assert_eq!(
306 document.get("paths./api/users/{id}.get.operationId").unwrap().as_str(),
307 Some("users.show")
308 );
309 }
310
311 #[test]
312 fn path_parameters_are_required_and_query_parameters_are_not() {
313 let document = document(&router(), &info());
314
315 let show = document.get("paths./api/users/{id}.get.parameters").unwrap().as_array().unwrap();
316 assert_eq!(show[0].get("name").unwrap().as_str(), Some("id"));
317 assert_eq!(show[0].get("in").unwrap().as_str(), Some("path"));
318 assert_eq!(show[0].get("required").unwrap().as_bool(), Some(true));
319 assert_eq!(show[0].get("description").unwrap().as_str(), Some("The user's id"));
320
321 let index = document.get("paths./api/users.get.parameters").unwrap().as_array().unwrap();
322 assert_eq!(index[0].get("in").unwrap().as_str(), Some("query"));
323 assert_eq!(index[0].get("required").unwrap().as_bool(), Some(false));
324 }
325
326 #[test]
327 fn documented_responses_are_carried_over() {
328 let document = document(&router(), &info());
329 let responses = document.get("paths./api/users/{id}.get.responses").unwrap();
330
331 assert_eq!(responses.get("200.description").unwrap().as_str(), Some("The user"));
332 assert_eq!(responses.get("404.description").unwrap().as_str(), Some("No such user"));
333 }
334
335 #[test]
336 fn an_undocumented_route_still_produces_a_valid_operation() {
337 let document = document(&router(), &info());
338 let legacy = document.get("paths./api/legacy.get").unwrap();
339
340 assert!(legacy.get("responses.200").is_some());
342 assert_eq!(legacy.get("deprecated").unwrap().as_bool(), Some(true));
343 }
344
345 #[test]
346 fn the_document_carries_the_api_identity() {
347 let document = document(&router(), &info());
348
349 assert_eq!(document.get("openapi").unwrap().as_str(), Some("3.1.0"));
350 assert_eq!(document.get("info.title").unwrap().as_str(), Some("Orders API"));
351 assert_eq!(document.get("info.version").unwrap().as_str(), Some("2.1"));
352 }
353
354 #[tokio::test]
355 async fn the_document_and_the_page_are_served() {
356 use rustlavel_http::TestClient;
357
358 let mut router = router();
359 mount(&mut router, &info(), "/openapi.json");
360
361 let client = TestClient::new(router);
362
363 client
364 .get("/openapi.json")
365 .await
366 .assert_ok()
367 .assert_header("content-type", "application/json")
368 .assert_json("info.title", "Orders API");
369
370 client.get("/openapi").await.assert_ok().assert_see("Orders API");
371 }
372
373 #[test]
374 fn configuration_supplies_the_identity() {
375 let config = Config::new();
376 config.set("app.name", "Shop");
377 config.set("app.url", "https://shop.example.com");
378 config.set("openapi.version", "3.4");
379
380 let info = Info::from_config(&config);
381 assert_eq!(info.title, "Shop");
382 assert_eq!(info.version, "3.4");
383
384 let document = document(&router(), &info);
385 assert_eq!(
386 document.get("servers.0.url").unwrap().as_str(),
387 Some("https://shop.example.com")
388 );
389 }
390}