diff --git a/order/order-web/src/main/java/demo/event/OrderEventType.java b/order/order-web/src/main/java/demo/event/OrderEventType.java index 85305f2..38e79b5 100644 --- a/order/order-web/src/main/java/demo/event/OrderEventType.java +++ b/order/order-web/src/main/java/demo/event/OrderEventType.java @@ -10,6 +10,15 @@ import demo.order.OrderStatus; * @author kbastani */ public enum OrderEventType { - // TODO: Implement - ORDER_CREATED + ORDER_CREATED, + ACCOUNT_CONNECTED, + RESERVATION_PENDING, + INVENTORY_RESERVED, + RESERVATION_SUCCEEDED, + RESERVATION_FAILED, + PAYMENT_CREATED, + PAYMENT_CONNECTED, + PAYMENT_PENDING, + PAYMENT_SUCCEEDED, + PAYMENT_FAILED } diff --git a/order/order-web/src/main/java/demo/order/Order.java b/order/order-web/src/main/java/demo/order/Order.java index dca0545..dcb2b04 100644 --- a/order/order-web/src/main/java/demo/order/Order.java +++ b/order/order-web/src/main/java/demo/order/Order.java @@ -31,7 +31,7 @@ public class Order extends BaseEntity { private Address shippingAddress; public Order() { - this.status = OrderStatus.PURCHASED; + this.status = OrderStatus.ORDER_CREATED; } public Order(String accountNumber, Address shippingAddress) { diff --git a/order/order-web/src/main/java/demo/order/OrderCommand.java b/order/order-web/src/main/java/demo/order/OrderCommand.java index f938f04..17ea5ce 100644 --- a/order/order-web/src/main/java/demo/order/OrderCommand.java +++ b/order/order-web/src/main/java/demo/order/OrderCommand.java @@ -1,6 +1,9 @@ package demo.order; public enum OrderCommand { - // TODO: Create commands - TODO + CONNECT_ACCOUNT, + RESERVE_INVENTORY, + CREATE_PAYMENT, + CONNECT_PAYMENT, + PROCESS_PAYMENT } diff --git a/order/order-web/src/main/java/demo/order/OrderController.java b/order/order-web/src/main/java/demo/order/OrderController.java index bf4b32d..2238d3e 100644 --- a/order/order-web/src/main/java/demo/order/OrderController.java +++ b/order/order-web/src/main/java/demo/order/OrderController.java @@ -76,10 +76,42 @@ public class OrderController { .orElseThrow(() -> new RuntimeException("The order could not be found")); } - @GetMapping(path = "/orders/{id}/commands/todo") - public ResponseEntity confirmOrder(@PathVariable Long id) { + @GetMapping(path = "/orders/{id}/commands/connectAccount") + public ResponseEntity connectAccount(@PathVariable Long id) { return Optional.ofNullable(getOrderResource( - orderService.applyCommand(id, OrderCommand.TODO))) + orderService.applyCommand(id, OrderCommand.CONNECT_ACCOUNT))) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElseThrow(() -> new RuntimeException("The command could not be applied")); + } + + @GetMapping(path = "/orders/{id}/commands/connectPayment") + public ResponseEntity connectPayment(@PathVariable Long id) { + return Optional.ofNullable(getOrderResource( + orderService.applyCommand(id, OrderCommand.CONNECT_PAYMENT))) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElseThrow(() -> new RuntimeException("The command could not be applied")); + } + + @GetMapping(path = "/orders/{id}/commands/createPayment") + public ResponseEntity createPayment(@PathVariable Long id) { + return Optional.ofNullable(getOrderResource( + orderService.applyCommand(id, OrderCommand.CREATE_PAYMENT))) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElseThrow(() -> new RuntimeException("The command could not be applied")); + } + + @GetMapping(path = "/orders/{id}/commands/processPayment") + public ResponseEntity processPayment(@PathVariable Long id) { + return Optional.ofNullable(getOrderResource( + orderService.applyCommand(id, OrderCommand.PROCESS_PAYMENT))) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElseThrow(() -> new RuntimeException("The command could not be applied")); + } + + @GetMapping(path = "/orders/{id}/commands/reserveInventory") + public ResponseEntity reserveInventory(@PathVariable Long id) { + return Optional.ofNullable(getOrderResource( + orderService.applyCommand(id, OrderCommand.RESERVE_INVENTORY))) .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) .orElseThrow(() -> new RuntimeException("The command could not be applied")); } @@ -177,17 +209,20 @@ public class OrderController { if (orderResource != null) { commandResource.add( getCommandLinkBuilder(id) - .slash("confirm") - .withRel("confirm"), + .slash("connectAccount") + .withRel("connectAccount"), getCommandLinkBuilder(id) - .slash("activate") - .withRel("activate"), + .slash("reserveInventory") + .withRel("reserveInventory"), getCommandLinkBuilder(id) - .slash("suspend") - .withRel("suspend"), + .slash("createPayment") + .withRel("createPayment"), getCommandLinkBuilder(id) - .slash("archive") - .withRel("archive") + .slash("connectPayment") + .withRel("connectPayment"), + getCommandLinkBuilder(id) + .slash("processPayment") + .withRel("processPayment") ); } diff --git a/order/order-web/src/main/java/demo/order/OrderStatus.java b/order/order-web/src/main/java/demo/order/OrderStatus.java index 257cd01..1f8cd05 100644 --- a/order/order-web/src/main/java/demo/order/OrderStatus.java +++ b/order/order-web/src/main/java/demo/order/OrderStatus.java @@ -1,9 +1,15 @@ package demo.order; public enum OrderStatus { - PURCHASED, - PENDING, - CONFIRMED, - SHIPPED, - DELIVERED + ORDER_CREATED, + ACCOUNT_CONNECTED, + RESERVATION_PENDING, + INVENTORY_RESERVED, + RESERVATION_SUCCEEDED, + RESERVATION_FAILED, + PAYMENT_CREATED, + PAYMENT_CONNECTED, + PAYMENT_PENDING, + PAYMENT_SUCCEEDED, + PAYMENT_FAILED } diff --git a/order/order-worker/src/main/java/demo/config/StateMachineConfig.java b/order/order-worker/src/main/java/demo/config/StateMachineConfig.java index b2abd4f..fc68cd4 100644 --- a/order/order-worker/src/main/java/demo/config/StateMachineConfig.java +++ b/order/order-worker/src/main/java/demo/config/StateMachineConfig.java @@ -2,11 +2,15 @@ package demo.config; import demo.event.OrderEvent; import demo.event.OrderEventType; -import demo.function.OrderFunction; +import demo.function.*; import demo.order.Order; import demo.order.OrderStatus; +import demo.stream.OrderStream; import org.apache.log4j.Logger; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.client.Traverson; import org.springframework.statemachine.StateContext; import org.springframework.statemachine.StateMachine; import org.springframework.statemachine.action.Action; @@ -15,6 +19,7 @@ import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter import org.springframework.statemachine.config.builders.StateMachineStateConfigurer; import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer; +import java.net.URI; import java.util.EnumSet; /** @@ -23,7 +28,7 @@ import java.util.EnumSet; *

* A state machine provides a robust declarative language for describing the state of an {@link Order} * resource given a sequence of ordered {@link demo.event.OrderEvents}. When an event is received - * in {@link demo.event.OrderEventStream}, an in-memory state machine is fully replicated given the + * in {@link OrderStream}, an in-memory state machine is fully replicated given the * {@link demo.event.OrderEvents} attached to an {@link Order} resource. * * @author kbastani @@ -44,7 +49,7 @@ public class StateMachineConfig extends EnumStateMachineConfigurerAdapter transitions) { try { // Describe state machine transitions for orders - // TODO: Configure state machine + transitions.withExternal() + .source(OrderStatus.ORDER_CREATED) + .target(OrderStatus.ORDER_CREATED) + .event(OrderEventType.ORDER_CREATED) + .action(orderCreated()) + .and() + .withExternal() + .source(OrderStatus.ORDER_CREATED) + .target(OrderStatus.ACCOUNT_CONNECTED) + .event(OrderEventType.ACCOUNT_CONNECTED) + .action(accountConnected()) + .and() + .withExternal() + .source(OrderStatus.ACCOUNT_CONNECTED) + .target(OrderStatus.RESERVATION_PENDING) + .event(OrderEventType.RESERVATION_PENDING) + .action(reservationPending()) + .and() + .withExternal() + .source(OrderStatus.RESERVATION_PENDING) + .target(OrderStatus.RESERVATION_SUCCEEDED) + .event(OrderEventType.RESERVATION_SUCCEEDED) + .action(reservationSucceeded()) + .and() + .withExternal() + .source(OrderStatus.RESERVATION_PENDING) + .target(OrderStatus.RESERVATION_FAILED) + .event(OrderEventType.RESERVATION_FAILED) + .action(reservationFailed()) + .and() + .withExternal() + .source(OrderStatus.RESERVATION_SUCCEEDED) + .target(OrderStatus.PAYMENT_CREATED) + .event(OrderEventType.PAYMENT_CREATED) + .action(paymentCreated()) + .and() + .withExternal() + .source(OrderStatus.PAYMENT_CREATED) + .target(OrderStatus.PAYMENT_CONNECTED) + .event(OrderEventType.PAYMENT_CONNECTED) + .action(paymentConnected()) + .and() + .withExternal() + .source(OrderStatus.PAYMENT_CONNECTED) + .target(OrderStatus.PAYMENT_PENDING) + .event(OrderEventType.PAYMENT_PENDING) + .action(paymentPending()) + .and() + .withExternal() + .source(OrderStatus.PAYMENT_PENDING) + .target(OrderStatus.PAYMENT_SUCCEEDED) + .event(OrderEventType.PAYMENT_SUCCEEDED) + .action(paymentSucceeded()) + .and() + .withExternal() + .source(OrderStatus.PAYMENT_PENDING) + .target(OrderStatus.PAYMENT_FAILED) + .event(OrderEventType.PAYMENT_FAILED) + .action(paymentFailed()); } catch (Exception e) { throw new RuntimeException("Could not configure state machine transitions", e); } } + @Bean + public Action orderCreated() { + return context -> applyEvent(context, + new OrderCreated(context, event -> { + log.info(event.getType() + ": " + event.getLink("order").getHref()); + // Get the account resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("order").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Order.class) + .getBody(); + })); + } + + @Bean + public Action paymentPending() { + return context -> applyEvent(context, + new PaymentPending(context, event -> { + log.info(event.getType() + ": " + event.getLink("order").getHref()); + // Get the account resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("order").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Order.class) + .getBody(); + })); + } + + @Bean + public Action reservationPending() { + return context -> applyEvent(context, + new ReservationPending(context, event -> { + log.info(event.getType() + ": " + event.getLink("order").getHref()); + // Get the account resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("order").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Order.class) + .getBody(); + })); + } + + @Bean + public Action paymentFailed() { + return context -> applyEvent(context, + new PaymentFailed(context, event -> { + log.info(event.getType() + ": " + event.getLink("order").getHref()); + // Get the account resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("order").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Order.class) + .getBody(); + })); + } + + @Bean + public Action paymentSucceeded() { + return context -> applyEvent(context, + new PaymentSucceeded(context, event -> { + log.info(event.getType() + ": " + event.getLink("order").getHref()); + // Get the account resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("order").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Order.class) + .getBody(); + })); + } + + @Bean + public Action paymentConnected() { + return context -> applyEvent(context, + new PaymentConnected(context, event -> { + log.info(event.getType() + ": " + event.getLink("order").getHref()); + // Get the account resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("order").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Order.class) + .getBody(); + })); + } + + @Bean + public Action paymentCreated() { + return context -> applyEvent(context, + new PaymentCreated(context, event -> { + log.info(event.getType() + ": " + event.getLink("order").getHref()); + // Get the account resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("order").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Order.class) + .getBody(); + })); + } + + @Bean + public Action reservationSucceeded() { + return context -> applyEvent(context, + new ReservationSucceeded(context, event -> { + log.info(event.getType() + ": " + event.getLink("order").getHref()); + // Get the account resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("order").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Order.class) + .getBody(); + })); + } + + @Bean + public Action reservationFailed() { + return context -> applyEvent(context, + new ReservationSucceeded(context, event -> { + log.info(event.getType() + ": " + event.getLink("order").getHref()); + // Get the account resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("order").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Order.class) + .getBody(); + })); + } + + @Bean + public Action accountConnected() { + return context -> applyEvent(context, + new ReservationFailed(context, event -> { + log.info(event.getType() + ": " + event.getLink("order").getHref()); + // Get the account resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("order").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Order.class) + .getBody(); + })); + } + /** * Functions are mapped to actions that are triggered during the replication of a state machine. Functions * should only be executed after the state machine has completed replication. This method checks the state @@ -78,12 +311,12 @@ public class StateMachineConfig extends EnumStateMachineConfigurerAdapter context, - OrderFunction orderFunction) { + OrderFunction orderFunction) { OrderEvent orderEvent = null; // Log out the progress of the state machine replication diff --git a/order/order-worker/src/main/java/demo/event/OrderEventType.java b/order/order-worker/src/main/java/demo/event/OrderEventType.java index 0984b05..1b31827 100644 --- a/order/order-worker/src/main/java/demo/event/OrderEventType.java +++ b/order/order-worker/src/main/java/demo/event/OrderEventType.java @@ -1,5 +1,15 @@ package demo.event; public enum OrderEventType { - // TODO: Add event types + ORDER_CREATED, + ACCOUNT_CONNECTED, + RESERVATION_PENDING, + INVENTORY_RESERVED, + RESERVATION_SUCCEEDED, + RESERVATION_FAILED, + PAYMENT_CREATED, + PAYMENT_CONNECTED, + PAYMENT_PENDING, + PAYMENT_SUCCEEDED, + PAYMENT_FAILED } diff --git a/order/order-worker/src/main/java/demo/function/AccountConnected.java b/order/order-worker/src/main/java/demo/function/AccountConnected.java new file mode 100644 index 0000000..3fe3dc5 --- /dev/null +++ b/order/order-worker/src/main/java/demo/function/AccountConnected.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.order.Order; +import demo.order.OrderStatus; +import demo.event.OrderEvent; +import demo.event.OrderEventType; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class AccountConnected extends OrderFunction { + + final private Logger log = Logger.getLogger(AccountConnected.class); + + public AccountConnected(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link OrderEvent} to the lambda function that was provided through the + * constructor of this {@link OrderFunction}. + * + * @param event is the {@link OrderEvent} to apply to the lambda function + */ + @Override + public Order apply(OrderEvent event) { + log.info("Executing workflow for account connected..."); + return super.apply(event); + } +} diff --git a/order/order-worker/src/main/java/demo/function/OrderCreated.java b/order/order-worker/src/main/java/demo/function/OrderCreated.java new file mode 100644 index 0000000..6c98ce3 --- /dev/null +++ b/order/order-worker/src/main/java/demo/function/OrderCreated.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.OrderEvent; +import demo.event.OrderEventType; +import demo.order.Order; +import demo.order.OrderStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class OrderCreated extends OrderFunction { + + final private Logger log = Logger.getLogger(OrderCreated.class); + + public OrderCreated(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link OrderEvent} to the lambda function that was provided through the + * constructor of this {@link OrderFunction}. + * + * @param event is the {@link OrderEvent} to apply to the lambda function + */ + @Override + public Order apply(OrderEvent event) { + log.info("Executing workflow for order created..."); + return super.apply(event); + } +} diff --git a/order/order-worker/src/main/java/demo/function/OrderFunction.java b/order/order-worker/src/main/java/demo/function/OrderFunction.java index cb34c50..7214b53 100644 --- a/order/order-worker/src/main/java/demo/function/OrderFunction.java +++ b/order/order-worker/src/main/java/demo/function/OrderFunction.java @@ -46,6 +46,7 @@ public abstract class OrderFunction { // Execute the lambda function Order result = lambda.apply(event); context.getExtendedState().getVariables().put("order", result); + log.info("Order function: " + event.getType()); return result; } } diff --git a/order/order-worker/src/main/java/demo/function/PaymentConnected.java b/order/order-worker/src/main/java/demo/function/PaymentConnected.java new file mode 100644 index 0000000..db1dace --- /dev/null +++ b/order/order-worker/src/main/java/demo/function/PaymentConnected.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.OrderEvent; +import demo.event.OrderEventType; +import demo.order.Order; +import demo.order.OrderStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class PaymentConnected extends OrderFunction { + + final private Logger log = Logger.getLogger(PaymentConnected.class); + + public PaymentConnected(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link OrderEvent} to the lambda function that was provided through the + * constructor of this {@link OrderFunction}. + * + * @param event is the {@link OrderEvent} to apply to the lambda function + */ + @Override + public Order apply(OrderEvent event) { + log.info("Executing workflow for payment connected..."); + return super.apply(event); + } +} diff --git a/order/order-worker/src/main/java/demo/function/PaymentCreated.java b/order/order-worker/src/main/java/demo/function/PaymentCreated.java new file mode 100644 index 0000000..a8743c4 --- /dev/null +++ b/order/order-worker/src/main/java/demo/function/PaymentCreated.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.OrderEvent; +import demo.event.OrderEventType; +import demo.order.Order; +import demo.order.OrderStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class PaymentCreated extends OrderFunction { + + final private Logger log = Logger.getLogger(PaymentCreated.class); + + public PaymentCreated(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link OrderEvent} to the lambda function that was provided through the + * constructor of this {@link OrderFunction}. + * + * @param event is the {@link OrderEvent} to apply to the lambda function + */ + @Override + public Order apply(OrderEvent event) { + log.info("Executing workflow for payment created..."); + return super.apply(event); + } +} diff --git a/order/order-worker/src/main/java/demo/function/PaymentFailed.java b/order/order-worker/src/main/java/demo/function/PaymentFailed.java new file mode 100644 index 0000000..0227ca9 --- /dev/null +++ b/order/order-worker/src/main/java/demo/function/PaymentFailed.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.OrderEvent; +import demo.event.OrderEventType; +import demo.order.Order; +import demo.order.OrderStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class PaymentFailed extends OrderFunction { + + final private Logger log = Logger.getLogger(PaymentFailed.class); + + public PaymentFailed(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link OrderEvent} to the lambda function that was provided through the + * constructor of this {@link OrderFunction}. + * + * @param event is the {@link OrderEvent} to apply to the lambda function + */ + @Override + public Order apply(OrderEvent event) { + log.info("Executing workflow for payment failed..."); + return super.apply(event); + } +} diff --git a/order/order-worker/src/main/java/demo/function/PaymentPending.java b/order/order-worker/src/main/java/demo/function/PaymentPending.java new file mode 100644 index 0000000..75e53cd --- /dev/null +++ b/order/order-worker/src/main/java/demo/function/PaymentPending.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.OrderEvent; +import demo.event.OrderEventType; +import demo.order.Order; +import demo.order.OrderStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class PaymentPending extends OrderFunction { + + final private Logger log = Logger.getLogger(PaymentPending.class); + + public PaymentPending(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link OrderEvent} to the lambda function that was provided through the + * constructor of this {@link OrderFunction}. + * + * @param event is the {@link OrderEvent} to apply to the lambda function + */ + @Override + public Order apply(OrderEvent event) { + log.info("Executing workflow for payment pending..."); + return super.apply(event); + } +} diff --git a/order/order-worker/src/main/java/demo/function/PaymentSucceeded.java b/order/order-worker/src/main/java/demo/function/PaymentSucceeded.java new file mode 100644 index 0000000..7f522b9 --- /dev/null +++ b/order/order-worker/src/main/java/demo/function/PaymentSucceeded.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.OrderEvent; +import demo.event.OrderEventType; +import demo.order.Order; +import demo.order.OrderStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class PaymentSucceeded extends OrderFunction { + + final private Logger log = Logger.getLogger(PaymentSucceeded.class); + + public PaymentSucceeded(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link OrderEvent} to the lambda function that was provided through the + * constructor of this {@link OrderFunction}. + * + * @param event is the {@link OrderEvent} to apply to the lambda function + */ + @Override + public Order apply(OrderEvent event) { + log.info("Executing workflow for payment succeeded..."); + return super.apply(event); + } +} diff --git a/order/order-worker/src/main/java/demo/function/ReservationFailed.java b/order/order-worker/src/main/java/demo/function/ReservationFailed.java new file mode 100644 index 0000000..6933bce --- /dev/null +++ b/order/order-worker/src/main/java/demo/function/ReservationFailed.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.OrderEvent; +import demo.event.OrderEventType; +import demo.order.Order; +import demo.order.OrderStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class ReservationFailed extends OrderFunction { + + final private Logger log = Logger.getLogger(ReservationFailed.class); + + public ReservationFailed(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link OrderEvent} to the lambda function that was provided through the + * constructor of this {@link OrderFunction}. + * + * @param event is the {@link OrderEvent} to apply to the lambda function + */ + @Override + public Order apply(OrderEvent event) { + log.info("Executing workflow for reservation failed..."); + return super.apply(event); + } +} diff --git a/order/order-worker/src/main/java/demo/function/ReservationPending.java b/order/order-worker/src/main/java/demo/function/ReservationPending.java new file mode 100644 index 0000000..6e341b6 --- /dev/null +++ b/order/order-worker/src/main/java/demo/function/ReservationPending.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.OrderEvent; +import demo.event.OrderEventType; +import demo.order.Order; +import demo.order.OrderStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class ReservationPending extends OrderFunction { + + final private Logger log = Logger.getLogger(ReservationPending.class); + + public ReservationPending(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link OrderEvent} to the lambda function that was provided through the + * constructor of this {@link OrderFunction}. + * + * @param event is the {@link OrderEvent} to apply to the lambda function + */ + @Override + public Order apply(OrderEvent event) { + log.info("Executing workflow for reservation pending..."); + return super.apply(event); + } +} diff --git a/order/order-worker/src/main/java/demo/function/ReservationSucceeded.java b/order/order-worker/src/main/java/demo/function/ReservationSucceeded.java new file mode 100644 index 0000000..7c9ce41 --- /dev/null +++ b/order/order-worker/src/main/java/demo/function/ReservationSucceeded.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.OrderEvent; +import demo.event.OrderEventType; +import demo.order.Order; +import demo.order.OrderStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class ReservationSucceeded extends OrderFunction { + + final private Logger log = Logger.getLogger(ReservationSucceeded.class); + + public ReservationSucceeded(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link OrderEvent} to the lambda function that was provided through the + * constructor of this {@link OrderFunction}. + * + * @param event is the {@link OrderEvent} to apply to the lambda function + */ + @Override + public Order apply(OrderEvent event) { + log.info("Executing workflow for reservation succeeded..."); + return super.apply(event); + } +} diff --git a/order/order-worker/src/main/java/demo/order/LineItem.java b/order/order-worker/src/main/java/demo/order/LineItem.java index f26f9db..02a26a4 100644 --- a/order/order-worker/src/main/java/demo/order/LineItem.java +++ b/order/order-worker/src/main/java/demo/order/LineItem.java @@ -1,17 +1,16 @@ package demo.order; -/** - * A simple domain class for the {@link LineItem} concept in the order context. - * - * @author Kenny Bastani - * @author Josh Long - */ -public class LineItem { +import java.io.Serializable; + +public class LineItem implements Serializable { private String name, productId; private Integer quantity; private Double price, tax; + public LineItem() { + } + public LineItem(String name, String productId, Integer quantity, Double price, Double tax) { this.name = name; diff --git a/order/order-worker/src/main/java/demo/order/OrderStatus.java b/order/order-worker/src/main/java/demo/order/OrderStatus.java index 7ffb9bb..b4f6230 100644 --- a/order/order-worker/src/main/java/demo/order/OrderStatus.java +++ b/order/order-worker/src/main/java/demo/order/OrderStatus.java @@ -1,14 +1,14 @@ package demo.order; -/** - * Describes the state of an {@link Order}. - * - * @author Kenny Bastani - * @author Josh Long - */ public enum OrderStatus { - PENDING, - CONFIRMED, - SHIPPED, - DELIVERED + ORDER_CREATED, + ACCOUNT_CONNECTED, + RESERVATION_PENDING, + RESERVATION_SUCCEEDED, + RESERVATION_FAILED, + PAYMENT_CREATED, + PAYMENT_CONNECTED, + PAYMENT_PENDING, + PAYMENT_SUCCEEDED, + PAYMENT_FAILED } diff --git a/order/order-worker/src/main/java/demo/event/OrderEventStream.java b/order/order-worker/src/main/java/demo/stream/OrderStream.java similarity index 74% rename from order/order-worker/src/main/java/demo/event/OrderEventStream.java rename to order/order-worker/src/main/java/demo/stream/OrderStream.java index cd2b471..1713e0d 100644 --- a/order/order-worker/src/main/java/demo/event/OrderEventStream.java +++ b/order/order-worker/src/main/java/demo/stream/OrderStream.java @@ -1,5 +1,7 @@ -package demo.event; +package demo.stream; +import demo.event.EventService; +import demo.event.OrderEvent; import demo.order.Order; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.cloud.stream.annotation.EnableBinding; @@ -8,7 +10,7 @@ import org.springframework.cloud.stream.messaging.Sink; import org.springframework.context.annotation.Profile; /** - * The {@link OrderEventStream} monitors for a variety of {@link OrderEvent} domain + * The {@link OrderStream} monitors for a variety of {@link OrderEvent} domain * events for an {@link Order}. * * @author kbastani @@ -16,11 +18,11 @@ import org.springframework.context.annotation.Profile; @EnableAutoConfiguration @EnableBinding(Sink.class) @Profile({ "cloud", "development" }) -public class OrderEventStream { +public class OrderStream { private EventService eventService; - public OrderEventStream(EventService eventService) { + public OrderStream(EventService eventService) { this.eventService = eventService; } diff --git a/order/order-worker/src/main/resources/application.yml b/order/order-worker/src/main/resources/application.yml index 37237e4..9f7428b 100644 --- a/order/order-worker/src/main/resources/application.yml +++ b/order/order-worker/src/main/resources/application.yml @@ -8,8 +8,8 @@ spring: stream: bindings: input: - destination: account - group: account-group + destination: order + group: order-group contentType: 'application/json' consumer: durableSubscription: true diff --git a/payment/README.md b/payment/README.md new file mode 100644 index 0000000..ac00b28 --- /dev/null +++ b/payment/README.md @@ -0,0 +1,3 @@ +# Payment Microservice + +This is the parent project that contains modules of a microservice deployment for the _Payment_ domain context. The two modules contained in this project are separated into separate deployment artifacts, one for synchronous HTTP-based interactions and one for asynchronous AMQP-based messaging. diff --git a/payment/payment-web/pom.xml b/payment/payment-web/pom.xml new file mode 100644 index 0000000..6b0f333 --- /dev/null +++ b/payment/payment-web/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + payment-web + 0.0.1-SNAPSHOT + jar + + payment-web + + + org.kbastani + payment + 1.0-SNAPSHOT + ../ + + + + UTF-8 + UTF-8 + 1.8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-redis + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-hateoas + + + org.springframework.cloud + spring-cloud-starter-stream-rabbit + + + org.springframework.boot + spring-boot-starter-integration + + + + com.h2database + h2 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + diff --git a/payment/payment-web/src/main/java/demo/PaymentServiceApplication.java b/payment/payment-web/src/main/java/demo/PaymentServiceApplication.java new file mode 100644 index 0000000..3563fb0 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/PaymentServiceApplication.java @@ -0,0 +1,14 @@ +package demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.hateoas.config.EnableHypermediaSupport; + +@SpringBootApplication +@EnableHypermediaSupport(type = {EnableHypermediaSupport.HypermediaType.HAL}) +public class PaymentServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(PaymentServiceApplication.class, args); + } +} diff --git a/payment/payment-web/src/main/java/demo/config/CacheConfig.java b/payment/payment-web/src/main/java/demo/config/CacheConfig.java new file mode 100644 index 0000000..0279460 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/config/CacheConfig.java @@ -0,0 +1,46 @@ +package demo.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Arrays; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public JedisConnectionFactory redisConnectionFactory( + @Value("${spring.redis.port}") Integer redisPort, + @Value("${spring.redis.host}") String redisHost) { + JedisConnectionFactory redisConnectionFactory = new JedisConnectionFactory(); + + redisConnectionFactory.setHostName(redisHost); + redisConnectionFactory.setPort(redisPort); + + return redisConnectionFactory; + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory cf) { + RedisTemplate redisTemplate = new RedisTemplate(); + redisTemplate.setConnectionFactory(cf); + return redisTemplate; + } + + @Bean + public CacheManager cacheManager(RedisTemplate redisTemplate) { + RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate); + cacheManager.setDefaultExpiration(50000); + cacheManager.setCacheNames(Arrays.asList("payments", "payment-events")); + cacheManager.setUsePrefix(true); + return cacheManager; + } +} \ No newline at end of file diff --git a/payment/payment-web/src/main/java/demo/config/JpaConfig.java b/payment/payment-web/src/main/java/demo/config/JpaConfig.java new file mode 100644 index 0000000..c6c09e0 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/config/JpaConfig.java @@ -0,0 +1,13 @@ +package demo.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * Enable JPA auditing on an empty configuration class to disable auditing on + * + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/payment/payment-web/src/main/java/demo/config/StreamConfig.java b/payment/payment-web/src/main/java/demo/config/StreamConfig.java new file mode 100644 index 0000000..2eaf902 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/config/StreamConfig.java @@ -0,0 +1,10 @@ +package demo.config; + +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableBinding(Source.class) +public class StreamConfig { +} diff --git a/payment/payment-web/src/main/java/demo/config/WebMvcConfig.java b/payment/payment-web/src/main/java/demo/config/WebMvcConfig.java new file mode 100644 index 0000000..9282e23 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/config/WebMvcConfig.java @@ -0,0 +1,36 @@ +package demo.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; + +import java.util.Collections; +import java.util.List; + +@Configuration +public class WebMvcConfig extends WebMvcConfigurerAdapter { + + private ObjectMapper objectMapper; + + public WebMvcConfig(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void configureMessageConverters(List> converters) { + final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + converter.setObjectMapper(objectMapper); + converters.add(converter); + } + + @Bean + protected RestTemplate restTemplate(ObjectMapper objectMapper) { + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + converter.setObjectMapper(objectMapper); + return new RestTemplate(Collections.singletonList(converter)); + } +} \ No newline at end of file diff --git a/payment/payment-web/src/main/java/demo/domain/BaseEntity.java b/payment/payment-web/src/main/java/demo/domain/BaseEntity.java new file mode 100644 index 0000000..fb472ef --- /dev/null +++ b/payment/payment-web/src/main/java/demo/domain/BaseEntity.java @@ -0,0 +1,48 @@ +package demo.domain; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.hateoas.ResourceSupport; + +import javax.persistence.EntityListeners; +import javax.persistence.MappedSuperclass; +import java.io.Serializable; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity extends ResourceSupport implements Serializable { + + @CreatedDate + private Long createdAt; + + @LastModifiedDate + private Long lastModified; + + public BaseEntity() { + } + + public Long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Long createdAt) { + this.createdAt = createdAt; + } + + public Long getLastModified() { + return lastModified; + } + + public void setLastModified(Long lastModified) { + this.lastModified = lastModified; + } + + @Override + public String toString() { + return "BaseEntity{" + + "createdAt=" + createdAt + + ", lastModified=" + lastModified + + '}'; + } +} diff --git a/payment/payment-web/src/main/java/demo/event/ConsistencyModel.java b/payment/payment-web/src/main/java/demo/event/ConsistencyModel.java new file mode 100644 index 0000000..8bef081 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/event/ConsistencyModel.java @@ -0,0 +1,6 @@ +package demo.event; + +public enum ConsistencyModel { + BASE, + ACID +} diff --git a/payment/payment-web/src/main/java/demo/event/EventController.java b/payment/payment-web/src/main/java/demo/event/EventController.java new file mode 100644 index 0000000..18e5e31 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/event/EventController.java @@ -0,0 +1,39 @@ +package demo.event; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +@RestController +@RequestMapping("/v1") +public class EventController { + + private final EventService eventService; + + public EventController(EventService eventService) { + this.eventService = eventService; + } + + @PostMapping(path = "/events/{id}") + public ResponseEntity createEvent(@RequestBody PaymentEvent event, @PathVariable Long id) { + return Optional.ofNullable(eventService.createEvent(id, event, ConsistencyModel.ACID)) + .map(e -> new ResponseEntity<>(e, HttpStatus.CREATED)) + .orElseThrow(() -> new IllegalArgumentException("Event creation failed")); + } + + @PutMapping(path = "/events/{id}") + public ResponseEntity updateEvent(@RequestBody PaymentEvent event, @PathVariable Long id) { + return Optional.ofNullable(eventService.updateEvent(id, event)) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElseThrow(() -> new IllegalArgumentException("Event update failed")); + } + + @GetMapping(path = "/events/{id}") + public ResponseEntity getEvent(@PathVariable Long id) { + return Optional.ofNullable(eventService.getEvent(id)) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND)); + } +} diff --git a/payment/payment-web/src/main/java/demo/event/EventRepository.java b/payment/payment-web/src/main/java/demo/event/EventRepository.java new file mode 100644 index 0000000..e906d2c --- /dev/null +++ b/payment/payment-web/src/main/java/demo/event/EventRepository.java @@ -0,0 +1,10 @@ +package demo.event; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; + +public interface EventRepository extends JpaRepository { + Page findPaymentEventsByPaymentId(@Param("paymentId") Long paymentId, Pageable pageable); +} diff --git a/payment/payment-web/src/main/java/demo/event/EventService.java b/payment/payment-web/src/main/java/demo/event/EventService.java new file mode 100644 index 0000000..b46e642 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/event/EventService.java @@ -0,0 +1,214 @@ +package demo.event; + +import demo.payment.Payment; +import demo.payment.PaymentController; +import org.apache.log4j.Logger; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.data.domain.PageRequest; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.Resource; +import org.springframework.http.RequestEntity; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo; + +/** + * The {@link EventService} provides transactional service methods for {@link PaymentEvent} + * entities of the Payment Service. Payment domain events are generated with a {@link PaymentEventType}, + * and action logs are appended to the {@link PaymentEvent}. + * + * @author kbastani + */ +@Service +@CacheConfig(cacheNames = {"payment-events"}) +public class EventService { + + private final Logger log = Logger.getLogger(EventService.class); + + private final EventRepository eventRepository; + private final Source paymentStreamSource; + private final RestTemplate restTemplate; + + public EventService(EventRepository eventRepository, Source paymentStreamSource, RestTemplate restTemplate) { + this.eventRepository = eventRepository; + this.paymentStreamSource = paymentStreamSource; + this.restTemplate = restTemplate; + } + + /** + * Create a new {@link PaymentEvent} and append it to the event log of the referenced {@link Payment}. + * After the {@link PaymentEvent} has been persisted, send the event to the payment stream. Events can + * be raised as a blocking or non-blocking operation depending on the {@link ConsistencyModel}. + * + * @param paymentId is the unique identifier for the {@link Payment} + * @param event is the {@link PaymentEvent} to create + * @param consistencyModel is the desired consistency model for the response + * @return an {@link PaymentEvent} that has been appended to the {@link Payment}'s event log + */ + public PaymentEvent createEvent(Long paymentId, PaymentEvent event, ConsistencyModel consistencyModel) { + event = createEvent(paymentId, event); + return raiseEvent(event, consistencyModel); + } + + /** + * Raise an {@link PaymentEvent} that attempts to transition the state of an {@link Payment}. + * + * @param event is an {@link PaymentEvent} that will be raised + * @param consistencyModel is the consistency model for this request + * @return an {@link PaymentEvent} that has been appended to the {@link Payment}'s event log + */ + public PaymentEvent raiseEvent(PaymentEvent event, ConsistencyModel consistencyModel) { + switch (consistencyModel) { + case BASE: + asyncRaiseEvent(event); + break; + case ACID: + event = raiseEvent(event); + break; + } + + return event; + } + + /** + * Raise an asynchronous {@link PaymentEvent} by sending an AMQP message to the payment stream. Any + * state changes will be applied to the {@link Payment} outside of the current HTTP request context. + *

+ * Use this operation when a workflow can be processed asynchronously outside of the current HTTP + * request context. + * + * @param event is an {@link PaymentEvent} that will be raised + */ + private void asyncRaiseEvent(PaymentEvent event) { + // Append the payment event to the stream + paymentStreamSource.output() + .send(MessageBuilder + .withPayload(getPaymentEventResource(event)) + .build()); + } + + /** + * Raise a synchronous {@link PaymentEvent} by sending a HTTP request to the payment stream. The response + * is a blocking operation, which ensures that the result of a multi-step workflow will not return until + * the transaction reaches a consistent state. + *

+ * Use this operation when the result of a workflow must be returned within the current HTTP request context. + * + * @param event is an {@link PaymentEvent} that will be raised + * @return an {@link PaymentEvent} which contains the consistent state of an {@link Payment} + */ + private PaymentEvent raiseEvent(PaymentEvent event) { + try { + // Create a new request entity + RequestEntity> requestEntity = RequestEntity.post( + URI.create("http://localhost:8081/v1/events")) + .contentType(MediaTypes.HAL_JSON) + .body(getPaymentEventResource(event), Resource.class); + + // Update the payment entity's status + Payment result = restTemplate.exchange(requestEntity, Payment.class) + .getBody(); + + log.info(result); + event.setPayment(result); + } catch (Exception ex) { + log.error(ex); + } + + return event; + } + + + /** + * Create a new {@link PaymentEvent} and publish it to the payment stream. + * + * @param event is the {@link PaymentEvent} to publish to the payment stream + * @return a hypermedia {@link PaymentEvent} resource + */ + @CacheEvict(cacheNames = "payment-events", key = "#id.toString()") + public PaymentEvent createEvent(Long id, PaymentEvent event) { + // Save new event + event = addEvent(event); + Assert.notNull(event, "The event could not be appended to the payment"); + + return event; + } + + /** + * Get an {@link PaymentEvent} with the supplied identifier. + * + * @param id is the unique identifier for the {@link PaymentEvent} + * @return an {@link PaymentEvent} + */ + public Resource getEvent(Long id) { + return getPaymentEventResource(eventRepository.findOne(id)); + } + + /** + * Update an {@link PaymentEvent} with the supplied identifier. + * + * @param id is the unique identifier for the {@link PaymentEvent} + * @param event is the {@link PaymentEvent} to update + * @return the updated {@link PaymentEvent} + */ + @CacheEvict(cacheNames = "payment-events", key = "#event.getPayment().getPaymentId().toString()") + public PaymentEvent updateEvent(Long id, PaymentEvent event) { + Assert.notNull(id); + Assert.isTrue(event.getId() == null || Objects.equals(id, event.getId())); + + return eventRepository.save(event); + } + + /** + * Get {@link PaymentEvents} for the supplied {@link Payment} identifier. + * + * @param id is the unique identifier of the {@link Payment} + * @return a list of {@link PaymentEvent} wrapped in a hypermedia {@link PaymentEvents} resource + */ + @Cacheable(cacheNames = "payment-events", key = "#id.toString()") + public List getPaymentEvents(Long id) { + return eventRepository.findPaymentEventsByPaymentId(id, + new PageRequest(0, Integer.MAX_VALUE)).getContent(); + } + + /** + * Gets a hypermedia resource for a {@link PaymentEvent} entity. + * + * @param event is the {@link PaymentEvent} to enrich with hypermedia + * @return a hypermedia resource for the supplied {@link PaymentEvent} entity + */ + private Resource getPaymentEventResource(PaymentEvent event) { + return new Resource(event, Arrays.asList( + linkTo(PaymentController.class) + .slash("events") + .slash(event.getEventId()) + .withSelfRel(), + linkTo(PaymentController.class) + .slash("payments") + .slash(event.getPayment().getPaymentId()) + .withRel("payment"))); + } + + /** + * Add a {@link PaymentEvent} to an {@link Payment} entity. + * + * @param event is the {@link PaymentEvent} to append to an {@link Payment} entity + * @return the newly appended {@link PaymentEvent} entity + */ + @CacheEvict(cacheNames = "payment-events", key = "#event.getPayment().getPaymentId().toString()") + private PaymentEvent addEvent(PaymentEvent event) { + event = eventRepository.saveAndFlush(event); + return event; + } +} diff --git a/payment/payment-web/src/main/java/demo/event/PaymentEvent.java b/payment/payment-web/src/main/java/demo/event/PaymentEvent.java new file mode 100644 index 0000000..9f2e37b --- /dev/null +++ b/payment/payment-web/src/main/java/demo/event/PaymentEvent.java @@ -0,0 +1,73 @@ +package demo.event; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import demo.payment.Payment; +import demo.domain.BaseEntity; + +import javax.persistence.*; + +/** + * The domain event {@link PaymentEvent} tracks the type and state of events as + * applied to the {@link Payment} domain object. This event resource can be used + * to event source the aggregate state of {@link Payment}. + *

+ * This event resource also provides a transaction log that can be used to append + * actions to the event. + * + * @author kbastani + */ +@Entity +public class PaymentEvent extends BaseEntity { + + @Id + @GeneratedValue + private Long id; + + @Enumerated(EnumType.STRING) + private PaymentEventType type; + + @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @JsonIgnore + private Payment payment; + + public PaymentEvent() { + } + + public PaymentEvent(PaymentEventType type) { + this.type = type; + } + + @JsonIgnore + public Long getEventId() { + return id; + } + + public void setEventId(Long id) { + this.id = id; + } + + public PaymentEventType getType() { + return type; + } + + public void setType(PaymentEventType type) { + this.type = type; + } + + public Payment getPayment() { + return payment; + } + + public void setPayment(Payment payment) { + this.payment = payment; + } + + @Override + public String toString() { + return "PaymentEvent{" + + "id=" + id + + ", type=" + type + + ", payment=" + payment + + "} " + super.toString(); + } +} diff --git a/payment/payment-web/src/main/java/demo/event/PaymentEventType.java b/payment/payment-web/src/main/java/demo/event/PaymentEventType.java new file mode 100644 index 0000000..483ea22 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/event/PaymentEventType.java @@ -0,0 +1,18 @@ +package demo.event; + +import demo.payment.Payment; +import demo.payment.PaymentStatus; + +/** + * The {@link PaymentEventType} represents a collection of possible events that describe + * state transitions of {@link PaymentStatus} on the {@link Payment} aggregate. + * + * @author kbastani + */ +public enum PaymentEventType { + PAYMENT_CREATED, + PAYMENT_PENDING, + PAYMENT_PROCESSED, + PAYMENT_FAILED, + PAYMENT_SUCCEEDED +} diff --git a/payment/payment-web/src/main/java/demo/event/PaymentEvents.java b/payment/payment-web/src/main/java/demo/event/PaymentEvents.java new file mode 100644 index 0000000..ff0d4cf --- /dev/null +++ b/payment/payment-web/src/main/java/demo/event/PaymentEvents.java @@ -0,0 +1,74 @@ +package demo.event; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import demo.payment.Payment; +import demo.payment.PaymentController; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkBuilder; +import org.springframework.hateoas.Resources; + +import java.io.Serializable; +import java.util.List; + +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo; + +/** + * The {@link PaymentEvents} is a hypermedia collection of {@link PaymentEvent} resources. + * + * @author kbastani + */ +public class PaymentEvents extends Resources implements Serializable { + + private Long paymentId; + + /** + * Create a new {@link PaymentEvents} hypermedia resources collection for an {@link Payment}. + * + * @param paymentId is the unique identifier for the {@link Payment} + * @param content is the collection of {@link PaymentEvents} attached to the {@link Payment} + */ + public PaymentEvents(Long paymentId, List content) { + this(content); + this.paymentId = paymentId; + + // Add hypermedia links to resources parent + add(linkTo(PaymentController.class) + .slash("payments") + .slash(paymentId) + .slash("events") + .withSelfRel(), + linkTo(PaymentController.class) + .slash("payments") + .slash(paymentId) + .withRel("payment")); + + LinkBuilder linkBuilder = linkTo(EventController.class); + + // Add hypermedia links to each item of the collection + content.stream().parallel().forEach(event -> event.add( + linkBuilder.slash("events") + .slash(event.getEventId()) + .withSelfRel() + )); + } + + /** + * Creates a {@link Resources} instance with the given content and {@link Link}s (optional). + * + * @param content must not be {@literal null}. + * @param links the links to be added to the {@link Resources}. + */ + private PaymentEvents(Iterable content, Link... links) { + super(content, links); + } + + /** + * Get the {@link Payment} identifier that the {@link PaymentEvents} apply to. + * + * @return the payment identifier + */ + @JsonIgnore + public Long getPaymentId() { + return paymentId; + } +} diff --git a/payment/payment-web/src/main/java/demo/payment/Payment.java b/payment/payment-web/src/main/java/demo/payment/Payment.java new file mode 100644 index 0000000..146563a --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/Payment.java @@ -0,0 +1,106 @@ +package demo.payment; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import demo.domain.BaseEntity; +import demo.event.PaymentEvent; + +import javax.persistence.*; +import java.util.HashSet; +import java.util.Set; + +/** + * The {@link Payment} domain object contains information related to + * a user's payment. The status of an payment is event sourced using + * events logged to the {@link PaymentEvent} collection attached to + * this resource. + * + * @author kbastani + */ +@Entity +public class Payment extends BaseEntity { + + @Id + @GeneratedValue + private Long id; + + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private Set events = new HashSet<>(); + + @Enumerated(value = EnumType.STRING) + private PaymentStatus status; + + private Double amount; + private Long orderId; + + @Enumerated(value = EnumType.STRING) + private PaymentMethod paymentMethod; + + public Payment() { + status = PaymentStatus.PAYMENT_CREATED; + } + + public Payment(Double amount, PaymentMethod paymentMethod) { + this.amount = amount; + this.paymentMethod = paymentMethod; + } + + @JsonIgnore + public Long getPaymentId() { + return id; + } + + public void setPaymentId(Long id) { + this.id = id; + } + + @JsonIgnore + public Set getEvents() { + return events; + } + + public void setEvents(Set events) { + this.events = events; + } + + public PaymentStatus getStatus() { + return status; + } + + public void setStatus(PaymentStatus status) { + this.status = status; + } + + public Double getAmount() { + return amount; + } + + public void setAmount(Double amount) { + this.amount = amount; + } + + public PaymentMethod getPaymentMethod() { + return paymentMethod; + } + + public void setPaymentMethod(PaymentMethod paymentMethod) { + this.paymentMethod = paymentMethod; + } + + @JsonIgnore + public Long getOrderId() { + return orderId; + } + + public void setOrderId(Long orderId) { + this.orderId = orderId; + } + + @Override + public String toString() { + return "Payment{" + + "id=" + id + + ", events=" + events + + ", status=" + status + + "} " + super.toString(); + } +} diff --git a/payment/payment-web/src/main/java/demo/payment/PaymentCommand.java b/payment/payment-web/src/main/java/demo/payment/PaymentCommand.java new file mode 100644 index 0000000..f087cff --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/PaymentCommand.java @@ -0,0 +1,13 @@ +package demo.payment; + +/** + * The {@link PaymentCommand} represents an action that can be performed to an + * {@link Payment} aggregate. Commands initiate an action that can mutate the state of + * an payment entity as it transitions between {@link PaymentStatus} values. + * + * @author kbastani + */ +public enum PaymentCommand { + CONNECT_ORDER, + PROCESS_PAYMENT +} diff --git a/payment/payment-web/src/main/java/demo/payment/PaymentCommands.java b/payment/payment-web/src/main/java/demo/payment/PaymentCommands.java new file mode 100644 index 0000000..882d62f --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/PaymentCommands.java @@ -0,0 +1,12 @@ +package demo.payment; + +import org.springframework.hateoas.ResourceSupport; + +/** + * A hypermedia resource that describes the collection of commands that + * can be applied to a {@link Payment} aggregate. + * + * @author kbastani + */ +public class PaymentCommands extends ResourceSupport { +} diff --git a/payment/payment-web/src/main/java/demo/payment/PaymentController.java b/payment/payment-web/src/main/java/demo/payment/PaymentController.java new file mode 100644 index 0000000..e74df77 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/PaymentController.java @@ -0,0 +1,248 @@ +package demo.payment; + +import demo.event.PaymentEvent; +import demo.event.PaymentEvents; +import demo.event.EventController; +import demo.event.EventService; +import org.springframework.hateoas.LinkBuilder; +import org.springframework.hateoas.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo; + +@RestController +@RequestMapping("/v1") +public class PaymentController { + + private final PaymentService paymentService; + private final EventService eventService; + + public PaymentController(PaymentService paymentService, EventService eventService) { + this.paymentService = paymentService; + this.eventService = eventService; + } + + @PostMapping(path = "/payments") + public ResponseEntity createPayment(@RequestBody Payment payment) { + return Optional.ofNullable(createPaymentResource(payment)) + .map(e -> new ResponseEntity<>(e, HttpStatus.CREATED)) + .orElseThrow(() -> new RuntimeException("Payment creation failed")); + } + + @PutMapping(path = "/payments/{id}") + public ResponseEntity updatePayment(@RequestBody Payment payment, @PathVariable Long id) { + return Optional.ofNullable(updatePaymentResource(id, payment)) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElseThrow(() -> new RuntimeException("Payment update failed")); + } + + @GetMapping(path = "/payments/{id}") + public ResponseEntity getPayment(@PathVariable Long id) { + return Optional.ofNullable(getPaymentResource(id)) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND)); + } + + @DeleteMapping(path = "/payments/{id}") + public ResponseEntity deletePayment(@PathVariable Long id) { + return Optional.ofNullable(paymentService.deletePayment(id)) + .map(e -> new ResponseEntity<>(HttpStatus.NO_CONTENT)) + .orElseThrow(() -> new RuntimeException("Payment deletion failed")); + } + + @GetMapping(path = "/payments/{id}/events") + public ResponseEntity getPaymentEvents(@PathVariable Long id) { + return Optional.of(getPaymentEventResources(id)) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElseThrow(() -> new RuntimeException("Could not get payment events")); + } + + @PostMapping(path = "/payments/{id}/events") + public ResponseEntity createPayment(@PathVariable Long id, @RequestBody PaymentEvent event) { + return Optional.ofNullable(appendEventResource(id, event)) + .map(e -> new ResponseEntity<>(e, HttpStatus.CREATED)) + .orElseThrow(() -> new RuntimeException("Append payment event failed")); + } + + @GetMapping(path = "/payments/{id}/commands") + public ResponseEntity getPaymentCommands(@PathVariable Long id) { + return Optional.ofNullable(getCommandsResource(id)) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElseThrow(() -> new RuntimeException("The payment could not be found")); + } + + @GetMapping(path = "/payments/{id}/commands/connectOrder") + public ResponseEntity connectOrder(@PathVariable Long id) { + return Optional.ofNullable(getPaymentResource( + paymentService.applyCommand(id, PaymentCommand.CONNECT_ORDER))) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElseThrow(() -> new RuntimeException("The command could not be applied")); + } + + @GetMapping(path = "/payments/{id}/commands/processPayment") + public ResponseEntity processPayment(@PathVariable Long id) { + return Optional.ofNullable(getPaymentResource( + paymentService.applyCommand(id, PaymentCommand.PROCESS_PAYMENT))) + .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) + .orElseThrow(() -> new RuntimeException("The command could not be applied")); + } + + /** + * Retrieves a hypermedia resource for {@link Payment} with the specified identifier. + * + * @param id is the unique identifier for looking up the {@link Payment} entity + * @return a hypermedia resource for the fetched {@link Payment} + */ + private Resource getPaymentResource(Long id) { + Resource paymentResource = null; + + // Get the payment for the provided id + Payment payment = paymentService.getPayment(id); + + // If the payment exists, wrap the hypermedia response + if (payment != null) + paymentResource = getPaymentResource(payment); + + + return paymentResource; + } + + /** + * Creates a new {@link Payment} entity and persists the result to the repository. + * + * @param payment is the {@link Payment} model used to create a new payment + * @return a hypermedia resource for the newly created {@link Payment} + */ + private Resource createPaymentResource(Payment payment) { + Assert.notNull(payment, "Payment body must not be null"); + + // Create the new payment + payment = paymentService.registerPayment(payment); + + return getPaymentResource(payment); + } + + /** + * Update a {@link Payment} entity for the provided identifier. + * + * @param id is the unique identifier for the {@link Payment} update + * @param payment is the entity representation containing any updated {@link Payment} fields + * @return a hypermedia resource for the updated {@link Payment} + */ + private Resource updatePaymentResource(Long id, Payment payment) { + return getPaymentResource(paymentService.updatePayment(id, payment)); + } + + /** + * Appends an {@link PaymentEvent} domain event to the event log of the {@link Payment} + * aggregate with the specified paymentId. + * + * @param paymentId is the unique identifier for the {@link Payment} + * @param event is the {@link PaymentEvent} that attempts to alter the state of the {@link Payment} + * @return a hypermedia resource for the newly appended {@link PaymentEvent} + */ + private Resource appendEventResource(Long paymentId, PaymentEvent event) { + Resource eventResource = null; + + event = paymentService.appendEvent(paymentId, event); + + if (event != null) { + eventResource = new Resource<>(event, + linkTo(EventController.class) + .slash("events") + .slash(event.getEventId()) + .withSelfRel(), + linkTo(PaymentController.class) + .slash("payments") + .slash(paymentId) + .withRel("payment") + ); + } + + return eventResource; + } + + /** + * Get the {@link PaymentCommand} hypermedia resource that lists the available commands that can be applied + * to an {@link Payment} entity. + * + * @param id is the {@link Payment} identifier to provide command links for + * @return an {@link PaymentCommands} with a collection of embedded command links + */ + private PaymentCommands getCommandsResource(Long id) { + // Get the payment resource for the identifier + Resource paymentResource = getPaymentResource(id); + + // Create a new payment commands hypermedia resource + PaymentCommands commandResource = new PaymentCommands(); + + // Add payment command hypermedia links + if (paymentResource != null) { + commandResource.add( + getCommandLinkBuilder(id) + .slash("connectOrder") + .withRel("connectOrder"), + getCommandLinkBuilder(id) + .slash("processPayment") + .withRel("processPayment") + ); + } + + return commandResource; + } + + /** + * Get {@link PaymentEvents} for the supplied {@link Payment} identifier. + * + * @param id is the unique identifier of the {@link Payment} + * @return a list of {@link PaymentEvent} wrapped in a hypermedia {@link PaymentEvents} resource + */ + private PaymentEvents getPaymentEventResources(Long id) { + return new PaymentEvents(id, eventService.getPaymentEvents(id)); + } + + /** + * Generate a {@link LinkBuilder} for generating the {@link PaymentCommands}. + * + * @param id is the unique identifier for a {@link Payment} + * @return a {@link LinkBuilder} for the {@link PaymentCommands} + */ + private LinkBuilder getCommandLinkBuilder(Long id) { + return linkTo(PaymentController.class) + .slash("payments") + .slash(id) + .slash("commands"); + } + + /** + * Get a hypermedia enriched {@link Payment} entity. + * + * @param payment is the {@link Payment} to enrich with hypermedia links + * @return is a hypermedia enriched resource for the supplied {@link Payment} entity + */ + private Resource getPaymentResource(Payment payment) { + Resource paymentResource; + + // Prepare hypermedia response + paymentResource = new Resource<>(payment, + linkTo(PaymentController.class) + .slash("payments") + .slash(payment.getPaymentId()) + .withSelfRel(), + linkTo(PaymentController.class) + .slash("payments") + .slash(payment.getPaymentId()) + .slash("events") + .withRel("events"), + getCommandLinkBuilder(payment.getPaymentId()) + .withRel("commands") + ); + + return paymentResource; + } +} diff --git a/payment/payment-web/src/main/java/demo/payment/PaymentMethod.java b/payment/payment-web/src/main/java/demo/payment/PaymentMethod.java new file mode 100644 index 0000000..1591721 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/PaymentMethod.java @@ -0,0 +1,5 @@ +package demo.payment; + +public enum PaymentMethod { + CREDIT_CARD +} diff --git a/payment/payment-web/src/main/java/demo/payment/PaymentRepository.java b/payment/payment-web/src/main/java/demo/payment/PaymentRepository.java new file mode 100644 index 0000000..5975dc4 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/PaymentRepository.java @@ -0,0 +1,9 @@ +package demo.payment; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; + +public interface PaymentRepository extends JpaRepository { + + Payment findPaymentByOrderId(@Param("orderId") Long orderId); +} diff --git a/payment/payment-web/src/main/java/demo/payment/PaymentService.java b/payment/payment-web/src/main/java/demo/payment/PaymentService.java new file mode 100644 index 0000000..4b1a88b --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/PaymentService.java @@ -0,0 +1,174 @@ +package demo.payment; + +import demo.event.ConsistencyModel; +import demo.event.EventService; +import demo.event.PaymentEvent; +import demo.event.PaymentEventType; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +import java.util.Objects; + +/** + * The {@link PaymentService} provides transactional support for managing {@link Payment} + * entities. This service also provides event sourcing support for {@link PaymentEvent}. + * Events can be appended to an {@link Payment}, which contains a append-only log of + * actions that can be used to support remediation for distributed transactions that encountered + * a partial failure. + * + * @author kbastani + */ +@Service +@CacheConfig(cacheNames = {"payments"}) +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final EventService eventService; + private final CacheManager cacheManager; + + public PaymentService(PaymentRepository paymentRepository, EventService eventService, CacheManager cacheManager) { + this.paymentRepository = paymentRepository; + this.eventService = eventService; + this.cacheManager = cacheManager; + } + + @CacheEvict(cacheNames = "payments", key = "#payment.getPaymentId().toString()") + public Payment registerPayment(Payment payment) { + + payment = createPayment(payment); + + cacheManager.getCache("payments") + .evict(payment.getPaymentId()); + + // Trigger the payment creation event + PaymentEvent event = appendEvent(payment.getPaymentId(), + new PaymentEvent(PaymentEventType.PAYMENT_CREATED)); + + // Attach payment identifier + event.getPayment().setPaymentId(payment.getPaymentId()); + + // Return the result + return event.getPayment(); + } + + /** + * Create a new {@link Payment} entity. + * + * @param payment is the {@link Payment} to create + * @return the newly created {@link Payment} + */ + @CacheEvict(cacheNames = "payments", key = "#payment.getPaymentId().toString()") + public Payment createPayment(Payment payment) { + + // Save the payment to the repository + payment = paymentRepository.saveAndFlush(payment); + + return payment; + } + + /** + * Get an {@link Payment} entity for the supplied identifier. + * + * @param id is the unique identifier of a {@link Payment} entity + * @return an {@link Payment} entity + */ + @Cacheable(cacheNames = "payments", key = "#id.toString()") + public Payment getPayment(Long id) { + return paymentRepository.findOne(id); + } + + /** + * Update an {@link Payment} entity with the supplied identifier. + * + * @param id is the unique identifier of the {@link Payment} entity + * @param payment is the {@link Payment} containing updated fields + * @return the updated {@link Payment} entity + */ + @CachePut(cacheNames = "payments", key = "#id.toString()") + public Payment updatePayment(Long id, Payment payment) { + Assert.notNull(id, "Payment id must be present in the resource URL"); + Assert.notNull(payment, "Payment request body cannot be null"); + + if (payment.getPaymentId() != null) { + Assert.isTrue(Objects.equals(id, payment.getPaymentId()), + "The payment id in the request body must match the resource URL"); + } else { + payment.setPaymentId(id); + } + + Assert.state(paymentRepository.exists(id), + "The payment with the supplied id does not exist"); + + Payment currentPayment = paymentRepository.findOne(id); + currentPayment.setStatus(payment.getStatus()); + + return paymentRepository.save(currentPayment); + } + + /** + * Delete the {@link Payment} with the supplied identifier. + * + * @param id is the unique identifier for the {@link Payment} + */ + @CacheEvict(cacheNames = "payments", key = "#id.toString()") + public Boolean deletePayment(Long id) { + Assert.state(paymentRepository.exists(id), + "The payment with the supplied id does not exist"); + this.paymentRepository.delete(id); + return true; + } + + /** + * Append a new {@link PaymentEvent} to the {@link Payment} reference for the supplied identifier. + * + * @param paymentId is the unique identifier for the {@link Payment} + * @param event is the {@link PaymentEvent} to append to the {@link Payment} entity + * @return the newly appended {@link PaymentEvent} + */ + public PaymentEvent appendEvent(Long paymentId, PaymentEvent event) { + return appendEvent(paymentId, event, ConsistencyModel.ACID); + } + + /** + * Append a new {@link PaymentEvent} to the {@link Payment} reference for the supplied identifier. + * + * @param paymentId is the unique identifier for the {@link Payment} + * @param event is the {@link PaymentEvent} to append to the {@link Payment} entity + * @return the newly appended {@link PaymentEvent} + */ + public PaymentEvent appendEvent(Long paymentId, PaymentEvent event, ConsistencyModel consistencyModel) { + Payment payment = getPayment(paymentId); + Assert.notNull(payment, "The payment with the supplied id does not exist"); + event.setPayment(payment); + event = eventService.createEvent(paymentId, event); + payment.getEvents().add(event); + paymentRepository.saveAndFlush(payment); + eventService.raiseEvent(event, consistencyModel); + return event; + } + + /** + * Apply an {@link PaymentCommand} to the {@link Payment} with a specified identifier. + * + * @param id is the unique identifier of the {@link Payment} + * @param paymentCommand is the command to apply to the {@link Payment} + * @return a hypermedia resource containing the updated {@link Payment} + */ + @CachePut(cacheNames = "payments", key = "#id.toString()") + public Payment applyCommand(Long id, PaymentCommand paymentCommand) { + Payment payment = getPayment(id); + + Assert.notNull(payment, "The payment for the supplied id could not be found"); + + PaymentStatus status = payment.getStatus(); + + // TODO: Implement + + return payment; + } +} diff --git a/payment/payment-web/src/main/java/demo/payment/PaymentStatus.java b/payment/payment-web/src/main/java/demo/payment/PaymentStatus.java new file mode 100644 index 0000000..eca6bbd --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/PaymentStatus.java @@ -0,0 +1,16 @@ +package demo.payment; + +/** + * The {@link PaymentStatus} describes the state of an {@link Payment}. + * The aggregate state of a {@link Payment} is sourced from attached domain + * events in the form of {@link demo.event.PaymentEvent}. + * + * @author kbastani + */ +public enum PaymentStatus { + PAYMENT_CREATED, + PAYMENT_PENDING, + PAYMENT_PROCESSED, + PAYMENT_FAILED, + PAYMENT_SUCCEEDED +} diff --git a/payment/payment-web/src/main/resources/application.yml b/payment/payment-web/src/main/resources/application.yml new file mode 100644 index 0000000..42ae822 --- /dev/null +++ b/payment/payment-web/src/main/resources/application.yml @@ -0,0 +1,17 @@ +spring: + profiles: + active: development +--- +spring: + profiles: development + cloud: + stream: + bindings: + output: + destination: payment + contentType: 'application/json' + redis: + host: localhost + port: 6379 +server: + port: 8080 \ No newline at end of file diff --git a/payment/payment-web/src/main/resources/bootstrap.yml b/payment/payment-web/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..e2c706f --- /dev/null +++ b/payment/payment-web/src/main/resources/bootstrap.yml @@ -0,0 +1,4 @@ +spring: + application: + name: payment-web +--- \ No newline at end of file diff --git a/payment/payment-web/src/test/java/demo/payment/PaymentControllerTest.java b/payment/payment-web/src/test/java/demo/payment/PaymentControllerTest.java new file mode 100644 index 0000000..82d4668 --- /dev/null +++ b/payment/payment-web/src/test/java/demo/payment/PaymentControllerTest.java @@ -0,0 +1,43 @@ +package demo.payment; + +import demo.event.EventService; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(SpringRunner.class) +@WebMvcTest(PaymentController.class) +public class PaymentControllerTest { + + @Autowired + private MockMvc mvc; + + @MockBean + private PaymentService paymentService; + + @MockBean + private EventService eventService; + + @Test + public void getUserPaymentResourceShouldReturnPayment() throws Exception { + String content = "{\"paymentMethod\": \"CREDIT_CARD\", \"amount\": 42.0 }"; + + Payment payment = new Payment(42.0, PaymentMethod.CREDIT_CARD); + + given(this.paymentService.getPayment(1L)) + .willReturn(payment); + + this.mvc.perform(get("/v1/payments/1").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().json(content)); + } +} diff --git a/payment/payment-web/src/test/java/demo/payment/PaymentServiceTests.java b/payment/payment-web/src/test/java/demo/payment/PaymentServiceTests.java new file mode 100644 index 0000000..e14a6e3 --- /dev/null +++ b/payment/payment-web/src/test/java/demo/payment/PaymentServiceTests.java @@ -0,0 +1,45 @@ +package demo.payment; + +import demo.event.EventService; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.cache.CacheManager; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@RunWith(SpringRunner.class) +public class PaymentServiceTests { + + @MockBean + private EventService eventService; + + @MockBean + private PaymentRepository paymentRepository; + + @MockBean + private CacheManager cacheManager; + + private PaymentService paymentService; + + @Before + public void before() { + paymentService = new PaymentService(paymentRepository, eventService, cacheManager); + } + + @Test + public void getPaymentReturnsPayment() throws Exception { + Payment expected = new Payment(42.0, PaymentMethod.CREDIT_CARD); + + given(this.paymentRepository.findOne(1L)).willReturn(expected); + + Payment actual = paymentService.getPayment(1L); + + assertThat(actual).isNotNull(); + assertThat(actual.getPaymentMethod()).isEqualTo(PaymentMethod.CREDIT_CARD); + assertThat(actual.getAmount()).isEqualTo(42.0); + } +} \ No newline at end of file diff --git a/payment/payment-web/src/test/resources/data-h2.sql b/payment/payment-web/src/test/resources/data-h2.sql new file mode 100644 index 0000000..aae6a4c --- /dev/null +++ b/payment/payment-web/src/test/resources/data-h2.sql @@ -0,0 +1 @@ +INSERT INTO PAYMENT(ID, FIRST_NAME, LAST_NAME, EMAIL) values (1, 'John', 'Doe', 'john.doe@example.com'); diff --git a/payment/payment-worker/pom.xml b/payment/payment-worker/pom.xml new file mode 100644 index 0000000..01b16a2 --- /dev/null +++ b/payment/payment-worker/pom.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + payment-worker + 0.0.1-SNAPSHOT + jar + + payment-worker + + + org.kbastani + payment + 1.0-SNAPSHOT + ../ + + + + UTF-8 + UTF-8 + 1.8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-hateoas + + + org.springframework.cloud + spring-cloud-starter-stream-rabbit + + + org.springframework.boot + spring-boot-starter-integration + + + org.springframework.statemachine + spring-statemachine-core + ${spring-statemachine-core.version} + + + org.kbastani + spring-boot-starter-aws-lambda + ${spring-boot-starter-aws-lambda.version} + + + com.amazonaws + aws-java-sdk-sts + ${aws-java-sdk-sts.version} + + + com.amazonaws + aws-java-sdk-lambda + + + + com.jayway.jsonpath + json-path + ${json-path.version} + + + + + + + com.amazonaws + aws-java-sdk-bom + ${aws-java-sdk-sts.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + diff --git a/payment/payment-worker/src/main/java/demo/PaymentStreamModuleApplication.java b/payment/payment-worker/src/main/java/demo/PaymentStreamModuleApplication.java new file mode 100644 index 0000000..aa5db35 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/PaymentStreamModuleApplication.java @@ -0,0 +1,16 @@ +package demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; + +@SpringBootApplication +@EnableHypermediaSupport(type = {HypermediaType.HAL}) +public class PaymentStreamModuleApplication { + public static void main(String[] args) { + SpringApplication.run(PaymentStreamModuleApplication.class, args); + } +} + + diff --git a/payment/payment-worker/src/main/java/demo/config/AwsLambdaConfig.java b/payment/payment-worker/src/main/java/demo/config/AwsLambdaConfig.java new file mode 100644 index 0000000..47022a5 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/config/AwsLambdaConfig.java @@ -0,0 +1,25 @@ +package demo.config; + +import amazon.aws.AWSLambdaConfigurerAdapter; +import com.fasterxml.jackson.databind.ObjectMapper; +import demo.function.LambdaFunctions; +import demo.util.LambdaUtil; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("cloud") +public class AwsLambdaConfig { + + @Bean + public LambdaFunctions lambdaInvoker(AWSLambdaConfigurerAdapter configurerAdapter) { + return configurerAdapter + .getFunctionInstance(LambdaFunctions.class); + } + + @Bean + public LambdaUtil lambdaUtil(ObjectMapper objectMapper) { + return new LambdaUtil(objectMapper); + } +} diff --git a/payment/payment-worker/src/main/java/demo/config/StateMachineConfig.java b/payment/payment-worker/src/main/java/demo/config/StateMachineConfig.java new file mode 100644 index 0000000..ffab680 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/config/StateMachineConfig.java @@ -0,0 +1,220 @@ +package demo.config; + +import demo.payment.Payment; +import demo.payment.PaymentStatus; +import demo.event.PaymentEvent; +import demo.event.PaymentEventType; +import demo.function.*; +import org.apache.log4j.Logger; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.client.Traverson; +import org.springframework.statemachine.StateContext; +import org.springframework.statemachine.StateMachine; +import org.springframework.statemachine.action.Action; +import org.springframework.statemachine.config.EnableStateMachineFactory; +import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter; +import org.springframework.statemachine.config.builders.StateMachineStateConfigurer; +import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer; + +import java.net.URI; +import java.util.EnumSet; + +/** + * A configuration adapter for describing a {@link StateMachine} factory that maps actions to functional + * expressions. Actions are executed during transitions between a source state and a target state. + *

+ * A state machine provides a robust declarative language for describing the state of an {@link Payment} + * resource given a sequence of ordered {@link demo.event.PaymentEvents}. When an event is received + * in {@link demo.event.PaymentEventStream}, an in-memory state machine is fully replicated given the + * {@link demo.event.PaymentEvents} attached to an {@link Payment} resource. + * + * @author kbastani + */ +@Configuration +@EnableStateMachineFactory +public class StateMachineConfig extends EnumStateMachineConfigurerAdapter { + + final private Logger log = Logger.getLogger(StateMachineConfig.class); + + /** + * Configures the initial conditions of a new in-memory {@link StateMachine} for {@link Payment}. + * + * @param states is the {@link StateMachineStateConfigurer} used to describe the initial condition + */ + @Override + public void configure(StateMachineStateConfigurer states) { + try { + // Describe the initial condition of the payment state machine + states.withStates() + .initial(PaymentStatus.PAYMENT_CREATED) + .states(EnumSet.allOf(PaymentStatus.class)); + } catch (Exception e) { + throw new RuntimeException("State machine configuration failed", e); + } + } + + /** + * Configures the {@link StateMachine} that describes how {@link PaymentEventType} drives the state + * of an {@link Payment}. Events are applied as transitions from a source {@link PaymentStatus} to + * a target {@link PaymentStatus}. An {@link Action} is attached to each transition, which maps to a + * function that is executed in the context of an {@link PaymentEvent}. + * + * @param transitions is the {@link StateMachineTransitionConfigurer} used to describe state transitions + */ + @Override + public void configure(StateMachineTransitionConfigurer transitions) { + try { + // Describe state machine transitions for payments + transitions.withExternal() + .source(PaymentStatus.PAYMENT_CREATED) + .target(PaymentStatus.PAYMENT_CREATED) + .event(PaymentEventType.PAYMENT_CREATED) + .action(paymentCreated()) + .and() + .withExternal() + .source(PaymentStatus.PAYMENT_CREATED) + .target(PaymentStatus.PAYMENT_PENDING) + .event(PaymentEventType.PAYMENT_PENDING) + .action(paymentPending()) + .and() + .withExternal() + .source(PaymentStatus.PAYMENT_PENDING) + .target(PaymentStatus.PAYMENT_PROCESSED) + .event(PaymentEventType.PAYMENT_PROCESSED) + .action(paymentProcessed()) + .and() + .withExternal() + .source(PaymentStatus.PAYMENT_PROCESSED) + .target(PaymentStatus.PAYMENT_SUCCEEDED) + .event(PaymentEventType.PAYMENT_SUCCEEDED) + .action(paymentSucceeded()) + .and() + .withExternal() + .source(PaymentStatus.PAYMENT_PROCESSED) + .target(PaymentStatus.PAYMENT_FAILED) + .event(PaymentEventType.PAYMENT_FAILED) + .action(paymentFailed()); + } catch (Exception e) { + throw new RuntimeException("Could not configure state machine transitions", e); + } + } + + /** + * Functions are mapped to actions that are triggered during the replication of a state machine. Functions + * should only be executed after the state machine has completed replication. This method checks the state + * context of the machine for an {@link PaymentEvent}, which signals that the state machine is finished + * replication. + *

+ * The {@link PaymentFunction} argument is only applied if an {@link PaymentEvent} is provided as a + * message header in the {@link StateContext}. + * + * @param context is the state machine context that may include an {@link PaymentEvent} + * @param paymentFunction is the payment function to apply after the state machine has completed replication + * @return an {@link PaymentEvent} only if this event has not yet been processed, otherwise returns null + */ + private PaymentEvent applyEvent(StateContext context, PaymentFunction paymentFunction) { + PaymentEvent paymentEvent = null; + + // Log out the progress of the state machine replication + log.info("Replicate event: " + context.getMessage().getPayload()); + + // The machine is finished replicating when an PaymentEvent is found in the message header + if (context.getMessageHeader("event") != null) { + paymentEvent = (PaymentEvent) context.getMessageHeader("event"); + log.info("State machine replicated: " + paymentEvent.getType()); + + // Apply the provided function to the PaymentEvent + paymentFunction.apply(paymentEvent); + } + + return paymentEvent; + } + + @Bean + public Action paymentCreated() { + return context -> applyEvent(context, + new PaymentCreated(context, event -> { + log.info(event.getType() + ": " + event.getLink("payment").getHref()); + // Get the payment resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("payment").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Payment.class) + .getBody(); + })); + } + + @Bean + public Action paymentPending() { + return context -> applyEvent(context, + new PaymentPending(context, event -> { + log.info(event.getType() + ": " + event.getLink("payment").getHref()); + // Get the payment resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("payment").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Payment.class) + .getBody(); + })); + } + + @Bean + public Action paymentProcessed() { + return context -> applyEvent(context, + new PaymentProcessed(context, event -> { + log.info(event.getType() + ": " + event.getLink("payment").getHref()); + // Get the payment resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("payment").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Payment.class) + .getBody(); + })); + } + + @Bean + public Action paymentSucceeded() { + return context -> applyEvent(context, + new PaymentSucceeded(context, event -> { + log.info(event.getType() + ": " + event.getLink("payment").getHref()); + // Get the payment resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("payment").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Payment.class) + .getBody(); + })); + } + + @Bean + public Action paymentFailed() { + return context -> applyEvent(context, + new PaymentFailed(context, event -> { + log.info(event.getType() + ": " + event.getLink("payment").getHref()); + // Get the payment resource for the event + Traverson traverson = new Traverson( + URI.create(event.getLink("payment").getHref()), + MediaTypes.HAL_JSON + ); + + return traverson.follow("self") + .toEntity(Payment.class) + .getBody(); + })); + } +} + diff --git a/payment/payment-worker/src/main/java/demo/domain/BaseEntity.java b/payment/payment-worker/src/main/java/demo/domain/BaseEntity.java new file mode 100644 index 0000000..586dbb4 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/domain/BaseEntity.java @@ -0,0 +1,36 @@ +package demo.domain; + +import org.springframework.hateoas.ResourceSupport; + +public class BaseEntity extends ResourceSupport { + + private Long createdAt; + private Long lastModified; + + public BaseEntity() { + } + + public Long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Long createdAt) { + this.createdAt = createdAt; + } + + public Long getLastModified() { + return lastModified; + } + + public void setLastModified(Long lastModified) { + this.lastModified = lastModified; + } + + @Override + public String toString() { + return "BaseEntity{" + + "createdAt=" + createdAt + + ", lastModified=" + lastModified + + "} " + super.toString(); + } +} diff --git a/payment/payment-worker/src/main/java/demo/event/EventController.java b/payment/payment-worker/src/main/java/demo/event/EventController.java new file mode 100644 index 0000000..115bede --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/event/EventController.java @@ -0,0 +1,28 @@ +package demo.event; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Optional; + +@RestController +@RequestMapping("/v1") +public class EventController { + + private EventService eventService; + + public EventController(EventService eventService) { + this.eventService = eventService; + } + + @PostMapping(path = "/events") + public ResponseEntity handleEvent(@RequestBody PaymentEvent event) { + return Optional.ofNullable(eventService.apply(event)) + .map(e -> new ResponseEntity<>(e, HttpStatus.CREATED)) + .orElseThrow(() -> new RuntimeException("Apply event failed")); + } +} diff --git a/payment/payment-worker/src/main/java/demo/event/EventService.java b/payment/payment-worker/src/main/java/demo/event/EventService.java new file mode 100644 index 0000000..98a6286 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/event/EventService.java @@ -0,0 +1,82 @@ +package demo.event; + +import demo.payment.Payment; +import demo.payment.PaymentStatus; +import demo.state.StateMachineService; +import org.apache.log4j.Logger; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.client.Traverson; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.statemachine.StateMachine; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +@Service +public class EventService { + + final private Logger log = Logger.getLogger(EventService.class); + final private StateMachineService stateMachineService; + + public EventService(StateMachineService stateMachineService) { + this.stateMachineService = stateMachineService; + } + + public Payment apply(PaymentEvent paymentEvent) { + + Payment result; + + log.info("Payment event received: " + paymentEvent.getLink("self").getHref()); + + // Generate a state machine for computing the state of the payment resource + StateMachine stateMachine = + stateMachineService.getStateMachine(); + + // Follow the hypermedia link to fetch the attached payment + Traverson traverson = new Traverson( + URI.create(paymentEvent.getLink("payment").getHref()), + MediaTypes.HAL_JSON + ); + + // Get the event log for the attached payment resource + PaymentEvents events = traverson.follow("events") + .toEntity(PaymentEvents.class) + .getBody(); + + // Prepare payment event message headers + Map headerMap = new HashMap<>(); + headerMap.put("event", paymentEvent); + + // Replicate the current state of the payment resource + events.getContent() + .stream() + .sorted((a1, a2) -> a1.getCreatedAt().compareTo(a2.getCreatedAt())) + .forEach(e -> { + MessageHeaders headers = new MessageHeaders(null); + + // Check to see if this is the current event + if (e.getLink("self").equals(paymentEvent.getLink("self"))) { + headers = new MessageHeaders(headerMap); + } + + // Send the event to the state machine + stateMachine.sendEvent(MessageBuilder.createMessage(e.getType(), headers)); + }); + + + // Get result + Map context = stateMachine.getExtendedState() + .getVariables(); + + // Get the payment result + result = (Payment) context.getOrDefault("payment", null); + + // Destroy the state machine + stateMachine.stop(); + + return result; + } +} diff --git a/payment/payment-worker/src/main/java/demo/event/PaymentEvent.java b/payment/payment-worker/src/main/java/demo/event/PaymentEvent.java new file mode 100644 index 0000000..a39393f --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/event/PaymentEvent.java @@ -0,0 +1,41 @@ +package demo.event; + +import demo.payment.Payment; +import demo.domain.BaseEntity; + +/** + * The domain event {@link PaymentEvent} tracks the type and state of events as + * applied to the {@link Payment} domain object. This event resource can be used + * to event source the aggregate state of {@link Payment}. + *

+ * This event resource also provides a transaction log that can be used to append + * actions to the event. + * + * @author kbastani + */ +public class PaymentEvent extends BaseEntity { + + private PaymentEventType type; + + public PaymentEvent() { + } + + public PaymentEvent(PaymentEventType type) { + this.type = type; + } + + public PaymentEventType getType() { + return type; + } + + public void setType(PaymentEventType type) { + this.type = type; + } + + @Override + public String toString() { + return "PaymentEvent{" + + "type=" + type + + "} " + super.toString(); + } +} diff --git a/payment/payment-worker/src/main/java/demo/event/PaymentEventStream.java b/payment/payment-worker/src/main/java/demo/event/PaymentEventStream.java new file mode 100644 index 0000000..78173cd --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/event/PaymentEventStream.java @@ -0,0 +1,40 @@ +package demo.event; + +import demo.payment.Payment; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.annotation.Profile; +import org.springframework.statemachine.StateMachine; + +/** + * The {@link PaymentEventStream} monitors for a variety of {@link PaymentEvent} domain + * events for an {@link Payment}. + * + * @author kbastani + */ +@EnableAutoConfiguration +@EnableBinding(Sink.class) +@Profile({ "cloud", "development" }) +public class PaymentEventStream { + + private EventService eventService; + + public PaymentEventStream(EventService eventService) { + this.eventService = eventService; + } + + /** + * Listens to a stream of incoming {@link PaymentEvent} messages. For each + * new message received, replicate an in-memory {@link StateMachine} that + * reproduces the current state of the {@link Payment} resource that is the + * subject of the {@link PaymentEvent}. + * + * @param paymentEvent is the {@link Payment} domain event to process + */ + @StreamListener(Sink.INPUT) + public void streamListerner(PaymentEvent paymentEvent) { + eventService.apply(paymentEvent); + } +} diff --git a/payment/payment-worker/src/main/java/demo/event/PaymentEventType.java b/payment/payment-worker/src/main/java/demo/event/PaymentEventType.java new file mode 100644 index 0000000..483ea22 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/event/PaymentEventType.java @@ -0,0 +1,18 @@ +package demo.event; + +import demo.payment.Payment; +import demo.payment.PaymentStatus; + +/** + * The {@link PaymentEventType} represents a collection of possible events that describe + * state transitions of {@link PaymentStatus} on the {@link Payment} aggregate. + * + * @author kbastani + */ +public enum PaymentEventType { + PAYMENT_CREATED, + PAYMENT_PENDING, + PAYMENT_PROCESSED, + PAYMENT_FAILED, + PAYMENT_SUCCEEDED +} diff --git a/payment/payment-worker/src/main/java/demo/event/PaymentEvents.java b/payment/payment-worker/src/main/java/demo/event/PaymentEvents.java new file mode 100644 index 0000000..438d110 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/event/PaymentEvents.java @@ -0,0 +1,11 @@ +package demo.event; + +import org.springframework.hateoas.Resources; + +/** + * The {@link PaymentEvents} is a hypermedia collection of {@link PaymentEvent} resources. + * + * @author kbastani + */ +public class PaymentEvents extends Resources { +} diff --git a/payment/payment-worker/src/main/java/demo/function/LambdaFunctions.java b/payment/payment-worker/src/main/java/demo/function/LambdaFunctions.java new file mode 100644 index 0000000..831d010 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/function/LambdaFunctions.java @@ -0,0 +1,5 @@ +package demo.function; + +public interface LambdaFunctions { + +} diff --git a/payment/payment-worker/src/main/java/demo/function/PaymentCreated.java b/payment/payment-worker/src/main/java/demo/function/PaymentCreated.java new file mode 100644 index 0000000..8dfefe5 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/function/PaymentCreated.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.PaymentEvent; +import demo.event.PaymentEventType; +import demo.payment.Payment; +import demo.payment.PaymentStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class PaymentCreated extends PaymentFunction { + + final private Logger log = Logger.getLogger(PaymentCreated.class); + + public PaymentCreated(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link PaymentEvent} to the lambda function that was provided through the + * constructor of this {@link PaymentFunction}. + * + * @param event is the {@link PaymentEvent} to apply to the lambda function + */ + @Override + public Payment apply(PaymentEvent event) { + log.info("Executing workflow for payment created..."); + return super.apply(event); + } +} diff --git a/payment/payment-worker/src/main/java/demo/function/PaymentFailed.java b/payment/payment-worker/src/main/java/demo/function/PaymentFailed.java new file mode 100644 index 0000000..1ec5338 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/function/PaymentFailed.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.PaymentEvent; +import demo.event.PaymentEventType; +import demo.payment.Payment; +import demo.payment.PaymentStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class PaymentFailed extends PaymentFunction { + + final private Logger log = Logger.getLogger(PaymentFailed.class); + + public PaymentFailed(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link PaymentEvent} to the lambda function that was provided through the + * constructor of this {@link PaymentFunction}. + * + * @param event is the {@link PaymentEvent} to apply to the lambda function + */ + @Override + public Payment apply(PaymentEvent event) { + log.info("Executing workflow for payment failed..."); + return super.apply(event); + } +} diff --git a/payment/payment-worker/src/main/java/demo/function/PaymentFunction.java b/payment/payment-worker/src/main/java/demo/function/PaymentFunction.java new file mode 100644 index 0000000..fec45f8 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/function/PaymentFunction.java @@ -0,0 +1,51 @@ +package demo.function; + +import demo.payment.Payment; +import demo.payment.PaymentStatus; +import demo.event.PaymentEvent; +import demo.event.PaymentEventType; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +/** + * The {@link PaymentFunction} is an abstraction used to map actions that are triggered by + * state transitions on a {@link demo.payment.Payment} resource on to a function. Mapped functions + * can take multiple forms and reside either remotely or locally on the classpath of this application. + * + * @author kbastani + */ +public abstract class PaymentFunction { + + final private Logger log = Logger.getLogger(PaymentFunction.class); + final protected StateContext context; + final protected Function lambda; + + /** + * Create a new instance of a class that extends {@link PaymentFunction}, supplying + * a state context and a lambda function used to apply {@link PaymentEvent} to a provided + * action. + * + * @param context is the {@link StateContext} for a replicated state machine + * @param lambda is the lambda function describing an action that consumes an {@link PaymentEvent} + */ + public PaymentFunction(StateContext context, + Function lambda) { + this.context = context; + this.lambda = lambda; + } + + /** + * Apply an {@link PaymentEvent} to the lambda function that was provided through the + * constructor of this {@link PaymentFunction}. + * + * @param event is the {@link PaymentEvent} to apply to the lambda function + */ + public Payment apply(PaymentEvent event) { + // Execute the lambda function + Payment result = lambda.apply(event); + context.getExtendedState().getVariables().put("payment", result); + return result; + } +} diff --git a/payment/payment-worker/src/main/java/demo/function/PaymentPending.java b/payment/payment-worker/src/main/java/demo/function/PaymentPending.java new file mode 100644 index 0000000..9319eac --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/function/PaymentPending.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.PaymentEvent; +import demo.event.PaymentEventType; +import demo.payment.Payment; +import demo.payment.PaymentStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class PaymentPending extends PaymentFunction { + + final private Logger log = Logger.getLogger(PaymentPending.class); + + public PaymentPending(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link PaymentEvent} to the lambda function that was provided through the + * constructor of this {@link PaymentFunction}. + * + * @param event is the {@link PaymentEvent} to apply to the lambda function + */ + @Override + public Payment apply(PaymentEvent event) { + log.info("Executing workflow for payment pending..."); + return super.apply(event); + } +} diff --git a/payment/payment-worker/src/main/java/demo/function/PaymentProcessed.java b/payment/payment-worker/src/main/java/demo/function/PaymentProcessed.java new file mode 100644 index 0000000..d67917e --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/function/PaymentProcessed.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.PaymentEvent; +import demo.event.PaymentEventType; +import demo.payment.Payment; +import demo.payment.PaymentStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class PaymentProcessed extends PaymentFunction { + + final private Logger log = Logger.getLogger(PaymentProcessed.class); + + public PaymentProcessed(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link PaymentEvent} to the lambda function that was provided through the + * constructor of this {@link PaymentFunction}. + * + * @param event is the {@link PaymentEvent} to apply to the lambda function + */ + @Override + public Payment apply(PaymentEvent event) { + log.info("Executing workflow for payment processed..."); + return super.apply(event); + } +} diff --git a/payment/payment-worker/src/main/java/demo/function/PaymentSucceeded.java b/payment/payment-worker/src/main/java/demo/function/PaymentSucceeded.java new file mode 100644 index 0000000..2c5acf2 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/function/PaymentSucceeded.java @@ -0,0 +1,31 @@ +package demo.function; + +import demo.event.PaymentEvent; +import demo.event.PaymentEventType; +import demo.payment.Payment; +import demo.payment.PaymentStatus; +import org.apache.log4j.Logger; +import org.springframework.statemachine.StateContext; + +import java.util.function.Function; + +public class PaymentSucceeded extends PaymentFunction { + + final private Logger log = Logger.getLogger(PaymentSucceeded.class); + + public PaymentSucceeded(StateContext context, Function lambda) { + super(context, lambda); + } + + /** + * Apply an {@link PaymentEvent} to the lambda function that was provided through the + * constructor of this {@link PaymentFunction}. + * + * @param event is the {@link PaymentEvent} to apply to the lambda function + */ + @Override + public Payment apply(PaymentEvent event) { + log.info("Executing workflow for payment succeeded..."); + return super.apply(event); + } +} diff --git a/payment/payment-worker/src/main/java/demo/payment/Payment.java b/payment/payment-worker/src/main/java/demo/payment/Payment.java new file mode 100644 index 0000000..c5f10c2 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/payment/Payment.java @@ -0,0 +1,46 @@ +package demo.payment; + +import demo.domain.BaseEntity; + +public class Payment extends BaseEntity { + + private Double amount; + private PaymentMethod paymentMethod; + private PaymentStatus status; + + public Payment() { + } + + public PaymentStatus getStatus() { + return status; + } + + public void setStatus(PaymentStatus status) { + this.status = status; + } + + public Double getAmount() { + return amount; + } + + public void setAmount(Double amount) { + this.amount = amount; + } + + public PaymentMethod getPaymentMethod() { + return paymentMethod; + } + + public void setPaymentMethod(PaymentMethod paymentMethod) { + this.paymentMethod = paymentMethod; + } + + @Override + public String toString() { + return "Payment{" + + "amount=" + amount + + ", paymentMethod=" + paymentMethod + + ", status=" + status + + "} " + super.toString(); + } +} diff --git a/payment/payment-worker/src/main/java/demo/payment/PaymentMethod.java b/payment/payment-worker/src/main/java/demo/payment/PaymentMethod.java new file mode 100644 index 0000000..1591721 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/payment/PaymentMethod.java @@ -0,0 +1,5 @@ +package demo.payment; + +public enum PaymentMethod { + CREDIT_CARD +} diff --git a/payment/payment-worker/src/main/java/demo/payment/PaymentStatus.java b/payment/payment-worker/src/main/java/demo/payment/PaymentStatus.java new file mode 100644 index 0000000..eca6bbd --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/payment/PaymentStatus.java @@ -0,0 +1,16 @@ +package demo.payment; + +/** + * The {@link PaymentStatus} describes the state of an {@link Payment}. + * The aggregate state of a {@link Payment} is sourced from attached domain + * events in the form of {@link demo.event.PaymentEvent}. + * + * @author kbastani + */ +public enum PaymentStatus { + PAYMENT_CREATED, + PAYMENT_PENDING, + PAYMENT_PROCESSED, + PAYMENT_FAILED, + PAYMENT_SUCCEEDED +} diff --git a/payment/payment-worker/src/main/java/demo/state/StateMachineService.java b/payment/payment-worker/src/main/java/demo/state/StateMachineService.java new file mode 100644 index 0000000..f7bb530 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/state/StateMachineService.java @@ -0,0 +1,42 @@ +package demo.state; + +import demo.payment.PaymentStatus; +import demo.event.PaymentEventType; +import org.springframework.statemachine.StateMachine; +import org.springframework.statemachine.config.StateMachineFactory; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +/** + * The {@link StateMachineService} provides factory access to get new state machines for + * replicating the state of an {@link demo.payment.Payment} from {@link demo.event.PaymentEvents}. + * + * @author kbastani + */ +@Service +public class StateMachineService { + + private final StateMachineFactory factory; + + public StateMachineService(StateMachineFactory factory) { + this.factory = factory; + } + + /** + * Create a new state machine that is initially configured and ready for replicating + * the state of an {@link demo.payment.Payment} from a sequence of {@link demo.event.PaymentEvent}. + * + * @return a new instance of {@link StateMachine} + */ + public StateMachine getStateMachine() { + // Create a new state machine in its initial state + StateMachine stateMachine = + factory.getStateMachine(UUID.randomUUID().toString()); + + // Start the new state machine + stateMachine.start(); + + return stateMachine; + } +} diff --git a/payment/payment-worker/src/main/java/demo/util/LambdaUtil.java b/payment/payment-worker/src/main/java/demo/util/LambdaUtil.java new file mode 100644 index 0000000..bd282f4 --- /dev/null +++ b/payment/payment-worker/src/main/java/demo/util/LambdaUtil.java @@ -0,0 +1,29 @@ +package demo.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; + +@Component +public class LambdaUtil { + + private ObjectMapper objectMapper; + + public LambdaUtil(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public HashMap objectToMap(Object object) { + HashMap result = null; + + try { + result = objectMapper.readValue(objectMapper.writeValueAsString(object), HashMap.class); + } catch (IOException e) { + e.printStackTrace(); + } + + return result; + } +} diff --git a/payment/payment-worker/src/main/resources/application.yml b/payment/payment-worker/src/main/resources/application.yml new file mode 100644 index 0000000..f3fe334 --- /dev/null +++ b/payment/payment-worker/src/main/resources/application.yml @@ -0,0 +1,24 @@ +spring: + profiles: + active: development +--- +spring: + profiles: development + cloud: + stream: + bindings: + input: + destination: payment + group: payment-group + contentType: 'application/json' + consumer: + durableSubscription: true +server: + port: 8081 +amazon: + aws: + access-key-id: replace + access-key-secret: replace +--- +spring: + profiles: test \ No newline at end of file diff --git a/payment/payment-worker/src/main/resources/bootstrap.yml b/payment/payment-worker/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..9191e9c --- /dev/null +++ b/payment/payment-worker/src/main/resources/bootstrap.yml @@ -0,0 +1,4 @@ +spring: + application: + name: payment-worker +--- \ No newline at end of file diff --git a/payment/payment-worker/src/test/java/demo/PaymentStreamModuleApplicationTests.java b/payment/payment-worker/src/test/java/demo/PaymentStreamModuleApplicationTests.java new file mode 100644 index 0000000..57f4f92 --- /dev/null +++ b/payment/payment-worker/src/test/java/demo/PaymentStreamModuleApplicationTests.java @@ -0,0 +1,18 @@ +package demo; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@ActiveProfiles("test") +public class PaymentStreamModuleApplicationTests { + + @Test + public void contextLoads() { + } + +} diff --git a/payment/pom.xml b/payment/pom.xml new file mode 100644 index 0000000..09c5986 --- /dev/null +++ b/payment/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + payment + 1.0-SNAPSHOT + pom + + payment + + + org.kbastani + event-stream-processing-microservices + 1.0-SNAPSHOT + ../ + + + + payment-web + payment-worker + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 5f1af4f..4ac9d4c 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ spring-boot-starters account order + payment