Skip to main content

siderite_cli/
routes.rs

1//! `routes`: the table of `METHOD PATH operation_id` an app serves.
2//!
3//! Rows come from the app's OpenAPI document, so mounted apps appear under
4//! their prefix and endpoints marked `hidden` are left out.
5
6use crate::error::CliError;
7use crate::report::build::route_reports;
8use crate::report::text::render_route_rows;
9use siderite_core::App;
10
11/// One row of the route table.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct RouteRow {
14    /// Upper-case HTTP method.
15    pub method: String,
16    /// Full path template, including mount prefixes.
17    pub path: String,
18    /// The operation id, if the endpoint has one.
19    pub operation_id: Option<String>,
20}
21
22/// Every documented route of `app`, sorted by path, then method.
23///
24/// # Errors
25/// [`CliError::OpenApi`] when the OpenAPI document cannot be generated (for
26/// example on a duplicate operation).
27pub fn route_table(app: &App) -> Result<Vec<RouteRow>, CliError> {
28    Ok(route_reports(app)?
29        .into_iter()
30        .map(|r| RouteRow {
31            method: r.method,
32            path: r.path,
33            operation_id: r.operation_id,
34        })
35        .collect())
36}
37
38/// Render `rows` as an aligned text table, one route per line.
39pub fn render_routes(rows: &[RouteRow]) -> String {
40    render_route_rows(rows.iter().map(|r| {
41        (
42            r.method.as_str(),
43            r.path.as_str(),
44            r.operation_id.as_deref(),
45        )
46    }))
47}
48
49#[cfg(test)]
50mod tests {
51    #![allow(clippy::unwrap_used)]
52
53    use super::*;
54    use siderite_core::{get, post};
55
56    fn app() -> App {
57        let v1 = App::new()
58            .route(
59                "/users",
60                post(|| async { "c" })
61                    .operation_id("create_user")
62                    .get(|| async { "l" }),
63            )
64            .route("/", get(|| async { "root" }));
65        App::new()
66            .route("/health", get(|| async { "ok" }).operation_id("health"))
67            .route("/secret", get(|| async { "s" }).hidden())
68            .mount("/api/v1", v1)
69    }
70
71    #[test]
72    fn lists_mounts_sorted_and_skips_hidden() {
73        let rows = route_table(&app()).unwrap();
74        let summary: Vec<_> = rows
75            .iter()
76            .map(|r| {
77                (
78                    r.method.as_str(),
79                    r.path.as_str(),
80                    r.operation_id.as_deref(),
81                )
82            })
83            .collect();
84        assert_eq!(
85            summary,
86            [
87                ("GET", "/api/v1", None),
88                ("GET", "/api/v1/users", None),
89                ("POST", "/api/v1/users", Some("create_user")),
90                ("GET", "/health", Some("health")),
91            ]
92        );
93    }
94
95    #[test]
96    fn duplicate_operations_are_an_error() {
97        let app = App::new()
98            .route("/a", get(|| async { "a" }).operation_id("same"))
99            .route("/b", get(|| async { "b" }).operation_id("same"));
100        let err = route_table(&app).unwrap_err();
101        assert!(err.to_string().contains("same"), "{err}");
102    }
103
104    #[test]
105    fn table_is_aligned() {
106        let text = render_routes(&route_table(&app()).unwrap());
107        let lines: Vec<&str> = text.lines().collect();
108        assert_eq!(lines.len(), 4);
109        assert_eq!(lines[2], "POST  /api/v1/users  create_user");
110        assert_eq!(lines[3], "GET   /health        health");
111        assert_eq!(lines[0], "GET   /api/v1        -");
112    }
113
114    #[test]
115    fn empty_app_has_no_routes() {
116        assert_eq!(
117            render_routes(&route_table(&App::new()).unwrap()),
118            "No routes.\n"
119        );
120    }
121}