adding shipping app

Signed-off-by: salaboy <Salaboy@gmail.com>
This commit is contained in:
salaboy 2025-08-05 10:53:38 +01:00
parent aaa8a46dad
commit 1ea44f871f
16 changed files with 162 additions and 401 deletions

View File

@ -15,7 +15,7 @@
<description>Shipping App Example</description>
<properties>
<dapr.spring.version>0.15.0-rc-7</dapr.spring.version>
<dapr.spring.version>0.15.0-rc-8</dapr.spring.version>
</properties>
<dependencies>
<dependency>

View File

@ -1,94 +0,0 @@
/*
* 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;
import io.dapr.spring.workflows.config.EnableDaprWorkflows;
import io.dapr.springboot.examples.external.ApprovalStatus;
import io.dapr.springboot.examples.external.ExternalEventsWorkflow;
import io.dapr.springboot.examples.external.Order;
import io.dapr.workflows.client.DaprWorkflowClient;
import io.dapr.workflows.client.WorkflowInstanceStatus;
import io.dapr.workflows.client.WorkflowRuntimeStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.concurrent.TimeoutException;
@RestController
/*
* Dapr Workflows and Activities need to be registered in the DI container otherwise
* the Dapr runtime does not know this application contains workflows and activities.
* In Spring Boot Applications, the `@EnableDaprWorkflow` annotation takes care of registering
* all workflows and activities components found in the classpath.
*/
@EnableDaprWorkflows
public class ExternalEventsRestController {
private final Logger logger = LoggerFactory.getLogger(ExternalEventsRestController.class);
@Autowired
private DaprWorkflowClient daprWorkflowClient;
private String instanceId;
/**
* The DaprWorkflowClient is the API to manage workflows.
* Here it is used to schedule a new workflow instance.
*
* @return the instanceId of the ExternalEventsWorkflow execution
*/
@PostMapping("start")
public String basic(@RequestBody Order order) throws TimeoutException {
logger.info("Received order: {}", order);
instanceId = daprWorkflowClient.scheduleNewWorkflow(ExternalEventsWorkflow.class, order);
return instanceId;
}
/**
* Obtain the status of the workflow
*
* @return the status of the ExternalEventsWorkflow instance
*/
@GetMapping("status")
public String status() throws TimeoutException {
if(instanceId != null && !instanceId.isEmpty()) {
WorkflowInstanceStatus instanceState = daprWorkflowClient.getInstanceState(instanceId, true);
if (instanceState != null) {
if (instanceState.getRuntimeStatus().equals(WorkflowRuntimeStatus.COMPLETED)) {
var output = instanceState.readOutputAs(String.class);
if (output != null && !output.isEmpty()) {
return "Workflow Instance (" + instanceId + ") Status: " + instanceState.getRuntimeStatus().name() + "\n"
+ "Output: " + output;
}
}
return "Workflow Instance (" + instanceId + ") Status: " + instanceState.getRuntimeStatus().name();
}
}
return "N/A";
}
/**
* Raise a workflow event
*/
@PostMapping("event")
public void raiseEvent(@RequestBody ApprovalStatus approvalStatus) {
daprWorkflowClient.raiseEvent(instanceId, "approval-event", approvalStatus);
}
}

View File

