Skip to main content

platform_provider/
proxy.rs

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}