Skip to main content

rustlavel_openapi/
lib.rs

1//! rustlavel-openapi: API documentation, generated from the routes themselves.
2//!
3//! The router already knows every path, method, and parameter; the route
4//! builder carries the prose. Nothing has to be repeated in a separate file,
5//! which is the reason hand-written API docs go stale.
6//!
7//! ```ignore
8//! App::new()?
9//!     .routes(routes::api::routes)
10//!     .plugin(OpenApi::new("Orders API", "1.0"))
11//! ```
12
13pub mod docs;
14
15use rustlavel_core::{Config, Json};
16use rustlavel_http::{Request, Response, Route, Router};
17
18/// What the generated document says about the API as a whole.
19#[derive(Debug, Clone)]
20pub struct Info {
21    pub title: String,
22    pub version: String,
23    pub description: Option<String>,
24    /// The base URL clients should call.
25    pub server: Option<String>,
26    /// Paths under this prefix are documented; everything else is skipped.
27    ///
28    /// Defaults to `/api`, because a browser-facing page is not an API and
29    /// documenting it produces noise nobody reads.
30    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
61/// Build an OpenAPI 3.1 document from a router.
62pub 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        // A wildcard route matches an open-ended family of paths; OpenAPI has
70        // no way to say that, so documenting one would be a lie.
71        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        // The route's name is stable and unique, which is exactly what an
117        // operationId has to be for a generated client to use it.
118        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    // Anything documented that is not in the path is a query parameter.
153    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        // Every operation must document at least one response, so an
179        // undocumented route still produces a valid document.
180        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
200/// The routes that serve the document and the documentation page.
201///
202/// Registered *after* the application's own routes, because a document
203/// generated before them would describe an empty API. That ordering is why
204/// this is a function the `App` calls at the end rather than a plugin: a plugin
205/// cannot see what is registered after it.
206pub 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    // `/openapi.json` documents the API; `/openapi` is where a human reads it.
219    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        // OpenAPI cannot express "everything under here", so claiming to would
282        // be a lie rather than documentation.
283        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        // OpenAPI requires at least one response per operation.
335        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}