diff --git a/.github/workflows/run-spring-boot-rest-tck.yml b/.github/workflows/run-spring-boot-rest-tck.yml new file mode 100644 index 000000000..e14c9d814 --- /dev/null +++ b/.github/workflows/run-spring-boot-rest-tck.yml @@ -0,0 +1,94 @@ +name: Run Spring Boot REST TCK + +on: + push: + branches: + - main + paths-ignore: + - '**/docs/**' + pull_request: + branches: + - main + paths-ignore: + - '**/docs/**' + workflow_dispatch: + +env: + # Tag/branch of the TCK + TCK_VERSION: 1.0.0.alpha2 + # Tells uv to not need a venv, and instead use system + UV_SYSTEM_PYTHON: 1 + SUT_URL: http://localhost:9999 + +# Only run the latest job +concurrency: + group: '${{ github.workflow }} @ ${{ github.head_ref || github.ref }}' + cancel-in-progress: true + +jobs: + spring-boot-rest-tck: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout a2a-java + uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Build Spring Boot server modules + run: mvn -B -pl integrations/spring-boot/server -am test + + - name: Checkout a2a-tck + uses: actions/checkout@v6 + with: + repository: a2aproject/a2a-tck + path: a2a-tck + ref: ${{ env.TCK_VERSION }} + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: 'a2a-tck/pyproject.toml' + + - name: Install uv and Python dependencies + run: | + pip install uv + uv pip install -e . + working-directory: a2a-tck + + - name: Run Spring Boot REST TCK + id: run-tck + run: bash ./scripts/run-spring-boot-rest-tck.sh + env: + TCK_DIR: a2a-tck + SUT_URL: ${{ env.SUT_URL }} + + - name: TCK Summary + if: always() + run: | + echo '### Spring Boot REST TCK Results' >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + if [ -f tck-output.log ]; then + tail -n 120 tck-output.log >> "$GITHUB_STEP_SUMMARY" + else + echo 'TCK log was not generated.' >> "$GITHUB_STEP_SUMMARY" + fi + echo '```' >> "$GITHUB_STEP_SUMMARY" + + - name: Upload TCK Reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: spring-boot-rest-tck-reports + path: | + a2a-tck/reports/ + spring-boot-rest-sut.log + spring-boot-rest-sut.pid + tck-output.log + retention-days: 14 + if-no-files-found: warn diff --git a/README.md b/README.md index 7ef5915e0..e96012b4d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ You can find examples of how to use the A2A Java SDK in the [a2a-samples reposit More examples will be added soon. +The repository also contains runnable examples in the `examples/` tree, including: + +- `examples/helloworld` for Quarkus-based hello world client and server samples +- `examples/spring-boot` for Spring Boot server examples + ## A2A Server The A2A Java SDK provides a Java server implementation of the [Agent2Agent (A2A) Protocol](https://a2a-protocol.org/). To run your agentic Java application as an A2A server, simply follow the steps below. @@ -909,6 +914,7 @@ The following list contains community contributed integrations with various Java To contribute an integration, please see [CONTRIBUTING_INTEGRATIONS.md](CONTRIBUTING_INTEGRATIONS.md). +* Spring Boot server integration modules live under [`integrations/spring-boot/server`](integrations/spring-boot/server/README.md). * This project contains integration with Quarkus and has the reference implementations for the JSON-RPC, gRPC, and HTTP+JSON (REST) transports. * https://github.com/wildfly-extras/a2a-jakarta - This integration is based on Jakarta EE, and should work in all runtimes supporting the [Jakarta EE Web Profile](https://jakarta.ee/specifications/webprofile/). diff --git a/boms/extras/src/it/extras-usage-test/src/main/java/org/a2aproject/sdk/test/ExtrasBomVerifier.java b/boms/extras/src/it/extras-usage-test/src/main/java/org/a2aproject/sdk/test/ExtrasBomVerifier.java index dacae1e9a..283fe9776 100644 --- a/boms/extras/src/it/extras-usage-test/src/main/java/org/a2aproject/sdk/test/ExtrasBomVerifier.java +++ b/boms/extras/src/it/extras-usage-test/src/main/java/org/a2aproject/sdk/test/ExtrasBomVerifier.java @@ -14,6 +14,7 @@ public class ExtrasBomVerifier extends DynamicBomVerifier { private static final Set EXTRAS_EXCLUSIONS = Set.of( "boms/", // BOM test modules themselves "examples/", // Example applications + "integrations/spring-boot/", // Spring Boot integration tree is verified separately "itk/", // Integration Test Kit agent "tck/", // TCK test suite "tests/", // Integration tests diff --git a/boms/reference/src/it/reference-usage-test/src/main/java/org/a2aproject/sdk/test/ReferenceBomVerifier.java b/boms/reference/src/it/reference-usage-test/src/main/java/org/a2aproject/sdk/test/ReferenceBomVerifier.java index d521ecf3e..61ed9db09 100644 --- a/boms/reference/src/it/reference-usage-test/src/main/java/org/a2aproject/sdk/test/ReferenceBomVerifier.java +++ b/boms/reference/src/it/reference-usage-test/src/main/java/org/a2aproject/sdk/test/ReferenceBomVerifier.java @@ -14,6 +14,7 @@ public class ReferenceBomVerifier extends DynamicBomVerifier { private static final Set REFERENCE_EXCLUSIONS = Set.of( "boms/", // BOM test modules themselves "examples/", // Example applications + "integrations/spring-boot/", // Spring Boot integration tree is verified separately "itk/", // Integration Test Kit agent "tck/", // TCK test suite "tests/", // Integration tests diff --git a/boms/sdk/src/it/sdk-usage-test/src/main/java/org/a2aproject/sdk/test/SdkBomVerifier.java b/boms/sdk/src/it/sdk-usage-test/src/main/java/org/a2aproject/sdk/test/SdkBomVerifier.java index 551c51c9a..8f1d52577 100644 --- a/boms/sdk/src/it/sdk-usage-test/src/main/java/org/a2aproject/sdk/test/SdkBomVerifier.java +++ b/boms/sdk/src/it/sdk-usage-test/src/main/java/org/a2aproject/sdk/test/SdkBomVerifier.java @@ -14,6 +14,7 @@ public class SdkBomVerifier extends DynamicBomVerifier { private static final Set SDK_EXCLUSIONS = Set.of( "boms/", // BOM test modules themselves "examples/", // Example applications + "integrations/spring-boot/", // Spring Boot integration tree is tested separately "itk/", // Integration Test Kit agent "tck/", // TCK test suite "compat-0.3/tck/", // Compat 0.3 TCK (not yet enabled) diff --git a/boms/test-utils/src/main/java/org/a2aproject/sdk/bom/test/DynamicBomVerifier.java b/boms/test-utils/src/main/java/org/a2aproject/sdk/bom/test/DynamicBomVerifier.java index da092d4a6..2fb7a0291 100644 --- a/boms/test-utils/src/main/java/org/a2aproject/sdk/bom/test/DynamicBomVerifier.java +++ b/boms/test-utils/src/main/java/org/a2aproject/sdk/bom/test/DynamicBomVerifier.java @@ -14,7 +14,7 @@ * Base class for dynamically discovering and verifying all classes in a BOM can be loaded. * Subclass this and pass BOM-specific exclusions and forbidden paths to the constructor. * - * - Excluded paths: Not tested at all (e.g., boms/, examples/, tck/, tests/) + * - Excluded paths: Not tested at all (e.g., boms/, examples/, integrations/spring-boot/, tck/, tests/) * - Forbidden paths: Must NOT be loadable (proves BOM doesn't include wrong dependencies) * - Required paths: Must be loadable (everything else) */ diff --git a/examples/spring-boot/README.md b/examples/spring-boot/README.md new file mode 100644 index 000000000..f71eb71e6 --- /dev/null +++ b/examples/spring-boot/README.md @@ -0,0 +1,74 @@ +# A2A Java SDK - Spring Boot Examples + +This directory contains runnable Spring Boot examples for the A2A Java SDK. + +## Layout + +- `rest/` + - REST transport examples. + - Currently includes runnable server and client examples. + - `server/` + - Runnable Spring Boot REST server example. + - `client/` + - Runnable Spring Boot REST client example. +- `jsonrpc/` + - Reserved for future JSON-RPC examples. + - `server/` + - Reserved for a future Spring Boot JSON-RPC server example. + - `client/` + - Reserved for a future Spring Boot JSON-RPC client example. +- `grpc/` + - Reserved for future gRPC examples. + - `server/` + - Reserved for a future Spring Boot gRPC server example. + - `client/` + - Reserved for a future Spring Boot gRPC client example. + +## Current Example + +- `rest/server` + - Demonstrates a runnable A2A server built with the Spring Boot REST starter. + - Exposes the standard A2A REST endpoints. +- `rest/client` + - Demonstrates a runnable Spring Boot client that calls the server and logs the response. + +## Run + +The REST example can be started in two terminals: + +```bash +mvn -pl examples/spring-boot/rest/server -am spring-boot:run +``` + +```bash +mvn -pl examples/spring-boot/rest/client -am spring-boot:run +``` + +The server listens on `http://localhost:18080` and the client connects to that URL by default. + +## Endpoint Preview + +Once running, the example exposes: + +- `/.well-known/agent-card.json` +- `POST /message:send` +- `POST /message:stream` +- `GET /tasks/{taskId}` +- `GET /tasks` +- `POST /tasks/{taskId}:cancel` +- `POST /tasks/{taskId}:subscribe` +- `POST /tasks/{taskId}/pushNotificationConfigs` +- `GET /tasks/{taskId}/pushNotificationConfigs/{configId}` +- `GET /tasks/{taskId}/pushNotificationConfigs` +- `DELETE /tasks/{taskId}/pushNotificationConfigs/{configId}` +- `GET /extendedAgentCard` + +## Next Transport Examples + +The example tree is transport-first so `jsonrpc` and `grpc` can be added later as sibling transport roots. + +## Build + +```bash +mvn -pl examples/spring-boot -am test +``` diff --git a/examples/spring-boot/grpc/README.md b/examples/spring-boot/grpc/README.md new file mode 100644 index 000000000..aaccea559 --- /dev/null +++ b/examples/spring-boot/grpc/README.md @@ -0,0 +1,11 @@ +# A2A Java SDK - Spring Boot gRPC Examples + +Reserved for future Spring Boot gRPC examples. + +## Build + +No buildable module exists yet. When it is added, use: + +```bash +mvn -pl examples/spring-boot/grpc -am test +``` diff --git a/examples/spring-boot/grpc/client/README.md b/examples/spring-boot/grpc/client/README.md new file mode 100644 index 000000000..0ca9a30d1 --- /dev/null +++ b/examples/spring-boot/grpc/client/README.md @@ -0,0 +1,11 @@ +# A2A Java SDK - Spring Boot gRPC Client Example + +Reserved for a future Spring Boot gRPC client example. + +## Build + +No buildable module exists yet. When it is added, use: + +```bash +mvn -pl examples/spring-boot/grpc/client -am test +``` diff --git a/examples/spring-boot/grpc/server/README.md b/examples/spring-boot/grpc/server/README.md new file mode 100644 index 000000000..cbecc5c07 --- /dev/null +++ b/examples/spring-boot/grpc/server/README.md @@ -0,0 +1,11 @@ +# A2A Java SDK - Spring Boot gRPC Server Example + +Reserved for a future Spring Boot gRPC server example. + +## Build + +No buildable module exists yet. When it is added, use: + +```bash +mvn -pl examples/spring-boot/grpc/server -am test +``` diff --git a/examples/spring-boot/jsonrpc/README.md b/examples/spring-boot/jsonrpc/README.md new file mode 100644 index 000000000..9a9155167 --- /dev/null +++ b/examples/spring-boot/jsonrpc/README.md @@ -0,0 +1,11 @@ +# A2A Java SDK - Spring Boot JSON-RPC Examples + +Reserved for future Spring Boot JSON-RPC examples. + +## Build + +No buildable module exists yet. When it is added, use: + +```bash +mvn -pl examples/spring-boot/jsonrpc -am test +``` diff --git a/examples/spring-boot/jsonrpc/client/README.md b/examples/spring-boot/jsonrpc/client/README.md new file mode 100644 index 000000000..2c8501f85 --- /dev/null +++ b/examples/spring-boot/jsonrpc/client/README.md @@ -0,0 +1,11 @@ +# A2A Java SDK - Spring Boot JSON-RPC Client Example + +Reserved for a future Spring Boot JSON-RPC client example. + +## Build + +No buildable module exists yet. When it is added, use: + +```bash +mvn -pl examples/spring-boot/jsonrpc/client -am test +``` diff --git a/examples/spring-boot/jsonrpc/server/README.md b/examples/spring-boot/jsonrpc/server/README.md new file mode 100644 index 000000000..e04332039 --- /dev/null +++ b/examples/spring-boot/jsonrpc/server/README.md @@ -0,0 +1,11 @@ +# A2A Java SDK - Spring Boot JSON-RPC Server Example + +Reserved for a future Spring Boot JSON-RPC server example. + +## Build + +No buildable module exists yet. When it is added, use: + +```bash +mvn -pl examples/spring-boot/jsonrpc/server -am test +``` diff --git a/examples/spring-boot/pom.xml b/examples/spring-boot/pom.xml new file mode 100644 index 000000000..2753d63b2 --- /dev/null +++ b/examples/spring-boot/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-parent + 1.1.0.Final + ../../pom.xml + + + a2a-java-sdk-examples-spring-boot + pom + + Java SDK A2A Examples - Spring Boot + Spring Boot examples for the Java SDK for the Agent2Agent Protocol (A2A) + + + + + org.springframework.boot + spring-boot-dependencies + 3.5.0 + pom + import + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + 3.5.0 + + + + + + + rest + + diff --git a/examples/spring-boot/rest/README.md b/examples/spring-boot/rest/README.md new file mode 100644 index 000000000..8c4d40e9b --- /dev/null +++ b/examples/spring-boot/rest/README.md @@ -0,0 +1,80 @@ +# A2A Java SDK - Spring Boot REST Example + +This directory contains the end-to-end REST demo for the A2A Spring Boot integration. + +It is split into two runnable apps: + +- `server/` - the A2A agent runtime and REST transport server +- `client/` - a small web app that exercises the server through scenario endpoints and Swagger UI + +## What This Demo Shows + +- How the Spring Boot A2A server is wired with a dedicated `AgentExecutor` bean +- How a client app fetches the remote `AgentCard` +- How to trigger a blocking request and inspect the returned message +- How to trigger a streaming request and observe task updates +- How to run the whole flow in one call +- How to test the client-side demo manually through Swagger UI + +## How To Run + +1. Start the server app: + +```bash +mvn -pl examples/spring-boot/rest/server -am spring-boot:run +``` + +2. Wait until the server is available on: + +```text +http://localhost:18080 +``` + +3. Start the client app: + +```bash +mvn -pl examples/spring-boot/rest/client -am spring-boot:run +``` + +4. Open Swagger UI for the client: + +```text +http://localhost:18081/swagger-ui.html +``` + +## What To Try + +Server-side: + +- Open `http://localhost:18080/.well-known/agent-card.json` +- Send a `hello` message to get a direct response +- Send a `stream` message to see the streaming task flow + +Client-side: + +- `GET /demo/agent-card` to fetch the server card +- `POST /demo/blocking` to run the blocking flow +- `POST /demo/streaming` to run the streaming flow +- `POST /demo/full-flow` to run the full demo end-to-end + +Example request body: + +```json +{ + "helloMessage": "hello from the Spring Boot REST client", + "streamMessage": "stream from the Spring Boot REST client", + "streamingTimeoutSeconds": 15 +} +``` + +## What You Should See + +- The server prints A2A runtime startup logs and exposes the REST transport endpoints. +- The client prints the fetched `AgentCard`, then logs the blocking and streaming scenario results. +- In Swagger UI you can replay the demo scenarios without using curl or a custom client. + +## Build + +```bash +mvn -pl examples/spring-boot/rest -am test +``` diff --git a/examples/spring-boot/rest/client/README.md b/examples/spring-boot/rest/client/README.md new file mode 100644 index 000000000..73bf86f8a --- /dev/null +++ b/examples/spring-boot/rest/client/README.md @@ -0,0 +1,105 @@ +# A2A Java SDK - Spring Boot REST Client Example + +This example shows how to interact with the Spring Boot REST server example through a small web app with Swagger UI. + +## What It Demonstrates + +- Fetching the server `AgentCard` +- Creating an A2A REST client from the SDK +- Running a blocking demo flow through a controller endpoint +- Running a streaming demo flow through a controller endpoint +- Running a full scenario that combines both flows +- Inspecting and replaying the demo through Swagger UI + +## Run + +Start the server example first: + +```bash +mvn -pl examples/spring-boot/rest/server -am spring-boot:run +``` + +Then run the client example in another terminal: + +```bash +mvn -pl examples/spring-boot/rest/client -am spring-boot:run +``` + +The client listens on `http://localhost:18081` by default. + +Swagger UI is available at: + +```text +http://localhost:18081/swagger-ui.html +``` + +## How To Test + +Use Swagger UI or curl against the client demo endpoints: + +- `GET /demo/agent-card` to fetch the server card and verify connectivity +- `POST /demo/blocking` to run the direct request path +- `POST /demo/streaming` to run the streaming task path +- `POST /demo/full-flow` to run the full scenario in one call + +The full flow is the recommended manual test: + +1. Fetch the remote `AgentCard` +2. Send a blocking message +3. Send a streaming message +4. Read the structured JSON result returned by the client demo + +Example request body: + +```json +{ + "helloMessage": "hello from the Spring Boot REST client", + "streamMessage": "stream from the Spring Boot REST client", + "streamingTimeoutSeconds": 15 +} +``` + +What you should see: + +- `blocking` returns a direct response message and a short event trace +- `streaming` returns task events and the final message +- `full-flow` returns a combined report containing the agent card and both scenario results + +## Demo Endpoints + +- `GET /demo/agent-card` +- `POST /demo/blocking` +- `POST /demo/streaming` +- `POST /demo/full-flow` + +Request body example: + +```json +{ + "helloMessage": "hello from the Spring Boot REST client", + "streamMessage": "stream from the Spring Boot REST client", + "streamingTimeoutSeconds": 15 +} +``` + +## Build + +```bash +mvn -pl examples/spring-boot/rest/client -am test +``` + +## Configuration + +Example `application.yml`: + +```yaml +server: + port: 18081 + +a2a: + example: + server-url: http://localhost:18080 + hello-message: hello from the Spring Boot REST client + stream-message: stream from the Spring Boot REST client + streaming-timeout-seconds: 15 +``` diff --git a/examples/spring-boot/rest/client/pom.xml b/examples/spring-boot/rest/client/pom.xml new file mode 100644 index 000000000..1e356736a --- /dev/null +++ b/examples/spring-boot/rest/client/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-examples-spring-boot-rest + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-examples-spring-boot-rest-client + jar + + Java SDK A2A Examples - Spring Boot REST Client + Runnable Spring Boot REST client example for the A2A Java SDK + + + + org.a2aproject.sdk + a2a-java-sdk-client + ${project.version} + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-rest + ${project.version} + + + org.a2aproject.sdk + a2a-java-sdk-jsonrpc-common + ${project.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.projectlombok + lombok + provided + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.14 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.a2aproject.sdk.examples.springboot.rest.client.SpringBootRestClientExampleApplication + + + + + repackage + + + + + + + diff --git a/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientDemoController.java b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientDemoController.java new file mode 100644 index 000000000..a1e4148df --- /dev/null +++ b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientDemoController.java @@ -0,0 +1,50 @@ +package org.a2aproject.sdk.examples.springboot.rest.client; + +import org.a2aproject.sdk.spec.AgentCard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/demo") +@Tag(name = "A2A Spring Boot REST Demo", description = "Scenario endpoints for exercising the A2A REST client against the example server") +public class SpringBootRestClientDemoController { + + private final SpringBootRestClientDemoService demoService; + + @GetMapping("/agent-card") + @Operation(summary = "Fetch the remote agent card", description = "Calls the server example and returns its AgentCard as-is.") + public ResponseEntity fetchAgentCard() { + return ResponseEntity.ok(demoService.fetchAgentCard()); + } + + @PostMapping("/blocking") + @Operation(summary = "Run the blocking message flow", description = "Sends one blocking message and returns the observed events.") + public ResponseEntity runBlocking(@RequestBody(required = false) SpringBootRestClientDemoRequest request) { + log.info("Running blocking demo endpoint"); + return ResponseEntity.ok(demoService.runBlockingDemo(request)); + } + + @PostMapping("/streaming") + @Operation(summary = "Run the streaming message flow", description = "Sends one streaming message and returns the observed task events.") + public ResponseEntity runStreaming(@RequestBody(required = false) SpringBootRestClientDemoRequest request) { + log.info("Running streaming demo endpoint"); + return ResponseEntity.ok(demoService.runStreamingDemo(request)); + } + + @PostMapping("/full-flow") + @Operation(summary = "Run the full demo flow", description = "Fetches the card, runs a blocking call, then runs a streaming call and returns a combined report.") + public ResponseEntity runFullFlow(@RequestBody(required = false) SpringBootRestClientDemoRequest request) { + log.info("Running full-flow demo endpoint"); + return ResponseEntity.ok(demoService.runFullFlow(request)); + } +} diff --git a/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientDemoRequest.java b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientDemoRequest.java new file mode 100644 index 000000000..2b4900f24 --- /dev/null +++ b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientDemoRequest.java @@ -0,0 +1,9 @@ +package org.a2aproject.sdk.examples.springboot.rest.client; + +import org.jspecify.annotations.Nullable; + +public record SpringBootRestClientDemoRequest( + @Nullable String helloMessage, + @Nullable String streamMessage, + @Nullable Integer streamingTimeoutSeconds) { +} diff --git a/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientDemoService.java b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientDemoService.java new file mode 100644 index 000000000..7af6570a6 --- /dev/null +++ b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientDemoService.java @@ -0,0 +1,237 @@ +package org.a2aproject.sdk.examples.springboot.rest.client; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.a2aproject.sdk.A2A; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.client.config.ClientConfig; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; +import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.TextPart; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpringBootRestClientDemoService { + + private final SpringBootRestClientExampleProperties properties; + + public AgentCard fetchAgentCard() { + log.info("Fetching agent card from {}", properties.serverUrl()); + AgentCard agentCard = A2A.getAgentCard(properties.serverUrl()); + try { + log.info("Fetched agent card:\n{}", JsonUtil.toJson(agentCard)); + } catch (JsonProcessingException e) { + log.warn("Fetched agent card, but could not serialize it for logs", e); + } + return agentCard; + } + + public SpringBootRestClientScenarioResponse runBlockingDemo(SpringBootRestClientDemoRequest request) { + String messageText = resolveHelloMessage(request); + try { + AgentCard agentCard = fetchAgentCard(); + return runBlockingDemo(agentCard, messageText); + } catch (Exception e) { + log.error("Blocking demo failed", e); + return SpringBootRestClientScenarioResponse.failure( + "blocking", + properties.serverUrl(), + messageText, + List.of(), + e.getMessage()); + } + } + + public SpringBootRestClientScenarioResponse runStreamingDemo(SpringBootRestClientDemoRequest request) { + String messageText = resolveStreamMessage(request); + int timeoutSeconds = resolveStreamingTimeoutSeconds(request); + try { + AgentCard agentCard = fetchAgentCard(); + return runStreamingDemo(agentCard, messageText, timeoutSeconds); + } catch (Exception e) { + log.error("Streaming demo failed", e); + return SpringBootRestClientScenarioResponse.failure( + "streaming", + properties.serverUrl(), + messageText, + List.of(), + e.getMessage()); + } + } + + public SpringBootRestClientFullFlowResponse runFullFlow(SpringBootRestClientDemoRequest request) { + try { + AgentCard agentCard = fetchAgentCard(); + SpringBootRestClientScenarioResponse blocking = runBlockingDemo(agentCard, resolveHelloMessage(request)); + SpringBootRestClientScenarioResponse streaming = runStreamingDemo( + agentCard, + resolveStreamMessage(request), + resolveStreamingTimeoutSeconds(request)); + return SpringBootRestClientFullFlowResponse.success(properties.serverUrl(), agentCard, blocking, streaming); + } catch (Exception e) { + log.error("Full flow demo failed", e); + return SpringBootRestClientFullFlowResponse.failure(properties.serverUrl(), e.getMessage()); + } + } + + private SpringBootRestClientScenarioResponse runBlockingDemo(AgentCard agentCard, String messageText) throws Exception { + List events = new ArrayList<>(); + AtomicReference receivedMessage = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + + try (Client client = createClient(agentCard, false)) { + Message message = A2A.toUserMessage(messageText); + log.info("Running blocking demo with message: {}", messageText); + client.sendMessage(message, List.of((event, card) -> { + String eventDescription = describeEvent("blocking", event, card); + events.add(eventDescription); + log.info(eventDescription); + if (event instanceof MessageEvent messageEvent) { + receivedMessage.set(extractText(messageEvent.getMessage())); + } + }), throwable -> { + errorRef.set(throwable); + log.error("Blocking transport callback reported an error", throwable); + }, null); + } + + if (errorRef.get() != null) { + throw new IllegalStateException("Blocking demo failed", errorRef.get()); + } + + return SpringBootRestClientScenarioResponse.success( + "blocking", + properties.serverUrl(), + agentCard, + messageText, + receivedMessage.get(), + List.copyOf(events)); + } + + private SpringBootRestClientScenarioResponse runStreamingDemo(AgentCard agentCard, String messageText, int timeoutSeconds) + throws Exception { + List events = new ArrayList<>(); + AtomicReference receivedMessage = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + CountDownLatch completionLatch = new CountDownLatch(1); + + try (Client client = createClient(agentCard, true)) { + Message message = Message.builder() + .role(Message.Role.ROLE_USER) + .parts(List.of(new TextPart(messageText))) + .build(); + + log.info("Running streaming demo with message: {}", messageText); + client.sendMessage(message, List.of((event, card) -> { + String eventDescription = describeEvent("streaming", event, card); + events.add(eventDescription); + log.info(eventDescription); + if (event instanceof MessageEvent messageEvent) { + receivedMessage.set(extractText(messageEvent.getMessage())); + completionLatch.countDown(); + } + if (event instanceof TaskEvent taskEvent && taskEvent.getTask().status().state().isFinal()) { + completionLatch.countDown(); + } + if (event instanceof TaskUpdateEvent taskUpdateEvent + && taskUpdateEvent.getTask().status().state().isFinal()) { + completionLatch.countDown(); + } + }), throwable -> { + errorRef.set(throwable); + completionLatch.countDown(); + log.error("Streaming transport callback reported an error", throwable); + }, null); + + if (!completionLatch.await(timeoutSeconds, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for streaming response"); + } + } + + if (errorRef.get() != null) { + throw new IllegalStateException("Streaming demo failed", errorRef.get()); + } + + return SpringBootRestClientScenarioResponse.success( + "streaming", + properties.serverUrl(), + agentCard, + messageText, + receivedMessage.get(), + List.copyOf(events)); + } + + private Client createClient(AgentCard agentCard, boolean streaming) throws Exception { + ClientConfig clientConfig = new ClientConfig.Builder() + .setStreaming(streaming) + .build(); + ClientBuilder builder = Client.builder(agentCard) + .clientConfig(clientConfig) + .withTransport(RestTransport.class, new RestTransportConfigBuilder()); + return builder.build(); + } + + private String resolveHelloMessage(SpringBootRestClientDemoRequest request) { + if (request != null && request.helloMessage() != null && !request.helloMessage().isBlank()) { + return request.helloMessage(); + } + return properties.helloMessage(); + } + + private String resolveStreamMessage(SpringBootRestClientDemoRequest request) { + if (request != null && request.streamMessage() != null && !request.streamMessage().isBlank()) { + return request.streamMessage(); + } + return properties.streamMessage(); + } + + private int resolveStreamingTimeoutSeconds(SpringBootRestClientDemoRequest request) { + if (request != null && request.streamingTimeoutSeconds() != null && request.streamingTimeoutSeconds() > 0) { + return request.streamingTimeoutSeconds(); + } + return properties.streamingTimeoutSeconds(); + } + + private String describeEvent(String scenario, ClientEvent event, AgentCard agentCard) { + if (event instanceof MessageEvent messageEvent) { + return "[" + scenario + "] MessageEvent from " + agentCard.name() + ": " + extractText(messageEvent.getMessage()); + } + if (event instanceof TaskEvent taskEvent) { + return "[" + scenario + "] TaskEvent: taskId=" + taskEvent.getTask().id() + ", state=" + taskEvent.getTask().status().state(); + } + if (event instanceof TaskUpdateEvent taskUpdateEvent) { + return "[" + scenario + "] TaskUpdateEvent: taskId=" + taskUpdateEvent.getTask().id() + + ", state=" + taskUpdateEvent.getTask().status().state(); + } + return "[" + scenario + "] Event received: " + event.getClass().getSimpleName(); + } + + private String extractText(Message message) { + StringBuilder builder = new StringBuilder(); + if (message.parts() != null) { + for (var part : message.parts()) { + if (part instanceof TextPart textPart) { + builder.append(textPart.text()); + } + } + } + return builder.toString(); + } +} diff --git a/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientExampleApplication.java b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientExampleApplication.java new file mode 100644 index 000000000..32e8b4519 --- /dev/null +++ b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientExampleApplication.java @@ -0,0 +1,14 @@ +package org.a2aproject.sdk.examples.springboot.rest.client; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(SpringBootRestClientExampleProperties.class) +public class SpringBootRestClientExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringBootRestClientExampleApplication.class, args); + } +} diff --git a/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientExampleProperties.java b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientExampleProperties.java new file mode 100644 index 000000000..9c365cfc0 --- /dev/null +++ b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientExampleProperties.java @@ -0,0 +1,11 @@ +package org.a2aproject.sdk.examples.springboot.rest.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "a2a.example") +public record SpringBootRestClientExampleProperties( + String serverUrl, + String helloMessage, + String streamMessage, + int streamingTimeoutSeconds) { +} diff --git a/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientFullFlowResponse.java b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientFullFlowResponse.java new file mode 100644 index 000000000..f6b7e3a43 --- /dev/null +++ b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientFullFlowResponse.java @@ -0,0 +1,26 @@ +package org.a2aproject.sdk.examples.springboot.rest.client; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.a2aproject.sdk.spec.AgentCard; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SpringBootRestClientFullFlowResponse( + boolean success, + String serverUrl, + AgentCard agentCard, + SpringBootRestClientScenarioResponse blocking, + SpringBootRestClientScenarioResponse streaming, + String errorMessage) { + + public static SpringBootRestClientFullFlowResponse success( + String serverUrl, + AgentCard agentCard, + SpringBootRestClientScenarioResponse blocking, + SpringBootRestClientScenarioResponse streaming) { + return new SpringBootRestClientFullFlowResponse(true, serverUrl, agentCard, blocking, streaming, null); + } + + public static SpringBootRestClientFullFlowResponse failure(String serverUrl, String errorMessage) { + return new SpringBootRestClientFullFlowResponse(false, serverUrl, null, null, null, errorMessage); + } +} diff --git a/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientOpenApiConfig.java b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientOpenApiConfig.java new file mode 100644 index 000000000..0c6df08de --- /dev/null +++ b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientOpenApiConfig.java @@ -0,0 +1,25 @@ +package org.a2aproject.sdk.examples.springboot.rest.client; + +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@OpenAPIDefinition( + info = @Info( + title = "A2A Spring Boot REST Client Demo", + version = "1.0.0", + description = """ + Scenario-based demo app for the A2A Spring Boot REST integration. + + Use the endpoints to fetch the remote agent card, run blocking and streaming + client flows, and inspect the end-to-end result as JSON. + """), + servers = @Server(url = "http://localhost:18081"), + tags = { + @Tag(name = "A2A Spring Boot REST Demo", description = "Scenario endpoints for exercising the A2A REST client against the example server") + }) +public class SpringBootRestClientOpenApiConfig { +} diff --git a/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientScenarioResponse.java b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientScenarioResponse.java new file mode 100644 index 000000000..01b17b183 --- /dev/null +++ b/examples/spring-boot/rest/client/src/main/java/org/a2aproject/sdk/examples/springboot/rest/client/SpringBootRestClientScenarioResponse.java @@ -0,0 +1,37 @@ +package org.a2aproject.sdk.examples.springboot.rest.client; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.a2aproject.sdk.spec.AgentCard; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SpringBootRestClientScenarioResponse( + boolean success, + String scenario, + String serverUrl, + AgentCard agentCard, + String sentMessage, + String receivedMessage, + List events, + String errorMessage) { + + public static SpringBootRestClientScenarioResponse success( + String scenario, + String serverUrl, + AgentCard agentCard, + String sentMessage, + String receivedMessage, + List events) { + return new SpringBootRestClientScenarioResponse(true, scenario, serverUrl, agentCard, sentMessage, receivedMessage, events, null); + } + + public static SpringBootRestClientScenarioResponse failure( + String scenario, + String serverUrl, + String sentMessage, + List events, + String errorMessage) { + return new SpringBootRestClientScenarioResponse(false, scenario, serverUrl, null, sentMessage, null, events, errorMessage); + } +} diff --git a/examples/spring-boot/rest/client/src/main/resources/application.yml b/examples/spring-boot/rest/client/src/main/resources/application.yml new file mode 100644 index 000000000..392d93b96 --- /dev/null +++ b/examples/spring-boot/rest/client/src/main/resources/application.yml @@ -0,0 +1,13 @@ +spring: + application: + name: a2a-java-sdk-examples-spring-boot-rest-client + +server: + port: 18081 + +a2a: + example: + server-url: http://localhost:18080 + hello-message: hello from the Spring Boot REST client + stream-message: stream from the Spring Boot REST client + streaming-timeout-seconds: 15 diff --git a/examples/spring-boot/rest/pom.xml b/examples/spring-boot/rest/pom.xml new file mode 100644 index 000000000..22e443759 --- /dev/null +++ b/examples/spring-boot/rest/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-examples-spring-boot + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-examples-spring-boot-rest + pom + + Java SDK A2A Examples - Spring Boot REST + Spring Boot REST examples for the Java SDK for the Agent2Agent Protocol (A2A) + + + client + server + + diff --git a/examples/spring-boot/rest/server/README.md b/examples/spring-boot/rest/server/README.md new file mode 100644 index 000000000..6a9224abb --- /dev/null +++ b/examples/spring-boot/rest/server/README.md @@ -0,0 +1,95 @@ +# A2A Java SDK - Spring Boot Server REST Example + +This example shows how to run an A2A server with the Spring Boot REST starter. + +## What It Demonstrates + +- Spring Boot auto-configuration from the SDK starter +- A minimal `AgentCard` bean +- A dedicated `AgentExecutor` Spring component +- REST transport endpoints exposed by the server module +- A direct message response for `hello` +- A streaming task flow for `stream` + +## Run + +From the repository root, run: + +```bash +mvn -pl examples/spring-boot/rest/server -am spring-boot:run +``` + +The example listens on `http://localhost:18080`. + +## Build + +```bash +mvn -pl examples/spring-boot/rest/server -am test +``` + +## How To Test + +Use a browser or HTTP client and open: + +```text +http://localhost:18080/.well-known/agent-card.json +``` + +Then send messages to the A2A REST transport endpoints: + +- `POST /message:send` for a blocking response +- `POST /message:stream` for a streaming task flow +- `POST /tasks/{taskId}:subscribe` to observe task updates + +The server demo is intentionally minimal: + +- `hello` returns a direct message +- `stream` returns a short streaming task flow +- the executor is a dedicated Spring component in `SpringBootRestServerAgentExecutor` + +## Available Endpoints + +- `/.well-known/agent-card.json` +- `POST /message:send` +- `POST /message:stream` +- `GET /tasks/{taskId}` +- `GET /tasks` +- `POST /tasks/{taskId}:cancel` +- `POST /tasks/{taskId}:subscribe` +- `POST /tasks/{taskId}/pushNotificationConfigs` +- `GET /tasks/{taskId}/pushNotificationConfigs/{configId}` +- `GET /tasks/{taskId}/pushNotificationConfigs` +- `DELETE /tasks/{taskId}/pushNotificationConfigs/{configId}` +- `GET /extendedAgentCard` + +Tenant-prefixed variants are also available for the transport endpoints and extended card: + +- `POST /{tenant}/message:send` +- `POST /{tenant}/message:stream` +- `GET /{tenant}/tasks/{taskId}` +- `GET /{tenant}/tasks` +- `POST /{tenant}/tasks/{taskId}:cancel` +- `POST /{tenant}/tasks/{taskId}:subscribe` +- `POST /{tenant}/tasks/{taskId}/pushNotificationConfigs` +- `GET /{tenant}/tasks/{taskId}/pushNotificationConfigs/{configId}` +- `GET /{tenant}/tasks/{taskId}/pushNotificationConfigs` +- `DELETE /{tenant}/tasks/{taskId}/pushNotificationConfigs/{configId}` +- `GET /{tenant}/extendedAgentCard` + +## Configuration + +Example `application.yml`: + +```yaml +server: + port: 18080 +a2a: + executor: + core-pool-size: 2 + max-pool-size: 4 + keep-alive-seconds: 60 + queue-capacity: 32 + blocking: + agent-timeout-seconds: 30 + consumption-timeout-seconds: 5 +``` diff --git a/examples/spring-boot/rest/server/pom.xml b/examples/spring-boot/rest/server/pom.xml new file mode 100644 index 000000000..38896b46f --- /dev/null +++ b/examples/spring-boot/rest/server/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-examples-spring-boot-rest + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-examples-spring-boot-rest-server + jar + + Java SDK A2A Examples - Spring Boot Server REST + Runnable Spring Boot REST server example for the A2A Java SDK + + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-starter-server-rest + ${project.version} + + + org.projectlombok + lombok + provided + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.a2aproject.sdk.examples.springboot.rest.server.SpringBootRestServerExampleApplication + + + + + repackage + + + + + + + diff --git a/examples/spring-boot/rest/server/src/main/java/org/a2aproject/sdk/examples/springboot/rest/server/SpringBootRestServerAgentExecutor.java b/examples/spring-boot/rest/server/src/main/java/org/a2aproject/sdk/examples/springboot/rest/server/SpringBootRestServerAgentExecutor.java new file mode 100644 index 000000000..2dcc4e2fd --- /dev/null +++ b/examples/spring-boot/rest/server/src/main/java/org/a2aproject/sdk/examples/springboot/rest/server/SpringBootRestServerAgentExecutor.java @@ -0,0 +1,40 @@ +package org.a2aproject.sdk.examples.springboot.rest.server; + +import java.util.List; + +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class SpringBootRestServerAgentExecutor implements AgentExecutor { + + @Override + public void execute(RequestContext context, AgentEmitter agentEmitter) { + String input = context.getUserInput("\n"); + log.info("AgentExecutor received input: {}", input); + + if (input != null && input.toLowerCase().contains("stream")) { + log.info("Running streaming task demo"); + agentEmitter.submit(); + agentEmitter.startWork(); + agentEmitter.addArtifact(List.of(new TextPart("Streaming artifact from Spring Boot REST"))); + agentEmitter.complete(); + return; + } + + log.info("Returning direct message response"); + agentEmitter.sendMessage("Hello from Spring Boot REST"); + } + + @Override + public void cancel(RequestContext context, AgentEmitter agentEmitter) { + log.info("Cancel requested for task {}", context.getTaskId()); + throw new UnsupportedOperationError(); + } +} diff --git a/examples/spring-boot/rest/server/src/main/java/org/a2aproject/sdk/examples/springboot/rest/server/SpringBootRestServerExampleApplication.java b/examples/spring-boot/rest/server/src/main/java/org/a2aproject/sdk/examples/springboot/rest/server/SpringBootRestServerExampleApplication.java new file mode 100644 index 000000000..def48fe15 --- /dev/null +++ b/examples/spring-boot/rest/server/src/main/java/org/a2aproject/sdk/examples/springboot/rest/server/SpringBootRestServerExampleApplication.java @@ -0,0 +1,12 @@ +package org.a2aproject.sdk.examples.springboot.rest.server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringBootRestServerExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringBootRestServerExampleApplication.class, args); + } +} diff --git a/examples/spring-boot/rest/server/src/main/java/org/a2aproject/sdk/examples/springboot/rest/server/SpringBootRestServerExampleConfiguration.java b/examples/spring-boot/rest/server/src/main/java/org/a2aproject/sdk/examples/springboot/rest/server/SpringBootRestServerExampleConfiguration.java new file mode 100644 index 000000000..60f7a7e71 --- /dev/null +++ b/examples/spring-boot/rest/server/src/main/java/org/a2aproject/sdk/examples/springboot/rest/server/SpringBootRestServerExampleConfiguration.java @@ -0,0 +1,42 @@ +package org.a2aproject.sdk.examples.springboot.rest.server; + +import java.util.List; + +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentSkill; +import org.a2aproject.sdk.spec.TransportProtocol; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration(proxyBeanMethods = false) +public class SpringBootRestServerExampleConfiguration { + + @Bean + public AgentCard agentCard() { + log.info("Creating Spring Boot REST example agent card"); + return AgentCard.builder() + .name("Spring Boot REST Example Agent") + .description("Minimal Spring Boot example for the A2A REST transport") + .supportedInterfaces(List.of( + new AgentInterface(TransportProtocol.HTTP_JSON.asString(), "http://localhost:18080"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of(AgentSkill.builder() + .id("spring_boot_rest_example") + .name("Spring Boot REST example") + .description("Responds with a short fixed message") + .tags(List.of("spring-boot", "rest", "example")) + .examples(List.of("hello")) + .build())) + .build(); + } +} diff --git a/examples/spring-boot/rest/server/src/main/resources/application.yml b/examples/spring-boot/rest/server/src/main/resources/application.yml new file mode 100644 index 000000000..0ba1acdc1 --- /dev/null +++ b/examples/spring-boot/rest/server/src/main/resources/application.yml @@ -0,0 +1,11 @@ +server: + port: 18080 +a2a: + executor: + core-pool-size: 2 + max-pool-size: 4 + keep-alive-seconds: 60 + queue-capacity: 32 + blocking: + agent-timeout-seconds: 30 + consumption-timeout-seconds: 5 diff --git a/integrations/spring-boot/README.md b/integrations/spring-boot/README.md new file mode 100644 index 000000000..7d472f5ce --- /dev/null +++ b/integrations/spring-boot/README.md @@ -0,0 +1,91 @@ +# A2A Java SDK - Spring Boot Integration + +This directory is the Maven aggregator for Spring Boot integration modules. + +## Layout + +- `server/` + - Server-side Spring Boot integration tree. + - Contains a shared runtime module plus transport-specific aggregators. +- `client/` + - Reserved for future client-side Spring Boot integration modules. + +## Server Tree + +- `spring-boot-server-autoconfigure` + - Artifact: `a2a-java-sdk-spring-boot-server-autoconfigure` + - Shared Spring Boot runtime auto-configuration for A2A. +- `rest/` + - Transport aggregator for the REST/MVC server integration. + - Contains the REST controller, starter, integration tests, and the TCK SUT. +- `jsonrpc/` + - Placeholder for future JSON-RPC server modules. +- `grpc/` + - Placeholder for future gRPC server modules. + +## Recommended Dependency + +For Spring Boot applications that use the REST transport, depend on: + +```xml + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-starter-server-rest + +``` + +## Server Configuration + +The server runtime is configured with `a2a.*` properties. + +Example `application.yml`: + +```yaml +a2a: + executor: + core-pool-size: 5 + max-pool-size: 50 + keep-alive-seconds: 60 + queue-capacity: 100 + blocking: + agent-timeout-seconds: 30 + consumption-timeout-seconds: 5 + agent-card: + cache: + max-age: 3600 +``` + +## Build + +```bash +mvn -pl integrations/spring-boot/server -am test +``` + +## REST TCK + +Run the REST TCK end-to-end with the checked-in script: + +```bash +bash ./scripts/run-spring-boot-rest-tck.sh +``` + +That script starts the REST SUT, waits for `/.well-known/agent-card.json`, runs the external `a2a-tck`, and stops the SUT on exit. + +If you want to run only the SUT locally, use: + +```bash +mvn -pl integrations/spring-boot/server/rest/spring-boot-server-rest-sut -am spring-boot:run +``` + +The SUT listens on `http://localhost:9999` by default. + +If you have the external `a2a-tck` repository checked out, you can validate it with: + +```bash +uv run ./run_tck.py --sut-host http://localhost:9999 -v +``` + +See also: + +- `integrations/spring-boot/server/rest/TCK.md` +- `.github/workflows/run-spring-boot-rest-tck.yml` diff --git a/integrations/spring-boot/client/README.md b/integrations/spring-boot/client/README.md new file mode 100644 index 000000000..2fd2dbbc7 --- /dev/null +++ b/integrations/spring-boot/client/README.md @@ -0,0 +1,12 @@ +# A2A Java SDK - Spring Boot Client Integration + +This directory is reserved for future client-oriented Spring Boot integration modules. + +## Status + +- No client modules are implemented yet. +- The directory exists to keep the Spring Boot integration tree symmetric with the server side. + +## Planned Shape + +The future client tree will mirror the server layout once the client transport is implemented. diff --git a/integrations/spring-boot/client/pom.xml b/integrations/spring-boot/client/pom.xml new file mode 100644 index 000000000..46ce33a11 --- /dev/null +++ b/integrations/spring-boot/client/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-spring-boot-client + pom + + A2A Java SDK - Spring Boot Client Integration + Reserved aggregator for future client-oriented Spring Boot integration + diff --git a/integrations/spring-boot/pom.xml b/integrations/spring-boot/pom.xml new file mode 100644 index 000000000..94aac888d --- /dev/null +++ b/integrations/spring-boot/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-parent + 1.1.0.Final + ../../pom.xml + + + a2a-java-sdk-spring-boot + pom + + A2A Java SDK - Spring Boot Integration + Spring Boot integration for A2A Java SDK + + + server + client + + diff --git a/integrations/spring-boot/server/README.md b/integrations/spring-boot/server/README.md new file mode 100644 index 000000000..8a050e267 --- /dev/null +++ b/integrations/spring-boot/server/README.md @@ -0,0 +1,90 @@ +# A2A Java SDK - Spring Boot Server Integration + +This directory is the server-side Spring Boot integration tree. + +## Layout + +- `spring-boot-server-autoconfigure/` + - Shared runtime auto-configuration for all Spring Boot transports. + - Works without Servlet APIs. +- `rest/` + - Transport aggregator for the REST/MVC server integration. + - Contains the runtime controller, starter, integration tests, and the REST TCK SUT. +- `jsonrpc/` + - Placeholder aggregator for the future JSON-RPC server integration. +- `grpc/` + - Placeholder aggregator for the future gRPC server integration. + +## Recommended Dependency + +For Spring Boot applications that use the REST transport, depend on: + +```xml + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-starter-server-rest + +``` + +## Runtime Properties + +The server runtime is configured with `a2a.*` properties. + +Example `application.yml`: + +```yaml +a2a: + executor: + core-pool-size: 5 + max-pool-size: 50 + keep-alive-seconds: 60 + queue-capacity: 100 + blocking: + agent-timeout-seconds: 30 + consumption-timeout-seconds: 5 + agent-card: + cache: + max-age: 3600 +``` + +## Required Application Beans + +The starter wires the runtime and transport infrastructure, but the application still needs to provide: + +- `AgentCard` +- `AgentExecutor` + +## Override Points + +Applications can replace these beans when custom behavior is needed: + +- `TaskStore` +- `PushNotificationConfigStore` +- `PushNotificationSender` +- `RequestHandler` +- `A2ASpringBootHttpResponseMapper` +- `A2ASpringBootMvcController` + +## Build + +```bash +mvn -pl integrations/spring-boot/server -am test +``` + +## TCK SUT + +Run the REST TCK end-to-end with: + +```bash +bash ./scripts/run-spring-boot-rest-tck.sh +``` + +If you want to run only the SUT locally, use: + +```bash +mvn -pl integrations/spring-boot/server/rest/spring-boot-server-rest-sut -am spring-boot:run +``` + +The SUT listens on `http://localhost:9999` by default so the external A2A TCK can target it directly. + +For CI, see `.github/workflows/run-spring-boot-rest-tck.yml`. diff --git a/integrations/spring-boot/server/grpc/README.md b/integrations/spring-boot/server/grpc/README.md new file mode 100644 index 000000000..ad01b2af0 --- /dev/null +++ b/integrations/spring-boot/server/grpc/README.md @@ -0,0 +1,5 @@ +# A2A Java SDK - Spring Boot Server gRPC Aggregator + +This directory is reserved for the future gRPC Spring Boot server integration. + +The REST integration currently lives under `../rest/`. diff --git a/integrations/spring-boot/server/grpc/pom.xml b/integrations/spring-boot/server/grpc/pom.xml new file mode 100644 index 000000000..75f68ccdd --- /dev/null +++ b/integrations/spring-boot/server/grpc/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-server + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-spring-boot-server-grpc-parent + pom + + A2A Java SDK - Spring Boot Server gRPC Aggregator + Placeholder aggregator for the Spring Boot gRPC server integration modules + diff --git a/integrations/spring-boot/server/jsonrpc/README.md b/integrations/spring-boot/server/jsonrpc/README.md new file mode 100644 index 000000000..96aff21df --- /dev/null +++ b/integrations/spring-boot/server/jsonrpc/README.md @@ -0,0 +1,5 @@ +# A2A Java SDK - Spring Boot Server JSON-RPC Aggregator + +This directory is reserved for the future JSON-RPC Spring Boot server integration. + +The REST integration currently lives under `../rest/`. diff --git a/integrations/spring-boot/server/jsonrpc/pom.xml b/integrations/spring-boot/server/jsonrpc/pom.xml new file mode 100644 index 000000000..e398dd782 --- /dev/null +++ b/integrations/spring-boot/server/jsonrpc/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-server + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-spring-boot-server-jsonrpc-parent + pom + + A2A Java SDK - Spring Boot Server JSON-RPC Aggregator + Placeholder aggregator for the Spring Boot JSON-RPC server integration modules + diff --git a/integrations/spring-boot/server/pom.xml b/integrations/spring-boot/server/pom.xml new file mode 100644 index 000000000..f30b0bffa --- /dev/null +++ b/integrations/spring-boot/server/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-spring-boot-server + pom + + A2A Java SDK - Spring Boot Server Integration + Server-oriented Spring Boot integration for A2A Java SDK + + + 3.5.0 + + + + + + ${project.groupId} + a2a-java-sdk-spring-boot-server-autoconfigure + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spring-boot-server-rest + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spring-boot-starter-server-rest + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spring-boot-server-rest-sut + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spring-boot-server-integration-tests + ${project.version} + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + spring-boot-server-autoconfigure + rest + jsonrpc + grpc + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + -Djdk.net.URLClassPath.disableClassPathURLCheck=true + false + + + + + diff --git a/integrations/spring-boot/server/rest/README.md b/integrations/spring-boot/server/rest/README.md new file mode 100644 index 000000000..0dd094f09 --- /dev/null +++ b/integrations/spring-boot/server/rest/README.md @@ -0,0 +1,34 @@ +# A2A Java SDK - Spring Boot Server REST Aggregator + +This directory aggregates the REST-specific Spring Boot server modules. + +## Modules + +- `spring-boot-server-rest` + - Servlet and Spring MVC transport adapter. +- `spring-boot-starter-server-rest` + - Dependency-only starter for REST/MVC applications. +- `spring-boot-server-integration-tests` + - Integration tests for the REST server stack. +- `spring-boot-server-rest-sut` + - Runnable REST SUT for the external A2A TCK. + +## Build + +```bash +mvn -pl integrations/spring-boot/server/rest -am test +``` + +## TCK SUT + +Run the REST TCK end-to-end with: + +```bash +bash ./scripts/run-spring-boot-rest-tck.sh +``` + +If you want to run only the REST SUT locally, use: + +```bash +mvn -pl integrations/spring-boot/server/rest/spring-boot-server-rest-sut -am spring-boot:run +``` diff --git a/integrations/spring-boot/server/rest/TCK.md b/integrations/spring-boot/server/rest/TCK.md new file mode 100644 index 000000000..2210d69ef --- /dev/null +++ b/integrations/spring-boot/server/rest/TCK.md @@ -0,0 +1,47 @@ +# Spring Boot REST TCK + +This directory contains the REST transport stack and the runnable TCK SUT. + +## What The TCK Checks + +- `/.well-known/agent-card.json` +- `POST /message:send` +- `POST /message:stream` +- task lifecycle endpoints exposed by the REST transport + +## Local Run + +From the repository root: + +```bash +bash ./scripts/run-spring-boot-rest-tck.sh +``` + +The script: + +1. starts the REST SUT from `integrations/spring-boot/server/rest/spring-boot-server-rest-sut` +2. waits for `http://localhost:9999/.well-known/agent-card.json` +3. runs the external `a2a-tck` against that URL +4. stops the SUT on exit + +## Manual Steps + +If you want to run the pieces separately: + +```bash +mvn -B -pl integrations/spring-boot/server/rest/spring-boot-server-rest-sut -am spring-boot:run +``` + +In another terminal, run the TCK from the checked-out `a2a-tck` repository: + +```bash +uv run ./run_tck.py --sut-host http://localhost:9999 -v +``` + +## CI + +The GitHub Actions workflow is: + +```text +.github/workflows/run-spring-boot-rest-tck.yml +``` diff --git a/integrations/spring-boot/server/rest/pom.xml b/integrations/spring-boot/server/rest/pom.xml new file mode 100644 index 000000000..47d5e7ae4 --- /dev/null +++ b/integrations/spring-boot/server/rest/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-server + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-spring-boot-server-rest-parent + pom + + A2A Java SDK - Spring Boot Server REST Aggregator + Aggregator for the Spring Boot REST server integration modules + + + spring-boot-server-rest + spring-boot-starter-server-rest + spring-boot-server-integration-tests + spring-boot-server-rest-sut + + diff --git a/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/README.md b/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/README.md new file mode 100644 index 000000000..387cbcb06 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/README.md @@ -0,0 +1,51 @@ +# A2A Java SDK - Spring Boot Server Integration Tests + +This module contains integration tests for the Spring Boot REST stack. + +## Artifact + +- `a2a-java-sdk-spring-boot-server-integration-tests` + +## Coverage + +- Spring Boot application context startup +- REST endpoint wiring +- request delegation into the A2A runtime +- server-sent events responses +- starter-level dependency behavior + +## Purpose + +The tests verify the assembled Spring Boot integration rather than individual unit classes only. They are meant to guard the full wiring between: + +- runtime auto-configuration +- Servlet auto-configuration +- Spring MVC controller mapping +- A2A request handling + +## Test Focus + +The integration suite should continue to cover: + +- application startup with the starter on the classpath +- bean replacement behavior for runtime and MVC components +- JSON response contract for A2A endpoints +- SSE response contract for streaming endpoints +- request delegation into `RequestHandler` + +Example test application setup: + +```yaml +a2a: + executor: + core-pool-size: 1 + max-pool-size: 1 + keep-alive-seconds: 1 + queue-capacity: 1 +``` + +## Build + +```bash +mvn -pl integrations/spring-boot/server/rest/spring-boot-server-integration-tests -am test +``` diff --git a/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/pom.xml b/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/pom.xml new file mode 100644 index 000000000..0585049bd --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-server-rest-parent + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-spring-boot-server-integration-tests + jar + + A2A Java SDK - Spring Boot Server Integration Tests + Integration tests for the Spring Boot server starter + + + + ${project.groupId} + a2a-java-sdk-spring-boot-starter-server-rest + + + ${project.groupId} + a2a-java-sdk-client-transport-rest + ${project.version} + test + + + ${project.groupId} + a2a-java-sdk-tests-server-common + ${project.version} + tests + test + + + jakarta.ws.rs + jakarta.ws.rs-api + test + + + io.rest-assured + rest-assured + test + + + org.springframework.boot + spring-boot-test + test + + + org.springframework.boot + spring-boot-test-autoconfigure + test + + + org.junit.jupiter + junit-jupiter-api + 5.12.2 + test + + + org.mockito + mockito-core + test + + + diff --git a/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootIntegrationTest.java b/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootIntegrationTest.java new file mode 100644 index 000000000..5d7e50ec3 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootIntegrationTest.java @@ -0,0 +1,376 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import static org.a2aproject.sdk.common.A2AHeaders.A2A_VERSION; +import static org.a2aproject.sdk.spec.TransportProtocol.HTTP_JSON; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; + +import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.spec.AuthenticationInfo; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest(classes = A2ASpringBootIntegrationTest.TestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +class A2ASpringBootIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private RequestHandler requestHandler; + + @Autowired + private AgentCard agentCard; + + @Test + void servesAgentCardAsJson() throws Exception { + MvcResult result = mockMvc.perform(get("/.well-known/agent-card.json")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + assertTrue(responseBody.contains("\"name\":\"Spring Boot Test Agent\"")); + assertTrue(responseBody.contains("\"version\":\"1.0.0\"")); + } + + @Test + void routesSendMessageThroughRequestHandler() throws Exception { + Message requestMessage = Message.builder() + .role(Message.Role.ROLE_USER) + .messageId("msg-1") + .parts(new TextPart("hello")) + .build(); + MessageSendParams params = MessageSendParams.builder() + .message(requestMessage) + .metadata(Map.of("traceId", "trace-1")) + .build(); + String body = toJson(params); + + Message responseMessage = Message.builder() + .role(Message.Role.ROLE_AGENT) + .messageId("msg-2") + .parts(new TextPart("ok")) + .build(); + when(requestHandler.onMessageSend(any(MessageSendParams.class), any(ServerCallContext.class))).thenReturn(responseMessage); + + MvcResult result = mockMvc.perform(post("/tenant-a/message:send") + .contentType(MediaType.APPLICATION_JSON) + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion()) + .content(body)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + assertTrue(responseBody.contains("\"msg-2\"")); + assertTrue(responseBody.contains("\"message\"")); + + var paramsCaptor = org.mockito.ArgumentCaptor.forClass(MessageSendParams.class); + var contextCaptor = org.mockito.ArgumentCaptor.forClass(ServerCallContext.class); + verify(requestHandler).onMessageSend(paramsCaptor.capture(), contextCaptor.capture()); + + assertEquals("tenant-a", paramsCaptor.getValue().tenant()); + assertEquals("trace-1", paramsCaptor.getValue().metadata().get("traceId")); + assertEquals(HTTP_JSON, contextCaptor.getValue().getState().get("transport")); + assertEquals("SendMessage", contextCaptor.getValue().getState().get("method")); + } + + @Test + void routesStreamingMessageThroughRequestHandler() throws Exception { + Flow.Publisher publisher = subscriber -> subscriber.onSubscribe(new Flow.Subscription() { + private boolean emitted; + + @Override + public void request(long n) { + if (!emitted) { + emitted = true; + subscriber.onNext(Message.builder() + .role(Message.Role.ROLE_AGENT) + .messageId("stream-1") + .parts(new TextPart("ok")) + .build()); + subscriber.onComplete(); + } + } + + @Override + public void cancel() { + } + }); + when(requestHandler.onMessageSendStream(any(MessageSendParams.class), any(ServerCallContext.class))).thenReturn(publisher); + + MessageSendParams params = MessageSendParams.builder() + .message(Message.builder() + .role(Message.Role.ROLE_USER) + .messageId("msg-1") + .parts(new TextPart("hello")) + .build()) + .build(); + + MvcResult result = mockMvc.perform(post("/tenant-a/message:stream") + .contentType(MediaType.APPLICATION_JSON) + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion()) + .content(toJson(params))) + .andExpect(request().asyncStarted()) + .andReturn(); + + MvcResult dispatched = mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)) + .andReturn(); + + String responseBody = dispatched.getResponse().getContentAsString(); + assertTrue(responseBody.contains("stream-1")); + } + + @Test + void routesTaskLookupThroughRequestHandler() throws Exception { + Task task = Task.builder() + .id("task-123") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.TASK_STATE_SUBMITTED)) + .build(); + when(requestHandler.onGetTask(any(), any())).thenReturn(task); + + MvcResult result = mockMvc.perform(get("/tenant-a/tasks/task-123") + .param("historyLength", "2") + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + assertTrue(responseBody.contains("\"task-123\"")); + } + + @Test + void routesTaskListThroughRequestHandler() throws Exception { + when(requestHandler.onListTasks(any(), any())).thenReturn(new ListTasksResult(List.of(task("task-123", TaskState.TASK_STATE_SUBMITTED)))); + + MvcResult result = mockMvc.perform(get("/tenant-a/tasks") + .param("status", "task_state_submitted") + .param("pageSize", "10") + .param("includeArtifacts", "true") + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + assertTrue(responseBody.contains("\"task-123\"")); + } + + @Test + void routesCancelTaskThroughRequestHandler() throws Exception { + when(requestHandler.onCancelTask(any(), any())).thenReturn(task("task-123", TaskState.TASK_STATE_CANCELED)); + + MvcResult result = mockMvc.perform(post("/tenant-a/tasks/task-123:cancel") + .contentType(MediaType.APPLICATION_JSON) + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion()) + .content("{\"metadata\":{\"reason\":\"user_requested\"}}")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + assertTrue(responseBody.contains("\"task-123\"")); + } + + @Test + void routesTaskSubscriptionThroughRequestHandler() throws Exception { + Flow.Publisher publisher = subscriber -> subscriber.onSubscribe(new Flow.Subscription() { + private boolean emitted; + + @Override + public void request(long n) { + if (!emitted) { + emitted = true; + subscriber.onNext(Message.builder() + .role(Message.Role.ROLE_AGENT) + .messageId("stream-2") + .parts(new TextPart("subscribed")) + .build()); + subscriber.onComplete(); + } + } + + @Override + public void cancel() { + } + }); + when(requestHandler.onSubscribeToTask(any(), any())).thenReturn(publisher); + + MvcResult result = mockMvc.perform(post("/tenant-a/tasks/task-123:subscribe") + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion())) + .andExpect(request().asyncStarted()) + .andReturn(); + + MvcResult dispatched = mockMvc.perform(asyncDispatch(result)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)) + .andReturn(); + + String responseBody = dispatched.getResponse().getContentAsString(); + assertTrue(responseBody.contains("stream-2")); + } + + @Test + void routesPushNotificationEndpointsThroughRequestHandler() throws Exception { + TaskPushNotificationConfig config = TaskPushNotificationConfig.builder() + .id("config-1") + .taskId("task-123") + .url("https://example.com/webhook") + .token("token-1") + .authentication(new AuthenticationInfo("Bearer", "secret")) + .tenant("tenant-a") + .build(); + when(requestHandler.onCreateTaskPushNotificationConfig(any(), any())).thenReturn(config); + when(requestHandler.onGetTaskPushNotificationConfig(any(GetTaskPushNotificationConfigParams.class), any())) + .thenReturn(config); + when(requestHandler.onListTaskPushNotificationConfigs(any(ListTaskPushNotificationConfigsParams.class), any())) + .thenReturn(new ListTaskPushNotificationConfigsResult(List.of(config), "next-token")); + + mockMvc.perform(post("/tenant-a/tasks/task-123/pushNotificationConfigs") + .contentType(MediaType.APPLICATION_JSON) + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion()) + .content(""" + { + "url": "https://example.com/webhook", + "token": "token-1", + "authentication": { + "scheme": "Bearer", + "credentials": "secret" + } + } + """)) + .andExpect(status().isCreated()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(result -> assertTrue(result.getResponse().getContentAsString().contains("\"config-1\""))); + + mockMvc.perform(get("/tenant-a/tasks/task-123/pushNotificationConfigs/config-1") + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(result -> assertTrue(result.getResponse().getContentAsString().contains("\"config-1\""))); + + mockMvc.perform(get("/tenant-a/tasks/task-123/pushNotificationConfigs") + .param("pageSize", "10") + .param("pageToken", "token-1") + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(result -> assertTrue(result.getResponse().getContentAsString().contains("\"config-1\""))); + + mockMvc.perform(delete("/tenant-a/tasks/task-123/pushNotificationConfigs/config-1") + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion())) + .andExpect(status().isNoContent()); + } + + @Test + void servesExtendedAgentCardWhenConfigured() throws Exception { + MvcResult result = mockMvc.perform(get("/tenant-a/extendedAgentCard") + .header(A2A_VERSION, agentCard.supportedInterfaces().get(0).protocolVersion())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + assertTrue(responseBody.contains("\"Spring Boot Extended Agent\"")); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestApplication { + + @Bean + AgentCard agentCard() { + return AgentCard.builder() + .name("Spring Boot Test Agent") + .description("Test agent for Spring Boot transport integration") + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extendedAgentCard(true) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface(HTTP_JSON.asString(), "http://localhost:8080"))) + .build(); + } + + @Bean("extendedAgentCard") + AgentCard extendedAgentCard() { + return AgentCard.builder() + .name("Spring Boot Extended Agent") + .description("Extended test agent for Spring Boot transport integration") + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extendedAgentCard(true) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface(HTTP_JSON.asString(), "http://localhost:8080"))) + .build(); + } + } + + private String toJson(Object value) throws Exception { + return JsonUtil.toJson(value); + } + + private Task task(String id, TaskState state) { + return Task.builder() + .id(id) + .contextId("ctx-1") + .status(new TaskStatus(state)) + .build(); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootServerContractTest.java b/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootServerContractTest.java new file mode 100644 index 000000000..493c4f3f2 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-integration-tests/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootServerContractTest.java @@ -0,0 +1,470 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerTest; +import org.a2aproject.sdk.server.apps.common.AgentToAgentClientFactory; +import org.a2aproject.sdk.server.events.MainEventBusProcessor; +import org.a2aproject.sdk.server.events.QueueManager; +import org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; +import org.a2aproject.sdk.server.tasks.TaskStore; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.InternalError; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.TransportProtocol; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Scope; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +import java.net.ServerSocket; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.a2aproject.sdk.server.ServerCallContext.TRANSPORT_KEY; +import static org.a2aproject.sdk.spec.TransportProtocol.HTTP_JSON; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest( + classes = A2ASpringBootServerContractTest.TestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +class A2ASpringBootServerContractTest extends AbstractA2AServerTest { + + private static final int SERVER_PORT = findAvailablePort(); + + @Autowired + private ApplicationContext applicationContext; + + A2ASpringBootServerContractTest() { + super(SERVER_PORT); + } + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("server.port", () -> SERVER_PORT); + } + + @Override + protected String getTransportProtocol() { + return TransportProtocol.HTTP_JSON.asString(); + } + + @Override + protected String getTransportUrl() { + return "http://localhost:" + SERVER_PORT; + } + + @Override + protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder()); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(TestSupportController.class) + static class TestApplication { + + @Bean + AgentCard agentCard() { + return testAgentCard(); + } + + @Bean("extendedAgentCard") + AgentCard extendedAgentCard() { + return testAgentCard(); + } + + @Bean + AtomicInteger streamingSubscribedCount() { + return new AtomicInteger(); + } + + @Bean + StreamingSubscriptionObserver streamingSubscriptionObserver(AtomicInteger streamingSubscribedCount) { + return streamingSubscribedCount::incrementAndGet; + } + + @Bean + @Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = org.springframework.context.annotation.ScopedProxyMode.TARGET_CLASS) + RequestScopedBean requestScopedBean() { + return new RequestScopedBean(); + } + + @Bean + AgentExecutor agentExecutor(AgentCard agentCard, RequestScopedBean requestScopedBean) { + return new TestAgentExecutor(agentCard, requestScopedBean); + } + + @Bean + RequestHandler requestHandler(AgentExecutor agentExecutor, + TaskStore taskStore, + QueueManager queueManager, + PushNotificationConfigStore pushNotificationConfigStore, + MainEventBusProcessor mainEventBusProcessor, + @org.springframework.beans.factory.annotation.Qualifier("a2aInternalExecutor") java.util.concurrent.Executor internalExecutor, + @org.springframework.beans.factory.annotation.Qualifier("a2aEventConsumerExecutor") java.util.concurrent.Executor eventConsumerExecutor) { + return new DefaultRequestHandler( + agentExecutor, + taskStore, + queueManager, + pushNotificationConfigStore, + mainEventBusProcessor, + internalExecutor, + eventConsumerExecutor); + } + + private AgentCard testAgentCard() { + return AgentCard.builder() + .name("test-card") + .description("A test agent card") + .version("1.0") + .documentationUrl("http://example.com/docs") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extendedAgentCard(true) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface(HTTP_JSON.asString(), "http://localhost:" + SERVER_PORT))) + .build(); + } + } + + private static int findAvailablePort() { + try (ServerSocket socket = new ServerSocket(0)) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } catch (Exception e) { + throw new IllegalStateException("Failed to allocate a free port for Spring Boot integration tests", e); + } + } + + @RestController + static class TestSupportController { + + private final TaskStore taskStore; + private final QueueManager queueManager; + private final PushNotificationConfigStore pushNotificationConfigStore; + private final AtomicInteger streamingSubscribedCount; + + TestSupportController(TaskStore taskStore, + QueueManager queueManager, + PushNotificationConfigStore pushNotificationConfigStore, + AtomicInteger streamingSubscribedCount) { + this.taskStore = taskStore; + this.queueManager = queueManager; + this.pushNotificationConfigStore = pushNotificationConfigStore; + this.streamingSubscribedCount = streamingSubscribedCount; + } + + @PostMapping(value = "/test/task", consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity saveTask(@RequestBody String body) throws Exception { + taskStore.save(JsonUtil.fromJson(body, Task.class), false); + return ResponseEntity.ok().build(); + } + + @GetMapping(value = "/test/task/{taskId}", produces = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity getTask(@PathVariable String taskId) throws Exception { + Task task = taskStore.get(taskId); + if (task == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(JsonUtil.toJson(task)); + } + + @DeleteMapping("/test/task/{taskId}") + ResponseEntity deleteTask(@PathVariable String taskId) { + taskStore.delete(taskId); + return ResponseEntity.ok().build(); + } + + @PostMapping("/test/queue/ensure/{taskId}") + ResponseEntity ensureQueue(@PathVariable String taskId) { + queueManager.createOrTap(taskId); + return ResponseEntity.ok().build(); + } + + @PostMapping(value = "/test/queue/enqueueTaskStatusUpdateEvent/{taskId}", consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity enqueueStatus(@PathVariable String taskId, @RequestBody String body) throws Exception { + TaskStatusUpdateEvent event = JsonUtil.fromJson(body, TaskStatusUpdateEvent.class); + queueManager.get(taskId).enqueueEvent(event); + return ResponseEntity.ok().build(); + } + + @PostMapping(value = "/test/queue/enqueueTaskArtifactUpdateEvent/{taskId}", consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity enqueueArtifact(@PathVariable String taskId, @RequestBody String body) throws Exception { + TaskArtifactUpdateEvent event = JsonUtil.fromJson(body, TaskArtifactUpdateEvent.class); + queueManager.get(taskId).enqueueEvent(event); + return ResponseEntity.ok().build(); + } + + @GetMapping("/test/streamingSubscribedCount") + ResponseEntity getStreamingSubscribedCount() { + return ResponseEntity.ok(String.valueOf(streamingSubscribedCount.get())); + } + + @GetMapping("/test/queue/childCount/{taskId}") + ResponseEntity getChildQueueCount(@PathVariable String taskId) { + return ResponseEntity.ok(String.valueOf(queueManager.getActiveChildQueueCount(taskId))); + } + + @DeleteMapping("/test/task/{taskId}/config/{configId}") + ResponseEntity deletePushNotificationConfig(@PathVariable String taskId, @PathVariable String configId) { + pushNotificationConfigStore.deleteInfo(taskId, configId); + return ResponseEntity.ok().build(); + } + + @PostMapping(value = "/test/task/{taskId}", consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity savePushNotificationConfig(@PathVariable String taskId, @RequestBody String body) throws Exception { + TaskPushNotificationConfig notificationConfig = JsonUtil.fromJson(body, TaskPushNotificationConfig.class); + pushNotificationConfigStore.setInfo(TaskPushNotificationConfig.builder(notificationConfig).taskId(taskId).build()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/test/queue/awaitChildCountStable/{taskId}/{expectedCount}/{timeoutMs}") + ResponseEntity awaitChildCountStable(@PathVariable String taskId, + @PathVariable String expectedCount, + @PathVariable String timeoutMs) throws Exception { + int expected = Integer.parseInt(expectedCount); + long timeout = Long.parseLong(timeoutMs); + long end = System.currentTimeMillis() + timeout; + int consecutive = 0; + while (System.currentTimeMillis() < end) { + if (queueManager.getActiveChildQueueCount(taskId) == expected) { + consecutive++; + if (consecutive >= 3) { + return ResponseEntity.ok("true"); + } + } else { + consecutive = 0; + } + Thread.sleep(50); + } + return ResponseEntity.ok("false"); + } + } + + static final class TestAgentExecutor implements AgentExecutor { + + private final AgentCard agentCard; + private final RequestScopedBean requestScopedBean; + + TestAgentExecutor(AgentCard agentCard, RequestScopedBean requestScopedBean) { + this.agentCard = agentCard; + this.requestScopedBean = requestScopedBean; + } + + @Override + public void execute(org.a2aproject.sdk.server.agentexecution.RequestContext context, + org.a2aproject.sdk.server.tasks.AgentEmitter agentEmitter) throws A2AError { + String taskId = context.getTaskId(); + String input = context.getMessage() != null ? extractTextFromMessage(context.getMessage()) : ""; + + if (input.startsWith("request-scoped:")) { + agentEmitter.startWork(); + agentEmitter.addArtifact(List.of(new TextPart("request-scoped:" + requestScopedBean.getValue()))); + agentEmitter.complete(); + return; + } + if (input.startsWith("delegate:") || input.startsWith("a2a-local:")) { + handleAgentToAgentTest(context, agentEmitter); + return; + } + if (input.startsWith("multi-event:first")) { + agentEmitter.startWork(); + return; + } + if (input.startsWith("multi-event:second")) { + agentEmitter.addArtifact(List.of(new TextPart("Second message artifact")), "artifact-2", "Second Artifact", null); + agentEmitter.complete(); + return; + } + if (input.startsWith("input-required:")) { + String payload = input.substring("input-required:".length()); + if ("User input".equals(payload)) { + agentEmitter.complete(); + return; + } + agentEmitter.requiresInput(agentEmitter.newAgentMessage( + List.of(new TextPart("Please provide additional information")), + context.getMessage().metadata())); + return; + } + if (input.startsWith("auth-required:")) { + agentEmitter.requiresAuth(agentEmitter.newAgentMessage( + List.of(new TextPart("Please authenticate with OAuth provider")), + context.getMessage().metadata())); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InternalError("Auth simulation interrupted: " + e.getMessage()); + } + agentEmitter.complete(); + return; + } + if ("task-not-supported-123".equals(taskId)) { + throw new UnsupportedOperationError(); + } + if (input.startsWith("#a2a-delegated#")) { + String actualContent = input.substring("#a2a-delegated#".length()); + agentEmitter.startWork(); + agentEmitter.addArtifact(List.of(new TextPart("Handled locally: " + actualContent))); + agentEmitter.complete(); + return; + } + if (context.getMessage() != null) { + agentEmitter.sendMessage(context.getMessage()); + } else { + agentEmitter.addTask(context.getTask()); + } + } + + @Override + public void cancel(org.a2aproject.sdk.server.agentexecution.RequestContext context, + org.a2aproject.sdk.server.tasks.AgentEmitter agentEmitter) throws A2AError { + if ("cancel-task-123".equals(context.getTask().id())) { + agentEmitter.cancel(); + } else if ("cancel-task-not-supported-123".equals(context.getTask().id())) { + throw new UnsupportedOperationError(); + } + } + + private void handleAgentToAgentTest(org.a2aproject.sdk.server.agentexecution.RequestContext context, + org.a2aproject.sdk.server.tasks.AgentEmitter agentEmitter) throws A2AError { + try { + ServerCallContext callContext = context.getCallContext(); + if (callContext == null) { + agentEmitter.fail(new InternalError("No call context available for agent-to-agent test")); + return; + } + + TransportProtocol transportProtocol = (TransportProtocol) callContext.getState().get(TRANSPORT_KEY); + if (transportProtocol == null) { + agentEmitter.fail(new InternalError("Transport type not set in call context")); + return; + } + + String userInput = context.getUserInput("\n"); + if (userInput == null || userInput.isEmpty()) { + agentEmitter.fail(new InternalError("No user input received")); + return; + } + + if (userInput.startsWith("delegate:")) { + handleDelegation(userInput, transportProtocol, agentEmitter); + } else { + handleLocally(userInput.substring("a2a-local:".length()), agentEmitter); + } + } catch (Exception e) { + e.printStackTrace(); + agentEmitter.fail(new InternalError("Agent-to-agent test failed: " + e.getMessage())); + } + } + + private void handleDelegation(String userInput, TransportProtocol transportProtocol, + org.a2aproject.sdk.server.tasks.AgentEmitter agentEmitter) { + String delegatedContent = userInput.substring("delegate:".length()).trim(); + + try (org.a2aproject.sdk.client.Client client = AgentToAgentClientFactory.createClient(agentCard, transportProtocol)) { + agentEmitter.startWork(); + java.util.concurrent.atomic.AtomicReference taskRef = new java.util.concurrent.atomic.AtomicReference<>(); + Message delegatedMessage = Message.builder() + .role(Message.Role.ROLE_USER) + .parts(new TextPart("#a2a-delegated#" + delegatedContent)) + .build(); + client.sendMessage(delegatedMessage, List.of((event, card) -> { + if (event instanceof org.a2aproject.sdk.client.TaskEvent te) { + taskRef.set(te.getTask()); + } else if (event instanceof org.a2aproject.sdk.client.TaskUpdateEvent tue) { + taskRef.set(tue.getTask()); + } + }), null); + Task delegatedResult = taskRef.get(); + assertNotNull(delegatedResult); + if (!delegatedResult.status().state().isFinal()) { + String diagnostic = String.format( + "RACE CONDITION DETECTED: Blocking call returned non-final task! State: %s, TaskId: %s, Artifacts: %d.", + delegatedResult.status().state(), + delegatedResult.id(), + delegatedResult.artifacts() != null ? delegatedResult.artifacts().size() : 0); + System.err.println(diagnostic); + agentEmitter.fail(new InternalError(diagnostic)); + return; + } + if (delegatedResult.artifacts() != null) { + for (Artifact artifact : delegatedResult.artifacts()) { + agentEmitter.addArtifact(artifact.parts()); + } + } + agentEmitter.complete(); + } catch (A2AClientException e) { + agentEmitter.fail(new InternalError("Failed to create client: " + e.getMessage())); + } + } + + private void handleLocally(String userInput, + org.a2aproject.sdk.server.tasks.AgentEmitter agentEmitter) { + try { + agentEmitter.startWork(); + agentEmitter.addArtifact(List.of(new TextPart("Handled locally: " + userInput))); + agentEmitter.complete(); + } catch (Exception e) { + agentEmitter.fail(new InternalError("Local handling failed: " + e.getMessage())); + } + } + + private String extractTextFromMessage(Message message) { + StringBuilder textBuilder = new StringBuilder(); + if (message.parts() != null) { + for (var part : message.parts()) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.text()); + } + } + } + return textBuilder.toString(); + } + } + + static class RequestScopedBean { + + String getValue() { + return "request-scoped-value"; + } + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/README.md b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/README.md new file mode 100644 index 000000000..8375bfcb8 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/README.md @@ -0,0 +1,49 @@ +# A2A Java SDK - Spring Boot REST TCK SUT + +This module is a runnable Spring Boot REST system-under-test for the external A2A TCK. + +## What It Is For + +- Start a real Spring Boot REST server that exposes the A2A protocol endpoints. +- Feed that server to the external `a2a-tck` runner. +- Keep the REST SUT separate from JSON-RPC and gRPC so each transport can be validated independently later. + +## Run + +From the repository root: + +```bash +mvn -pl integrations/spring-boot/server/rest/spring-boot-server-rest-sut -am spring-boot:run +``` + +The SUT listens on `http://localhost:9999` by default. + +## TCK + +For the full end-to-end run, use the checked-in helper: + +```bash +bash ./scripts/run-spring-boot-rest-tck.sh +``` + +Point the external A2A TCK at the SUT URL: + +```text +http://localhost:9999 +``` + +The TCK should then verify the REST transport contract against the live Spring Boot app. + +If you have the external `a2a-tck` repository checked out, the manual run looks like this: + +```bash +uv run ./run_tck.py --sut-host http://localhost:9999 -v +``` + +For CI, see `.github/workflows/run-spring-boot-rest-tck.yml` and `integrations/spring-boot/server/rest/TCK.md`. + +## Build + +```bash +mvn -pl integrations/spring-boot/server/rest/spring-boot-server-rest-sut -am test +``` diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/pom.xml b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/pom.xml new file mode 100644 index 000000000..d38eb5260 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-server-rest-parent + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-spring-boot-server-rest-sut + jar + + A2A Java SDK - Spring Boot Server REST SUT + Runnable Spring Boot REST SUT for external A2A TCK validation + + + + ${project.groupId} + a2a-java-sdk-spring-boot-starter-server-rest + + + ${project.groupId} + a2a-java-sdk-client + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-test + test + + + org.springframework.boot + spring-boot-test-autoconfigure + test + + + org.junit.jupiter + junit-jupiter-api + 5.12.2 + test + + + org.assertj + assertj-core + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.a2aproject.sdk.integrations.springboot.server.tck.rest.SpringBootRestTckSutApplication + + + + + repackage + + + + + + + diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutAgentExecutor.java b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutAgentExecutor.java new file mode 100644 index 000000000..6f3322ab5 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutAgentExecutor.java @@ -0,0 +1,120 @@ +package org.a2aproject.sdk.integrations.springboot.server.tck.rest; + +import java.util.List; + +import org.a2aproject.sdk.A2A; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.FilePart; +import org.a2aproject.sdk.spec.FileWithBytes; +import org.a2aproject.sdk.spec.FileWithUri; +import org.a2aproject.sdk.spec.TextPart; +import org.springframework.stereotype.Component; + +@Component +public class SpringBootRestTckSutAgentExecutor implements AgentExecutor { + + @Override + public void execute(RequestContext context, AgentEmitter emitter) throws A2AError { + String messageId = context.getMessage() == null || context.getMessage().messageId() == null + ? "" + : context.getMessage().messageId(); + + if (messageId.startsWith("tck-stream-artifact-chunked")) { + emitter.startWork(); + emitter.addArtifact(List.of(new TextPart("chunk-1 ")), null, null, null, true, false); + emitter.addArtifact(List.of(new TextPart("chunk-2")), null, null, null, true, true); + emitter.complete(); + return; + } + if (messageId.startsWith("test-resubscribe-message-id")) { + emitter.startWork(); + try { + Thread.sleep(4000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + emitter.complete(); + return; + } + if (messageId.startsWith("tck-stream-artifact-text")) { + emitter.startWork(); + emitter.addArtifact(List.of(new TextPart("Streamed text content")), null, null, null); + emitter.complete(); + return; + } + if (messageId.startsWith("tck-stream-artifact-file")) { + emitter.startWork(); + emitter.addArtifact(List.of(new FilePart(new FileWithBytes("text/plain", "output.txt", "dGNr"))), null, null, null); + emitter.complete(); + return; + } + if (messageId.startsWith("tck-stream-ordering-001")) { + emitter.startWork(); + emitter.addArtifact(List.of(new TextPart("Ordered output")), null, null, null); + emitter.complete(); + return; + } + if (messageId.startsWith("tck-artifact-file-url")) { + emitter.addArtifact(List.of(new FilePart(new FileWithUri("text/plain", "output.txt", "https://example.com/output.txt"))), null, null, null); + emitter.complete(); + return; + } + if (messageId.startsWith("tck-message-response")) { + emitter.sendMessage(List.of(new TextPart("Direct message response"))); + return; + } + if (messageId.startsWith("tck-input-required")) { + emitter.requiresInput(); + return; + } + if (messageId.startsWith("tck-complete-task")) { + emitter.complete(A2A.toAgentMessage("Hello from TCK")); + return; + } + if (messageId.startsWith("tck-artifact-text")) { + emitter.addArtifact(List.of(new TextPart("Generated text content")), null, null, null); + emitter.complete(); + return; + } + if (messageId.startsWith("tck-artifact-file")) { + emitter.addArtifact(List.of(new FilePart(new FileWithBytes("text/plain", "output.txt", "dGNr"))), null, null, null); + emitter.complete(); + return; + } + if (messageId.startsWith("tck-artifact-data")) { + emitter.addArtifact(List.of(DataPart.fromJson("{\"key\": \"value\", \"count\": 42}")), null, null, null); + emitter.complete(); + return; + } + if (messageId.startsWith("tck-reject-task")) { + throw new A2AError(-1, "rejected", null); + } + if (messageId.startsWith("tck-stream-001")) { + emitter.startWork(); + emitter.addArtifact(List.of(new TextPart("Stream hello from TCK")), null, null, null); + emitter.complete(); + return; + } + if (messageId.startsWith("tck-stream-002")) { + emitter.complete(); + return; + } + if (messageId.startsWith("tck-stream-003")) { + emitter.startWork(); + emitter.addArtifact(List.of(new TextPart("Stream task lifecycle")), null, null, null); + emitter.complete(); + return; + } + + emitter.complete(A2A.toAgentMessage("Unhandled messageId prefix: " + messageId)); + } + + @Override + public void cancel(RequestContext context, AgentEmitter emitter) throws A2AError { + emitter.cancel(); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutApplication.java b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutApplication.java new file mode 100644 index 000000000..a0a81e798 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutApplication.java @@ -0,0 +1,12 @@ +package org.a2aproject.sdk.integrations.springboot.server.tck.rest; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringBootRestTckSutApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringBootRestTckSutApplication.class, args); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutConfiguration.java b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutConfiguration.java new file mode 100644 index 000000000..9920a7175 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutConfiguration.java @@ -0,0 +1,38 @@ +package org.a2aproject.sdk.integrations.springboot.server.tck.rest; + +import java.util.List; + +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentSkill; +import org.a2aproject.sdk.spec.TransportProtocol; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +public class SpringBootRestTckSutConfiguration { + + @Bean + public AgentCard agentCard() { + return AgentCard.builder() + .name("A2A Java SDK REST TCK SUT") + .description("Spring Boot REST system-under-test for A2A TCK validation") + .version("1.0.0") + .supportedInterfaces(List.of( + new AgentInterface(TransportProtocol.HTTP_JSON.asString(), "http://localhost:9999"))) + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of(AgentSkill.builder() + .id("tck") + .name("TCK Conformance") + .description("Handles A2A TCK conformance messages") + .tags(List.of("tck")) + .build())) + .build(); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/resources/application.yml b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/resources/application.yml new file mode 100644 index 000000000..3c5113474 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/main/resources/application.yml @@ -0,0 +1,2 @@ +server: + port: 9999 diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/test/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutSmokeTest.java b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/test/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutSmokeTest.java new file mode 100644 index 000000000..3739b777d --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest-sut/src/test/java/org/a2aproject/sdk/integrations/springboot/server/tck/rest/SpringBootRestTckSutSmokeTest.java @@ -0,0 +1,61 @@ +package org.a2aproject.sdk.integrations.springboot.server.tck.rest; + +import static org.a2aproject.sdk.common.A2AHeaders.A2A_VERSION; +import static org.assertj.core.api.Assertions.assertThat; + +import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = SpringBootRestTckSutApplication.class, webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +class SpringBootRestTckSutSmokeTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void servesAgentCard() throws Exception { + MvcResult result = mockMvc.perform(get("/.well-known/agent-card.json")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andReturn(); + + assertThat(result.getResponse().getContentAsString()).contains("A2A Java SDK REST TCK SUT"); + } + + @Test + void handlesTckMessageResponsePrefix() throws Exception { + MessageSendParams params = MessageSendParams.builder() + .message(Message.builder() + .role(Message.Role.ROLE_USER) + .messageId("tck-message-response-001") + .parts(new TextPart("hello")) + .build()) + .build(); + + MvcResult result = mockMvc.perform(post("/message:send") + .contentType(MediaType.APPLICATION_JSON) + .header(A2A_VERSION, AgentInterface.CURRENT_PROTOCOL_VERSION) + .content(JsonUtil.toJson(params))) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andReturn(); + + assertThat(result.getResponse().getContentAsString()).contains("Direct message response"); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/README.md b/integrations/spring-boot/server/rest/spring-boot-server-rest/README.md new file mode 100644 index 000000000..6866eb949 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/README.md @@ -0,0 +1,73 @@ +# A2A Java SDK - Spring Boot Server Web MVC + +This module provides the REST transport adapter for the A2A SDK. + +## Artifact + +- `a2a-java-sdk-spring-boot-server-rest` + +## Responsibilities + +- Expose the HTTP response mapper used by MVC endpoints. +- Expose the Spring MVC controller for A2A HTTP transport. +- Register servlet-specific auto-configuration. +- Centralize HTTP error mapping through `A2ASpringBootMvcExceptionHandler`. + +## Behavior + +- Activates only in Servlet web applications. +- Requires the runtime beans provided by `spring-boot-server-autoconfigure`. +- Keeps the HTTP paths and payload shapes aligned with the core SDK contract. + +Example `application.yml`: + +```yaml +a2a: + executor: + core-pool-size: 5 + max-pool-size: 50 + keep-alive-seconds: 60 + queue-capacity: 100 +``` + +## Endpoints + +The module currently exposes the standard A2A HTTP endpoints implemented by the MVC controller, including: + +- `/.well-known/agent-card.json` +- `POST /message:send` +- `POST /message:stream` +- `GET /tasks/{taskId}` +- `GET /tasks` +- `POST /tasks/{taskId}:cancel` +- `POST /tasks/{taskId}:subscribe` +- `POST /tasks/{taskId}/pushNotificationConfigs` +- `GET /tasks/{taskId}/pushNotificationConfigs/{configId}` +- `GET /tasks/{taskId}/pushNotificationConfigs` +- `DELETE /tasks/{taskId}/pushNotificationConfigs/{configId}` +- `GET /extendedAgentCard` + +Each endpoint also supports the optional tenant-prefixed form `/{tenant}/...` where applicable. + +## Extension Point + +Applications can replace the default mapper or controller by defining their own Spring beans. + +Example `@Configuration`: + +```java +@Configuration(proxyBeanMethods = false) +public class CustomA2ARestConfiguration { + + @Bean + public A2ASpringBootHttpResponseMapper a2aSpringBootHttpResponseMapper() { + return new A2ASpringBootHttpResponseMapper(); + } +} +``` + +## Build + +```bash +mvn -pl integrations/spring-boot/server/rest/spring-boot-server-rest -am test +``` diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/pom.xml b/integrations/spring-boot/server/rest/spring-boot-server-rest/pom.xml new file mode 100644 index 000000000..2789a3b18 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-server-rest-parent + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-spring-boot-server-rest + jar + + A2A Java SDK - Spring Boot Server REST + REST transport adapter for the A2A server runtime + + + + ${project.groupId} + a2a-java-sdk-spring-boot-server-autoconfigure + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework + spring-webmvc + + + jakarta.servlet + jakarta.servlet-api + provided + + + + org.springframework.boot + spring-boot-test + test + + + org.assertj + assertj-core + test + + + org.junit.jupiter + junit-jupiter-api + 5.12.2 + test + + + org.mockito + mockito-core + test + + + diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2APushNotificationConfigRequestMapper.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2APushNotificationConfigRequestMapper.java new file mode 100644 index 000000000..63b1ccff5 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2APushNotificationConfigRequestMapper.java @@ -0,0 +1,89 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import com.google.gson.JsonObject; + +import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; +import org.a2aproject.sdk.spec.AuthenticationInfo; +import org.a2aproject.sdk.spec.InvalidParamsError; +import org.a2aproject.sdk.spec.InvalidRequestError; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.jspecify.annotations.Nullable; + +/** + * Parses push-notification create requests into the A2A domain DTOs. + * + *

