platform_module_remote/
admin_data.rs1use crate::config::RemoteModuleConfig;
2use crate::config::RemoteModuleTransport;
3use crate::protocol::{RemoteGetResponse, RemoteListResponse, RemoteQueryResponse};
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::{AdminDataSource, AdminListQuery, AdminPage, AdminQuerySource};
9use serde_json::Value;
10use std::time::Duration;
11
12#[derive(Debug, Clone)]
13pub struct RemoteAdminDataSource {
14 client: reqwest::Client,
15 config: RemoteModuleConfig,
16}
17
18impl RemoteAdminDataSource {
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 async fn send_json<T: serde::de::DeserializeOwned>(
45 &self,
46 request: reqwest::RequestBuilder,
47 ) -> AppResult<Option<T>> {
48 let response = request.send().await.map_err(|error| {
49 AppError::new(
50 ErrorCode::ExternalDependency,
51 format!("remote module request failed: {error}"),
52 )
53 .retryable()
54 })?;
55
56 decode_json_response_with_policy(
57 response,
58 "admin data",
59 true,
60 ResponseBodyPolicy {
61 max_bytes: Some(MAX_REMOTE_JSON_RESPONSE_BYTES),
62 require_json_content_type: true,
63 allow_empty_success: false,
64 },
65 )
66 .await
67 }
68}
69
70#[async_trait::async_trait]
71impl AdminDataSource for RemoteAdminDataSource {
72 async fn list(&self, entity: &str, query: &AdminListQuery) -> AppResult<AdminPage> {
73 if self.config.transport == RemoteModuleTransport::Grpc {
74 return crate::grpc::list_admin_records(&self.config, entity, query)
75 .await
76 .map(Into::into);
77 }
78
79 let mut request = self
80 .request(reqwest::Method::GET, &format!("admin/{entity}"))
81 .query(&[("limit", query.limit.to_string())]);
82 if let Some(cursor) = &query.cursor {
83 request = request.query(&[("cursor", cursor)]);
84 }
85 let response = self
86 .send_json::<RemoteListResponse>(request)
87 .await?
88 .ok_or_else(|| AppError::new(ErrorCode::NotFound, "remote admin entity not found"))?;
89 Ok(response.into())
90 }
91
92 async fn get(&self, entity: &str, id: &str) -> AppResult<Option<Value>> {
93 if self.config.transport == RemoteModuleTransport::Grpc {
94 return match crate::grpc::get_admin_record(&self.config, entity, id).await {
95 Ok(response) => Ok(response.record),
96 Err(error) if error.code == ErrorCode::NotFound => Ok(None),
97 Err(error) => Err(error),
98 };
99 }
100
101 let request = self.request(reqwest::Method::GET, &format!("admin/{entity}/{id}"));
102 Ok(self
103 .send_json::<RemoteGetResponse>(request)
104 .await?
105 .and_then(|response| response.record))
106 }
107}
108
109#[async_trait::async_trait]
110impl AdminQuerySource for RemoteAdminDataSource {
111 async fn query(&self, query: &str) -> AppResult<Value> {
112 validate_admin_query_name(query)?;
113 if self.config.transport == RemoteModuleTransport::Grpc {
114 return crate::grpc::query_admin_value(&self.config, query)
115 .await
116 .map(|response| response.data);
117 }
118
119 let request = self.request(reqwest::Method::GET, &format!("admin/queries/{query}"));
120 let response = self
121 .send_json::<RemoteQueryResponse>(request)
122 .await?
123 .ok_or_else(|| AppError::new(ErrorCode::NotFound, "remote admin query not found"))?;
124 Ok(response.data)
125 }
126}
127
128fn validate_admin_query_name(query: &str) -> AppResult<()> {
129 let valid = !query.is_empty()
130 && query.chars().all(|character| {
131 character.is_ascii_alphanumeric()
132 || character == '.'
133 || character == '_'
134 || character == '-'
135 });
136 if valid {
137 return Ok(());
138 }
139
140 Err(AppError::new(
141 ErrorCode::Validation,
142 "remote admin query name must be a stable path segment",
143 ))
144}