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