diff --git a/config/curl.md b/config/curl.md index b80ca57f5916..fbc748aa0c58 100644 --- a/config/curl.md +++ b/config/curl.md @@ -7,6 +7,12 @@ #### get Users 100001 `curl -s http://localhost:8080/topjava/rest/admin/users/100001 --user admin@gmail.com:admin` +#### register User +`curl -s -i -X POST -d '{"name":"New User","email":"test@mail.ru","password":"test-password"}' -H 'Content-Type:application/json;charset=UTF-8' http://localhost:8080/topjava/rest/profile` + +#### get Profile +`curl -s http://localhost:8080/topjava/rest/profile --user test@mail.ru:test-password` + #### get All Meals `curl -s http://localhost:8080/topjava/rest/profile/meals --user user@yandex.ru:password` @@ -26,4 +32,7 @@ `curl -s -X POST -d '{"dateTime":"2020-02-01T12:00","description":"Created lunch","calories":300}' -H 'Content-Type:application/json;charset=UTF-8' http://localhost:8080/topjava/rest/profile/meals --user user@yandex.ru:password` #### update Meals -`curl -s -X PUT -d '{"dateTime":"2020-01-30T07:00", "description":"Updated breakfast", "calories":200}' -H 'Content-Type: application/json' http://localhost:8080/topjava/rest/profile/meals/100003 --user user@yandex.ru:password` \ No newline at end of file +`curl -s -X PUT -d '{"dateTime":"2020-01-30T07:00", "description":"Updated breakfast", "calories":200}' -H 'Content-Type: application/json' http://localhost:8080/topjava/rest/profile/meals/100003 --user user@yandex.ru:password` + +#### validate with Error +`curl -s -X POST -d '{}' -H 'Content-Type: application/json' http://localhost:8080/topjava/rest/admin/users --user admin@gmail.com:admin` diff --git a/config/messages/app.properties b/config/messages/app.properties index f34c18257b9e..979407b488bd 100644 --- a/config/messages/app.properties +++ b/config/messages/app.properties @@ -6,6 +6,9 @@ Meals could be filtered by date and time. Meal record color depends on daily cal All REST interface covered with JUnit tests by Spring MVC Test and Spring Security Test. app.footer=Spring 5/JPA Enterprise (Topjava) internship application app.login=Login as +app.profile=profile +app.register=Registration +app.registered=You are registered. Please Sign in. user.title=Users user.edit=Edit user @@ -16,6 +19,7 @@ user.roles=Roles user.active=Active user.registered=Registered user.password=Password +user.caloriesPerDay=Daily calorie limit meal.title=Meals meal.edit=Edit meal @@ -36,6 +40,7 @@ common.saved=Record saved common.enabled=Record enabled common.disabled=Record disabled common.errorStatus=Error status +common.appError=Application error common.confirm=Are you sure? common.save=Save common.cancel=Cancel \ No newline at end of file diff --git a/config/messages/app_ru.properties b/config/messages/app_ru.properties index 4df962942c36..be85ad30ed7c 100644 --- a/config/messages/app_ru.properties +++ b/config/messages/app_ru.properties @@ -6,6 +6,9 @@ app.description=Java Enterprise п Весь REST интерфейс покрывается JUnit тестами, используя Spring MVC Test и Spring Security Test. app.footer=Приложение стажировки Spring 5/JPA Enterprise (Topjava) app.login=Зайти как +app.profile=профиль +app.register=Регистрация +app.registered=Вы зарегистрированы. Введите ваш логин/пароль. user.title=Пользователи user.edit=Редактировать пользователя @@ -16,6 +19,7 @@ user.roles=Роли user.active=Активный user.registered=Зарегистрирован user.password=Пароль +user.caloriesPerDay=Норма калорий в день meal.title=Моя еда meal.edit=Редактировать еду @@ -36,6 +40,7 @@ common.saved=Запись сохранена common.enabled=Запись активирована common.disabled=Запись деактивирована common.errorStatus=Статус ошибки +common.appError=Ошибка приложения common.confirm=Вы уверены? common.save=Сохранить common.cancel=Отменить \ No newline at end of file diff --git a/pom.xml b/pom.xml index bfa927232c4b..45190f442750 100644 --- a/pom.xml +++ b/pom.xml @@ -163,6 +163,11 @@ spring-security-config ${spring.security.version} + + org.springframework.security + spring-security-taglibs + ${spring.security.version} + diff --git a/src/main/java/ru/javawebinar/topjava/AuthorizedUser.java b/src/main/java/ru/javawebinar/topjava/AuthorizedUser.java index 47c06302d022..b51930e872d5 100644 --- a/src/main/java/ru/javawebinar/topjava/AuthorizedUser.java +++ b/src/main/java/ru/javawebinar/topjava/AuthorizedUser.java @@ -14,14 +14,15 @@ public class AuthorizedUser extends org.springframework.security.core.userdetail public AuthorizedUser(User user) { super(user.getEmail(), user.getPassword(), user.isEnabled(), true, true, true, user.getRoles()); - this.userTo = UsersUtil.asTo(user); + setTo(UsersUtil.asTo(user)); } public int getId() { return userTo.id(); } - public void update(UserTo newTo) { + public void setTo(UserTo newTo) { + newTo.setPassword(null); userTo = newTo; } diff --git a/src/main/java/ru/javawebinar/topjava/View.java b/src/main/java/ru/javawebinar/topjava/View.java new file mode 100644 index 000000000000..62c332bcf970 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/View.java @@ -0,0 +1,7 @@ +package ru.javawebinar.topjava; + +import javax.validation.groups.Default; + +public class View { + public interface Persist extends Default {} +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/model/Meal.java b/src/main/java/ru/javawebinar/topjava/model/Meal.java index b318c399a7da..e6f78ddaa079 100644 --- a/src/main/java/ru/javawebinar/topjava/model/Meal.java +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -1,14 +1,22 @@ package ru.javawebinar.topjava.model; import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonView; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; import org.hibernate.validator.constraints.Range; +import org.springframework.format.annotation.DateTimeFormat; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.View.ValidatedUI; +import ru.javawebinar.topjava.util.DateTimeUtil; import javax.persistence.*; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; +import javax.validation.groups.Default; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -31,23 +39,25 @@ public class Meal extends AbstractBaseEntity { public static final String GET_BETWEEN = "Meal.getBetween"; @Column(name = "date_time", nullable = false) - @NotNull + @NotNull(groups = {ValidatedUI.class, Default.class}) + @JsonView(View.JsonREST.class) private LocalDateTime dateTime; @Column(name = "description", nullable = false) - @NotBlank - @Size(min = 2, max = 120) + @NotBlank(groups = {ValidatedUI.class, Default.class}) + @Size(min = 2, max = 120, groups = {ValidatedUI.class, Default.class}) private String description; @Column(name = "calories", nullable = false) - @Range(min = 10, max = 5000) - private int calories; + @NotNull(groups = {ValidatedUI.class, Default.class}) + @Range(min = 10, max = 5000, groups = {ValidatedUI.class, Default.class}) + private Integer calories; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) @JsonBackReference -// @NotNull + @NotNull private User user; public Meal() { @@ -88,7 +98,7 @@ public void setDescription(String description) { this.description = description; } - public void setCalories(int calories) { + public void setCalories(Integer calories) { this.calories = calories; } @@ -100,6 +110,19 @@ public void setUser(User user) { this.user = user; } + + @JsonGetter + @JsonView(View.JsonUI.class) + @JsonFormat(pattern = DateTimeUtil.DATE_TIME_PATTERN) + public LocalDateTime getDateTimeUI() { + return dateTime; + } + + @DateTimeFormat(pattern = DateTimeUtil.DATE_TIME_PATTERN) + public void setDateTimeUI(LocalDateTime dateTime) { + this.dateTime = dateTime; + } + @Override public String toString() { return "Meal{" + diff --git a/src/main/java/ru/javawebinar/topjava/model/User.java b/src/main/java/ru/javawebinar/topjava/model/User.java index 7880f2d7f0b4..12c608d0cf27 100644 --- a/src/main/java/ru/javawebinar/topjava/model/User.java +++ b/src/main/java/ru/javawebinar/topjava/model/User.java @@ -1,6 +1,7 @@ package ru.javawebinar.topjava.model; import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.fasterxml.jackson.annotation.JsonProperty; import org.hibernate.annotations.Cache; import org.hibernate.annotations.*; import org.hibernate.validator.constraints.Range; @@ -43,6 +44,8 @@ public class User extends AbstractNamedEntity { @Column(name = "password", nullable = false) @NotBlank @Size(min = 5, max = 128) + // https://stackoverflow.com/a/12505165/548473 + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String password; @Column(name = "enabled", nullable = false, columnDefinition = "bool default true") @@ -50,6 +53,7 @@ public class User extends AbstractNamedEntity { @Column(name = "registered", nullable = false, columnDefinition = "timestamp default now()", updatable = false) @NotNull + @JsonProperty(access = JsonProperty.Access.READ_ONLY) private Date registered = new Date(); @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) @@ -81,8 +85,8 @@ public User(User u) { this(u.id, u.name, u.email, u.password, u.caloriesPerDay, u.enabled, u.registered, u.roles); } - public User(Integer id, String name, String email, String password, Role... roles) { - this(id, name, email, password, DEFAULT_CALORIES_PER_DAY, true, new Date(), List.of(roles)); + public User(Integer id, String name, String email, String password, int caloriesPerDay, Role... roles) { + this(id, name, email, password, caloriesPerDay, true, new Date(), List.of(roles)); } public User(Integer id, String name, String email, String password, int caloriesPerDay, boolean enabled, Date registered, Collection roles) { diff --git a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java index 775d314ed961..9ff92e63173a 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java @@ -9,6 +9,7 @@ import org.springframework.jdbc.core.simple.SimpleJdbcInsert; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import ru.javawebinar.topjava.View; import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.repository.MealRepository; import ru.javawebinar.topjava.util.ValidationUtil; @@ -40,7 +41,7 @@ public JdbcMealRepository(JdbcTemplate jdbcTemplate, NamedParameterJdbcTemplate @Override @Transactional public Meal save(Meal meal, int userId) { - ValidationUtil.validate(meal); + ValidationUtil.validate(meal, View.ValidatedUI.class); MapSqlParameterSource map = new MapSqlParameterSource() .addValue("id", meal.getId()) diff --git a/src/main/java/ru/javawebinar/topjava/service/UserService.java b/src/main/java/ru/javawebinar/topjava/service/UserService.java index 47874971c5bd..19070ed22312 100644 --- a/src/main/java/ru/javawebinar/topjava/service/UserService.java +++ b/src/main/java/ru/javawebinar/topjava/service/UserService.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.Assert; @@ -17,6 +18,7 @@ import java.util.List; +import static ru.javawebinar.topjava.util.UsersUtil.prepareToSave; import static ru.javawebinar.topjava.util.ValidationUtil.checkNotFound; import static ru.javawebinar.topjava.util.ValidationUtil.checkNotFoundWithId; @@ -25,15 +27,17 @@ public class UserService implements UserDetailsService { private final UserRepository repository; + private final PasswordEncoder passwordEncoder; - public UserService(UserRepository repository) { + public UserService(UserRepository repository, PasswordEncoder passwordEncoder) { this.repository = repository; + this.passwordEncoder = passwordEncoder; } @CacheEvict(value = "users", allEntries = true) public User create(User user) { Assert.notNull(user, "user must not be null"); - return repository.save(user); + return prepareAndSave(user); } @CacheEvict(value = "users", allEntries = true) @@ -59,15 +63,14 @@ public List getAll() { public void update(User user) { Assert.notNull(user, "user must not be null"); // checkNotFoundWithId : check works only for JDBC, disabled - repository.save(user); + prepareAndSave(user); } @CacheEvict(value = "users", allEntries = true) @Transactional public void update(UserTo userTo) { User user = get(userTo.id()); - User updatedUser = UsersUtil.updateFromTo(user, userTo); - repository.save(updatedUser); // !! need only for JDBC implementation + prepareAndSave(UsersUtil.updateFromTo(user, userTo)); } @CacheEvict(value = "users", allEntries = true) @@ -87,6 +90,10 @@ public AuthorizedUser loadUserByUsername(String email) throws UsernameNotFoundEx return new AuthorizedUser(user); } + private User prepareAndSave(User user) { + return repository.save(prepareToSave(user, passwordEncoder)); + } + public User getWithMeals(int id) { return checkNotFoundWithId(repository.getWithMeals(id), id); } diff --git a/src/main/java/ru/javawebinar/topjava/to/MealTo.java b/src/main/java/ru/javawebinar/topjava/to/MealTo.java index 059f14a44f35..526f26e40e89 100644 --- a/src/main/java/ru/javawebinar/topjava/to/MealTo.java +++ b/src/main/java/ru/javawebinar/topjava/to/MealTo.java @@ -1,11 +1,18 @@ package ru.javawebinar.topjava.to; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonView; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.util.DateTimeUtil; + import java.beans.ConstructorProperties; import java.time.LocalDateTime; import java.util.Objects; public class MealTo extends BaseTo { + @JsonView(View.JsonREST.class) private final LocalDateTime dateTime; private final String description; @@ -56,6 +63,13 @@ public int hashCode() { return Objects.hash(id, dateTime, description, calories, excess); } + @JsonGetter + @JsonView(View.JsonUI.class) + @JsonFormat(pattern = DateTimeUtil.DATE_TIME_PATTERN) + public LocalDateTime getDateTimeUI() { + return dateTime; + } + @Override public String toString() { return "MealTo{" + diff --git a/src/main/java/ru/javawebinar/topjava/to/UserTo.java b/src/main/java/ru/javawebinar/topjava/to/UserTo.java index 3a421df497fb..217d7390a5b4 100644 --- a/src/main/java/ru/javawebinar/topjava/to/UserTo.java +++ b/src/main/java/ru/javawebinar/topjava/to/UserTo.java @@ -66,6 +66,10 @@ public void setEmail(String email) { this.email = email; } + public void setCaloriesPerDay(Integer caloriesPerDay) { + this.caloriesPerDay = caloriesPerDay; + } + public Integer getCaloriesPerDay() { return caloriesPerDay; } diff --git a/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java b/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java index 7c1ac9fecdba..09052faa56cb 100644 --- a/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java @@ -9,7 +9,8 @@ import java.time.format.DateTimeFormatter; public class DateTimeUtil { - private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm"; + public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN); // DB doesn't support LocalDate.MIN/MAX private static final LocalDateTime MIN_DATE = LocalDateTime.of(1, 1, 1, 0, 0); diff --git a/src/main/java/ru/javawebinar/topjava/util/UsersUtil.java b/src/main/java/ru/javawebinar/topjava/util/UsersUtil.java index 8243fa95537b..bdb4291f7d65 100644 --- a/src/main/java/ru/javawebinar/topjava/util/UsersUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/UsersUtil.java @@ -1,5 +1,6 @@ package ru.javawebinar.topjava.util; +import org.springframework.security.crypto.password.PasswordEncoder; import ru.javawebinar.topjava.model.Role; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.to.UserTo; @@ -9,7 +10,7 @@ public class UsersUtil { public static final int DEFAULT_CALORIES_PER_DAY = 2000; public static User createNewFromTo(UserTo userTo) { - return new User(null, userTo.getName(), userTo.getEmail().toLowerCase(), userTo.getPassword(), Role.USER); + return new User(null, userTo.getName(), userTo.getEmail().toLowerCase(), userTo.getPassword(), userTo.getCaloriesPerDay(), Role.USER); } public static UserTo asTo(User user) { @@ -23,4 +24,10 @@ public static User updateFromTo(User user, UserTo userTo) { user.setPassword(userTo.getPassword()); return user; } + + public static User prepareToSave(User user, PasswordEncoder passwordEncoder) { + user.setPassword(passwordEncoder.encode(user.getPassword())); + user.setEmail(user.getEmail().toLowerCase()); + return user; + } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java b/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java index b77462d3fe82..093048b28ed9 100644 --- a/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java @@ -2,12 +2,16 @@ import org.springframework.core.NestedExceptionUtils; +import org.springframework.http.ResponseEntity; import org.springframework.lang.NonNull; +import org.springframework.validation.BindingResult; import ru.javawebinar.topjava.HasId; +import ru.javawebinar.topjava.util.exception.IllegalRequestDataException; import ru.javawebinar.topjava.util.exception.NotFoundException; import javax.validation.*; import java.util.Set; +import java.util.stream.Collectors; public class ValidationUtil { @@ -23,9 +27,9 @@ public class ValidationUtil { private ValidationUtil() { } - public static void validate(T bean) { + public static void validate(T bean, Class... groups) { // https://alexkosarev.name/2018/07/30/bean-validation-api/ - Set> violations = validator.validate(bean); + Set> violations = validator.validate(bean, groups); if (!violations.isEmpty()) { throw new ConstraintViolationException(violations); } @@ -53,7 +57,7 @@ public static void checkNotFound(boolean found, String msg) { public static void checkNew(HasId bean) { if (!bean.isNew()) { - throw new IllegalArgumentException(bean + " must be new (id=null)"); + throw new IllegalRequestDataException(bean + " must be new (id=null)"); } } @@ -62,7 +66,7 @@ public static void assureIdConsistent(HasId bean, int id) { if (bean.isNew()) { bean.setId(id); } else if (bean.id() != id) { - throw new IllegalArgumentException(bean + " must be with id=" + id); + throw new IllegalRequestDataException(bean + " must be with id=" + id); } } @@ -72,4 +76,12 @@ public static Throwable getRootCause(@NonNull Throwable t) { Throwable rootCause = NestedExceptionUtils.getRootCause(t); return rootCause != null ? rootCause : t; } + + public static ResponseEntity getErrorResponse(BindingResult result) { + return ResponseEntity.unprocessableEntity().body( + result.getFieldErrors().stream() + .map(fe -> String.format("[%s] %s", fe.getField(), fe.getDefaultMessage())) + .collect(Collectors.joining("
")) + ); + } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/exception/ErrorInfo.java b/src/main/java/ru/javawebinar/topjava/util/exception/ErrorInfo.java new file mode 100644 index 000000000000..d43323590d95 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/exception/ErrorInfo.java @@ -0,0 +1,13 @@ +package ru.javawebinar.topjava.util.exception; + +public class ErrorInfo { + private final String url; + private final ErrorType type; + private final String detail; + + public ErrorInfo(CharSequence url, ErrorType type, String detail) { + this.url = url.toString(); + this.type = type; + this.detail = detail; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/exception/ErrorType.java b/src/main/java/ru/javawebinar/topjava/util/exception/ErrorType.java new file mode 100644 index 000000000000..c53a433bc668 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/exception/ErrorType.java @@ -0,0 +1,8 @@ +package ru.javawebinar.topjava.util.exception; + +public enum ErrorType { + APP_ERROR, + DATA_NOT_FOUND, + DATA_ERROR, + VALIDATION_ERROR +} diff --git a/src/main/java/ru/javawebinar/topjava/util/exception/IllegalRequestDataException.java b/src/main/java/ru/javawebinar/topjava/util/exception/IllegalRequestDataException.java new file mode 100644 index 000000000000..2b144f91c91c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/exception/IllegalRequestDataException.java @@ -0,0 +1,7 @@ +package ru.javawebinar.topjava.util.exception; + +public class IllegalRequestDataException extends RuntimeException { + public IllegalRequestDataException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/ExceptionInfoHandler.java b/src/main/java/ru/javawebinar/topjava/web/ExceptionInfoHandler.java new file mode 100644 index 000000000000..5b7e9575810c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/ExceptionInfoHandler.java @@ -0,0 +1,65 @@ +package ru.javawebinar.topjava.web; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import ru.javawebinar.topjava.util.ValidationUtil; +import ru.javawebinar.topjava.util.exception.ErrorInfo; +import ru.javawebinar.topjava.util.exception.ErrorType; +import ru.javawebinar.topjava.util.exception.IllegalRequestDataException; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +import javax.servlet.http.HttpServletRequest; + +import static ru.javawebinar.topjava.util.exception.ErrorType.*; + +@RestControllerAdvice(annotations = RestController.class) +@Order(Ordered.HIGHEST_PRECEDENCE + 5) +public class ExceptionInfoHandler { + private static final Logger log = LoggerFactory.getLogger(ExceptionInfoHandler.class); + + // http://stackoverflow.com/a/22358422/548473 + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + @ExceptionHandler(NotFoundException.class) + public ErrorInfo notFoundError(HttpServletRequest req, NotFoundException e) { + return logAndGetErrorInfo(req, e, false, DATA_NOT_FOUND); + } + + @ResponseStatus(HttpStatus.CONFLICT) // 409 + @ExceptionHandler(DataIntegrityViolationException.class) + public ErrorInfo conflict(HttpServletRequest req, DataIntegrityViolationException e) { + return logAndGetErrorInfo(req, e, true, DATA_ERROR); + } + + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) // 422 + @ExceptionHandler({IllegalRequestDataException.class, MethodArgumentTypeMismatchException.class, HttpMessageNotReadableException.class}) + public ErrorInfo validationError(HttpServletRequest req, Exception e) { + return logAndGetErrorInfo(req, e, false, VALIDATION_ERROR); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + public ErrorInfo internalError(HttpServletRequest req, Exception e) { + return logAndGetErrorInfo(req, e, true, APP_ERROR); + } + + // https://stackoverflow.com/questions/538870/should-private-helper-methods-be-static-if-they-can-be-static + private static ErrorInfo logAndGetErrorInfo(HttpServletRequest req, Exception e, boolean logException, ErrorType errorType) { + Throwable rootCause = ValidationUtil.getRootCause(e); + if (logException) { + log.error(errorType + " at request " + req.getRequestURL(), rootCause); + } else { + log.warn("{} at request {}: {}", errorType, req.getRequestURL(), rootCause.toString()); + } + return new ErrorInfo(req.getRequestURL(), errorType, rootCause.toString()); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/GlobalExceptionHandler.java b/src/main/java/ru/javawebinar/topjava/web/GlobalExceptionHandler.java new file mode 100644 index 000000000000..bd401d1c150e --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/GlobalExceptionHandler.java @@ -0,0 +1,36 @@ +package ru.javawebinar.topjava.web; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.ModelAndView; +import ru.javawebinar.topjava.AuthorizedUser; +import ru.javawebinar.topjava.util.ValidationUtil; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; + +@ControllerAdvice +public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(Exception.class) + public ModelAndView defaultErrorHandler(HttpServletRequest req, Exception e) throws Exception { + log.error("Exception at request " + req.getRequestURL(), e); + Throwable rootCause = ValidationUtil.getRootCause(e); + + HttpStatus httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + ModelAndView mav = new ModelAndView("exception", + Map.of("exception", rootCause, "message", rootCause.toString(), "status", httpStatus)); + mav.setStatus(httpStatus); + + // Interceptor is not invoked, put userTo + AuthorizedUser authorizedUser = SecurityUtil.safeGet(); + if (authorizedUser != null) { + mav.addObject("userTo", authorizedUser.getUserTo()); + } + return mav; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/web/RootController.java b/src/main/java/ru/javawebinar/topjava/web/RootController.java index 3ca4591e02ca..d42b390d59fb 100644 --- a/src/main/java/ru/javawebinar/topjava/web/RootController.java +++ b/src/main/java/ru/javawebinar/topjava/web/RootController.java @@ -2,26 +2,22 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; -import ru.javawebinar.topjava.service.MealService; -import ru.javawebinar.topjava.util.MealsUtil; @Controller public class RootController { private static final Logger log = LoggerFactory.getLogger(RootController.class); - @Autowired - private MealService mealService; - @GetMapping("/") public String root() { log.info("root"); return "redirect:meals"; } + // @Secured("ROLE_ADMIN") + @PreAuthorize("hasRole('ADMIN')") @GetMapping("/users") public String getUsers() { log.info("users"); @@ -35,10 +31,8 @@ public String login() { } @GetMapping("/meals") - public String getMeals(Model model) { + public String getMeals() { log.info("meals"); - model.addAttribute("meals", - MealsUtil.getTos(mealService.getAll(SecurityUtil.authUserId()), SecurityUtil.authUserCaloriesPerDay())); return "meals"; } } diff --git a/src/main/java/ru/javawebinar/topjava/web/interceptor/ModelInterceptor.java b/src/main/java/ru/javawebinar/topjava/web/interceptor/ModelInterceptor.java new file mode 100644 index 000000000000..4ee6eaef1386 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/interceptor/ModelInterceptor.java @@ -0,0 +1,25 @@ +package ru.javawebinar.topjava.web.interceptor; + +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; +import ru.javawebinar.topjava.AuthorizedUser; +import ru.javawebinar.topjava.web.SecurityUtil; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * This interceptor adds userTo to the model of every requests + */ +public class ModelInterceptor implements HandlerInterceptor { + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { + if (modelAndView != null && !modelAndView.isEmpty()) { + AuthorizedUser authorizedUser = SecurityUtil.safeGet(); + if (authorizedUser != null) { + modelAndView.getModelMap().addAttribute("userTo", authorizedUser.getUserTo()); + } + } + } +} diff --git a/src/main/java/ru/javawebinar/topjava/web/json/JacksonObjectMapper.java b/src/main/java/ru/javawebinar/topjava/web/json/JacksonObjectMapper.java index 8237df93bffe..58704d86db13 100644 --- a/src/main/java/ru/javawebinar/topjava/web/json/JacksonObjectMapper.java +++ b/src/main/java/ru/javawebinar/topjava/web/json/JacksonObjectMapper.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import ru.javawebinar.topjava.View; /** *

