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