Order and Payment

This commit is contained in:
Kenny Bastani
2016-12-23 13:39:53 -05:00
parent 03c18c0fe3
commit e1dc702107
78 changed files with 3059 additions and 51 deletions

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>payment-web</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>payment-web</name>
<parent>
<groupId>org.kbastani</groupId>
<artifactId>payment</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../</relativePath>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -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<HttpMessageConverter<?>> 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));
}
}

View File

@@ -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 +
'}';
}
}

View File

@@ -0,0 +1,6 @@
package demo.event;
public enum ConsistencyModel {
BASE,
ACID
}

View File

@@ -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));
}
}

View File

@@ -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<PaymentEvent, Long> {
Page<PaymentEvent> findPaymentEventsByPaymentId(@Param("paymentId") Long paymentId, Pageable pageable);
}

View File

@@ -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.
* <p>
* 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.
* <p>
* 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<Resource<PaymentEvent>> 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<PaymentEvent> 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<PaymentEvent> 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<PaymentEvent> getPaymentEventResource(PaymentEvent event) {
return new Resource<PaymentEvent>(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;
}
}

View File

@@ -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}.
* <p>
* 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();
}
}

View File

@@ -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
}

View File

@@ -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<PaymentEvent> 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<PaymentEvent> 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<PaymentEvent> 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;
}
}

View File

@@ -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<PaymentEvent> 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<PaymentEvent> getEvents() {
return events;
}
public void setEvents(Set<PaymentEvent> 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();
}
}

View File

@@ -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
}

View File

@@ -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 {
}

View File

@@ -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<Payment> getPaymentResource(Long id) {
Resource<Payment> 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<Payment> 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<Payment> 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<PaymentEvent> appendEventResource(Long paymentId, PaymentEvent event) {
Resource<PaymentEvent> 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<Payment> 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<Payment> getPaymentResource(Payment payment) {
Resource<Payment> 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;
}
}

View File

@@ -0,0 +1,5 @@
package demo.payment;
public enum PaymentMethod {
CREDIT_CARD
}

View File

@@ -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, Long> {
Payment findPaymentByOrderId(@Param("orderId") Long orderId);
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -0,0 +1,4 @@
spring:
application:
name: payment-web
---

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1 @@
INSERT INTO PAYMENT(ID, FIRST_NAME, LAST_NAME, EMAIL) values (1, 'John', 'Doe', 'john.doe@example.com');