|
| 1 | +package io.roach.data.jdbc; |
| 2 | + |
| 3 | +import java.math.BigDecimal; |
| 4 | + |
| 5 | +import org.springframework.beans.factory.annotation.Autowired; |
| 6 | +import org.springframework.dao.DataRetrievalFailureException; |
| 7 | +import org.springframework.data.domain.PageRequest; |
| 8 | +import org.springframework.data.domain.Pageable; |
| 9 | +import org.springframework.data.domain.Sort; |
| 10 | +import org.springframework.data.web.PageableDefault; |
| 11 | +import org.springframework.data.web.PagedResourcesAssembler; |
| 12 | +import org.springframework.hateoas.IanaLinkRelations; |
| 13 | +import org.springframework.hateoas.Link; |
| 14 | +import org.springframework.hateoas.PagedModel; |
| 15 | +import org.springframework.hateoas.RepresentationModel; |
| 16 | +import org.springframework.hateoas.server.RepresentationModelAssembler; |
| 17 | +import org.springframework.http.HttpEntity; |
| 18 | +import org.springframework.http.HttpStatus; |
| 19 | +import org.springframework.http.ResponseEntity; |
| 20 | +import org.springframework.transaction.annotation.Transactional; |
| 21 | +import org.springframework.web.bind.annotation.*; |
| 22 | +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; |
| 23 | + |
| 24 | +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; |
| 25 | +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; |
| 26 | +import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; |
| 27 | + |
| 28 | +/** |
| 29 | + * Main remoting and transaction boundary in the form of a REST controller. The discipline |
| 30 | + * when following the entity-control-boundary (ECB) pattern is that only service boundaries |
| 31 | + * are allowed to start and end transactions. A service boundary can be a controller, business |
| 32 | + * service facade or service activator (JMS/Kafka listener). |
| 33 | + * <p> |
| 34 | + * This is enforced by the REQUIRES_NEW propagation attribute of @Transactional annotated |
| 35 | + * controller methods. Between the web container's HTTP listener and the transaction proxy, |
| 36 | + * there's yet another transparent proxy in the form of a retry loop advice with exponential |
| 37 | + * backoff. It takes care of retrying transactions that are aborted by transient SQL errors, |
| 38 | + * rather than having these propagate all the way over the wire to the client / user agent. |
| 39 | + * |
| 40 | + * @see RetryableTransactionAspect |
| 41 | + */ |
| 42 | +@RestController |
| 43 | +public class AccountController { |
| 44 | + @Autowired |
| 45 | + private AccountRepository accountRepository; |
| 46 | + |
| 47 | + @Autowired |
| 48 | + private PagedResourcesAssembler<Account> pagedResourcesAssembler; |
| 49 | + |
| 50 | + /** |
| 51 | + * Provides the service index resource representation which is only links |
| 52 | + * for clients to follow. |
| 53 | + */ |
| 54 | + @GetMapping |
| 55 | + public ResponseEntity<RepresentationModel> index() { |
| 56 | + RepresentationModel index = new RepresentationModel(); |
| 57 | + |
| 58 | + // Type-safe way to generate URLs bound to controller methods |
| 59 | + index.add(linkTo(methodOn(AccountController.class) |
| 60 | + .listAccounts(PageRequest.of(0, 5))) |
| 61 | + .withRel("accounts")); // Lets skip curies and affordances for now |
| 62 | + |
| 63 | + // This rel essentially informs the client that a POST to its href with |
| 64 | + // form parameters will transfer funds between referenced accounts. |
| 65 | + // (its only a demo) |
| 66 | + index.add(linkTo(AccountController.class) |
| 67 | + .slash("transfer{?fromId,toId,amount}") |
| 68 | + .withRel("transfer")); |
| 69 | + |
| 70 | + // Spring boot actuators for observability / monitoring |
| 71 | + index.add(new Link( |
| 72 | + ServletUriComponentsBuilder |
| 73 | + .fromCurrentContextPath() |
| 74 | + .pathSegment("actuator") |
| 75 | + .buildAndExpand() |
| 76 | + .toUriString() |
| 77 | + ).withRel("actuator")); |
| 78 | + |
| 79 | + return new ResponseEntity<>(index, HttpStatus.OK); |
| 80 | + } |
| 81 | + |
| 82 | + /** |
| 83 | + * Provides a paged representation of accounts (sort order omitted). |
| 84 | + */ |
| 85 | + @GetMapping("/account") |
| 86 | + @Transactional(propagation = REQUIRES_NEW) |
| 87 | + public HttpEntity<PagedModel<AccountModel>> listAccounts( |
| 88 | + @PageableDefault(size = 5, direction = Sort.Direction.ASC) Pageable page) { |
| 89 | + return ResponseEntity |
| 90 | + .ok(pagedResourcesAssembler.toModel(accountRepository.findAll(page), accountModelAssembler())); |
| 91 | + } |
| 92 | + |
| 93 | + /** |
| 94 | + * Provides a point lookup of a given account. |
| 95 | + */ |
| 96 | + @GetMapping(value = "/account/{id}") |
| 97 | + @Transactional(propagation = REQUIRES_NEW, readOnly = true) // Notice its marked read-only |
| 98 | + public HttpEntity<AccountModel> getAccount(@PathVariable("id") Long accountId) { |
| 99 | + return new ResponseEntity<>(accountModelAssembler().toModel( |
| 100 | + accountRepository.findById(accountId) |
| 101 | + .orElseThrow(() -> new DataRetrievalFailureException("No such account: " + accountId))), |
| 102 | + HttpStatus.OK); |
| 103 | + } |
| 104 | + |
| 105 | + /** |
| 106 | + * Main funds transfer method. |
| 107 | + */ |
| 108 | + @PostMapping(value = "/transfer") |
| 109 | + @Transactional(propagation = REQUIRES_NEW) |
| 110 | + public HttpEntity<BigDecimal> transfer( |
| 111 | + @RequestParam("fromId") Long fromId, |
| 112 | + @RequestParam("toId") Long toId, |
| 113 | + @RequestParam("amount") BigDecimal amount |
| 114 | + ) { |
| 115 | + if (amount.compareTo(BigDecimal.ZERO) < 0) { |
| 116 | + throw new IllegalArgumentException("Negative amount"); |
| 117 | + } |
| 118 | + if (fromId.equals(toId)) { |
| 119 | + throw new IllegalArgumentException("From and to accounts must be different"); |
| 120 | + } |
| 121 | + |
| 122 | + BigDecimal fromBalance = accountRepository.getBalance(fromId).add(amount.negate()); |
| 123 | + // Application level invariant check. |
| 124 | + // Could be enhanced or replaced with a CHECK constraint like: |
| 125 | + // ALTER TABLE account ADD CONSTRAINT check_account_positive_balance CHECK (balance >= 0) |
| 126 | + if (fromBalance.compareTo(BigDecimal.ZERO) < 0) { |
| 127 | + throw new NegativeBalanceException("Insufficient funds " + amount + " for account " + fromId); |
| 128 | + } |
| 129 | + |
| 130 | + accountRepository.updateBalance(fromId, amount.negate()); |
| 131 | + accountRepository.updateBalance(toId, amount); |
| 132 | + |
| 133 | + return ResponseEntity.ok().build(); |
| 134 | + } |
| 135 | + |
| 136 | + private RepresentationModelAssembler<Account, AccountModel> accountModelAssembler() { |
| 137 | + return (entity) -> { |
| 138 | + AccountModel model = new AccountModel(); |
| 139 | + model.setName(entity.getName()); |
| 140 | + model.setType(entity.getType()); |
| 141 | + model.setBalance(entity.getBalance()); |
| 142 | + model.add(linkTo(methodOn(AccountController.class) |
| 143 | + .getAccount(entity.getId()) |
| 144 | + ).withRel(IanaLinkRelations.SELF)); |
| 145 | + return model; |
| 146 | + }; |
| 147 | + } |
| 148 | +} |
0 commit comments