Skip to main content

platform_module_remote/
proxy.rs

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}