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