@@ -29,6 +30,9 @@ private JacksonObjectMapper() { setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); setSerializationInclusion(JsonInclude.Include.NON_NULL); + +// https://stackoverflow.com/questions/22875642/jackson-set-default-view + setConfig(getSerializationConfig().withView(View.JsonREST.class)); } public static ObjectMapper getMapper() { diff --git a/src/main/java/ru/javawebinar/topjava/web/json/JsonUtil.java b/src/main/java/ru/javawebinar/topjava/web/json/JsonUtil.java index fda04590d618..4d753a8a4f8d 100644 --- a/src/main/java/ru/javawebinar/topjava/web/json/JsonUtil.java +++ b/src/main/java/ru/javawebinar/topjava/web/json/JsonUtil.java @@ -1,10 +1,13 @@ package ru.javawebinar.topjava.web.json; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; import java.io.IOException; import java.util.List; +import java.util.Map; import static ru.javawebinar.topjava.web.json.JacksonObjectMapper.getMapper; @@ -34,4 +37,22 @@ public static String writeValue(T obj) { throw new IllegalStateException("Invalid write to JSON:\n'" + obj + "'", e); } } + + public static String writeValue(T obj, ObjectWriter ow) { + try { + return ow.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Invalid write to JSON:\n'" + obj + "'", e); + } + } + + public static String writeAdditionProps(T obj, String addName, Object addValue) { + return writeAdditionProps(obj, Map.of(addName, addValue)); + } + + public static String writeAdditionProps(T obj, Map addProps) { + Map map = getMapper().convertValue(obj, new TypeReference<>() {}); + map.putAll(addProps); + return writeValue(map); + } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/meal/MealUIController.java b/src/main/java/ru/javawebinar/topjava/web/meal/MealUIController.java index dafc7a70f284..26ccf85374b0 100644 --- a/src/main/java/ru/javawebinar/topjava/web/meal/MealUIController.java +++ b/src/main/java/ru/javawebinar/topjava/web/meal/MealUIController.java @@ -1,15 +1,20 @@ package ru.javawebinar.topjava.web.meal; -import org.springframework.format.annotation.DateTimeFormat; +import com.fasterxml.jackson.annotation.JsonView; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.lang.Nullable; +import org.springframework.validation.BindingResult; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.View.ValidatedUI; import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.to.MealTo; +import ru.javawebinar.topjava.util.ValidationUtil; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; import java.util.List; @@ -19,10 +24,18 @@ public class MealUIController extends AbstractMealController { @Override @GetMapping + @JsonView(View.JsonUI.class) public List getAll() { return super.getAll(); } + @Override + @GetMapping( "/{id}") + @JsonView(View.JsonUI.class) + public Meal get(@PathVariable int id) { + return super.get(id); + } + @Override @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) @@ -32,14 +45,22 @@ public void delete(@PathVariable int id) { @PostMapping @ResponseStatus(HttpStatus.NO_CONTENT) - public void create(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime dateTime, - @RequestParam String description, - @RequestParam int calories) { - super.create(new Meal(null, dateTime, description, calories)); + public ResponseEntity createOrUpdate(@Validated(ValidatedUI.class) Meal meal, BindingResult result) { + if (result.hasErrors()) { + // TODO change to exception handler + return ValidationUtil.getErrorResponse(result); + } + if (meal.isNew()) { + super.create(meal); + } else { + super.update(meal, meal.getId()); + } + return ResponseEntity.ok().build(); } @Override @GetMapping("/filter") + @JsonView(View.JsonUI.class) public List getBetween( @RequestParam @Nullable LocalDate startDate, @RequestParam @Nullable LocalTime startTime, diff --git a/src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java b/src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java index 1b8a9432146f..18e3698e8f52 100644 --- a/src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java +++ b/src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java @@ -29,10 +29,10 @@ public User get(int id) { return service.get(id); } - public void create(UserTo userTo) { + public User create(UserTo userTo) { log.info("create {}", userTo); checkNew(userTo); - service.create(UsersUtil.createNewFromTo(userTo)); + return service.create(UsersUtil.createNewFromTo(userTo)); } public User create(User user) { diff --git a/src/main/java/ru/javawebinar/topjava/web/user/AdminUIController.java b/src/main/java/ru/javawebinar/topjava/web/user/AdminUIController.java index 199daa8f1916..0f52185605b8 100644 --- a/src/main/java/ru/javawebinar/topjava/web/user/AdminUIController.java +++ b/src/main/java/ru/javawebinar/topjava/web/user/AdminUIController.java @@ -7,10 +7,10 @@ import org.springframework.web.bind.annotation.*; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.to.UserTo; +import ru.javawebinar.topjava.util.ValidationUtil; import javax.validation.Valid; import java.util.List; -import java.util.stream.Collectors; @RestController @RequestMapping(value = "/admin/users", produces = MediaType.APPLICATION_JSON_VALUE) @@ -39,10 +39,8 @@ public void delete(@PathVariable int id) { @ResponseStatus(HttpStatus.NO_CONTENT) public ResponseEntity createOrUpdate(@Valid UserTo userTo, BindingResult result) { if (result.hasErrors()) { - String errorFieldsMsg = result.getFieldErrors().stream() - .map(fe -> String.format("[%s] %s", fe.getField(), fe.getDefaultMessage())) - .collect(Collectors.joining("
")); - return ResponseEntity.unprocessableEntity().body(errorFieldsMsg); + // TODO change to exception handler + return ValidationUtil.getErrorResponse(result); } if (userTo.isNew()) { super.create(userTo); diff --git a/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java b/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java index ccc43013a471..c99f2ccaa689 100644 --- a/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java +++ b/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java @@ -2,10 +2,14 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.to.UserTo; +import java.net.URI; + import static ru.javawebinar.topjava.web.SecurityUtil.authUserId; @RestController @@ -24,6 +28,15 @@ public void delete() { super.delete(authUserId()); } + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public ResponseEntity register(@RequestBody UserTo userTo) { + User created = super.create(userTo); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL).build().toUri(); + return ResponseEntity.created(uriOfNewResource).body(created); + } + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.NO_CONTENT) public void update(@RequestBody UserTo userTo) { diff --git a/src/main/java/ru/javawebinar/topjava/web/user/ProfileUIController.java b/src/main/java/ru/javawebinar/topjava/web/user/ProfileUIController.java new file mode 100644 index 000000000000..657396a7dc18 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/user/ProfileUIController.java @@ -0,0 +1,54 @@ +package ru.javawebinar.topjava.web.user; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.support.SessionStatus; +import ru.javawebinar.topjava.to.UserTo; +import ru.javawebinar.topjava.web.SecurityUtil; + +import javax.validation.Valid; + +@Controller +@RequestMapping("/profile") +public class ProfileUIController extends AbstractUserController { + + @GetMapping + public String profile() { + return "profile"; + } + + @PostMapping + public String updateProfile(@Valid UserTo userTo, BindingResult result, SessionStatus status) { + if (result.hasErrors()) { + return "profile"; + } else { + super.update(userTo, SecurityUtil.authUserId()); + SecurityUtil.get().setTo(userTo); + status.setComplete(); + return "redirect:/meals"; + } + } + + @GetMapping("/register") + public String register(ModelMap model) { + model.addAttribute("userTo", new UserTo()); + model.addAttribute("register", true); + return "profile"; + } + + @PostMapping("/register") + public String saveRegister(@Valid UserTo userTo, BindingResult result, SessionStatus status, ModelMap model) { + if (result.hasErrors()) { + model.addAttribute("register", true); + return "profile"; + } else { + super.create(userTo); + status.setComplete(); + return "redirect:/login?message=app.registered&username=" + userTo.getEmail(); + } + } +} \ No newline at end of file diff --git a/src/main/resources/db/populateDB.sql b/src/main/resources/db/populateDB.sql index 9e9bd828babe..8d66cc0e5e12 100644 --- a/src/main/resources/db/populateDB.sql +++ b/src/main/resources/db/populateDB.sql @@ -3,10 +3,10 @@ DELETE FROM meal; DELETE FROM users; ALTER SEQUENCE global_seq RESTART WITH 100000; -INSERT INTO users (name, email, password) -VALUES ('User', 'user@yandex.ru', 'password'), - ('Admin', 'admin@gmail.com', 'admin'), - ('Guest', 'guest@gmail.com', 'guest'); +INSERT INTO users (name, email, password, calories_per_day) +VALUES ('User', 'user@yandex.ru', '{noop}password', 2005), + ('Admin', 'admin@gmail.com', '{noop}admin', 1900), + ('Guest', 'guest@gmail.com', '{noop}guest', 2000); INSERT INTO user_role (role, user_id) VALUES ('USER', 100000), diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index e5ed3ccdebf1..e2b565616949 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -23,7 +23,8 @@ - + + diff --git a/src/main/resources/spring/spring-db.xml b/src/main/resources/spring/spring-db.xml index 48afdb11a749..9c090f3c544a 100644 --- a/src/main/resources/spring/spring-db.xml +++ b/src/main/resources/spring/spring-db.xml @@ -93,6 +93,9 @@ + + + + + @@ -65,4 +69,8 @@ + + + + \ No newline at end of file diff --git a/src/main/resources/spring/spring-security.xml b/src/main/resources/spring/spring-security.xml index 97b944b4ed59..efcda19fe5ee 100644 --- a/src/main/resources/spring/spring-security.xml +++ b/src/main/resources/spring/spring-security.xml @@ -10,14 +10,16 @@ + - + + @@ -26,12 +28,11 @@ authentication-failure-url="/login?error=true" login-processing-url="/spring_security_check"/> - - + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp b/src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp index 8e14ccf823dd..62f05f9a4ac3 100644 --- a/src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp +++ b/src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp @@ -1,14 +1,30 @@ <%@page contentType="text/html" pageEncoding="UTF-8" %> <%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> +<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> +<%@taglib prefix="form" uri="http://www.springframework.org/tags/form" %>

