diff --git a/.github/scripts/update_sdk_version.sh b/.github/scripts/update_sdk_version.sh index fba6ff9af..f66d2adf2 100755 --- a/.github/scripts/update_sdk_version.sh +++ b/.github/scripts/update_sdk_version.sh @@ -27,4 +27,7 @@ mvn versions:set -DnewVersion=$DAPR_JAVA_SDK_ALPHA_VERSION -f testcontainers-dap # dapr-spring mvn versions:set -DnewVersion=$DAPR_JAVA_SDK_ALPHA_VERSION -f dapr-spring/pom.xml +# spring-boot-examples +mvn versions:set -DnewVersion=$DAPR_JAVA_SDK_ALPHA_VERSION -f spring-boot-examples/pom.xml + git clean -f diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8d1e23187..4e606d00f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -164,3 +164,7 @@ jobs: working-directory: ./examples run: | mm.py ./src/main/java/io/dapr/examples/pubsub/stream/README.md + - name: Validate Spring Boot examples + working-directory: ./spring-boot-examples + run: | + mm.py README.md diff --git a/README.md b/README.md index 780c66412..f962cbc08 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This is the Dapr SDK for Java, including the following features: * Binding * State Store * Actors +* Workflows ## Getting Started @@ -112,6 +113,13 @@ Try the following examples to learn more about Dapr's Java SDK: * [Exception handling](./examples/src/main/java/io/dapr/examples/exception) * [Unit testing](./examples/src/main/java/io/dapr/examples/unittesting) +### Running Spring Boot examples + +The Spring Boot integration for Dapr use [Testcontainers](https://testcontainers.com) to set up a local environment development flow that doesn't +require the use of the `dapr` CLI and it integrates with the Spring Boot programming model. + +You can find a [step-by-step tutorial showing this integration here](./spring-boot-examples/README.md). + ### API Documentation Please, refer to our [Javadoc](https://dapr.github.io/java-sdk/) website. diff --git a/pom.xml b/pom.xml index 1448ac448..53b2908e2 100644 --- a/pom.xml +++ b/pom.xml @@ -337,6 +337,7 @@ sdk-springboot dapr-spring examples + spring-boot-examples testcontainers-dapr diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerIT.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerIT.java index ed7c14ee5..b617bfdf6 100644 --- a/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerIT.java @@ -63,9 +63,10 @@ public class DaprContainerIT { @Container private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) - .withAppName("dapr-app") - .withAppPort(8081) - .withAppChannelAddress("host.testcontainers.internal"); + .withAppName("dapr-app") + .withAppPort(8081) + .withAppHealthCheckPath("/actuator/health") + .withAppChannelAddress("host.testcontainers.internal"); /** * Sets the Dapr properties for the test. @@ -77,18 +78,21 @@ public class DaprContainerIT { } private void configStub() { + stubFor(any(urlMatching("/actuator/health")) + .willReturn(aResponse().withBody("[]").withStatus(200))); + stubFor(any(urlMatching("/dapr/subscribe")) - .willReturn(aResponse().withBody("[]").withStatus(200))); + .willReturn(aResponse().withBody("[]").withStatus(200))); stubFor(get(urlMatching("/dapr/config")) - .willReturn(aResponse().withBody("[]").withStatus(200))); + .willReturn(aResponse().withBody("[]").withStatus(200))); stubFor(any(urlMatching("/([a-z1-9]*)")) - .willReturn(aResponse().withBody("[]").withStatus(200))); + .willReturn(aResponse().withBody("[]").withStatus(200))); // create a stub stubFor(post(urlEqualTo("/events")) - .willReturn(aResponse().withBody("event received!").withStatus(200))); + .willReturn(aResponse().withBody("event received!").withStatus(200))); configureFor("localhost", 8081); } @@ -96,13 +100,13 @@ public class DaprContainerIT { @Test public void testDaprContainerDefaults() { assertEquals(2, - DAPR_CONTAINER.getComponents().size(), - "The pubsub and kvstore component should be configured by default" + DAPR_CONTAINER.getComponents().size(), + "The pubsub and kvstore component should be configured by default" ); assertEquals( - 1, - DAPR_CONTAINER.getSubscriptions().size(), - "A subscription should be configured by default if none is provided" + 1, + DAPR_CONTAINER.getSubscriptions().size(), + "A subscription should be configured by default if none is provided" ); } @@ -129,10 +133,10 @@ public class DaprContainerIT { Thread.sleep(1000); OkHttpClient okHttpClient = new OkHttpClient.Builder() - .build(); + .build(); Request request = new Request.Builder() - .url(DAPR_CONTAINER.getHttpEndpoint() + "/v1.0/metadata") - .build(); + .url(DAPR_CONTAINER.getHttpEndpoint() + "/v1.0/metadata") + .build(); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { @@ -158,7 +162,7 @@ public class DaprContainerIT { private DaprClientBuilder createDaprClientBuilder() { return new DaprClientBuilder() - .withPropertyOverride(Properties.HTTP_ENDPOINT, DAPR_CONTAINER.getHttpEndpoint()) - .withPropertyOverride(Properties.GRPC_ENDPOINT, DAPR_CONTAINER.getGrpcEndpoint()); + .withPropertyOverride(Properties.HTTP_ENDPOINT, DAPR_CONTAINER.getHttpEndpoint()) + .withPropertyOverride(Properties.GRPC_ENDPOINT, DAPR_CONTAINER.getGrpcEndpoint()); } } diff --git a/spring-boot-examples/README.md b/spring-boot-examples/README.md new file mode 100644 index 000000000..3cc88610d --- /dev/null +++ b/spring-boot-examples/README.md @@ -0,0 +1,176 @@ +# Dapr Spring Boot and Testcontainers integration Example + +This example consists of two applications: +- Producer App: + - Publish messages using a Spring Messaging approach + - Store and retrieve information using Spring Data CrudRepository + - Implements a Workflow with Dapr Workflows +- Consumer App: + - Subscribe to messages + +## Running these examples from source code + +To run these examples you will need: +- Java SDK +- Maven +- Docker or a container runtime such as Podman + +From the `spring-boot-examples/` directory you can start each service using the test configuration that uses +[Testcontainers](https://testcontainers.com) to boostrap [Dapr](https://dapr.io) by running the following command: + + + + +```sh +cd producer-app/ +../../mvnw -Dspring-boot.run.arguments="--reuse=true" spring-boot:test-run +``` + + + +This will start the `producer-app` with Dapr services and the infrastructure needed by the application to run, +in this case RabbitMQ and PostgreSQL. The `producer-app` starts on port `8080` by default. + +The `-Dspring-boot.run.arguments="--reuse=true"` flag helps the application to connect to an existing shared +infrastructure if it already exists. For development purposes, and to connect both applications we will set the flag +in both. For more details check the `DaprTestContainersConfig.java` classes in both, the `producer-app` and the `consumer-app`. + +Then run in a different terminal: + + + + +```sh +cd consumer-app/ +../../mvnw -Dspring-boot.run.arguments="--reuse=true" spring-boot:test-run +``` + + +The `consumer-app` starts in port `8081` by default. + +## Interacting with the applications + +Now that both applications are up you can place an order by sending a POST request to `:8080/orders/` +You can use `curl` to send a POST request to the `producer-app`: + + + + + +```sh +curl -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{ "item": "the mars volta EP", "amount": 1 }' +``` + + + + +If you check the `producer-app` logs you should see the following lines: + +```bash +... +Storing Order: Order{id='null', item='the mars volta EP', amount=1} +Publishing Order Event: Order{id='d4f8ea15-b774-441e-bcd2-7a4208a80bec', item='the mars volta EP', amount=1} + +``` + +If you check the `consumer-app` logs you should see the following lines, showing that the message +published by the `producer-app` was correctly consumed by the `consumer-app`: + +```bash +Order Event Received: Order{id='d4f8ea15-b774-441e-bcd2-7a4208a80bec', item='the mars volta EP', amount=1} +``` + +Next, you can create a new customer to trigger the customer's tracking workflow: + + + + +```sh +curl -X POST localhost:8080/customers -H 'Content-Type: application/json' -d '{ "customerName": "salaboy" }' +``` + + + + +A new Workflow Instance was created to track the customers interactions. Now, the workflow instance +is waiting for the customer to request a follow-up. + +You should see in the `producer-app` logs: + +```bash +Workflow instance started +Let's register the customer: salaboy +Customer: salaboy registered. +Let's wait for the customer: salaboy to request a follow up. +``` + +Send an event simulating the customer request for a follow-up: + + + + +```sh +curl -X POST localhost:8080/customers/followup -H 'Content-Type: application/json' -d '{ "customerName": "salaboy" }' +``` + + + +In the `producer-app` logs you should see that the workflow instance id moved forward to the Customer Follow Up activity: + +```bash +Customer follow-up requested: salaboy +Let's book a follow up for the customer: salaboy +Customer: salaboy follow-up done. +Congratulations the customer: salaboy is happy! +``` + +## Running on Kubernetes + +You can run the same example on a Kubernetes cluster. [Check the Kubernetes tutorial here](kubernetes/README.md). diff --git a/spring-boot-examples/consumer-app/pom.xml b/spring-boot-examples/consumer-app/pom.xml new file mode 100644 index 000000000..ebbffe52a --- /dev/null +++ b/spring-boot-examples/consumer-app/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + + io.dapr + spring-boot-examples + 0.14.0-SNAPSHOT + + + consumer-app + consumer-app + Spring Boot, Testcontainers and Dapr Integration Examples :: Consumer App + + + 3.2.6 + + + + + + org.springframework.boot + spring-boot-dependencies + ${springboot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + io.dapr.spring + dapr-spring-boot-starter + ${dapr-java-sdk.alpha-version} + + + + io.dapr.spring + dapr-spring-boot-starter + ${dapr.sdk.alpha.version} + + + + io.dapr.spring + dapr-spring-boot-starter-test + ${dapr.sdk.alpha.version} + test + + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + 1.20.0 + test + + + org.testcontainers + rabbitmq + 1.20.0 + test + + + org.testcontainers + kafka + 1.20.0 + test + + + + io.rest-assured + rest-assured + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/ConsumerApplication.java b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/ConsumerApplication.java new file mode 100644 index 000000000..fbf4e005a --- /dev/null +++ b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/ConsumerApplication.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ConsumerApplication { + + public static void main(String[] args) { + SpringApplication.run(ConsumerApplication.class, args); + } + +} diff --git a/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/Order.java b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/Order.java new file mode 100644 index 000000000..80fc2d0f2 --- /dev/null +++ b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/Order.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +public class Order { + private String id; + private String item; + private Integer amount; + + public Order() { + } + + /** + * Creates a new Order. + * + * @param id order id + * @param item item reference + * @param amount of items in the order + */ + public Order(String id, String item, Integer amount) { + this.id = id; + this.item = item; + this.amount = amount; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getItem() { + return item; + } + + public void setItem(String item) { + this.item = item; + } + + public Integer getAmount() { + return amount; + } + + public void setAmount(Integer amount) { + this.amount = amount; + } + + @Override + public String toString() { + return "Order{" + "id='" + id + '\'' + ", item='" + item + '\'' + ", amount=" + amount + '}'; + } +} diff --git a/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/SubscriberRestController.java b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/SubscriberRestController.java new file mode 100644 index 000000000..42617699f --- /dev/null +++ b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/SubscriberRestController.java @@ -0,0 +1,52 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import io.dapr.Topic; +import io.dapr.client.domain.CloudEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.RestController; + +import java.util.ArrayList; +import java.util.List; + +@RestController +public class SubscriberRestController { + + private final Logger logger = LoggerFactory.getLogger(SubscriberRestController.class); + + private List events = new ArrayList<>(); + + /** + * Subscribe to cloud events. + * @param cloudEvent payload + */ + @PostMapping("subscribe") + @Topic(pubsubName = "pubsub", name = "topic") + public void subscribe(@RequestBody CloudEvent cloudEvent) { + logger.info("Order Event Received: " + cloudEvent.getData()); + events.add(cloudEvent); + } + + @GetMapping("events") + public List getAllEvents() { + return events; + } + +} + diff --git a/spring-boot-examples/consumer-app/src/main/resources/application.properties b/spring-boot-examples/consumer-app/src/main/resources/application.properties new file mode 100644 index 000000000..b01c2106d --- /dev/null +++ b/spring-boot-examples/consumer-app/src/main/resources/application.properties @@ -0,0 +1,4 @@ +dapr.pubsub.name=pubsub +spring.application.name=consumer-app +server.port=8081 + diff --git a/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTestConfiguration.java b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTestConfiguration.java new file mode 100644 index 000000000..6a5f8a84d --- /dev/null +++ b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTestConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import io.dapr.client.DaprClient; +import io.dapr.spring.boot.autoconfigure.pubsub.DaprPubSubProperties; +import io.dapr.spring.messaging.DaprMessagingTemplate; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({DaprPubSubProperties.class}) +public class ConsumerAppTestConfiguration { + @Bean + public DaprMessagingTemplate messagingTemplate(DaprClient daprClient, + DaprPubSubProperties daprPubSubProperties) { + return new DaprMessagingTemplate<>(daprClient, daprPubSubProperties.getName(), false); + } +} diff --git a/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTests.java b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTests.java new file mode 100644 index 000000000..e7e6f0c03 --- /dev/null +++ b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import io.dapr.client.DaprClient; +import io.dapr.spring.messaging.DaprMessagingTemplate; +import io.dapr.springboot.DaprAutoConfiguration; +import io.dapr.testcontainers.DaprContainer; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.io.IOException; +import java.time.Duration; + +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.equalTo; + + +@SpringBootTest(classes = {TestConsumerApplication.class, DaprTestContainersConfig.class, + ConsumerAppTestConfiguration.class, DaprAutoConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +class ConsumerAppTests { + + private static final String SUBSCRIPTION_MESSAGE_PATTERN = ".*app is subscribed to the following topics.*"; + + @Autowired + private DaprMessagingTemplate messagingTemplate; + + @Autowired + private SubscriberRestController subscriberRestController; + + @Autowired + private DaprClient daprClient; + + @Autowired + private DaprContainer daprContainer; + + @BeforeAll + public static void setup() { + org.testcontainers.Testcontainers.exposeHostPorts(8081); + } + + @BeforeEach + void setUp() { + RestAssured.baseURI = "http://localhost:" + 8081; + Wait.forLogMessage(SUBSCRIPTION_MESSAGE_PATTERN, 1).waitUntilReady(daprContainer); + } + + + @Test + void testMessageConsumer() throws InterruptedException, IOException { + + messagingTemplate.send("topic", new Order("abc-123", "the mars volta LP", 1)); + + given().contentType(ContentType.JSON) + .when() + .get("/events") + .then() + .statusCode(200); + + await().atMost(Duration.ofSeconds(10)) + .until(subscriberRestController.getAllEvents()::size, equalTo(1)); + + } + +} diff --git a/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/DaprTestContainersConfig.java b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/DaprTestContainersConfig.java new file mode 100644 index 000000000..7d2218992 --- /dev/null +++ b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/DaprTestContainersConfig.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.RabbitMQContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@TestConfiguration(proxyBeanMethods = false) +public class DaprTestContainersConfig { + + @Bean + public Network getDaprNetwork() { + Network defaultDaprNetwork = new Network() { + @Override + public String getId() { + return "dapr-network"; + } + + @Override + public void close() { + + } + + @Override + public Statement apply(Statement base, Description description) { + return null; + } + }; + + List networks = DockerClientFactory.instance().client().listNetworksCmd() + .withNameFilter("dapr-network").exec(); + if (networks.isEmpty()) { + Network.builder().createNetworkCmdModifier(cmd -> cmd.withName("dapr-network")).build().getId(); + return defaultDaprNetwork; + } else { + return defaultDaprNetwork; + } + } + + @Bean + public RabbitMQContainer rabbitMQContainer(Network daprNetwork, Environment env) { + boolean reuse = env.getProperty("reuse", Boolean.class, false); + return new RabbitMQContainer(DockerImageName.parse("rabbitmq:3.7.25-management-alpine")) + .withExposedPorts(5672) + .withNetworkAliases("rabbitmq") + .withReuse(true) + .withNetwork(daprNetwork); + } + + @Bean + @ServiceConnection + public DaprContainer daprContainer(Network daprNetwork, RabbitMQContainer rabbitMQContainer, Environment env) { + boolean reuse = env.getProperty("reuse", Boolean.class, false); + Map rabbitMqProperties = new HashMap<>(); + rabbitMqProperties.put("connectionString", "amqp://guest:guest@rabbitmq:5672"); + rabbitMqProperties.put("user", "guest"); + rabbitMqProperties.put("password", "guest"); + + return new DaprContainer("daprio/daprd:1.14.4") + .withAppName("consumer-app") + .withNetwork(daprNetwork).withComponent(new Component("pubsub", + "pubsub.rabbitmq", "v1", rabbitMqProperties)) + .withDaprLogLevel(DaprLogLevel.INFO) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withAppPort(8081).withAppChannelAddress("host.testcontainers.internal") + .withReusablePlacement(reuse) + .withAppHealthCheckPath("/actuator/health") + .dependsOn(rabbitMQContainer); + } + + +} diff --git a/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/TestConsumerApplication.java b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/TestConsumerApplication.java new file mode 100644 index 000000000..d37150746 --- /dev/null +++ b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/TestConsumerApplication.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class TestConsumerApplication { + + public static void main(String[] args) { + SpringApplication.from(ConsumerApplication::main) + .with(DaprTestContainersConfig.class) + .run(args); + org.testcontainers.Testcontainers.exposeHostPorts(8081); + } + + +} diff --git a/spring-boot-examples/consumer-app/src/test/resources/application.properties b/spring-boot-examples/consumer-app/src/test/resources/application.properties new file mode 100644 index 000000000..d6cb8d293 --- /dev/null +++ b/spring-boot-examples/consumer-app/src/test/resources/application.properties @@ -0,0 +1,2 @@ +dapr.pubsub.name=pubsub +server.port=8081 diff --git a/spring-boot-examples/kubernetes/README.md b/spring-boot-examples/kubernetes/README.md new file mode 100644 index 000000000..4c3fc709d --- /dev/null +++ b/spring-boot-examples/kubernetes/README.md @@ -0,0 +1,100 @@ +# Running this example on Kubernetes + +To run this example on Kubernetes, you can use any Kubernetes distribution. +We install Dapr on a Kubernetes cluster and then we will deploy both the `producer-app` and `consumer-app`. + +## Creating a cluster and installing Dapr + +If you don't have any Kubernetes cluster you can use Kubernetes KIND to create a local cluster. We will create a cluster +with a local container registry, so we can push our container images to it. This is covered in the +[KIND documentation here](https://kind.sigs.k8s.io/docs/user/local-registry/). + +```bash +./kind-with-registry.sh +``` + +Once you have the cluster up and running you can install Dapr: + +```bash +helm repo add dapr https://dapr.github.io/helm-charts/ +helm repo update +helm upgrade --install dapr dapr/dapr \ +--version=1.14.4 \ +--namespace dapr-system \ +--create-namespace \ +--wait +``` + +## Creating containers using Spring Boot and pushing to local registry + +Now that we have our cluster set up with a local container registry, we need to build our `producer-app` and `consumer-app` containers. +For this we will use Spring Boot build it functions to create container images using [Buildpacks](https://buildpacks.io): + +From inside the `spring-boot-examples/producer-app` directory you can run the following command to create a container: +```bash +mvn spring-boot:build-image +``` + +Once we have the container image created, we need to tag and push to the local registry, so the image can be used from our local cluster. +Alternatively, you can push the images to a public registry and update the Kubernetes manifests accordingly. + +```bash +docker tag producer-app:0.14.0-SNAPSHOT localhost:5001/sb-producer-app +docker push localhost:5001/sb-producer-app +``` + +From inside the `spring-boot-examples/consumer-app` directory you can run the following command to create a container: +```bash +mvn spring-boot:build-image +``` + +Once we have the container image created, we need to tag and push to the local registry, so the image can be used from our local cluster. +Alternatively, you can push the images to a public registry and update the Kubernetes manifests accordingly. + +```bash +docker tag consumer-app:0.14.0-SNAPSHOT localhost:5001/sb-consumer-app +docker push localhost:5001/sb-consumer-app +``` + +Now we are ready to install our application into the cluster. + +## Installing and interacting with the application + +Now that we have a running Kubernetes cluster, we need to first install the components needed by the application. +In this case RabbitMQ and PostgreSQL. We will use Helm to do so: + +Let's start with RabbitMQ: +```bash +helm install rabbitmq oci://registry-1.docker.io/bitnamicharts/rabbitmq --set auth.username=guest --set auth.password=guest --set auth.erlangCookie=ABC +``` + +Then PostgreSQL: +```bash +helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql --set global.postgresql.auth.database=dapr --set global.postgresql.auth.postgresPassword=password +``` + +Once we have these components up and running we can install the application by running from inside +the `spring-boot-examples/kubernetes/` directory: + +```bash +kubectl apply -f . +``` + +Next you need to use `kubectl port-forward` to be able to send requests to the applications. + +```bash +kubectl port-forward svc/producer-app 8080:8080 +``` + +In a different terminals you can check the logs of the `producer-app` and `consumer-app`: + +```bash +kubectl logs -f producer-app- +``` +and + +```bash +kubectl logs -f consumer-app- +``` + + diff --git a/spring-boot-examples/kubernetes/consumer-app.yaml b/spring-boot-examples/kubernetes/consumer-app.yaml new file mode 100644 index 000000000..ab9041783 --- /dev/null +++ b/spring-boot-examples/kubernetes/consumer-app.yaml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: consumer-app + name: consumer-app +spec: + type: NodePort + ports: + - name: "consumer-app" + port: 8081 + targetPort: 8081 + nodePort: 31001 + selector: + app: consumer-app + +--- + +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: consumer-app + name: consumer-app +spec: + replicas: 1 + selector: + matchLabels: + app: consumer-app + template: + metadata: + annotations: + dapr.io/app-id: consumer-app + dapr.io/app-port: "8081" + dapr.io/enabled: "true" + labels: + app: consumer-app + spec: + containers: + - image: localhost:5001/sb-consumer-app + name: consumer-app + imagePullPolicy: Always + ports: + - containerPort: 8081 + name: consumer-app diff --git a/spring-boot-examples/kubernetes/kind-with-registry.sh b/spring-boot-examples/kubernetes/kind-with-registry.sh new file mode 100755 index 000000000..9fe55a821 --- /dev/null +++ b/spring-boot-examples/kubernetes/kind-with-registry.sh @@ -0,0 +1,64 @@ +#!/bin/sh +set -o errexit + +# 1. Create registry container unless it already exists +reg_name='kind-registry' +reg_port='5001' +if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then + docker run \ + -d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \ + registry:2 +fi + +# 2. Create kind cluster with containerd registry config dir enabled +# TODO: kind will eventually enable this by default and this patch will +# be unnecessary. +# +# See: +# https://github.com/kubernetes-sigs/kind/issues/2875 +# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration +# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md +cat < + + 4.0.0 + + io.dapr + dapr-sdk-parent + 1.14.0-SNAPSHOT + + + spring-boot-examples + 0.14.0-SNAPSHOT + pom + + + producer-app + consumer-app + + + diff --git a/spring-boot-examples/producer-app/pom.xml b/spring-boot-examples/producer-app/pom.xml new file mode 100644 index 000000000..c9c6a7e76 --- /dev/null +++ b/spring-boot-examples/producer-app/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + + io.dapr + spring-boot-examples + 0.14.0-SNAPSHOT + + + producer-app + producer-app + Spring Boot, Testcontainers and Dapr Integration Examples :: Producer App + + + 3.2.6 + + + + + + org.springframework.boot + spring-boot-dependencies + ${springboot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-web + + + io.dapr.spring + dapr-spring-boot-starter + ${dapr.sdk.alpha.version} + + + io.dapr.spring + dapr-spring-boot-starter-test + ${dapr.sdk.alpha.version} + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + 1.20.0 + test + + + org.testcontainers + rabbitmq + 1.20.0 + test + + + io.rest-assured + rest-assured + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Customer.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Customer.java new file mode 100644 index 000000000..2211d1e90 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Customer.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +public class Customer { + private String customerName; + private String workflowId; + private boolean inCustomerDB = false; + private boolean followUp = false; + + public boolean isFollowUp() { + return followUp; + } + + public void setFollowUp(boolean followUp) { + this.followUp = followUp; + } + + public boolean isInCustomerDB() { + return inCustomerDB; + } + + public void setInCustomerDB(boolean inCustomerDB) { + this.inCustomerDB = inCustomerDB; + } + + public String getWorkflowId() { + return workflowId; + } + + public void setWorkflowId(String workflowId) { + this.workflowId = workflowId; + } + + public String getCustomerName() { + return customerName; + } + + public void setCustomerName(String customerName) { + this.customerName = customerName; + } + + @Override + public String toString() { + return "Customer [customerName=" + customerName + ", workflowId=" + workflowId + ", inCustomerDB=" + + inCustomerDB + ", followUp=" + followUp + "]"; + } +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomerStore.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomerStore.java new file mode 100644 index 000000000..35c884e06 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomerStore.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +@Component +public class CustomerStore { + private Map customers = new HashMap<>(); + + public void addCustomer(Customer customer) { + customers.put(customer.getCustomerName(), customer); + } + + public Customer getCustomer(String customerName) { + return customers.get(customerName); + } + + public Collection getCustomers() { + return customers.values(); + } + +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomersRestController.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomersRestController.java new file mode 100644 index 000000000..57622d104 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomersRestController.java @@ -0,0 +1,89 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import io.dapr.spring.workflows.config.EnableDaprWorkflows; +import io.dapr.springboot.examples.producer.workflow.CustomerWorkflow; +import io.dapr.workflows.client.DaprWorkflowClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +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.RestController; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +@RestController +@EnableDaprWorkflows +public class CustomersRestController { + + + private final Logger logger = LoggerFactory.getLogger(CustomersRestController.class); + + @Autowired + private DaprWorkflowClient daprWorkflowClient; + + @Autowired + private CustomerStore customerStore; + + @GetMapping("/") + public String root() { + return "OK"; + } + + private Map customersWorkflows = new HashMap<>(); + + /** + * Track customer endpoint. + * + * @param customer provided customer to track + * @return confirmation that the workflow instance was created for a given customer + */ + @PostMapping("/customers") + public String trackCustomer(@RequestBody Customer customer) { + String instanceId = daprWorkflowClient.scheduleNewWorkflow(CustomerWorkflow.class, customer); + logger.info("Workflow instance " + instanceId + " started"); + customersWorkflows.put(customer.getCustomerName(), instanceId); + return "New Workflow Instance created for Customer: " + customer.getCustomerName(); + } + + /** + * Request customer follow-up. + * @param customer associated with a workflow instance + * @return confirmation that the follow-up was requested + */ + @PostMapping("/customers/followup") + public String customerNotification(@RequestBody Customer customer) { + logger.info("Customer follow-up requested: " + customer.getCustomerName()); + String workflowIdForCustomer = customersWorkflows.get(customer.getCustomerName()); + if (workflowIdForCustomer == null || workflowIdForCustomer.isEmpty()) { + return "There is no workflow associated with customer: " + customer.getCustomerName(); + } else { + daprWorkflowClient.raiseEvent(workflowIdForCustomer, "CustomerReachOut", customer); + return "Customer Follow-up requested"; + } + } + + + public Collection getCustomers() { + return customerStore.getCustomers(); + } + + +} + diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Order.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Order.java new file mode 100644 index 000000000..820613037 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Order.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import org.springframework.data.annotation.Id; + +public class Order { + + @Id + private String id; + private String item; + private Integer amount; + + public Order() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getItem() { + return item; + } + + public void setItem(String item) { + this.item = item; + } + + public Integer getAmount() { + return amount; + } + + public void setAmount(Integer amount) { + this.amount = amount; + } + + @Override + public String toString() { + return "Order{" + "id='" + id + '\'' + ", item='" + item + '\'' + ", amount=" + amount + '}'; + } +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrderRepository.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrderRepository.java new file mode 100644 index 000000000..1111f28db --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrderRepository.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import org.springframework.data.repository.CrudRepository; + +import java.util.List; + +public interface OrderRepository extends CrudRepository { + + List findByItem(String item); + + List findByAmount(Integer amount); +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrdersRestController.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrdersRestController.java new file mode 100644 index 000000000..90384b8c0 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrdersRestController.java @@ -0,0 +1,71 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import io.dapr.spring.data.repository.config.EnableDaprRepositories; +import io.dapr.spring.messaging.DaprMessagingTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@EnableDaprRepositories +public class OrdersRestController { + + private final Logger logger = LoggerFactory.getLogger(OrdersRestController.class); + + @Autowired + private OrderRepository repository; + + @Autowired + private DaprMessagingTemplate messagingTemplate; + + /** + * Store orders from customers. + * @param order from the customer + * + * @return confirmation that the order was stored and the event published + */ + @PostMapping("/orders") + public String storeOrder(@RequestBody Order order) { + logger.info("Storing Order: " + order); + repository.save(order); + logger.info("Publishing Order Event: " + order); + messagingTemplate.send("topic", order); + return "Order Stored and Event Published"; + } + + @GetMapping("/orders") + public Iterable getAll() { + return repository.findAll(); + } + + @GetMapping("/orders/byItem/") + public Iterable getAllByItem(@RequestParam("item") String item) { + return repository.findByItem(item); + } + + @GetMapping("/orders/byAmount/") + public Iterable getAllByItem(@RequestParam("amount") Integer amount) { + return repository.findByAmount(amount); + } + + +} + diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerAppConfiguration.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerAppConfiguration.java new file mode 100644 index 000000000..18a3d237c --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerAppConfiguration.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dapr.client.DaprClient; +import io.dapr.spring.boot.autoconfigure.pubsub.DaprPubSubProperties; +import io.dapr.spring.boot.autoconfigure.statestore.DaprStateStoreProperties; +import io.dapr.spring.data.DaprKeyValueAdapterResolver; +import io.dapr.spring.data.DaprKeyValueTemplate; +import io.dapr.spring.data.KeyValueAdapterResolver; +import io.dapr.spring.messaging.DaprMessagingTemplate; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({DaprPubSubProperties.class, DaprStateStoreProperties.class}) +public class ProducerAppConfiguration { + @Bean + public ObjectMapper mapper() { + return new ObjectMapper(); + } + + + /** + * Produce a KeyValueAdapterResolver for Dapr. + * @param daprClient dapr client + * @param mapper object mapper + * @param daprStatestoreProperties properties to configure state store + * @return KeyValueAdapterResolver + */ + @Bean + public KeyValueAdapterResolver keyValueAdapterResolver(DaprClient daprClient, ObjectMapper mapper, + DaprStateStoreProperties daprStatestoreProperties) { + String storeName = daprStatestoreProperties.getName(); + String bindingName = daprStatestoreProperties.getBinding(); + + return new DaprKeyValueAdapterResolver(daprClient, mapper, storeName, bindingName); + } + + @Bean + public DaprKeyValueTemplate daprKeyValueTemplate(KeyValueAdapterResolver keyValueAdapterResolver) { + return new DaprKeyValueTemplate(keyValueAdapterResolver); + } + + @Bean + public DaprMessagingTemplate messagingTemplate(DaprClient daprClient, + DaprPubSubProperties daprPubSubProperties) { + return new DaprMessagingTemplate<>(daprClient, daprPubSubProperties.getName(), false); + } + +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerApplication.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerApplication.java new file mode 100644 index 000000000..9ff372714 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerApplication.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class ProducerApplication { + + public static void main(String[] args) { + SpringApplication.run(ProducerApplication.class, args); + } + +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerFollowupActivity.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerFollowupActivity.java new file mode 100644 index 000000000..fb4e20770 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerFollowupActivity.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer.workflow; + +import io.dapr.springboot.examples.producer.Customer; +import io.dapr.springboot.examples.producer.CustomerStore; +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class CustomerFollowupActivity implements WorkflowActivity { + + private final Logger logger = LoggerFactory.getLogger(CustomerFollowupActivity.class); + + private final CustomerStore customerStore; + + public CustomerFollowupActivity(CustomerStore customerStore) { + this.customerStore = customerStore; + } + + @Override + public Object run(WorkflowActivityContext ctx) { + Customer customer = ctx.getInput(Customer.class); + //Let's get the hydrate the real customer from the CustomerStore + customer = customerStore.getCustomer(customer.getCustomerName()); + customer.setFollowUp(true); + customerStore.addCustomer(customer); + logger.info("Customer: " + customer.getCustomerName() + " follow-up done."); + return customer; + } + +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerWorkflow.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerWorkflow.java new file mode 100644 index 000000000..1e8757f99 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerWorkflow.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer.workflow; + +import io.dapr.springboot.examples.producer.Customer; +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +public class CustomerWorkflow implements Workflow { + + @Override + public WorkflowStub create() { + return ctx -> { + String instanceId = ctx.getInstanceId(); + Customer customer = ctx.getInput(Customer.class); + customer.setWorkflowId(instanceId); + ctx.getLogger().info("Let's register the customer: " + customer.getCustomerName()); + ctx.callActivity(RegisterCustomerActivity.class.getName(), customer, Customer.class).await(); + ctx.getLogger().info("Let's wait for the customer: " + customer.getCustomerName() + " to request a follow up."); + customer = ctx.waitForExternalEvent("CustomerReachOut", Duration.ofMinutes(5), Customer.class).await(); + ctx.getLogger().info("Let's book a follow up for the customer: " + customer.getCustomerName()); + customer = ctx.callActivity(CustomerFollowupActivity.class.getName(), customer, Customer.class).await(); + ctx.getLogger().info("Congratulations the customer: " + customer.getCustomerName() + " is happy!"); + ctx.complete(customer); + }; + } +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/RegisterCustomerActivity.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/RegisterCustomerActivity.java new file mode 100644 index 000000000..c326c002c --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/RegisterCustomerActivity.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer.workflow; + + +import io.dapr.springboot.examples.producer.Customer; +import io.dapr.springboot.examples.producer.CustomerStore; +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class RegisterCustomerActivity implements WorkflowActivity { + + private final Logger logger = LoggerFactory.getLogger(RegisterCustomerActivity.class); + private final CustomerStore customerStore; + + public RegisterCustomerActivity(CustomerStore customerStore) { + this.customerStore = customerStore; + } + + @Override + public Object run(WorkflowActivityContext ctx) { + Customer customer = ctx.getInput(Customer.class); + customer.setInCustomerDB(true); + logger.info("Customer: " + customer.getCustomerName() + " registered."); + customerStore.addCustomer(customer); + return customer; + } + + +} diff --git a/spring-boot-examples/producer-app/src/main/resources/application.properties b/spring-boot-examples/producer-app/src/main/resources/application.properties new file mode 100644 index 000000000..1498965c7 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/resources/application.properties @@ -0,0 +1,4 @@ +spring.application.name=producer-app +dapr.pubsub.name=pubsub +dapr.statestore.name=kvstore +dapr.statestore.binding=kvbinding diff --git a/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/DaprTestContainersConfig.java b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/DaprTestContainersConfig.java new file mode 100644 index 000000000..180f8badd --- /dev/null +++ b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/DaprTestContainersConfig.java @@ -0,0 +1,137 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.Subscription; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.RabbitMQContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@TestConfiguration(proxyBeanMethods = false) +public class DaprTestContainersConfig { + + static final String CONNECTION_STRING = + "host=postgres user=postgres password=password port=5432 connect_timeout=10 database=dapr_db_repository"; + static final Map STATE_STORE_PROPERTIES = createStateStoreProperties(); + + static final Map BINDING_PROPERTIES = Collections.singletonMap("connectionString", CONNECTION_STRING); + + + @Bean + public Network getNetwork() { + Network defaultDaprNetwork = new Network() { + @Override + public String getId() { + return "dapr-network"; + } + + @Override + public void close() { + + } + + @Override + public Statement apply(Statement base, Description description) { + return null; + } + }; + + List networks = DockerClientFactory.instance().client().listNetworksCmd().withNameFilter("dapr-network").exec(); + if (networks.isEmpty()) { + Network.builder() + .createNetworkCmdModifier(cmd -> cmd.withName("dapr-network")) + .build().getId(); + return defaultDaprNetwork; + } else { + return defaultDaprNetwork; + } + } + + + @Bean + public RabbitMQContainer rabbitMQContainer(Network daprNetwork, Environment env) { + boolean reuse = env.getProperty("reuse", Boolean.class, false); + return new RabbitMQContainer(DockerImageName.parse("rabbitmq:3.7.25-management-alpine")) + .withExposedPorts(5672) + .withNetworkAliases("rabbitmq") + .withReuse(true) + .withNetwork(daprNetwork); + + } + + @Bean + public PostgreSQLContainer postgreSQLContainer(Network daprNetwork) { + return new PostgreSQLContainer<>("postgres:16-alpine") + .withNetworkAliases("postgres") + .withDatabaseName("dapr_db_repository") + .withUsername("postgres") + .withPassword("password") + .withExposedPorts(5432) + .withNetwork(daprNetwork); + + } + + @Bean + @ServiceConnection + public DaprContainer daprContainer(Network daprNetwork, PostgreSQLContainer postgreSQLContainer, RabbitMQContainer rabbitMQContainer) { + + Map rabbitMqProperties = new HashMap<>(); + rabbitMqProperties.put("connectionString", "amqp://guest:guest@rabbitmq:5672"); + rabbitMqProperties.put("user", "guest"); + rabbitMqProperties.put("password", "guest"); + + return new DaprContainer("daprio/daprd:1.14.4") + .withAppName("producer-app") + .withNetwork(daprNetwork) + .withComponent(new Component("kvstore", "state.postgresql", "v1", STATE_STORE_PROPERTIES)) + .withComponent(new Component("kvbinding", "bindings.postgresql", "v1", BINDING_PROPERTIES)) + .withComponent(new Component("pubsub", "pubsub.rabbitmq", "v1", rabbitMqProperties)) + .withSubscription(new Subscription("app", "pubsub", "topic", "/subscribe")) +// .withDaprLogLevel(DaprLogLevel.DEBUG) +// .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withAppPort(8080) + .withAppHealthCheckPath("/actuator/health") + .withAppChannelAddress("host.testcontainers.internal") + .dependsOn(rabbitMQContainer) + .dependsOn(postgreSQLContainer); + } + + + private static Map createStateStoreProperties() { + Map result = new HashMap<>(); + + result.put("keyPrefix", "name"); + result.put("actorStateStore", String.valueOf(true)); + result.put("connectionString", CONNECTION_STRING); + + return result; + } + + +} diff --git a/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/ProducerAppTests.java b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/ProducerAppTests.java new file mode 100644 index 000000000..47e5eb486 --- /dev/null +++ b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/ProducerAppTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import io.dapr.client.DaprClient; +import io.dapr.springboot.DaprAutoConfiguration; +import io.dapr.springboot.examples.producer.workflow.CustomerFollowupActivity; +import io.dapr.springboot.examples.producer.workflow.CustomerWorkflow; +import io.dapr.springboot.examples.producer.workflow.RegisterCustomerActivity; +import io.dapr.testcontainers.DaprContainer; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.io.IOException; +import java.time.Duration; + +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(classes = {TestProducerApplication.class, DaprTestContainersConfig.class, + DaprAutoConfiguration.class, CustomerWorkflow.class, CustomerFollowupActivity.class, + RegisterCustomerActivity.class, CustomerStore.class}, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +class ProducerAppTests { + + private static final String SUBSCRIPTION_MESSAGE_PATTERN = ".*app is subscribed to the following topics.*"; + + @Autowired + private TestSubscriberRestController controller; + + @Autowired + private CustomerStore customerStore; + + @Autowired + private DaprClient daprClient; + + + @Autowired + private DaprContainer daprContainer; + + + @BeforeEach + void setUp() { + RestAssured.baseURI = "http://localhost:" + 8080; + org.testcontainers.Testcontainers.exposeHostPorts(8080); + // Ensure the subscriptions are registered + Wait.forLogMessage(SUBSCRIPTION_MESSAGE_PATTERN, 1).waitUntilReady(daprContainer); + + } + + + @Test + void testOrdersEndpointAndMessaging() throws InterruptedException, IOException { + + given().contentType(ContentType.JSON) + .body("{ \"id\": \"abc-123\",\"item\": \"the mars volta LP\",\"amount\": 1}") + .when() + .post("/orders") + .then() + .statusCode(200); + + await().atMost(Duration.ofSeconds(15)) + .until(controller.getAllEvents()::size, equalTo(1)); + + given().contentType(ContentType.JSON) + .when() + .get("/orders") + .then() + .statusCode(200).body("size()", is(1)); + + given().contentType(ContentType.JSON) + .when() + .queryParam("item", "the mars volta LP") + .get("/orders/byItem/") + .then() + .statusCode(200).body("size()", is(1)); + + given().contentType(ContentType.JSON) + .when() + .queryParam("item", "other") + .get("/orders/byItem/") + .then() + .statusCode(200).body("size()", is(0)); + + given().contentType(ContentType.JSON) + .when() + .queryParam("amount", 1) + .get("/orders/byAmount/") + .then() + .statusCode(200).body("size()", is(1)); + + given().contentType(ContentType.JSON) + .when() + .queryParam("amount", 2) + .get("/orders/byAmount/") + .then() + .statusCode(200).body("size()", is(0)); + + } + + @Test + void testCustomersWorkflows() throws InterruptedException, IOException { + + given().contentType(ContentType.JSON) + .body("{\"customerName\": \"salaboy\"}") + .when() + .post("/customers") + .then() + .statusCode(200); + + + await().atMost(Duration.ofSeconds(15)) + .until(customerStore.getCustomers()::size, equalTo(1)); + Customer customer = customerStore.getCustomer("salaboy"); + assertEquals(true, customer.isInCustomerDB()); + String workflowId = customer.getWorkflowId(); + given().contentType(ContentType.JSON) + .body("{ \"workflowId\": \"" + workflowId + "\",\"customerName\": \"salaboy\" }") + .when() + .post("/customers/followup") + .then() + .statusCode(200); + + assertEquals(1, customerStore.getCustomers().size()); + + await().atMost(Duration.ofSeconds(10)) + .until(customerStore.getCustomer("salaboy")::isFollowUp, equalTo(true)); + + } + +} diff --git a/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestProducerApplication.java b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestProducerApplication.java new file mode 100644 index 000000000..0abb4c929 --- /dev/null +++ b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestProducerApplication.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class TestProducerApplication { + + public static void main(String[] args) { + + SpringApplication.from(ProducerApplication::main) + .with(DaprTestContainersConfig.class) + .run(args); + org.testcontainers.Testcontainers.exposeHostPorts(8080); + } + +} diff --git a/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestSubscriberRestController.java b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestSubscriberRestController.java new file mode 100644 index 000000000..0f39dd9a9 --- /dev/null +++ b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestSubscriberRestController.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import io.dapr.Topic; +import io.dapr.client.domain.CloudEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; + +@RestController +public class TestSubscriberRestController { + + private List events = new ArrayList<>(); + + private final Logger logger = LoggerFactory.getLogger(TestSubscriberRestController.class); + + @PostMapping("subscribe") + @Topic(pubsubName = "pubsub", name = "topic") + public void subscribe(@RequestBody CloudEvent cloudEvent){ + logger.info("Order Event Received: " + cloudEvent.getData()); + events.add(cloudEvent); + } + + public List getAllEvents() { + return events; + } +} + diff --git a/spring-boot-examples/producer-app/src/test/resources/application.properties b/spring-boot-examples/producer-app/src/test/resources/application.properties new file mode 100644 index 000000000..2429abb69 --- /dev/null +++ b/spring-boot-examples/producer-app/src/test/resources/application.properties @@ -0,0 +1,3 @@ +dapr.statestore.name=kvstore +dapr.statestore.binding=kvbinding +dapr.pubsub.name=pubsub diff --git a/spring-boot-examples/spotbugs-exclude.xml b/spring-boot-examples/spotbugs-exclude.xml new file mode 100644 index 000000000..264fc79b0 --- /dev/null +++ b/spring-boot-examples/spotbugs-exclude.xml @@ -0,0 +1,6 @@ + + + + + +