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