cache = new ConcurrentHashMap<>(1);
+
+ public BigDecimal getSellRate(Symbol currency) {
+ return getCurrency(currency).sell();
+ }
+
+ public BigDecimal getBuyRate(Symbol currency) {
+ return getCurrency(currency).buy();
+ }
+
+ private synchronized RatesCache getCurrency(Symbol currency) {
+ var today = getFetchDate();
+ var cacheObject = cache.get(currency);
+ if (cacheObject == null || cacheObject.date().isBefore(today)) {
+ var fresh = fetchData(currency, dtf.format(today));
+ var rate = fresh.getRates().getFirst();
+ cacheObject = new RatesCache(
+ LocalDate.parse(rate.getEffectiveDate(), dtf),
+ rate.getBid(),
+ rate.getAsk()
+ );
+ cache.put(Symbol.valueOf(fresh.getCode().toUpperCase()), cacheObject);
+ }
+ return cacheObject;
+ }
+
+ /**
+ * Calculates date for data fetch.
+ *
+ * Usually this would be today, but as rates are set only Mon-Fri, during weekends it is needed to fetch last friday rates.
+ *
+ * @return date for data fetch
+ */
+ @VisibleForTesting
+ LocalDate getFetchDate() {
+ var today = LocalDate.now(clock);
+ return isWeekend(today) ? today.with(TemporalAdjusters.previous(DayOfWeek.FRIDAY)) : today;
+ }
+
+ @VisibleForTesting
+ Rates fetchData(Symbol code, String date) {
+ return restClient.get().uri(URI_PATTERN, code.name().toLowerCase(), date)
+ .retrieve()
+ .onStatus(HttpStatusCode::is4xxClientError, ((request, response) -> {
+ throw new NoDataException(code, date);
+ }))
+ .body(Rates.class);
+ }
+
+ private static boolean isWeekend(LocalDate today) {
+ return today.getDayOfWeek() == DayOfWeek.SATURDAY
+ || today.getDayOfWeek() == DayOfWeek.SUNDAY;
+ }
+
+ private record RatesCache(LocalDate date, BigDecimal buy, BigDecimal sell) {
+
+ }
+
+}
diff --git a/src/main/java/eu/ztsh/wymiana/service/UserService.java b/src/main/java/eu/ztsh/wymiana/service/UserService.java
index 01cba7c..2205d19 100644
--- a/src/main/java/eu/ztsh/wymiana/service/UserService.java
+++ b/src/main/java/eu/ztsh/wymiana/service/UserService.java
@@ -1,31 +1,45 @@
package eu.ztsh.wymiana.service;
+import eu.ztsh.wymiana.config.CurrencyProperties;
import eu.ztsh.wymiana.data.repository.UserRepository;
import eu.ztsh.wymiana.exception.UserAlreadyExistsException;
import eu.ztsh.wymiana.model.User;
+import eu.ztsh.wymiana.model.UserCreateRequestConfiguredWrapper;
import eu.ztsh.wymiana.util.UserMapper;
+import eu.ztsh.wymiana.validation.InstanceValidator;
import eu.ztsh.wymiana.web.model.UserCreateRequest;
-import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import org.hibernate.validator.constraints.pl.PESEL;
import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
import java.util.Optional;
+@Validated
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
+ private final InstanceValidator validator;
+ private final CurrencyProperties currencyProperties;
- public User create(@Valid UserCreateRequest request) {
+ public User create(UserCreateRequest request) {
+ validator.validate(request);
if (userRepository.findById(request.pesel()).isPresent()) {
throw new UserAlreadyExistsException(request);
}
- return UserMapper.entityToPojo(userRepository.save(UserMapper.requestToEntity(request)));
+ return UserMapper.entityToPojo(userRepository.save(UserMapper.requestToEntity(
+ UserCreateRequestConfiguredWrapper.wrap(request).withInitial(currencyProperties.initial()).build()
+ )));
}
- public Optional get(String pesel) {
+ public Optional get(@PESEL String pesel) {
return userRepository.findById(pesel).map(UserMapper::entityToPojo);
}
+ public User update(User user) {
+ return UserMapper.entityToPojo(userRepository.save(UserMapper.pojoToEntity(user)));
+ }
+
}
diff --git a/src/main/java/eu/ztsh/wymiana/util/CurrencyMapper.java b/src/main/java/eu/ztsh/wymiana/util/CurrencyMapper.java
index 7542187..697e956 100644
--- a/src/main/java/eu/ztsh/wymiana/util/CurrencyMapper.java
+++ b/src/main/java/eu/ztsh/wymiana/util/CurrencyMapper.java
@@ -2,6 +2,7 @@ package eu.ztsh.wymiana.util;
import eu.ztsh.wymiana.data.entity.CurrencyEntity;
import eu.ztsh.wymiana.model.Currency;
+import eu.ztsh.wymiana.model.Symbol;
import java.util.List;
import java.util.Map;
@@ -13,11 +14,19 @@ public class CurrencyMapper {
return new Currency(entity.getSymbol(), entity.getAmount());
}
- public static Map entitiesToPojoMap(List values) {
+ public static CurrencyEntity pojoToEntity(Currency pojo, String pesel) {
+ return new CurrencyEntity(pesel, pojo.symbol(), pojo.amount());
+ }
+
+ public static Map entitiesToPojoMap(List values) {
return values.stream().map(CurrencyMapper::entityToPojo)
.collect(Collectors.toMap(Currency::symbol, pojo -> pojo));
}
+ public static List pojoMapToEntities(Map currencies, String pesel) {
+ return currencies.values().stream().map(entry -> pojoToEntity(entry, pesel)).toList();
+ }
+
private CurrencyMapper() {
}
diff --git a/src/main/java/eu/ztsh/wymiana/util/UserMapper.java b/src/main/java/eu/ztsh/wymiana/util/UserMapper.java
index b4aff44..31c2a20 100644
--- a/src/main/java/eu/ztsh/wymiana/util/UserMapper.java
+++ b/src/main/java/eu/ztsh/wymiana/util/UserMapper.java
@@ -3,7 +3,8 @@ package eu.ztsh.wymiana.util;
import eu.ztsh.wymiana.data.entity.CurrencyEntity;
import eu.ztsh.wymiana.data.entity.UserEntity;
import eu.ztsh.wymiana.model.User;
-import eu.ztsh.wymiana.web.model.UserCreateRequest;
+import eu.ztsh.wymiana.model.UserCreateRequestConfiguredWrapper;
+import eu.ztsh.wymiana.web.model.UserResponse;
import java.util.List;
@@ -14,9 +15,18 @@ public class UserMapper {
CurrencyMapper.entitiesToPojoMap(entity.getCurrencies()));
}
- public static UserEntity requestToEntity(UserCreateRequest request) {
+ public static UserEntity pojoToEntity(User pojo) {
+ return new UserEntity(pojo.pesel(), pojo.name(), pojo.surname(),
+ CurrencyMapper.pojoMapToEntities(pojo.currencies(), pojo.pesel()));
+ }
+
+ public static UserResponse pojoToResponse(User pojo) {
+ return new UserResponse(pojo.name(), pojo.surname(), pojo.pesel(), pojo.currencies().values().stream().toList());
+ }
+
+ public static UserEntity requestToEntity(UserCreateRequestConfiguredWrapper request) {
return new UserEntity(request.pesel(), request.name(), request.surname(),
- List.of(new CurrencyEntity(request.pesel(), "PLN", request.pln())));
+ List.of(new CurrencyEntity(request.pesel(), request.initialSymbol(), request.initial())));
}
private UserMapper() {
diff --git a/src/main/java/eu/ztsh/wymiana/validation/Adult.java b/src/main/java/eu/ztsh/wymiana/validation/Adult.java
index affba90..0d7448f 100644
--- a/src/main/java/eu/ztsh/wymiana/validation/Adult.java
+++ b/src/main/java/eu/ztsh/wymiana/validation/Adult.java
@@ -16,9 +16,12 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Constraint(validatedBy = AdultValidator.class)
@Documented
public @interface Adult {
- String message() default "{jakarta.validation.constraints.Adult.message}";
+ String message() default MESSAGE;
Class>[] groups() default { };
Class extends Payload>[] payload() default { };
+
+ String MESSAGE = "The person has not reached the age of 18";
+
}
diff --git a/src/main/java/eu/ztsh/wymiana/validation/InstanceValidator.java b/src/main/java/eu/ztsh/wymiana/validation/InstanceValidator.java
new file mode 100644
index 0000000..664f5c4
--- /dev/null
+++ b/src/main/java/eu/ztsh/wymiana/validation/InstanceValidator.java
@@ -0,0 +1,20 @@
+package eu.ztsh.wymiana.validation;
+
+import jakarta.validation.Validator;
+
+public class InstanceValidator {
+
+ private final Validator validator;
+
+ public InstanceValidator(Validator validator) {
+ this.validator = validator;
+ }
+
+ public void validate(T t, Class>... classes) {
+ var violations = validator.validate(t, classes);
+ if (!violations.isEmpty()) {
+ throw new ValidationFailedException(violations);
+ }
+ }
+
+}
diff --git a/src/main/java/eu/ztsh/wymiana/validation/InstanceValidatorFactory.java b/src/main/java/eu/ztsh/wymiana/validation/InstanceValidatorFactory.java
new file mode 100644
index 0000000..69289ab
--- /dev/null
+++ b/src/main/java/eu/ztsh/wymiana/validation/InstanceValidatorFactory.java
@@ -0,0 +1,18 @@
+package eu.ztsh.wymiana.validation;
+
+import jakarta.validation.Validation;
+import jakarta.validation.ValidatorFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class InstanceValidatorFactory {
+
+ @Bean
+ public InstanceValidator instanceValidator() {
+ try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) {
+ return new InstanceValidator(factory.getValidator());
+ }
+ }
+
+}
diff --git a/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequest.java b/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequest.java
new file mode 100644
index 0000000..608bc3c
--- /dev/null
+++ b/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequest.java
@@ -0,0 +1,24 @@
+package eu.ztsh.wymiana.validation;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+
+@Retention(RUNTIME)
+@Target({ TYPE_USE })
+@Documented
+@Constraint(validatedBy = {ValidExchangeRequestValidator.class })
+public @interface ValidExchangeRequest {
+
+ String message() default "Exchange request is not valid";
+ Class>[] groups() default { };
+ Class extends Payload>[] payload() default { };
+
+}
diff --git a/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequestValidator.java b/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequestValidator.java
new file mode 100644
index 0000000..9a01a0c
--- /dev/null
+++ b/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequestValidator.java
@@ -0,0 +1,25 @@
+package eu.ztsh.wymiana.validation;
+
+import eu.ztsh.wymiana.web.model.CurrencyExchangeRequest;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+public class ValidExchangeRequestValidator implements
+ ConstraintValidator {
+
+ @Override
+ public boolean isValid(CurrencyExchangeRequest request,
+ ConstraintValidatorContext constraintValidatorContext) {
+ if (request == null) {
+ return false;
+ }
+
+ // Apart from @NotNull annotation we need to check if request.from() != null to avoid NPE in equals
+ return (request.from() != null && !request.from().equals(request.to()))
+ && !((request.toBuy() == null && request.toSell() == null)
+ || (request.toBuy() != null && request.toSell() != null))
+ && ((request.toBuy() != null && request.toBuy().signum() >= 0)
+ || (request.toSell() != null && request.toSell().signum() >= 0));
+ }
+
+}
diff --git a/src/main/java/eu/ztsh/wymiana/validation/ValidationFailedException.java b/src/main/java/eu/ztsh/wymiana/validation/ValidationFailedException.java
new file mode 100644
index 0000000..7ed17fa
--- /dev/null
+++ b/src/main/java/eu/ztsh/wymiana/validation/ValidationFailedException.java
@@ -0,0 +1,20 @@
+package eu.ztsh.wymiana.validation;
+
+import jakarta.validation.ConstraintViolation;
+import lombok.Getter;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Getter
+public class ValidationFailedException extends RuntimeException {
+
+ private final transient Set> violations;
+
+ public ValidationFailedException(Set> violations) {
+ super("Validation failed: %s".formatted(violations.stream()
+ .map(ConstraintViolation::getMessage).collect(Collectors.joining(System.lineSeparator()))));
+ this.violations = violations;
+ }
+
+}
diff --git a/src/main/java/eu/ztsh/wymiana/web/controller/ExchangeController.java b/src/main/java/eu/ztsh/wymiana/web/controller/ExchangeController.java
new file mode 100644
index 0000000..39c635d
--- /dev/null
+++ b/src/main/java/eu/ztsh/wymiana/web/controller/ExchangeController.java
@@ -0,0 +1,62 @@
+package eu.ztsh.wymiana.web.controller;
+
+import eu.ztsh.wymiana.exception.InsufficientFundsException;
+import eu.ztsh.wymiana.exception.UserNotFoundException;
+import eu.ztsh.wymiana.service.CurrencyService;
+import eu.ztsh.wymiana.web.model.CurrencyExchangeRequest;
+import eu.ztsh.wymiana.web.model.UserResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+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;
+
+@RequiredArgsConstructor
+@Validated
+@RestController
+@RequestMapping(path = "/api/exchange", produces = "application/json")
+public class ExchangeController {
+
+ private final CurrencyService currencyService;
+
+ @Operation(summary = "Perform exchange")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200",
+ description = "Exchange performed successfully",
+ content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
+ schema = @Schema(implementation = UserResponse.class))),
+ @ApiResponse(responseCode = "400",
+ description = "Insufficient funds",
+ content = @Content(mediaType = MediaType.TEXT_PLAIN_VALUE)),
+ @ApiResponse(responseCode = "404",
+ description = "User not found",
+ content = @Content(mediaType = MediaType.TEXT_PLAIN_VALUE)),
+ @ApiResponse(responseCode = "500",
+ description = "Another error has occurred",
+ content = @Content(mediaType = MediaType.TEXT_PLAIN_VALUE))
+ })
+ @PostMapping
+ public ResponseEntity