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}
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}