Cleaning up
This commit is contained in:
309
account/account-web/README.md
Normal file
309
account/account-web/README.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# Account Microservice: Web Role
|
||||
|
||||
The `account-web` module is a web application that produces a REST API that can be used by consumers to interact with and manage domain objects in the `Account` context. _Domain Events_ can be triggered directly over HTTP, and will also be produced in the response to actions that alter the state of the `Account` object. This web service also provides built-in hypermedia support for looking up the event logs on an aggregate domain object.
|
||||
|
||||
## Usage
|
||||
|
||||
The `account-web` application provides a hypermedia-driven REST API for managing accounts.
|
||||
|
||||
To create a new account, we can send an HTTP POST request to `/v1/accounts`.
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": 1,
|
||||
"accountNumber": "123456",
|
||||
"defaultAccount": true
|
||||
}
|
||||
```
|
||||
|
||||
If the request was successful, a hypermedia response will be returned back.
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": 1481351048967,
|
||||
"lastModified": 1481351048967,
|
||||
"userId": 1,
|
||||
"accountNumber": "123456",
|
||||
"defaultAccount": true,
|
||||
"status": "ACCOUNT_CREATED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/accounts/1"
|
||||
},
|
||||
"events": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/events"
|
||||
},
|
||||
"commands": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The snippet above is the response that was returned after creating the new account. We can see the field `status` has a value of `ACCOUNT_CREATED`. Notice the embedded links in the response, which provide context for the resource. In this case, we have two links of interest: `events` and `commands`. Let's take a look at both of these resources, starting with `commands`.
|
||||
|
||||
The `commands` resource provides us with an additional set of hypermedia links for each command that can be applied to the `Account` resource.
|
||||
|
||||
_GET_ `/v1/accounts/1/commands`
|
||||
|
||||
```json
|
||||
{
|
||||
"_links": {
|
||||
"confirm": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands/confirm"
|
||||
},
|
||||
"activate": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands/activate"
|
||||
},
|
||||
"suspend": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands/suspend"
|
||||
},
|
||||
"archive": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands/archive"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In an event-driven architecture, the state of an object can only change in response to an event. The `account-worker` module will be monitoring for events and can execute commands against the resource that is the subject of an event.
|
||||
|
||||
Let's return back to the parent `Account` resource for these commands.
|
||||
|
||||
_GET_ `/v1/accounts/1`
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": 1481351048967,
|
||||
"lastModified": 1481351049385,
|
||||
"userId": 1,
|
||||
"accountNumber": "123456",
|
||||
"defaultAccount": true,
|
||||
"status": "ACCOUNT_ACTIVE",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/accounts/1"
|
||||
},
|
||||
"events": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/events"
|
||||
},
|
||||
"commands": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Sending a new _GET_ request to the parent `Account` resource returns back a different the object with a different `status` value. The status value is now set to `ACCOUNT_ACTIVE`, which previously was `ACCOUNT_CREATED`. The cause for this is because we are also running the `account-worker` in parallel, which is reacting to domain events triggered by `account-web`.
|
||||
|
||||
During an account creation workflow, a domain event will be triggered and sent to the account stream, which the `account-worker` module is listening to. To understand exactly what has happened to this `Account` resource, we can trace the events that led to its current state. Sending a GET request to the attached hypermedia link named `events` returns back an event log.
|
||||
|
||||
_GET_ `/v1/accounts/1/events`
|
||||
|
||||
```json
|
||||
{
|
||||
"_embedded": {
|
||||
"accountEventList": [
|
||||
{
|
||||
"createdAt": 1481351049002,
|
||||
"lastModified": 1481351049002,
|
||||
"type": "ACCOUNT_CREATED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/events/1"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"createdAt": 1481351049318,
|
||||
"lastModified": 1481351049318,
|
||||
"type": "ACCOUNT_CONFIRMED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/events/2"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"createdAt": 1481351049387,
|
||||
"lastModified": 1481351049387,
|
||||
"type": "ACCOUNT_ACTIVATED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/events/3"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/events"
|
||||
},
|
||||
"account": {
|
||||
"href": "http://localhost:54656/v1/accounts/1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The response returned is an ordered collection of account events. The log describes the events that caused the `status` of the account resource to be set to the `ACCOUNT_ACTIVE` value. But how did the `account-worker` module drive the state changes of this account resource?
|
||||
|
||||
Source: _EventService.java_
|
||||
|
||||
```java
|
||||
public AccountEvent createEvent(AccountEvent event) {
|
||||
// Save new event
|
||||
event = addEvent(event);
|
||||
Assert.notNull(event, "The event could not be appended to the account");
|
||||
|
||||
// Append the account event to the stream
|
||||
accountStreamSource.output()
|
||||
.send(MessageBuilder
|
||||
.withPayload(getAccountEventResource(event))
|
||||
.build());
|
||||
|
||||
return event;
|
||||
}
|
||||
```
|
||||
|
||||
In the snippet above we see the creation of a new `AccountEvent` in the `EventService` class. Notice how the payload that is sent to the account stream is being constructed. The `getAccountEventResource` method will prepare the `AccountEvent` object as a hypermedia resource. This means that when the `account-worker` processes the event message that it will be able to use the embedded links to understand the full context of the event. The exact representation of the `AccountEvent` resource that will be sent is shown in the snippet below.
|
||||
|
||||
The body of the _AccountEvent_ message for creating an account:
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": 1481353397395,
|
||||
"lastModified": 1481353397395,
|
||||
"type": "ACCOUNT_CREATED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54932/v1/events/1"
|
||||
},
|
||||
"account": {
|
||||
"href": "http://localhost:54932/v1/accounts/1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Here we see the `AccountEvent` resource that was sent to the account stream during the create account workflow. Notice how the body of the event message contains no fields describing the new `Account` resource. The full context for the event is only available as a graph of hypermedia links attached to the `AccountEvent` resource. Because of this, the event processor does not need to know anything about the location of resources. With this approach, we can drive the behavior and the state of the application using hypermedia.
|
||||
|
||||
The snippet below is an example of how the `account-worker` is able to traverse the graph of hypermedia links of an `AccountEvent` resource.
|
||||
|
||||
```java
|
||||
// Traverse the hypermedia link for the attached account
|
||||
Traverson traverson = new Traverson(
|
||||
new URI(accountEvent.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
// Traverse the hypermedia link to retrieve the event log for the account
|
||||
AccountEvents events = traverson.follow("events")
|
||||
.toEntity(AccountEvents.class)
|
||||
.getBody();
|
||||
```
|
||||
|
||||
Here we use a `Traverson` as a REST client instead of a `RestTemplate` to easily follow related hypermedia links. `Traverson` wraps a standard `RestTemplate` with support that makes it simple to process related hypermedia links. In this kind of architecture, caching is absolutely essential, since traversing hypermedia links is a sequential traversal.
|
||||
|
||||
The event processor has no knowledge about the structure of a REST API outside of the hypermedia links it discovers. Because of this, we may be required to dispatch more HTTP _GET_ requests than we normally would.
|
||||
|
||||
Now that we understand how the event processor can use hypermedia to discover the context of a domain event, let's now take a look at how it drives the state of domain resources.
|
||||
|
||||
```java
|
||||
// Create a traverson for the root account
|
||||
Traverson traverson = new Traverson(
|
||||
new URI(event.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
// Get the account resource attached to the event
|
||||
Account account = traverson.follow("self")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
...
|
||||
```
|
||||
|
||||
Here we see how the `account-worker` drives the state of an `Account` resource by traversing hypermedia links attached to an `AccountEvent` resource it is processing. Here we start by traversing to the `Account` resource that is the subject of the current `AccountEvent`. The result of that request is below.
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": 1481353397350,
|
||||
"lastModified": 1481353397809,
|
||||
"userId": 1,
|
||||
"accountNumber": "123456",
|
||||
"defaultAccount": true,
|
||||
"status": "ACCOUNT_PENDING",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54932/v1/accounts/1"
|
||||
},
|
||||
"events": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/events"
|
||||
},
|
||||
"commands": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this workflow we are wanting to transition the account from a pending state to a confirmed state. Since the `Account` resource has a hypermedia link relation for `commands`, we can use that link to find the list of possible operations that can be performed on an `Account` resource. Following the `commands` link returns the following result.
|
||||
|
||||
```json
|
||||
{
|
||||
"_links": {
|
||||
"confirm": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands/confirm"
|
||||
},
|
||||
"activate": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands/activate"
|
||||
},
|
||||
"suspend": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands/suspend"
|
||||
},
|
||||
"archive": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands/archive"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now that we have the list of commands that can be applied to the resource, we can choose which one we need to transition the state of the `Account` from `ACCOUNT_PENDING` to `ACCOUNT_CONFIRMED`. To do this, all we need to do is to follow the `confirm` link, using the `Traverson` client, which is shown below.
|
||||
|
||||
```java
|
||||
// Traverse to the confirm account command
|
||||
account = traverson.follow("commands")
|
||||
.follow("confirm")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
|
||||
log.info("Account confirmed: " + account);
|
||||
```
|
||||
|
||||
The response returned for the `confirm` command returns the updated state of the `Account` resource, shown below.
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": 1481353397350,
|
||||
"lastModified": 1481355618050,
|
||||
"userId": 1,
|
||||
"accountNumber": "123456",
|
||||
"defaultAccount": true,
|
||||
"status": "ACCOUNT_CONFIRMED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54932/v1/accounts/1"
|
||||
},
|
||||
"events": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/events"
|
||||
},
|
||||
"commands": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
As the `Account` resource transitions from `ACCOUNT_PENDING` to `ACCOUNT_CONFIRMED`, yet another event is triggered by the `account-web` application and dispatched to the account stream.
|
||||
|
||||
By using hypermedia to drive the state of domain resources, an subscriber of a domain event only needs to know the structure of how domain resources are connected by hypermedia relationships. This means that any prior knowledge of the URL structure of a REST API is not required by subscribers processing an event stream.
|
||||
11
account/account-web/manifest.yml
Normal file
11
account/account-web/manifest.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
name: account-web
|
||||
memory: 1024M
|
||||
instances: 1
|
||||
path: ./target/account-web-0.0.1-SNAPSHOT.jar
|
||||
buildpack: java_buildpack
|
||||
services:
|
||||
- rabbit-events
|
||||
- redis-cache
|
||||
disk_quota: 1024M
|
||||
host: account-event-web
|
||||
domain: cfapps.io
|
||||
71
account/account-web/pom.xml
Normal file
71
account/account-web/pom.xml
Normal 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>account-web</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>account-web</name>
|
||||
|
||||
<parent>
|
||||
<groupId>org.kbastani</groupId>
|
||||
<artifactId>account</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>
|
||||
@@ -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 AccountServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AccountServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
108
account/account-web/src/main/java/demo/account/Account.java
Normal file
108
account/account-web/src/main/java/demo/account/Account.java
Normal file
@@ -0,0 +1,108 @@
|
||||
package demo.account;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import demo.domain.BaseEntity;
|
||||
import demo.event.AccountEvent;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The {@link Account} domain object contains information related to
|
||||
* a user's account. The status of an account is event sourced using
|
||||
* events logged to the {@link AccountEvent} collection attached to
|
||||
* this resource.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
@Entity
|
||||
public class Account extends BaseEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
private Long id;
|
||||
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
|
||||
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
private Set<AccountEvent> events = new HashSet<>();
|
||||
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
private AccountStatus status;
|
||||
|
||||
public Account() {
|
||||
status = AccountStatus.ACCOUNT_CREATED;
|
||||
}
|
||||
|
||||
public Account(String firstName, String lastName, String email) {
|
||||
this();
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Long getAccountId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setAccountId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Set<AccountEvent> getEvents() {
|
||||
return events;
|
||||
}
|
||||
|
||||
public void setEvents(Set<AccountEvent> events) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
public AccountStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(AccountStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Account{" +
|
||||
"id=" + id +
|
||||
", firstName='" + firstName + '\'' +
|
||||
", lastName='" + lastName + '\'' +
|
||||
", email='" + email + '\'' +
|
||||
", events=" + events +
|
||||
", status=" + status +
|
||||
"} " + super.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package demo.account;
|
||||
|
||||
/**
|
||||
* The {@link AccountCommand} represents an action that can be performed to an
|
||||
* {@link Account} aggregate. Commands initiate an action that can mutate the state of
|
||||
* an account entity as it transitions between {@link AccountStatus} values.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public enum AccountCommand {
|
||||
CONFIRM_ACCOUNT,
|
||||
ACTIVATE_ACCOUNT,
|
||||
SUSPEND_ACCOUNT,
|
||||
ARCHIVE_ACCOUNT
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package demo.account;
|
||||
|
||||
import org.springframework.hateoas.ResourceSupport;
|
||||
|
||||
/**
|
||||
* A hypermedia resource that describes the collection of commands that
|
||||
* can be applied to a {@link Account} aggregate.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class AccountCommandsResource extends ResourceSupport {
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package demo.account;
|
||||
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEvents;
|
||||
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 AccountController {
|
||||
|
||||
private final AccountService accountService;
|
||||
private final EventService eventService;
|
||||
|
||||
public AccountController(AccountService accountService, EventService eventService) {
|
||||
this.accountService = accountService;
|
||||
this.eventService = eventService;
|
||||
}
|
||||
|
||||
@PostMapping(path = "/accounts")
|
||||
public ResponseEntity createAccount(@RequestBody Account account) {
|
||||
return Optional.ofNullable(createAccountResource(account))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
|
||||
.orElseThrow(() -> new RuntimeException("Account creation failed"));
|
||||
}
|
||||
|
||||
@PutMapping(path = "/accounts/{id}")
|
||||
public ResponseEntity updateAccount(@RequestBody Account account, @PathVariable Long id) {
|
||||
return Optional.ofNullable(updateAccountResource(id, account))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("Account update failed"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}")
|
||||
public ResponseEntity getAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getAccountResource(id))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
@DeleteMapping(path = "/accounts/{id}")
|
||||
public ResponseEntity deleteAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(accountService.deleteAccount(id))
|
||||
.map(e -> new ResponseEntity<>(HttpStatus.NO_CONTENT))
|
||||
.orElseThrow(() -> new RuntimeException("Account deletion failed"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/events")
|
||||
public ResponseEntity getAccountEvents(@PathVariable Long id) {
|
||||
return Optional.of(getAccountEventResources(id))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("Could not get account events"));
|
||||
}
|
||||
|
||||
@PostMapping(path = "/accounts/{id}/events")
|
||||
public ResponseEntity createAccount(@PathVariable Long id, @RequestBody AccountEvent event) {
|
||||
return Optional.ofNullable(appendEventResource(id, event))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
|
||||
.orElseThrow(() -> new RuntimeException("Append account event failed"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/commands")
|
||||
public ResponseEntity getAccountCommands(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getCommandsResource(id))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The account could not be found"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/commands/confirm")
|
||||
public ResponseEntity confirmAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getAccountResource(
|
||||
accountService.applyCommand(id, AccountCommand.CONFIRM_ACCOUNT)))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The command could not be applied"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/commands/activate")
|
||||
public ResponseEntity activateAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getAccountResource(
|
||||
accountService.applyCommand(id, AccountCommand.ACTIVATE_ACCOUNT)))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The command could not be applied"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/commands/suspend")
|
||||
public ResponseEntity suspendAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getAccountResource(
|
||||
accountService.applyCommand(id, AccountCommand.SUSPEND_ACCOUNT)))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The command could not be applied"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/commands/archive")
|
||||
public ResponseEntity archiveAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getAccountResource(
|
||||
accountService.applyCommand(id, AccountCommand.ARCHIVE_ACCOUNT)))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The command could not be applied"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a hypermedia resource for {@link Account} with the specified identifier.
|
||||
*
|
||||
* @param id is the unique identifier for looking up the {@link Account} entity
|
||||
* @return a hypermedia resource for the fetched {@link Account}
|
||||
*/
|
||||
private Resource<Account> getAccountResource(Long id) {
|
||||
Resource<Account> accountResource = null;
|
||||
|
||||
// Get the account for the provided id
|
||||
Account account = accountService.getAccount(id);
|
||||
|
||||
// If the account exists, wrap the hypermedia response
|
||||
if (account != null)
|
||||
accountResource = getAccountResource(account);
|
||||
|
||||
|
||||
return accountResource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link Account} entity and persists the result to the repository.
|
||||
*
|
||||
* @param account is the {@link Account} model used to create a new account
|
||||
* @return a hypermedia resource for the newly created {@link Account}
|
||||
*/
|
||||
private Resource<Account> createAccountResource(Account account) {
|
||||
Assert.notNull(account, "Account body must not be null");
|
||||
Assert.notNull(account.getEmail(), "Email is required");
|
||||
Assert.notNull(account.getFirstName(), "First name is required");
|
||||
Assert.notNull(account.getLastName(), "Last name is required");
|
||||
|
||||
// Create the new account
|
||||
account = accountService.registerAccount(account);
|
||||
|
||||
return getAccountResource(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a {@link Account} entity for the provided identifier.
|
||||
*
|
||||
* @param id is the unique identifier for the {@link Account} update
|
||||
* @param account is the entity representation containing any updated {@link Account} fields
|
||||
* @return a hypermedia resource for the updated {@link Account}
|
||||
*/
|
||||
private Resource<Account> updateAccountResource(Long id, Account account) {
|
||||
return getAccountResource(accountService.updateAccount(id, account));
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an {@link AccountEvent} domain event to the event log of the {@link Account}
|
||||
* aggregate with the specified accountId.
|
||||
*
|
||||
* @param accountId is the unique identifier for the {@link Account}
|
||||
* @param event is the {@link AccountEvent} that attempts to alter the state of the {@link Account}
|
||||
* @return a hypermedia resource for the newly appended {@link AccountEvent}
|
||||
*/
|
||||
private Resource<AccountEvent> appendEventResource(Long accountId, AccountEvent event) {
|
||||
Resource<AccountEvent> eventResource = null;
|
||||
|
||||
event = accountService.appendEvent(accountId, event);
|
||||
|
||||
if (event != null) {
|
||||
eventResource = new Resource<>(event,
|
||||
linkTo(EventController.class)
|
||||
.slash("events")
|
||||
.slash(event.getEventId())
|
||||
.withSelfRel(),
|
||||
linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(accountId)
|
||||
.withRel("account")
|
||||
);
|
||||
}
|
||||
|
||||
return eventResource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link AccountCommand} hypermedia resource that lists the available commands that can be applied
|
||||
* to an {@link Account} entity.
|
||||
*
|
||||
* @param id is the {@link Account} identifier to provide command links for
|
||||
* @return an {@link AccountCommandsResource} with a collection of embedded command links
|
||||
*/
|
||||
private AccountCommandsResource getCommandsResource(Long id) {
|
||||
// Get the account resource for the identifier
|
||||
Resource<Account> accountResource = getAccountResource(id);
|
||||
|
||||
// Create a new account commands hypermedia resource
|
||||
AccountCommandsResource commandResource = new AccountCommandsResource();
|
||||
|
||||
// Add account command hypermedia links
|
||||
if (accountResource != null) {
|
||||
commandResource.add(
|
||||
getCommandLinkBuilder(id)
|
||||
.slash("confirm")
|
||||
.withRel("confirm"),
|
||||
getCommandLinkBuilder(id)
|
||||
.slash("activate")
|
||||
.withRel("activate"),
|
||||
getCommandLinkBuilder(id)
|
||||
.slash("suspend")
|
||||
.withRel("suspend"),
|
||||
getCommandLinkBuilder(id)
|
||||
.slash("archive")
|
||||
.withRel("archive")
|
||||
);
|
||||
}
|
||||
|
||||
return commandResource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get {@link AccountEvents} for the supplied {@link Account} identifier.
|
||||
*
|
||||
* @param id is the unique identifier of the {@link Account}
|
||||
* @return a list of {@link AccountEvent} wrapped in a hypermedia {@link AccountEvents} resource
|
||||
*/
|
||||
private AccountEvents getAccountEventResources(Long id) {
|
||||
return new AccountEvents(id, eventService.getAccountEvents(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a {@link LinkBuilder} for generating the {@link AccountCommandsResource}.
|
||||
*
|
||||
* @param id is the unique identifier for a {@link Account}
|
||||
* @return a {@link LinkBuilder} for the {@link AccountCommandsResource}
|
||||
*/
|
||||
private LinkBuilder getCommandLinkBuilder(Long id) {
|
||||
return linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(id)
|
||||
.slash("commands");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a hypermedia enriched {@link Account} entity.
|
||||
*
|
||||
* @param account is the {@link Account} to enrich with hypermedia links
|
||||
* @return is a hypermedia enriched resource for the supplied {@link Account} entity
|
||||
*/
|
||||
private Resource<Account> getAccountResource(Account account) {
|
||||
Resource<Account> accountResource;
|
||||
|
||||
// Prepare hypermedia response
|
||||
accountResource = new Resource<>(account,
|
||||
linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(account.getAccountId())
|
||||
.withSelfRel(),
|
||||
linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(account.getAccountId())
|
||||
.slash("events")
|
||||
.withRel("events"),
|
||||
getCommandLinkBuilder(account.getAccountId())
|
||||
.withRel("commands")
|
||||
);
|
||||
|
||||
return accountResource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package demo.account;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface AccountRepository extends JpaRepository<Account, Long> {
|
||||
|
||||
Account findAccountByEmail(@Param("email") String email);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package demo.account;
|
||||
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import demo.event.EventService;
|
||||
import demo.event.ConsistencyModel;
|
||||
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.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
import static demo.account.AccountStatus.*;
|
||||
|
||||
/**
|
||||
* The {@link AccountService} provides transactional support for managing {@link Account}
|
||||
* entities. This service also provides event sourcing support for {@link AccountEvent}.
|
||||
* Events can be appended to an {@link Account}, 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 = {"accounts"})
|
||||
public class AccountService {
|
||||
|
||||
private final AccountRepository accountRepository;
|
||||
private final EventService eventService;
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
public AccountService(AccountRepository accountRepository, EventService eventService, CacheManager cacheManager) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.eventService = eventService;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
|
||||
@CacheEvict(cacheNames = "accounts", key = "#account.getAccountId().toString()")
|
||||
public Account registerAccount(Account account) {
|
||||
|
||||
account = createAccount(account);
|
||||
|
||||
cacheManager.getCache("accounts")
|
||||
.evict(account.getAccountId());
|
||||
|
||||
// Trigger the account creation event
|
||||
AccountEvent event = appendEvent(account.getAccountId(),
|
||||
new AccountEvent(AccountEventType.ACCOUNT_CREATED));
|
||||
|
||||
// Attach account identifier
|
||||
event.getAccount().setAccountId(account.getAccountId());
|
||||
|
||||
// Return the result
|
||||
return event.getAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link Account} entity.
|
||||
*
|
||||
* @param account is the {@link Account} to create
|
||||
* @return the newly created {@link Account}
|
||||
*/
|
||||
@CacheEvict(cacheNames = "accounts", key = "#account.getAccountId().toString()")
|
||||
public Account createAccount(Account account) {
|
||||
|
||||
// Assert for uniqueness constraint
|
||||
Assert.isNull(accountRepository.findAccountByEmail(account.getEmail()),
|
||||
"An account with the supplied email already exists");
|
||||
|
||||
// Save the account to the repository
|
||||
account = accountRepository.saveAndFlush(account);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an {@link Account} entity for the supplied identifier.
|
||||
*
|
||||
* @param id is the unique identifier of a {@link Account} entity
|
||||
* @return an {@link Account} entity
|
||||
*/
|
||||
@Cacheable(cacheNames = "accounts", key = "#id.toString()")
|
||||
public Account getAccount(Long id) {
|
||||
return accountRepository.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an {@link Account} entity with the supplied identifier.
|
||||
*
|
||||
* @param id is the unique identifier of the {@link Account} entity
|
||||
* @param account is the {@link Account} containing updated fields
|
||||
* @return the updated {@link Account} entity
|
||||
*/
|
||||
@CachePut(cacheNames = "accounts", key = "#id.toString()")
|
||||
public Account updateAccount(Long id, Account account) {
|
||||
Assert.notNull(id, "Account id must be present in the resource URL");
|
||||
Assert.notNull(account, "Account request body cannot be null");
|
||||
|
||||
if (account.getAccountId() != null) {
|
||||
Assert.isTrue(Objects.equals(id, account.getAccountId()),
|
||||
"The account id in the request body must match the resource URL");
|
||||
} else {
|
||||
account.setAccountId(id);
|
||||
}
|
||||
|
||||
Assert.state(accountRepository.exists(id),
|
||||
"The account with the supplied id does not exist");
|
||||
|
||||
Account currentAccount = accountRepository.findOne(id);
|
||||
currentAccount.setEmail(account.getEmail());
|
||||
currentAccount.setFirstName(account.getFirstName());
|
||||
currentAccount.setLastName(account.getLastName());
|
||||
currentAccount.setStatus(account.getStatus());
|
||||
|
||||
return accountRepository.save(currentAccount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the {@link Account} with the supplied identifier.
|
||||
*
|
||||
* @param id is the unique identifier for the {@link Account}
|
||||
*/
|
||||
@CacheEvict(cacheNames = "accounts", key = "#id.toString()")
|
||||
public Boolean deleteAccount(Long id) {
|
||||
Assert.state(accountRepository.exists(id),
|
||||
"The account with the supplied id does not exist");
|
||||
this.accountRepository.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a new {@link AccountEvent} to the {@link Account} reference for the supplied identifier.
|
||||
*
|
||||
* @param accountId is the unique identifier for the {@link Account}
|
||||
* @param event is the {@link AccountEvent} to append to the {@link Account} entity
|
||||
* @return the newly appended {@link AccountEvent}
|
||||
*/
|
||||
public AccountEvent appendEvent(Long accountId, AccountEvent event) {
|
||||
return appendEvent(accountId, event, ConsistencyModel.ACID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a new {@link AccountEvent} to the {@link Account} reference for the supplied identifier.
|
||||
*
|
||||
* @param accountId is the unique identifier for the {@link Account}
|
||||
* @param event is the {@link AccountEvent} to append to the {@link Account} entity
|
||||
* @return the newly appended {@link AccountEvent}
|
||||
*/
|
||||
public AccountEvent appendEvent(Long accountId, AccountEvent event, ConsistencyModel consistencyModel) {
|
||||
Account account = getAccount(accountId);
|
||||
Assert.notNull(account, "The account with the supplied id does not exist");
|
||||
event.setAccount(account);
|
||||
event = eventService.createEvent(accountId, event);
|
||||
account.getEvents().add(event);
|
||||
accountRepository.saveAndFlush(account);
|
||||
eventService.raiseEvent(event, consistencyModel);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an {@link AccountCommand} to the {@link Account} with a specified identifier.
|
||||
*
|
||||
* @param id is the unique identifier of the {@link Account}
|
||||
* @param accountCommand is the command to apply to the {@link Account}
|
||||
* @return a hypermedia resource containing the updated {@link Account}
|
||||
*/
|
||||
@CachePut(cacheNames = "accounts", key = "#id.toString()")
|
||||
public Account applyCommand(Long id, AccountCommand accountCommand) {
|
||||
Account account = getAccount(id);
|
||||
|
||||
Assert.notNull(account, "The account for the supplied id could not be found");
|
||||
|
||||
AccountStatus status = account.getStatus();
|
||||
|
||||
switch (accountCommand) {
|
||||
case CONFIRM_ACCOUNT:
|
||||
Assert.isTrue(status == ACCOUNT_PENDING, "The account has already been confirmed");
|
||||
|
||||
// Confirm the account
|
||||
Account updateAccount = account;
|
||||
updateAccount.setStatus(ACCOUNT_CONFIRMED);
|
||||
this.updateAccount(id, updateAccount);
|
||||
this.appendEvent(id, new AccountEvent(AccountEventType.ACCOUNT_CONFIRMED))
|
||||
.getAccount();
|
||||
break;
|
||||
case ACTIVATE_ACCOUNT:
|
||||
Assert.isTrue(status != ACCOUNT_ACTIVE, "The account is already active");
|
||||
Assert.isTrue(Arrays.asList(ACCOUNT_CONFIRMED, ACCOUNT_SUSPENDED, ACCOUNT_ARCHIVED)
|
||||
.contains(status), "The account cannot be activated");
|
||||
|
||||
// Activate the account
|
||||
account.setStatus(ACCOUNT_ACTIVE);
|
||||
this.updateAccount(id, account);
|
||||
this.appendEvent(id, new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED))
|
||||
.getAccount();
|
||||
break;
|
||||
case SUSPEND_ACCOUNT:
|
||||
Assert.isTrue(status == ACCOUNT_ACTIVE, "An inactive account cannot be suspended");
|
||||
|
||||
// Suspend the account
|
||||
account.setStatus(ACCOUNT_SUSPENDED);
|
||||
account = this.updateAccount(id, account);
|
||||
this.appendEvent(id, new AccountEvent(AccountEventType.ACCOUNT_SUSPENDED));
|
||||
break;
|
||||
case ARCHIVE_ACCOUNT:
|
||||
Assert.isTrue(status == ACCOUNT_ACTIVE, "An inactive account cannot be archived");
|
||||
|
||||
// Archive the account
|
||||
account.setStatus(ACCOUNT_ARCHIVED);
|
||||
account = this.updateAccount(id, account);
|
||||
this.appendEvent(id, new AccountEvent(AccountEventType.ACCOUNT_ARCHIVED));
|
||||
break;
|
||||
default:
|
||||
Assert.notNull(accountCommand,
|
||||
"The provided command cannot be applied to this account in its current state");
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package demo.account;
|
||||
|
||||
/**
|
||||
* The {@link AccountStatus} describes the state of an {@link Account}.
|
||||
* The aggregate state of a {@link Account} is sourced from attached domain
|
||||
* events in the form of {@link demo.event.AccountEvent}.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public enum AccountStatus {
|
||||
ACCOUNT_CREATED,
|
||||
ACCOUNT_PENDING,
|
||||
ACCOUNT_CONFIRMED,
|
||||
ACCOUNT_ACTIVE,
|
||||
ACCOUNT_SUSPENDED,
|
||||
ACCOUNT_ARCHIVED
|
||||
}
|
||||
@@ -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("accounts", "events"));
|
||||
cacheManager.setUsePrefix(true);
|
||||
return cacheManager;
|
||||
}
|
||||
}
|
||||
13
account/account-web/src/main/java/demo/config/JpaConfig.java
Normal file
13
account/account-web/src/main/java/demo/config/JpaConfig.java
Normal 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 {
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package demo.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import demo.account.Account;
|
||||
import demo.domain.BaseEntity;
|
||||
|
||||
import javax.persistence.*;
|
||||
|
||||
/**
|
||||
* The domain event {@link AccountEvent} tracks the type and state of events as
|
||||
* applied to the {@link Account} domain object. This event resource can be used
|
||||
* to event source the aggregate state of {@link Account}.
|
||||
* <p>
|
||||
* This event resource also provides a transaction log that can be used to append
|
||||
* actions to the event.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
@Entity
|
||||
public class AccountEvent extends BaseEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private AccountEventType type;
|
||||
|
||||
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
@JsonIgnore
|
||||
private Account account;
|
||||
|
||||
public AccountEvent() {
|
||||
}
|
||||
|
||||
public AccountEvent(AccountEventType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Long getEventId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setEventId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public AccountEventType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(AccountEventType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Account getAccount() {
|
||||
return account;
|
||||
}
|
||||
|
||||
public void setAccount(Account account) {
|
||||
this.account = account;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AccountEvent{" +
|
||||
"id=" + id +
|
||||
", type=" + type +
|
||||
", account=" + account +
|
||||
"} " + super.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package demo.event;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
|
||||
/**
|
||||
* The {@link AccountEventType} represents a collection of possible events that describe
|
||||
* state transitions of {@link AccountStatus} on the {@link Account} aggregate.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public enum AccountEventType {
|
||||
ACCOUNT_CREATED,
|
||||
ACCOUNT_CONFIRMED,
|
||||
ACCOUNT_ACTIVATED,
|
||||
ACCOUNT_SUSPENDED,
|
||||
ACCOUNT_ARCHIVED
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package demo.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountController;
|
||||
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 AccountEvents} is a hypermedia collection of {@link AccountEvent} resources.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class AccountEvents extends Resources<AccountEvent> implements Serializable {
|
||||
|
||||
private Long accountId;
|
||||
|
||||
/**
|
||||
* Create a new {@link AccountEvents} hypermedia resources collection for an {@link Account}.
|
||||
*
|
||||
* @param accountId is the unique identifier for the {@link Account}
|
||||
* @param content is the collection of {@link AccountEvents} attached to the {@link Account}
|
||||
*/
|
||||
public AccountEvents(Long accountId, List<AccountEvent> content) {
|
||||
this(content);
|
||||
this.accountId = accountId;
|
||||
|
||||
// Add hypermedia links to resources parent
|
||||
add(linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(accountId)
|
||||
.slash("events")
|
||||
.withSelfRel(),
|
||||
linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(accountId)
|
||||
.withRel("account"));
|
||||
|
||||
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 AccountEvents(Iterable<AccountEvent> content, Link... links) {
|
||||
super(content, links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link Account} identifier that the {@link AccountEvents} apply to.
|
||||
*
|
||||
* @return the account identifier
|
||||
*/
|
||||
@JsonIgnore
|
||||
public Long getAccountId() {
|
||||
return accountId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package demo.event;
|
||||
|
||||
public enum ConsistencyModel {
|
||||
BASE,
|
||||
ACID
|
||||
}
|
||||
@@ -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 AccountEvent 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 AccountEvent 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));
|
||||
}
|
||||
}
|
||||
@@ -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<AccountEvent, Long> {
|
||||
Page<AccountEvent> findAccountEventsByAccountId(@Param("accountId") Long accountId, Pageable pageable);
|
||||
}
|
||||
214
account/account-web/src/main/java/demo/event/EventService.java
Normal file
214
account/account-web/src/main/java/demo/event/EventService.java
Normal file
@@ -0,0 +1,214 @@
|
||||
package demo.event;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountController;
|
||||
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 AccountEvent}
|
||||
* entities of the Account Service. Account domain events are generated with a {@link AccountEventType},
|
||||
* and action logs are appended to the {@link AccountEvent}.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
@Service
|
||||
@CacheConfig(cacheNames = {"events"})
|
||||
public class EventService {
|
||||
|
||||
private final Logger log = Logger.getLogger(EventService.class);
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
private final Source accountStreamSource;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public EventService(EventRepository eventRepository, Source accountStreamSource, RestTemplate restTemplate) {
|
||||
this.eventRepository = eventRepository;
|
||||
this.accountStreamSource = accountStreamSource;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link AccountEvent} and append it to the event log of the referenced {@link Account}.
|
||||
* After the {@link AccountEvent} has been persisted, send the event to the account stream. Events can
|
||||
* be raised as a blocking or non-blocking operation depending on the {@link ConsistencyModel}.
|
||||
*
|
||||
* @param accountId is the unique identifier for the {@link Account}
|
||||
* @param event is the {@link AccountEvent} to create
|
||||
* @param consistencyModel is the desired consistency model for the response
|
||||
* @return an {@link AccountEvent} that has been appended to the {@link Account}'s event log
|
||||
*/
|
||||
public AccountEvent createEvent(Long accountId, AccountEvent event, ConsistencyModel consistencyModel) {
|
||||
event = createEvent(accountId, event);
|
||||
return raiseEvent(event, consistencyModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise an {@link AccountEvent} that attempts to transition the state of an {@link Account}.
|
||||
*
|
||||
* @param event is an {@link AccountEvent} that will be raised
|
||||
* @param consistencyModel is the consistency model for this request
|
||||
* @return an {@link AccountEvent} that has been appended to the {@link Account}'s event log
|
||||
*/
|
||||
public AccountEvent raiseEvent(AccountEvent event, ConsistencyModel consistencyModel) {
|
||||
switch (consistencyModel) {
|
||||
case BASE:
|
||||
asyncRaiseEvent(event);
|
||||
break;
|
||||
case ACID:
|
||||
event = raiseEvent(event);
|
||||
break;
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise an asynchronous {@link AccountEvent} by sending an AMQP message to the account stream. Any
|
||||
* state changes will be applied to the {@link Account} 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 AccountEvent} that will be raised
|
||||
*/
|
||||
private void asyncRaiseEvent(AccountEvent event) {
|
||||
// Append the account event to the stream
|
||||
accountStreamSource.output()
|
||||
.send(MessageBuilder
|
||||
.withPayload(getAccountEventResource(event))
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise a synchronous {@link AccountEvent} by sending a HTTP request to the account 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 AccountEvent} that will be raised
|
||||
* @return an {@link AccountEvent} which contains the consistent state of an {@link Account}
|
||||
*/
|
||||
private AccountEvent raiseEvent(AccountEvent event) {
|
||||
try {
|
||||
// Create a new request entity
|
||||
RequestEntity<Resource<AccountEvent>> requestEntity = RequestEntity.post(
|
||||
URI.create("http://localhost:8081/v1/events"))
|
||||
.contentType(MediaTypes.HAL_JSON)
|
||||
.body(getAccountEventResource(event), Resource.class);
|
||||
|
||||
// Update the account entity's status
|
||||
Account result = restTemplate.exchange(requestEntity, Account.class)
|
||||
.getBody();
|
||||
|
||||
log.info(result);
|
||||
event.setAccount(result);
|
||||
} catch (Exception ex) {
|
||||
log.error(ex);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new {@link AccountEvent} and publish it to the account stream.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to publish to the account stream
|
||||
* @return a hypermedia {@link AccountEvent} resource
|
||||
*/
|
||||
@CacheEvict(cacheNames = "events", key = "#id.toString()")
|
||||
public AccountEvent createEvent(Long id, AccountEvent event) {
|
||||
// Save new event
|
||||
event = addEvent(event);
|
||||
Assert.notNull(event, "The event could not be appended to the account");
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an {@link AccountEvent} with the supplied identifier.
|
||||
*
|
||||
* @param id is the unique identifier for the {@link AccountEvent}
|
||||
* @return an {@link AccountEvent}
|
||||
*/
|
||||
public Resource<AccountEvent> getEvent(Long id) {
|
||||
return getAccountEventResource(eventRepository.findOne(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an {@link AccountEvent} with the supplied identifier.
|
||||
*
|
||||
* @param id is the unique identifier for the {@link AccountEvent}
|
||||
* @param event is the {@link AccountEvent} to update
|
||||
* @return the updated {@link AccountEvent}
|
||||
*/
|
||||
@CacheEvict(cacheNames = "events", key = "#event.getAccount().getAccountId().toString()")
|
||||
public AccountEvent updateEvent(Long id, AccountEvent event) {
|
||||
Assert.notNull(id);
|
||||
Assert.isTrue(event.getId() == null || Objects.equals(id, event.getId()));
|
||||
|
||||
return eventRepository.save(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get {@link AccountEvents} for the supplied {@link Account} identifier.
|
||||
*
|
||||
* @param id is the unique identifier of the {@link Account}
|
||||
* @return a list of {@link AccountEvent} wrapped in a hypermedia {@link AccountEvents} resource
|
||||
*/
|
||||
@Cacheable(cacheNames = "events", key = "#id.toString()")
|
||||
public List<AccountEvent> getAccountEvents(Long id) {
|
||||
return eventRepository.findAccountEventsByAccountId(id,
|
||||
new PageRequest(0, Integer.MAX_VALUE)).getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a hypermedia resource for a {@link AccountEvent} entity.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to enrich with hypermedia
|
||||
* @return a hypermedia resource for the supplied {@link AccountEvent} entity
|
||||
*/
|
||||
private Resource<AccountEvent> getAccountEventResource(AccountEvent event) {
|
||||
return new Resource<AccountEvent>(event, Arrays.asList(
|
||||
linkTo(AccountController.class)
|
||||
.slash("events")
|
||||
.slash(event.getEventId())
|
||||
.withSelfRel(),
|
||||
linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(event.getAccount().getAccountId())
|
||||
.withRel("account")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a {@link AccountEvent} to an {@link Account} entity.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to append to an {@link Account} entity
|
||||
* @return the newly appended {@link AccountEvent} entity
|
||||
*/
|
||||
@CacheEvict(cacheNames = "events", key = "#event.getAccount().getAccountId().toString()")
|
||||
private AccountEvent addEvent(AccountEvent event) {
|
||||
event = eventRepository.saveAndFlush(event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
17
account/account-web/src/main/resources/application.yml
Normal file
17
account/account-web/src/main/resources/application.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
spring:
|
||||
profiles:
|
||||
active: development
|
||||
---
|
||||
spring:
|
||||
profiles: development
|
||||
cloud:
|
||||
stream:
|
||||
bindings:
|
||||
output:
|
||||
destination: account
|
||||
contentType: 'application/json'
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
server:
|
||||
port: 8080
|
||||
4
account/account-web/src/main/resources/bootstrap.yml
Normal file
4
account/account-web/src/main/resources/bootstrap.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
spring:
|
||||
application:
|
||||
name: account-web
|
||||
---
|
||||
@@ -0,0 +1,43 @@
|
||||
package demo.account;
|
||||
|
||||
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(AccountController.class)
|
||||
public class AccountControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@MockBean
|
||||
private AccountService accountService;
|
||||
|
||||
@MockBean
|
||||
private EventService eventService;
|
||||
|
||||
@Test
|
||||
public void getUserAccountResourceShouldReturnAccount() throws Exception {
|
||||
String content = "{\"firstName\": \"Jane\", \"lastName\": \"Doe\", \"email\": \"jane.doe@example.com\"}";
|
||||
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
|
||||
given(this.accountService.getAccount(1L))
|
||||
.willReturn(account);
|
||||
|
||||
this.mvc.perform(get("/v1/accounts/1").accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk()).andExpect(content().json(content));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package demo.account;
|
||||
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
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 AccountServiceTests {
|
||||
|
||||
@MockBean
|
||||
private EventService eventService;
|
||||
|
||||
@MockBean
|
||||
private AccountRepository accountRepository;
|
||||
|
||||
@MockBean
|
||||
private CacheManager cacheManager;
|
||||
|
||||
private AccountService accountService;
|
||||
|
||||
@Before
|
||||
public void before() {
|
||||
accountService = new AccountService(accountRepository, eventService, cacheManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAccountReturnsAccount() throws Exception {
|
||||
Account expected = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(expected);
|
||||
|
||||
Account actual = accountService.getAccount(1L);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getEmail()).isEqualTo("jane.doe@example.com");
|
||||
assertThat(actual.getFirstName()).isEqualTo("Jane");
|
||||
assertThat(actual.getLastName()).isEqualTo("Doe");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createAccountReturnsAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setAccountId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.saveAndFlush(account)).willReturn(account);
|
||||
|
||||
Account actual = accountService.createAccount(account);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_CREATED);
|
||||
assertThat(actual.getEmail()).isEqualTo("jane.doe@example.com");
|
||||
assertThat(actual.getFirstName()).isEqualTo("Jane");
|
||||
assertThat(actual.getLastName()).isEqualTo("Doe");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandSuspendsAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_ACTIVE);
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_SUSPENDED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_SUSPENDED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.SUSPEND_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_SUSPENDED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandUnsuspendsAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_SUSPENDED);
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.ACTIVATE_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandArchivesAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_ACTIVE);
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_ARCHIVED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_ARCHIVED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.ARCHIVE_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_ARCHIVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandUnarchivesAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_ARCHIVED);
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.ACTIVATE_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandConfirmsAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_PENDING);
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_CONFIRMED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_CONFIRMED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.CONFIRM_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_CONFIRMED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandActivatesAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_CONFIRMED);
|
||||
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.ACTIVATE_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_ACTIVE);
|
||||
}
|
||||
}
|
||||
1
account/account-web/src/test/resources/data-h2.sql
Normal file
1
account/account-web/src/test/resources/data-h2.sql
Normal file
@@ -0,0 +1 @@
|
||||
INSERT INTO ACCOUNT(ID, FIRST_NAME, LAST_NAME, EMAIL) values (1, 'John', 'Doe', 'john.doe@example.com');
|
||||
Reference in New Issue
Block a user