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//! ```
8//! # use rustlavel_openapi::Info;
9//! # use rustlavel_core::Config;
10//! # let config = Config::with_defaults();
11//! let info = Info::from_config(&config);   // openapi.title, openapi.version, openapi.prefix
12//! # assert!(!info.title.is_empty());
13//! ```
14//!
15//! and in `main.rs`, `App::new()?.routes(routes::api::routes).openapi(info)`.
16//!
17//! There is no plugin here and no `OpenApi` type. This crate generates a
18//! document rather than owning a piece of the application, so the meta-crate
19//! mounts it with `App::openapi` and [`mount`] does the same for a bare
20//! router. The paragraph that used to be here showed `.plugin(OpenApi::new(…))`
21//! — a call to a type that has never existed — and it was an `ignore` block,
22//! so nothing caught it. This one is compiled.
23
24pub mod docs;
25
26use rustlavel_core::{Config, Json};
27use rustlavel_http::{Request, Response, Route, Router};
28
29/// What the generated document says about the API as a whole.
30#[derive(Debug, Clone)]
31pub struct Info {
32    pub title: String,
33    pub version: String,
34    pub description: Option<String>,
35    /// The base URL clients should call.
36    pub server: Option<String>,
37    /// Paths under this prefix are documented; everything else is skipped.
38    ///
39    /// Defaults to `/api`, because a browser-facing page is not an API and
40    /// documenting it produces noise nobody reads.
41    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
72/// Build an OpenAPI 3.1 document from a router.
73pub 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        // A wildcard route matches an open-ended family of paths; OpenAPI has
81        // no way to say that, so documenting one would be a lie.
82        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        // The route's name is stable and unique, which is exactly what an
128        // operationId has to be for a generated client to use it.
129        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    // OpenAPI has no field for a retirement date, so it goes in an extension
138    // — the `x-` prefix is the specification's own escape hatch — as the same
139    // HTTP-date the Sunset header carries.
140    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    // Anything documented that is not in the path is a query parameter.
170    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        // Every operation must document at least one response, so an
196        // undocumented route still produces a valid document.
197        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
217/// The routes that serve the document and the documentation page.
218///
219/// Registered *after* the application's own routes, because a document
220/// generated before them would describe an empty API. That ordering is why
221/// this is a function the `App` calls at the end rather than a plugin: a plugin
222/// cannot see what is registered after it.
223pub 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    // `/openapi.json` documents the API; `/openapi` is where a human reads it.
236    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        // OpenAPI cannot express "everything under here", so claiming to would
299        // be a lie rather than documentation.
300        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        // OpenAPI requires at least one response per operation.
352        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}