@ -20,7 +20,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class ExternalEventsConfiguration {
public class ShippingAppConfiguration {
@Bean
public RestTemplate restTemplate() {

View File

@ -0,0 +1,67 @@
/*
* 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;
import io.dapr.client.DaprClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
public class ShippingAppRestController {
public final static String DAPR_PUBSUB_COMPONENT = "shippingpubsub";
public final static String DAPR_PUBSUB_REGISTRATION_TOPIC = "shipment-registration-events";
public final static String DAPR_PUBSUB_CONFIRMED_TOPIC = "shipment-registration-confirmed-events";
private final Logger logger = LoggerFactory.getLogger(ShippingAppRestController.class);
@Autowired
private DaprClient daprClient;
/// This endpoint is called by the CheckShippingDestination activity in the WorkflowApp.
@PostMapping("checkDestination")
public ShippingDestinationResult checkDestination(@RequestBody Order order) {
logger.info("checkDestination: Received input: {}", order);
return new ShippingDestinationResult( true, "");
}
// This endpoint handles messages that are published to the shipment-registration-events topic.
// The RegisterShipment activity in the WorkflowApp is publishing to this topic.
// This method is publishing a message to the shipment-registration-confirmed-events topic.
@PostMapping("registerShipment")
public ShipmentRegistrationStatus registerShipment(@RequestBody Order order){
logger.info("registerShipment: Received input: {}", order);
var status = new ShipmentRegistrationStatus(order.id(), true, "");
if( order.id().isEmpty()){
logger.info("Order Id is empty!");
}else{
daprClient.publishEvent(DAPR_PUBSUB_COMPONENT,
DAPR_PUBSUB_CONFIRMED_TOPIC, status).block();
}
return status;
}
public record Order(String id, OrderItem orderItem, CustomerInfo customerInfo){}
public record OrderItem(String productId, String productName, int quantity, BigDecimal totalPrice){}
public record CustomerInfo(String id, String country){}
public record ShipmentRegistrationStatus(String orderId, boolean isSuccess, String message){}
public record ShippingDestinationResult(boolean isSuccess, String message ){}
}

View File

@ -18,10 +18,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ExternalEventsApplication {
public class ShippingApplication {
public static void main(String[] args) {
SpringApplication.run(ExternalEventsApplication.class, args);
SpringApplication.run(ShippingApplication.class, args);
}
}

View File

@ -1,3 +0,0 @@
package io.dapr.springboot.examples.external;
public record ApprovalStatus(String orderId, boolean isApproved){}

View File

@ -1,59 +0,0 @@
/*
* Copyright 2023 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.external;
import io.dapr.durabletask.TaskCanceledException;
import io.dapr.springboot.examples.external.activities.ProcessOrderActivity;
import io.dapr.springboot.examples.external.activities.SendNotificationActivity;
import io.dapr.workflows.Workflow;
import io.dapr.workflows.WorkflowStub;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
public class ExternalEventsWorkflow implements Workflow {
@Override
public WorkflowStub create() {
return ctx -> {
ctx.getLogger().info("Starting Workflow: {}", ctx.getName());
var order = ctx.getInput(Order.class);
var approvalStatus = new ApprovalStatus(order.id(), true);
if(order.totalPrice() > 250){
try{
approvalStatus = ctx.waitForExternalEvent("approval-event", Duration.ofSeconds(12), ApprovalStatus.class).await();
} catch (TaskCanceledException tce){
var notification = "Approval request for order " + order.id() + " timed out.";
ctx.callActivity(SendNotificationActivity.class.getName(), notification).await();
ctx.complete(notification);
}
}
if (approvalStatus.isApproved()){
ctx.callActivity(ProcessOrderActivity.class.getName(), order).await();
}
var notification = approvalStatus.isApproved() ?
"Order " + order.id() + " has been approved." :
"Order " + order.id() +" has been rejected.";
ctx.callActivity(SendNotificationActivity.class.getName(), notification).await();
ctx.complete(notification);
};
}
}

View File

@ -1,3 +0,0 @@
package io.dapr.springboot.examples.external;
public record Order(String id, String description, int quantity, double totalPrice){}

View File

@ -1,41 +0,0 @@
/*
* Copyright 2023 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.external.activities;
import io.dapr.springboot.examples.external.Order;
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 ProcessOrderActivity implements WorkflowActivity {
/*
*/
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(ProcessOrderActivity.class);
var order = ctx.getInput(Order.class);
// Imagine the order being processed by another system
logger.info("{} : Processed Order: {}", ctx.getName(), order.id());
return true;
}
}

View File

@ -1,33 +0,0 @@
/*
* Copyright 2023 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.external.activities;
import io.dapr.springboot.examples.external.Order;
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 RequestApprovalActivity implements WorkflowActivity {
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(RequestApprovalActivity.class);
var order = ctx.getInput(Order.class);
logger.info("{} : Request Approval for Order: {}", ctx.getName(), order.id());
return true;
}
}

View File

@ -1,40 +0,0 @@
/*
* Copyright 2023 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.external.activities;
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 SendNotificationActivity implements WorkflowActivity {
/*
*/
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(SendNotificationActivity.class);
var message = ctx.getInput(String.class);
// Imagine a notification being sent to the user
logger.info("{} : Sending Notification: {}", ctx.getName(), message);
return true;
}
}

View File

@ -1 +1 @@
spring.application.name=external-system-interactions
spring.application.name=shipping-app

View File

