Skip to main content

lenso_contracts/
http.rs

1//! Pure-data HTTP route declarations for module manifests.
2//!
3//! These declarations are metadata only. Linked modules still contribute real
4//! Axum/OpenAPI routes through `lenso-bootstrap`; remote route proxying requires a
5//! separate host protocol before these entries can be mounted.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use utoipa::ToSchema;
10
11use crate::module_source::ModuleSource;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
14#[serde(rename_all = "UPPERCASE")]
15#[non_exhaustive]
16pub enum ModuleHttpMethod {
17    Get,
18    Post,
19    Put,
20    Patch,
21    Delete,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
25pub struct ModuleHttpRoute {
26    pub method: ModuleHttpMethod,
27    /// Module-local path, e.g. `/contacts` or `/contacts/{id}`.
28    pub path: String,
29    /// Optional capability required before a future host proxy exposes this
30    /// route. No enforcement exists until the proxy protocol is implemented.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub capability: Option<String>,
33    /// Compact label used when this route appears as a runtime story node.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub display_name: Option<String>,
36    /// Optional story title for direct requests to this route.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub story_title: Option<String>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
42#[serde(rename_all = "snake_case")]
43pub enum ModuleRouteLintSeverity {
44    Ok,
45    Warning,
46    Error,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
50pub struct ModuleRouteLint {
51    pub severity: ModuleRouteLintSeverity,
52    pub subject: String,
53    pub message: String,
54    pub suggestion: String,
55}
56
57pub fn lint_module_http_routes(
58    source: ModuleSource,
59    routes: &[ModuleHttpRoute],
60) -> Vec<ModuleRouteLint> {
61    if routes.is_empty() {
62        return vec![ModuleRouteLint {
63            severity: if source == ModuleSource::Remote {
64                ModuleRouteLintSeverity::Warning
65            } else {
66                ModuleRouteLintSeverity::Ok
67            },
68            subject: "routes".to_owned(),
69            message: "No HTTP interfaces are declared in this manifest.".to_owned(),
70            suggestion: if source == ModuleSource::Remote {
71                "Add ModuleHttpRoute declarations for remote HTTP interfaces that should be visible to the host."
72            } else {
73                "No action needed unless this linked module owns public HTTP routes."
74            }
75            .to_owned(),
76        }];
77    }
78
79    let mut lints = Vec::new();
80    let mut route_counts = HashMap::<String, usize>::new();
81    for route in routes {
82        *route_counts.entry(route_identity(route)).or_default() += 1;
83    }
84
85    for (identity, count) in route_counts.iter().filter(|(_, count)| **count > 1) {
86        lints.push(ModuleRouteLint {
87            severity: ModuleRouteLintSeverity::Error,
88            subject: identity.clone(),
89            message: format!("{count} routes declare the same method and path."),
90            suggestion: "Keep one route declaration per method and path.".to_owned(),
91        });
92    }
93
94    for (index, route) in routes.iter().enumerate() {
95        let identity = route_identity(route);
96        if !present(route.display_name.as_deref()) {
97            lints.push(ModuleRouteLint {
98                severity: ModuleRouteLintSeverity::Warning,
99                subject: identity.clone(),
100                message: "Missing display_name for compact runtime story nodes.".to_owned(),
101                suggestion:
102                    "Add display_name to ModuleHttpRoute for compact story timeline labels."
103                        .to_owned(),
104            });
105        }
106        if !present(route.story_title.as_deref()) {
107            lints.push(ModuleRouteLint {
108                severity: ModuleRouteLintSeverity::Warning,
109                subject: identity.clone(),
110                message: "Missing story_title for direct HTTP entry stories.".to_owned(),
111                suggestion: "Add story_title when this route can be a direct business entry."
112                    .to_owned(),
113            });
114        }
115        if source == ModuleSource::Remote && !present(route.capability.as_deref()) {
116            lints.push(ModuleRouteLint {
117                severity: ModuleRouteLintSeverity::Warning,
118                subject: identity.clone(),
119                message: "Missing capability declaration for host proxy authorization.".to_owned(),
120                suggestion:
121                    "Remote routes should declare the capability used by host proxy authorization."
122                        .to_owned(),
123            });
124        }
125
126        if index == routes.len() - 1 && lints.is_empty() {
127            lints.push(ModuleRouteLint {
128                severity: ModuleRouteLintSeverity::Ok,
129                subject: "routes".to_owned(),
130                message: if source == ModuleSource::Remote {
131                    "Declared routes include display, story, and capability metadata."
132                } else {
133                    "Declared routes include display and story metadata."
134                }
135                .to_owned(),
136                suggestion: "No action needed.".to_owned(),
137            });
138        }
139    }
140
141    lints
142}
143
144fn route_identity(route: &ModuleHttpRoute) -> String {
145    format!("{} {}", method_label(route.method), route.path)
146}
147
148fn present(value: Option<&str>) -> bool {
149    value.is_some_and(|value| !value.trim().is_empty())
150}
151
152fn method_label(method: ModuleHttpMethod) -> &'static str {
153    match method {
154        ModuleHttpMethod::Get => "GET",
155        ModuleHttpMethod::Post => "POST",
156        ModuleHttpMethod::Put => "PUT",
157        ModuleHttpMethod::Patch => "PATCH",
158        ModuleHttpMethod::Delete => "DELETE",
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn route(method: ModuleHttpMethod, path: &str) -> ModuleHttpRoute {
167        ModuleHttpRoute {
168            method,
169            path: path.to_owned(),
170            capability: None,
171            display_name: None,
172            story_title: None,
173        }
174    }
175
176    #[test]
177    fn linked_routes_do_not_require_capability() {
178        let mut route = route(ModuleHttpMethod::Post, "/v1/identity/users");
179        route.display_name = Some("Create User Request".to_owned());
180        route.story_title = Some("User Registration".to_owned());
181
182        assert_eq!(
183            lint_module_http_routes(ModuleSource::Linked, &[route]),
184            vec![ModuleRouteLint {
185                severity: ModuleRouteLintSeverity::Ok,
186                subject: "routes".to_owned(),
187                message: "Declared routes include display and story metadata.".to_owned(),
188                suggestion: "No action needed.".to_owned(),
189            }]
190        );
191    }
192
193    #[test]
194    fn remote_routes_require_capability() {
195        let mut route = route(ModuleHttpMethod::Get, "/contacts/{id}");
196        route.display_name = Some("Fetch Contact".to_owned());
197        route.story_title = Some("Fetch Contact".to_owned());
198
199        assert_eq!(
200            lint_module_http_routes(ModuleSource::Remote, &[route]),
201            vec![ModuleRouteLint {
202                severity: ModuleRouteLintSeverity::Warning,
203                subject: "GET /contacts/{id}".to_owned(),
204                message: "Missing capability declaration for host proxy authorization.".to_owned(),
205                suggestion:
206                    "Remote routes should declare the capability used by host proxy authorization."
207                        .to_owned(),
208            }]
209        );
210    }
211
212    #[test]
213    fn duplicate_routes_are_errors() {
214        assert_eq!(
215            lint_module_http_routes(
216                ModuleSource::Remote,
217                &[
218                    route(ModuleHttpMethod::Get, "/contacts/{id}"),
219                    route(ModuleHttpMethod::Get, "/contacts/{id}"),
220                ],
221            )[0],
222            ModuleRouteLint {
223                severity: ModuleRouteLintSeverity::Error,
224                subject: "GET /contacts/{id}".to_owned(),
225                message: "2 routes declare the same method and path.".to_owned(),
226                suggestion: "Keep one route declaration per method and path.".to_owned(),
227            }
228        );
229    }
230
231    #[test]
232    fn remote_empty_routes_are_warnings() {
233        assert_eq!(
234            lint_module_http_routes(ModuleSource::Remote, &[]),
235            vec![ModuleRouteLint {
236                severity: ModuleRouteLintSeverity::Warning,
237                subject: "routes".to_owned(),
238                message: "No HTTP interfaces are declared in this manifest.".to_owned(),
239                suggestion:
240                    "Add ModuleHttpRoute declarations for remote HTTP interfaces that should be visible to the host."
241                        .to_owned(),
242            }]
243        );
244    }
245}