The mapper handles the small amount of request-body validation that is awkward to express + * directly in controller signatures: path/body ID consistency, tenant consistency, and the + * optional nested authentication payload. + */ +final class A2APushNotificationConfigRequestMapper { + + TaskPushNotificationConfig parseCreateRequest(String body, String taskId, @Nullable String tenant) { + if (body == null || body.isBlank()) { + throw new InvalidRequestError("Request body is required"); + } + JsonObject jsonObject = JsonUtil.OBJECT_MAPPER.fromJson(body, JsonObject.class); + if (jsonObject == null) { + throw new InvalidRequestError("Request body is required"); + } + + String bodyTaskId = readOptionalString(jsonObject, "taskId"); + if (!bodyTaskId.isBlank() && !bodyTaskId.equals(taskId)) { + throw new InvalidParamsError("Task ID in request body (" + bodyTaskId + + ") does not match task ID in URL path (" + taskId + ")."); + } + + String bodyTenant = readOptionalString(jsonObject, "tenant"); + if (!bodyTenant.isBlank() && tenant != null && !tenant.isBlank() && !bodyTenant.equals(tenant)) { + throw new InvalidParamsError("Tenant in request body (" + bodyTenant + + ") does not match tenant in URL path (" + tenant + ")."); + } + + AuthenticationInfo authentication = null; + if (jsonObject.has("authentication") && jsonObject.get("authentication").isJsonObject()) { + authentication = deserialize(jsonObject.get("authentication").toString(), AuthenticationInfo.class); + } + + String effectiveTenant = tenant; + if (effectiveTenant == null || effectiveTenant.isBlank()) { + effectiveTenant = bodyTenant.isBlank() ? null : bodyTenant; + } + + return TaskPushNotificationConfig.builder() + .id(readOptionalString(jsonObject, "id")) + .taskId(taskId) + .url(readRequiredString(jsonObject, "url")) + .token(readNullableString(jsonObject, "token")) + .authentication(authentication) + .tenant(effectiveTenant) + .build(); + } + + private String readRequiredString(JsonObject jsonObject, String fieldName) { + String value = readOptionalString(jsonObject, fieldName); + if (value.isBlank()) { + throw new InvalidParamsError("Missing required field: " + fieldName); + } + return value; + } + + private String readOptionalString(JsonObject jsonObject, String fieldName) { + if (!jsonObject.has(fieldName) || jsonObject.get(fieldName).isJsonNull()) { + return ""; + } + return jsonObject.get(fieldName).getAsString(); + } + + private @Nullable String readNullableString(JsonObject jsonObject, String fieldName) { + String value = readOptionalString(jsonObject, fieldName); + return value.isBlank() ? null : value; + } + + private T deserialize(String json, Class type) { + try { + return JsonUtil.fromJson(json, type); + } catch (org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException e) { + throw new InvalidParamsError(e.getMessage()); + } + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2AServletWebAutoConfiguration.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2AServletWebAutoConfiguration.java new file mode 100644 index 000000000..47ad0ab50 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2AServletWebAutoConfiguration.java @@ -0,0 +1,65 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import org.a2aproject.sdk.integrations.springboot.server.autoconfigure.A2ARuntimeAutoConfiguration; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.spec.AgentCard; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configures the servlet-based A2A transport adapter. + * + *

This configuration is only active in servlet web applications and only contributes the + * MVC-layer beans: the response mapper, exception handler, request mapper, and controller. + * The core runtime beans are provided separately by {@link A2ARuntimeAutoConfiguration}. + */ +@AutoConfiguration(after = A2ARuntimeAutoConfiguration.class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +public class A2AServletWebAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public A2ASpringBootHttpResponseMapper a2aSpringBootHttpResponseMapper() { + return new A2ASpringBootHttpResponseMapper(); + } + + @Bean + @ConditionalOnMissingBean + public A2ASpringBootMvcExceptionHandler a2aSpringBootMvcExceptionHandler(A2ASpringBootHttpResponseMapper responseMapper) { + return new A2ASpringBootMvcExceptionHandler(responseMapper); + } + + @Bean + @ConditionalOnMissingBean + public A2APushNotificationConfigRequestMapper a2aPushNotificationConfigRequestMapper() { + return new A2APushNotificationConfigRequestMapper(); + } + + @Bean + @ConditionalOnMissingBean + public A2ASpringBootMvcController a2aSpringBootMvcController( + @Qualifier("agentCard") ObjectProvider agentCardProvider, + @Qualifier("extendedAgentCard") ObjectProvider extendedAgentCard, + ObjectProvider requestHandlerProvider, + A2ASpringBootHttpResponseMapper responseMapper, + A2APushNotificationConfigRequestMapper pushNotificationConfigRequestMapper, + ObjectProvider streamingSubscriptionObserver + ) { + AgentCard agentCard = agentCardProvider.getIfAvailable(); + RequestHandler requestHandler = requestHandlerProvider.getIfAvailable(); + if (agentCard == null || requestHandler == null) { + return null; + } + return new A2ASpringBootMvcController( + agentCard, + extendedAgentCard, + requestHandler, + responseMapper, + pushNotificationConfigRequestMapper, + streamingSubscriptionObserver); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootHttpResponseMapper.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootHttpResponseMapper.java new file mode 100644 index 000000000..a5a1f966a --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootHttpResponseMapper.java @@ -0,0 +1,219 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Flow; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; +import org.a2aproject.sdk.grpc.utils.ProtoJsonUtils; +import org.a2aproject.sdk.grpc.utils.ProtoUtils; +import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; +import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.A2AErrorCodes; +import org.a2aproject.sdk.spec.EventKind; +import org.a2aproject.sdk.spec.InternalError; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.util.ErrorDetail; +import org.springframework.http.MediaType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.jspecify.annotations.Nullable; + +/** + * Serializes A2A runtime results into HTTP response bodies for Spring MVC. + * + *

The mapper keeps protocol serialization in one place so the controller can stay thin and + * focused on request extraction and delegation. It also owns the SSE conversion for streaming + * task responses. + */ +public final class A2ASpringBootHttpResponseMapper { + + private static final JsonFormat.Printer DEFAULT_PRINTER = JsonFormat.printer().alwaysPrintFieldsWithNoPresence(); + private static final JsonFormat.Printer STREAM_PRINTER = JsonFormat.printer().omittingInsignificantWhitespace(); + + ResponseEntity ok(Object body) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(toJson(body)); + } + + ResponseEntity ok(Object body, Map headers) { + ResponseEntity.BodyBuilder builder = ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON); + headers.forEach(builder::header); + return builder.body(toJson(body)); + } + + ResponseEntity created(Object body) { + return ResponseEntity.status(201) + .contentType(MediaType.APPLICATION_JSON) + .body(toJson(body)); + } + + ResponseEntity noContent() { + return ResponseEntity.noContent().build(); + } + + ResponseEntity error(A2AError error) { + int statusCode = mapErrorToHttpStatus(error); + return ResponseEntity.status(statusCode) + .contentType(MediaType.APPLICATION_JSON) + .body(toJson(new ErrorResponse(error))); + } + + ResponseEntity error(Throwable throwable) { + String message = throwable.getMessage() != null ? throwable.getMessage() : throwable.getClass().getName(); + return error(new InternalError(message)); + } + + ResponseEntity okTask(Task task) { + return okProto(ProtoUtils.ToProto.task(task)); + } + + ResponseEntity okSendMessage(EventKind event) { + return okProto(ProtoUtils.ToProto.taskOrMessage(event)); + } + + ResponseEntity okListTasks(ListTasksResult result) { + return okProto(ProtoUtils.ToProto.listTasksResult(result)); + } + + ResponseEntity okTaskPushNotificationConfig(TaskPushNotificationConfig config) { + return okProto(ProtoUtils.ToProto.taskPushNotificationConfig(config)); + } + + ResponseEntity createdTaskPushNotificationConfig(TaskPushNotificationConfig config) { + return createdProto(ProtoUtils.ToProto.taskPushNotificationConfig(config)); + } + + ResponseEntity okListTaskPushNotificationConfigs(ListTaskPushNotificationConfigsResult result) { + return okProto(ProtoUtils.ToProto.listTaskPushNotificationConfigsResponse(result)); + } + + SseEmitter toSseEmitter(Flow.Publisher publisher, ServerCallContext context) { + SseEmitter emitter = new SseEmitter(0L); + java.util.concurrent.atomic.AtomicReference subscriptionRef = + new java.util.concurrent.atomic.AtomicReference<>(); + context.setEventConsumerCancelCallback(() -> { + java.util.concurrent.Flow.Subscription subscription = subscriptionRef.get(); + if (subscription != null) { + subscription.cancel(); + } + }); + emitter.onCompletion(context::invokeEventConsumerCancelCallback); + emitter.onTimeout(context::invokeEventConsumerCancelCallback); + emitter.onError(throwable -> context.invokeEventConsumerCancelCallback()); + + publisher.subscribe(new java.util.concurrent.Flow.Subscriber<>() { + @Override + public void onSubscribe(java.util.concurrent.Flow.Subscription subscription) { + subscriptionRef.set(subscription); + subscription.request(1); + } + + @Override + public void onNext(StreamingEventKind item) { + try { + emitter.send(SseEmitter.event().data(toProtoJson(ProtoUtils.ToProto.taskOrMessageStream(item)))); + } catch (IOException e) { + java.util.concurrent.Flow.Subscription subscription = subscriptionRef.get(); + if (subscription != null) { + subscription.cancel(); + } + emitter.completeWithError(e); + return; + } + java.util.concurrent.Flow.Subscription subscription = subscriptionRef.get(); + if (subscription != null) { + subscription.request(1); + } + } + + @Override + public void onError(Throwable throwable) { + emitter.completeWithError(throwable); + } + + @Override + public void onComplete() { + emitter.complete(); + } + }); + return emitter; + } + + private String toJson(Object value) { + try { + return JsonUtil.toJson(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize A2A response", e); + } + } + + private static int mapErrorToHttpStatus(A2AError error) { + A2AErrorCodes errorCode = A2AErrorCodes.fromCode(error.getCode()); + if (errorCode != null) { + return errorCode.httpCode(); + } + return A2AErrorCodes.INTERNAL.httpCode(); + } + + private record ErrorResponse(ErrorBody error) { + private ErrorResponse(A2AError error) { + this(new ErrorBody(error)); + } + } + + private ResponseEntity okProto(com.google.protobuf.MessageOrBuilder proto) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(toProtoJson(DEFAULT_PRINTER, proto)); + } + + private ResponseEntity createdProto(com.google.protobuf.MessageOrBuilder proto) { + return ResponseEntity.status(201) + .contentType(MediaType.APPLICATION_JSON) + .body(toProtoJson(DEFAULT_PRINTER, proto)); + } + + private String toProtoJson(com.google.protobuf.MessageOrBuilder proto) { + return toProtoJson(STREAM_PRINTER, proto); + } + + private String toProtoJson(JsonFormat.Printer printer, com.google.protobuf.MessageOrBuilder proto) { + try { + return ProtoJsonUtils.toJson(printer, proto); + } catch (InvalidProtocolBufferException e) { + throw new IllegalStateException("Failed to serialize A2A protobuf response", e); + } + } + + private record ErrorBody(int code, String status, String message, List details) { + private ErrorBody(A2AError error) { + this( + mapErrorToHttpStatus(error), + A2AErrorCodes.fromCode(error.getCode()) != null + ? A2AErrorCodes.fromCode(error.getCode()).grpcStatus() + : A2AErrorCodes.INTERNAL.grpcStatus(), + error.getMessage() == null ? error.getClass().getName() : error.getMessage(), + List.of(ErrorDetail.of( + A2AErrorCodes.fromCode(error.getCode()) != null + ? A2AErrorCodes.fromCode(error.getCode()).name() + : A2AErrorCodes.INTERNAL.name(), + error.getDetails())) + ); + } + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcController.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcController.java new file mode 100644 index 000000000..592e1bad7 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcController.java @@ -0,0 +1,396 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import static org.a2aproject.sdk.common.A2AHeaders.A2A_EXTENSIONS; +import static org.a2aproject.sdk.common.A2AHeaders.A2A_VERSION; +import static org.a2aproject.sdk.spec.A2AMethods.CANCEL_TASK_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.GET_TASK_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.LIST_TASK_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.SEND_MESSAGE_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.SEND_STREAMING_MESSAGE_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.SUBSCRIBE_TO_TASK_METHOD; +import static org.a2aproject.sdk.server.ServerCallContext.EXECUTION_WRAPPER_KEY; +import static org.a2aproject.sdk.server.ServerCallContext.STRICT_CONTEXT_VALIDATION_KEY; +import static org.a2aproject.sdk.server.ServerCallContext.TRANSPORT_KEY; +import static org.a2aproject.sdk.server.auth.UnauthenticatedUser.INSTANCE; +import static org.a2aproject.sdk.spec.TransportProtocol.HTTP_JSON; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE; + +import java.security.Principal; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Flow; + +import jakarta.servlet.http.HttpServletRequest; + +import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.auth.AuthenticatedUser; +import org.a2aproject.sdk.server.extensions.A2AExtensions; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.server.version.A2AVersionValidator; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; +import org.a2aproject.sdk.spec.EventKind; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.InvalidParamsError; +import org.a2aproject.sdk.spec.InvalidRequestError; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.PushNotificationNotSupportedError; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.jspecify.annotations.Nullable; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * Spring MVC transport adapter for A2A server runtime. + * + *

This controller keeps HTTP handling thin: + *

    + *
  • extract request metadata into {@link ServerCallContext}
  • + *
  • delegate protocol work to {@link RequestHandler}
  • + *
  • serialize the A2A response/error contract back to JSON
  • + *
+ */ +@RestController +public class A2ASpringBootMvcController { + + private final AgentCard agentCard; + private final ObjectProvider extendedAgentCard; + private final RequestHandler requestHandler; + private final A2ASpringBootHttpResponseMapper responseMapper; + private final A2APushNotificationConfigRequestMapper pushNotificationConfigRequestMapper; + private final org.springframework.beans.factory.ObjectProvider streamingSubscriptionObserver; + private final Instant startupTime = Instant.now(); + + public A2ASpringBootMvcController(AgentCard agentCard, + ObjectProvider extendedAgentCard, + RequestHandler requestHandler, + A2ASpringBootHttpResponseMapper responseMapper, + A2APushNotificationConfigRequestMapper pushNotificationConfigRequestMapper, + org.springframework.beans.factory.ObjectProvider streamingSubscriptionObserver) { + this.agentCard = agentCard; + this.extendedAgentCard = extendedAgentCard; + this.requestHandler = requestHandler; + this.responseMapper = responseMapper; + this.pushNotificationConfigRequestMapper = pushNotificationConfigRequestMapper; + this.streamingSubscriptionObserver = streamingSubscriptionObserver; + } + + @GetMapping(value = "/.well-known/agent-card.json", produces = APPLICATION_JSON_VALUE) + public ResponseEntity getAgentCard() { + return responseMapper.ok(agentCard, createAgentCardHeaders()); + } + + @PostMapping(value = {"/message:send", "/{tenant}/message:send"}, produces = APPLICATION_JSON_VALUE) + public ResponseEntity sendMessage(@PathVariable(required = false) @Nullable String tenant, + @RequestBody String body, + HttpServletRequest request) { + String effectiveTenant = normalizeTenant(tenant); + ServerCallContext context = createCallContext(request, effectiveTenant, SEND_MESSAGE_METHOD); + A2AVersionValidator.validateProtocolVersion(agentCard, context); + A2AExtensions.validateRequiredExtensions(agentCard, context); + MessageSendParams params = deserialize(body, MessageSendParams.class); + EventKind result = requestHandler.onMessageSend(withTenant(params, effectiveTenant), context); + return responseMapper.okSendMessage(result); + } + + @PostMapping(value = {"/message:stream", "/{tenant}/message:stream"}, produces = TEXT_EVENT_STREAM_VALUE) + public SseEmitter sendMessageStream(@PathVariable(required = false) @Nullable String tenant, + @RequestBody String body, + HttpServletRequest request) { + requireStreamingSupported(); + String effectiveTenant = normalizeTenant(tenant); + ServerCallContext context = createCallContext(request, effectiveTenant, SEND_STREAMING_MESSAGE_METHOD); + A2AVersionValidator.validateProtocolVersion(agentCard, context); + A2AExtensions.validateRequiredExtensions(agentCard, context); + MessageSendParams params = deserialize(body, MessageSendParams.class); + MessageSendParams effectiveParams = withTenant(params, effectiveTenant); + requestHandler.validateRequestedTask(effectiveParams.message().taskId()); + Flow.Publisher publisher = requestHandler.onMessageSendStream(effectiveParams, context); + notifyStreamingSubscriptionStarted(); + return responseMapper.toSseEmitter(publisher, context); + } + + @GetMapping(value = {"/tasks/{taskId}", "/{tenant}/tasks/{taskId}"}, produces = APPLICATION_JSON_VALUE) + public ResponseEntity getTask(@PathVariable(required = false) @Nullable String tenant, + @PathVariable String taskId, + @RequestParam(required = false) @Nullable Integer historyLength, + HttpServletRequest request) { + String effectiveTenant = normalizeTenant(tenant); + ServerCallContext context = createCallContext(request, effectiveTenant, GET_TASK_METHOD); + Task result = requestHandler.onGetTask(new TaskQueryParams(taskId, historyLength, effectiveTenant), context); + return responseMapper.okTask(result); + } + + @GetMapping(value = {"/tasks", "/{tenant}/tasks"}, produces = APPLICATION_JSON_VALUE) + public ResponseEntity listTasks(@PathVariable(required = false) @Nullable String tenant, + @RequestParam(required = false) @Nullable String contextId, + @RequestParam(required = false) @Nullable String status, + @RequestParam(required = false) @Nullable Integer pageSize, + @RequestParam(required = false) @Nullable String pageToken, + @RequestParam(required = false) @Nullable Integer historyLength, + @RequestParam(required = false) @Nullable String statusTimestampAfter, + @RequestParam(required = false) @Nullable Boolean includeArtifacts, + HttpServletRequest request) { + String effectiveTenant = normalizeTenant(tenant); + ServerCallContext context = createCallContext(request, effectiveTenant, LIST_TASK_METHOD); + ListTasksParams.Builder builder = ListTasksParams.builder() + .contextId(contextId) + .pageSize(pageSize) + .pageToken(pageToken) + .historyLength(historyLength) + .includeArtifacts(includeArtifacts) + .tenant(effectiveTenant); + if (status != null && !status.isBlank()) { + builder.status(TaskState.valueOf(status.toUpperCase())); + } + if (statusTimestampAfter != null && !statusTimestampAfter.isBlank()) { + builder.statusTimestampAfter(Instant.parse(statusTimestampAfter)); + } + return responseMapper.okListTasks(requestHandler.onListTasks(builder.build(), context)); + } + + @PostMapping(value = {"/tasks/{taskId}:cancel", "/{tenant}/tasks/{taskId}:cancel"}, produces = APPLICATION_JSON_VALUE) + public ResponseEntity cancelTask(@PathVariable(required = false) @Nullable String tenant, + @PathVariable String taskId, + @RequestBody(required = false) @Nullable String body, + HttpServletRequest request) { + String effectiveTenant = normalizeTenant(tenant); + ServerCallContext context = createCallContext(request, effectiveTenant, CANCEL_TASK_METHOD); + Map metadata = body == null || body.isBlank() ? Map.of() + : JsonUtil.readMetadata(JsonUtil.OBJECT_MAPPER.fromJson(body, com.google.gson.JsonObject.class)); + return responseMapper.okTask(requestHandler.onCancelTask(new CancelTaskParams(taskId, effectiveTenant, metadata), context)); + } + + @PostMapping(value = {"/tasks/{taskId}:subscribe", "/{tenant}/tasks/{taskId}:subscribe"}, produces = TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribeToTask(@PathVariable(required = false) @Nullable String tenant, + @PathVariable String taskId, + HttpServletRequest request) { + requireStreamingSupported(); + String effectiveTenant = normalizeTenant(tenant); + ServerCallContext context = createCallContext(request, effectiveTenant, SUBSCRIBE_TO_TASK_METHOD); + requestHandler.validateRequestedTask(taskId); + Flow.Publisher publisher = requestHandler.onSubscribeToTask( + new TaskIdParams(taskId, effectiveTenant), context); + notifyStreamingSubscriptionStarted(); + return responseMapper.toSseEmitter(publisher, context); + } + + @PostMapping(value = {"/tasks/{taskId}/pushNotificationConfigs", "/{tenant}/tasks/{taskId}/pushNotificationConfigs"}, + produces = APPLICATION_JSON_VALUE) + public ResponseEntity createTaskPushNotificationConfiguration( + @PathVariable(required = false) @Nullable String tenant, + @PathVariable String taskId, + @RequestBody String body, + HttpServletRequest request) { + ensurePushNotificationsSupported(); + String effectiveTenant = normalizeTenant(tenant); + TaskPushNotificationConfig params = pushNotificationConfigRequestMapper.parseCreateRequest(body, taskId, effectiveTenant); + ServerCallContext context = createCallContext(request, effectiveTenant, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + return responseMapper.createdTaskPushNotificationConfig( + requestHandler.onCreateTaskPushNotificationConfig(params, context)); + } + + @GetMapping(value = {"/tasks/{taskId}/pushNotificationConfigs/{configId}", + "/{tenant}/tasks/{taskId}/pushNotificationConfigs/{configId}"}, produces = APPLICATION_JSON_VALUE) + public ResponseEntity getTaskPushNotificationConfiguration( + @PathVariable(required = false) @Nullable String tenant, + @PathVariable String taskId, + @PathVariable String configId, + HttpServletRequest request) { + ensurePushNotificationsSupported(); + String effectiveTenant = normalizeTenant(tenant); + ServerCallContext context = createCallContext(request, effectiveTenant, GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + return responseMapper.okTaskPushNotificationConfig(requestHandler.onGetTaskPushNotificationConfig( + new GetTaskPushNotificationConfigParams(taskId, configId, effectiveTenant), context)); + } + + @GetMapping(value = {"/tasks/{taskId}/pushNotificationConfigs", "/{tenant}/tasks/{taskId}/pushNotificationConfigs"}, + produces = APPLICATION_JSON_VALUE) + public ResponseEntity listTaskPushNotificationConfigurations( + @PathVariable(required = false) @Nullable String tenant, + @PathVariable String taskId, + @RequestParam(required = false) @Nullable Integer pageSize, + @RequestParam(required = false) @Nullable String pageToken, + HttpServletRequest request) { + ensurePushNotificationsSupported(); + String effectiveTenant = normalizeTenant(tenant); + ServerCallContext context = createCallContext(request, effectiveTenant, LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + return responseMapper.okListTaskPushNotificationConfigs(requestHandler.onListTaskPushNotificationConfigs( + new ListTaskPushNotificationConfigsParams(taskId, pageSize == null ? 0 : pageSize, + pageToken == null ? "" : pageToken, effectiveTenant), + context)); + } + + @DeleteMapping(value = {"/tasks/{taskId}/pushNotificationConfigs/{configId}", + "/{tenant}/tasks/{taskId}/pushNotificationConfigs/{configId}"}) + public ResponseEntity deleteTaskPushNotificationConfiguration( + @PathVariable(required = false) @Nullable String tenant, + @PathVariable String taskId, + @PathVariable String configId, + HttpServletRequest request) { + ensurePushNotificationsSupported(); + String effectiveTenant = normalizeTenant(tenant); + ServerCallContext context = createCallContext(request, effectiveTenant, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + requestHandler.onDeleteTaskPushNotificationConfig( + new DeleteTaskPushNotificationConfigParams(taskId, configId, effectiveTenant), context); + return responseMapper.noContent(); + } + + @GetMapping(value = {"/extendedAgentCard", "/{tenant}/extendedAgentCard"}, produces = APPLICATION_JSON_VALUE) + public ResponseEntity getExtendedAgentCard(@PathVariable(required = false) @Nullable String tenant, + HttpServletRequest request) { + String effectiveTenant = normalizeTenant(tenant); + ServerCallContext context = createCallContext(request, effectiveTenant, GET_EXTENDED_AGENT_CARD_METHOD); + if (agentCard.capabilities() == null || !agentCard.capabilities().extendedAgentCard()) { + throw new UnsupportedOperationError(); + } + AgentCard extendedCard = extendedAgentCard.getIfAvailable(); + if (extendedCard == null) { + throw new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null); + } + return responseMapper.ok(extendedCard); + } + + private MessageSendParams withTenant(MessageSendParams params, String tenant) { + return MessageSendParams.builder() + .message(params.message()) + .configuration(params.configuration()) + .metadata(params.metadata()) + .tenant(tenant) + .build(); + } + + private ServerCallContext createCallContext(HttpServletRequest request, String tenant, String methodName) { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + Map state = new HashMap<>(); + state.put(TRANSPORT_KEY, HTTP_JSON); + state.put(STRICT_CONTEXT_VALIDATION_KEY, Boolean.TRUE); + state.put(EXECUTION_WRAPPER_KEY, createExecutionWrapper(requestAttributes)); + state.put("method", methodName); + state.put("tenant", tenant); + state.put("headers", readHeaders(request)); + + List requestedExtensions = readHeaderValues(request, A2A_EXTENSIONS); + String requestedProtocolVersion = request.getHeader(A2A_VERSION); + Principal principal = request.getUserPrincipal(); + var user = principal == null ? INSTANCE : new AuthenticatedUser(principal.getName()); + return new ServerCallContext(user, state, A2AExtensions.getRequestedExtensions(requestedExtensions), + requestedProtocolVersion); + } + + private String normalizeTenant(@Nullable String tenant) { + return tenant == null ? "" : tenant; + } + + private java.util.function.UnaryOperator createExecutionWrapper(@Nullable RequestAttributes requestAttributes) { + if (requestAttributes == null) { + return runnable -> runnable; + } + return runnable -> () -> { + RequestAttributes previous = RequestContextHolder.getRequestAttributes(); + RequestContextHolder.setRequestAttributes(requestAttributes, false); + try { + runnable.run(); + } finally { + if (previous == null) { + RequestContextHolder.resetRequestAttributes(); + } else { + RequestContextHolder.setRequestAttributes(previous, false); + } + } + }; + } + + private Map readHeaders(HttpServletRequest request) { + Map headers = new HashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames != null && headerNames.hasMoreElements()) { + String name = headerNames.nextElement(); + headers.put(name, request.getHeader(name)); + } + return headers; + } + + private List readHeaderValues(HttpServletRequest request, String headerName) { + List values = new ArrayList<>(); + Enumeration headers = request.getHeaders(headerName); + while (headers != null && headers.hasMoreElements()) { + values.add(headers.nextElement()); + } + return values; + } + + private void ensurePushNotificationsSupported() throws PushNotificationNotSupportedError { + if (agentCard.capabilities() == null || !agentCard.capabilities().pushNotifications()) { + throw new PushNotificationNotSupportedError(); + } + } + + private void requireStreamingSupported() { + if (agentCard.capabilities() == null || !agentCard.capabilities().streaming()) { + throw new InvalidRequestError("Streaming is not supported by the agent"); + } + } + + private T deserialize(String json, Class type) { + if (json == null || json.isBlank()) { + throw new InvalidRequestError("Request body is required"); + } + try { + T result = JsonUtil.fromJson(json, type); + if (result == null) { + throw new InvalidRequestError("Request body is required"); + } + return result; + } catch (org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException e) { + throw new InvalidParamsError(e.getMessage()); + } + } + + private Map createAgentCardHeaders() { + return Map.of( + "Cache-Control", "public, max-age=300", + "ETag", "\"" + Integer.toHexString(agentCard.hashCode()) + "\"", + "Last-Modified", DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC).format(startupTime) + ); + } + + private void notifyStreamingSubscriptionStarted() { + streamingSubscriptionObserver.ifAvailable(StreamingSubscriptionObserver::onStreamingSubscription); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcExceptionHandler.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcExceptionHandler.java new file mode 100644 index 000000000..466dba9ee --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcExceptionHandler.java @@ -0,0 +1,41 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import java.time.format.DateTimeParseException; + +import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.InvalidParamsError; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Centralized exception mapping for the Spring MVC transport adapter. + * + *

This handler translates protocol and parsing errors into the A2A JSON error envelope rather + * than letting Spring Boot fall back to its default error response format. + */ +@RestControllerAdvice +public class A2ASpringBootMvcExceptionHandler { + + private final A2ASpringBootHttpResponseMapper responseMapper; + + public A2ASpringBootMvcExceptionHandler(A2ASpringBootHttpResponseMapper responseMapper) { + this.responseMapper = responseMapper; + } + + @ExceptionHandler(A2AError.class) + public ResponseEntity handleA2AError(A2AError error) { + return responseMapper.error(error); + } + + @ExceptionHandler({JsonProcessingException.class, IllegalArgumentException.class, DateTimeParseException.class}) + public ResponseEntity handleInvalidParams(Exception exception) { + return responseMapper.error(new InvalidParamsError(exception.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleThrowable(Exception throwable) { + return responseMapper.error(throwable); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/StreamingSubscriptionObserver.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/StreamingSubscriptionObserver.java new file mode 100644 index 000000000..a59e3d2ac --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/java/org/a2aproject/sdk/integrations/springboot/server/rest/StreamingSubscriptionObserver.java @@ -0,0 +1,13 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +/** + * Observer for streaming subscription startup events. + * + *

The bean is optional. Production applications do not need to define it, but tests and demos + * can use it to observe when a streaming response has been initialized. + */ +@FunctionalInterface +public interface StreamingSubscriptionObserver { + + void onStreamingSubscription(); +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..b9de92fcb --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.a2aproject.sdk.integrations.springboot.server.rest.A2AServletWebAutoConfiguration diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2APushNotificationConfigRequestMapperTest.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2APushNotificationConfigRequestMapperTest.java new file mode 100644 index 000000000..655ff9110 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2APushNotificationConfigRequestMapperTest.java @@ -0,0 +1,50 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.a2aproject.sdk.spec.InvalidParamsError; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.junit.jupiter.api.Test; + +class A2APushNotificationConfigRequestMapperTest { + + private final A2APushNotificationConfigRequestMapper mapper = new A2APushNotificationConfigRequestMapper(); + + @Test + void parsesCreateRequestWithOptionalIdAndTenant() { + TaskPushNotificationConfig config = mapper.parseCreateRequest(""" + { + "url": "https://example.com/webhook", + "token": "token-1", + "tenant": "tenant-a" + } + """, "task-123", "tenant-a"); + + assertEquals("", config.id()); + assertEquals("task-123", config.taskId()); + assertEquals("https://example.com/webhook", config.url()); + assertEquals("token-1", config.token()); + assertEquals("tenant-a", config.tenant()); + } + + @Test + void rejectsTaskIdConflicts() { + assertThrows(InvalidParamsError.class, () -> mapper.parseCreateRequest(""" + { + "taskId": "task-999", + "url": "https://example.com/webhook" + } + """, "task-123", "tenant-a")); + } + + @Test + void rejectsTenantConflicts() { + assertThrows(InvalidParamsError.class, () -> mapper.parseCreateRequest(""" + { + "tenant": "tenant-b", + "url": "https://example.com/webhook" + } + """, "task-123", "tenant-a")); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2AServletWebAutoConfigurationTest.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2AServletWebAutoConfigurationTest.java new file mode 100644 index 000000000..dbaf6d883 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2AServletWebAutoConfigurationTest.java @@ -0,0 +1,111 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.TransportProtocol; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.beans.factory.support.StaticListableBeanFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +class A2AServletWebAutoConfigurationTest { + + private final ApplicationContextRunner nonWebContextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2AServletWebAutoConfiguration.class)) + .withBean(AgentCard.class, this::agentCard) + .withBean(RequestHandler.class, () -> Mockito.mock(RequestHandler.class)); + + private final WebApplicationContextRunner webContextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2AServletWebAutoConfiguration.class)) + .withBean(AgentCard.class, this::agentCard) + .withBean(RequestHandler.class, () -> Mockito.mock(RequestHandler.class)); + + @Test + void doesNotCreateServletBeansInNonWebApplication() { + nonWebContextRunner.run(context -> { + assertFalse(context.containsBean("a2aSpringBootHttpResponseMapper")); + assertFalse(context.containsBean("a2aSpringBootMvcController")); + }); + } + + @Test + void createsServletBeansWhenWebApplicationAndDependenciesExist() { + webContextRunner.run(context -> { + assertNotNull(context.getBean(A2ASpringBootHttpResponseMapper.class)); + assertNotNull(context.getBean(A2ASpringBootMvcController.class)); + }); + } + + @Test + void respectsCustomMapperAndControllerBeans() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2AServletWebAutoConfiguration.class)) + .withUserConfiguration(CustomBeans.class) + .run(context -> { + assertEquals(1, context.getBeansOfType(A2ASpringBootHttpResponseMapper.class).size()); + assertEquals(1, context.getBeansOfType(A2ASpringBootMvcController.class).size()); + assertSame(context.getBean("customHttpResponseMapper"), context.getBean(A2ASpringBootHttpResponseMapper.class)); + assertSame(context.getBean("customMvcController"), context.getBean(A2ASpringBootMvcController.class)); + }); + } + + private AgentCard agentCard() { + return AgentCard.builder() + .name("Spring Boot Test Agent") + .description("Test agent for Spring Boot MVC transport") + .version("1.0.0") + .capabilities(AgentCapabilities.builder().streaming(true).pushNotifications(false).build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface(TransportProtocol.HTTP_JSON.asString(), "http://localhost:8080"))) + .build(); + } + + @Configuration(proxyBeanMethods = false) + static class CustomBeans { + + @Bean + AgentCard agentCard() { + return new A2AServletWebAutoConfigurationTest().agentCard(); + } + + @Bean + RequestHandler requestHandler() { + return Mockito.mock(RequestHandler.class); + } + + @Bean + A2ASpringBootHttpResponseMapper customHttpResponseMapper() { + return new A2ASpringBootHttpResponseMapper(); + } + + @Bean + A2ASpringBootMvcController customMvcController( + AgentCard agentCard, + RequestHandler requestHandler, + A2ASpringBootHttpResponseMapper responseMapper + ) { + return new A2ASpringBootMvcController( + agentCard, + new StaticListableBeanFactory().getBeanProvider(AgentCard.class), + requestHandler, + responseMapper, + new A2APushNotificationConfigRequestMapper(), + new StaticListableBeanFactory().getBeanProvider(StreamingSubscriptionObserver.class)); + } + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootHttpResponseMapperTest.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootHttpResponseMapperTest.java new file mode 100644 index 000000000..8b39dc42e --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootHttpResponseMapperTest.java @@ -0,0 +1,117 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.SubmissionPublisher; + +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.auth.UnauthenticatedUser; +import org.a2aproject.sdk.spec.InvalidParamsError; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +class A2ASpringBootHttpResponseMapperTest { + + private final A2ASpringBootHttpResponseMapper mapper = new A2ASpringBootHttpResponseMapper(); + + @Test + void serializesJsonBodies() { + Task task = task("task-1"); + + var response = mapper.okTask(task); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType()); + assertTrue(response.getBody().contains("\"task-1\"")); + } + + @Test + void serializesA2AErrors() { + var response = mapper.error(new InvalidParamsError("bad request")); + + assertEquals(422, response.getStatusCodeValue()); + assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType()); + assertTrue(response.getBody().contains("\"bad request\"")); + } + + @Test + void serializesThrowableWithoutMessageUsingFallbackClassName() { + var response = mapper.error(new RuntimeException()); + + assertEquals(500, response.getStatusCodeValue()); + assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType()); + assertTrue(response.getBody().contains(RuntimeException.class.getName())); + } + + @Test + void createsCreatedResponses() { + var response = mapper.createdTaskPushNotificationConfig( + org.a2aproject.sdk.spec.TaskPushNotificationConfig.builder() + .id("config-1") + .taskId("task-3") + .url("https://example.com/hook") + .build()); + + assertEquals(201, response.getStatusCodeValue()); + assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType()); + assertTrue(response.getBody().contains("\"task-3\"")); + } + + @Test + void createsNoContentResponses() { + var response = mapper.noContent(); + + assertEquals(204, response.getStatusCodeValue()); + assertEquals(null, response.getBody()); + } + + @Test + void createsSseEmitter() { + ServerCallContext context = serverCallContext(); + SubmissionPublisher publisher = new SubmissionPublisher<>(); + publisher.submit(Message.builder() + .role(Message.Role.ROLE_AGENT) + .messageId("msg-1") + .parts(new TextPart("ok")) + .build()); + publisher.close(); + + var emitter = mapper.toSseEmitter(publisher, context); + + assertTrue(emitter != null); + } + + @Test + void mapsSendMessageResponses() { + var taskResponse = mapper.okSendMessage(task("task-2")); + var messageResponse = mapper.okSendMessage(Message.builder() + .role(Message.Role.ROLE_AGENT) + .messageId("msg-1") + .parts(new TextPart("ok")) + .build()); + + assertEquals(200, taskResponse.getStatusCodeValue()); + assertEquals(200, messageResponse.getStatusCodeValue()); + assertTrue(taskResponse.getBody().contains("\"task\"")); + assertTrue(messageResponse.getBody().contains("\"message\"")); + } + + private Task task(String id) { + return Task.builder() + .id(id) + .contextId("ctx-1") + .status(new TaskStatus(TaskState.TASK_STATE_SUBMITTED)) + .build(); + } + + private ServerCallContext serverCallContext() { + return new ServerCallContext(UnauthenticatedUser.INSTANCE, java.util.Map.of(), java.util.Set.of(), null); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcControllerTest.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcControllerTest.java new file mode 100644 index 000000000..0aa712d5e --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcControllerTest.java @@ -0,0 +1,491 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import static org.a2aproject.sdk.common.A2AHeaders.A2A_VERSION; +import static org.a2aproject.sdk.spec.TransportProtocol.HTTP_JSON; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.security.Principal; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.SubmissionPublisher; + +import jakarta.servlet.http.HttpServletRequest; + +import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AuthenticationInfo; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; +import org.a2aproject.sdk.spec.InvalidRequestError; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.support.StaticListableBeanFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +class A2ASpringBootMvcControllerTest { + + private final RequestHandler requestHandler = mock(RequestHandler.class); + private final A2ASpringBootHttpResponseMapper responseMapper = new A2ASpringBootHttpResponseMapper(); + private final A2APushNotificationConfigRequestMapper pushNotificationConfigRequestMapper = + new A2APushNotificationConfigRequestMapper(); + private final ObjectProvider streamingSubscriptionObserver = + new StaticListableBeanFactory().getBeanProvider(StreamingSubscriptionObserver.class); + private final A2ASpringBootMvcController controller = + new A2ASpringBootMvcController( + agentCard(), + emptyExtendedAgentCardProvider(), + requestHandler, + responseMapper, + pushNotificationConfigRequestMapper, + streamingSubscriptionObserver); + + @Test + void servesAgentCardAsJson() { + ResponseEntity response = controller.getAgentCard(); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType()); + assertTrue(response.getBody().contains("\"Spring Boot Test Agent\"")); + } + + @Test + void routesSendMessageThroughRequestHandler() throws Exception { + Message requestMessage = Message.builder() + .role(Message.Role.ROLE_USER) + .messageId("msg-1") + .parts(new TextPart("hello")) + .build(); + MessageSendParams params = MessageSendParams.builder() + .message(requestMessage) + .metadata(Map.of("traceId", "trace-1")) + .build(); + String body = toJson(params); + + Message responseMessage = Message.builder() + .role(Message.Role.ROLE_AGENT) + .messageId("msg-2") + .parts(new TextPart("ok")) + .build(); + when(requestHandler.onMessageSend(any(MessageSendParams.class), any(ServerCallContext.class))).thenReturn(responseMessage); + + ResponseEntity response = controller.sendMessage("tenant-a", body, httpRequest()); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType()); + assertTrue(response.getBody().contains("\"msg-2\"")); + + var paramsCaptor = org.mockito.ArgumentCaptor.forClass(MessageSendParams.class); + var contextCaptor = org.mockito.ArgumentCaptor.forClass(ServerCallContext.class); + verify(requestHandler).onMessageSend(paramsCaptor.capture(), contextCaptor.capture()); + + assertEquals("tenant-a", paramsCaptor.getValue().tenant()); + assertEquals("trace-1", paramsCaptor.getValue().metadata().get("traceId")); + assertEquals(HTTP_JSON, contextCaptor.getValue().getState().get("transport")); + assertEquals("SendMessage", contextCaptor.getValue().getState().get("method")); + } + + @Test + void routesGetTaskThroughRequestHandler() { + Task task = Task.builder() + .id("task-123") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.TASK_STATE_SUBMITTED)) + .build(); + when(requestHandler.onGetTask(any(TaskQueryParams.class), any(ServerCallContext.class))).thenReturn(task); + + ResponseEntity response = controller.getTask("tenant-a", "task-123", 2, httpRequest()); + + assertEquals(200, response.getStatusCodeValue()); + assertTrue(response.getBody().contains("\"task-123\"")); + + var paramsCaptor = org.mockito.ArgumentCaptor.forClass(TaskQueryParams.class); + var contextCaptor = org.mockito.ArgumentCaptor.forClass(ServerCallContext.class); + verify(requestHandler).onGetTask(paramsCaptor.capture(), contextCaptor.capture()); + + assertEquals("task-123", paramsCaptor.getValue().id()); + assertEquals(2, paramsCaptor.getValue().historyLength()); + assertEquals("tenant-a", paramsCaptor.getValue().tenant()); + assertEquals("GetTask", contextCaptor.getValue().getState().get("method")); + } + + @Test + void routesCancelTaskThroughRequestHandler() { + Task task = Task.builder() + .id("task-123") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.TASK_STATE_CANCELED)) + .build(); + when(requestHandler.onCancelTask(any(CancelTaskParams.class), any(ServerCallContext.class))).thenReturn(task); + + ResponseEntity response = controller.cancelTask("tenant-a", "task-123", "{\"metadata\":{\"reason\":\"user_requested\"}}", httpRequest()); + + assertEquals(200, response.getStatusCodeValue()); + assertTrue(response.getBody().contains("\"task-123\"")); + + var paramsCaptor = org.mockito.ArgumentCaptor.forClass(CancelTaskParams.class); + var contextCaptor = org.mockito.ArgumentCaptor.forClass(ServerCallContext.class); + verify(requestHandler).onCancelTask(paramsCaptor.capture(), contextCaptor.capture()); + + assertEquals("task-123", paramsCaptor.getValue().id()); + assertEquals("tenant-a", paramsCaptor.getValue().tenant()); + assertEquals("user_requested", paramsCaptor.getValue().metadata().get("reason")); + assertEquals("CancelTask", contextCaptor.getValue().getState().get("method")); + } + + @Test + void routesCancelTaskWithBlankBodyThroughRequestHandler() { + Task task = Task.builder() + .id("task-123") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.TASK_STATE_CANCELED)) + .build(); + when(requestHandler.onCancelTask(any(CancelTaskParams.class), any(ServerCallContext.class))).thenReturn(task); + + ResponseEntity response = controller.cancelTask("tenant-a", "task-123", " ", httpRequest()); + + assertEquals(200, response.getStatusCodeValue()); + var paramsCaptor = org.mockito.ArgumentCaptor.forClass(CancelTaskParams.class); + verify(requestHandler).onCancelTask(paramsCaptor.capture(), any(ServerCallContext.class)); + assertEquals(Map.of(), paramsCaptor.getValue().metadata()); + } + + @Test + void routesListTasksThroughRequestHandler() { + when(requestHandler.onListTasks(any(), any())).thenReturn(new ListTasksResult(List.of(task("task-123")))); + + ResponseEntity response = controller.listTasks( + "tenant-a", + "ctx-1", + "task_state_submitted", + 10, + "token-1", + 2, + "2026-01-01T00:00:00Z", + true, + httpRequest()); + + assertEquals(200, response.getStatusCodeValue()); + assertTrue(response.getBody().contains("\"task-123\"")); + + var paramsCaptor = org.mockito.ArgumentCaptor.forClass(ListTasksParams.class); + verify(requestHandler).onListTasks(paramsCaptor.capture(), any(ServerCallContext.class)); + assertEquals("tenant-a", paramsCaptor.getValue().tenant()); + assertEquals("ctx-1", paramsCaptor.getValue().contextId()); + assertEquals(TaskState.TASK_STATE_SUBMITTED, paramsCaptor.getValue().status()); + assertEquals(10, paramsCaptor.getValue().pageSize()); + assertEquals("token-1", paramsCaptor.getValue().pageToken()); + assertEquals(2, paramsCaptor.getValue().historyLength()); + assertEquals(true, paramsCaptor.getValue().includeArtifacts()); + } + + @Test + void throwsForBadListStatus() { + assertThrows(IllegalArgumentException.class, () -> controller.listTasks( + "tenant-a", + null, + "not-a-status", + null, + null, + null, + null, + null, + httpRequest())); + } + + @Test + void rejectsBlankSendMessageBody() { + assertThrows(InvalidRequestError.class, () -> controller.sendMessage("tenant-a", " ", httpRequest())); + } + + @Test + void rejectsNullJsonSendMessageBody() { + assertThrows(InvalidRequestError.class, () -> controller.sendMessage("tenant-a", "null", httpRequest())); + } + + @Test + void routesStreamingMessageThroughRequestHandler() throws Exception { + SubmissionPublisher publisher = new SubmissionPublisher<>(); + when(requestHandler.onMessageSendStream(any(MessageSendParams.class), any(ServerCallContext.class))).thenReturn(publisher); + + SseEmitter response = controller.sendMessageStream("tenant-a", + toJson(MessageSendParams.builder() + .message(Message.builder() + .role(Message.Role.ROLE_USER) + .messageId("msg-1") + .parts(new TextPart("hello")) + .build()) + .build()), + httpRequest()); + + assertInstanceOf(SseEmitter.class, response); + } + + @Test + void throwsWhenStreamingCapabilityDisabled() { + A2ASpringBootMvcController nonStreamingController = new A2ASpringBootMvcController( + agentCard(false, false, false), + emptyExtendedAgentCardProvider(), + requestHandler, + responseMapper, + pushNotificationConfigRequestMapper, + streamingSubscriptionObserver); + + assertThrows(InvalidRequestError.class, () -> nonStreamingController.sendMessageStream("tenant-a", + toJson(MessageSendParams.builder() + .message(Message.builder() + .role(Message.Role.ROLE_USER) + .messageId("msg-1") + .parts(new TextPart("hello")) + .build()) + .build()), + httpRequest())); + } + + @Test + void routesTaskSubscriptionThroughRequestHandler() { + SubmissionPublisher publisher = new SubmissionPublisher<>(); + when(requestHandler.onSubscribeToTask(any(), any())).thenReturn(publisher); + + SseEmitter response = controller.subscribeToTask("tenant-a", "task-123", httpRequest()); + + assertInstanceOf(SseEmitter.class, response); + } + + @Test + void throwsWhenSubscribeStreamingCapabilityDisabled() { + A2ASpringBootMvcController nonStreamingController = new A2ASpringBootMvcController( + agentCard(false, false, false), + emptyExtendedAgentCardProvider(), + requestHandler, + responseMapper, + pushNotificationConfigRequestMapper, + streamingSubscriptionObserver); + + assertThrows(InvalidRequestError.class, () -> nonStreamingController.subscribeToTask("tenant-a", "task-123", httpRequest())); + } + + @Test + void routesCreatePushNotificationConfigurationThroughRequestHandler() { + TaskPushNotificationConfig storedConfig = TaskPushNotificationConfig.builder() + .id("config-1") + .taskId("task-123") + .url("https://example.com/webhook") + .token("token-1") + .authentication(new AuthenticationInfo("Bearer", "secret")) + .tenant("tenant-a") + .build(); + when(requestHandler.onCreateTaskPushNotificationConfig(any(TaskPushNotificationConfig.class), any(ServerCallContext.class))) + .thenReturn(storedConfig); + + ResponseEntity response = controller.createTaskPushNotificationConfiguration( + "tenant-a", + "task-123", + "{\"url\":\"https://example.com/webhook\",\"token\":\"token-1\",\"authentication\":{\"scheme\":\"Bearer\",\"credentials\":\"secret\"}}", + httpRequest()); + + assertEquals(201, response.getStatusCodeValue()); + assertTrue(response.getBody().contains("\"config-1\"")); + + var paramsCaptor = org.mockito.ArgumentCaptor.forClass(TaskPushNotificationConfig.class); + verify(requestHandler).onCreateTaskPushNotificationConfig(paramsCaptor.capture(), any(ServerCallContext.class)); + assertEquals("task-123", paramsCaptor.getValue().taskId()); + assertEquals("tenant-a", paramsCaptor.getValue().tenant()); + assertEquals("https://example.com/webhook", paramsCaptor.getValue().url()); + } + + @Test + void routesGetPushNotificationConfigurationThroughRequestHandler() { + TaskPushNotificationConfig storedConfig = TaskPushNotificationConfig.builder() + .id("config-1") + .taskId("task-123") + .url("https://example.com/webhook") + .tenant("tenant-a") + .build(); + when(requestHandler.onGetTaskPushNotificationConfig(any(GetTaskPushNotificationConfigParams.class), any(ServerCallContext.class))) + .thenReturn(storedConfig); + + ResponseEntity response = controller.getTaskPushNotificationConfiguration( + "tenant-a", "task-123", "config-1", httpRequest()); + + assertEquals(200, response.getStatusCodeValue()); + assertTrue(response.getBody().contains("\"config-1\"")); + verify(requestHandler).onGetTaskPushNotificationConfig(eq(new GetTaskPushNotificationConfigParams("task-123", "config-1", "tenant-a")), + any(ServerCallContext.class)); + } + + @Test + void routesListPushNotificationConfigurationsThroughRequestHandler() { + TaskPushNotificationConfig storedConfig = TaskPushNotificationConfig.builder() + .id("config-1") + .taskId("task-123") + .url("https://example.com/webhook") + .tenant("tenant-a") + .build(); + when(requestHandler.onListTaskPushNotificationConfigs(any(ListTaskPushNotificationConfigsParams.class), any(ServerCallContext.class))) + .thenReturn(new ListTaskPushNotificationConfigsResult(List.of(storedConfig), "next-token")); + + ResponseEntity response = controller.listTaskPushNotificationConfigurations( + "tenant-a", "task-123", 10, "token-1", httpRequest()); + + assertEquals(200, response.getStatusCodeValue()); + assertTrue(response.getBody().contains("\"config-1\"")); + verify(requestHandler).onListTaskPushNotificationConfigs( + eq(new ListTaskPushNotificationConfigsParams("task-123", 10, "token-1", "tenant-a")), + any(ServerCallContext.class)); + } + + @Test + void routesDeletePushNotificationConfigurationThroughRequestHandler() { + ResponseEntity response = controller.deleteTaskPushNotificationConfiguration( + "tenant-a", "task-123", "config-1", httpRequest()); + + assertEquals(204, response.getStatusCodeValue()); + verify(requestHandler).onDeleteTaskPushNotificationConfig( + eq(new DeleteTaskPushNotificationConfigParams("task-123", "config-1", "tenant-a")), + any(ServerCallContext.class)); + } + + @Test + void servesExtendedAgentCardWhenAvailable() { + AgentCard extended = agentCard(true, true, true); + A2ASpringBootMvcController extendedCardController = new A2ASpringBootMvcController( + agentCard(true, true, true), + singleExtendedAgentCardProvider(extended), + requestHandler, + responseMapper, + pushNotificationConfigRequestMapper, + streamingSubscriptionObserver); + + ResponseEntity response = extendedCardController.getExtendedAgentCard("tenant-a", httpRequest()); + + assertEquals(200, response.getStatusCodeValue()); + assertTrue(response.getBody().contains("\"Spring Boot Test Agent\"")); + } + + @Test + void throwsWhenExtendedAgentCardConfiguredButBeanMissing() { + A2ASpringBootMvcController extendedCardController = new A2ASpringBootMvcController( + agentCard(true, true, true), + emptyExtendedAgentCardProvider(), + requestHandler, + responseMapper, + pushNotificationConfigRequestMapper, + streamingSubscriptionObserver); + + assertThrows(ExtendedAgentCardNotConfiguredError.class, + () -> extendedCardController.getExtendedAgentCard("tenant-a", httpRequest())); + } + + @Test + void throwsWhenExtendedAgentCardCapabilityDisabled() { + A2ASpringBootMvcController extendedCardController = new A2ASpringBootMvcController( + agentCard(true, true, false), + emptyExtendedAgentCardProvider(), + requestHandler, + responseMapper, + pushNotificationConfigRequestMapper, + streamingSubscriptionObserver); + + assertThrows(UnsupportedOperationError.class, + () -> extendedCardController.getExtendedAgentCard("tenant-a", httpRequest())); + } + + private AgentCard agentCard() { + return agentCard(true, true, false); + } + + private AgentCard agentCard(boolean streaming, boolean pushNotifications, boolean extendedAgentCard) { + return AgentCard.builder() + .name("Spring Boot Test Agent") + .description("Test agent for Spring Boot MVC transport") + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(streaming) + .pushNotifications(pushNotifications) + .extendedAgentCard(extendedAgentCard) + .extensions(List.of(AgentExtension.builder() + .description("Test extension") + .uri("trace") + .required(false) + .build())) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface(HTTP_JSON.asString(), "http://localhost:8080"))) + .build(); + } + + private Task task(String taskId) { + return Task.builder() + .id(taskId) + .contextId(taskId + "-context") + .status(new TaskStatus(TaskState.TASK_STATE_SUBMITTED)) + .build(); + } + + private HttpServletRequest httpRequest() { + HttpServletRequest request = mock(HttpServletRequest.class); + Map> headers = Map.of( + A2A_VERSION, List.of("1.0"), + "A2A-Extensions", List.of("trace"), + "X-Trace-Id", List.of("trace-1")); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(headers.keySet())); + when(request.getHeader(any(String.class))).thenAnswer(invocation -> { + String headerName = invocation.getArgument(0, String.class); + List values = headers.get(headerName); + return values == null || values.isEmpty() ? null : values.get(0); + }); + when(request.getHeaders(any(String.class))).thenAnswer(invocation -> { + String headerName = invocation.getArgument(0, String.class); + List values = headers.get(headerName); + return Collections.enumeration(values == null ? List.of() : values); + }); + when(request.getUserPrincipal()).thenReturn((Principal) () -> "alice"); + return request; + } + + private String toJson(Object value) throws Exception { + return JsonUtil.toJson(value); + } + + private ObjectProvider emptyExtendedAgentCardProvider() { + return new StaticListableBeanFactory().getBeanProvider(AgentCard.class); + } + + private ObjectProvider singleExtendedAgentCardProvider(AgentCard agentCard) { + return new StaticListableBeanFactory(Map.of("extendedAgentCard", agentCard)).getBeanProvider(AgentCard.class); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcExceptionHandlerTest.java b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcExceptionHandlerTest.java new file mode 100644 index 000000000..34ac70917 --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-server-rest/src/test/java/org/a2aproject/sdk/integrations/springboot/server/rest/A2ASpringBootMvcExceptionHandlerTest.java @@ -0,0 +1,39 @@ +package org.a2aproject.sdk.integrations.springboot.server.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.format.DateTimeParseException; + +import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; +import org.junit.jupiter.api.Test; + +class A2ASpringBootMvcExceptionHandlerTest { + + private final A2ASpringBootMvcExceptionHandler handler = + new A2ASpringBootMvcExceptionHandler(new A2ASpringBootHttpResponseMapper()); + + @Test + void mapsA2AErrorsThroughResponseMapper() { + var response = handler.handleA2AError(new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null)); + + assertEquals(400, response.getStatusCodeValue()); + assertTrue(response.getBody().contains("Extended Card not configured")); + } + + @Test + void mapsInvalidParamsExceptionsToInvalidParamsError() { + var response = handler.handleInvalidParams(new DateTimeParseException("bad timestamp", "abc", 0)); + + assertEquals(422, response.getStatusCodeValue()); + assertTrue(response.getBody().contains("bad timestamp")); + } + + @Test + void mapsThrowableToInternalError() { + var response = handler.handleThrowable(new RuntimeException("boom")); + + assertEquals(500, response.getStatusCodeValue()); + assertTrue(response.getBody().contains("boom")); + } +} diff --git a/integrations/spring-boot/server/rest/spring-boot-starter-server-rest/README.md b/integrations/spring-boot/server/rest/spring-boot-starter-server-rest/README.md new file mode 100644 index 000000000..081381ebd --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-starter-server-rest/README.md @@ -0,0 +1,46 @@ +# A2A Java SDK - Spring Boot Server REST Starter + +This module is a dependency-only starter for Spring Boot REST/MVC applications. + +## Artifact + +- `a2a-java-sdk-spring-boot-starter-server-rest` + +## Transitive Dependencies + +- `a2a-java-sdk-spring-boot-server-autoconfigure` +- `a2a-java-sdk-spring-boot-server-rest` +- `spring-boot-starter-web` + +## Purpose + +Add this starter to get the full Spring Boot server integration with a single dependency. + +Example `application.yml`: + +```yaml +a2a: + executor: + core-pool-size: 5 + max-pool-size: 50 + keep-alive-seconds: 60 + queue-capacity: 100 + blocking: + agent-timeout-seconds: 30 + consumption-timeout-seconds: 5 +``` + +## Application Requirements + +The application should still provide: + +- `AgentCard` +- `AgentExecutor` + +Those beans drive the agent identity and runtime execution logic. + +## Build + +```bash +mvn -pl integrations/spring-boot/server/rest/spring-boot-starter-server-rest -am test +``` diff --git a/integrations/spring-boot/server/rest/spring-boot-starter-server-rest/pom.xml b/integrations/spring-boot/server/rest/spring-boot-starter-server-rest/pom.xml new file mode 100644 index 000000000..85ebf5f8c --- /dev/null +++ b/integrations/spring-boot/server/rest/spring-boot-starter-server-rest/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-server-rest-parent + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-spring-boot-starter-server-rest + jar + + A2A Java SDK - Spring Boot Server REST Starter + Dependency-only starter for REST integration + + + + ${project.groupId} + a2a-java-sdk-spring-boot-server-autoconfigure + + + ${project.groupId} + a2a-java-sdk-spring-boot-server-rest + + + org.springframework.boot + spring-boot-starter-web + + + diff --git a/integrations/spring-boot/server/spring-boot-server-autoconfigure/README.md b/integrations/spring-boot/server/spring-boot-server-autoconfigure/README.md new file mode 100644 index 000000000..4b72e3edd --- /dev/null +++ b/integrations/spring-boot/server/spring-boot-server-autoconfigure/README.md @@ -0,0 +1,90 @@ +# A2A Java SDK - Spring Boot Server AutoConfiguration + +This module provides Spring Boot auto-configuration for the A2A server runtime layer. + +## Artifact + +- `a2a-java-sdk-spring-boot-server-autoconfigure` + +## Responsibilities + +- Bind `a2a.*` configuration properties. +- Adapt Spring `Environment` to the A2A `A2AConfigProvider` contract. +- Provide runtime beans for the server core: + - `DefaultValuesConfigProvider` + - `A2AConfigProvider` + - `TaskStore` + - `MainEventBus` + - `QueueManager` + - `MainEventBusProcessor` + - `PushNotificationConfigStore` + - `PushNotificationSender` + - internal executor beans + - `RequestHandler` + +## Configuration Properties + +The module binds these `a2a.*` properties: + +| Property | Default | Purpose | +| --- | --- | --- | +| `a2a.executor.core-pool-size` | `5` | Core size of the internal executor. | +| `a2a.executor.max-pool-size` | `50` | Maximum size of the internal executor. | +| `a2a.executor.keep-alive-seconds` | `60` | Idle timeout for extra executor threads. | +| `a2a.executor.queue-capacity` | `100` | Queue size for the internal executor. | +| `a2a.blocking.agent-timeout-seconds` | `30` | Timeout for agent-side blocking operations. | +| `a2a.blocking.consumption-timeout-seconds` | `5` | Timeout for event consumption operations. | +| `a2a.agent-card.cache.max-age` | `3600` | Agent card cache max age in seconds. | + +Example `application.yml`: + +```yaml +a2a: + executor: + core-pool-size: 5 + max-pool-size: 50 + keep-alive-seconds: 60 + queue-capacity: 100 + blocking: + agent-timeout-seconds: 30 + consumption-timeout-seconds: 5 + agent-card: + cache: + max-age: 3600 +``` + +## Bean Overrides + +Application beans override the defaults when they are present in the Spring context. + +### Runtime beans + +- `TaskStore` +- `MainEventBus` +- `QueueManager` +- `MainEventBusProcessor` +- `PushNotificationConfigStore` +- `PushNotificationSender` +- `RequestHandler` + +### Executor beans + +- `a2aInternalExecutor` +- `a2aEventConsumerExecutor` + +### Configuration provider chain + +`A2AConfigProvider` reads from the Spring `Environment` first and falls back to the classpath defaults loaded by `DefaultValuesConfigProvider`. + +## Notes + +- This module does not depend on Servlet APIs. +- It is safe to use in non-web Spring Boot applications. +- `RequestHandler` is created only when the application provides an `AgentExecutor`. +- The module registers its auto-configuration through `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. + +## Build + +```bash +mvn -pl integrations/spring-boot/server/spring-boot-server-autoconfigure -am test +``` diff --git a/integrations/spring-boot/server/spring-boot-server-autoconfigure/pom.xml b/integrations/spring-boot/server/spring-boot-server-autoconfigure/pom.xml new file mode 100644 index 000000000..768e598f8 --- /dev/null +++ b/integrations/spring-boot/server/spring-boot-server-autoconfigure/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-spring-boot-server + 1.1.0.Final + ../pom.xml + + + a2a-java-sdk-spring-boot-server-autoconfigure + jar + + A2A Java SDK - Spring Boot Server AutoConfiguration + Spring Boot auto-configuration for the A2A server runtime + + + + ${project.groupId} + a2a-java-sdk-server-common + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-test + test + + + org.assertj + assertj-core + test + + + org.junit.jupiter + junit-jupiter-api + 5.12.2 + test + + + diff --git a/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2AEventConsumerThreadFactory.java b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2AEventConsumerThreadFactory.java new file mode 100644 index 000000000..0d3180b5b --- /dev/null +++ b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2AEventConsumerThreadFactory.java @@ -0,0 +1,16 @@ +package org.a2aproject.sdk.integrations.springboot.server.autoconfigure; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +final class A2AEventConsumerThreadFactory implements ThreadFactory { + + private final AtomicInteger threadNumber = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "a2a-event-consumer-" + threadNumber.getAndIncrement()); + thread.setDaemon(true); + return thread; + } +} diff --git a/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2AInternalThreadFactory.java b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2AInternalThreadFactory.java new file mode 100644 index 000000000..f344ec319 --- /dev/null +++ b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2AInternalThreadFactory.java @@ -0,0 +1,16 @@ +package org.a2aproject.sdk.integrations.springboot.server.autoconfigure; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +final class A2AInternalThreadFactory implements ThreadFactory { + + private final AtomicInteger threadNumber = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "a2a-agent-executor-" + threadNumber.getAndIncrement()); + thread.setDaemon(false); + return thread; + } +} diff --git a/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2ARuntimeAutoConfiguration.java b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2ARuntimeAutoConfiguration.java new file mode 100644 index 000000000..c9c5f43d1 --- /dev/null +++ b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2ARuntimeAutoConfiguration.java @@ -0,0 +1,161 @@ +package org.a2aproject.sdk.integrations.springboot.server.autoconfigure; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.config.A2AConfigProvider; +import org.a2aproject.sdk.server.config.DefaultValuesConfigProvider; +import org.a2aproject.sdk.server.events.InMemoryQueueManager; +import org.a2aproject.sdk.server.events.MainEventBus; +import org.a2aproject.sdk.server.events.MainEventBusProcessor; +import org.a2aproject.sdk.server.events.QueueManager; +import org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.server.tasks.BasePushNotificationSender; +import org.a2aproject.sdk.server.tasks.InMemoryPushNotificationConfigStore; +import org.a2aproject.sdk.server.tasks.InMemoryTaskStore; +import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; +import org.a2aproject.sdk.server.tasks.PushNotificationSender; +import org.a2aproject.sdk.server.tasks.TaskStateProvider; +import org.a2aproject.sdk.server.tasks.TaskStore; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.Environment; + +/** + * Auto-configures the A2A runtime layer for Spring Boot applications. + * + *