@ -21,6 +21,7 @@ import org.springframework.context.annotation.Bean;
import java.util.Collections;
import static io.dapr.springboot.examples.ShippingAppRestController.DAPR_PUBSUB_COMPONENT;
import static io.dapr.testcontainers.DaprContainerConstants.DAPR_RUNTIME_IMAGE_TAG;
@TestConfiguration(proxyBeanMethods = false)
@ -31,7 +32,7 @@ public class DaprTestContainersConfig {
public DaprContainer daprContainer() {
return new DaprContainer(DAPR_RUNTIME_IMAGE_TAG)
.withAppName("external-system-interactions")
.withComponent(new Component("kvstore", "state.in-memory", "v1", Collections.singletonMap("actorStateStore", String.valueOf(true))))
.withComponent(new Component(DAPR_PUBSUB_COMPONENT, "pubsub.in-memory", "v1", Collections.emptyList()))
.withAppPort(8080)
.withAppHealthCheckPath("/actuator/health")
.withAppChannelAddress("host.testcontainers.internal");

View File

@ -1,120 +0,0 @@
/*
* 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;
import io.dapr.client.DaprClient;
import io.dapr.springboot.DaprAutoConfiguration;
import io.dapr.springboot.examples.external.ApprovalStatus;
import io.dapr.springboot.examples.external.Order;
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.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static io.dapr.springboot.examples.StringMatchesUUIDPattern.matchesThePatternOfAUUID;
import static io.restassured.RestAssured.given;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(classes = {TestExternalEventsApplication.class, DaprTestContainersConfig.class,
DaprAutoConfiguration.class, },
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
class ExternalEventsAppTests {
@Autowired
private DaprClient daprClient;
@BeforeEach
void setUp() {
RestAssured.baseURI = "http://localhost:" + 8080;
org.testcontainers.Testcontainers.exposeHostPorts(8080);
}
@Test
void testExternalEventsWorkflow() throws InterruptedException {
var order = new Order("123", "Rubber ducks", 100, 500);
given().contentType(ContentType.JSON)
.body(order)
.when()
.post("/start")
.then()
.statusCode(200).body(matchesThePatternOfAUUID());
String status = given().contentType(ContentType.JSON)
.when()
.get("/status")
.then()
.statusCode(200).extract().asString();
assertTrue(status.contains("RUNNING"));
ApprovalStatus approvalStatus = new ApprovalStatus("123", true);
given().contentType(ContentType.JSON)
.body(approvalStatus)
.when()
.post("/event")
.then()
.statusCode(200);
// Wait for the workflow instance to complete
await().atMost(Duration.ofSeconds(2))
.pollDelay(500, TimeUnit.MILLISECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.until(() -> {
var completedStatus = given().contentType(ContentType.JSON)
.when()
.get("/status")
.then()
.statusCode(200).extract().asString();
assertTrue(completedStatus.contains("COMPLETED"));
assertTrue(completedStatus.contains("has been approved"));
return true;
});
}
}
class StringMatchesUUIDPattern extends TypeSafeMatcher<String> {
private static final String UUID_REGEX = "[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}";
@Override
protected boolean matchesSafely(String s) {
return s.matches(UUID_REGEX);
}
@Override
public void describeTo(Description description) {
description.appendText("a string matching the pattern of a UUID");
}
public static Matcher<String> matchesThePatternOfAUUID() {
return new StringMatchesUUIDPattern();
}
}

View File

@ -0,0 +1,86 @@
/*
* 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;
import io.dapr.client.DaprClient;
import io.dapr.springboot.DaprAutoConfiguration;
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 java.math.BigDecimal;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(classes = {TestShippingApplication.class, DaprTestContainersConfig.class,
DaprAutoConfiguration.class, },
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
class ShippingAppTests {
@Autowired
private DaprClient daprClient;
@BeforeEach
void setUp() {
RestAssured.baseURI = "http://localhost:" + 8080;
org.testcontainers.Testcontainers.exposeHostPorts(8080);
}
@Test
void testCheckDestinationEndpoint() throws InterruptedException {
ShippingAppRestController.OrderItem orderItem = new ShippingAppRestController.OrderItem("ABC-123", "The Mars Volta EP", 1, BigDecimal.valueOf(100));
ShippingAppRestController.CustomerInfo customerInfo = new ShippingAppRestController.CustomerInfo("CUST-456", "UK");
var order = new ShippingAppRestController.Order("123", orderItem, customerInfo);
ShippingAppRestController.ShippingDestinationResult result = given().contentType(ContentType.JSON)
.body(order)
.when()
.post("/checkDestination")
.then()
.statusCode(200).extract()
.as(ShippingAppRestController.ShippingDestinationResult.class);
assertTrue(result.isSuccess());
}
@Test
void testCheckRegisterShipmentEndpoint() throws InterruptedException {
ShippingAppRestController.OrderItem orderItem = new ShippingAppRestController.OrderItem("ABC-123", "The Mars Volta EP", 1, BigDecimal.valueOf(100));
ShippingAppRestController.CustomerInfo customerInfo = new ShippingAppRestController.CustomerInfo("CUST-456", "UK");
var order = new ShippingAppRestController.Order("123", orderItem, customerInfo);
ShippingAppRestController.ShipmentRegistrationStatus status = given().contentType(ContentType.JSON)
.body(order)
.when()
.post("/registerShipment")
.then()
.statusCode(200).extract()
.as(ShippingAppRestController.ShipmentRegistrationStatus.class);
assertTrue(status.isSuccess());
}
}

View File

@ -18,11 +18,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TestExternalEventsApplication {
public class TestShippingApplication {
public static void main(String[] args) {
SpringApplication.from(ExternalEventsApplication::main)
SpringApplication.from(ShippingApplication::main)
.with(DaprTestContainersConfig.class)
.run(args);
org.testcontainers.Testcontainers.exposeHostPorts(8080);