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
11#[derive(
12    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema,
13)]
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, schemars::JsonSchema)]
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(
44    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema,
45)]
46#[serde(rename_all = "snake_case")]
47pub enum ModuleRouteLintSeverity {
48    Ok,
49    Warning,
50    Error,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
54pub struct ModuleRouteLint {
55    pub severity: ModuleRouteLintSeverity,
56    pub subject: String,
57    pub message: String,
58    pub suggestion: String,
59}
60
61pub fn lint_module_http_routes(routes: &[ModuleHttpRoute]) -> Vec<ModuleRouteLint> {
62    if routes.is_empty() {
63        return vec![ModuleRouteLint {
64            severity: ModuleRouteLintSeverity::Ok,
65            subject: "routes".to_owned(),
66            message: "No HTTP interfaces are declared in this manifest.".to_owned(),
67            suggestion: "No action needed unless this module owns public HTTP routes.".to_owned(),
68        }];
69    }
70
71    let mut lints = Vec::new();
72    let mut route_counts = HashMap::<String, usize>::new();
73    for route in routes {
74        *route_counts.entry(route_identity(route)).or_default() += 1;
75    }
76
77    for (identity, count) in route_counts.iter().filter(|(_, count)| **count > 1) {
78        lints.push(ModuleRouteLint {
79            severity: ModuleRouteLintSeverity::Error,
80            subject: identity.clone(),
81            message: format!("{count} routes declare the same method and path."),
82            suggestion: "Keep one route declaration per method and path.".to_owned(),
83        });
84    }
85
86    for (index, route) in routes.iter().enumerate() {
87        let identity = route_identity(route);
88        if !present(route.display_name.as_deref()) {
89            lints.push(ModuleRouteLint {
90                severity: ModuleRouteLintSeverity::Warning,
91                subject: identity.clone(),
92                message: "Missing display_name for compact runtime story nodes.".to_owned(),
93                suggestion:
94                    "Add display_name to ModuleHttpRoute for compact story timeline labels."
95                        .to_owned(),
96            });
97        }
98        if !present(route.story_title.as_deref()) {
99            lints.push(ModuleRouteLint {
100                severity: ModuleRouteLintSeverity::Warning,
101                subject: identity.clone(),
102                message: "Missing story_title for direct HTTP entry stories.".to_owned(),
103                suggestion: "Add story_title when this route can be a direct business entry."
104                    .to_owned(),
105            });
106        }
107        if index == routes.len() - 1 && lints.is_empty() {
108            lints.push(ModuleRouteLint {
109                severity: ModuleRouteLintSeverity::Ok,
110                subject: "routes".to_owned(),
111                message: "Declared routes include display and story metadata.".to_owned(),
112                suggestion: "No action needed.".to_owned(),
113            });
114        }
115    }
116
117    lints
118}
119
120fn route_identity(route: &ModuleHttpRoute) -> String {
121    format!("{} {}", method_label(route.method), route.path)
122}
123
124fn present(value: Option<&str>) -> bool {
125    value.is_some_and(|value| !value.trim().is_empty())
126}
127
128fn method_label(method: ModuleHttpMethod) -> &'static str {
129    match method {
130        ModuleHttpMethod::Get => "GET",
131        ModuleHttpMethod::Post => "POST",
132        ModuleHttpMethod::Put => "PUT",
133        ModuleHttpMethod::Patch => "PATCH",
134        ModuleHttpMethod::Delete => "DELETE",
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn route(method: ModuleHttpMethod, path: &str) -> ModuleHttpRoute {
143        ModuleHttpRoute {
144            method,
145            path: path.to_owned(),
146            capability: None,
147            display_name: None,
148            story_title: None,
149            operation: None,
150        }
151    }
152
153    #[test]
154    fn routes_do_not_require_delivery_metadata() {
155        let mut route = route(ModuleHttpMethod::Post, "/v1/identity/users");
156        route.display_name = Some("Create User Request".to_owned());
157        route.story_title = Some("User Registration".to_owned());
158
159        assert_eq!(
160            lint_module_http_routes(&[route]),
161            vec![ModuleRouteLint {
162                severity: ModuleRouteLintSeverity::Ok,
163                subject: "routes".to_owned(),
164                message: "Declared routes include display and story metadata.".to_owned(),
165                suggestion: "No action needed.".to_owned(),
166            }]
167        );
168    }
169
170    #[test]
171    fn duplicate_routes_are_errors() {
172        assert_eq!(
173            lint_module_http_routes(&[
174                route(ModuleHttpMethod::Get, "/contacts/{id}"),
175                route(ModuleHttpMethod::Get, "/contacts/{id}"),
176            ],)[0],
177            ModuleRouteLint {
178                severity: ModuleRouteLintSeverity::Error,
179                subject: "GET /contacts/{id}".to_owned(),
180                message: "2 routes declare the same method and path.".to_owned(),
181                suggestion: "Keep one route declaration per method and path.".to_owned(),
182            }
183        );
184    }
185
186    #[test]
187    fn empty_routes_are_delivery_neutral() {
188        assert_eq!(
189            lint_module_http_routes(&[]),
190            vec![ModuleRouteLint {
191                severity: ModuleRouteLintSeverity::Ok,
192                subject: "routes".to_owned(),
193                message: "No HTTP interfaces are declared in this manifest.".to_owned(),
194                suggestion: "No action needed unless this module owns public HTTP routes."
195                    .to_owned(),
196            }]
197        );
198    }
199}