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::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}