1use 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 pub path: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub capability: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub display_name: Option<String>,
36 #[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}