├── src ├── main │ └── java │ │ ├── rest │ │ ├── ApiHandler.java │ │ ├── Service.java │ │ └── v1 │ │ │ ├── TransferAPI.java │ │ │ ├── Handler.java │ │ │ └── AccountsAPI.java │ │ ├── exceptions │ │ ├── IdenticalAccountsException.java │ │ ├── InvalidAmountException.java │ │ ├── InvalidParamsException.java │ │ ├── AccountNotExistsException.java │ │ └── TransactionFailedException.java │ │ ├── model │ │ ├── Message.java │ │ ├── Account.java │ │ └── Transaction.java │ │ ├── storage │ │ ├── Storage.java │ │ └── InMemoryStorage.java │ │ ├── util │ │ ├── ResponseWith.java │ │ └── Validators.java │ │ └── Main.java └── test │ └── java │ ├── storage │ ├── InMemoryStorageStressTest.java │ └── InMemoryStorageTest.java │ └── api │ └── ServiceApiV1Test.java ├── .gitignore ├── README.md └── pom.xml /src/main/java/rest/ApiHandler.java: -------------------------------------------------------------------------------- 1 | package rest; 2 | 3 | public interface ApiHandler { 4 | } 5 | -------------------------------------------------------------------------------- /src/main/java/exceptions/IdenticalAccountsException.java: -------------------------------------------------------------------------------- 1 | package exceptions; 2 | 3 | public class IdenticalAccountsException extends RuntimeException { 4 | public IdenticalAccountsException() { 5 | super(String.format("Account 'from' and 'to' are identical")); 6 | } 7 | } -------------------------------------------------------------------------------- /src/main/java/exceptions/InvalidAmountException.java: -------------------------------------------------------------------------------- 1 | package exceptions; 2 | 3 | public class InvalidAmountException extends RuntimeException { 4 | public InvalidAmountException(float amount) { 5 | super(String.format("Amount %f is invalid", amount)); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/exceptions/InvalidParamsException.java: -------------------------------------------------------------------------------- 1 | package exceptions; 2 | 3 | public class InvalidParamsException extends RuntimeException { 4 | public InvalidParamsException(String message) { 5 | super(String.format("Invalid params: %s", message)); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/model/Message.java: -------------------------------------------------------------------------------- 1 | package model; 2 | 3 | import lombok.*; 4 | import java.util.Optional; 5 | 6 | @Builder 7 | public class Message { 8 | private String status; 9 | @Builder.Default 10 | private Optional message = Optional.empty(); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/exceptions/AccountNotExistsException.java: -------------------------------------------------------------------------------- 1 | package exceptions; 2 | 3 | public class AccountNotExistsException extends RuntimeException { 4 | public AccountNotExistsException(String id) { 5 | super(String.format("Account id %s does not exists", id)); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/exceptions/TransactionFailedException.java: -------------------------------------------------------------------------------- 1 | package exceptions; 2 | 3 | public class TransactionFailedException extends RuntimeException { 4 | public TransactionFailedException(String message) { 5 | super(String.format("Transaction failed: %s", message)); 6 | } 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/main/java/model/Account.java: -------------------------------------------------------------------------------- 1 | package model; 2 | 3 | import lombok.*; 4 | 5 | @Getter 6 | @Setter 7 | public class Account { 8 | public Account(String id, long balance) { 9 | this.id = id; 10 | this.balance = balance; 11 | } 12 | 13 | private String id; 14 | long balance; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/storage/Storage.java: -------------------------------------------------------------------------------- 1 | package storage; 2 | 3 | import model.Account; 4 | import model.Transaction; 5 | 6 | import java.util.List; 7 | 8 | public interface Storage { 9 | Account get(String id); 10 | List getAll(); 11 | boolean create(Account account); 12 | 13 | void transfer(Transaction transaction); 14 | 15 | void clear(); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/rest/Service.java: -------------------------------------------------------------------------------- 1 | package rest; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import storage.Storage; 6 | import storage.InMemoryStorage; 7 | import rest.v1.Handler; 8 | import static spark.Spark.port; 9 | 10 | public class Service { 11 | private final Storage storage = new InMemoryStorage(); 12 | private final Logger logger = LoggerFactory.getLogger(Service.class); 13 | 14 | public Service(int servicePort) { 15 | logger.info("Starting REST service"); 16 | port(servicePort); 17 | var apiV1 = new rest.v1.Handler(storage); 18 | } 19 | } -------------------------------------------------------------------------------- /src/main/java/util/ResponseWith.java: -------------------------------------------------------------------------------- 1 | package util; 2 | 3 | import model.Message; 4 | 5 | import java.util.Optional; 6 | 7 | import spark.Response; 8 | 9 | public class ResponseWith { 10 | 11 | public static Message Error(Response response, String message) { 12 | response.status(400); 13 | return Message.builder().status("error").message(Optional.of(message)).build(); 14 | } 15 | 16 | public static Message NotFound(Response response) { 17 | response.status(404); 18 | return Message.builder().status("error").message(Optional.of("Not found")).build(); 19 | } 20 | 21 | public static Message Ok(Response response) { 22 | response.status(200); 23 | return Message.builder().status("ok").build(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/model/Transaction.java: -------------------------------------------------------------------------------- 1 | package model; 2 | 3 | import lombok.Value; 4 | import lombok.EqualsAndHashCode; 5 | 6 | @Value 7 | @EqualsAndHashCode 8 | public class Transaction { 9 | public enum Status { 10 | FAILED, 11 | PERFORMING, 12 | SUCCESS 13 | } 14 | 15 | public Transaction(String from, String to, long amount) { 16 | this.from = from; 17 | this.to = to; 18 | this.amount = amount; 19 | this.idempotencyKey = null; 20 | } 21 | 22 | public Transaction(String from, String to, long amount, String idempotencyKey) { 23 | this.from = from; 24 | this.to = to; 25 | this.amount = amount; 26 | this.idempotencyKey = idempotencyKey; 27 | } 28 | 29 | String from; 30 | String to; 31 | long amount; 32 | String idempotencyKey; 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/Main.java: -------------------------------------------------------------------------------- 1 | import rest.Service; 2 | 3 | import org.apache.commons.cli.*; 4 | 5 | public class Main { 6 | public static void main(String[] args) { 7 | Options options = new Options(); 8 | options.addOption(new Option("p", "port", true, "service port")); 9 | 10 | CommandLineParser parser = new DefaultParser(); 11 | HelpFormatter formatter = new HelpFormatter(); 12 | CommandLine cmd; 13 | Integer port = 0; 14 | 15 | try { 16 | cmd = parser.parse(options, args); 17 | port = Integer.parseInt(cmd.getOptionValue("port", "8080")); 18 | 19 | } catch (Exception e) { 20 | System.out.println(e.getMessage()); 21 | formatter.printHelp("money-transfer", options); 22 | 23 | System.exit(1); 24 | } 25 | 26 | Service service = new Service(port); 27 | } 28 | } -------------------------------------------------------------------------------- /src/main/java/rest/v1/TransferAPI.java: -------------------------------------------------------------------------------- 1 | package rest.v1; 2 | 3 | import model.Transaction; 4 | import storage.Storage; 5 | import util.ResponseWith; 6 | import util.Validators; 7 | 8 | import spark.Request; 9 | import spark.Response; 10 | import com.google.gson.Gson; 11 | 12 | public class TransferAPI { 13 | private final Storage storage; 14 | private final Gson gson = new Gson(); 15 | 16 | public TransferAPI(Storage storage) { 17 | this.storage = storage; 18 | } 19 | 20 | public Object transfer(Request request, Response response) { 21 | try { 22 | Transaction transaction = gson.fromJson(request.body(), Transaction.class); 23 | Validators.validateTransaction(transaction); 24 | 25 | storage.transfer(transaction); 26 | } catch (Exception e) { 27 | return ResponseWith.Error(response, e.getMessage()); 28 | } 29 | 30 | return ResponseWith.Ok(response); 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/java/rest/v1/Handler.java: -------------------------------------------------------------------------------- 1 | package rest.v1; 2 | 3 | import rest.ApiHandler; 4 | import storage.Storage; 5 | 6 | import com.google.gson.Gson; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | 10 | import static spark.Spark.*; 11 | import static spark.Spark.post; 12 | 13 | public class Handler implements ApiHandler { 14 | private final Gson gson = new Gson(); 15 | private final Logger logger = LoggerFactory.getLogger(ApiHandler.class); 16 | 17 | public Handler(Storage storage) { 18 | logger.info("Create accounts API v1"); 19 | AccountsAPI accountsApi = new rest.v1.AccountsAPI(storage); 20 | path("/api/v1/", () -> { 21 | before((req, res) -> { 22 | res.type("application/json"); 23 | }); 24 | get("/accounts", accountsApi::List, gson::toJson); 25 | get("/accounts/:id", accountsApi::Get, gson::toJson); 26 | post("/accounts", accountsApi::Create, gson::toJson); 27 | }); 28 | 29 | logger.info("Create transfers API v1"); 30 | TransferAPI transfersApi = new rest.v1.TransferAPI(storage); 31 | path("/api/v1/", () -> { 32 | before((req, res) -> { 33 | res.type("application/json"); 34 | }); 35 | post("/transfer", transfersApi::transfer, gson::toJson); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/util/Validators.java: -------------------------------------------------------------------------------- 1 | package util; 2 | 3 | import model.Account; 4 | import model.Transaction; 5 | 6 | import exceptions.*; 7 | 8 | public class Validators { 9 | public static void validateTransaction(Transaction transaction) { 10 | if (transaction.getFrom() == null || transaction.getFrom().isEmpty()) { 11 | throw new InvalidParamsException("param 'getFrom' is empty"); 12 | } 13 | if (transaction.getTo() == null || transaction.getTo().isEmpty()) { 14 | throw new InvalidParamsException("prarm 'to' is empty"); 15 | } 16 | 17 | if (transaction.getFrom().equals(transaction.getTo())) { 18 | throw new IdenticalAccountsException(); 19 | } 20 | if (!(transaction.getAmount() > 0)) { 21 | throw new InvalidAmountException(transaction.getAmount()); 22 | } 23 | } 24 | 25 | public static void validateBalance(Account account, Transaction transaction) { 26 | if (account.getBalance() < transaction.getAmount()) { 27 | throw new TransactionFailedException("Insufficent balance on account"); 28 | } 29 | } 30 | 31 | public static void validateMaxBalance(Account account, Transaction transaction) { 32 | var maxAmount = Long.MAX_VALUE - account.getBalance(); 33 | if (transaction.getAmount() > maxAmount) { 34 | throw new TransactionFailedException("Resulting amount is more than the maximum possible balance."); 35 | } 36 | } 37 | 38 | public static void validateAccount(String id, Account account) { 39 | if (account == null) { 40 | throw new AccountNotExistsException(id); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/rest/v1/AccountsAPI.java: -------------------------------------------------------------------------------- 1 | package rest.v1; 2 | 3 | import model.Account; 4 | import storage.Storage; 5 | import util.ResponseWith; 6 | 7 | import spark.Request; 8 | import spark.Response; 9 | 10 | import com.google.gson.Gson; 11 | 12 | public class AccountsAPI { 13 | private final Storage storage; 14 | private final Gson gson = new Gson(); 15 | 16 | public AccountsAPI(Storage storage) { 17 | this.storage = storage; 18 | } 19 | 20 | public Object List(Request request, Response response) { 21 | return storage.getAll(); 22 | } 23 | 24 | public Object Create(Request request, Response response) { 25 | try { 26 | Account account = gson.fromJson(request.body(), Account.class); 27 | 28 | if (account.getId() == null || account.getId().isEmpty()) { 29 | return ResponseWith.Error(response, "Id empty"); 30 | } 31 | 32 | if (!storage.create(account)) { 33 | return ResponseWith.Error(response, String.format("Account id %s already exists", account.getId())); 34 | } 35 | 36 | response.header("Location", "/api/v1/accounts/"+account.getId()); 37 | return ResponseWith.Ok(response); 38 | } catch(Exception e) { 39 | return ResponseWith.Error(response, String.format("Error: %s", e.toString())); 40 | } 41 | } 42 | 43 | public Object Get(Request request, Response response) { 44 | String id = request.params(":id"); 45 | Account account = storage.get(id); 46 | 47 | if (account == null) { 48 | return ResponseWith.NotFound(response); 49 | } 50 | 51 | return account; 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 2 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 3 | 4 | # User-specific stuff 5 | .idea 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # Generated files 13 | .idea/**/contentModel.xml 14 | 15 | # Sensitive or high-churn files 16 | .idea/**/dataSources/ 17 | .idea/**/dataSources.ids 18 | .idea/**/dataSources.local.xml 19 | .idea/**/sqlDataSources.xml 20 | .idea/**/dynamic.xml 21 | .idea/**/uiDesigner.xml 22 | .idea/**/dbnavigator.xml 23 | 24 | # Gradle 25 | .idea/**/gradle.xml 26 | .idea/**/libraries 27 | 28 | # Gradle and Maven with auto-import 29 | # When using Gradle or Maven with auto-import, you should exclude module files, 30 | # since they will be recreated, and may cause churn. Uncomment if using 31 | # auto-import. 32 | # .idea/modules.xml 33 | # .idea/*.iml 34 | # .idea/modules 35 | # *.iml 36 | # *.ipr 37 | 38 | # CMake 39 | cmake-build-*/ 40 | 41 | # Mongo Explorer plugin 42 | .idea/**/mongoSettings.xml 43 | 44 | # File-based project format 45 | *.iws 46 | 47 | # IntelliJ 48 | out/ 49 | 50 | # mpeltonen/sbt-idea plugin 51 | .idea_modules/ 52 | 53 | # JIRA plugin 54 | atlassian-ide-plugin.xml 55 | 56 | # Cursive Clojure plugin 57 | .idea/replstate.xml 58 | 59 | # Crashlytics plugin (for Android Studio and IntelliJ) 60 | com_crashlytics_export_strings.xml 61 | crashlytics.properties 62 | crashlytics-build.properties 63 | fabric.properties 64 | 65 | # Editor-based Rest Client 66 | .idea/httpRequests 67 | 68 | # Android studio 3.1+ serialized cache file 69 | .idea/caches/build_file_checksums.ser 70 | -------------------------------------------------------------------------------- /src/test/java/storage/InMemoryStorageStressTest.java: -------------------------------------------------------------------------------- 1 | package storage; 2 | 3 | import model.Account; 4 | import model.Transaction; 5 | 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.DisplayName; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.function.Executable; 10 | 11 | import java.util.stream.IntStream; 12 | import java.util.concurrent.atomic.AtomicLong; 13 | 14 | import static org.junit.jupiter.api.Assertions.*; 15 | 16 | class InMemoryStorageStressTest { 17 | 18 | private Storage storage; 19 | 20 | @BeforeEach 21 | void setup() { 22 | storage = new InMemoryStorage(); 23 | } 24 | 25 | @Test 26 | @DisplayName("A lot of parallel transactions") 27 | void ParallelTransactions() { 28 | storage.create(new Account("1", 10000)); 29 | storage.create(new Account("2", 0)); 30 | 31 | Executable transfer = () -> 32 | IntStream.range(1, 101).parallel().forEach(value -> { 33 | storage.transfer(new Transaction("1", "2", value)); 34 | }); 35 | 36 | assertDoesNotThrow(transfer); 37 | 38 | assertEquals(10000 - 5050, storage.get("1").getBalance()); 39 | assertEquals(5050, storage.get("2").getBalance()); 40 | } 41 | 42 | @Test 43 | @DisplayName("A lot of parallel transactions and some can lead to error") 44 | void ParallelTransactionsWithErrors() { 45 | storage.create(new Account("1", 1000)); 46 | storage.create(new Account("2", 0)); 47 | 48 | var failedOperations = new AtomicLong(0); 49 | Executable transfer = () -> 50 | IntStream.range(0, 200).parallel().forEach(value -> { 51 | try { 52 | storage.transfer(new Transaction("1", "2", 10)); 53 | } catch (Exception e) { 54 | failedOperations.incrementAndGet(); 55 | } 56 | }); 57 | 58 | assertDoesNotThrow(transfer); 59 | 60 | assertEquals(0, storage.get("1").getBalance()); 61 | assertEquals(1000, storage.get("2").getBalance()); 62 | assertEquals(100, failedOperations.get()); 63 | } 64 | 65 | @Test 66 | @DisplayName("A lot of parallel transactions with same idempotency key") 67 | void ParallelTransactionsWithSameIdempotencyKey() { 68 | storage.create(new Account("1", 10000)); 69 | storage.create(new Account("2", 0)); 70 | 71 | Executable transfer = () -> 72 | IntStream.range(1, 101).parallel().forEach(value -> { 73 | storage.transfer(new Transaction("1", "2", 100, "equalIdempotencyKey")); 74 | }); 75 | 76 | assertDoesNotThrow(transfer); 77 | 78 | assertEquals(10000 - 100, storage.get("1").getBalance()); 79 | assertEquals(100, storage.get("2").getBalance()); 80 | } 81 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Money transfer 2 | 3 | Simple money transfer rest service. 4 | 5 | ## Notes: 6 | 7 | Trying to stick to the principle of "the simpler the better", I did not use different database implementations, 8 | but made a simple storage based on ConcurrentHashMap. The design is also simple, but have some extensibility - 9 | versioning APIs. I also believe that transfer API must supports idempotency for safely retrying requests without 10 | accidentally performing the same operation twice, so I added optional idempotencyKey field to transaction request. 11 | 12 | Used [sparkjava](http://sparkjava.com) for implementing REST service, [gson](https://github.com/google/gson) 13 | for working with JSON, [lombok](https://projectlombok.org/) for slightly easier setters and getters, 14 | [Commons Cli](https://commons.apache.org/proper/commons-cli/) for command line argument passing, 15 | [Jersey](https://jersey.github.io/) and [JUnit](https://junit.org) for unit and functional testing. 16 | 17 | ## Build: 18 | ``` 19 | mvn clean package 20 | ``` 21 | ## Run: 22 | - Run the app, default port 8080: 23 | ``` 24 | java -jar target/money-transfer-1.0-SNAPSHOT.jar 25 | ``` 26 | 27 | - Run the app, specify the port: 28 | ``` 29 | java -jar target/money-transfer-1.0-SNAPSHOT.jar -p 6666 30 | ``` 31 | 32 | - Run Test Cases: 33 | ``` 34 | mvn test 35 | ``` 36 | 37 | ## API 38 | 39 | ### Available methods 40 | 41 | | HTTP METHOD | PATH | USAGE | 42 | | -----------| ------ | ------ | 43 | | POST | /api/v1/accounts | create a new account 44 | | GET | /api/v1/accounts/{accountId} | get account by Id | 45 | | GET | /api/v1/accounts/ | get all acounts information | 46 | | POST | /api/transfer | perform transaction between 2 accounts | 47 | 48 | ### Methods description 49 | #### Create account: 50 | ##### Request: 51 | ```sh 52 | POST /api/v1/accounts 53 | ``` 54 | ```sh 55 | { 56 | "id":"someid", 57 | "balance":100 58 | } 59 | ``` 60 | 61 | - **id** _(required)_- some unique string with account id. 62 | - **balance** - a positive integer in cents representing start balance on account, default 0. 63 | 64 | ##### Response: 65 | ```sh 66 | Header: 67 | "Location":"http://localhost:8080/api/v1/account/someid" 68 | { 69 | "status": "ok", 70 | "message": {} 71 | } 72 | ``` 73 | 74 | #### Get account: 75 | ##### Request: 76 | ```sh 77 | GET /api/v1/accounts/someid 78 | ``` 79 | 80 | ##### Response: 81 | ```sh 82 | { 83 | "id":"someid", 84 | "balance":100 85 | } 86 | ``` 87 | 88 | #### Get all accounts: 89 | ##### Request: 90 | ```sh 91 | GET /api/v1/accounts 92 | ``` 93 | 94 | ##### Response: 95 | ```sh 96 | [ 97 | { 98 | "id":"someid1", 99 | "balance":100 100 | }, 101 | { 102 | "id":"someid2", 103 | "balance":1000 104 | } 105 | ] 106 | ``` 107 | 108 | #### Transaction: 109 | ##### Request: 110 | ```sh 111 | { 112 | "from":"some-id", 113 | "to":"someid", 114 | "amount":100, 115 | "idempotencyKey":"some-key" 116 | } 117 | ``` 118 | 119 | ##### Response: 120 | ```sh 121 | { 122 | "status": "ok", 123 | "message": {} 124 | } 125 | ``` 126 | 127 | - **from** _(required)_ - id to charge. 128 | - **to** _(required)_ - id to transfer moeny. 129 | - **amount** _(required)_ - a positive integer in cents representing amount to transfer. 130 | - **idempotencyKey** - transfer API supports idempotency for safely retrying requests without accidentally performing 131 | the same operation twice. -------------------------------------------------------------------------------- /src/test/java/storage/InMemoryStorageTest.java: -------------------------------------------------------------------------------- 1 | package storage; 2 | 3 | import model.Account; 4 | import model.Transaction; 5 | import exceptions.*; 6 | 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.DisplayName; 9 | import org.junit.jupiter.api.Test; 10 | import org.junit.jupiter.api.function.Executable; 11 | 12 | import java.util.Collections; 13 | import java.util.List; 14 | 15 | import static org.junit.jupiter.api.Assertions.*; 16 | 17 | class InMemoryStorageTest { 18 | 19 | private Storage storage; 20 | 21 | @BeforeEach 22 | void setup() { 23 | storage = new InMemoryStorage(); 24 | } 25 | 26 | @Test 27 | @DisplayName("Basic create and get account") 28 | void createAndGet() { 29 | Account account = new Account("1", 100); 30 | storage.create(account); 31 | assertEquals(account, storage.get("1")); 32 | } 33 | 34 | @Test 35 | @DisplayName("Get all accounts") 36 | void getAll() { 37 | Account account1 = new Account("1", 100); 38 | storage.create(account1); 39 | Account account2 = new Account("2", 200); 40 | storage.create(account2); 41 | 42 | List result = storage.getAll(); 43 | assertEquals(List.of(account1, account2), result); 44 | } 45 | 46 | @Test 47 | @DisplayName("Basic transfer operation") 48 | void transfer() { 49 | Account account1 = new Account("1", 100); 50 | storage.create(account1); 51 | Account account2 = new Account("2", 200); 52 | storage.create(account2); 53 | 54 | storage.transfer(new Transaction("2", "1", 59, "key1")); 55 | 56 | assertEquals(159, storage.get("1").getBalance()); 57 | assertEquals(141, storage.get("2").getBalance()); 58 | } 59 | 60 | @Test 61 | @DisplayName("Using idempotency key twice") 62 | void transferIdempotancyNoError() { 63 | Account account1 = new Account("id1", 100); 64 | storage.create(account1); 65 | Account account2 = new Account("id2", 200); 66 | storage.create(account2); 67 | 68 | storage.transfer(new Transaction("id2", "id1", 10, "key1")); 69 | 70 | assertEquals(190, storage.get("id2").getBalance()); 71 | assertEquals(110, storage.get("id1").getBalance()); 72 | 73 | Executable makeTransfer = 74 | () -> storage.transfer(new Transaction("id2", "id1", 10, "key1")); 75 | 76 | assertDoesNotThrow(makeTransfer); 77 | 78 | assertEquals(190, storage.get("id2").getBalance()); 79 | assertEquals(110, storage.get("id1").getBalance()); 80 | } 81 | 82 | @Test 83 | @DisplayName("Transfer validation errors") 84 | void transferValidationErrors() { 85 | Account account1 = new Account("id1", 100); 86 | storage.create(account1); 87 | Account account2 = new Account("id2", 200); 88 | storage.create(account2); 89 | 90 | Executable makeTransferFromNonExistingAccount = 91 | () -> storage.transfer(new Transaction("id3", "id1", 10, "key1")); 92 | 93 | assertThrows(AccountNotExistsException.class, makeTransferFromNonExistingAccount); 94 | 95 | Executable makeTransferToNonExistingAccount = 96 | () -> storage.transfer(new Transaction("id2", "id3", 10, "key1")); 97 | 98 | assertThrows(AccountNotExistsException.class, makeTransferToNonExistingAccount); 99 | 100 | Executable makeInsufficientBalanceTransfer = 101 | () -> storage.transfer(new Transaction("id2", "id1", 1000, "key1")); 102 | 103 | assertThrows(TransactionFailedException.class, makeInsufficientBalanceTransfer); 104 | 105 | Executable makeOverflowTransfer = 106 | () -> storage.transfer(new Transaction("id2", "id1", Long.MAX_VALUE, "key1")); 107 | 108 | assertThrows(TransactionFailedException.class, makeOverflowTransfer); 109 | } 110 | 111 | @Test 112 | @DisplayName("Clear storage") 113 | void clear() { 114 | Account account1 = new Account("id1", 100); 115 | storage.create(account1); 116 | Account account2 = new Account("id2", 200); 117 | storage.create(account2); 118 | 119 | storage.clear(); 120 | 121 | assertEquals(Collections.EMPTY_LIST, storage.getAll()); 122 | } 123 | } -------------------------------------------------------------------------------- /src/main/java/storage/InMemoryStorage.java: -------------------------------------------------------------------------------- 1 | package storage; 2 | 3 | import model.Account; 4 | import model.Transaction; 5 | 6 | import java.util.List; 7 | import java.util.Map; 8 | import java.util.concurrent.ConcurrentHashMap; 9 | import java.util.stream.Collectors; 10 | 11 | import exceptions.*; 12 | import util.Validators; 13 | 14 | public class InMemoryStorage implements Storage { 15 | private final Map accounts = new ConcurrentHashMap<>(); 16 | private final Map operationsLog = new ConcurrentHashMap<>(); 17 | 18 | @Override 19 | public boolean create(Account account) { 20 | return accounts.putIfAbsent(account.getId(), account) == null; 21 | } 22 | 23 | @Override 24 | public Account get(String id) { 25 | return accounts.get(id); 26 | } 27 | 28 | @Override 29 | public List getAll() { 30 | List listOfAccounts = 31 | accounts.entrySet() 32 | .stream() 33 | .map(e -> e.getValue()) 34 | .sorted((lh,rh)-> lh.getId().compareTo(rh.getId())) 35 | .collect(Collectors.toList()); 36 | return listOfAccounts; 37 | } 38 | 39 | @Override 40 | public void transfer(Transaction transaction) { 41 | if (idempotencyKeyPresent(transaction)) { 42 | /* This part of logic one can find a litle bit complicated. But for idempotency we need to 43 | store transaction status, and if some transaction already present, return the same result, 44 | as it was on first operation. If transaction is still performing, lets wait for it result. 45 | */ 46 | var value = operationsLog.putIfAbsent(transaction, Transaction.Status.PERFORMING); 47 | // wait for result of performing transaction 48 | while (value == Transaction.Status.PERFORMING) { 49 | try { 50 | Thread.sleep(100); 51 | } catch (Exception e) { 52 | break; 53 | } 54 | value = operationsLog.get(transaction); 55 | } 56 | if (value == Transaction.Status.SUCCESS) { 57 | // if transaction was successful just return 58 | return; 59 | } else if (value == Transaction.Status.FAILED) { 60 | // if previous transaction failed return error 61 | throw new TransactionFailedException("Transaction with this idempotency parameters falied"); 62 | } 63 | } 64 | 65 | try { 66 | var accountFrom = accounts.get(transaction.getFrom()); 67 | Validators.validateAccount(transaction.getFrom(), accountFrom); 68 | var accountTo = accounts.get(transaction.getTo()); 69 | Validators.validateAccount(transaction.getTo(), accountTo); 70 | 71 | Account minAcc, maxAcc; 72 | if (accountFrom.getId().compareTo(accountTo.getId()) < 0) { 73 | minAcc = accountFrom; 74 | maxAcc = accountTo; 75 | } else { 76 | minAcc = accountTo; 77 | maxAcc = accountFrom; 78 | } 79 | 80 | synchronized (minAcc) { 81 | synchronized (maxAcc) { 82 | Validators.validateBalance(accountFrom, transaction); 83 | Validators.validateMaxBalance(accountTo, transaction); 84 | 85 | if (!idempotencyKeyPresent(transaction) || 86 | operationsLog.replace(transaction, Transaction.Status.PERFORMING, Transaction.Status.SUCCESS)) { 87 | accountFrom.setBalance(accountFrom.getBalance() - transaction.getAmount()); 88 | accountTo.setBalance(accountTo.getBalance() + transaction.getAmount()); 89 | } else { 90 | // we cannot be in this situation by contact above 91 | throw new TransactionFailedException("Some service indepotency issue"); 92 | } 93 | } 94 | } 95 | } catch (Exception e) { 96 | operationsLog.replace(transaction, Transaction.Status.PERFORMING, Transaction.Status.FAILED); 97 | throw e; 98 | } 99 | } 100 | 101 | @Override 102 | public void clear() { 103 | accounts.clear(); 104 | operationsLog.clear(); 105 | } 106 | 107 | private boolean idempotencyKeyPresent(Transaction transaction) { 108 | return transaction.getIdempotencyKey() != null && !transaction.getIdempotencyKey().isEmpty(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | com.github.movb 8 | money-transfer 9 | 1.0-SNAPSHOT 10 | 11 | 12 | 13 | org.apache.maven.plugins 14 | maven-compiler-plugin 15 | 16 | 11 17 | 11 18 | 19 | 20 | 21 | 22 | org.codehaus.mojo 23 | exec-maven-plugin 24 | 1.2.1 25 | 26 | 27 | 28 | java 29 | 30 | 31 | 32 | 33 | Main 34 | 35 | 36 | 37 | 38 | maven-assembly-plugin 39 | 40 | 41 | 42 | Main 43 | 44 | 45 | 46 | jar-with-dependencies 47 | 48 | money-transfer-${project.version} 49 | false 50 | 51 | 52 | 53 | make-assembly 54 | package 55 | 56 | single 57 | 58 | 59 | 60 | 61 | 62 | 63 | maven-dependency-plugin 64 | 2.8 65 | 66 | 67 | analyze 68 | 69 | analyze-only 70 | 71 | 72 | true 73 | true 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 1.11 83 | 1.11 84 | 85 | 86 | 87 | 88 | 89 | com.sparkjava 90 | spark-core 91 | 2.9.1 92 | 93 | 94 | 95 | com.google.code.gson 96 | gson 97 | 2.8.0 98 | 99 | 100 | 101 | org.projectlombok 102 | lombok 103 | 1.18.8 104 | 105 | 106 | 107 | commons-cli 108 | commons-cli 109 | 1.4 110 | 111 | 112 | 113 | org.glassfish.jersey.core 114 | jersey-client 115 | 2.29 116 | 117 | 118 | 119 | org.glassfish.jersey.inject 120 | jersey-hk2 121 | 2.29 122 | 123 | 124 | 125 | org.junit.jupiter 126 | junit-jupiter-engine 127 | 5.4.2 128 | test 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /src/test/java/api/ServiceApiV1Test.java: -------------------------------------------------------------------------------- 1 | package api; 2 | 3 | import model.Account; 4 | import org.junit.jupiter.api.*; 5 | import rest.v1.Handler; 6 | import spark.Spark; 7 | import storage.InMemoryStorage; 8 | import storage.Storage; 9 | 10 | import java.net.URI; 11 | 12 | import javax.ws.rs.client.ClientBuilder; 13 | import javax.ws.rs.client.Client; 14 | import javax.ws.rs.client.Entity; 15 | import javax.ws.rs.core.Response; 16 | 17 | import static org.junit.jupiter.api.Assertions.*; 18 | 19 | import com.google.gson.Gson; 20 | 21 | public class ServiceApiV1Test { 22 | private static Storage storage = new InMemoryStorage();; 23 | private static Handler handler = new rest.v1.Handler(storage); 24 | private String serviceUri = "http://localhost:" + Spark.port() + "/api/v1/"; 25 | private String accountsUri = serviceUri + "accounts"; 26 | private String transferUri = serviceUri + "transfer"; 27 | private Client client = ClientBuilder.newBuilder().build(); 28 | 29 | private final Gson gson = new Gson(); 30 | 31 | @BeforeAll 32 | static void setUpAll() { 33 | Spark.awaitInitialization(); 34 | } 35 | 36 | @BeforeEach 37 | public void setUp() { 38 | storage.clear(); 39 | } 40 | 41 | @AfterAll 42 | static void tearDown() { 43 | Spark.stop(); 44 | } 45 | 46 | @Test 47 | public void CreateAccount() { 48 | String payload = "{\"id\":\"test\", \"balance\":1000}"; 49 | Response response = client.target(URI.create(accountsUri)) 50 | .request() 51 | .post(Entity.json(payload)); 52 | assertEquals(200, response.getStatus()); 53 | assertEquals("{\"status\":\"ok\",\"message\":{}}", response.readEntity(String.class)); 54 | assertEquals( "/api/v1/accounts/test", response.getHeaders().getFirst("Location")); 55 | 56 | Account acc = storage.get("test"); 57 | assertNotNull(acc); 58 | assertEquals(acc.getId(), "test"); 59 | assertEquals(acc.getBalance(), 1000); 60 | } 61 | 62 | @Test 63 | public void GetAccountsEmpty() { 64 | Response response = client.target(URI.create(accountsUri)) 65 | .request() 66 | .get(); 67 | assertEquals(200, response.getStatus()); 68 | assertEquals("[]", response.readEntity(String.class)); 69 | } 70 | 71 | @Test 72 | public void GetAccounts() { 73 | storage.create(new Account("test1", 1000)); 74 | storage.create(new Account("test2", 100)); 75 | 76 | Response response = client.target(URI.create(accountsUri)) 77 | .request() 78 | .get(); 79 | assertEquals(200, response.getStatus()); 80 | String expected = "[{\"id\":\"test1\",\"balance\":1000},{\"id\":\"test2\",\"balance\":100}]"; 81 | assertEquals(expected, response.readEntity(String.class)); 82 | } 83 | 84 | @Test 85 | public void Get() { 86 | storage.create(new Account("test", 1000)); 87 | 88 | Response response = client.target(URI.create(accountsUri+"/test")) 89 | .request() 90 | .get(); 91 | assertEquals(200, response.getStatus()); 92 | String expected = "{\"id\":\"test\",\"balance\":1000}"; 93 | assertEquals(expected, response.readEntity(String.class)); 94 | } 95 | 96 | @Test 97 | public void GetNotFound() { 98 | Response response = client.target(URI.create(accountsUri+"/test")) 99 | .request() 100 | .get(); 101 | assertEquals(404, response.getStatus()); 102 | String expected = "{\"status\":\"error\",\"message\":{\"value\":\"Not found\"}}"; 103 | assertEquals(expected, response.readEntity(String.class)); 104 | } 105 | 106 | @Test 107 | public void Transfer() { 108 | storage.create(new Account("test1", 1000)); 109 | storage.create(new Account("test2", 100)); 110 | 111 | String payload = "{\"from\":\"test1\", \"to\":\"test2\", \"amount\":500}"; 112 | Response response = client.target(URI.create(transferUri)) 113 | .request() 114 | .post(Entity.json(payload)); 115 | assertEquals(200, response.getStatus()); 116 | assertEquals("{\"status\":\"ok\",\"message\":{}}", response.readEntity(String.class)); 117 | 118 | Account acc1 = storage.get("test1"); 119 | assertNotNull(acc1); 120 | assertEquals(500, acc1.getBalance()); 121 | 122 | Account acc2 = storage.get("test2"); 123 | assertNotNull(acc2); 124 | assertEquals(600, acc2.getBalance()); 125 | } 126 | 127 | @Test 128 | public void TransferWithIdempotencyKeys() { 129 | storage.create(new Account("test1", 1000)); 130 | storage.create(new Account("test2", 100)); 131 | 132 | String payload = "{\"from\":\"test1\", \"to\":\"test2\", \"amount\":500, \"idempotencyKey\":\"key\"}"; 133 | Response response = client.target(URI.create(transferUri)) 134 | .request() 135 | .post(Entity.json(payload)); 136 | assertEquals(200, response.getStatus()); 137 | assertEquals("{\"status\":\"ok\",\"message\":{}}", response.readEntity(String.class)); 138 | 139 | response = client.target(URI.create(transferUri)) 140 | .request() 141 | .post(Entity.json(payload)); 142 | assertEquals(200, response.getStatus()); 143 | 144 | Account acc1 = storage.get("test1"); 145 | assertNotNull(acc1); 146 | assertEquals(500, acc1.getBalance()); 147 | 148 | Account acc2 = storage.get("test2"); 149 | assertNotNull(acc2); 150 | assertEquals(600, acc2.getBalance()); 151 | } 152 | 153 | @Test 154 | public void TransferErrors() { 155 | storage.create(new Account("test1", 1000)); 156 | storage.create(new Account("test2", 100)); 157 | 158 | // equal ids 159 | String payload = "{\"from\":\"test1\", \"to\":\"test1\", \"amount\":500, \"idempotencyKey\":\"key\"}"; 160 | Response response = client.target(URI.create(transferUri)) 161 | .request() 162 | .post(Entity.json(payload)); 163 | assertEquals(400, response.getStatus()); 164 | 165 | // insufficient amount 166 | payload = "{\"from\":\"test1\", \"to\":\"test2\", \"amount\":50000, \"idempotencyKey\":\"key\"}"; 167 | response = client.target(URI.create(transferUri)) 168 | .request() 169 | .post(Entity.json(payload)); 170 | assertEquals(400, response.getStatus()); 171 | 172 | // negative amount 173 | payload = "{\"from\":\"test1\", \"to\":\"test2\", \"amount\":-100, \"idempotencyKey\":\"key\"}"; 174 | response = client.target(URI.create(transferUri)) 175 | .request() 176 | .post(Entity.json(payload)); 177 | assertEquals(400, response.getStatus()); 178 | 179 | Account acc1 = storage.get("test1"); 180 | assertNotNull(acc1); 181 | assertEquals(1000, acc1.getBalance()); 182 | 183 | Account acc2 = storage.get("test2"); 184 | assertNotNull(acc2); 185 | assertEquals(100, acc2.getBalance()); 186 | } 187 | } --------------------------------------------------------------------------------