This configuration owns the core server runtime beans only: config providers, task store, + * event bus, queue manager, push-notification infrastructure, internal executors, and the + * default {@link RequestHandler} wiring when an {@link AgentExecutor} is available. + * + *

The class deliberately does not depend on Servlet or Spring MVC APIs so the runtime layer + * can activate in non-web Spring Boot applications as well. + */ +@AutoConfiguration +@EnableConfigurationProperties(A2ASpringBootProperties.class) +public class A2ARuntimeAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public DefaultValuesConfigProvider defaultValuesConfigProvider() { + return new DefaultValuesConfigProvider(); + } + + @Bean + @Primary + @ConditionalOnMissingBean(value = A2AConfigProvider.class, ignored = DefaultValuesConfigProvider.class) + public A2AConfigProvider a2aConfigProvider( + Environment environment, + DefaultValuesConfigProvider defaultValuesConfigProvider + ) { + return new SpringEnvironmentA2AConfigProvider(environment, defaultValuesConfigProvider); + } + + @Bean + @ConditionalOnMissingBean(TaskStore.class) + public TaskStore taskStore() { + return new InMemoryTaskStore(); + } + + @Bean + @ConditionalOnMissingBean(MainEventBus.class) + public MainEventBus mainEventBus() { + return new MainEventBus(); + } + + @Bean + @ConditionalOnMissingBean(QueueManager.class) + public QueueManager queueManager(TaskStore taskStore, MainEventBus mainEventBus) { + if (!(taskStore instanceof TaskStateProvider taskStateProvider)) { + throw new IllegalStateException( + "Spring Boot A2A runtime requires a TaskStore that also implements TaskStateProvider."); + } + return new InMemoryQueueManager(taskStateProvider, mainEventBus); + } + + @Bean + @ConditionalOnMissingBean(MainEventBusProcessor.class) + public MainEventBusProcessor mainEventBusProcessor( + MainEventBus mainEventBus, + TaskStore taskStore, + PushNotificationSender pushNotificationSender, + QueueManager queueManager + ) { + return new MainEventBusProcessor(mainEventBus, taskStore, pushNotificationSender, queueManager); + } + + @Bean + @ConditionalOnMissingBean(PushNotificationConfigStore.class) + public PushNotificationConfigStore pushNotificationConfigStore() { + return new InMemoryPushNotificationConfigStore(); + } + + @Bean + @ConditionalOnMissingBean(PushNotificationSender.class) + public PushNotificationSender pushNotificationSender(PushNotificationConfigStore pushNotificationConfigStore) { + return new BasePushNotificationSender(pushNotificationConfigStore); + } + + @Bean(name = "a2aInternalExecutor", destroyMethod = "shutdown") + @ConditionalOnMissingBean(name = "a2aInternalExecutor") + public ExecutorService a2aInternalExecutor(A2ASpringBootProperties properties) { + A2ASpringBootProperties.Executor executor = properties.getExecutor(); + ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( + executor.getCorePoolSize(), + executor.getMaxPoolSize(), + executor.getKeepAliveSeconds(), + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(executor.getQueueCapacity()), + new A2AInternalThreadFactory()); + threadPoolExecutor.allowCoreThreadTimeOut(true); + return threadPoolExecutor; + } + + @Bean(name = "a2aEventConsumerExecutor", destroyMethod = "shutdown") + @ConditionalOnMissingBean(name = "a2aEventConsumerExecutor") + public ExecutorService a2aEventConsumerExecutor() { + return new ThreadPoolExecutor( + 0, + Integer.MAX_VALUE, + 10, + TimeUnit.SECONDS, + new java.util.concurrent.SynchronousQueue<>(), + new A2AEventConsumerThreadFactory()); + } + + @Bean + @ConditionalOnMissingBean(RequestHandler.class) + public RequestHandler requestHandler( + ObjectProvider agentExecutorProvider, + TaskStore taskStore, + QueueManager queueManager, + PushNotificationConfigStore pushNotificationConfigStore, + MainEventBusProcessor mainEventBusProcessor, + @Qualifier("a2aInternalExecutor") Executor internalExecutor, + @Qualifier("a2aEventConsumerExecutor") Executor eventConsumerExecutor + ) { + AgentExecutor agentExecutor = agentExecutorProvider.getIfAvailable(); + if (agentExecutor == null) { + return null; + } + return new DefaultRequestHandler( + agentExecutor, + taskStore, + queueManager, + pushNotificationConfigStore, + mainEventBusProcessor, + internalExecutor, + eventConsumerExecutor + ); + } +} diff --git a/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2ASpringBootProperties.java b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2ASpringBootProperties.java new file mode 100644 index 000000000..d77558b64 --- /dev/null +++ b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2ASpringBootProperties.java @@ -0,0 +1,108 @@ +package org.a2aproject.sdk.integrations.springboot.server.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Spring Boot configuration properties for the A2A server runtime. + * + *

