1use crate::config::{RemoteModuleConfig, RemoteModuleTransport};
2use platform_module::{Module, ModuleHttpMethod, ModuleHttpRoute, ModuleSource};
3use std::collections::{BTreeMap, HashSet};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct RemoteHttpProxyRegistry {
7 modules: BTreeMap<String, RemoteHttpProxyModule>,
8}
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct RemoteHttpProxyModule {
12 pub module_name: String,
13 pub base_url: String,
14 pub transport: RemoteModuleTransport,
15 pub timeout_ms: u64,
16 pub(crate) auth_token: Option<String>,
17 pub routes: Vec<RemoteHttpProxyRoute>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RemoteHttpProxyRoute {
22 pub method: ModuleHttpMethod,
23 pub declared_path: String,
24 pub capability: Option<String>,
25 pub display_name: Option<String>,
26 pub story_title: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RemoteHttpProxyMatch {
31 pub module_name: String,
32 pub base_url: String,
33 pub(crate) transport: RemoteModuleTransport,
34 pub(crate) timeout_ms: u64,
35 pub(crate) auth_token: Option<String>,
36 pub method: ModuleHttpMethod,
37 pub declared_path: String,
38 pub remote_path: String,
39 pub capability: Option<String>,
40 pub display_name: Option<String>,
41 pub story_title: Option<String>,
42 pub path_params: BTreeMap<String, String>,
43}
44
45impl RemoteHttpProxyRegistry {
46 #[must_use]
47 pub fn from_modules(modules: &[Module], configs: &[RemoteModuleConfig]) -> Self {
48 let config_by_name: BTreeMap<_, _> = configs
49 .iter()
50 .map(|config| (config.name.as_str(), config))
51 .collect();
52 let modules = modules
53 .iter()
54 .filter(|module| module.source == ModuleSource::Remote)
55 .filter_map(|module| {
56 let config = config_by_name.get(module.manifest.name.as_str())?;
57 let routes = module
58 .manifest
59 .http_routes
60 .iter()
61 .filter_map(RemoteHttpProxyRoute::from_manifest_route)
62 .collect::<Vec<_>>();
63 if routes.is_empty() {
64 return None;
65 }
66 Some((
67 module.manifest.name.clone(),
68 RemoteHttpProxyModule {
69 module_name: module.manifest.name.clone(),
70 base_url: config.base_url.clone(),
71 transport: config.transport,
72 timeout_ms: config.timeout_ms,
73 auth_token: config.auth_token.clone(),
74 routes,
75 },
76 ))
77 })
78 .collect();
79 Self { modules }
80 }
81
82 #[must_use]
83 pub fn is_empty(&self) -> bool {
84 self.modules.is_empty()
85 }
86
87 #[must_use]
88 pub fn modules(&self) -> impl Iterator<Item = &RemoteHttpProxyModule> {
89 self.modules.values()
90 }
91
92 #[must_use]
93 pub fn match_route(
94 &self,
95 module_name: &str,
96 method: ModuleHttpMethod,
97 request_path: &str,
98 ) -> Option<RemoteHttpProxyMatch> {
99 let module = self.modules.get(module_name)?;
100 let normalized_path = normalize_request_path(request_path)?;
101 module.routes.iter().find_map(|route| {
102 if route.method != method {
103 return None;
104 }
105 let path_params = match_declared_path(&route.declared_path, &normalized_path)?;
106 Some(RemoteHttpProxyMatch {
107 module_name: module.module_name.clone(),
108 base_url: module.base_url.clone(),
109 transport: module.transport,
110 timeout_ms: module.timeout_ms,
111 auth_token: module.auth_token.clone(),
112 method: route.method,
113 declared_path: route.declared_path.clone(),
114 remote_path: normalized_path.clone(),
115 capability: route.capability.clone(),
116 display_name: route.display_name.clone(),
117 story_title: route.story_title.clone(),
118 path_params,
119 })
120 })
121 }
122}
123
124impl RemoteHttpProxyRoute {
125 fn from_manifest_route(route: &ModuleHttpRoute) -> Option<Self> {
126 validate_declared_path_pattern(&route.path)?;
127 Some(Self {
128 method: route.method,
129 declared_path: route.path.clone(),
130 capability: route.capability.clone(),
131 display_name: route.display_name.clone(),
132 story_title: route.story_title.clone(),
133 })
134 }
135}
136
137fn validate_declared_path_pattern(path: &str) -> Option<()> {
138 let segments = normalized_segments(path)?;
139 let mut params = HashSet::new();
140 for segment in segments {
141 if is_parameter_segment(segment) {
142 let name = &segment[1..segment.len() - 1];
143 if name.is_empty() || !is_identifier(name) || !params.insert(name.to_owned()) {
144 return None;
145 }
146 } else if segment.contains('{') || segment.contains('}') || segment.contains('*') {
147 return None;
148 }
149 }
150 Some(())
151}
152
153fn match_declared_path(
154 declared_path: &str,
155 request_path: &str,
156) -> Option<BTreeMap<String, String>> {
157 let declared_segments = normalized_segments(declared_path)?;
158 let request_segments = normalized_segments(request_path)?;
159 if declared_segments.len() != request_segments.len() {
160 return None;
161 }
162
163 let mut params = BTreeMap::new();
164 for (declared, requested) in declared_segments.iter().zip(request_segments) {
165 if is_parameter_segment(declared) {
166 let name = &declared[1..declared.len() - 1];
167 params.insert(name.to_owned(), requested.to_owned());
168 } else if *declared != requested {
169 return None;
170 }
171 }
172 Some(params)
173}
174
175fn normalize_request_path(path: &str) -> Option<String> {
176 let segments = normalized_segments(path)?;
177 Some(format!("/{}", segments.join("/")))
178}
179
180fn normalized_segments(path: &str) -> Option<Vec<&str>> {
181 if !path.starts_with('/')
182 || path.starts_with("//")
183 || path.contains("://")
184 || path.contains('?')
185 || path.contains('#')
186 {
187 return None;
188 }
189 let segments = path.split('/').skip(1).collect::<Vec<_>>();
190 if segments.is_empty()
191 || segments
192 .iter()
193 .any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
194 {
195 return None;
196 }
197 Some(segments)
198}
199
200fn is_parameter_segment(segment: &str) -> bool {
201 segment.starts_with('{') && segment.ends_with('}')
202}
203
204fn is_identifier(value: &str) -> bool {
205 value
206 .chars()
207 .all(|ch| ch == '_' || ch == '-' || ch.is_ascii_alphanumeric())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use platform_module::{LinkedBinding, ModuleManifest};
214
215 fn route(method: ModuleHttpMethod, path: &str) -> ModuleHttpRoute {
216 ModuleHttpRoute {
217 method,
218 path: path.to_owned(),
219 capability: Some("remote_crm.contacts.read".to_owned()),
220 display_name: None,
221 story_title: None,
222 operation: None,
223 }
224 }
225
226 fn remote_module(name: &str, routes: Vec<ModuleHttpRoute>) -> Module {
227 Module::remote(
228 ModuleManifest::builder(name).http_routes(routes).build(),
229 std::sync::Arc::new(crate::RemoteBinding::default()),
230 )
231 }
232
233 #[test]
234 fn registry_includes_remote_modules_with_valid_routes() {
235 let modules = vec![
236 remote_module(
237 "remote-crm",
238 vec![
239 route(ModuleHttpMethod::Get, "/contacts"),
240 route(ModuleHttpMethod::Get, "/contacts/{id}"),
241 ],
242 ),
243 Module::linked(
244 ModuleManifest::builder("identity")
245 .http_routes(vec![route(ModuleHttpMethod::Get, "/users")])
246 .build(),
247 LinkedBinding::builder().build(),
248 ),
249 ];
250 let registry = RemoteHttpProxyRegistry::from_modules(
251 &modules,
252 &[RemoteModuleConfig::new(
253 "remote-crm",
254 "http://127.0.0.1:4100/lenso/module/v1",
255 )],
256 );
257
258 assert_eq!(registry.modules().count(), 1);
259 let module = registry.modules().next().expect("remote module");
260 assert_eq!(module.module_name, "remote-crm");
261 assert_eq!(module.routes.len(), 2);
262 }
263
264 #[test]
265 fn registry_preserves_configured_remote_auth_token() {
266 let registry = RemoteHttpProxyRegistry::from_modules(
267 &[remote_module(
268 "remote-crm",
269 vec![route(ModuleHttpMethod::Get, "/contacts/{id}")],
270 )],
271 &[
272 RemoteModuleConfig::new("remote-crm", "http://127.0.0.1:4100/lenso/module/v1")
273 .with_timeout_ms(250)
274 .with_auth_token("remote-secret"),
275 ],
276 );
277
278 let matched = registry
279 .match_route("remote-crm", ModuleHttpMethod::Get, "/contacts/contact_1")
280 .expect("route should match");
281 assert_eq!(matched.timeout_ms, 250);
282 assert_eq!(matched.auth_token.as_deref(), Some("remote-secret"));
283 }
284
285 #[test]
286 fn registry_includes_grpc_remote_modules() {
287 let registry = RemoteHttpProxyRegistry::from_modules(
288 &[remote_module(
289 "remote-crm",
290 vec![route(ModuleHttpMethod::Get, "/contacts")],
291 )],
292 &[RemoteModuleConfig::new(
293 "remote-crm",
294 "grpc://127.0.0.1:50051",
295 )],
296 );
297
298 let matched = registry
299 .match_route("remote-crm", ModuleHttpMethod::Get, "/contacts")
300 .expect("route should match");
301 assert_eq!(matched.transport, RemoteModuleTransport::Grpc);
302 assert_eq!(matched.base_url, "http://127.0.0.1:50051");
303 }
304
305 #[test]
306 fn matcher_extracts_single_segment_params() {
307 let registry = RemoteHttpProxyRegistry::from_modules(
308 &[remote_module(
309 "remote-crm",
310 vec![route(ModuleHttpMethod::Get, "/contacts/{id}")],
311 )],
312 &[RemoteModuleConfig::new(
313 "remote-crm",
314 "http://127.0.0.1:4100/lenso/module/v1",
315 )],
316 );
317
318 let matched = registry
319 .match_route("remote-crm", ModuleHttpMethod::Get, "/contacts/contact_1")
320 .expect("route should match");
321
322 assert_eq!(matched.declared_path, "/contacts/{id}");
323 assert_eq!(matched.remote_path, "/contacts/contact_1");
324 assert_eq!(
325 matched.path_params.get("id").map(String::as_str),
326 Some("contact_1")
327 );
328 assert_eq!(
329 matched.capability.as_deref(),
330 Some("remote_crm.contacts.read")
331 );
332 assert_eq!(matched.display_name, None);
333 assert_eq!(matched.story_title, None);
334 }
335
336 #[test]
337 fn matcher_preserves_route_display_metadata() {
338 let mut route = route(ModuleHttpMethod::Get, "/contacts/{id}");
339 route.display_name = Some("Fetch Contact".to_owned());
340 route.story_title = Some("Fetch Contact".to_owned());
341 let registry = RemoteHttpProxyRegistry::from_modules(
342 &[remote_module("remote-crm", vec![route])],
343 &[RemoteModuleConfig::new(
344 "remote-crm",
345 "http://127.0.0.1:4100/lenso/module/v1",
346 )],
347 );
348
349 let matched = registry
350 .match_route("remote-crm", ModuleHttpMethod::Get, "/contacts/contact_1")
351 .expect("route should match");
352
353 assert_eq!(matched.display_name.as_deref(), Some("Fetch Contact"));
354 assert_eq!(matched.story_title.as_deref(), Some("Fetch Contact"));
355 }
356
357 #[test]
358 fn matcher_rejects_wrong_method_module_and_shape() {
359 let registry = RemoteHttpProxyRegistry::from_modules(
360 &[remote_module(
361 "remote-crm",
362 vec![route(ModuleHttpMethod::Get, "/contacts/{id}")],
363 )],
364 &[RemoteModuleConfig::new(
365 "remote-crm",
366 "http://127.0.0.1:4100/lenso/module/v1",
367 )],
368 );
369
370 assert!(
371 registry
372 .match_route("remote-crm", ModuleHttpMethod::Post, "/contacts/contact_1")
373 .is_none()
374 );
375 assert!(
376 registry
377 .match_route("other", ModuleHttpMethod::Get, "/contacts/contact_1")
378 .is_none()
379 );
380 assert!(
381 registry
382 .match_route("remote-crm", ModuleHttpMethod::Get, "/contacts")
383 .is_none()
384 );
385 }
386
387 #[test]
388 fn registry_drops_invalid_declared_patterns() {
389 let registry = RemoteHttpProxyRegistry::from_modules(
390 &[remote_module(
391 "remote-crm",
392 vec![
393 route(ModuleHttpMethod::Get, "/contacts/{id}/{id}"),
394 route(ModuleHttpMethod::Get, "/contacts/{id"),
395 route(ModuleHttpMethod::Get, "/contacts/*tail"),
396 ],
397 )],
398 &[RemoteModuleConfig::new(
399 "remote-crm",
400 "http://127.0.0.1:4100/lenso/module/v1",
401 )],
402 );
403
404 assert!(registry.is_empty());
405 }
406
407 #[test]
408 fn matcher_rejects_unsafe_request_paths() {
409 let registry = RemoteHttpProxyRegistry::from_modules(
410 &[remote_module(
411 "remote-crm",
412 vec![route(ModuleHttpMethod::Get, "/contacts/{id}")],
413 )],
414 &[RemoteModuleConfig::new(
415 "remote-crm",
416 "http://127.0.0.1:4100/lenso/module/v1",
417 )],
418 );
419
420 for path in [
421 "contacts/contact_1",
422 "//contacts/contact_1",
423 "/contacts/../secret",
424 "/contacts/contact_1?x=1",
425 "/contacts/contact_1#frag",
426 ] {
427 assert!(
428 registry
429 .match_route("remote-crm", ModuleHttpMethod::Get, path)
430 .is_none(),
431 "{path} should not match"
432 );
433 }
434 }
435}