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::{RemoteGetResponse, RemoteListResponse, RemoteQueryResponse};
use crate::response::{
    MAX_REMOTE_JSON_RESPONSE_BYTES, ResponseBodyPolicy, decode_json_response_with_policy,
};
use platform_core::{AppError, AppResult, ErrorCode};
use platform_module::{AdminDataSource, AdminListQuery, AdminPage, AdminQuerySource};
use serde_json::Value;
use std::time::Duration;

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

impl RemoteAdminDataSource {
    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 fn send_json<T: serde::de::DeserializeOwned>(
        &self,
        request: reqwest::RequestBuilder,
    ) -> AppResult<Option<T>> {
        let response = request.send().await.map_err(|error| {
            AppError::new(
                ErrorCode::ExternalDependency,
                format!("remote module request failed: {error}"),
            )
            .retryable()
        })?;

        decode_json_response_with_policy(
            response,
            "admin data",
            true,
            ResponseBodyPolicy {
                max_bytes: Some(MAX_REMOTE_JSON_RESPONSE_BYTES),
                require_json_content_type: true,
                allow_empty_success: false,
            },
        )
        .await
    }
}

#[async_trait::async_trait]
impl AdminDataSource for RemoteAdminDataSource {
    async fn list(&self, entity: &str, query: &AdminListQuery) -> AppResult<AdminPage> {
        if self.config.transport == RemoteModuleTransport::Grpc {
            return crate::grpc::list_admin_records(&self.config, entity, query)
                .await
                .map(Into::into);
        }

        let mut request = self
            .request(reqwest::Method::GET, &format!("admin/{entity}"))
            .query(&[("limit", query.limit.to_string())]);
        if let Some(cursor) = &query.cursor {
            request = request.query(&[("cursor", cursor)]);
        }
        let response = self
            .send_json::<RemoteListResponse>(request)
            .await?
            .ok_or_else(|| AppError::new(ErrorCode::NotFound, "remote admin entity not found"))?;
        Ok(response.into())
    }

    async fn get(&self, entity: &str, id: &str) -> AppResult<Option<Value>> {
        if self.config.transport == RemoteModuleTransport::Grpc {
            return match crate::grpc::get_admin_record(&self.config, entity, id).await {
                Ok(response) => Ok(response.record),
                Err(error) if error.code == ErrorCode::NotFound => Ok(None),
                Err(error) => Err(error),
            };
        }

        let request = self.request(reqwest::Method::GET, &format!("admin/{entity}/{id}"));
        Ok(self
            .send_json::<RemoteGetResponse>(request)
            .await?
            .and_then(|response| response.record))
    }
}

#[async_trait::async_trait]
impl AdminQuerySource for RemoteAdminDataSource {
    async fn query(&self, query: &str) -> AppResult<Value> {
        validate_admin_query_name(query)?;
        if self.config.transport == RemoteModuleTransport::Grpc {
            return crate::grpc::query_admin_value(&self.config, query)
                .await
                .map(|response| response.data);
        }

        let request = self.request(reqwest::Method::GET, &format!("admin/queries/{query}"));
        let response = self
            .send_json::<RemoteQueryResponse>(request)
            .await?
            .ok_or_else(|| AppError::new(ErrorCode::NotFound, "remote admin query not found"))?;
        Ok(response.data)
    }
}

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

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