platform_module_remote/
source.rs1use 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}