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