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::RemoteManifestResponse;
6use crate::response::decode_json_response;
7use platform_core::error::ErrorDetail;
8use platform_core::{AppError, AppResult, ErrorCode};
9use platform_module::{AdminSurface, Module, ModuleHttpRoute};
10use std::sync::Arc;
11use std::time::Duration;
12
13#[derive(Debug, Clone)]
14pub struct RemoteModuleSource {
15 client: reqwest::Client,
16 config: RemoteModuleConfig,
17}
18
19impl RemoteModuleSource {
20 pub fn new(config: RemoteModuleConfig) -> AppResult<Self> {
21 let client = reqwest::Client::builder()
22 .timeout(Duration::from_millis(config.timeout_ms))
23 .build()
24 .map_err(|error| {
25 AppError::new(
26 ErrorCode::Internal,
27 format!("failed to build remote module client: {error}"),
28 )
29 })?;
30 Ok(Self { client, config })
31 }
32
33 pub async fn load(&self) -> AppResult<Module> {
34 let manifest = self.fetch_manifest().await?;
35 if manifest.name != self.config.name {
36 return Err(AppError::new(
37 ErrorCode::Internal,
38 format!(
39 "remote module manifest name '{}' does not match configured name '{}'",
40 manifest.name, self.config.name
41 ),
42 ));
43 }
44 validate_remote_http_routes(&manifest.http_routes)?;
45 let binding = RemoteBinding::from_surfaces(
46 self.config.clone(),
47 manifest.runtime.as_ref(),
48 manifest.events.as_ref(),
49 )?;
50
51 let has_admin_data = match &manifest.admin {
52 Some(AdminSurface::Schema(_)) => true,
53 Some(AdminSurface::DeclarativeCustom(surface)) => surface.fallback_schema.is_some(),
54 _ => false,
55 };
56 let has_admin_actions = matches!(
57 &manifest.admin,
58 Some(AdminSurface::DeclarativeCustom(surface)) if !surface.actions.is_empty()
59 );
60 let mut module = Module::remote(manifest, Arc::new(binding));
61 if has_admin_data {
62 module =
63 module.with_admin_data(Arc::new(RemoteAdminDataSource::new(self.config.clone())?));
64 }
65 if has_admin_actions {
66 module = module
67 .with_admin_actions(Arc::new(RemoteAdminActionSource::new(self.config.clone())?));
68 }
69 Ok(module)
70 }
71
72 async fn fetch_manifest(&self) -> AppResult<RemoteManifestResponse> {
73 if self.config.transport == RemoteModuleTransport::Grpc {
74 return crate::grpc::fetch_manifest(&self.config).await;
75 }
76
77 let request = self
78 .client
79 .get(format!("{}/manifest", self.config.base_url));
80 let request = match &self.config.auth_token {
81 Some(token) => request.bearer_auth(token),
82 None => request,
83 };
84 let response = request.send().await.map_err(|error| {
85 AppError::new(
86 ErrorCode::ExternalDependency,
87 format!("remote manifest request failed: {error}"),
88 )
89 .retryable()
90 })?;
91
92 decode_json_response(response, "manifest", false)
93 .await?
94 .ok_or_else(|| AppError::new(ErrorCode::NotFound, "remote module manifest not found"))
95 }
96}
97
98fn validate_remote_http_routes(routes: &[ModuleHttpRoute]) -> AppResult<()> {
99 let mut details = Vec::new();
100 for (index, route) in routes.iter().enumerate() {
101 if !is_valid_remote_http_route_path(&route.path) {
102 details.push(ErrorDetail {
103 field: Some(format!("http_routes.{index}.path")),
104 reason: "remote HTTP route path must be module-local, start with '/', and not contain empty or '..' segments".to_owned(),
105 });
106 }
107 }
108
109 if details.is_empty() {
110 Ok(())
111 } else {
112 Err(AppError::validation(
113 "remote module manifest contains invalid HTTP route declarations",
114 details,
115 ))
116 }
117}
118
119fn is_valid_remote_http_route_path(path: &str) -> bool {
120 path.starts_with('/')
121 && !path.starts_with("//")
122 && !path.contains("://")
123 && !path.contains('?')
124 && !path.contains('#')
125 && path
126 .split('/')
127 .skip(1)
128 .all(|segment| !segment.is_empty() && segment != "." && segment != "..")
129}