Skip to main content

platform_module_remote/
admin_action.rs

1use crate::config::RemoteModuleConfig;
2use crate::config::RemoteModuleTransport;
3use crate::protocol::RemoteActionInvokeResponse;
4use crate::response::{
5    MAX_REMOTE_JSON_RESPONSE_BYTES, ResponseBodyPolicy, decode_json_response_with_policy,
6};
7use platform_core::{AppError, AppResult, ErrorCode};
8use platform_module::AdminActionSource;
9use serde_json::Value;
10use std::time::Duration;
11
12#[derive(Debug, Clone)]
13pub struct RemoteAdminActionSource {
14    client: reqwest::Client,
15    config: RemoteModuleConfig,
16}
17
18impl RemoteAdminActionSource {
19    pub fn new(config: RemoteModuleConfig) -> AppResult<Self> {
20        let client = reqwest::Client::builder()
21            .timeout(Duration::from_millis(config.timeout_ms))
22            .build()
23            .map_err(|error| {
24                AppError::new(
25                    ErrorCode::Internal,
26                    format!("failed to build remote module client: {error}"),
27                )
28            })?;
29        Ok(Self { client, config })
30    }
31
32    fn url(&self, path: &str) -> String {
33        format!("{}/{}", self.config.base_url, path.trim_start_matches('/'))
34    }
35
36    fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
37        let request = self.client.request(method, self.url(path));
38        match &self.config.auth_token {
39            Some(token) => request.bearer_auth(token),
40            None => request,
41        }
42    }
43}
44
45#[async_trait::async_trait]
46impl AdminActionSource for RemoteAdminActionSource {
47    async fn invoke(&self, action: &str, input: Value) -> AppResult<Value> {
48        validate_action_name(action)?;
49        if self.config.transport == RemoteModuleTransport::Grpc {
50            return crate::grpc::invoke_admin_action(&self.config, action, input)
51                .await
52                .map(|envelope| envelope.result);
53        }
54
55        let response = self
56            .request(reqwest::Method::POST, &format!("admin/actions/{action}"))
57            .json(&input)
58            .send()
59            .await
60            .map_err(|error| {
61                AppError::new(
62                    ErrorCode::ExternalDependency,
63                    format!("remote module action request failed: {error}"),
64                )
65                .retryable()
66            })?;
67
68        let envelope = decode_json_response_with_policy::<RemoteActionInvokeResponse>(
69            response,
70            "admin action",
71            true,
72            ResponseBodyPolicy {
73                max_bytes: Some(MAX_REMOTE_JSON_RESPONSE_BYTES),
74                require_json_content_type: true,
75                allow_empty_success: false,
76            },
77        )
78        .await?
79        .ok_or_else(|| AppError::new(ErrorCode::NotFound, "remote admin action not found"))?;
80        Ok(envelope.result)
81    }
82}
83
84fn validate_action_name(action: &str) -> AppResult<()> {
85    let valid = !action.is_empty()
86        && action.chars().all(|character| {
87            character.is_ascii_alphanumeric()
88                || character == '.'
89                || character == '_'
90                || character == '-'
91        });
92    if valid {
93        return Ok(());
94    }
95
96    Err(AppError::new(
97        ErrorCode::Validation,
98        "remote admin action name must be a stable path segment",
99    ))
100}