From 5b26bb9362526baeb405d795bad454936c533eaa Mon Sep 17 00:00:00 2001 From: "Sergey G. Ivanov" Date: Sun, 31 Oct 2021 19:48:48 +0300 Subject: [PATCH] interview! --- pom.xml | 13 +++++ .../java/com/devexperts/account/Account.java | 42 ++++++++++++++++ .../com/devexperts/account/AccountKey.java | 13 +++++ .../rest/AbstractAccountController.java | 3 +- .../devexperts/rest/AccountController.java | 35 +++++++++++-- .../rest/pojo/TransferOperationDTO.java | 25 ++++++++++ .../rest/validation/AccountIdValidator.java | 26 ++++++++++ .../rest/validation/ExistingAccountId.java | 24 +++++++++ .../service/AccountServiceImpl.java | 49 ++++++++++++++++--- .../devexperts/service/TransferHelper.java | 12 +++++ src/main/resources/accounts.sql | 8 +++ src/main/resources/select.sql | 5 ++ src/main/resources/transfers.sql | 12 +++++ 13 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/devexperts/rest/pojo/TransferOperationDTO.java create mode 100644 src/main/java/com/devexperts/rest/validation/AccountIdValidator.java create mode 100644 src/main/java/com/devexperts/rest/validation/ExistingAccountId.java create mode 100644 src/main/java/com/devexperts/service/TransferHelper.java create mode 100644 src/main/resources/accounts.sql create mode 100644 src/main/resources/select.sql create mode 100644 src/main/resources/transfers.sql 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[] 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