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        }
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}