diff --git a/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp b/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp index 86e49b422d48..cc302e868a64 100644 --- a/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp +++ b/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp @@ -4,6 +4,10 @@ + + + + <spring:message code="app.title"/> @@ -12,12 +16,14 @@ + <%--http://stackoverflow.com/a/24070373/548473--%> - + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/fragments/i18n.jsp b/src/main/webapp/WEB-INF/jsp/fragments/i18n.jsp new file mode 100644 index 000000000000..98bd60fa9c8b --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/fragments/i18n.jsp @@ -0,0 +1,14 @@ +<%@ page contentType="text/html" pageEncoding="UTF-8" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/login.jsp b/src/main/webapp/WEB-INF/jsp/login.jsp index 443f6c4344b4..7f29ad7a76fc 100644 --- a/src/main/webapp/WEB-INF/jsp/login.jsp +++ b/src/main/webapp/WEB-INF/jsp/login.jsp @@ -1,22 +1,12 @@ <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> +<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> - +
@@ -26,16 +16,17 @@
-
-

- - -

-
+ +
+ » + + +
+

Spring Security, Spring MVC, @@ -67,10 +58,17 @@
diff --git a/src/main/webapp/WEB-INF/jsp/meals.jsp b/src/main/webapp/WEB-INF/jsp/meals.jsp index 6d5159a9da6b..783b3b2cc8c5 100644 --- a/src/main/webapp/WEB-INF/jsp/meals.jsp +++ b/src/main/webapp/WEB-INF/jsp/meals.jsp @@ -17,21 +17,21 @@
-
+
- +
-
+
- +
-
+
- +
-
+
- +
@@ -62,21 +62,6 @@ - - - - - <%--${meal.dateTime.toLocalDate()} ${meal.dateTime.toLocalTime()}--%> - <%--<%=TimeUtil.toString(meal.getDateTime())%>--%> - <%--${fn:replace(meal.dateTime, 'T', ' ')}--%> - ${fn:formatDateTime(meal.dateTime)} - - ${meal.description} - ${meal.calories} - - - -
@@ -85,7 +70,7 @@