The properties are intentionally aligned with the core {@code a2a.*} configuration keys + * already understood by the underlying server modules: + *

    + *
  • {@code a2a.executor.*} for async worker pool sizing
  • + *
  • {@code a2a.blocking.*} for blocking request timeout behavior
  • + *
  • {@code a2a.agent-card.cache.max-age} for discovery caching
  • + *
+ * + *

Keeping this class narrow avoids inventing a second Spring-specific configuration surface. + */ +@ConfigurationProperties(prefix = "a2a") +public class A2ASpringBootProperties { + + private final Executor executor = new Executor(); + private final Blocking blocking = new Blocking(); + private final AgentCardCache agentCardCache = new AgentCardCache(); + + public Executor getExecutor() { + return executor; + } + + public Blocking getBlocking() { + return blocking; + } + + public AgentCardCache getAgentCardCache() { + return agentCardCache; + } + + public static class Executor { + private int corePoolSize = 5; + private int maxPoolSize = 50; + private long keepAliveSeconds = 60; + private int queueCapacity = 100; + + public int getCorePoolSize() { + return corePoolSize; + } + + public void setCorePoolSize(int corePoolSize) { + this.corePoolSize = corePoolSize; + } + + public int getMaxPoolSize() { + return maxPoolSize; + } + + public void setMaxPoolSize(int maxPoolSize) { + this.maxPoolSize = maxPoolSize; + } + + public long getKeepAliveSeconds() { + return keepAliveSeconds; + } + + public void setKeepAliveSeconds(long keepAliveSeconds) { + this.keepAliveSeconds = keepAliveSeconds; + } + + public int getQueueCapacity() { + return queueCapacity; + } + + public void setQueueCapacity(int queueCapacity) { + this.queueCapacity = queueCapacity; + } + } + + public static class Blocking { + private long agentTimeoutSeconds = 30; + private long consumptionTimeoutSeconds = 5; + + public long getAgentTimeoutSeconds() { + return agentTimeoutSeconds; + } + + public void setAgentTimeoutSeconds(long agentTimeoutSeconds) { + this.agentTimeoutSeconds = agentTimeoutSeconds; + } + + public long getConsumptionTimeoutSeconds() { + return consumptionTimeoutSeconds; + } + + public void setConsumptionTimeoutSeconds(long consumptionTimeoutSeconds) { + this.consumptionTimeoutSeconds = consumptionTimeoutSeconds; + } + } + + public static class AgentCardCache { + private long maxAge = 3600; + + public long getMaxAge() { + return maxAge; + } + + public void setMaxAge(long maxAge) { + this.maxAge = maxAge; + } + } +} diff --git a/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/SpringEnvironmentA2AConfigProvider.java b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/SpringEnvironmentA2AConfigProvider.java new file mode 100644 index 000000000..54ab97210 --- /dev/null +++ b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/SpringEnvironmentA2AConfigProvider.java @@ -0,0 +1,58 @@ +package org.a2aproject.sdk.integrations.springboot.server.autoconfigure; + +import java.util.Optional; + +import org.a2aproject.sdk.server.config.A2AConfigProvider; +import org.a2aproject.sdk.server.config.DefaultValuesConfigProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.env.Environment; + +/** + * {@link A2AConfigProvider} backed by the Spring {@link Environment}. + * + *

Spring property sources win first. If a key is absent there, this provider falls back to + * the default values bundled with the SDK via {@link DefaultValuesConfigProvider}. That keeps the + * runtime behavior identical to the core server while still letting Spring Boot override values + * from {@code application.yml}, environment variables, command-line args, or any other Spring + * property source. + */ +final class SpringEnvironmentA2AConfigProvider implements A2AConfigProvider { + + private static final Logger LOGGER = LoggerFactory.getLogger(SpringEnvironmentA2AConfigProvider.class); + + private final Environment environment; + private final DefaultValuesConfigProvider defaultValuesConfigProvider; + + SpringEnvironmentA2AConfigProvider(Environment environment, DefaultValuesConfigProvider defaultValuesConfigProvider) { + this.environment = environment; + this.defaultValuesConfigProvider = defaultValuesConfigProvider; + } + + @Override + public String getValue(String name) { + String value = environment.getProperty(name); + if (value != null) { + LOGGER.trace("Config value '{}' = '{}' (from Spring Environment)", name, value); + return value; + } + + String defaultValue = defaultValuesConfigProvider.getValue(name); + LOGGER.trace("Config value '{}' = '{}' (from DefaultValuesConfigProvider)", name, defaultValue); + return defaultValue; + } + + @Override + public Optional getOptionalValue(String name) { + String value = environment.getProperty(name); + if (value != null) { + LOGGER.trace("Optional config value '{}' = '{}' (from Spring Environment)", name, value); + return Optional.of(value); + } + + Optional defaultValue = defaultValuesConfigProvider.getOptionalValue(name); + LOGGER.trace("Optional config value '{}' = '{}' (from DefaultValuesConfigProvider)", + name, defaultValue.orElse("")); + return defaultValue; + } +} diff --git a/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..11219d9bd --- /dev/null +++ b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.a2aproject.sdk.integrations.springboot.server.autoconfigure.A2ARuntimeAutoConfiguration diff --git a/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/test/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2ARuntimeAutoConfigurationTest.java b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/test/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2ARuntimeAutoConfigurationTest.java new file mode 100644 index 000000000..adaeca58b --- /dev/null +++ b/integrations/spring-boot/server/spring-boot-server-autoconfigure/src/test/java/org/a2aproject/sdk/integrations/springboot/server/autoconfigure/A2ARuntimeAutoConfigurationTest.java @@ -0,0 +1,164 @@ +package org.a2aproject.sdk.integrations.springboot.server.autoconfigure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.config.A2AConfigProvider; +import org.a2aproject.sdk.server.config.DefaultValuesConfigProvider; +import org.a2aproject.sdk.server.events.MainEventBus; +import org.a2aproject.sdk.server.events.MainEventBusProcessor; +import org.a2aproject.sdk.server.events.QueueManager; +import org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.server.tasks.InMemoryTaskStore; +import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; +import org.a2aproject.sdk.server.tasks.PushNotificationSender; +import org.a2aproject.sdk.server.tasks.TaskStore; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.TransportProtocol; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class A2ARuntimeAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2ARuntimeAutoConfiguration.class)) + .withBean(AgentCard.class, this::agentCard) + .withPropertyValues( + "a2a.executor.core-pool-size=1", + "a2a.executor.max-pool-size=1", + "a2a.executor.queue-capacity=1", + "a2a.executor.keep-alive-seconds=1"); + + @Test + void createsCoreRuntimeBeansWithoutWebEnvironment() { + contextRunner.run(context -> { + assertNotNull(context.getBean(DefaultValuesConfigProvider.class)); + assertNotNull(context.getBean(A2AConfigProvider.class)); + assertNotNull(context.getBean(TaskStore.class)); + assertNotNull(context.getBean(MainEventBus.class)); + assertNotNull(context.getBean(QueueManager.class)); + assertNotNull(context.getBean(MainEventBusProcessor.class)); + assertNotNull(context.getBean(PushNotificationConfigStore.class)); + assertNotNull(context.getBean(PushNotificationSender.class)); + assertTrue(context.getBeansOfType(RequestHandler.class).isEmpty()); + }); + } + + @Test + void createsRequestHandlerOnlyWhenAgentExecutorIsPresent() { + contextRunner.withBean(AgentExecutor.class, this::agentExecutor).run(context -> { + assertInstanceOf(DefaultRequestHandler.class, context.getBean(RequestHandler.class)); + }); + } + + @Test + void doesNotCreateRequestHandlerWithoutAgentExecutor() { + contextRunner.run(context -> assertTrue(context.getBeansOfType(RequestHandler.class).isEmpty())); + } + + @Test + void preservesCustomTaskStoreAndExecutorBeans() { + contextRunner + .withBean(TaskStore.class, CustomTaskStore::new) + .withBean("a2aInternalExecutor", ExecutorService.class, MarkerExecutorService::new) + .withBean("a2aEventConsumerExecutor", ExecutorService.class, MarkerExecutorService::new) + .withBean(AgentExecutor.class, this::agentExecutor) + .run(context -> { + assertInstanceOf(CustomTaskStore.class, context.getBean(TaskStore.class)); + assertInstanceOf(MarkerExecutorService.class, context.getBean("a2aInternalExecutor")); + assertInstanceOf(MarkerExecutorService.class, context.getBean("a2aEventConsumerExecutor")); + assertInstanceOf(DefaultRequestHandler.class, context.getBean(RequestHandler.class)); + }); + } + + @Test + void springEnvironmentOverridesClasspathDefaults() { + contextRunner.run(context -> { + A2AConfigProvider provider = context.getBean(A2AConfigProvider.class); + assertEquals("1", provider.getValue("a2a.executor.core-pool-size")); + }); + } + + private AgentCard agentCard() { + return AgentCard.builder() + .name("Spring Boot Test Agent") + .description("Test agent for Spring Boot integration") + .version("1.0.0") + .capabilities(AgentCapabilities.builder().streaming(true).pushNotifications(false).build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface(TransportProtocol.HTTP_JSON.asString(), "http://localhost:8080"))) + .build(); + } + + private AgentExecutor agentExecutor() { + return new AgentExecutor() { + @Override + public void execute(RequestContext context, AgentEmitter emitter) throws A2AError { + emitter.sendMessage(new TextPart("ok").text()); + emitter.complete(); + } + + @Override + public void cancel(RequestContext context, AgentEmitter emitter) throws A2AError { + emitter.cancel(); + } + }; + } + + static final class CustomTaskStore extends InMemoryTaskStore { + } + + static final class MarkerExecutorService extends AbstractExecutorService { + + private boolean shutdown; + + @Override + public void shutdown() { + shutdown = true; + } + + @Override + public List shutdownNow() { + shutdown = true; + return List.of(); + } + + @Override + public boolean isShutdown() { + return shutdown; + } + + @Override + public boolean isTerminated() { + return shutdown; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + + @Override + public void execute(Runnable command) { + command.run(); + } + } +} diff --git a/pom.xml b/pom.xml index 41c018a4c..df89be39e 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,7 @@ 1.52.0 5.23.0 6.1.0 + 1.18.46 1.1.1 1.7.1 4.33.2 @@ -356,7 +357,11 @@ ch.qos.logback logback-classic ${logback.version} - test + + + org.projectlombok + lombok + ${lombok.version} ${project.groupId} @@ -444,6 +449,11 @@ protobuf-spi-impl ${mapstruct-spi-protobuf.version} + + org.projectlombok + lombok + ${lombok.version} +