a2a_protocol_server/push/
tenant_postgres_config_store.rs1use std::future::Future;
14use std::pin::Pin;
15
16use a2a_protocol_types::error::{A2aError, A2aResult};
17use a2a_protocol_types::push::TaskPushNotificationConfig;
18use sqlx::postgres::PgPool;
19
20use super::config_store::PushConfigStore;
21use crate::store::tenant::TenantContext;
22
23#[derive(Debug, Clone)]
39pub struct TenantAwarePostgresPushConfigStore {
40 pool: PgPool,
41}
42
43fn to_a2a_error(e: &sqlx::Error) -> A2aError {
44 A2aError::internal(format!("postgres error: {e}"))
45}
46
47impl TenantAwarePostgresPushConfigStore {
48 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
54 let pool = sqlx::postgres::PgPoolOptions::new()
55 .max_connections(10)
56 .connect(url)
57 .await?;
58 Self::from_pool(pool).await
59 }
60
61 pub async fn from_pool(pool: PgPool) -> Result<Self, sqlx::Error> {
67 sqlx::query(
68 "CREATE TABLE IF NOT EXISTS tenant_push_configs (
69 tenant_id TEXT NOT NULL DEFAULT '',
70 task_id TEXT NOT NULL,
71 id TEXT NOT NULL,
72 data JSONB NOT NULL,
73 PRIMARY KEY (tenant_id, task_id, id)
74 )",
75 )
76 .execute(&pool)
77 .await?;
78
79 Ok(Self { pool })
80 }
81}
82
83#[allow(clippy::manual_async_fn)]
84impl PushConfigStore for TenantAwarePostgresPushConfigStore {
85 fn set<'a>(
86 &'a self,
87 mut config: TaskPushNotificationConfig,
88 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskPushNotificationConfig>> + Send + 'a>> {
89 Box::pin(async move {
90 let tenant = TenantContext::current();
91 let Some(task_id) = config.task_id.clone() else {
95 return Err(A2aError::invalid_params(
96 "taskId is required to store a push notification config",
97 ));
98 };
99 let id = config
100 .id
101 .clone()
102 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
103 config.id = Some(id.clone());
104
105 let data = serde_json::to_value(&config)
106 .map_err(|e| A2aError::internal(format!("serialize: {e}")))?;
107
108 sqlx::query(
109 "INSERT INTO tenant_push_configs (tenant_id, task_id, id, data)
110 VALUES ($1, $2, $3, $4)
111 ON CONFLICT(tenant_id, task_id, id) DO UPDATE SET data = EXCLUDED.data",
112 )
113 .bind(&tenant)
114 .bind(&task_id)
115 .bind(&id)
116 .bind(&data)
117 .execute(&self.pool)
118 .await
119 .map_err(|e| to_a2a_error(&e))?;
120
121 Ok(config)
122 })
123 }
124
125 fn get<'a>(
126 &'a self,
127 task_id: &'a str,
128 id: &'a str,
129 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<TaskPushNotificationConfig>>> + Send + 'a>>
130 {
131 Box::pin(async move {
132 let tenant = TenantContext::current();
133 let row: Option<(serde_json::Value,)> = sqlx::query_as(
134 "SELECT data FROM tenant_push_configs WHERE tenant_id = $1 AND task_id = $2 AND id = $3",
135 )
136 .bind(&tenant)
137 .bind(task_id)
138 .bind(id)
139 .fetch_optional(&self.pool)
140 .await
141 .map_err(|e| to_a2a_error(&e))?;
142
143 match row {
144 Some((data,)) => {
145 let config: TaskPushNotificationConfig = serde_json::from_value(data)
146 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))?;
147 Ok(Some(config))
148 }
149 None => Ok(None),
150 }
151 })
152 }
153
154 fn list<'a>(
155 &'a self,
156 task_id: &'a str,
157 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<TaskPushNotificationConfig>>> + Send + 'a>> {
158 Box::pin(async move {
159 let tenant = TenantContext::current();
160 let rows: Vec<(serde_json::Value,)> = sqlx::query_as(
161 "SELECT data FROM tenant_push_configs WHERE tenant_id = $1 AND task_id = $2",
162 )
163 .bind(&tenant)
164 .bind(task_id)
165 .fetch_all(&self.pool)
166 .await
167 .map_err(|e| to_a2a_error(&e))?;
168
169 rows.into_iter()
170 .map(|(data,)| {
171 serde_json::from_value(data)
172 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
173 })
174 .collect()
175 })
176 }
177
178 fn delete<'a>(
179 &'a self,
180 task_id: &'a str,
181 id: &'a str,
182 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
183 Box::pin(async move {
184 let tenant = TenantContext::current();
185 sqlx::query(
186 "DELETE FROM tenant_push_configs WHERE tenant_id = $1 AND task_id = $2 AND id = $3",
187 )
188 .bind(&tenant)
189 .bind(task_id)
190 .bind(id)
191 .execute(&self.pool)
192 .await
193 .map_err(|e| to_a2a_error(&e))?;
194 Ok(())
195 })
196 }
197
198 fn count(&self) -> Pin<Box<dyn Future<Output = A2aResult<Option<usize>>> + Send + '_>> {
199 Box::pin(async move {
200 let tenant = TenantContext::current();
202 let (total,): (i64,) =
203 sqlx::query_as("SELECT COUNT(*) FROM tenant_push_configs WHERE tenant_id = $1")
204 .bind(&tenant)
205 .fetch_one(&self.pool)
206 .await
207 .map_err(|e| to_a2a_error(&e))?;
208 Ok(Some(usize::try_from(total).unwrap_or(usize::MAX)))
209 })
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn to_a2a_error_formats_message() {
219 let pg_err = sqlx::Error::RowNotFound;
220 let a2a_err = to_a2a_error(&pg_err);
221 let msg = format!("{a2a_err}");
222 assert!(
223 msg.contains("postgres error"),
224 "error message should contain 'postgres error': {msg}"
225 );
226 }
227}