lenso-platform-module-remote 0.1.16

Remote module host support for the Lenso backend framework.
Documentation
use crate::config::RemoteModuleConfig;
use crate::config::RemoteModuleTransport;
use crate::protocol::RemoteActionInvokeResponse;
use crate::response::{
    MAX_REMOTE_JSON_RESPONSE_BYTES, ResponseBodyPolicy, decode_json_response_with_policy,
};
use platform_core::{AppError, AppResult, ErrorCode};
use platform_module::AdminActionSource;
use serde_json::Value;
use std::time::Duration;

#[derive(Debug, Clone)]
pub struct RemoteAdminActionSource {
    client: reqwest::Client,
    config: RemoteModuleConfig,
}

impl RemoteAdminActionSource {
    pub fn new(config: RemoteModuleConfig) -> AppResult<Self> {
        let client = reqwest::Client::builder()
            .timeout(Duration::from_millis(config.timeout_ms))
            .build()
            .map_err(|error| {
                AppError::new(
                    ErrorCode::Internal,
                    format!("failed to build remote module client: {error}"),
                )
            })?;
        Ok(Self { client, config })
    }

    fn url(&self, path: &str) -> String {
        format!("{}/{}", self.config.base_url, path.trim_start_matches('/'))
    }

    fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
        let request = self.client.request(method, self.url(path));
        match &self.config.auth_token {
            Some(token) => request.bearer_auth(token),
            None => request,
        }
    }
}

#[async_trait::async_trait]
impl AdminActionSource for RemoteAdminActionSource {
    async fn invoke(&self, action: &str, input: Value) -> AppResult<Value> {
        validate_action_name(action)?;
        if self.config.transport == RemoteModuleTransport::Grpc {
            return crate::grpc::invoke_admin_action(&self.config, action, input)
                .await
                .map(|envelope| envelope.result);
        }

        let response = self
            .request(reqwest::Method::POST, &format!("admin/actions/{action}"))
            .json(&input)
            .send()
            .await
            .map_err(|error| {
                AppError::new(
                    ErrorCode::ExternalDependency,
                    format!("remote module action request failed: {error}"),
                )
                .retryable()
            })?;

        let envelope = decode_json_response_with_policy::<RemoteActionInvokeResponse>(
            response,
            "admin action",
            true,
            ResponseBodyPolicy {
                max_bytes: Some(MAX_REMOTE_JSON_RESPONSE_BYTES),
                require_json_content_type: true,
                allow_empty_success: false,
            },
        )
        .await?
        .ok_or_else(|| AppError::new(ErrorCode::NotFound, "remote admin action not found"))?;
        Ok(envelope.result)
    }
}

fn validate_action_name(action: &str) -> AppResult<()> {
    let valid = !action.is_empty()
        && action.chars().all(|character| {
            character.is_ascii_alphanumeric()
                || character == '.'
                || character == '_'
                || character == '-'
        });
    if valid {
        return Ok(());
    }

    Err(AppError::new(
        ErrorCode::Validation,
        "remote admin action name must be a stable path segment",
    ))
}