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    // OpenAPI has no field for a retirement date, so it goes in an extension
127    // — the `x-` prefix is the specification's own escape hatch — as the same
128    // HTTP-date the Sunset header carries.
129    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    // Anything documented that is not in the path is a query parameter.
159    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        // Every operation must document at least one response, so an
185        // undocumented route still produces a valid document.
186        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
206/// The routes that serve the document and the documentation page.
207///
208/// Registered *after* the application's own routes, because a document
209/// generated before them would describe an empty API. That ordering is why
210/// this is a function the `App` calls at the end rather than a plugin: a plugin
211/// cannot see what is registered after it.
212pub 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    // `/openapi.json` documents the API; `/openapi` is where a human reads it.
225    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        // OpenAPI cannot express "everything under here", so claiming to would
288        // be a lie rather than documentation.
289        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        // OpenAPI requires at least one response per operation.
341        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}