Skip to content

Commit 3f89c77

Browse files
authored
feat: Implement a RDMS backed PushNotificationConfigStore (#301)
Interaction with the RDBMS happens with JPA. Also renamed the JPA TaskStore AgentCard and AgentExecutor test producers since I am not a fan of having lots of classes with the same name
1 parent c5d6dd2 commit 3f89c77

27 files changed

Lines changed: 1210 additions & 9 deletions

File tree

extras/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ This directory contains additions to what is provided by the default SDK impleme
44

55
Please see the README's of each child directory for more details.
66

7-
[`taskstore-database-jpa`](./taskstore-database-jpa/README.md) - Replaces the default `InMemoryTaskStore` with a `TaskStore` backed by a RDBMS. It uses JPA to interact with the RDBMS.
7+
[`task-store-database-jpa`](./task-store-database-jpa/README.md) - Replaces the default `InMemoryTaskStore` with a `TaskStore` backed by a RDBMS. It uses JPA to interact with the RDBMS.
8+
[`push-notification-config-store-database-jpa`](./push-notification-config-store-database-jpa/README.md) - Replaces the default `InMemoryPushNotificationConfigStore` with a `PushNotificationConfigStore` backed by a RDBMS. It uses JPA to interact with the RDBMS.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# A2A Java SDK - JPA Database PushNotificationConfigStore
2+
3+
This module provides a JPA-based implementation of the `PushNotificationConfigStore` interface that persists push notification configurations to a relational database instead of keeping them in memory.
4+
5+
The persistence is done with the Jakarta Persistence API, so this should be suitable for any JPA 3.0+ provider and Jakarta EE application server.
6+
7+
## Quick Start
8+
9+
### 1. Add Dependency
10+
11+
Add this module to your project's `pom.xml`:
12+
13+
```xml
14+
<dependency>
15+
<groupId>io.github.a2asdk</groupId>
16+
<artifactId>a2a-java-extras-push-notification-config-store-database-jpa</artifactId>
17+
<version>${a2a.version}</version>
18+
</dependency>
19+
```
20+
21+
The `JpaDatabasePushNotificationConfigStore` is annotated in such a way that it should take precedence over the default `InMemoryPushNotificationConfigStore`. Hence, it is a drop-in replacement.
22+
23+
### 2. Configure Database
24+
25+
The following examples assume you are using PostgreSQL as your database. To use another database, adjust as needed for your environment.
26+
27+
#### For Quarkus Reference Servers
28+
29+
Add to your `application.properties`:
30+
31+
```properties
32+
# Database configuration
33+
quarkus.datasource.db-kind=postgresql
34+
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/a2a_db
35+
quarkus.datasource.username=your_username
36+
quarkus.datasource.password=your_password
37+
38+
# Hibernate configuration
39+
quarkus.hibernate-orm.database.generation=update
40+
```
41+
42+
#### For WildFly/Jakarta EE Servers
43+
44+
Create or update your `persistence.xml`:
45+
46+
```xml
47+
<?xml version="1.0" encoding="UTF-8"?>
48+
<persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.0">
49+
<persistence-unit name="a2a-java" transaction-type="JTA">
50+
<jta-data-source>java:jboss/datasources/A2ADataSource</jta-data-source>
51+
52+
<class>io.a2a.extras.pushnotificationconfigstore.database.jpa.JpaPushNotificationConfig</class>
53+
<exclude-unlisted-classes>true</exclude-unlisted-classes>
54+
55+
<properties>
56+
<!-- Change as required for your environment -->
57+
<property name="jakarta.persistence.schema-generation.database.action" value="create"/>
58+
<property name="hibernate.dialect" value="org.hibernate.dialect.PostgreSQLDialect"/>
59+
</properties>
60+
</persistence-unit>
61+
</persistence>
62+
```
63+
64+
### 3. Database Schema
65+
66+
The module will automatically create the required table, which uses a composite primary key:
67+
68+
```sql
69+
CREATE TABLE a2a_push_notification_configs (
70+
task_id VARCHAR(255) NOT NULL,
71+
config_id VARCHAR(255) NOT NULL,
72+
task_data TEXT NOT NULL,
73+
PRIMARY KEY (task_id, config_id)
74+
);
75+
```
76+
77+
## Configuration Options
78+
79+
### Persistence Unit Name
80+
81+
The module uses the persistence unit name `"a2a-java"`. Ensure your `persistence.xml` defines a persistence unit with this name.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?xml version="1.0"?>
2+
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"
3+
xmlns="http://maven.apache.org/POM/4.0.0"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<parent>
8+
<groupId>io.github.a2asdk</groupId>
9+
<artifactId>a2a-java-sdk-parent</artifactId>
10+
<version>0.3.0.Beta2-SNAPSHOT</version>
11+
<relativePath>../../pom.xml</relativePath>
12+
</parent>
13+
<artifactId>a2a-java-extras-push-notification-config-store-database-jpa</artifactId>
14+
15+
<packaging>jar</packaging>
16+
17+
<name>Java A2A Extras: JPA Database PushNotificationConfigStore</name>
18+
<description>Java SDK for the Agent2Agent Protocol (A2A) - Extras - JPA Database PushNotificationConfigStore</description>
19+
20+
<dependencies>
21+
<dependency>
22+
<groupId>${project.groupId}</groupId>
23+
<artifactId>a2a-java-sdk-server-common</artifactId>
24+
</dependency>
25+
<dependency>
26+
<groupId>jakarta.annotation</groupId>
27+
<artifactId>jakarta.annotation-api</artifactId>
28+
</dependency>
29+
<dependency>
30+
<groupId>jakarta.enterprise</groupId>
31+
<artifactId>jakarta.enterprise.cdi-api</artifactId>
32+
</dependency>
33+
<dependency>
34+
<groupId>jakarta.inject</groupId>
35+
<artifactId>jakarta.inject-api</artifactId>
36+
</dependency>
37+
<dependency>
38+
<groupId>jakarta.persistence</groupId>
39+
<artifactId>jakarta.persistence-api</artifactId>
40+
</dependency>
41+
<dependency>
42+
<groupId>org.slf4j</groupId>
43+
<artifactId>slf4j-api</artifactId>
44+
</dependency>
45+
46+
<dependency>
47+
<groupId>io.quarkus</groupId>
48+
<artifactId>quarkus-junit5</artifactId>
49+
<scope>test</scope>
50+
</dependency>
51+
<dependency>
52+
<groupId>io.quarkus</groupId>
53+
<artifactId>quarkus-rest-client-jackson</artifactId>
54+
<scope>test</scope>
55+
</dependency>
56+
<dependency>
57+
<groupId>org.junit.jupiter</groupId>
58+
<artifactId>junit-jupiter-api</artifactId>
59+
<scope>test</scope>
60+
</dependency>
61+
<dependency>
62+
<groupId>org.mockito</groupId>
63+
<artifactId>mockito-core</artifactId>
64+
<scope>test</scope>
65+
</dependency>
66+
<dependency>
67+
<groupId>io.rest-assured</groupId>
68+
<artifactId>rest-assured</artifactId>
69+
<scope>test</scope>
70+
</dependency>
71+
<dependency>
72+
<groupId>jakarta.transaction</groupId>
73+
<artifactId>jakarta.transaction-api</artifactId>
74+
</dependency>
75+
<dependency>
76+
<groupId>io.quarkus</groupId>
77+
<artifactId>quarkus-hibernate-orm</artifactId>
78+
<scope>test</scope>
79+
</dependency>
80+
<dependency>
81+
<groupId>io.quarkus</groupId>
82+
<artifactId>quarkus-jdbc-h2</artifactId>
83+
<scope>test</scope>
84+
</dependency>
85+
<!-- Additional dependencies for integration tests (from reference/jsonrpc) -->
86+
<dependency>
87+
<groupId>${project.groupId}</groupId>
88+
<artifactId>a2a-java-sdk-reference-jsonrpc</artifactId>
89+
<scope>test</scope>
90+
</dependency>
91+
<dependency>
92+
<groupId>${project.groupId}</groupId>
93+
<artifactId>a2a-java-sdk-client-transport-jsonrpc</artifactId>
94+
<scope>test</scope>
95+
</dependency>
96+
<dependency>
97+
<groupId>${project.groupId}</groupId>
98+
<artifactId>a2a-java-sdk-client</artifactId>
99+
<scope>test</scope>
100+
</dependency>
101+
<dependency>
102+
<groupId>io.quarkus</groupId>
103+
<artifactId>quarkus-reactive-routes</artifactId>
104+
<scope>test</scope>
105+
</dependency>
106+
</dependencies>
107+
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package io.a2a.extras.pushnotificationconfigstore.database.jpa;
2+
3+
import java.util.List;
4+
5+
import jakarta.annotation.Priority;
6+
import jakarta.enterprise.context.ApplicationScoped;
7+
import jakarta.enterprise.inject.Alternative;
8+
import jakarta.persistence.EntityManager;
9+
import jakarta.persistence.PersistenceContext;
10+
import jakarta.transaction.Transactional;
11+
12+
import com.fasterxml.jackson.core.JsonProcessingException;
13+
import io.a2a.server.tasks.PushNotificationConfigStore;
14+
import io.a2a.spec.PushNotificationConfig;
15+
import org.slf4j.Logger;
16+
import org.slf4j.LoggerFactory;
17+
18+
@ApplicationScoped
19+
@Alternative
20+
@Priority(50)
21+
public class JpaDatabasePushNotificationConfigStore implements PushNotificationConfigStore {
22+
23+
private static final Logger LOGGER = LoggerFactory.getLogger(JpaDatabasePushNotificationConfigStore.class);
24+
25+
@PersistenceContext(unitName = "a2a-java")
26+
EntityManager em;
27+
28+
@Transactional
29+
@Override
30+
public PushNotificationConfig setInfo(String taskId, PushNotificationConfig notificationConfig) {
31+
// Ensure config has an ID - default to taskId if not provided (mirroring InMemoryPushNotificationConfigStore behavior)
32+
PushNotificationConfig.Builder builder = new PushNotificationConfig.Builder(notificationConfig);
33+
if (notificationConfig.id() == null || notificationConfig.id().isEmpty()) {
34+
builder.id(taskId);
35+
}
36+
notificationConfig = builder.build();
37+
38+
LOGGER.debug("Saving PushNotificationConfig for Task '{}' with ID: {}", taskId, notificationConfig.id());
39+
try {
40+
TaskConfigId configId = new TaskConfigId(taskId, notificationConfig.id());
41+
42+
// Check if entity already exists
43+
JpaPushNotificationConfig existingJpaConfig = em.find(JpaPushNotificationConfig.class, configId);
44+
45+
if (existingJpaConfig != null) {
46+
// Update existing entity
47+
existingJpaConfig.setConfig(notificationConfig);
48+
LOGGER.debug("Updated existing PushNotificationConfig for Task '{}' with ID: {}",
49+
taskId, notificationConfig.id());
50+
} else {
51+
// Create new entity
52+
JpaPushNotificationConfig jpaConfig = JpaPushNotificationConfig.createFromConfig(taskId, notificationConfig);
53+
em.persist(jpaConfig);
54+
LOGGER.debug("Persisted new PushNotificationConfig for Task '{}' with ID: {}",
55+
taskId, notificationConfig.id());
56+
}
57+
} catch (JsonProcessingException e) {
58+
LOGGER.error("Failed to serialize PushNotificationConfig for Task '{}' with ID: {}",
59+
taskId, notificationConfig.id(), e);
60+
throw new RuntimeException("Failed to serialize PushNotificationConfig for Task '" +
61+
taskId + "' with ID: " + notificationConfig.id(), e);
62+
}
63+
return notificationConfig;
64+
}
65+
66+
@Transactional
67+
@Override
68+
public List<PushNotificationConfig> getInfo(String taskId) {
69+
LOGGER.debug("Retrieving PushNotificationConfigs for Task '{}'", taskId);
70+
try {
71+
List<JpaPushNotificationConfig> jpaConfigs = em.createQuery(
72+
"SELECT c FROM JpaPushNotificationConfig c WHERE c.id.taskId = :taskId",
73+
JpaPushNotificationConfig.class)
74+
.setParameter("taskId", taskId)
75+
.getResultList();
76+
77+
List<PushNotificationConfig> configs = jpaConfigs.stream()
78+
.map(jpaConfig -> {
79+
try {
80+
return jpaConfig.getConfig();
81+
} catch (JsonProcessingException e) {
82+
LOGGER.error("Failed to deserialize PushNotificationConfig for Task '{}' with ID: {}",
83+
taskId, jpaConfig.getId().getConfigId(), e);
84+
throw new RuntimeException("Failed to deserialize PushNotificationConfig for Task '" +
85+
taskId + "' with ID: " + jpaConfig.getId().getConfigId(), e);
86+
}
87+
})
88+
.toList();
89+
90+
LOGGER.debug("Successfully retrieved {} PushNotificationConfigs for Task '{}'", configs.size(), taskId);
91+
return configs;
92+
} catch (Exception e) {
93+
LOGGER.error("Failed to retrieve PushNotificationConfigs for Task '{}'", taskId, e);
94+
throw e;
95+
}
96+
}
97+
98+
@Transactional
99+
@Override
100+
public void deleteInfo(String taskId, String configId) {
101+
if (configId == null) {
102+
configId = taskId;
103+
}
104+
105+
LOGGER.debug("Deleting PushNotificationConfig for Task '{}' with Config ID: {}", taskId, configId);
106+
JpaPushNotificationConfig jpaConfig = em.find(JpaPushNotificationConfig.class,
107+
new TaskConfigId(taskId, configId));
108+
109+
if (jpaConfig != null) {
110+
em.remove(jpaConfig);
111+
LOGGER.debug("Successfully deleted PushNotificationConfig for Task '{}' with Config ID: {}",
112+
taskId, configId);
113+
} else {
114+
LOGGER.debug("PushNotificationConfig not found for deletion with Task '{}' and Config ID: {}",
115+
taskId, configId);
116+
}
117+
}
118+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package io.a2a.extras.pushnotificationconfigstore.database.jpa;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.EmbeddedId;
5+
import jakarta.persistence.Entity;
6+
import jakarta.persistence.Table;
7+
import jakarta.persistence.Transient;
8+
9+
import com.fasterxml.jackson.core.JsonProcessingException;
10+
import io.a2a.spec.PushNotificationConfig;
11+
import io.a2a.util.Utils;
12+
13+
@Entity
14+
@Table(name = "a2a_push_notification_configs")
15+
public class JpaPushNotificationConfig {
16+
@EmbeddedId
17+
private TaskConfigId id;
18+
19+
@Column(name = "task_data", columnDefinition = "TEXT", nullable = false)
20+
private String configJson;
21+
22+
@Transient
23+
private PushNotificationConfig config;
24+
25+
// Default constructor required by JPA
26+
public JpaPushNotificationConfig() {
27+
}
28+
29+
public JpaPushNotificationConfig(TaskConfigId id, String configJson) {
30+
this.id = id;
31+
this.configJson = configJson;
32+
}
33+
34+
35+
public TaskConfigId getId() {
36+
return id;
37+
}
38+
39+
public void setId(TaskConfigId id) {
40+
this.id = id;
41+
}
42+
43+
public void setConfigJson(String configJson) {
44+
this.configJson = configJson;
45+
}
46+
47+
public PushNotificationConfig getConfig() throws JsonProcessingException {
48+
if (config == null) {
49+
this.config = Utils.unmarshalFrom(configJson, PushNotificationConfig.TYPE_REFERENCE);
50+
}
51+
return config;
52+
}
53+
54+
public void setConfig(PushNotificationConfig config) throws JsonProcessingException {
55+
if (config.id() == null || !config.id().equals(id.getConfigId())) {
56+
throw new IllegalArgumentException("Mismatched config id. " +
57+
"Expected '" + id.getConfigId() + "'. Got: '" + config.id() + "'");
58+
}
59+
configJson = Utils.OBJECT_MAPPER.writeValueAsString(config);
60+
this.config = config;
61+
}
62+
63+
static JpaPushNotificationConfig createFromConfig(String taskId, PushNotificationConfig config) throws JsonProcessingException {
64+
String json = Utils.OBJECT_MAPPER.writeValueAsString(config);
65+
JpaPushNotificationConfig jpaPushNotificationConfig =
66+
new JpaPushNotificationConfig(new TaskConfigId(taskId, config.id()), json);
67+
jpaPushNotificationConfig.config = config;
68+
return jpaPushNotificationConfig;
69+
}
70+
}

0 commit comments

Comments
 (0)