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