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