diff --git a/pom.xml b/pom.xml
index a8ffa1a..0f7a615 100644
--- a/pom.xml
+++ b/pom.xml
@@ -95,6 +95,19 @@
spring-boot-starter-test
test
+
+ org.springframework
+ spring-web
+
+
+ org.projectlombok
+ lombok
+
+
+ org.apache.commons
+ commons-lang3
+
+
diff --git a/src/main/java/com/devexperts/account/Account.java b/src/main/java/com/devexperts/account/Account.java
index fb2a3af..ccc2bd2 100644
--- a/src/main/java/com/devexperts/account/Account.java
+++ b/src/main/java/com/devexperts/account/Account.java
@@ -1,6 +1,14 @@
package com.devexperts.account;
+import lombok.Builder;
+import lombok.Data;
+import lombok.Getter;
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+
+@Builder(builderMethodName = "of")
public class Account {
+
private final AccountKey accountKey;
private final String firstName;
private final String lastName;
@@ -32,4 +40,38 @@ public Double getBalance() {
public void setBalance(Double balance) {
this.balance = balance;
}
+
+ public void increaseBalance(double amount) {
+ this.balance += amount;
+ }
+
+ public void decreaseBalance(double amount){
+ this.balance -= amount;
+ }
+
+ public long getAccountId(){
+ return accountKey.getAccountId();
+ }
+
+ @Override
+ public String toString() {
+ return accountKey.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Account account = (Account) o;
+ return new EqualsBuilder()
+ .append(getAccountKey(), account.getAccountKey())
+ .isEquals();
+ }
+
+ @Override
+ public int hashCode() {
+ return new HashCodeBuilder(17, 37)
+ .append(getAccountKey())
+ .toHashCode();
+ }
}
diff --git a/src/main/java/com/devexperts/account/AccountKey.java b/src/main/java/com/devexperts/account/AccountKey.java
index 1b0a233..6ca2fcb 100644
--- a/src/main/java/com/devexperts/account/AccountKey.java
+++ b/src/main/java/com/devexperts/account/AccountKey.java
@@ -1,5 +1,7 @@
package com.devexperts.account;
+import lombok.EqualsAndHashCode;
+
/**
* Unique Account identifier
*
@@ -7,6 +9,8 @@
* NOTE: we suspect that later {@link #accountId} is not going to be uniquely identifying an account,
* as we might add human-readable account representation and some clearing codes for partners.
* */
+
+@EqualsAndHashCode
public class AccountKey {
private final long accountId;
@@ -17,4 +21,13 @@ private AccountKey(long accountId) {
public static AccountKey valueOf(long accountId) {
return new AccountKey(accountId);
}
+
+ public long getAccountId() {
+ return accountId;
+ }
+
+ @Override
+ public String toString() {
+ return "AccountKey{accountId=" + accountId + '}';
+ }
}
diff --git a/src/main/java/com/devexperts/rest/AbstractAccountController.java b/src/main/java/com/devexperts/rest/AbstractAccountController.java
index dea5a3c..9e96b51 100644
--- a/src/main/java/com/devexperts/rest/AbstractAccountController.java
+++ b/src/main/java/com/devexperts/rest/AbstractAccountController.java
@@ -1,7 +1,8 @@
package com.devexperts.rest;
+import com.devexperts.rest.pojo.TransferOperationDTO;
import org.springframework.http.ResponseEntity;
public abstract class AbstractAccountController {
- abstract ResponseEntity transfer(long sourceId, long targetId, double amount);
+ abstract ResponseEntity> transfer(TransferOperationDTO transferOperation);
}
diff --git a/src/main/java/com/devexperts/rest/AccountController.java b/src/main/java/com/devexperts/rest/AccountController.java
index b300282..0d30f42 100644
--- a/src/main/java/com/devexperts/rest/AccountController.java
+++ b/src/main/java/com/devexperts/rest/AccountController.java
@@ -1,14 +1,43 @@
package com.devexperts.rest;
+import com.devexperts.account.Account;
+import com.devexperts.rest.pojo.TransferOperationDTO;
+import com.devexperts.service.AccountService;
+import lombok.AllArgsConstructor;
+import lombok.extern.apachecommons.CommonsLog;
+import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+
+@CommonsLog
@RestController
-@RequestMapping("/api")
+@AllArgsConstructor
+@RequestMapping("/api/operations")
public class AccountController extends AbstractAccountController {
- public ResponseEntity transfer(long sourceId, long targetId, double amount) {
- return null;
+ private final AccountService accountService;
+
+ @PostMapping(value = "/transfer")
+ public ResponseEntity> transfer(@RequestBody @Valid @NotNull TransferOperationDTO transferOperation) {
+
+ Account source = accountService.getAccount(transferOperation.getSourceId()),
+ target = accountService.getAccount(transferOperation.getTargetId());
+
+ try {
+ accountService.transfer(source, target, transferOperation.getAmount());
+ } catch (RuntimeException exception) {
+ log.error("Couldn't complete transfer", exception);
+ throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, exception.getMessage(), exception);
+ }
+
+ log.info("Transfer complete.");
+ return ResponseEntity.ok("nice!");
}
}
diff --git a/src/main/java/com/devexperts/rest/pojo/TransferOperationDTO.java b/src/main/java/com/devexperts/rest/pojo/TransferOperationDTO.java
new file mode 100644
index 0000000..934ab58
--- /dev/null
+++ b/src/main/java/com/devexperts/rest/pojo/TransferOperationDTO.java
@@ -0,0 +1,25 @@
+package com.devexperts.rest.pojo;
+
+import com.devexperts.rest.validation.ExistingAccountId;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Positive;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class TransferOperationDTO {
+
+ @ExistingAccountId
+ private Long sourceId;
+
+ @ExistingAccountId
+ private Long targetId;
+
+ @NotNull
+ @Positive
+ private Double amount;
+}
diff --git a/src/main/java/com/devexperts/rest/validation/AccountIdValidator.java b/src/main/java/com/devexperts/rest/validation/AccountIdValidator.java
new file mode 100644
index 0000000..b09290f
--- /dev/null
+++ b/src/main/java/com/devexperts/rest/validation/AccountIdValidator.java
@@ -0,0 +1,26 @@
+package com.devexperts.rest.validation;
+
+import com.devexperts.service.AccountService;
+import lombok.AllArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+
+import javax.validation.ConstraintValidator;
+import javax.validation.ConstraintValidatorContext;
+
+@AllArgsConstructor
+public class AccountIdValidator implements ConstraintValidator {
+
+ private final AccountService accountService;
+
+ @Override
+ public boolean isValid(Long id, ConstraintValidatorContext context) {
+ if(id == null)
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Account id cannot be null");
+ else if(id <= 0)
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Account id must be above 0");
+ else if(accountService.getAccount(id) == null)
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, String.format("Account [%s] not found", id));
+ else return true;
+ }
+}
diff --git a/src/main/java/com/devexperts/rest/validation/ExistingAccountId.java b/src/main/java/com/devexperts/rest/validation/ExistingAccountId.java
new file mode 100644
index 0000000..1d15231
--- /dev/null
+++ b/src/main/java/com/devexperts/rest/validation/ExistingAccountId.java
@@ -0,0 +1,24 @@
+package com.devexperts.rest.validation;
+
+import javax.validation.Constraint;
+import javax.validation.Payload;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+@Documented
+@Retention(RUNTIME)
+@Target({FIELD, PARAMETER})
+@Constraint(validatedBy = AccountIdValidator.class)
+public @interface ExistingAccountId {
+
+ String message() default "Account not exists.";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+}
diff --git a/src/main/java/com/devexperts/service/AccountServiceImpl.java b/src/main/java/com/devexperts/service/AccountServiceImpl.java
index 91261ba..5bb4c45 100644
--- a/src/main/java/com/devexperts/service/AccountServiceImpl.java
+++ b/src/main/java/com/devexperts/service/AccountServiceImpl.java
@@ -4,13 +4,19 @@
import com.devexperts.account.AccountKey;
import org.springframework.stereotype.Service;
-import java.util.ArrayList;
+import javax.annotation.PostConstruct;
+import java.util.Arrays;
+import java.util.Comparator;
import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static com.devexperts.service.TransferHelper.validateBalance;
@Service
public class AccountServiceImpl implements AccountService {
- private final List accounts = new ArrayList<>();
+ private final Map accounts = new ConcurrentHashMap<>();
@Override
public void clear() {
@@ -19,19 +25,46 @@ public void clear() {
@Override
public void createAccount(Account account) {
- accounts.add(account);
+ accounts.put(account.getAccountKey(), account);
}
@Override
public Account getAccount(long id) {
- return accounts.stream()
- .filter(account -> account.getAccountKey() == AccountKey.valueOf(id))
- .findAny()
- .orElse(null);
+ return accounts.get(AccountKey.valueOf(id));
+ }
+
+
+ @PostConstruct
+ public void fillAccounts() {
+ Account
+ one = Account.of()
+ .accountKey(AccountKey.valueOf(1))
+ .balance(5d)
+ .firstName("Daisy")
+ .lastName("Duck")
+ .build(),
+ two = Account.of()
+ .accountKey(AccountKey.valueOf(2))
+ .balance(500000d)
+ .firstName("Donald")
+ .lastName("Duck")
+ .build();
+
+ accounts.put(one.getAccountKey(), one);
+ accounts.put(two.getAccountKey(), two);
}
@Override
public void transfer(Account source, Account target, double amount) {
- //do nothing for now
+ List locks = Arrays.asList(source, target); //consider ids sequential
+ locks.sort(Comparator.comparingLong(Account::getAccountId));
+
+ synchronized (locks.get(0)) {
+ synchronized (locks.get(1)) {
+ validateBalance(source, target, amount);
+ source.decreaseBalance(amount);
+ target.increaseBalance(amount);
+ }
+ }
}
}
diff --git a/src/main/java/com/devexperts/service/TransferHelper.java b/src/main/java/com/devexperts/service/TransferHelper.java
new file mode 100644
index 0000000..d87230e
--- /dev/null
+++ b/src/main/java/com/devexperts/service/TransferHelper.java
@@ -0,0 +1,12 @@
+package com.devexperts.service;
+
+import com.devexperts.account.Account;
+
+public class TransferHelper {
+ public static void validateBalance(Account source, Account target, double amount) throws RuntimeException{
+ if(source.getBalance() < amount)
+ throw new RuntimeException(String.format("[%s] has not enough money", source.getFirstName()));
+ if(source.equals(target))
+ throw new IllegalArgumentException("Cannot transfer to the same account");
+ }
+}
diff --git a/src/main/resources/accounts.sql b/src/main/resources/accounts.sql
new file mode 100644
index 0000000..026e67f
--- /dev/null
+++ b/src/main/resources/accounts.sql
@@ -0,0 +1,8 @@
+CREATE TABLE accounts
+(
+ id BIGINT NOT NULL,
+ first_name VARCHAR(255) NOT NULL,
+ last_name VARCHAR(255) NOT NULL,
+ balance BIGINT NOT NULL,
+ CONSTRAINT accounts_pkey2 PRIMARY KEY (id)
+);
\ No newline at end of file
diff --git a/src/main/resources/select.sql b/src/main/resources/select.sql
new file mode 100644
index 0000000..014eef3
--- /dev/null
+++ b/src/main/resources/select.sql
@@ -0,0 +1,5 @@
+select source.first_name, concat(coalesce(sum(transfer.amount), 0), ' $') as total_sent
+from accounts source inner join transfers transfer on source.id = transfer.source_id
+where transfer.transfer_time > '2011-01-01'
+group by source.id
+having coalesce(sum(transfer.amount), 0) > 1000;
\ No newline at end of file
diff --git a/src/main/resources/transfers.sql b/src/main/resources/transfers.sql
new file mode 100644
index 0000000..235f4ce
--- /dev/null
+++ b/src/main/resources/transfers.sql
@@ -0,0 +1,12 @@
+CREATE TABLE transfers
+(
+ id BIGINT NOT NULL,
+ source_id BIGINT NOT NULL,
+ target_id BIGINT NOT NULL,
+ amount BIGINT NOT NULL,
+ transfer_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ CONSTRAINT transfers_pkey2 PRIMARY KEY (id)
+);
+
+ALTER TABLE transfers ADD CONSTRAINT transfers_source_id FOREIGN KEY (source_id) REFERENCES accounts (id) ON UPDATE CASCADE ON DELETE RESTRICT;
+ALTER TABLE transfers ADD CONSTRAINT transfers_target_id FOREIGN KEY (target_id) REFERENCES accounts (id) ON UPDATE CASCADE ON DELETE RESTRICT;
\ No newline at end of file