From 55cf52132a8562c1eea4adb02d01db901b99a036 Mon Sep 17 00:00:00 2001 From: Kenny Bastani Date: Wed, 28 Dec 2016 14:00:55 -0500 Subject: [PATCH] Pattern language for mapping commands to domain model --- payment/payment-web/pom.xml | 4 + .../{BaseEntity.java => AbstractEntity.java} | 16 +- .../src/main/java/demo/payment/Payment.java | 48 +++-- .../java/demo/payment/PaymentCommand.java | 13 -- .../java/demo/payment/PaymentCommands.java | 11 -- .../java/demo/payment/PaymentProvider.java | 19 ++ .../java/demo/payment/PaymentService.java | 34 +--- .../demo/payment/action/ConnectOrder.java | 14 ++ .../demo/payment/action/ProcessPayment.java | 15 ++ .../{ => controller}/PaymentController.java | 136 ++++++-------- .../java/demo/payment/EventServiceTests.java | 2 +- .../demo/payment/PaymentControllerTest.java | 14 +- .../spring-boot-starter-data-events/pom.xml | 5 + .../src/main/java/demo/domain/Action.java | 23 +++ .../src/main/java/demo/domain/Aggregate.java | 125 +++++++++++++ .../src/main/java/demo/domain/Command.java | 28 +++ .../src/main/java/demo/domain/Commodity.java | 18 ++ .../src/main/java/demo/domain/Provider.java | 28 +++ .../src/main/java/demo/domain/Service.java | 26 +++ .../src/main/java/demo/domain/Value.java | 16 ++ .../test/java/demo/domain/ProviderTests.java | 169 ++++++++++++++++++ 21 files changed, 608 insertions(+), 156 deletions(-) rename payment/payment-web/src/main/java/demo/domain/{BaseEntity.java => AbstractEntity.java} (77%) delete mode 100644 payment/payment-web/src/main/java/demo/payment/PaymentCommand.java delete mode 100644 payment/payment-web/src/main/java/demo/payment/PaymentCommands.java create mode 100644 payment/payment-web/src/main/java/demo/payment/PaymentProvider.java create mode 100644 payment/payment-web/src/main/java/demo/payment/action/ConnectOrder.java create mode 100644 payment/payment-web/src/main/java/demo/payment/action/ProcessPayment.java rename payment/payment-web/src/main/java/demo/payment/{ => controller}/PaymentController.java (63%) create mode 100644 spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Action.java create mode 100644 spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Aggregate.java create mode 100644 spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Command.java create mode 100644 spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Commodity.java create mode 100644 spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Provider.java create mode 100644 spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Service.java create mode 100644 spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Value.java create mode 100644 spring-boot-starters/spring-boot-starter-data-events/src/test/java/demo/domain/ProviderTests.java diff --git a/payment/payment-web/pom.xml b/payment/payment-web/pom.xml index 63b7afd..c7b98fa 100644 --- a/payment/payment-web/pom.xml +++ b/payment/payment-web/pom.xml @@ -57,6 +57,10 @@ 1.0-SNAPSHOT + + org.projectlombok + lombok + com.h2database h2 diff --git a/payment/payment-web/src/main/java/demo/domain/BaseEntity.java b/payment/payment-web/src/main/java/demo/domain/AbstractEntity.java similarity index 77% rename from payment/payment-web/src/main/java/demo/domain/BaseEntity.java rename to payment/payment-web/src/main/java/demo/domain/AbstractEntity.java index fb472ef..431a1d0 100644 --- a/payment/payment-web/src/main/java/demo/domain/BaseEntity.java +++ b/payment/payment-web/src/main/java/demo/domain/AbstractEntity.java @@ -3,7 +3,6 @@ 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; @@ -11,7 +10,9 @@ import java.io.Serializable; @MappedSuperclass @EntityListeners(AuditingEntityListener.class) -public class BaseEntity extends ResourceSupport implements Serializable { +public class AbstractEntity extends Aggregate implements Serializable { + + private T identity; @CreatedDate private Long createdAt; @@ -19,7 +20,7 @@ public class BaseEntity extends ResourceSupport implements Serializable { @LastModifiedDate private Long lastModified; - public BaseEntity() { + public AbstractEntity() { } public Long getCreatedAt() { @@ -38,6 +39,15 @@ public class BaseEntity extends ResourceSupport implements Serializable { this.lastModified = lastModified; } + @Override + public T getIdentity() { + return identity; + } + + public void setIdentity(T id) { + this.identity = id; + } + @Override public String toString() { return "BaseEntity{" + diff --git a/payment/payment-web/src/main/java/demo/payment/Payment.java b/payment/payment-web/src/main/java/demo/payment/Payment.java index bd9fbbf..1bfa404 100644 --- a/payment/payment-web/src/main/java/demo/payment/Payment.java +++ b/payment/payment-web/src/main/java/demo/payment/Payment.java @@ -1,8 +1,13 @@ package demo.payment; import com.fasterxml.jackson.annotation.JsonIgnore; -import demo.domain.BaseEntity; +import com.fasterxml.jackson.annotation.JsonProperty; +import demo.domain.AbstractEntity; +import demo.domain.Command; import demo.event.PaymentEvent; +import demo.payment.action.ConnectOrder; +import demo.payment.action.ProcessPayment; +import demo.payment.controller.PaymentController; import org.springframework.hateoas.Link; import javax.persistence.*; @@ -18,7 +23,7 @@ import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo; * @author Kenny Bastani */ @Entity -public class Payment extends BaseEntity { +public class Payment extends AbstractEntity { @Id @GeneratedValue @@ -41,16 +46,18 @@ public class Payment extends BaseEntity { } public Payment(Double amount, PaymentMethod paymentMethod) { - this(); this.amount = amount; this.paymentMethod = paymentMethod; } - public Long getPaymentId() { - return id; + @JsonProperty("paymentId") + @Override + public Long getIdentity() { + return this.id; } - public void setPaymentId(Long id) { + @Override + public void setIdentity(Long id) { this.id = id; } @@ -96,6 +103,24 @@ public class Payment extends BaseEntity { this.orderId = orderId; } + @Command(method = "connectOrder", controller = PaymentController.class) + public Payment connectOrder(Long orderId) { + getAction(ConnectOrder.class) + .getConsumer() + .accept(this, orderId); + + return this; + } + + @Command(method = "processPayment", controller = PaymentController.class) + public Payment processPayment() { + getAction(ProcessPayment.class) + .getConsumer() + .accept(this); + + return this; + } + /** * Returns the {@link Link} with a rel of {@link Link#REL_SELF}. */ @@ -103,16 +128,9 @@ public class Payment extends BaseEntity { public Link getId() { return linkTo(PaymentController.class) .slash("payments") - .slash(getPaymentId()) + .slash(getIdentity()) .withSelfRel(); } - @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 deleted file mode 100644 index 7434fcf..0000000 --- a/payment/payment-web/src/main/java/demo/payment/PaymentCommand.java +++ /dev/null @@ -1,13 +0,0 @@ -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 Kenneth Bastani - */ -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 deleted file mode 100644 index 984b897..0000000 --- a/payment/payment-web/src/main/java/demo/payment/PaymentCommands.java +++ /dev/null @@ -1,11 +0,0 @@ -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 Kenny Bastani - */ -public class PaymentCommands extends ResourceSupport { -} diff --git a/payment/payment-web/src/main/java/demo/payment/PaymentProvider.java b/payment/payment-web/src/main/java/demo/payment/PaymentProvider.java new file mode 100644 index 0000000..8e83593 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/PaymentProvider.java @@ -0,0 +1,19 @@ +package demo.payment; + +import demo.domain.Provider; +import demo.domain.Service; + +@org.springframework.stereotype.Service +public class PaymentProvider extends Provider { + + private final PaymentService paymentService; + + public PaymentProvider(PaymentService paymentService) { + this.paymentService = paymentService; + } + + @Override + protected Service getDefaultService() { + return paymentService; + } +} diff --git a/payment/payment-web/src/main/java/demo/payment/PaymentService.java b/payment/payment-web/src/main/java/demo/payment/PaymentService.java index 1b52cb6..f4fe652 100644 --- a/payment/payment-web/src/main/java/demo/payment/PaymentService.java +++ b/payment/payment-web/src/main/java/demo/payment/PaymentService.java @@ -1,10 +1,10 @@ package demo.payment; +import demo.domain.Service; import demo.event.EventService; import demo.event.PaymentEvent; import demo.event.PaymentEventType; import demo.util.ConsistencyModel; -import org.springframework.stereotype.Service; import org.springframework.util.Assert; import java.util.Objects; @@ -17,8 +17,8 @@ import java.util.Objects; * * @author Kenny Bastani */ -@Service -public class PaymentService { +@org.springframework.stereotype.Service +public class PaymentService extends Service { private final PaymentRepository paymentRepository; private final EventService eventService; @@ -33,11 +33,11 @@ public class PaymentService { payment = createPayment(payment); // Trigger the payment creation event - PaymentEvent event = appendEvent(payment.getPaymentId(), + PaymentEvent event = appendEvent(payment.getIdentity(), new PaymentEvent(PaymentEventType.PAYMENT_CREATED)); // Attach payment identifier - event.getEntity().setPaymentId(payment.getPaymentId()); + event.getEntity().setIdentity(payment.getIdentity()); // Return the result return event.getEntity(); @@ -78,11 +78,11 @@ public class PaymentService { 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()), + if (payment.getIdentity() != null) { + Assert.isTrue(Objects.equals(id, payment.getIdentity()), "The payment id in the request body must match the resource URL"); } else { - payment.setPaymentId(id); + payment.setIdentity(id); } Assert.state(paymentRepository.exists(id), @@ -150,22 +150,4 @@ public class PaymentService { 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} - */ - 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/action/ConnectOrder.java b/payment/payment-web/src/main/java/demo/payment/action/ConnectOrder.java new file mode 100644 index 0000000..148ad41 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/action/ConnectOrder.java @@ -0,0 +1,14 @@ +package demo.payment.action; + +import demo.domain.Action; +import demo.payment.Payment; +import org.springframework.stereotype.Service; + +import java.util.function.BiConsumer; + +@Service +public class ConnectOrder extends Action { + public BiConsumer getConsumer() { + return Payment::setOrderId; + } +} diff --git a/payment/payment-web/src/main/java/demo/payment/action/ProcessPayment.java b/payment/payment-web/src/main/java/demo/payment/action/ProcessPayment.java new file mode 100644 index 0000000..3883a57 --- /dev/null +++ b/payment/payment-web/src/main/java/demo/payment/action/ProcessPayment.java @@ -0,0 +1,15 @@ +package demo.payment.action; + +import demo.domain.Action; +import demo.payment.Payment; +import demo.payment.PaymentStatus; +import org.springframework.stereotype.Service; + +import java.util.function.Consumer; + +@Service +public class ProcessPayment extends Action { + public Consumer getConsumer() { + return payment -> payment.setStatus(PaymentStatus.PAYMENT_PROCESSED); + } +} diff --git a/payment/payment-web/src/main/java/demo/payment/PaymentController.java b/payment/payment-web/src/main/java/demo/payment/controller/PaymentController.java similarity index 63% rename from payment/payment-web/src/main/java/demo/payment/PaymentController.java rename to payment/payment-web/src/main/java/demo/payment/controller/PaymentController.java index 38476df..0cf72ea 100644 --- a/payment/payment-web/src/main/java/demo/payment/PaymentController.java +++ b/payment/payment-web/src/main/java/demo/payment/controller/PaymentController.java @@ -1,14 +1,21 @@ -package demo.payment; +package demo.payment.controller; -import demo.event.*; +import demo.event.EventController; +import demo.event.EventService; +import demo.event.Events; +import demo.event.PaymentEvent; +import demo.payment.Payment; +import demo.payment.PaymentService; import org.springframework.hateoas.ExposesResourceFor; import org.springframework.hateoas.LinkBuilder; import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceSupport; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.*; +import java.lang.reflect.Method; import java.util.Optional; import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo; @@ -31,68 +38,68 @@ public class PaymentController { this.eventService = eventService; } - @PostMapping(path = "/payments") + @RequestMapping(path = "/payments", method = RequestMethod.POST) 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}") + @RequestMapping(path = "/payments/{id}", method = RequestMethod.PUT) 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}") + @RequestMapping(path = "/payments/{id}", method = RequestMethod.GET) 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}") + @RequestMapping(path = "/payments/{id}", method = RequestMethod.DELETE) 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") + @RequestMapping(path = "/payments/{id}/events", method = RequestMethod.GET) public ResponseEntity getPaymentEvents(@PathVariable Long id) { - return Optional.of(getPaymentEventResources(id)) + return Optional.ofNullable(getPaymentEventResources(id)) .map(e -> new ResponseEntity<>(e, HttpStatus.OK)) .orElseThrow(() -> new RuntimeException("Could not get payment events")); } - @PostMapping(path = "/payments/{id}/events") + @RequestMapping(path = "/payments/{id}/events", method = RequestMethod.POST) 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)) + @RequestMapping(path = "/payments/{id}/commands") + public ResponseEntity getCommands(@PathVariable Long id) { + return Optional.ofNullable(getCommandsResources(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)) + @RequestMapping(path = "/payments/{id}/commands/connectOrder") + public ResponseEntity connectOrder(@PathVariable Long id, @RequestParam(value = "orderId") Long orderId) { + return Optional.of(paymentService.getPayment(id) + .connectOrder(orderId)) + .map(e -> new ResponseEntity<>(getPaymentResource(e), HttpStatus.OK)) .orElseThrow(() -> new RuntimeException("The command could not be applied")); } - @GetMapping(path = "/payments/{id}/commands/processPayment") + @RequestMapping(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)) + return Optional.of(paymentService.getPayment(id) + .processPayment()) + .map(e -> new ResponseEntity<>(getPaymentResource(e), HttpStatus.OK)) .orElseThrow(() -> new RuntimeException("The command could not be applied")); } @@ -103,16 +110,10 @@ public class PaymentController { * @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; + return getPaymentResource(payment); } /** @@ -170,50 +171,20 @@ public class PaymentController { return eventResource; } - /** - * Get the {@link PaymentCommand} hypermedia resource that lists the available commands that can be applied to a - * {@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; - } - private Events getPaymentEventResources(Long id) { return eventService.find(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"); + private LinkBuilder linkBuilder(String name, Long id) { + Method method; + + try { + method = PaymentController.class.getMethod(name, Long.class); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + + return linkTo(PaymentController.class, method, id); } /** @@ -223,23 +194,20 @@ public class PaymentController { * @return is a hypermedia enriched resource for the supplied {@link Payment} entity */ private Resource getPaymentResource(Payment payment) { - Resource paymentResource; + Assert.notNull(payment, "Payment must not be null"); - // 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") - ); + // Add command link + payment.add(linkBuilder("getCommands", payment.getIdentity()).withRel("commands")); - return paymentResource; + // Add get events link + payment.add(linkBuilder("getPaymentEvents", payment.getIdentity()).withRel("events")); + + return new Resource<>(payment); + } + + private ResourceSupport getCommandsResources(Long id) { + Payment payment = new Payment(); + payment.setIdentity(id); + return new Resource<>(payment.getCommands()); } } diff --git a/payment/payment-web/src/test/java/demo/payment/EventServiceTests.java b/payment/payment-web/src/test/java/demo/payment/EventServiceTests.java index 8b338c5..3bc393d 100644 --- a/payment/payment-web/src/test/java/demo/payment/EventServiceTests.java +++ b/payment/payment-web/src/test/java/demo/payment/EventServiceTests.java @@ -23,7 +23,7 @@ public class EventServiceTests { Payment payment = new Payment(11.0, PaymentMethod.CREDIT_CARD); payment = paymentRepository.saveAndFlush(payment); eventService.save(new PaymentEvent(PaymentEventType.PAYMENT_CREATED, payment)); - Events events = eventService.find(payment.getPaymentId()); + Events events = eventService.find(payment.getIdentity()); Assert.notNull(events); } } \ 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 index e709f44..e128b12 100644 --- a/payment/payment-web/src/test/java/demo/payment/PaymentControllerTest.java +++ b/payment/payment-web/src/test/java/demo/payment/PaymentControllerTest.java @@ -1,7 +1,10 @@ package demo.payment; import demo.event.EventService; +import demo.event.Events; import demo.event.PaymentEvent; +import demo.event.PaymentEventType; +import demo.payment.controller.PaymentController; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; @@ -11,6 +14,8 @@ import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; +import java.util.Collections; + 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; @@ -35,10 +40,13 @@ public class PaymentControllerTest { Payment payment = new Payment(42.0, PaymentMethod.CREDIT_CARD); - given(this.paymentService.getPayment(1L)) - .willReturn(payment); + given(this.paymentService.getPayment(1L)).willReturn(payment); + given(this.eventService.find(1L)).willReturn(new Events<>(1L, Collections + .singletonList(new PaymentEvent(PaymentEventType + .PAYMENT_CREATED)))); this.mvc.perform(get("/v1/payments/1").accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()).andExpect(content().json(content)); + .andExpect(status().isOk()) + .andExpect(content().json(content)); } } diff --git a/spring-boot-starters/spring-boot-starter-data-events/pom.xml b/spring-boot-starters/spring-boot-starter-data-events/pom.xml index 04f92f4..b83765b 100755 --- a/spring-boot-starters/spring-boot-starter-data-events/pom.xml +++ b/spring-boot-starters/spring-boot-starter-data-events/pom.xml @@ -47,5 +47,10 @@ org.springframework.data spring-data-commons + + + org.projectlombok + lombok + diff --git a/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Action.java b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Action.java new file mode 100644 index 0000000..16f4854 --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Action.java @@ -0,0 +1,23 @@ +package demo.domain; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +/** + * An {@link Action} is a reference of a method. A function contains an address to the location of a method. A function + * may contain meta-data that describes the inputs and outputs of a method. An action invokes a method annotated with + * {@link Command}. + * + * @author Kenny Bastani + */ +@Component +public abstract class Action implements ApplicationContextAware { + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } +} diff --git a/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Aggregate.java b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Aggregate.java new file mode 100644 index 0000000..f65e125 --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Aggregate.java @@ -0,0 +1,125 @@ +package demo.domain; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; +import org.springframework.hateoas.*; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.RequestParam; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo; + +/** + * An {@link Aggregate} is an entity that contains references to one or more other {@link Value} objects. Aggregates + * may contain a collection of references to a {@link Command}. All command references on an aggregate should be + * explicitly typed. + * + * @author Kenny Bastani + */ +public abstract class Aggregate extends ResourceSupport implements Value { + + @JsonProperty("id") + abstract ID getIdentity(); + + private final ApplicationContext applicationContext = Optional.ofNullable(Provider.getApplicationContext()) + .orElse(null); + + /** + * Retrieves an {@link Action} for this {@link Provider} + * + * @return the action for this provider + * @throws IllegalArgumentException if the application context is unavailable or the provider does not exist + */ + @SuppressWarnings("unchecked") + protected , A extends Aggregate> T getAction( + Class actionType) throws IllegalArgumentException { + Provider provider = getProvider(); + Service service = provider.getDefaultService(); + return (T) service.getAction(actionType); + } + + /** + * Retrieves an instance of the {@link Provider} for this instance + * + * @return the provider for this instance + * @throws IllegalArgumentException if the application context is unavailable or the provider does not exist + */ + @SuppressWarnings("unchecked") + protected , A extends Aggregate> T getProvider() throws IllegalArgumentException { + return getProvider((Class) ResolvableType + .forClassWithGenerics(Provider.class, ResolvableType.forInstance(this)) + .getRawClass()); + } + + /** + * Retrieves an instance of a {@link Provider} with the supplied type + * + * @return an instance of the requested {@link Provider} + * @throws IllegalArgumentException if the application context is unavailable or the provider does not exist + */ + protected , A extends Aggregate> T getProvider( + Class providerType) throws IllegalArgumentException { + Assert.notNull(applicationContext, "The application context is unavailable"); + T provider = applicationContext.getBean(providerType); + Assert.notNull(provider, "The requested provider is not registered in the application context"); + return provider; + } + + @Override + public List getLinks() { + List links = super.getLinks() + .stream() + .collect(Collectors.toList()); + + links.add(getId()); + + return links; + } + + @JsonIgnore + @SuppressWarnings("unchecked") + public CommandResources getCommands() { + CommandResources commandResources = new CommandResources(); + + // Get command annotations on the aggregate + List commands = Arrays.stream(this.getClass() + .getMethods()) + .filter(a -> a.isAnnotationPresent(Command.class)) + .map(a -> a.getAnnotation(Command.class)) + .collect(Collectors.toList()); + + // Compile the collection of command links + List commandLinks = commands.stream() + .map(a -> Arrays.stream(ReflectionUtils.getAllDeclaredMethods(a.controller())) + .filter(m -> m.getName() + .equalsIgnoreCase(a.method())) + .findFirst() + .orElseGet(null)) + .map(m -> { + String uri = linkTo(m, getIdentity()).withRel(m.getName()) + .getHref(); + + return new Link(new UriTemplate(uri, new TemplateVariables(Arrays.stream(m.getParameters()) + .filter(p -> p.isAnnotationPresent(RequestParam.class)) + .map(p -> new TemplateVariable(p.getAnnotation(RequestParam.class) + .value(), TemplateVariable.VariableType.REQUEST_PARAM)) + .toArray(TemplateVariable[]::new))), m.getName()); + }) + .collect(Collectors.toList()); + + commandResources.add(commandLinks); + + return commandResources; + } + + public static class CommandResources extends ResourceSupport { + } +} diff --git a/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Command.java b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Command.java new file mode 100644 index 0000000..6c8f0a0 --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Command.java @@ -0,0 +1,28 @@ +package demo.domain; + +import org.springframework.beans.factory.annotation.Required; +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +/** + * A {@link Command} is an annotated method that contains a reference to a function in the context of an + * {@link Aggregate}. A command maps a method reference on an {@link Aggregate} to a function invocation. Commands + * are discoverable. + * + * @author Kenny Bastani + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface Command { + + String description() default ""; + + @Required + String method() default ""; + + @Required + Class controller(); +} diff --git a/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Commodity.java b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Commodity.java new file mode 100644 index 0000000..fc55a31 --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Commodity.java @@ -0,0 +1,18 @@ +package demo.domain; + +/** + * A {@link Commodity} object is a {@link Value} object that is also an {@link Aggregate} root. A commodity object + * describes all aspects of an aggregate and is both stateless and immutable. A commodity is a locator that connects + * relationships of a value object to a {@link Provider}. + *

+ * The key difference between a commodity object and an aggregate is that a commodity object is a distributed + * representation of an aggregate root that combines together references from multiple bounded contexts. + *

+ * Commodities are used by a discovery service to create a reverse-proxy that translates the relationships of an + * aggregate into URIs. + * + * @author Kenny Bastani + */ +public abstract class Commodity extends Aggregate { + +} diff --git a/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Provider.java b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Provider.java new file mode 100644 index 0000000..f1b70fc --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Provider.java @@ -0,0 +1,28 @@ +package demo.domain; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +/** + * A {@link Provider} is a collection of {@link Service} references that are used to consume and/or produce + * {@link org.springframework.hateoas.Resource}s. Providers transfer a resource into a {@link Commodity}. + * + * @author Kenny Bastani + */ +@Component +public abstract class Provider implements ApplicationContextAware { + private static ApplicationContext applicationContext; + + public static ApplicationContext getApplicationContext() { + return applicationContext; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + Provider.applicationContext = applicationContext; + } + + protected abstract Service getDefaultService(); +} diff --git a/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Service.java b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Service.java new file mode 100644 index 0000000..c36762e --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Service.java @@ -0,0 +1,26 @@ +package demo.domain; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; + +/** + * A {@link Service} is a functional unit that provides a need. Services are immutable and often stateless. Services + * always consume or produce {@link Commodity} objects. Services are addressable and discoverable by other services. + * + * @author Kenny Bastani + */ +@org.springframework.stereotype.Service +public abstract class Service implements ApplicationContextAware { + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @SuppressWarnings("unchecked") + public > A getAction(Class clazz) { + return applicationContext.getBean(clazz); + } +} diff --git a/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Value.java b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Value.java new file mode 100644 index 0000000..54d39d3 --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-data-events/src/main/java/demo/domain/Value.java @@ -0,0 +1,16 @@ +package demo.domain; + + +import org.springframework.hateoas.Identifiable; + +import java.io.Serializable; + +/** + * {@link Value} objects are wrappers that contain the serializable properties that uniquely identify an entity. + * Value objects contain a collection of relationships. Value objects contain a collection of comparison operators. + * The default identity comparator evaluates true if the compared objects have the same identifier. + * + * @author Kenny Bastani + */ +public interface Value extends Identifiable { +} diff --git a/spring-boot-starters/spring-boot-starter-data-events/src/test/java/demo/domain/ProviderTests.java b/spring-boot-starters/spring-boot-starter-data-events/src/test/java/demo/domain/ProviderTests.java new file mode 100644 index 0000000..34f9aca --- /dev/null +++ b/spring-boot-starters/spring-boot-starter-data-events/src/test/java/demo/domain/ProviderTests.java @@ -0,0 +1,169 @@ +package demo.domain; + +import lombok.*; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.util.EnvironmentTestUtils; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.Link; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.*; + +import java.util.function.Consumer; + +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertNotNull; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = {ProviderTests.class, ProviderTests.EmptyConfiguration.class, ProviderTests.EmptyProvider.class, ProviderTests.EmptyService.class, ProviderTests.EmptyAction.class}) +public class ProviderTests { + + private AnnotationConfigApplicationContext context; + + @After + public void tearDown() { + if (this.context != null) { + this.context.close(); + } + } + + @Before + public void setup() { + load(EmptyProvider.class); + assertNotNull(context); + } + + @Test + public void testGetProviderReturnsProvider() { + assertNotNull(new EmptyAggregate().getProvider(EmptyProvider.class)); + } + + @Test + public void testGetServiceReturnsService() { + EmptyProvider provider = new EmptyAggregate().getProvider(EmptyProvider.class); + assertNotNull(provider.getEmptyService()); + } + + @Test + public void testGetActionReturnsAction() { + EmptyProvider provider = new EmptyAggregate().getProvider(EmptyProvider.class); + EmptyService service = provider.getEmptyService(); + assertNotNull(service.getAction(EmptyAction.class)); + } + + @Test + public void testProcessCommandChangesStatus() { + EmptyAggregate aggregate = new EmptyAggregate(0L, AggregateStatus.CREATED); + EmptyProvider provider = new EmptyAggregate().getProvider(EmptyProvider.class); + EmptyService service = provider.getEmptyService(); + EmptyAction emptyAction = service.getAction(EmptyAction.class); + emptyAction.getConsumer().accept(aggregate); + assertEquals(aggregate.getStatus(), AggregateStatus.PROCESSED); + } + + @Test + public void testProcessEmptyAggregateChangesStatus() { + EmptyAggregate aggregate = new EmptyAggregate(0L, AggregateStatus.CREATED); + aggregate.emptyAction(); + assertEquals(aggregate.getStatus(), AggregateStatus.PROCESSED); + } + + @Test + public void testAggregateProducesCommandLinks() { + EmptyAggregate aggregate = new EmptyAggregate(0L, AggregateStatus.CREATED); + Assert.notEmpty(aggregate.getLinks()); + Assert.notEmpty(aggregate.getCommands().getLinks()); + } + + private void load(Class provider, String... environment) { + AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(); + EnvironmentTestUtils.addEnvironment(applicationContext, environment); + applicationContext.register(EmptyConfiguration.class, provider, EmptyService.class, EmptyAction.class, EmptyController.class); + applicationContext.refresh(); + this.context = applicationContext; + } + + + @Configuration + public static class EmptyConfiguration { + } + + @AllArgsConstructor + @NoArgsConstructor + @Getter + @Setter + public static class EmptyAggregate extends Aggregate { + @NonNull + private Long id; + @NonNull + private AggregateStatus status; + + @Command(controller = EmptyController.class, method = "emptyAction") + public void emptyAction() { + EmptyProvider emptyProvider = this.getProvider(); + emptyProvider.getEmptyService() + .getAction(EmptyAction.class) + .getConsumer() + .accept(this); + } + + @Override + public Link getId() { + return new Link("example.com").withSelfRel(); + } + + @Override + Long getIdentity() { + return this.id; + } + } + + @Getter + @RequiredArgsConstructor + public static class EmptyProvider extends Provider { + private final EmptyService emptyService; + + public Service getDefaultService() { + return emptyService; + } + } + + public static class EmptyService extends Service { + public EmptyAggregate getEmptyAggregate(Long id) { + return new EmptyAggregate(id, AggregateStatus.CREATED); + } + } + + public static class EmptyAction extends Action { + + public Consumer getConsumer() { + return a -> a.setStatus(AggregateStatus.PROCESSED); + } + } + + @RestController + @RequestMapping("/v1") + public static class EmptyController { + + private EmptyProvider provider; + + public EmptyController(EmptyProvider provider) { + this.provider = provider; + } + + @RequestMapping(value = "/empty/{id}", method = RequestMethod.GET) + public EmptyAggregate emptyAction(@PathVariable("id") Long id, @RequestParam("q") String query) { + return provider.getEmptyService().getEmptyAggregate(id); + } + } + + public enum AggregateStatus { + CREATED, + PROCESSED + } +} \ No newline at end of file