Skip to main content

platform_module_remote/
source.rs

1use crate::admin_action::RemoteAdminActionSource;
2use crate::admin_data::RemoteAdminDataSource;
3use crate::binding::RemoteBinding;
4use crate::config::{RemoteModuleConfig, RemoteModuleTransport};
5use crate::protocol::{RemoteManifestEnvelope, RemoteManifestResponse};
6use crate::response::{
7    MAX_REMOTE_JSON_RESPONSE_BYTES, ResponseBodyPolicy, decode_json_response_with_policy,
8};
9use platform_core::error::ErrorDetail;
10use platform_core::{AppError, AppResult, ErrorCode};
11use platform_module::{
12    AdminDeclarativeComponent, AdminDeclarativeSurface, AdminSurface, Module, ModuleHttpRoute,
13};
14use std::sync::Arc;
15use std::time::Duration;
16
17#[derive(Debug, Clone)]
18pub struct RemoteModuleSource {
19    client: reqwest::Client,
20    config: RemoteModuleConfig,
21}
22
23#[derive(Debug)]
24pub struct LoadedRemoteModule {
25    pub module: Module,
26    pub config: RemoteModuleConfig,
27}
28
29impl RemoteModuleSource {
30    pub fn new(config: RemoteModuleConfig) -> AppResult<Self> {
31        let client = reqwest::Client::builder()
32            .timeout(Duration::from_millis(config.timeout_ms))
33            .build()
34            .map_err(|error| {
35                AppError::new(
36                    ErrorCode::Internal,
37                    format!("failed to build remote module client: {error}"),
38                )
39            })?;
40        Ok(Self { client, config })
41    }
42
43    pub async fn load(&self) -> AppResult<Module> {
44        let mut loaded = self.load_all().await?;
45        if loaded.len() == 1 {
46            return Ok(loaded.remove(0).module);
47        }
48        loaded
49            .into_iter()
50            .find(|loaded| loaded.module.manifest.name == self.config.name)
51            .map(|loaded| loaded.module)
52            .ok_or_else(|| {
53                AppError::new(
54                    ErrorCode::Internal,
55                    format!(
56                        "remote service '{}' did not provide a module named '{}'",
57                        self.config.name, self.config.name
58                    ),
59                )
60            })
61    }
62
63    pub async fn load_all(&self) -> AppResult<Vec<LoadedRemoteModule>> {
64        match self.fetch_manifest().await? {
65            RemoteManifestEnvelope::Module(manifest) => {
66                if manifest.name != self.config.name {
67                    return Err(AppError::new(
68                        ErrorCode::Internal,
69                        format!(
70                            "remote module manifest name '{}' does not match configured name '{}'",
71                            manifest.name, self.config.name
72                        ),
73                    ));
74                }
75                Ok(vec![self.load_module(manifest, self.config.clone())?])
76            }
77            RemoteManifestEnvelope::Service(service) => {
78                if service.name != self.config.name {
79                    return Err(AppError::new(
80                        ErrorCode::Internal,
81                        format!(
82                            "remote service manifest name '{}' does not match configured name '{}'",
83                            service.name, self.config.name
84                        ),
85                    ));
86                }
87                service
88                    .modules
89                    .into_iter()
90                    .map(|manifest| {
91                        let config = self.config.for_service_module(&manifest.name);
92                        self.load_module(manifest, config)
93                    })
94                    .collect()
95            }
96        }
97    }
98
99    fn load_module(
100        &self,
101        manifest: RemoteManifestResponse,
102        config: RemoteModuleConfig,
103    ) -> AppResult<LoadedRemoteModule> {
104        validate_remote_http_routes(&manifest.http_routes)?;
105        let binding = RemoteBinding::from_surfaces(
106            config.clone(),
107            manifest.runtime.as_ref(),
108            manifest.events.as_ref(),
109        )?;
110
111        let has_admin_data = match &manifest.admin {
112            Some(AdminSurface::Schema(_)) => true,
113            Some(AdminSurface::DeclarativeCustom(surface)) => surface.fallback_schema.is_some(),
114            _ => false,
115        };
116        let has_admin_actions = matches!(
117            &manifest.admin,
118            Some(AdminSurface::DeclarativeCustom(surface)) if !surface.actions.is_empty()
119        );
120        let has_admin_queries = matches!(
121            &manifest.admin,
122            Some(AdminSurface::DeclarativeCustom(surface)) if has_query_value_component(surface)
123        );
124        let mut module = Module::remote(manifest, Arc::new(binding));
125        if has_admin_data {
126            module = module.with_admin_data(Arc::new(RemoteAdminDataSource::new(config.clone())?));
127        }
128        if has_admin_actions {
129            module =
130                module.with_admin_actions(Arc::new(RemoteAdminActionSource::new(config.clone())?));
131        }
132        if has_admin_queries {
133            module =
134                module.with_admin_queries(Arc::new(RemoteAdminDataSource::new(config.clone())?));
135        }
136        Ok(LoadedRemoteModule { module, config })
137    }
138
139    async fn fetch_manifest(&self) -> AppResult<RemoteManifestEnvelope> {
140        if self.config.transport == RemoteModuleTransport::Grpc {
141            return crate::grpc::fetch_manifest(&self.config)
142                .await
143                .map(RemoteManifestEnvelope::Module);
144        }
145
146        let request = self
147            .client
148            .get(format!("{}/manifest", self.config.base_url));
149        let request = match &self.config.auth_token {
150            Some(token) => request.bearer_auth(token),
151            None => request,
152        };
153        let response = request.send().await.map_err(|error| {
154            AppError::new(
155                ErrorCode::ExternalDependency,
156                format!("remote manifest request failed: {error}"),
157            )
158            .retryable()
159        })?;
160
161        decode_json_response_with_policy(
162            response,
163            "manifest",
164            false,
165            ResponseBodyPolicy {
166                max_bytes: Some(MAX_REMOTE_JSON_RESPONSE_BYTES),
167                require_json_content_type: true,
168                allow_empty_success: false,
169            },
170        )
171        .await?
172        .ok_or_else(|| AppError::new(ErrorCode::NotFound, "remote module manifest not found"))
173    }
174}
175
176fn has_query_value_component(surface: &AdminDeclarativeSurface) -> bool {
177    surface.pages.iter().any(|page| {
178        page.sections.iter().any(|section| {
179            matches!(
180                section.component,
181                AdminDeclarativeComponent::QueryValue { .. }
182            )
183        })
184    })
185}
186
187fn validate_remote_http_routes(routes: &[ModuleHttpRoute]) -> AppResult<()> {
188    let mut details = Vec::new();
189    for (index, route) in routes.iter().enumerate() {
190        if !is_valid_remote_http_route_path(&route.path) {
191            details.push(ErrorDetail {
192                field: Some(format!("http_routes.{index}.path")),
193                reason: "remote HTTP route path must be module-local, start with '/', and not contain empty or '..' segments".to_owned(),
194            });
195        }
196    }
197
198    if details.is_empty() {
199        Ok(())
200    } else {
201        Err(AppError::validation(
202            "remote module manifest contains invalid HTTP route declarations",
203            details,
204        ))
205    }
206}
207
208fn is_valid_remote_http_route_path(path: &str) -> bool {
209    path.starts_with('/')
210        && !path.starts_with("//")
211        && !path.contains('\\')
212        && !path.contains("://")
213        && !path.contains('?')
214        && !path.contains('#')
215        && path
216            .split('/')
217            .skip(1)
218            .all(|segment| !segment.is_empty() && segment != "." && segment != "..")
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use platform_module::{ModuleHttpMethod, ModuleHttpRoute};
225
226    #[test]
227    fn manifest_routes_reject_backslashes() {
228        let route = ModuleHttpRoute {
229            method: ModuleHttpMethod::Get,
230            path: "/contacts\\..\\admin".to_owned(),
231            capability: Some("remote_crm.contacts.read".to_owned()),
232            display_name: None,
233            story_title: None,
234            operation: None,
235        };
236
237        assert!(validate_remote_http_routes(&[route]).is_err());
238    }
239}