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 || path.contains('#')
187 {
188 return None;
189 }
190 let segments = path.split('/').skip(1).collect::<Vec<_>>();
191 if segments.is_empty()
192 || segments
193 .iter()
194 .any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
195 {
196 return None;
197 }
198 Some(segments)
199}
200
201fn is_parameter_segment(segment: &str) -> bool {
202 segment.starts_with('{') && segment.ends_with('}')
203}
204
205fn is_identifier(value: &str) -> bool {
206 value
207 .chars()
208 .all(|ch| ch == '_' || ch == '-' || ch.is_ascii_alphanumeric())
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use platform_module::{LinkedBinding, ModuleManifest};
215
216 fn route(method: ModuleHttpMethod, path: &str) -> ModuleHttpRoute {
217 ModuleHttpRoute {
218 method,
219 path: path.to_owned(),
220 capability: Some("remote_crm.contacts.read".to_owned()),
221 display_name: None,
222 story_title: None,
223 operation: None,
224 }
225 }
226
227 fn remote_module(name: &str, routes: Vec<ModuleHttpRoute>) -> Module {
228 Module::remote(
229 ModuleManifest::builder(name).http_routes(routes).build(),
230 std::sync::Arc::new(crate::RemoteBinding::default()),
231 )
232 }
233
234 #[test]
235 fn registry_includes_remote_modules_with_valid_routes() {
236 let modules = vec![
237 remote_module(
238 "remote-crm",
239 vec![
240 route(ModuleHttpMethod::Get, "/contacts"),
241 route(ModuleHttpMethod::Get, "/contacts/{id}"),
242 ],
243 ),
244 Module::linked(
245 ModuleManifest::builder("identity")
246 .http_routes(vec![route(ModuleHttpMethod::Get, "/users")])
247 .build(),
248 LinkedBinding::builder().build(),
249 ),
250 ];
251 let registry = RemoteHttpProxyRegistry::from_modules(
252 &modules,
253 &[RemoteModuleConfig::new(
254 "remote-crm",
255 "http://127.0.0.1:4100/lenso/module/v1",
256 )],
257 );
258
259 assert_eq!(registry.modules().count(), 1);
260 let module = registry.modules().next().expect("remote module");
261 assert_eq!(module.module_name, "remote-crm");
262 assert_eq!(module.routes.len(), 2);
263 }
264
265 #[test]
266 fn registry_preserves_configured_remote_auth_token() {
267 let registry = RemoteHttpProxyRegistry::from_modules(
268 &[remote_module(
269 "remote-crm",
270 vec![route(ModuleHttpMethod::Get, "/contacts/{id}")],
271 )],
272 &[
273 RemoteModuleConfig::new("remote-crm", "http://127.0.0.1:4100/lenso/module/v1")
274 .with_timeout_ms(250)
275 .with_auth_token("remote-secret"),
276 ],
277 );
278
279 let matched = registry
280 .match_route("remote-crm", ModuleHttpMethod::Get, "/contacts/contact_1")
281 .expect("route should match");
282 assert_eq!(matched.timeout_ms, 250);
283 assert_eq!(matched.auth_token.as_deref(), Some("remote-secret"));
284 }
285
286 #[test]
287 fn registry_includes_grpc_remote_modules() {
288 let registry = RemoteHttpProxyRegistry::from_modules(
289 &[remote_module(
290 "remote-crm",
291 vec![route(ModuleHttpMethod::Get, "/contacts")],
292 )],
293 &[RemoteModuleConfig::new(
294 "remote-crm",
295 "grpc://127.0.0.1:50051",
296 )],
297 );
298
299 let matched = registry
300 .match_route("remote-crm", ModuleHttpMethod::Get, "/contacts")
301 .expect("route should match");
302 assert_eq!(matched.transport, RemoteModuleTransport::Grpc);
303 assert_eq!(matched.base_url, "http://127.0.0.1:50051");
304 }
305
306 #[test]
307 fn matcher_extracts_single_segment_params() {
308 let registry = RemoteHttpProxyRegistry::from_modules(
309 &[remote_module(
310 "remote-crm",
311 vec![route(ModuleHttpMethod::Get, "/contacts/{id}")],
312 )],
313 &[RemoteModuleConfig::new(
314 "remote-crm",
315 "http://127.0.0.1:4100/lenso/module/v1",
316 )],
317 );
318
319 let matched = registry
320 .match_route("remote-crm", ModuleHttpMethod::Get, "/contacts/contact_1")
321 .expect("route should match");
322
323 assert_eq!(matched.declared_path, "/contacts/{id}");
324 assert_eq!(matched.remote_path, "/contacts/contact_1");
325 assert_eq!(
326 matched.path_params.get("id").map(String::as_str),
327 Some("contact_1")
328 );
329 assert_eq!(
330 matched.capability.as_deref(),
331 Some("remote_crm.contacts.read")
332 );
333 assert_eq!(matched.display_name, None);
334 assert_eq!(matched.story_title, None);
335 }
336
337 #[test]
338 fn matcher_preserves_route_display_metadata() {
339 let mut route = route(ModuleHttpMethod::Get, "/contacts/{id}");
340 route.display_name = Some("Fetch Contact".to_owned());
341 route.story_title = Some("Fetch Contact".to_owned());
342 let registry = RemoteHttpProxyRegistry::from_modules(
343 &[remote_module("remote-crm", vec![route])],
344 &[RemoteModuleConfig::new(
345 "remote-crm",
346 "http://127.0.0.1:4100/lenso/module/v1",
347 )],
348 );
349
350 let matched = registry
351 .match_route("remote-crm", ModuleHttpMethod::Get, "/contacts/contact_1")
352 .expect("route should match");
353
354 assert_eq!(matched.display_name.as_deref(), Some("Fetch Contact"));
355 assert_eq!(matched.story_title.as_deref(), Some("Fetch Contact"));
356 }
357
358 #[test]
359 fn matcher_rejects_wrong_method_module_and_shape() {
360 let registry = RemoteHttpProxyRegistry::from_modules(
361 &[remote_module(
362 "remote-crm",
363 vec![route(ModuleHttpMethod::Get, "/contacts/{id}")],
364 )],
365 &[RemoteModuleConfig::new(
366 "remote-crm",
367 "http://127.0.0.1:4100/lenso/module/v1",
368 )],
369 );
370
371 assert!(
372 registry
373 .match_route("remote-crm", ModuleHttpMethod::Post, "/contacts/contact_1")
374 .is_none()
375 );
376 assert!(
377 registry
378 .match_route("other", ModuleHttpMethod::Get, "/contacts/contact_1")
379 .is_none()
380 );
381 assert!(
382 registry
383 .match_route("remote-crm", ModuleHttpMethod::Get, "/contacts")
384 .is_none()
385 );
386 }
387
388 #[test]
389 fn registry_drops_invalid_declared_patterns() {
390 let registry = RemoteHttpProxyRegistry::from_modules(
391 &[remote_module(
392 "remote-crm",
393 vec![
394 route(ModuleHttpMethod::Get, "/contacts/{id}/{id}"),
395 route(ModuleHttpMethod::Get, "/contacts/{id"),
396 route(ModuleHttpMethod::Get, "/contacts/*tail"),
397 ],
398 )],
399 &[RemoteModuleConfig::new(
400 "remote-crm",
401 "http://127.0.0.1:4100/lenso/module/v1",
402 )],
403 );
404
405 assert!(registry.is_empty());
406 }
407
408 #[test]
409 fn matcher_rejects_unsafe_request_paths() {
410 let registry = RemoteHttpProxyRegistry::from_modules(
411 &[remote_module(
412 "remote-crm",
413 vec![route(ModuleHttpMethod::Get, "/contacts/{id}")],
414 )],
415 &[RemoteModuleConfig::new(
416 "remote-crm",
417 "http://127.0.0.1:4100/lenso/module/v1",
418 )],
419 );
420
421 for path in [
422 "contacts/contact_1",
423 "//contacts/contact_1",
424 "/contacts/../secret",
425 "/contacts/..\\admin",
426 "/contacts\\..\\admin",
427 "/contacts/\\evil.example",
428 "/contacts/contact_1?x=1",
429 "/contacts/contact_1#frag",
430 ] {
431 assert!(
432 registry
433 .match_route("remote-crm", ModuleHttpMethod::Get, path)
434 .is_none(),
435 "{path} should not match"
436 );
437 }
438 }
439}