1use 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 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(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}