From 9f0638bb4205e957b68aa5da817d56102d10ab5d Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Thu, 27 Jan 2022 02:30:43 +0300 Subject: [PATCH 01/19] init --- .gitignore | 6 +++ pom.xml | 44 +++++++++++++++++++ .../java/ru/javawebinar/topjava/Main.java | 11 +++++ 3 files changed, 61 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/ru/javawebinar/topjava/Main.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b4860155a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea +out +target +*.iml +log +*.patch \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 000000000..0b1c2896d --- /dev/null +++ b/pom.xml @@ -0,0 +1,44 @@ + + 4.0.0 + + ru.javawebinar + topjava + jar + + 1.0-SNAPSHOT + + Calories Management + http://topjava.herokuapp.com/ + + + 1.8 + UTF-8 + UTF-8 + + + + topjava + install + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + ${java.version} + ${java.version} + + + + + + + + + + + + + + diff --git a/src/main/java/ru/javawebinar/topjava/Main.java b/src/main/java/ru/javawebinar/topjava/Main.java new file mode 100644 index 000000000..c2f9cc618 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/Main.java @@ -0,0 +1,11 @@ +package ru.javawebinar.topjava; + +/** + * @see Demo application + * @see Initial project + */ +public class Main { + public static void main(String[] args) { + System.out.format("Hello TopJava Enterprise!"); + } +} From 4f0d895c485aa924c5ef9755b157b28b984ca47b Mon Sep 17 00:00:00 2001 From: gkislin Date: Thu, 26 May 2022 00:36:40 +0300 Subject: [PATCH 02/19] add readme --- README.md | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 000000000..b10c5718d --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +Java Enterprise Online Project +=============================== + +Наиболее востребованные технологии /инструменты / фреймворки Java Enterprise: +Maven/ Spring/ Security/ JPA(Hibernate)/ REST(Jackson)/ Bootstrap(CSS)/ jQuery + plugins. + +- [Вступительное занятие](https://github.com/JavaOPs/topjava) +- [Описание и план проекта](https://github.com/JavaOPs/topjava/blob/master/description.md) +- [Wiki](https://github.com/JavaOPs/topjava/wiki) +- [Wiki Git](https://github.com/JavaOPs/topjava/wiki/Git) +- [Wiki IDEA](https://github.com/JavaOPs/topjava/wiki/IDEA) +- [Демо разрабатываемого приложения](http://topjava.herokuapp.com/) + +### 26.05: Старт проекта +- Начало проверки [вступительного задания](https://github.com/JavaOPs/topjava#-Домашнее-задание-hw0) + +#### 31.05 Дедлайн на сдачу HW0 +### 02.06: 1-е занятие +#### 03.06 Дедлайн подачи заявки на [дипломную программу](https://javaops.ru/view/register/diploma) +- Разбор домашнего задания вступительного занятия (вместе с Optional) +- Обзор используемых в проекте технологий. Интеграция ПО +- Maven +- WAR. Веб-контейнер Tomcat. Сервлеты +- Логирование +- Уровни и зависимости логгирования. JMX +- Домашнее задание 1-го занятия (HW1 + Optional) + +### 09.06: 2-е занятие +- Разбор домашнего задания HW1 + Optional +- Библиотека vs Фреймворк. Стандартные библиотеки Apache Commons, Guava +- Слои приложения. Создание каркаса приложения +- Обзор Spring Framework. Spring Context +- Пояснения к HW2. Обработка Autowired +- Домашнее задание (HW2 + Optional) + +### 16.06: 3-е занятие +- Разбор домашнего задания HW2 + Optional +- Жизненный цикл Spring контекста +- Тестирование через JUnit +- Spring Test +- Базы данных. Обзор NoSQL и Java persistence solution без ORM +- Установка PostgreSQL. Docker +- Настройка Database в IDEA +- Скрипты инициализации базы. Spring Jdbc Template +- Тестирование UserService через AssertJ +- Логирование тестов +- Домашнее задание (HW3 + Optional) + +### 23.06: 4-е занятие +- Разбор домашнего задания HW3 + Optional +- Методы улучшения качества кода +- Spring: инициализация и популирование DB +- Подмена контекста при тестировании +- ORM. Hibernate. JPA +- Поддержка HSQLDB +- Домашнее задание (HW4 + Optional) +#### Начало выполнения [выпускного проекта](https://github.com/JavaOPs/topjava/blob/master/graduation.md) + +### 30.06: 5-е занятие +- Обзор JDK 9/17. Миграция Topjava с 1.8 на 17 +- Разбор вопросов +- Разбор домашнего задания HW4 + Optional +- Транзакции +- Профили Maven и Spring +- Пул коннектов +- Spring Data JPA +- Spring кэш +- Домашнее задание (HW5 + Optional) + +### 07.07: 6-е занятие +- Разбор домашнего задания HW5 + Optional +- Кэш Hibernate +- Spring Web +- JSP, JSTL, internationalization +- Динамическое изменение профиля при запуске +- Конфигурирование Tomcat через maven plugin. Jndi-lookup +- Spring Web MVC +- Spring Internationalization +- Домашнее задание (HW6 + Optional) + +#### Большое ДЗ + выпускной проект + начинаем [курс BootJava](https://javaops.ru/view/bootjava) + подтягиваем "хвосты". + +### 21.07: 7-е занятие +- Разбор домашнего задания HW6 + Optional +- Автогенерация DDL по модели +- Тестирование Spring MVC +- Миграция на JUnit 5 +- Принципы REST. REST контроллеры +- Тестирование REST контроллеров. Jackson +- jackson-datatype-hibernate. Тестирование через матчеры +- Тестирование через SoapUi. UTF-8 +- Домашнее задание (HW7 + Optional) + +### 28.07: 8-е занятие +- Разбор домашнего задания HW7 + Optional +- WebJars. jQuery и JavaScript frameworks +- Bootstrap +- AJAX. Datatables. jQuery +- jQuery notifications plugin +- Добавление Spring Security +- Домашнее задание (HW8 + Optional) + +### 04.08: 9-е занятие +- Разбор домашнего задания HW8 + Optional +- Spring Binding +- Spring Validation +- Перевод DataTables на Ajax +- Форма login / logout +- Реализация собственного провайдера авторицазии +- Принцип работы Spring Security. Проксирование +- Spring Security Test +- Cookie. Session +- Домашнее задание (HW9 + Optional) + +### 11.08: 10-е занятие +- Разбор домашнего задания HW10 + Optional +- Кастомизация JSON (@JsonView) и валидации (groups) +- Рефакторинг: jQuery конверторы и группы валидации по умолчанию +- Spring Security Taglib. Method Security Expressions +- Интерсепторы. Редактирование профиля. JSP tag files +- Форма регистрации +- Обработка исключений в Spring +- Encoding password +- Миграция на Spring 5 +- Защита от межсайтовой подделки запросов (CSRF) +- Домашнее задание (HW10) + +### 18.08: 11-е занятие +- Разбор домашнего задания HW10 + Optional +- Локализация datatables, ошибок валидации +- Защита от XSS (Cross Site Scripting) +- Обработка ошибок 404 (NotFound) +- Доступ к AuthorizedUser +- Ограничение модификации пользователей +- Деплой [приложения в Heroku](http://topjava.herokuapp.com) +- Собеседование. Разработка ПО +- Возможные доработки приложения +- Домашнее задание по проекту: составление резюме + +### 22.08: Миграция на Spring-Boot +- Основы Spring Boot. Spring Boot maven plugin +- Lombok, база H2, ApplicationRunner +- Spring Data REST + HATEOAS +- Миграция приложения подсчета калорий на Spring Boot + +### 11.09.22: Дедлайн на сдачу [выпускного проекта](https://github.com/JavaOPs/topjava/blob/master/graduation.md) +### 21.09.22: Получение дипломов для участников [Дипломной программы](https://javaops.ru/view/register/diploma) From adb661232e688265ab3da0af82f67a0a7db36859 Mon Sep 17 00:00:00 2001 From: gkislin Date: Thu, 26 May 2022 00:39:26 +0300 Subject: [PATCH 03/19] prepare_to_HW0_patch --- .../javawebinar/topjava/model/UserMeal.java | 29 ++++++++++++++ .../topjava/model/UserMealWithExcess.java | 30 ++++++++++++++ .../ru/javawebinar/topjava/util/TimeUtil.java | 9 +++++ .../topjava/util/UserMealsUtil.java | 39 +++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 src/main/java/ru/javawebinar/topjava/model/UserMeal.java create mode 100644 src/main/java/ru/javawebinar/topjava/model/UserMealWithExcess.java create mode 100644 src/main/java/ru/javawebinar/topjava/util/TimeUtil.java create mode 100644 src/main/java/ru/javawebinar/topjava/util/UserMealsUtil.java diff --git a/src/main/java/ru/javawebinar/topjava/model/UserMeal.java b/src/main/java/ru/javawebinar/topjava/model/UserMeal.java new file mode 100644 index 000000000..d8f91b127 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/UserMeal.java @@ -0,0 +1,29 @@ +package ru.javawebinar.topjava.model; + +import java.time.LocalDateTime; + +public class UserMeal { + private final LocalDateTime dateTime; + + private final String description; + + private final int calories; + + public UserMeal(LocalDateTime dateTime, String description, int calories) { + this.dateTime = dateTime; + this.description = description; + this.calories = calories; + } + + public LocalDateTime getDateTime() { + return dateTime; + } + + public String getDescription() { + return description; + } + + public int getCalories() { + return calories; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/model/UserMealWithExcess.java b/src/main/java/ru/javawebinar/topjava/model/UserMealWithExcess.java new file mode 100644 index 000000000..d0aa431a3 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/UserMealWithExcess.java @@ -0,0 +1,30 @@ +package ru.javawebinar.topjava.model; + +import java.time.LocalDateTime; + +public class UserMealWithExcess { + private final LocalDateTime dateTime; + + private final String description; + + private final int calories; + + private final boolean excess; + + public UserMealWithExcess(LocalDateTime dateTime, String description, int calories, boolean excess) { + this.dateTime = dateTime; + this.description = description; + this.calories = calories; + this.excess = excess; + } + + @Override + public String toString() { + return "UserMealWithExcess{" + + "dateTime=" + dateTime + + ", description='" + description + '\'' + + ", calories=" + calories + + ", excess=" + excess + + '}'; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/util/TimeUtil.java b/src/main/java/ru/javawebinar/topjava/util/TimeUtil.java new file mode 100644 index 000000000..0ebfdb5fc --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/TimeUtil.java @@ -0,0 +1,9 @@ +package ru.javawebinar.topjava.util; + +import java.time.LocalTime; + +public class TimeUtil { + public static boolean isBetweenHalfOpen(LocalTime lt, LocalTime startTime, LocalTime endTime) { + return lt.compareTo(startTime) >= 0 && lt.compareTo(endTime) < 0; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/util/UserMealsUtil.java b/src/main/java/ru/javawebinar/topjava/util/UserMealsUtil.java new file mode 100644 index 000000000..3c171b4a5 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/UserMealsUtil.java @@ -0,0 +1,39 @@ +package ru.javawebinar.topjava.util; + +import ru.javawebinar.topjava.model.UserMeal; +import ru.javawebinar.topjava.model.UserMealWithExcess; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Month; +import java.util.Arrays; +import java.util.List; + +public class UserMealsUtil { + public static void main(String[] args) { + List meals = Arrays.asList( + new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 30, 10, 0), "Завтрак", 500), + new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 30, 13, 0), "Обед", 1000), + new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 30, 20, 0), "Ужин", 500), + new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 31, 0, 0), "Еда на граничное значение", 100), + new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 31, 10, 0), "Завтрак", 1000), + new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 31, 13, 0), "Обед", 500), + new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 31, 20, 0), "Ужин", 410) + ); + + List mealsTo = filteredByCycles(meals, LocalTime.of(7, 0), LocalTime.of(12, 0), 2000); + mealsTo.forEach(System.out::println); + +// System.out.println(filteredByStreams(meals, LocalTime.of(7, 0), LocalTime.of(12, 0), 2000)); + } + + public static List filteredByCycles(List meals, LocalTime startTime, LocalTime endTime, int caloriesPerDay) { + // TODO return filtered list with excess. Implement by cycles + return null; + } + + public static List filteredByStreams(List meals, LocalTime startTime, LocalTime endTime, int caloriesPerDay) { + // TODO Implement by streams + return null; + } +} From aaa8093ecaa6e1fefffaf9a7e25a0901363bc8d0 Mon Sep 17 00:00:00 2001 From: gkislin Date: Thu, 2 Jun 2022 08:14:29 +0300 Subject: [PATCH 04/19] 1_0_rename --- .../model/{UserMeal.java => Meal.java} | 4 +- .../{UserMealWithExcess.java => MealTo.java} | 6 +-- .../javawebinar/topjava/util/MealsUtil.java | 39 +++++++++++++++++++ .../topjava/util/UserMealsUtil.java | 39 ------------------- 4 files changed, 44 insertions(+), 44 deletions(-) rename src/main/java/ru/javawebinar/topjava/model/{UserMeal.java => Meal.java} (83%) rename src/main/java/ru/javawebinar/topjava/model/{UserMealWithExcess.java => MealTo.java} (77%) create mode 100644 src/main/java/ru/javawebinar/topjava/util/MealsUtil.java delete mode 100644 src/main/java/ru/javawebinar/topjava/util/UserMealsUtil.java diff --git a/src/main/java/ru/javawebinar/topjava/model/UserMeal.java b/src/main/java/ru/javawebinar/topjava/model/Meal.java similarity index 83% rename from src/main/java/ru/javawebinar/topjava/model/UserMeal.java rename to src/main/java/ru/javawebinar/topjava/model/Meal.java index d8f91b127..f546cef0f 100644 --- a/src/main/java/ru/javawebinar/topjava/model/UserMeal.java +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -2,14 +2,14 @@ import java.time.LocalDateTime; -public class UserMeal { +public class Meal { private final LocalDateTime dateTime; private final String description; private final int calories; - public UserMeal(LocalDateTime dateTime, String description, int calories) { + public Meal(LocalDateTime dateTime, String description, int calories) { this.dateTime = dateTime; this.description = description; this.calories = calories; diff --git a/src/main/java/ru/javawebinar/topjava/model/UserMealWithExcess.java b/src/main/java/ru/javawebinar/topjava/model/MealTo.java similarity index 77% rename from src/main/java/ru/javawebinar/topjava/model/UserMealWithExcess.java rename to src/main/java/ru/javawebinar/topjava/model/MealTo.java index d0aa431a3..07f04f8db 100644 --- a/src/main/java/ru/javawebinar/topjava/model/UserMealWithExcess.java +++ b/src/main/java/ru/javawebinar/topjava/model/MealTo.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; -public class UserMealWithExcess { +public class MealTo { private final LocalDateTime dateTime; private final String description; @@ -11,7 +11,7 @@ public class UserMealWithExcess { private final boolean excess; - public UserMealWithExcess(LocalDateTime dateTime, String description, int calories, boolean excess) { + public MealTo(LocalDateTime dateTime, String description, int calories, boolean excess) { this.dateTime = dateTime; this.description = description; this.calories = calories; @@ -20,7 +20,7 @@ public UserMealWithExcess(LocalDateTime dateTime, String description, int calori @Override public String toString() { - return "UserMealWithExcess{" + + return "MealTo{" + "dateTime=" + dateTime + ", description='" + description + '\'' + ", calories=" + calories + diff --git a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java new file mode 100644 index 000000000..bb5ddbf5c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java @@ -0,0 +1,39 @@ +package ru.javawebinar.topjava.util; + +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.model.MealTo; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Month; +import java.util.Arrays; +import java.util.List; + +public class MealsUtil { + public static void main(String[] args) { + List meals = Arrays.asList( + new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 10, 0), "Завтрак", 500), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 13, 0), "Обед", 1000), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 20, 0), "Ужин", 500), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 0, 0), "Еда на граничное значение", 100), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 10, 0), "Завтрак", 1000), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 13, 0), "Обед", 500), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 20, 0), "Ужин", 410) + ); + + List mealsTo = filteredByCycles(meals, LocalTime.of(7, 0), LocalTime.of(12, 0), 2000); + mealsTo.forEach(System.out::println); + +// System.out.println(filteredByStreams(meals, LocalTime.of(7, 0), LocalTime.of(12, 0), 2000)); + } + + public static List filteredByCycles(List meals, LocalTime startTime, LocalTime endTime, int caloriesPerDay) { + // TODO return filtered list with excess. Implement by cycles + return null; + } + + public static List filteredByStreams(List meals, LocalTime startTime, LocalTime endTime, int caloriesPerDay) { + // TODO Implement by streams + return null; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/util/UserMealsUtil.java b/src/main/java/ru/javawebinar/topjava/util/UserMealsUtil.java deleted file mode 100644 index 3c171b4a5..000000000 --- a/src/main/java/ru/javawebinar/topjava/util/UserMealsUtil.java +++ /dev/null @@ -1,39 +0,0 @@ -package ru.javawebinar.topjava.util; - -import ru.javawebinar.topjava.model.UserMeal; -import ru.javawebinar.topjava.model.UserMealWithExcess; - -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.Month; -import java.util.Arrays; -import java.util.List; - -public class UserMealsUtil { - public static void main(String[] args) { - List meals = Arrays.asList( - new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 30, 10, 0), "Завтрак", 500), - new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 30, 13, 0), "Обед", 1000), - new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 30, 20, 0), "Ужин", 500), - new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 31, 0, 0), "Еда на граничное значение", 100), - new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 31, 10, 0), "Завтрак", 1000), - new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 31, 13, 0), "Обед", 500), - new UserMeal(LocalDateTime.of(2020, Month.JANUARY, 31, 20, 0), "Ужин", 410) - ); - - List mealsTo = filteredByCycles(meals, LocalTime.of(7, 0), LocalTime.of(12, 0), 2000); - mealsTo.forEach(System.out::println); - -// System.out.println(filteredByStreams(meals, LocalTime.of(7, 0), LocalTime.of(12, 0), 2000)); - } - - public static List filteredByCycles(List meals, LocalTime startTime, LocalTime endTime, int caloriesPerDay) { - // TODO return filtered list with excess. Implement by cycles - return null; - } - - public static List filteredByStreams(List meals, LocalTime startTime, LocalTime endTime, int caloriesPerDay) { - // TODO Implement by streams - return null; - } -} From ee3f5c3bc9e4e1c4961136be44f08adcb178be62 Mon Sep 17 00:00:00 2001 From: gkislin Date: Thu, 2 Jun 2022 08:14:57 +0300 Subject: [PATCH 05/19] 1_1_HW0_streams --- .../ru/javawebinar/topjava/model/Meal.java | 10 +++++++ .../javawebinar/topjava/util/MealsUtil.java | 26 ++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/main/java/ru/javawebinar/topjava/model/Meal.java b/src/main/java/ru/javawebinar/topjava/model/Meal.java index f546cef0f..943ff5cd5 100644 --- a/src/main/java/ru/javawebinar/topjava/model/Meal.java +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -1,6 +1,8 @@ package ru.javawebinar.topjava.model; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; public class Meal { private final LocalDateTime dateTime; @@ -26,4 +28,12 @@ public String getDescription() { public int getCalories() { return calories; } + + public LocalDate getDate() { + return dateTime.toLocalDate(); + } + + public LocalTime getTime() { + return dateTime.toLocalTime(); + } } diff --git a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java index bb5ddbf5c..c29e1fbbb 100644 --- a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java @@ -3,11 +3,14 @@ import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.model.MealTo; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.Month; import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; public class MealsUtil { public static void main(String[] args) { @@ -21,19 +24,24 @@ public static void main(String[] args) { new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 20, 0), "Ужин", 410) ); - List mealsTo = filteredByCycles(meals, LocalTime.of(7, 0), LocalTime.of(12, 0), 2000); + List mealsTo = filteredByStreams(meals, LocalTime.of(7, 0), LocalTime.of(12, 0), 2000); mealsTo.forEach(System.out::println); - -// System.out.println(filteredByStreams(meals, LocalTime.of(7, 0), LocalTime.of(12, 0), 2000)); } - public static List filteredByCycles(List meals, LocalTime startTime, LocalTime endTime, int caloriesPerDay) { - // TODO return filtered list with excess. Implement by cycles - return null; + public static List filteredByStreams(List meals, LocalTime startTime, LocalTime endTime, int caloriesPerDay) { + Map caloriesSumByDate = meals.stream() + .collect( + Collectors.groupingBy(Meal::getDate, Collectors.summingInt(Meal::getCalories)) +// Collectors.toMap(Meal::getDate, Meal::getCalories, Integer::sum) + ); + + return meals.stream() + .filter(meal -> TimeUtil.isBetweenHalfOpen(meal.getTime(), startTime, endTime)) + .map(meal -> createTo(meal, caloriesSumByDate.get(meal.getDate()) > caloriesPerDay)) + .collect(Collectors.toList()); } - public static List filteredByStreams(List meals, LocalTime startTime, LocalTime endTime, int caloriesPerDay) { - // TODO Implement by streams - return null; + private static MealTo createTo(Meal meal, boolean excess) { + return new MealTo(meal.getDateTime(), meal.getDescription(), meal.getCalories(), excess); } } From f444d48b457d1ea884af7da1020942ec06177b42 Mon Sep 17 00:00:00 2001 From: gkislin Date: Thu, 2 Jun 2022 08:17:02 +0300 Subject: [PATCH 06/19] 1_2_switch_to_war --- pom.xml | 2 +- src/main/webapp/WEB-INF/web.xml | 19 +++++++++++++++++++ src/main/webapp/index.html | 13 +++++++++++++ src/main/webapp/users.jsp | 11 +++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/main/webapp/WEB-INF/web.xml create mode 100644 src/main/webapp/index.html create mode 100644 src/main/webapp/users.jsp diff --git a/pom.xml b/pom.xml index 0b1c2896d..b4426c1aa 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ ru.javawebinar topjava - jar + war 1.0-SNAPSHOT diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 000000000..c63810c43 --- /dev/null +++ b/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,19 @@ + + + Topjava + + + userServlet + ru.javawebinar.topjava.web.UserServlet + 0 + + + userServlet + /users + + + diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html new file mode 100644 index 000000000..58d8d5ab6 --- /dev/null +++ b/src/main/webapp/index.html @@ -0,0 +1,13 @@ + + + + Java Enterprise (Topjava) + + +

Проект Java Enterprise (Topjava)

+
+ + + diff --git a/src/main/webapp/users.jsp b/src/main/webapp/users.jsp new file mode 100644 index 000000000..650c8dda4 --- /dev/null +++ b/src/main/webapp/users.jsp @@ -0,0 +1,11 @@ +<%@ page contentType="text/html;charset=UTF-8" %> + + + Users + + +

Home

+
+

Users

+ + \ No newline at end of file From ed82b0a7250f5918c9b69691ad5a01d5b53ad701 Mon Sep 17 00:00:00 2001 From: gkislin Date: Thu, 2 Jun 2022 08:17:54 +0300 Subject: [PATCH 07/19] 1_3_add_servlet_api --- pom.xml | 7 +++++++ .../ru/javawebinar/topjava/web/UserServlet.java | 15 +++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/main/java/ru/javawebinar/topjava/web/UserServlet.java diff --git a/pom.xml b/pom.xml index b4426c1aa..3cd703f93 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,13 @@ + + + javax.servlet + javax.servlet-api + 4.0.1 + provided + diff --git a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java new file mode 100644 index 000000000..76056e06c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java @@ -0,0 +1,15 @@ +package ru.javawebinar.topjava.web; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class UserServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + request.getRequestDispatcher("/users.jsp").forward(request, response); + } +} From f1f25b433c2029eedcc1b3de4d24ca5dc5a62ed6 Mon Sep 17 00:00:00 2001 From: gkislin Date: Thu, 2 Jun 2022 08:18:42 +0300 Subject: [PATCH 08/19] 1_4_forward_to_redirect --- pom.xml | 2 +- src/main/java/ru/javawebinar/topjava/web/UserServlet.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 3cd703f93..f7abfea2b 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ topjava - install + package org.apache.maven.plugins diff --git a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java index 76056e06c..11f282bac 100644 --- a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java +++ b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java @@ -10,6 +10,7 @@ public class UserServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - request.getRequestDispatcher("/users.jsp").forward(request, response); +// request.getRequestDispatcher("/users.jsp").forward(request, response); + response.sendRedirect("users.jsp"); } } From e4ffe526695631c138d3c34b3527fce99fdfa650 Mon Sep 17 00:00:00 2001 From: gkislin Date: Thu, 2 Jun 2022 08:21:26 +0300 Subject: [PATCH 09/19] 1_5_logging --- pom.xml | 19 ++++++++++++ .../javawebinar/topjava/web/UserServlet.java | 7 +++++ src/main/resources/logback.xml | 29 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 src/main/resources/logback.xml diff --git a/pom.xml b/pom.xml index f7abfea2b..e8dc01d33 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,10 @@ 1.8 UTF-8 UTF-8 + + + 1.2.11 + 1.7.36 @@ -34,6 +38,21 @@ + + + org.slf4j + slf4j-api + ${slf4j.version} + compile + + + + ch.qos.logback + logback-classic + ${logback.version} + runtime + + javax.servlet diff --git a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java index 11f282bac..ef52d6757 100644 --- a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java +++ b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java @@ -1,15 +1,22 @@ package ru.javawebinar.topjava.web; +import org.slf4j.Logger; + import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import static org.slf4j.LoggerFactory.getLogger; + public class UserServlet extends HttpServlet { + private static final Logger log = getLogger(UserServlet.class); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + log.debug("redirect to users"); + // request.getRequestDispatcher("/users.jsp").forward(request, response); response.sendRedirect("users.jsp"); } diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 000000000..e9b900b26 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,29 @@ + + + + + + + + ${TOPJAVA_ROOT}/log/topjava.log + + + UTF-8 + %date %-5level %logger{0} [%file:%line] %msg%n + + + + + + UTF-8 + %-5level %logger{0} [%file:%line] %msg%n + + + + + + + + + + From e1c71c6a273184fb5e716d1eaac9576bb694927b Mon Sep 17 00:00:00 2001 From: art94timer Date: Tue, 14 Jun 2022 21:13:15 +0300 Subject: [PATCH 10/19] prepare HW02 --- pom.xml | 15 +++ .../java/ru/javawebinar/topjava/Main.java | 11 --- .../ru/javawebinar/topjava/SpringMain.java | 20 ++++ .../topjava/model/AbstractBaseEntity.java | 26 +++++ .../topjava/model/AbstractNamedEntity.java | 24 +++++ .../ru/javawebinar/topjava/model/Meal.java | 29 ++++++ .../ru/javawebinar/topjava/model/MealTo.java | 28 +++++- .../ru/javawebinar/topjava/model/Role.java | 6 ++ .../ru/javawebinar/topjava/model/User.java | 95 +++++++++++++++++++ .../topjava/repository/MealRepository.java | 20 ++++ .../topjava/repository/UserRepository.java | 21 ++++ .../inmemory/InMemoryMealRepository.java | 46 +++++++++ .../inmemory/InMemoryUserRepository.java | 45 +++++++++ .../topjava/service/MealService.java | 9 ++ .../topjava/service/UserService.java | 44 +++++++++ .../topjava/util/DateTimeUtil.java | 18 ++++ .../javawebinar/topjava/util/MealsUtil.java | 39 ++++---- .../ru/javawebinar/topjava/util/TimeUtil.java | 9 -- .../topjava/util/ValidationUtil.java | 43 +++++++++ .../util/exception/NotFoundException.java | 7 ++ .../javawebinar/topjava/web/MealServlet.java | 77 +++++++++++++++ .../javawebinar/topjava/web/SecurityUtil.java | 14 +++ .../javawebinar/topjava/web/UserServlet.java | 6 +- .../topjava/web/meal/MealRestController.java | 8 ++ .../web/user/AbstractUserController.java | 51 ++++++++++ .../topjava/web/user/AdminRestController.java | 40 ++++++++ .../web/user/ProfileRestController.java | 22 +++++ src/main/resources/spring/spring-app.xml | 18 ++++ src/main/webapp/WEB-INF/tld/functions.tld | 16 ++++ src/main/webapp/WEB-INF/web.xml | 10 ++ src/main/webapp/index.html | 3 +- src/main/webapp/mealForm.jsp | 51 ++++++++++ src/main/webapp/meals.jsp | 54 +++++++++++ 33 files changed, 882 insertions(+), 43 deletions(-) delete mode 100644 src/main/java/ru/javawebinar/topjava/Main.java create mode 100644 src/main/java/ru/javawebinar/topjava/SpringMain.java create mode 100644 src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java create mode 100644 src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java create mode 100644 src/main/java/ru/javawebinar/topjava/model/Role.java create mode 100644 src/main/java/ru/javawebinar/topjava/model/User.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/MealRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/UserRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/service/MealService.java create mode 100644 src/main/java/ru/javawebinar/topjava/service/UserService.java create mode 100644 src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java delete mode 100644 src/main/java/ru/javawebinar/topjava/util/TimeUtil.java create mode 100644 src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java create mode 100644 src/main/java/ru/javawebinar/topjava/util/exception/NotFoundException.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/MealServlet.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java create mode 100644 src/main/resources/spring/spring-app.xml create mode 100644 src/main/webapp/WEB-INF/tld/functions.tld create mode 100644 src/main/webapp/mealForm.jsp create mode 100644 src/main/webapp/meals.jsp diff --git a/pom.xml b/pom.xml index e8dc01d33..4961d1850 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,8 @@ UTF-8 UTF-8 + 5.3.20 + 1.2.11 1.7.36 @@ -53,6 +55,13 @@ runtime + + + org.springframework + spring-context + ${spring.version} + + javax.servlet @@ -60,6 +69,12 @@ 4.0.1 provided + + + javax.servlet + jstl + 1.2 + diff --git a/src/main/java/ru/javawebinar/topjava/Main.java b/src/main/java/ru/javawebinar/topjava/Main.java deleted file mode 100644 index c2f9cc618..000000000 --- a/src/main/java/ru/javawebinar/topjava/Main.java +++ /dev/null @@ -1,11 +0,0 @@ -package ru.javawebinar.topjava; - -/** - * @see Demo application - * @see Initial project - */ -public class Main { - public static void main(String[] args) { - System.out.format("Hello TopJava Enterprise!"); - } -} diff --git a/src/main/java/ru/javawebinar/topjava/SpringMain.java b/src/main/java/ru/javawebinar/topjava/SpringMain.java new file mode 100644 index 000000000..85d0832e8 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/SpringMain.java @@ -0,0 +1,20 @@ +package ru.javawebinar.topjava; + +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.support.ClassPathXmlApplicationContext; +import ru.javawebinar.topjava.model.Role; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.web.user.AdminRestController; + +import java.util.Arrays; + +public class SpringMain { + public static void main(String[] args) { + // java 7 automatic resource management (ARM) + try (ConfigurableApplicationContext appCtx = new ClassPathXmlApplicationContext("spring/spring-app.xml")) { + System.out.println("Bean definition names: " + Arrays.toString(appCtx.getBeanDefinitionNames())); + AdminRestController adminUserController = appCtx.getBean(AdminRestController.class); + adminUserController.create(new User(null, "userName", "email@mail.ru", "password", Role.ADMIN)); + } + } +} diff --git a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java new file mode 100644 index 000000000..8f27c902e --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java @@ -0,0 +1,26 @@ +package ru.javawebinar.topjava.model; + +public abstract class AbstractBaseEntity { + protected Integer id; + + protected AbstractBaseEntity(Integer id) { + this.id = id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getId() { + return id; + } + + public boolean isNew() { + return this.id == null; + } + + @Override + public String toString() { + return getClass().getSimpleName() + ":" + id; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java b/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java new file mode 100644 index 000000000..2054a3d3c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java @@ -0,0 +1,24 @@ +package ru.javawebinar.topjava.model; + +public abstract class AbstractNamedEntity extends AbstractBaseEntity { + + protected String name; + + protected AbstractNamedEntity(Integer id, String name) { + super(id); + this.name = name; + } + + public void setName(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + @Override + public String toString() { + return super.toString() + '(' + name + ')'; + } +} \ 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 943ff5cd5..3abbee425 100644 --- a/src/main/java/ru/javawebinar/topjava/model/Meal.java +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -5,6 +5,8 @@ import java.time.LocalTime; public class Meal { + private Integer id; + private final LocalDateTime dateTime; private final String description; @@ -12,11 +14,24 @@ public class Meal { private final int calories; public Meal(LocalDateTime dateTime, String description, int calories) { + this(null, dateTime, description, calories); + } + + public Meal(Integer id, LocalDateTime dateTime, String description, int calories) { + this.id = id; this.dateTime = dateTime; this.description = description; this.calories = calories; } + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + public LocalDateTime getDateTime() { return dateTime; } @@ -36,4 +51,18 @@ public LocalDate getDate() { public LocalTime getTime() { return dateTime.toLocalTime(); } + + public boolean isNew() { + return id == null; + } + + @Override + public String toString() { + return "Meal{" + + "id=" + id + + ", dateTime=" + dateTime + + ", description='" + description + '\'' + + ", calories=" + calories + + '}'; + } } diff --git a/src/main/java/ru/javawebinar/topjava/model/MealTo.java b/src/main/java/ru/javawebinar/topjava/model/MealTo.java index 07f04f8db..01b3a5fda 100644 --- a/src/main/java/ru/javawebinar/topjava/model/MealTo.java +++ b/src/main/java/ru/javawebinar/topjava/model/MealTo.java @@ -3,6 +3,8 @@ import java.time.LocalDateTime; public class MealTo { + private final Integer id; + private final LocalDateTime dateTime; private final String description; @@ -11,17 +13,39 @@ public class MealTo { private final boolean excess; - public MealTo(LocalDateTime dateTime, String description, int calories, boolean excess) { + public MealTo(Integer id, LocalDateTime dateTime, String description, int calories, boolean excess) { + this.id = id; this.dateTime = dateTime; this.description = description; this.calories = calories; this.excess = excess; } + public Integer getId() { + return id; + } + + public LocalDateTime getDateTime() { + return dateTime; + } + + public String getDescription() { + return description; + } + + public int getCalories() { + return calories; + } + + public boolean isExcess() { + return excess; + } + @Override public String toString() { return "MealTo{" + - "dateTime=" + dateTime + + "id=" + id + + ", dateTime=" + dateTime + ", description='" + description + '\'' + ", calories=" + calories + ", excess=" + excess + diff --git a/src/main/java/ru/javawebinar/topjava/model/Role.java b/src/main/java/ru/javawebinar/topjava/model/Role.java new file mode 100644 index 000000000..acb7a276f --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/Role.java @@ -0,0 +1,6 @@ +package ru.javawebinar.topjava.model; + +public enum Role { + USER, + ADMIN +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/model/User.java b/src/main/java/ru/javawebinar/topjava/model/User.java new file mode 100644 index 000000000..b11abc469 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/User.java @@ -0,0 +1,95 @@ +package ru.javawebinar.topjava.model; + +import org.springframework.util.CollectionUtils; + +import java.util.*; + +import static ru.javawebinar.topjava.util.MealsUtil.DEFAULT_CALORIES_PER_DAY; + +public class User extends AbstractNamedEntity { + + private String email; + + private String password; + + private boolean enabled = true; + + private Date registered = new Date(); + + private Set roles; + + private int caloriesPerDay = DEFAULT_CALORIES_PER_DAY; + + public User(Integer id, String name, String email, String password, Role... roles) { + this(id, name, email, password, DEFAULT_CALORIES_PER_DAY, true, Arrays.asList((roles))); + } + + public User(Integer id, String name, String email, String password, int caloriesPerDay, boolean enabled, Collection roles) { + super(id, name); + this.email = email; + this.password = password; + this.caloriesPerDay = caloriesPerDay; + this.enabled = enabled; + setRoles(roles); + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public void setPassword(String password) { + this.password = password; + } + + public Date getRegistered() { + return registered; + } + + public void setRegistered(Date registered) { + this.registered = registered; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getCaloriesPerDay() { + return caloriesPerDay; + } + + public void setCaloriesPerDay(int caloriesPerDay) { + this.caloriesPerDay = caloriesPerDay; + } + + public boolean isEnabled() { + return enabled; + } + + public Set getRoles() { + return roles; + } + + public void setRoles(Collection roles) { + this.roles = CollectionUtils.isEmpty(roles) ? EnumSet.noneOf(Role.class) : EnumSet.copyOf(roles); + } + + public String getPassword() { + return password; + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", email=" + email + + ", name=" + name + + ", enabled=" + enabled + + ", roles=" + roles + + ", caloriesPerDay=" + caloriesPerDay + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java new file mode 100644 index 000000000..675cdbb1b --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java @@ -0,0 +1,20 @@ +package ru.javawebinar.topjava.repository; + +import ru.javawebinar.topjava.model.Meal; + +import java.util.Collection; + +// TODO add userId +public interface MealRepository { + // null if updated meal does not belong to userId + Meal save(Meal meal); + + // false if meal does not belong to userId + boolean delete(int id); + + // null if meal does not belong to userId + Meal get(int id); + + // ORDERED dateTime desc + Collection getAll(); +} diff --git a/src/main/java/ru/javawebinar/topjava/repository/UserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/UserRepository.java new file mode 100644 index 000000000..138369789 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/UserRepository.java @@ -0,0 +1,21 @@ +package ru.javawebinar.topjava.repository; + +import ru.javawebinar.topjava.model.User; + +import java.util.List; + +public interface UserRepository { + // null if not found, when updated + User save(User user); + + // false if not found + boolean delete(int id); + + // null if not found + User get(int id); + + // null if not found + User getByEmail(String email); + + List getAll(); +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java new file mode 100644 index 000000000..3c7c9ff94 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java @@ -0,0 +1,46 @@ +package ru.javawebinar.topjava.repository.inmemory; + +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.repository.MealRepository; +import ru.javawebinar.topjava.util.MealsUtil; + +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +public class InMemoryMealRepository implements MealRepository { + private final Map repository = new ConcurrentHashMap<>(); + private final AtomicInteger counter = new AtomicInteger(0); + + { + MealsUtil.meals.forEach(this::save); + } + + @Override + public Meal save(Meal meal) { + if (meal.isNew()) { + meal.setId(counter.incrementAndGet()); + repository.put(meal.getId(), meal); + return meal; + } + // handle case: update, but not present in storage + return repository.computeIfPresent(meal.getId(), (id, oldMeal) -> meal); + } + + @Override + public boolean delete(int id) { + return repository.remove(id) != null; + } + + @Override + public Meal get(int id) { + return repository.get(id); + } + + @Override + public Collection getAll() { + return repository.values(); + } +} + diff --git a/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java new file mode 100644 index 000000000..e2f8a9b8b --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java @@ -0,0 +1,45 @@ +package ru.javawebinar.topjava.repository.inmemory; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Repository; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.repository.UserRepository; + +import java.util.Collections; +import java.util.List; + +@Repository +public class InMemoryUserRepository implements UserRepository { + private static final Logger log = LoggerFactory.getLogger(InMemoryUserRepository.class); + + @Override + public boolean delete(int id) { + log.info("delete {}", id); + return true; + } + + @Override + public User save(User user) { + log.info("save {}", user); + return user; + } + + @Override + public User get(int id) { + log.info("get {}", id); + return null; + } + + @Override + public List getAll() { + log.info("getAll"); + return Collections.emptyList(); + } + + @Override + public User getByEmail(String email) { + log.info("getByEmail {}", email); + return null; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/service/MealService.java b/src/main/java/ru/javawebinar/topjava/service/MealService.java new file mode 100644 index 000000000..0dc4a43c3 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/service/MealService.java @@ -0,0 +1,9 @@ +package ru.javawebinar.topjava.service; + +import ru.javawebinar.topjava.repository.MealRepository; + +public class MealService { + + private MealRepository repository; + +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/service/UserService.java b/src/main/java/ru/javawebinar/topjava/service/UserService.java new file mode 100644 index 000000000..8fbe8dc06 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/service/UserService.java @@ -0,0 +1,44 @@ +package ru.javawebinar.topjava.service; + +import org.springframework.stereotype.Service; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.repository.UserRepository; + +import java.util.List; + +import static ru.javawebinar.topjava.util.ValidationUtil.checkNotFound; +import static ru.javawebinar.topjava.util.ValidationUtil.checkNotFoundWithId; + +@Service +public class UserService { + + private final UserRepository repository; + + public UserService(UserRepository repository) { + this.repository = repository; + } + + public User create(User user) { + return repository.save(user); + } + + public void delete(int id) { + checkNotFoundWithId(repository.delete(id), id); + } + + public User get(int id) { + return checkNotFoundWithId(repository.get(id), id); + } + + public User getByEmail(String email) { + return checkNotFound(repository.getByEmail(email), "email=" + email); + } + + public List getAll() { + return repository.getAll(); + } + + public void update(User user) { + checkNotFoundWithId(repository.save(user), user.getId()); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java b/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java new file mode 100644 index 000000000..3f23f83fd --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java @@ -0,0 +1,18 @@ +package ru.javawebinar.topjava.util; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +public class DateTimeUtil { + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + public static boolean isBetweenHalfOpen(LocalTime lt, LocalTime startTime, LocalTime endTime) { + return lt.compareTo(startTime) >= 0 && lt.compareTo(endTime) < 0; + } + + public static String toString(LocalDateTime ldt) { + return ldt == null ? "" : ldt.format(DATE_TIME_FORMATTER); + } +} + diff --git a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java index c29e1fbbb..8d940a63e 100644 --- a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java @@ -8,27 +8,34 @@ import java.time.LocalTime; import java.time.Month; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.function.Predicate; import java.util.stream.Collectors; public class MealsUtil { - public static void main(String[] args) { - List meals = Arrays.asList( - new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 10, 0), "Завтрак", 500), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 13, 0), "Обед", 1000), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 20, 0), "Ужин", 500), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 0, 0), "Еда на граничное значение", 100), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 10, 0), "Завтрак", 1000), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 13, 0), "Обед", 500), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 20, 0), "Ужин", 410) - ); - - List mealsTo = filteredByStreams(meals, LocalTime.of(7, 0), LocalTime.of(12, 0), 2000); - mealsTo.forEach(System.out::println); + public static final int DEFAULT_CALORIES_PER_DAY = 2000; + + public static final List meals = Arrays.asList( + new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 10, 0), "Завтрак", 500), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 13, 0), "Обед", 1000), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 20, 0), "Ужин", 500), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 0, 0), "Еда на граничное значение", 100), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 10, 0), "Завтрак", 1000), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 13, 0), "Обед", 500), + new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 20, 0), "Ужин", 410) + ); + + public static List getTos(Collection meals, int caloriesPerDay) { + return filterByPredicate(meals, caloriesPerDay, meal -> true); + } + + public static List getFilteredTos(Collection meals, int caloriesPerDay, LocalTime startTime, LocalTime endTime) { + return filterByPredicate(meals, caloriesPerDay, meal -> DateTimeUtil.isBetweenHalfOpen(meal.getTime(), startTime, endTime)); } - public static List filteredByStreams(List meals, LocalTime startTime, LocalTime endTime, int caloriesPerDay) { + private static List filterByPredicate(Collection meals, int caloriesPerDay, Predicate filter) { Map caloriesSumByDate = meals.stream() .collect( Collectors.groupingBy(Meal::getDate, Collectors.summingInt(Meal::getCalories)) @@ -36,12 +43,12 @@ public static List filteredByStreams(List meals, LocalTime startTi ); return meals.stream() - .filter(meal -> TimeUtil.isBetweenHalfOpen(meal.getTime(), startTime, endTime)) + .filter(filter) .map(meal -> createTo(meal, caloriesSumByDate.get(meal.getDate()) > caloriesPerDay)) .collect(Collectors.toList()); } private static MealTo createTo(Meal meal, boolean excess) { - return new MealTo(meal.getDateTime(), meal.getDescription(), meal.getCalories(), excess); + return new MealTo(meal.getId(), meal.getDateTime(), meal.getDescription(), meal.getCalories(), excess); } } diff --git a/src/main/java/ru/javawebinar/topjava/util/TimeUtil.java b/src/main/java/ru/javawebinar/topjava/util/TimeUtil.java deleted file mode 100644 index 0ebfdb5fc..000000000 --- a/src/main/java/ru/javawebinar/topjava/util/TimeUtil.java +++ /dev/null @@ -1,9 +0,0 @@ -package ru.javawebinar.topjava.util; - -import java.time.LocalTime; - -public class TimeUtil { - public static boolean isBetweenHalfOpen(LocalTime lt, LocalTime startTime, LocalTime endTime) { - return lt.compareTo(startTime) >= 0 && lt.compareTo(endTime) < 0; - } -} diff --git a/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java b/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java new file mode 100644 index 000000000..971eb9c0c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java @@ -0,0 +1,43 @@ +package ru.javawebinar.topjava.util; + + +import ru.javawebinar.topjava.model.AbstractBaseEntity; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +public class ValidationUtil { + + public static T checkNotFoundWithId(T object, int id) { + checkNotFoundWithId(object != null, id); + return object; + } + + public static void checkNotFoundWithId(boolean found, int id) { + checkNotFound(found, "id=" + id); + } + + public static T checkNotFound(T object, String msg) { + checkNotFound(object != null, msg); + return object; + } + + public static void checkNotFound(boolean found, String msg) { + if (!found) { + throw new NotFoundException("Not found entity with " + msg); + } + } + + public static void checkNew(AbstractBaseEntity entity) { + if (!entity.isNew()) { + throw new IllegalArgumentException(entity + " must be new (id=null)"); + } + } + + public static void assureIdConsistent(AbstractBaseEntity entity, int id) { +// conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473) + if (entity.isNew()) { + entity.setId(id); + } else if (entity.getId() != id) { + throw new IllegalArgumentException(entity + " must be with id=" + id); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/exception/NotFoundException.java b/src/main/java/ru/javawebinar/topjava/util/exception/NotFoundException.java new file mode 100644 index 000000000..f1e9b0e46 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package ru.javawebinar.topjava.util.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/MealServlet.java b/src/main/java/ru/javawebinar/topjava/web/MealServlet.java new file mode 100644 index 000000000..5017a6287 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/MealServlet.java @@ -0,0 +1,77 @@ +package ru.javawebinar.topjava.web; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.repository.MealRepository; +import ru.javawebinar.topjava.repository.inmemory.InMemoryMealRepository; +import ru.javawebinar.topjava.util.MealsUtil; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Objects; + +public class MealServlet extends HttpServlet { + private static final Logger log = LoggerFactory.getLogger(MealServlet.class); + + private MealRepository repository; + + @Override + public void init() { + repository = new InMemoryMealRepository(); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + request.setCharacterEncoding("UTF-8"); + String id = request.getParameter("id"); + + Meal meal = new Meal(id.isEmpty() ? null : Integer.valueOf(id), + LocalDateTime.parse(request.getParameter("dateTime")), + request.getParameter("description"), + Integer.parseInt(request.getParameter("calories"))); + + log.info(meal.isNew() ? "Create {}" : "Update {}", meal); + repository.save(meal); + response.sendRedirect("meals"); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + String action = request.getParameter("action"); + + switch (action == null ? "all" : action) { + case "delete": + int id = getId(request); + log.info("Delete id={}", id); + repository.delete(id); + response.sendRedirect("meals"); + break; + case "create": + case "update": + final Meal meal = "create".equals(action) ? + new Meal(LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES), "", 1000) : + repository.get(getId(request)); + request.setAttribute("meal", meal); + request.getRequestDispatcher("/mealForm.jsp").forward(request, response); + break; + case "all": + default: + log.info("getAll"); + request.setAttribute("meals", + MealsUtil.getTos(repository.getAll(), MealsUtil.DEFAULT_CALORIES_PER_DAY)); + request.getRequestDispatcher("/meals.jsp").forward(request, response); + break; + } + } + + private int getId(HttpServletRequest request) { + String paramId = Objects.requireNonNull(request.getParameter("id")); + return Integer.parseInt(paramId); + } +} diff --git a/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java b/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java new file mode 100644 index 000000000..e78a4b284 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java @@ -0,0 +1,14 @@ +package ru.javawebinar.topjava.web; + +import static ru.javawebinar.topjava.util.MealsUtil.DEFAULT_CALORIES_PER_DAY; + +public class SecurityUtil { + + public static int authUserId() { + return 1; + } + + public static int authUserCaloriesPerDay() { + return DEFAULT_CALORIES_PER_DAY; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java index ef52d6757..f6cf12e69 100644 --- a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java +++ b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java @@ -15,9 +15,7 @@ public class UserServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - log.debug("redirect to users"); - -// request.getRequestDispatcher("/users.jsp").forward(request, response); - response.sendRedirect("users.jsp"); + log.debug("forward to users"); + request.getRequestDispatcher("/users.jsp").forward(request, response); } } diff --git a/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java b/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java new file mode 100644 index 000000000..ab4e8ea8b --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java @@ -0,0 +1,8 @@ +package ru.javawebinar.topjava.web.meal; + +import ru.javawebinar.topjava.service.MealService; + +public class MealRestController { + private MealService service; + +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java b/src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java new file mode 100644 index 000000000..0000f1c1e --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java @@ -0,0 +1,51 @@ +package ru.javawebinar.topjava.web.user; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.service.UserService; + +import java.util.List; + +import static ru.javawebinar.topjava.util.ValidationUtil.assureIdConsistent; +import static ru.javawebinar.topjava.util.ValidationUtil.checkNew; + +public abstract class AbstractUserController { + protected final Logger log = LoggerFactory.getLogger(getClass()); + + @Autowired + private UserService service; + + public List getAll() { + log.info("getAll"); + return service.getAll(); + } + + public User get(int id) { + log.info("get {}", id); + return service.get(id); + } + + public User create(User user) { + log.info("create {}", user); + checkNew(user); + return service.create(user); + } + + public void delete(int id) { + log.info("delete {}", id); + service.delete(id); + } + + public void update(User user, int id) { + log.info("update {} with id={}", user, id); + assureIdConsistent(user, id); + service.update(user); + } + + public User getByMail(String email) { + log.info("getByEmail {}", email); + return service.getByEmail(email); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java b/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java new file mode 100644 index 000000000..b37a8ed6c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java @@ -0,0 +1,40 @@ +package ru.javawebinar.topjava.web.user; + +import org.springframework.stereotype.Controller; +import ru.javawebinar.topjava.model.User; + +import java.util.List; + +@Controller +public class AdminRestController extends AbstractUserController { + + @Override + public List getAll() { + return super.getAll(); + } + + @Override + public User get(int id) { + return super.get(id); + } + + @Override + public User create(User user) { + return super.create(user); + } + + @Override + public void delete(int id) { + super.delete(id); + } + + @Override + public void update(User user, int id) { + super.update(user, id); + } + + @Override + public User getByMail(String email) { + return super.getByMail(email); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java b/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java new file mode 100644 index 000000000..7d3702c31 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java @@ -0,0 +1,22 @@ +package ru.javawebinar.topjava.web.user; + +import org.springframework.stereotype.Controller; +import ru.javawebinar.topjava.model.User; + +import static ru.javawebinar.topjava.web.SecurityUtil.authUserId; + +@Controller +public class ProfileRestController extends AbstractUserController { + + public User get() { + return super.get(authUserId()); + } + + public void delete() { + super.delete(authUserId()); + } + + public void update(User user) { + super.update(user, authUserId()); + } +} \ No newline at end of file diff --git a/src/main/resources/spring/spring-app.xml b/src/main/resources/spring/spring-app.xml new file mode 100644 index 000000000..cac42ba13 --- /dev/null +++ b/src/main/resources/spring/spring-app.xml @@ -0,0 +1,18 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/tld/functions.tld b/src/main/webapp/WEB-INF/tld/functions.tld new file mode 100644 index 000000000..d138fecdb --- /dev/null +++ b/src/main/webapp/WEB-INF/tld/functions.tld @@ -0,0 +1,16 @@ + + + + 1.0 + functions + http://topjava.javawebinar.ru/functions + + + formatDateTime + ru.javawebinar.topjava.util.DateTimeUtil + java.lang.String toString(java.time.LocalDateTime) + + diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index c63810c43..bd98d3bf3 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -16,4 +16,14 @@ /users + + mealServlet + ru.javawebinar.topjava.web.MealServlet + 0 + + + mealServlet + /meals + + diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html index 58d8d5ab6..714683bd3 100644 --- a/src/main/webapp/index.html +++ b/src/main/webapp/index.html @@ -6,8 +6,9 @@

Проект Java Enterprise (Topjava)


-
    + diff --git a/src/main/webapp/mealForm.jsp b/src/main/webapp/mealForm.jsp new file mode 100644 index 000000000..28f140b65 --- /dev/null +++ b/src/main/webapp/mealForm.jsp @@ -0,0 +1,51 @@ +<%@ page contentType="text/html;charset=UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + + + Meal + + + +
    +

    Home

    +
    +

    ${param.action == 'create' ? 'Create meal' : 'Edit meal'}

    + +
    + +
    +
    DateTime:
    +
    +
    +
    +
    Description:
    +
    +
    +
    +
    Calories:
    +
    +
    + + +
    +
    + + diff --git a/src/main/webapp/meals.jsp b/src/main/webapp/meals.jsp new file mode 100644 index 000000000..224d98761 --- /dev/null +++ b/src/main/webapp/meals.jsp @@ -0,0 +1,54 @@ +<%@ page contentType="text/html;charset=UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> +<%@ taglib prefix="fn" uri="http://topjava.javawebinar.ru/functions" %> +<%--<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>--%> + + + Meal list + + + +
    +

    Home

    +
    +

    Meals

    + Add Meal +

    + + + + + + + + + + + + + + + + + + + + +
    DateDescriptionCalories
    + <%--${meal.dateTime.toLocalDate()} ${meal.dateTime.toLocalTime()}--%> + <%--<%=TimeUtil.toString(meal.getDateTime())%>--%> + <%--${fn:replace(meal.dateTime, 'T', ' ')}--%> + ${fn:formatDateTime(meal.dateTime)} + ${meal.description}${meal.calories}UpdateDelete
    +
    + + \ No newline at end of file From 835217667acea3c796c2f466aed4df16255a67d8 Mon Sep 17 00:00:00 2001 From: art94timer Date: Sun, 19 Jun 2022 10:25:56 +0300 Subject: [PATCH 11/19] prepare HW03 --- pom.xml | 53 +++++++++++ .../ru/javawebinar/topjava/SpringMain.java | 16 ++++ .../topjava/model/AbstractBaseEntity.java | 22 +++++ .../topjava/model/AbstractNamedEntity.java | 3 + .../ru/javawebinar/topjava/model/Meal.java | 18 +--- .../ru/javawebinar/topjava/model/MealTo.java | 2 +- .../ru/javawebinar/topjava/model/User.java | 12 ++- .../topjava/repository/MealRepository.java | 15 +-- .../inmemory/InMemoryMealRepository.java | 46 --------- .../inmemory/InMemoryUserRepository.java | 45 --------- .../repository/jdbc/JdbcMealRepository.java | 37 ++++++++ .../repository/jdbc/JdbcUserRepository.java | 81 ++++++++++++++++ .../topjava/service/MealService.java | 40 +++++++- .../topjava/util/DateTimeUtil.java | 28 +++++- .../javawebinar/topjava/util/MealsUtil.java | 4 +- .../ru/javawebinar/topjava/util/Util.java | 9 ++ .../javawebinar/topjava/web/MealServlet.java | 55 +++++++---- .../javawebinar/topjava/web/SecurityUtil.java | 10 +- .../javawebinar/topjava/web/UserServlet.java | 7 ++ .../topjava/web/meal/MealRestController.java | 70 +++++++++++++- src/main/resources/db/initDB.sql | 25 +++++ src/main/resources/db/populateDB.sql | 12 +++ src/main/resources/db/postgres.properties | 7 ++ src/main/resources/logback.xml | 4 +- src/main/resources/spring/spring-app.xml | 2 +- src/main/resources/spring/spring-db.xml | 25 +++++ src/main/webapp/css/style.css | 24 +++++ src/main/webapp/index.html | 12 ++- src/main/webapp/mealForm.jsp | 19 +--- src/main/webapp/meals.jsp | 38 +++++--- .../ru/javawebinar/topjava/UserTestData.java | 49 ++++++++++ .../inmemory/InMemoryBaseRepository.java | 42 ++++++++ .../inmemory/InMemoryMealRepository.java | 86 +++++++++++++++++ .../inmemory/InMemoryUserRepository.java | 40 ++++++++ .../topjava/service/UserServiceTest.java | 95 +++++++++++++++++++ ...InMemoryAdminRestControllerSpringTest.java | 43 +++++++++ .../user/InMemoryAdminRestControllerTest.java | 53 +++++++++++ src/test/resources/logback-test.xml | 22 +++++ 38 files changed, 990 insertions(+), 181 deletions(-) delete mode 100644 src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java delete mode 100644 src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/util/Util.java create mode 100644 src/main/resources/db/initDB.sql create mode 100644 src/main/resources/db/populateDB.sql create mode 100644 src/main/resources/db/postgres.properties create mode 100644 src/main/resources/spring/spring-db.xml create mode 100644 src/main/webapp/css/style.css create mode 100644 src/test/java/ru/javawebinar/topjava/UserTestData.java create mode 100644 src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryBaseRepository.java create mode 100644 src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java create mode 100644 src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java create mode 100644 src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java create mode 100644 src/test/resources/logback-test.xml diff --git a/pom.xml b/pom.xml index 4961d1850..7e05c1619 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,12 @@ 1.2.11 1.7.36 + + + 42.4.0 + + 4.13.2 + 3.23.1 @@ -36,6 +42,14 @@ ${java.version} + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + -Dfile.encoding=UTF-8 + + @@ -48,6 +62,13 @@ compile + + org.slf4j + jul-to-slf4j + ${slf4j.version} + runtime + + ch.qos.logback logback-classic @@ -61,6 +82,18 @@ spring-context ${spring.version} + + org.springframework + spring-jdbc + ${spring.version} + + + + + org.postgresql + postgresql + ${postgresql.version} + @@ -75,6 +108,26 @@ jstl 1.2 + + + + junit + junit + ${junit.version} + test + + + org.springframework + spring-test + ${spring.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + diff --git a/src/main/java/ru/javawebinar/topjava/SpringMain.java b/src/main/java/ru/javawebinar/topjava/SpringMain.java index 85d0832e8..b869d1c6e 100644 --- a/src/main/java/ru/javawebinar/topjava/SpringMain.java +++ b/src/main/java/ru/javawebinar/topjava/SpringMain.java @@ -4,9 +4,15 @@ import org.springframework.context.support.ClassPathXmlApplicationContext; import ru.javawebinar.topjava.model.Role; import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.to.MealTo; +import ru.javawebinar.topjava.web.meal.MealRestController; import ru.javawebinar.topjava.web.user.AdminRestController; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.Month; import java.util.Arrays; +import java.util.List; public class SpringMain { public static void main(String[] args) { @@ -15,6 +21,16 @@ public static void main(String[] args) { System.out.println("Bean definition names: " + Arrays.toString(appCtx.getBeanDefinitionNames())); AdminRestController adminUserController = appCtx.getBean(AdminRestController.class); adminUserController.create(new User(null, "userName", "email@mail.ru", "password", Role.ADMIN)); + System.out.println(); + + MealRestController mealController = appCtx.getBean(MealRestController.class); + List filteredMealsWithExcess = + mealController.getBetween( + LocalDate.of(2020, Month.JANUARY, 30), LocalTime.of(7, 0), + LocalDate.of(2020, Month.JANUARY, 31), LocalTime.of(11, 0)); + filteredMealsWithExcess.forEach(System.out::println); + System.out.println(); + System.out.println(mealController.getBetween(null, null, null, null)); } } } diff --git a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java index 8f27c902e..46e8d6e30 100644 --- a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java @@ -1,8 +1,13 @@ package ru.javawebinar.topjava.model; public abstract class AbstractBaseEntity { + public static final int START_SEQ = 100000; + protected Integer id; + public AbstractBaseEntity() { + } + protected AbstractBaseEntity(Integer id) { this.id = id; } @@ -23,4 +28,21 @@ public boolean isNew() { public String toString() { return getClass().getSimpleName() + ":" + id; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AbstractBaseEntity that = (AbstractBaseEntity) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return id == null ? 0 : id; + } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java b/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java index 2054a3d3c..5f0fde2bb 100644 --- a/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java @@ -4,6 +4,9 @@ public abstract class AbstractNamedEntity extends AbstractBaseEntity { protected String name; + public AbstractNamedEntity() { + } + protected AbstractNamedEntity(Integer id, String name) { super(id); this.name = name; diff --git a/src/main/java/ru/javawebinar/topjava/model/Meal.java b/src/main/java/ru/javawebinar/topjava/model/Meal.java index 3abbee425..9eed15f70 100644 --- a/src/main/java/ru/javawebinar/topjava/model/Meal.java +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -4,9 +4,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; -public class Meal { - private Integer id; - +public class Meal extends AbstractBaseEntity { private final LocalDateTime dateTime; private final String description; @@ -18,20 +16,12 @@ public Meal(LocalDateTime dateTime, String description, int calories) { } public Meal(Integer id, LocalDateTime dateTime, String description, int calories) { - this.id = id; + super(id); this.dateTime = dateTime; this.description = description; this.calories = calories; } - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - public LocalDateTime getDateTime() { return dateTime; } @@ -52,10 +42,6 @@ public LocalTime getTime() { return dateTime.toLocalTime(); } - public boolean isNew() { - return id == null; - } - @Override public String toString() { return "Meal{" + diff --git a/src/main/java/ru/javawebinar/topjava/model/MealTo.java b/src/main/java/ru/javawebinar/topjava/model/MealTo.java index 01b3a5fda..d14feae79 100644 --- a/src/main/java/ru/javawebinar/topjava/model/MealTo.java +++ b/src/main/java/ru/javawebinar/topjava/model/MealTo.java @@ -1,4 +1,4 @@ -package ru.javawebinar.topjava.model; +package ru.javawebinar.topjava.to; import java.time.LocalDateTime; diff --git a/src/main/java/ru/javawebinar/topjava/model/User.java b/src/main/java/ru/javawebinar/topjava/model/User.java index b11abc469..3114a5300 100644 --- a/src/main/java/ru/javawebinar/topjava/model/User.java +++ b/src/main/java/ru/javawebinar/topjava/model/User.java @@ -20,16 +20,24 @@ public class User extends AbstractNamedEntity { private int caloriesPerDay = DEFAULT_CALORIES_PER_DAY; + public User() { + } + + 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, Arrays.asList((roles))); + this(id, name, email, password, DEFAULT_CALORIES_PER_DAY, true, new Date(), Arrays.asList((roles))); } - public User(Integer id, String name, String email, String password, int caloriesPerDay, boolean enabled, Collection roles) { + public User(Integer id, String name, String email, String password, int caloriesPerDay, boolean enabled, Date registered, Collection roles) { super(id, name); this.email = email; this.password = password; this.caloriesPerDay = caloriesPerDay; this.enabled = enabled; + this.registered = registered; setRoles(roles); } diff --git a/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java index 675cdbb1b..9461d5f9f 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java @@ -2,19 +2,22 @@ import ru.javawebinar.topjava.model.Meal; -import java.util.Collection; +import java.time.LocalDateTime; +import java.util.List; -// TODO add userId public interface MealRepository { // null if updated meal does not belong to userId - Meal save(Meal meal); + Meal save(Meal meal, int userId); // false if meal does not belong to userId - boolean delete(int id); + boolean delete(int id, int userId); // null if meal does not belong to userId - Meal get(int id); + Meal get(int id, int userId); // ORDERED dateTime desc - Collection getAll(); + List getAll(int userId); + + // ORDERED dateTime desc + List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId); } diff --git a/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java deleted file mode 100644 index 3c7c9ff94..000000000 --- a/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java +++ /dev/null @@ -1,46 +0,0 @@ -package ru.javawebinar.topjava.repository.inmemory; - -import ru.javawebinar.topjava.model.Meal; -import ru.javawebinar.topjava.repository.MealRepository; -import ru.javawebinar.topjava.util.MealsUtil; - -import java.util.Collection; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; - -public class InMemoryMealRepository implements MealRepository { - private final Map repository = new ConcurrentHashMap<>(); - private final AtomicInteger counter = new AtomicInteger(0); - - { - MealsUtil.meals.forEach(this::save); - } - - @Override - public Meal save(Meal meal) { - if (meal.isNew()) { - meal.setId(counter.incrementAndGet()); - repository.put(meal.getId(), meal); - return meal; - } - // handle case: update, but not present in storage - return repository.computeIfPresent(meal.getId(), (id, oldMeal) -> meal); - } - - @Override - public boolean delete(int id) { - return repository.remove(id) != null; - } - - @Override - public Meal get(int id) { - return repository.get(id); - } - - @Override - public Collection getAll() { - return repository.values(); - } -} - diff --git a/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java deleted file mode 100644 index e2f8a9b8b..000000000 --- a/src/main/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java +++ /dev/null @@ -1,45 +0,0 @@ -package ru.javawebinar.topjava.repository.inmemory; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Repository; -import ru.javawebinar.topjava.model.User; -import ru.javawebinar.topjava.repository.UserRepository; - -import java.util.Collections; -import java.util.List; - -@Repository -public class InMemoryUserRepository implements UserRepository { - private static final Logger log = LoggerFactory.getLogger(InMemoryUserRepository.class); - - @Override - public boolean delete(int id) { - log.info("delete {}", id); - return true; - } - - @Override - public User save(User user) { - log.info("save {}", user); - return user; - } - - @Override - public User get(int id) { - log.info("get {}", id); - return null; - } - - @Override - public List getAll() { - log.info("getAll"); - return Collections.emptyList(); - } - - @Override - public User getByEmail(String email) { - log.info("getByEmail {}", email); - return null; - } -} diff --git a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java new file mode 100644 index 000000000..c29288c39 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java @@ -0,0 +1,37 @@ +package ru.javawebinar.topjava.repository.jdbc; + +import org.springframework.stereotype.Repository; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.repository.MealRepository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class JdbcMealRepository implements MealRepository { + + @Override + public Meal save(Meal meal, int userId) { + return null; + } + + @Override + public boolean delete(int id, int userId) { + return false; + } + + @Override + public Meal get(int id, int userId) { + return null; + } + + @Override + public List getAll(int userId) { + return null; + } + + @Override + public List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId) { + return null; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java new file mode 100644 index 000000000..7f6f8626a --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java @@ -0,0 +1,81 @@ +package ru.javawebinar.topjava.repository.jdbc; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.support.DataAccessUtils; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.simple.SimpleJdbcInsert; +import org.springframework.stereotype.Repository; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.repository.UserRepository; + +import java.util.List; + +@Repository +public class JdbcUserRepository implements UserRepository { + + private static final BeanPropertyRowMapper ROW_MAPPER = BeanPropertyRowMapper.newInstance(User.class); + + private final JdbcTemplate jdbcTemplate; + + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + + private final SimpleJdbcInsert insertUser; + + @Autowired + public JdbcUserRepository(JdbcTemplate jdbcTemplate, NamedParameterJdbcTemplate namedParameterJdbcTemplate) { + this.insertUser = new SimpleJdbcInsert(jdbcTemplate) + .withTableName("users") + .usingGeneratedKeyColumns("id"); + + this.jdbcTemplate = jdbcTemplate; + this.namedParameterJdbcTemplate = namedParameterJdbcTemplate; + } + + @Override + public User save(User user) { + MapSqlParameterSource map = new MapSqlParameterSource() + .addValue("id", user.getId()) + .addValue("name", user.getName()) + .addValue("email", user.getEmail()) + .addValue("password", user.getPassword()) + .addValue("registered", user.getRegistered()) + .addValue("enabled", user.isEnabled()) + .addValue("caloriesPerDay", user.getCaloriesPerDay()); + + if (user.isNew()) { + Number newKey = insertUser.executeAndReturnKey(map); + user.setId(newKey.intValue()); + } else if (namedParameterJdbcTemplate.update( + "UPDATE users SET name=:name, email=:email, password=:password, " + + "registered=:registered, enabled=:enabled, calories_per_day=:caloriesPerDay WHERE id=:id", map) == 0) { + return null; + } + return user; + } + + @Override + public boolean delete(int id) { + return jdbcTemplate.update("DELETE FROM users WHERE id=?", id) != 0; + } + + @Override + public User get(int id) { + List users = jdbcTemplate.query("SELECT * FROM users WHERE id=?", ROW_MAPPER, id); + return DataAccessUtils.singleResult(users); + } + + @Override + public User getByEmail(String email) { +// return jdbcTemplate.queryForObject("SELECT * FROM users WHERE email=?", ROW_MAPPER, email); + List users = jdbcTemplate.query("SELECT * FROM users WHERE email=?", ROW_MAPPER, email); + return DataAccessUtils.singleResult(users); + } + + @Override + public List getAll() { + return jdbcTemplate.query("SELECT * FROM users ORDER BY name, email", ROW_MAPPER); + } +} diff --git a/src/main/java/ru/javawebinar/topjava/service/MealService.java b/src/main/java/ru/javawebinar/topjava/service/MealService.java index 0dc4a43c3..7957a0895 100644 --- a/src/main/java/ru/javawebinar/topjava/service/MealService.java +++ b/src/main/java/ru/javawebinar/topjava/service/MealService.java @@ -1,9 +1,47 @@ package ru.javawebinar.topjava.service; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.repository.MealRepository; +import java.time.LocalDate; +import java.util.List; + +import static ru.javawebinar.topjava.util.DateTimeUtil.atStartOfDayOrMin; +import static ru.javawebinar.topjava.util.DateTimeUtil.atStartOfNextDayOrMax; +import static ru.javawebinar.topjava.util.ValidationUtil.checkNotFoundWithId; + +@Service public class MealService { - private MealRepository repository; + private final MealRepository repository; + + public MealService(MealRepository repository) { + this.repository = repository; + } + + public Meal get(int id, int userId) { + return checkNotFoundWithId(repository.get(id, userId), id); + } + + public void delete(int id, int userId) { + checkNotFoundWithId(repository.delete(id, userId), id); + } + + public List getBetweenInclusive(@Nullable LocalDate startDate, @Nullable LocalDate endDate, int userId) { + return repository.getBetweenHalfOpen(atStartOfDayOrMin(startDate), atStartOfNextDayOrMax(endDate), userId); + } + + public List getAll(int userId) { + return repository.getAll(userId); + } + + public void update(Meal meal, int userId) { + checkNotFoundWithId(repository.save(meal, userId), meal.getId()); + } + public Meal create(Meal meal, int userId) { + return repository.save(meal, userId); + } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java b/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java index 3f23f83fd..b63ecf506 100644 --- a/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java @@ -1,18 +1,40 @@ package ru.javawebinar.topjava.util; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; public class DateTimeUtil { private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - public static boolean isBetweenHalfOpen(LocalTime lt, LocalTime startTime, LocalTime endTime) { - return lt.compareTo(startTime) >= 0 && lt.compareTo(endTime) < 0; + // DB doesn't support LocalDate.MIN/MAX + private static final LocalDateTime MIN_DATE = LocalDateTime.of(1, 1, 1, 0, 0); + private static final LocalDateTime MAX_DATE = LocalDateTime.of(3000, 1, 1, 0, 0); + + public static LocalDateTime atStartOfDayOrMin(LocalDate localDate) { + return localDate != null ? localDate.atStartOfDay() : MIN_DATE; + } + + public static LocalDateTime atStartOfNextDayOrMax(LocalDate localDate) { + return localDate != null ? localDate.plus(1, ChronoUnit.DAYS).atStartOfDay() : MAX_DATE; } public static String toString(LocalDateTime ldt) { return ldt == null ? "" : ldt.format(DATE_TIME_FORMATTER); } -} + public static @Nullable + LocalDate parseLocalDate(@Nullable String str) { + return StringUtils.hasLength(str) ? LocalDate.parse(str) : null; + } + + public static @Nullable + LocalTime parseLocalTime(@Nullable String str) { + return StringUtils.hasLength(str) ? LocalTime.parse(str) : null; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java index 8d940a63e..d10e1e674 100644 --- a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java @@ -1,7 +1,7 @@ package ru.javawebinar.topjava.util; import ru.javawebinar.topjava.model.Meal; -import ru.javawebinar.topjava.model.MealTo; +import ru.javawebinar.topjava.to.MealTo; import java.time.LocalDate; import java.time.LocalDateTime; @@ -32,7 +32,7 @@ public static List getTos(Collection meals, int caloriesPerDay) { } public static List getFilteredTos(Collection meals, int caloriesPerDay, LocalTime startTime, LocalTime endTime) { - return filterByPredicate(meals, caloriesPerDay, meal -> DateTimeUtil.isBetweenHalfOpen(meal.getTime(), startTime, endTime)); + return filterByPredicate(meals, caloriesPerDay, meal -> Util.isBetweenHalfOpen(meal.getTime(), startTime, endTime)); } private static List filterByPredicate(Collection meals, int caloriesPerDay, Predicate filter) { diff --git a/src/main/java/ru/javawebinar/topjava/util/Util.java b/src/main/java/ru/javawebinar/topjava/util/Util.java new file mode 100644 index 000000000..a17a6927f --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/Util.java @@ -0,0 +1,9 @@ +package ru.javawebinar.topjava.util; + +import org.springframework.lang.Nullable; + +public class Util { + public static > boolean isBetweenHalfOpen(T value, @Nullable T start, @Nullable T end) { + return (start == null || value.compareTo(start) >= 0) && (end == null || value.compareTo(end) < 0); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/MealServlet.java b/src/main/java/ru/javawebinar/topjava/web/MealServlet.java index 5017a6287..a8f609938 100644 --- a/src/main/java/ru/javawebinar/topjava/web/MealServlet.java +++ b/src/main/java/ru/javawebinar/topjava/web/MealServlet.java @@ -1,43 +1,55 @@ package ru.javawebinar.topjava.web; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.support.ClassPathXmlApplicationContext; +import org.springframework.util.StringUtils; import ru.javawebinar.topjava.model.Meal; -import ru.javawebinar.topjava.repository.MealRepository; -import ru.javawebinar.topjava.repository.inmemory.InMemoryMealRepository; -import ru.javawebinar.topjava.util.MealsUtil; +import ru.javawebinar.topjava.web.meal.MealRestController; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.temporal.ChronoUnit; import java.util.Objects; +import static ru.javawebinar.topjava.util.DateTimeUtil.parseLocalDate; +import static ru.javawebinar.topjava.util.DateTimeUtil.parseLocalTime; + public class MealServlet extends HttpServlet { - private static final Logger log = LoggerFactory.getLogger(MealServlet.class); - private MealRepository repository; + private ConfigurableApplicationContext springContext; + private MealRestController mealController; @Override public void init() { - repository = new InMemoryMealRepository(); + springContext = new ClassPathXmlApplicationContext("spring/spring-app.xml", "spring/spring-db.xml"); + mealController = springContext.getBean(MealRestController.class); + } + + @Override + public void destroy() { + springContext.close(); + super.destroy(); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); - String id = request.getParameter("id"); - - Meal meal = new Meal(id.isEmpty() ? null : Integer.valueOf(id), + Meal meal = new Meal( LocalDateTime.parse(request.getParameter("dateTime")), request.getParameter("description"), Integer.parseInt(request.getParameter("calories"))); - log.info(meal.isNew() ? "Create {}" : "Update {}", meal); - repository.save(meal); + if (StringUtils.hasLength(request.getParameter("id"))) { + mealController.update(meal, getId(request)); + } else { + mealController.create(meal); + } response.sendRedirect("meals"); } @@ -48,23 +60,28 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) t switch (action == null ? "all" : action) { case "delete": int id = getId(request); - log.info("Delete id={}", id); - repository.delete(id); + mealController.delete(id); response.sendRedirect("meals"); break; case "create": case "update": final Meal meal = "create".equals(action) ? new Meal(LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES), "", 1000) : - repository.get(getId(request)); + mealController.get(getId(request)); request.setAttribute("meal", meal); request.getRequestDispatcher("/mealForm.jsp").forward(request, response); break; + case "filter": + LocalDate startDate = parseLocalDate(request.getParameter("startDate")); + LocalDate endDate = parseLocalDate(request.getParameter("endDate")); + LocalTime startTime = parseLocalTime(request.getParameter("startTime")); + LocalTime endTime = parseLocalTime(request.getParameter("endTime")); + request.setAttribute("meals", mealController.getBetween(startDate, startTime, endDate, endTime)); + request.getRequestDispatcher("/meals.jsp").forward(request, response); + break; case "all": default: - log.info("getAll"); - request.setAttribute("meals", - MealsUtil.getTos(repository.getAll(), MealsUtil.DEFAULT_CALORIES_PER_DAY)); + request.setAttribute("meals", mealController.getAll()); request.getRequestDispatcher("/meals.jsp").forward(request, response); break; } diff --git a/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java b/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java index e78a4b284..588217547 100644 --- a/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java +++ b/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java @@ -1,11 +1,19 @@ package ru.javawebinar.topjava.web; +import ru.javawebinar.topjava.model.AbstractBaseEntity; + import static ru.javawebinar.topjava.util.MealsUtil.DEFAULT_CALORIES_PER_DAY; public class SecurityUtil { + private static int id = AbstractBaseEntity.START_SEQ; + public static int authUserId() { - return 1; + return id; + } + + public static void setAuthUserId(int id) { + SecurityUtil.id = id; } public static int authUserCaloriesPerDay() { diff --git a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java index f6cf12e69..226023400 100644 --- a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java +++ b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java @@ -13,6 +13,13 @@ public class UserServlet extends HttpServlet { private static final Logger log = getLogger(UserServlet.class); + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + int userId = Integer.parseInt(request.getParameter("userId")); + SecurityUtil.setAuthUserId(userId); + response.sendRedirect("meals"); + } + @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { log.debug("forward to users"); diff --git a/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java b/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java index ab4e8ea8b..bbfe35e3f 100644 --- a/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java +++ b/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java @@ -1,8 +1,76 @@ package ru.javawebinar.topjava.web.meal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Controller; +import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.service.MealService; +import ru.javawebinar.topjava.to.MealTo; +import ru.javawebinar.topjava.util.MealsUtil; +import ru.javawebinar.topjava.web.SecurityUtil; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import static ru.javawebinar.topjava.util.ValidationUtil.assureIdConsistent; +import static ru.javawebinar.topjava.util.ValidationUtil.checkNew; + +@Controller public class MealRestController { - private MealService service; + private static final Logger log = LoggerFactory.getLogger(MealRestController.class); + + private final MealService service; + + public MealRestController(MealService service) { + this.service = service; + } + + public Meal get(int id) { + int userId = SecurityUtil.authUserId(); + log.info("get meal {} for user {}", id, userId); + return service.get(id, userId); + } + + public void delete(int id) { + int userId = SecurityUtil.authUserId(); + log.info("delete meal {} for user {}", id, userId); + service.delete(id, userId); + } + + public List getAll() { + int userId = SecurityUtil.authUserId(); + log.info("getAll for user {}", userId); + return MealsUtil.getTos(service.getAll(userId), SecurityUtil.authUserCaloriesPerDay()); + } + + public Meal create(Meal meal) { + int userId = SecurityUtil.authUserId(); + checkNew(meal); + log.info("create {} for user {}", meal, userId); + return service.create(meal, userId); + } + + public void update(Meal meal, int id) { + int userId = SecurityUtil.authUserId(); + assureIdConsistent(meal, id); + log.info("update {} for user {}", meal, userId); + service.update(meal, userId); + } + + /** + *
      Filter separately + *
    1. by date
    2. + *
    3. by time for every date
    4. + *
    + */ + public List getBetween(@Nullable LocalDate startDate, @Nullable LocalTime startTime, + @Nullable LocalDate endDate, @Nullable LocalTime endTime) { + int userId = SecurityUtil.authUserId(); + log.info("getBetween dates({} - {}) time({} - {}) for user {}", startDate, endDate, startTime, endTime, userId); + List mealsDateFiltered = service.getBetweenInclusive(startDate, endDate, userId); + return MealsUtil.getFilteredTos(mealsDateFiltered, SecurityUtil.authUserCaloriesPerDay(), startTime, endTime); + } } \ No newline at end of file diff --git a/src/main/resources/db/initDB.sql b/src/main/resources/db/initDB.sql new file mode 100644 index 000000000..57f3f2c06 --- /dev/null +++ b/src/main/resources/db/initDB.sql @@ -0,0 +1,25 @@ +DROP TABLE IF EXISTS user_roles; +DROP TABLE IF EXISTS users; +DROP SEQUENCE IF EXISTS global_seq; + +CREATE SEQUENCE global_seq START WITH 100000; + +CREATE TABLE users +( + id INTEGER PRIMARY KEY DEFAULT nextval('global_seq'), + name VARCHAR NOT NULL, + email VARCHAR NOT NULL, + password VARCHAR NOT NULL, + registered TIMESTAMP DEFAULT now() NOT NULL, + enabled BOOL DEFAULT TRUE NOT NULL, + calories_per_day INTEGER DEFAULT 2000 NOT NULL +); +CREATE UNIQUE INDEX users_unique_email_idx ON users (email); + +CREATE TABLE user_roles +( + user_id INTEGER NOT NULL, + role VARCHAR, + CONSTRAINT user_roles_idx UNIQUE (user_id, role), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/main/resources/db/populateDB.sql b/src/main/resources/db/populateDB.sql new file mode 100644 index 000000000..11375c6ea --- /dev/null +++ b/src/main/resources/db/populateDB.sql @@ -0,0 +1,12 @@ +DELETE FROM user_roles; +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 user_roles (role, user_id) +VALUES ('USER', 100000), + ('ADMIN', 100001); diff --git a/src/main/resources/db/postgres.properties b/src/main/resources/db/postgres.properties new file mode 100644 index 000000000..0e9abf404 --- /dev/null +++ b/src/main/resources/db/postgres.properties @@ -0,0 +1,7 @@ +#database.url=jdbc:postgresql://ec2-34-248-169-69.eu-west-1.compute.amazonaws.com:5432/d1ohm99dookbqn?ssl=true&sslmode=require&sslfactory=org.postgresql.ssl.NonValidatingFactory +#database.username=qhazsiozndzrzc +#database.password=749f7852a65b5ec57bde033af8fde7f8b782a3ef802921acd4613b133d62559e + +database.url=jdbc:postgresql://localhost:5432/topjava +database.username=user +database.password=password diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index e9b900b26..c7bffc3a9 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -9,14 +9,14 @@ UTF-8 - %date %-5level %logger{0} [%file:%line] %msg%n + %date %-5level %logger{50}.%M:%L - %msg%n UTF-8 - %-5level %logger{0} [%file:%line] %msg%n + %d{HH:mm:ss.SSS} %-5level %class{50}.%M:%L - %msg%n diff --git a/src/main/resources/spring/spring-app.xml b/src/main/resources/spring/spring-app.xml index cac42ba13..4c05aea49 100644 --- a/src/main/resources/spring/spring-app.xml +++ b/src/main/resources/spring/spring-app.xml @@ -9,7 +9,7 @@ --> - + diff --git a/src/main/resources/spring/spring-db.xml b/src/main/resources/spring/spring-db.xml new file mode 100644 index 000000000..8ec56c9e6 --- /dev/null +++ b/src/main/resources/spring/spring-db.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/css/style.css b/src/main/webapp/css/style.css new file mode 100644 index 000000000..cfffdcb53 --- /dev/null +++ b/src/main/webapp/css/style.css @@ -0,0 +1,24 @@ +dl { + background: none repeat scroll 0 0 #FAFAFA; + margin: 8px 0; + padding: 0; +} + +dt { + display: inline-block; + width: 170px; +} + +dd { + display: inline-block; + margin-left: 8px; + vertical-align: top; +} + +tr[data-meal-excess="false"] { + color: green; +} + +tr[data-meal-excess="true"] { + color: red; +} diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html index 714683bd3..57d710a81 100644 --- a/src/main/webapp/index.html +++ b/src/main/webapp/index.html @@ -6,9 +6,13 @@

    Проект Java Enterprise (Topjava)


    - +
    + Meals of  + + +
    diff --git a/src/main/webapp/mealForm.jsp b/src/main/webapp/mealForm.jsp index 28f140b65..98a6f4873 100644 --- a/src/main/webapp/mealForm.jsp +++ b/src/main/webapp/mealForm.jsp @@ -4,24 +4,7 @@ Meal - +
    diff --git a/src/main/webapp/meals.jsp b/src/main/webapp/meals.jsp index 224d98761..7d9bf3e42 100644 --- a/src/main/webapp/meals.jsp +++ b/src/main/webapp/meals.jsp @@ -2,25 +2,37 @@ <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <%@ taglib prefix="fn" uri="http://topjava.javawebinar.ru/functions" %> -<%--<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>--%> - Meal list - + Meals +

    Home


    Meals

    +
    + +
    +
    From Date (inclusive):
    +
    +
    +
    +
    To Date (inclusive):
    +
    +
    +
    +
    From Time (inclusive):
    +
    +
    +
    +
    To Time (exclusive):
    +
    +
    + +
    +
    Add Meal

    @@ -34,8 +46,8 @@ - - + + + + + + + + + + + +
    <%--${meal.dateTime.toLocalDate()} ${meal.dateTime.toLocalTime()}--%> <%--<%=TimeUtil.toString(meal.getDateTime())%>--%> diff --git a/src/test/java/ru/javawebinar/topjava/UserTestData.java b/src/test/java/ru/javawebinar/topjava/UserTestData.java new file mode 100644 index 000000000..125f43333 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/UserTestData.java @@ -0,0 +1,49 @@ +package ru.javawebinar.topjava; + +import ru.javawebinar.topjava.model.Role; +import ru.javawebinar.topjava.model.User; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static ru.javawebinar.topjava.model.AbstractBaseEntity.START_SEQ; + +public class UserTestData { + public static final int USER_ID = START_SEQ; + public static final int ADMIN_ID = START_SEQ + 1; + public static final int GUEST_ID = START_SEQ + 2; + public static final int NOT_FOUND = 10; + + public static final User user = new User(USER_ID, "User", "user@yandex.ru", "password", Role.USER); + public static final User admin = new User(ADMIN_ID, "Admin", "admin@gmail.com", "admin", Role.ADMIN); + public static final User guest = new User(GUEST_ID, "Guest", "guest@gmail.com", "guest"); + + public static User getNew() { + return new User(null, "New", "new@gmail.com", "newPass", 1555, false, new Date(), Collections.singleton(Role.USER)); + } + + public static User getUpdated() { + User updated = new User(user); + updated.setEmail("update@gmail.com"); + updated.setName("UpdatedName"); + updated.setCaloriesPerDay(330); + updated.setPassword("newPass"); + updated.setEnabled(false); + updated.setRoles(Collections.singletonList(Role.ADMIN)); + return updated; + } + + public static void assertMatch(User actual, User expected) { + assertThat(actual).usingRecursiveComparison().ignoringFields("registered", "roles").isEqualTo(expected); + } + + public static void assertMatch(Iterable actual, User... expected) { + assertMatch(actual, Arrays.asList(expected)); + } + + public static void assertMatch(Iterable actual, Iterable expected) { + assertThat(actual).usingRecursiveFieldByFieldElementComparatorIgnoringFields("registered", "roles").isEqualTo(expected); + } +} diff --git a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryBaseRepository.java b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryBaseRepository.java new file mode 100644 index 000000000..7d9f43b98 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryBaseRepository.java @@ -0,0 +1,42 @@ +package ru.javawebinar.topjava.repository.inmemory; + +import ru.javawebinar.topjava.model.AbstractBaseEntity; + +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static ru.javawebinar.topjava.model.AbstractBaseEntity.START_SEQ; + +public class InMemoryBaseRepository { + + static final AtomicInteger counter = new AtomicInteger(START_SEQ); + + final Map map = new ConcurrentHashMap<>(); + + public T save(T entity) { + if (entity.isNew()) { + entity.setId(counter.incrementAndGet()); + map.put(entity.getId(), entity); + return entity; + } + return map.computeIfPresent(entity.getId(), (id, oldT) -> entity); + } + + public boolean delete(int id) { + return map.remove(id) != null; + } + + public T get(int id) { + return map.get(id); + } + + Collection getCollection() { + return map.values(); + } + + void put(T entity) { + map.put(entity.getId(), entity); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java new file mode 100644 index 000000000..d8a0fada5 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java @@ -0,0 +1,86 @@ +package ru.javawebinar.topjava.repository.inmemory; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Repository; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.repository.MealRepository; +import ru.javawebinar.topjava.util.MealsUtil; +import ru.javawebinar.topjava.util.Util; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.time.LocalDateTime; +import java.time.Month; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static ru.javawebinar.topjava.UserTestData.ADMIN_ID; +import static ru.javawebinar.topjava.UserTestData.USER_ID; + +@Repository +public class InMemoryMealRepository implements MealRepository { + private static final Logger log = LoggerFactory.getLogger(InMemoryMealRepository.class); + + // Map userId -> mealRepository + private final Map> usersMealsMap = new ConcurrentHashMap<>(); + + { + MealsUtil.meals.forEach(meal -> save(meal, USER_ID)); + save(new Meal(LocalDateTime.of(2015, Month.JUNE, 1, 14, 0), "Админ ланч", 510), ADMIN_ID); + save(new Meal(LocalDateTime.of(2015, Month.JUNE, 1, 21, 0), "Админ ужин", 1500), ADMIN_ID); + } + + + @Override + public Meal save(Meal meal, int userId) { + InMemoryBaseRepository meals = usersMealsMap.computeIfAbsent(userId, uId -> new InMemoryBaseRepository<>()); + return meals.save(meal); + } + + @PostConstruct + public void postConstruct() { + log.info("+++ PostConstruct"); + } + + @PreDestroy + public void preDestroy() { + log.info("+++ PreDestroy"); + } + + @Override + public boolean delete(int id, int userId) { + InMemoryBaseRepository meals = usersMealsMap.get(userId); + return meals != null && meals.delete(id); + } + + @Override + public Meal get(int id, int userId) { + InMemoryBaseRepository meals = usersMealsMap.get(userId); + return meals == null ? null : meals.get(id); + } + + @Override + public List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId) { + return filterByPredicate(userId, meal -> Util.isBetweenHalfOpen(meal.getDateTime(), startDateTime, endDateTime)); + } + + @Override + public List getAll(int userId) { + return filterByPredicate(userId, meal -> true); + } + + private List filterByPredicate(int userId, Predicate filter) { + InMemoryBaseRepository meals = usersMealsMap.get(userId); + return meals == null ? Collections.emptyList() : + meals.getCollection().stream() + .filter(filter) + .sorted(Comparator.comparing(Meal::getDateTime).reversed()) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java new file mode 100644 index 000000000..9c128105e --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java @@ -0,0 +1,40 @@ +package ru.javawebinar.topjava.repository.inmemory; + +import org.springframework.stereotype.Repository; +import ru.javawebinar.topjava.UserTestData; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.repository.UserRepository; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import static ru.javawebinar.topjava.UserTestData.*; + + +@Repository +public class InMemoryUserRepository extends InMemoryBaseRepository implements UserRepository { + + public void init() { + map.clear(); + put(user); + put(admin); + put(guest); + counter.getAndSet(UserTestData.GUEST_ID + 1); + } + + @Override + public List getAll() { + return getCollection().stream() + .sorted(Comparator.comparing(User::getName).thenComparing(User::getEmail)) + .collect(Collectors.toList()); + } + + @Override + public User getByEmail(String email) { + return getCollection().stream() + .filter(u -> email.equals(u.getEmail())) + .findFirst() + .orElse(null); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java new file mode 100644 index 000000000..d10a5ff59 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java @@ -0,0 +1,95 @@ +package ru.javawebinar.topjava.service; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.slf4j.bridge.SLF4JBridgeHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataAccessException; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.context.junit4.SpringRunner; +import ru.javawebinar.topjava.UserTestData; +import ru.javawebinar.topjava.model.Role; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +import java.util.List; + +import static org.junit.Assert.assertThrows; +import static ru.javawebinar.topjava.UserTestData.*; + +@ContextConfiguration({ + "classpath:spring/spring-app.xml", + "classpath:spring/spring-db.xml" +}) +@RunWith(SpringRunner.class) +@Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8")) +public class UserServiceTest { + + static { + // Only for postgres driver logging + // It uses java.util.logging and logged via jul-to-slf4j bridge + SLF4JBridgeHandler.install(); + } + + @Autowired + private UserService service; + + @Test + public void create() { + User created = service.create(getNew()); + Integer newId = created.getId(); + User newUser = getNew(); + newUser.setId(newId); + assertMatch(created, newUser); + assertMatch(service.get(newId), newUser); + } + + @Test + public void duplicateMailCreate() { + assertThrows(DataAccessException.class, () -> + service.create(new User(null, "Duplicate", "user@yandex.ru", "newPass", Role.USER))); + } + + @Test + public void delete() { + service.delete(USER_ID); + assertThrows(NotFoundException.class, () -> service.get(USER_ID)); + } + + @Test + public void deletedNotFound() { + assertThrows(NotFoundException.class, () -> service.delete(NOT_FOUND)); + } + + @Test + public void get() { + User user = service.get(USER_ID); + assertMatch(user, UserTestData.user); + } + + @Test + public void getNotFound() { + assertThrows(NotFoundException.class, () -> service.get(NOT_FOUND)); + } + + @Test + public void getByEmail() { + User user = service.getByEmail("admin@gmail.com"); + assertMatch(user, admin); + } + + @Test + public void update() { + User updated = getUpdated(); + service.update(updated); + assertMatch(service.get(USER_ID), getUpdated()); + } + + @Test + public void getAll() { + List all = service.getAll(); + assertMatch(all, admin, guest, user); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java new file mode 100644 index 000000000..bdd730c22 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java @@ -0,0 +1,43 @@ +package ru.javawebinar.topjava.web.user; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import ru.javawebinar.topjava.repository.inmemory.InMemoryUserRepository; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +import static ru.javawebinar.topjava.UserTestData.NOT_FOUND; +import static ru.javawebinar.topjava.UserTestData.USER_ID; + +@ContextConfiguration("classpath:spring/spring-app.xml") +@RunWith(SpringRunner.class) +@Ignore +public class InMemoryAdminRestControllerSpringTest { + + @Autowired + private AdminRestController controller; + + @Autowired + private InMemoryUserRepository repository; + + @Before + public void setUp() { + repository.init(); + } + + @Test + public void delete() { + controller.delete(USER_ID); + Assert.assertNull(repository.get(USER_ID)); + } + + @Test + public void deleteNotFound() { + Assert.assertThrows(NotFoundException.class, () -> controller.delete(NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java new file mode 100644 index 000000000..9ba51812d --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java @@ -0,0 +1,53 @@ +package ru.javawebinar.topjava.web.user; + +import org.junit.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.support.ClassPathXmlApplicationContext; +import ru.javawebinar.topjava.repository.inmemory.InMemoryUserRepository; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +import java.util.Arrays; + +import static ru.javawebinar.topjava.UserTestData.NOT_FOUND; +import static ru.javawebinar.topjava.UserTestData.USER_ID; + +@Ignore +public class InMemoryAdminRestControllerTest { + private static final Logger log = LoggerFactory.getLogger(InMemoryAdminRestControllerTest.class); + + private static ConfigurableApplicationContext appCtx; + private static AdminRestController controller; + private static InMemoryUserRepository repository; + + @BeforeClass + public static void beforeClass() { + appCtx = new ClassPathXmlApplicationContext("spring/spring-app.xml"); + log.info("\n{}\n", Arrays.toString(appCtx.getBeanDefinitionNames())); + controller = appCtx.getBean(AdminRestController.class); + repository = appCtx.getBean(InMemoryUserRepository.class); + } + + @AfterClass + public static void afterClass() { + appCtx.close(); + } + + @Before + public void setUp() { + // re-initialize + repository.init(); + } + + @Test + public void delete() { + controller.delete(USER_ID); + Assert.assertNull(repository.get(USER_ID)); + } + + @Test + public void deleteNotFound() { + Assert.assertThrows(NotFoundException.class, () -> controller.delete(NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 000000000..428ceca2e --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,22 @@ + + + + true + + + + + UTF-8 + %d{HH:mm:ss.SSS} %-5level %class{50}.%M:%L - %msg%n + + + + + + + + + + + + \ No newline at end of file From d3904767409dde0dc9223f53223c11bf76ca44e1 Mon Sep 17 00:00:00 2001 From: art94timer Date: Mon, 22 Aug 2022 20:51:41 +0300 Subject: [PATCH 12/19] Prepare HW4 --- .codacy.yml | 5 + .gitignore | 6 +- README.md | 2 + pom.xml | 34 +++++- .../ru/javawebinar/topjava/SpringMain.java | 2 +- .../topjava/model/AbstractBaseEntity.java | 17 ++- .../topjava/model/AbstractNamedEntity.java | 12 +- .../ru/javawebinar/topjava/model/Meal.java | 34 +++++- .../ru/javawebinar/topjava/model/User.java | 34 ++++++ .../repository/jdbc/JdbcMealRepository.java | 59 +++++++++- .../repository/jdbc/JdbcUserRepository.java | 15 +-- .../repository/jpa/JpaMealRepository.java | 37 ++++++ .../repository/jpa/JpaUserRepository.java | 73 ++++++++++++ .../topjava/service/MealService.java | 5 +- .../topjava/service/UserService.java | 6 +- .../topjava/util/DateTimeUtil.java | 3 + .../javawebinar/topjava/util/MealsUtil.java | 14 +-- .../ru/javawebinar/topjava/util/Util.java | 4 + .../topjava/util/ValidationUtil.java | 5 +- .../javawebinar/topjava/web/SecurityUtil.java | 3 + src/main/resources/db/hsqldb.properties | 12 ++ src/main/resources/db/initDB.sql | 16 ++- src/main/resources/db/initDB_hsql.sql | 39 ++++++ src/main/resources/db/populateDB.sql | 12 ++ src/main/resources/db/postgres.properties | 7 ++ src/main/resources/spring/spring-app.xml | 1 - src/main/resources/spring/spring-db.xml | 67 +++++++++-- .../javawebinar/topjava/MatcherFactory.java | 37 ++++++ .../ru/javawebinar/topjava/MealTestData.java | 39 ++++++ .../ru/javawebinar/topjava/UserTestData.java | 16 +-- .../inmemory/InMemoryBaseRepository.java | 5 +- .../inmemory/InMemoryMealRepository.java | 19 ++- .../inmemory/InMemoryUserRepository.java | 5 +- .../topjava/service/MealServiceTest.java | 111 ++++++++++++++++++ .../topjava/service/UserServiceTest.java | 21 ++-- ...InMemoryAdminRestControllerSpringTest.java | 3 +- .../user/InMemoryAdminRestControllerTest.java | 3 +- src/test/resources/logback-test.xml | 2 +- src/test/resources/spring/inmemory.xml | 7 ++ 39 files changed, 695 insertions(+), 97 deletions(-) create mode 100644 .codacy.yml create mode 100644 src/main/java/ru/javawebinar/topjava/repository/jpa/JpaMealRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/jpa/JpaUserRepository.java create mode 100644 src/main/resources/db/hsqldb.properties create mode 100644 src/main/resources/db/initDB_hsql.sql create mode 100644 src/test/java/ru/javawebinar/topjava/MatcherFactory.java create mode 100644 src/test/java/ru/javawebinar/topjava/MealTestData.java create mode 100644 src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java create mode 100644 src/test/resources/spring/inmemory.xml diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 000000000..311a8f4e0 --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,5 @@ +--- +exclude_paths: + - 'src/main/webapp/**' + - '**.md' + - '**.sql' \ No newline at end of file diff --git a/.gitignore b/.gitignore index b4860155a..e134710a6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,8 @@ out target *.iml log -*.patch \ No newline at end of file +*.patch +/.project +/.classpath +/.gitignore +/.settings/ diff --git a/README.md b/README.md index b10c5718d..7fd61b43a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/bee16f3145654047a0505c62aeefd8a2)](https://www.codacy.com/gh/JavaWebinar/topjava/dashboard) + Java Enterprise Online Project =============================== diff --git a/pom.xml b/pom.xml index 7e05c1619..6a48ff25e 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,12 @@ 42.4.0 + + + 5.6.9.Final + 6.2.3.Final + 3.0.1-b12 + 4.13.2 3.23.1 @@ -84,7 +90,7 @@ org.springframework - spring-jdbc + spring-orm ${spring.version} @@ -94,6 +100,32 @@ postgresql ${postgresql.version} + + org.hsqldb + hsqldb + 2.3.4 + + + + + + org.hibernate + hibernate-core + ${hibernate.version} + + + org.hibernate.validator + hibernate-validator + ${hibernate-validator.version} + + + + + org.glassfish + javax.el + ${javax-el.version} + provided + diff --git a/src/main/java/ru/javawebinar/topjava/SpringMain.java b/src/main/java/ru/javawebinar/topjava/SpringMain.java index b869d1c6e..e41f1ae11 100644 --- a/src/main/java/ru/javawebinar/topjava/SpringMain.java +++ b/src/main/java/ru/javawebinar/topjava/SpringMain.java @@ -17,7 +17,7 @@ public class SpringMain { public static void main(String[] args) { // java 7 automatic resource management (ARM) - try (ConfigurableApplicationContext appCtx = new ClassPathXmlApplicationContext("spring/spring-app.xml")) { + try (ConfigurableApplicationContext appCtx = new ClassPathXmlApplicationContext("spring/spring-app.xml", "spring/inmemory.xml")) { System.out.println("Bean definition names: " + Arrays.toString(appCtx.getBeanDefinitionNames())); AdminRestController adminUserController = appCtx.getBean(AdminRestController.class); adminUserController.create(new User(null, "userName", "email@mail.ru", "password", Role.ADMIN)); diff --git a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java index 46e8d6e30..d9343a074 100644 --- a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java @@ -1,11 +1,21 @@ package ru.javawebinar.topjava.model; +import org.springframework.util.Assert; + +import javax.persistence.*; + +@MappedSuperclass +// http://stackoverflow.com/questions/594597/hibernate-annotations-which-is-better-field-or-property-access +@Access(AccessType.FIELD) public abstract class AbstractBaseEntity { public static final int START_SEQ = 100000; + @Id + @SequenceGenerator(name = "global_seq", sequenceName = "global_seq", allocationSize = 1, initialValue = START_SEQ) + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "global_seq") protected Integer id; - public AbstractBaseEntity() { + protected AbstractBaseEntity() { } protected AbstractBaseEntity(Integer id) { @@ -20,6 +30,11 @@ public Integer getId() { return id; } + public int id() { + Assert.notNull(id, "Entity must have id"); + return id; + } + public boolean isNew() { return this.id == null; } diff --git a/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java b/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java index 5f0fde2bb..0b32aac58 100644 --- a/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java @@ -1,10 +1,20 @@ package ru.javawebinar.topjava.model; +import javax.persistence.Column; +import javax.persistence.MappedSuperclass; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + + +@MappedSuperclass public abstract class AbstractNamedEntity extends AbstractBaseEntity { + @NotBlank + @Size(min = 2, max = 128) + @Column(name = "name", nullable = false) protected String name; - public AbstractNamedEntity() { + protected AbstractNamedEntity() { } protected AbstractNamedEntity(Integer id, String name) { diff --git a/src/main/java/ru/javawebinar/topjava/model/Meal.java b/src/main/java/ru/javawebinar/topjava/model/Meal.java index 9eed15f70..788ae8f6f 100644 --- a/src/main/java/ru/javawebinar/topjava/model/Meal.java +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -1,15 +1,23 @@ package ru.javawebinar.topjava.model; +import javax.persistence.FetchType; +import javax.persistence.ManyToOne; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; public class Meal extends AbstractBaseEntity { - private final LocalDateTime dateTime; + private LocalDateTime dateTime; - private final String description; + private String description; - private final int calories; + private int calories; + + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + public Meal() { + } public Meal(LocalDateTime dateTime, String description, int calories) { this(null, dateTime, description, calories); @@ -42,6 +50,26 @@ public LocalTime getTime() { return dateTime.toLocalTime(); } + public void setDateTime(LocalDateTime dateTime) { + this.dateTime = dateTime; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setCalories(int calories) { + this.calories = calories; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + @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 3114a5300..1b121fc08 100644 --- a/src/main/java/ru/javawebinar/topjava/model/User.java +++ b/src/main/java/ru/javawebinar/topjava/model/User.java @@ -1,23 +1,57 @@ package ru.javawebinar.topjava.model; +import org.hibernate.validator.constraints.Range; import org.springframework.util.CollectionUtils; +import javax.persistence.*; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; import java.util.*; import static ru.javawebinar.topjava.util.MealsUtil.DEFAULT_CALORIES_PER_DAY; +@NamedQueries({ + @NamedQuery(name = User.DELETE, query = "DELETE FROM User u WHERE u.id=:id"), + @NamedQuery(name = User.BY_EMAIL, query = "SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email=?1"), + @NamedQuery(name = User.ALL_SORTED, query = "SELECT u FROM User u LEFT JOIN FETCH u.roles ORDER BY u.name, u.email"), +}) +@Entity +@Table(name = "users") public class User extends AbstractNamedEntity { + public static final String DELETE = "User.delete"; + public static final String BY_EMAIL = "User.getByEmail"; + public static final String ALL_SORTED = "User.getAllSorted"; + + @Column(name = "email", nullable = false, unique = true) + @Email + @NotBlank + @Size(max = 128) private String email; + @Column(name = "password", nullable = false) + @NotBlank + @Size(min = 5, max = 128) private String password; + @Column(name = "enabled", nullable = false, columnDefinition = "bool default true") private boolean enabled = true; + @Column(name = "registered", nullable = false, columnDefinition = "timestamp default now()", updatable = false) + @NotNull private Date registered = new Date(); + @Enumerated(EnumType.STRING) + @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"), + uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "role"}, name = "uk_user_roles")}) + @Column(name = "role") + @ElementCollection(fetch = FetchType.EAGER) private Set roles; + @Column(name = "calories_per_day", nullable = false, columnDefinition = "int default 2000") + @Range(min = 10, max = 10000) private int caloriesPerDay = DEFAULT_CALORIES_PER_DAY; public User() { 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 c29288c39..fa26d5663 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java @@ -1,5 +1,13 @@ package ru.javawebinar.topjava.repository.jdbc; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.support.DataAccessUtils; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.simple.SimpleJdbcInsert; import org.springframework.stereotype.Repository; import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.repository.MealRepository; @@ -10,28 +18,69 @@ @Repository public class JdbcMealRepository implements MealRepository { + private static final RowMapper ROW_MAPPER = BeanPropertyRowMapper.newInstance(Meal.class); + + private final JdbcTemplate jdbcTemplate; + + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + + private final SimpleJdbcInsert insertMeal; + + @Autowired + public JdbcMealRepository(JdbcTemplate jdbcTemplate, NamedParameterJdbcTemplate namedParameterJdbcTemplate) { + this.insertMeal = new SimpleJdbcInsert(jdbcTemplate) + .withTableName("meals") + .usingGeneratedKeyColumns("id"); + + this.jdbcTemplate = jdbcTemplate; + this.namedParameterJdbcTemplate = namedParameterJdbcTemplate; + } + @Override public Meal save(Meal meal, int userId) { - return null; + MapSqlParameterSource map = new MapSqlParameterSource() + .addValue("id", meal.getId()) + .addValue("description", meal.getDescription()) + .addValue("calories", meal.getCalories()) + .addValue("date_time", meal.getDateTime()) + .addValue("user_id", userId); + + if (meal.isNew()) { + Number newId = insertMeal.executeAndReturnKey(map); + meal.setId(newId.intValue()); + } else { + if (namedParameterJdbcTemplate.update("" + + "UPDATE meals " + + " SET description=:description, calories=:calories, date_time=:date_time " + + " WHERE id=:id AND user_id=:user_id", map) == 0) { + return null; + } + } + return meal; } @Override public boolean delete(int id, int userId) { - return false; + return jdbcTemplate.update("DELETE FROM meals WHERE id=? AND user_id=?", id, userId) != 0; } @Override public Meal get(int id, int userId) { - return null; + List meals = jdbcTemplate.query( + "SELECT * FROM meals WHERE id = ? AND user_id = ?", ROW_MAPPER, id, userId); + return DataAccessUtils.singleResult(meals); } @Override public List getAll(int userId) { - return null; + return jdbcTemplate.query( + "SELECT * FROM meals WHERE user_id=? ORDER BY date_time DESC", ROW_MAPPER, userId); } @Override public List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId) { - return null; + return jdbcTemplate.query( + "SELECT * FROM meals WHERE user_id=? AND date_time >= ? AND date_time < ? ORDER BY date_time DESC", + ROW_MAPPER, userId, startDateTime, endDateTime); } } diff --git a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java index 7f6f8626a..412bfbebc 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java @@ -4,7 +4,7 @@ import org.springframework.dao.support.DataAccessUtils; import org.springframework.jdbc.core.BeanPropertyRowMapper; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.simple.SimpleJdbcInsert; import org.springframework.stereotype.Repository; @@ -36,21 +36,14 @@ public JdbcUserRepository(JdbcTemplate jdbcTemplate, NamedParameterJdbcTemplate @Override public User save(User user) { - MapSqlParameterSource map = new MapSqlParameterSource() - .addValue("id", user.getId()) - .addValue("name", user.getName()) - .addValue("email", user.getEmail()) - .addValue("password", user.getPassword()) - .addValue("registered", user.getRegistered()) - .addValue("enabled", user.isEnabled()) - .addValue("caloriesPerDay", user.getCaloriesPerDay()); + BeanPropertySqlParameterSource parameterSource = new BeanPropertySqlParameterSource(user); if (user.isNew()) { - Number newKey = insertUser.executeAndReturnKey(map); + Number newKey = insertUser.executeAndReturnKey(parameterSource); user.setId(newKey.intValue()); } else if (namedParameterJdbcTemplate.update( "UPDATE users SET name=:name, email=:email, password=:password, " + - "registered=:registered, enabled=:enabled, calories_per_day=:caloriesPerDay WHERE id=:id", map) == 0) { + "registered=:registered, enabled=:enabled, calories_per_day=:caloriesPerDay WHERE id=:id", parameterSource) == 0) { return null; } return user; diff --git a/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaMealRepository.java new file mode 100644 index 000000000..9cc19a4dc --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaMealRepository.java @@ -0,0 +1,37 @@ +package ru.javawebinar.topjava.repository.jpa; + +import org.springframework.stereotype.Repository; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.repository.MealRepository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class JpaMealRepository implements MealRepository { + + @Override + public Meal save(Meal meal, int userId) { + return null; + } + + @Override + public boolean delete(int id, int userId) { + return false; + } + + @Override + public Meal get(int id, int userId) { + return null; + } + + @Override + public List getAll(int userId) { + return null; + } + + @Override + public List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId) { + return null; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaUserRepository.java new file mode 100644 index 000000000..3a1bbddc4 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaUserRepository.java @@ -0,0 +1,73 @@ +package ru.javawebinar.topjava.repository.jpa; + +import org.springframework.dao.support.DataAccessUtils; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.repository.UserRepository; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import java.util.List; + +@Repository +@Transactional(readOnly = true) +public class JpaUserRepository implements UserRepository { + +/* + @Autowired + private SessionFactory sessionFactory; + + private Session openSession() { + return sessionFactory.getCurrentSession(); + } +*/ + + @PersistenceContext + private EntityManager em; + + @Override + @Transactional + public User save(User user) { + if (user.isNew()) { + em.persist(user); + return user; + } else { + return em.merge(user); + } + } + + @Override + public User get(int id) { + return em.find(User.class, id); + } + + @Override + @Transactional + public boolean delete(int id) { + +/* User ref = em.getReference(User.class, id); + em.remove(ref); + + Query query = em.createQuery("DELETE FROM User u WHERE u.id=:id"); + return query.setParameter("id", id).executeUpdate() != 0; +*/ + return em.createNamedQuery(User.DELETE) + .setParameter("id", id) + .executeUpdate() != 0; + } + + @Override + public User getByEmail(String email) { + List users = em.createNamedQuery(User.BY_EMAIL, User.class) + .setParameter(1, email) + .getResultList(); + return DataAccessUtils.singleResult(users); + } + + @Override + public List getAll() { + return em.createNamedQuery(User.ALL_SORTED, User.class) + .getResultList(); + } +} diff --git a/src/main/java/ru/javawebinar/topjava/service/MealService.java b/src/main/java/ru/javawebinar/topjava/service/MealService.java index 7957a0895..df874378e 100644 --- a/src/main/java/ru/javawebinar/topjava/service/MealService.java +++ b/src/main/java/ru/javawebinar/topjava/service/MealService.java @@ -2,6 +2,7 @@ import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; +import org.springframework.util.Assert; import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.repository.MealRepository; @@ -38,10 +39,12 @@ public List getAll(int userId) { } public void update(Meal meal, int userId) { - checkNotFoundWithId(repository.save(meal, userId), meal.getId()); + Assert.notNull(meal, "meal must not be null"); + checkNotFoundWithId(repository.save(meal, userId), meal.id()); } public Meal create(Meal meal, int userId) { + Assert.notNull(meal, "meal must not be null"); return repository.save(meal, userId); } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/service/UserService.java b/src/main/java/ru/javawebinar/topjava/service/UserService.java index 8fbe8dc06..09ccee68c 100644 --- a/src/main/java/ru/javawebinar/topjava/service/UserService.java +++ b/src/main/java/ru/javawebinar/topjava/service/UserService.java @@ -1,6 +1,7 @@ package ru.javawebinar.topjava.service; import org.springframework.stereotype.Service; +import org.springframework.util.Assert; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.repository.UserRepository; @@ -19,6 +20,7 @@ public UserService(UserRepository repository) { } public User create(User user) { + Assert.notNull(user, "user must not be null"); return repository.save(user); } @@ -31,6 +33,7 @@ public User get(int id) { } public User getByEmail(String email) { + Assert.notNull(email, "email must not be null"); return checkNotFound(repository.getByEmail(email), "email=" + email); } @@ -39,6 +42,7 @@ public List getAll() { } public void update(User user) { - checkNotFoundWithId(repository.save(user), user.getId()); + Assert.notNull(user, "user must not be null"); + checkNotFoundWithId(repository.save(user), user.id()); } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java b/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java index b63ecf506..a4665e2ae 100644 --- a/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java @@ -16,6 +16,9 @@ public class DateTimeUtil { private static final LocalDateTime MIN_DATE = LocalDateTime.of(1, 1, 1, 0, 0); private static final LocalDateTime MAX_DATE = LocalDateTime.of(3000, 1, 1, 0, 0); + private DateTimeUtil() { + } + public static LocalDateTime atStartOfDayOrMin(LocalDate localDate) { return localDate != null ? localDate.atStartOfDay() : MIN_DATE; } diff --git a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java index d10e1e674..d767652eb 100644 --- a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java @@ -4,10 +4,7 @@ import ru.javawebinar.topjava.to.MealTo; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; -import java.time.Month; -import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; @@ -17,15 +14,8 @@ public class MealsUtil { public static final int DEFAULT_CALORIES_PER_DAY = 2000; - public static final List meals = Arrays.asList( - new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 10, 0), "Завтрак", 500), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 13, 0), "Обед", 1000), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 30, 20, 0), "Ужин", 500), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 0, 0), "Еда на граничное значение", 100), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 10, 0), "Завтрак", 1000), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 13, 0), "Обед", 500), - new Meal(LocalDateTime.of(2020, Month.JANUARY, 31, 20, 0), "Ужин", 410) - ); + private MealsUtil() { + } public static List getTos(Collection meals, int caloriesPerDay) { return filterByPredicate(meals, caloriesPerDay, meal -> true); diff --git a/src/main/java/ru/javawebinar/topjava/util/Util.java b/src/main/java/ru/javawebinar/topjava/util/Util.java index a17a6927f..9a083383d 100644 --- a/src/main/java/ru/javawebinar/topjava/util/Util.java +++ b/src/main/java/ru/javawebinar/topjava/util/Util.java @@ -3,6 +3,10 @@ import org.springframework.lang.Nullable; public class Util { + + private Util() { + } + public static > boolean isBetweenHalfOpen(T value, @Nullable T start, @Nullable T end) { return (start == null || value.compareTo(start) >= 0) && (end == null || value.compareTo(end) < 0); } diff --git a/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java b/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java index 971eb9c0c..5212eea75 100644 --- a/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java @@ -6,6 +6,9 @@ public class ValidationUtil { + private ValidationUtil() { + } + public static T checkNotFoundWithId(T object, int id) { checkNotFoundWithId(object != null, id); return object; @@ -36,7 +39,7 @@ public static void assureIdConsistent(AbstractBaseEntity entity, int id) { // conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473) if (entity.isNew()) { entity.setId(id); - } else if (entity.getId() != id) { + } else if (entity.id() != id) { throw new IllegalArgumentException(entity + " must be with id=" + id); } } diff --git a/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java b/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java index 588217547..4bad5863e 100644 --- a/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java +++ b/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java @@ -8,6 +8,9 @@ public class SecurityUtil { private static int id = AbstractBaseEntity.START_SEQ; + private SecurityUtil() { + } + public static int authUserId() { return id; } diff --git a/src/main/resources/db/hsqldb.properties b/src/main/resources/db/hsqldb.properties new file mode 100644 index 000000000..c7944e25c --- /dev/null +++ b/src/main/resources/db/hsqldb.properties @@ -0,0 +1,12 @@ +#database.url=jdbc:hsqldb:file:D:/temp/topjava + +database.url=jdbc:hsqldb:mem:topjava +database.username=sa +database.password= +database.driverClassName=org.hsqldb.jdbcDriver + +database.init=true +jdbc.initLocation=classpath:db/initDB_hsql.sql +jpa.showSql=true +hibernate.format_sql=true +hibernate.use_sql_comments=true \ No newline at end of file diff --git a/src/main/resources/db/initDB.sql b/src/main/resources/db/initDB.sql index 57f3f2c06..7644dc610 100644 --- a/src/main/resources/db/initDB.sql +++ b/src/main/resources/db/initDB.sql @@ -1,4 +1,5 @@ DROP TABLE IF EXISTS user_roles; +DROP TABLE IF EXISTS meals; DROP TABLE IF EXISTS users; DROP SEQUENCE IF EXISTS global_seq; @@ -19,7 +20,18 @@ CREATE UNIQUE INDEX users_unique_email_idx ON users (email); CREATE TABLE user_roles ( user_id INTEGER NOT NULL, - role VARCHAR, + role VARCHAR NOT NULL, CONSTRAINT user_roles_idx UNIQUE (user_id, role), FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE -); \ No newline at end of file +); + +CREATE TABLE meals +( + id INTEGER PRIMARY KEY DEFAULT nextval('global_seq'), + user_id INTEGER NOT NULL, + date_time TIMESTAMP NOT NULL, + description TEXT NOT NULL, + calories INT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX meals_unique_user_datetime_idx ON meals (user_id, date_time); \ No newline at end of file diff --git a/src/main/resources/db/initDB_hsql.sql b/src/main/resources/db/initDB_hsql.sql new file mode 100644 index 000000000..37f2da1bf --- /dev/null +++ b/src/main/resources/db/initDB_hsql.sql @@ -0,0 +1,39 @@ +DROP TABLE user_roles IF EXISTS; +DROP TABLE meals IF EXISTS; +DROP TABLE users IF EXISTS; +DROP SEQUENCE global_seq IF EXISTS; + +CREATE SEQUENCE GLOBAL_SEQ AS INTEGER START WITH 100000; + +CREATE TABLE users +( + id INTEGER GENERATED BY DEFAULT AS SEQUENCE GLOBAL_SEQ PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + registered TIMESTAMP DEFAULT now() NOT NULL, + enabled BOOLEAN DEFAULT TRUE NOT NULL, + calories_per_day INTEGER DEFAULT 2000 NOT NULL +); +CREATE UNIQUE INDEX users_unique_email_idx + ON USERS (email); + +CREATE TABLE user_roles +( + user_id INTEGER NOT NULL, + role VARCHAR(255), + CONSTRAINT user_roles_idx UNIQUE (user_id, role), + FOREIGN KEY (user_id) REFERENCES USERS (id) ON DELETE CASCADE +); + +CREATE TABLE meals +( + id INTEGER GENERATED BY DEFAULT AS SEQUENCE GLOBAL_SEQ PRIMARY KEY, + date_time TIMESTAMP NOT NULL, + description VARCHAR(255) NOT NULL, + calories INT NOT NULL, + user_id INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES USERS (id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX meals_unique_user_datetime_idx + ON meals (user_id, date_time) \ No newline at end of file diff --git a/src/main/resources/db/populateDB.sql b/src/main/resources/db/populateDB.sql index 11375c6ea..f29b325f2 100644 --- a/src/main/resources/db/populateDB.sql +++ b/src/main/resources/db/populateDB.sql @@ -1,4 +1,5 @@ DELETE FROM user_roles; +DELETE FROM meals; DELETE FROM users; ALTER SEQUENCE global_seq RESTART WITH 100000; @@ -10,3 +11,14 @@ VALUES ('User', 'user@yandex.ru', 'password'), INSERT INTO user_roles (role, user_id) VALUES ('USER', 100000), ('ADMIN', 100001); + +INSERT INTO meals (date_time, description, calories, user_id) +VALUES ('2020-01-30 10:00:00', 'Завтрак', 500, 100000), + ('2020-01-30 13:00:00', 'Обед', 1000, 100000), + ('2020-01-30 20:00:00', 'Ужин', 500, 100000), + ('2020-01-31 0:00:00', 'Еда на граничное значение', 100, 100000), + ('2020-01-31 10:00:00', 'Завтрак', 500, 100000), + ('2020-01-31 13:00:00', 'Обед', 1000, 100000), + ('2020-01-31 20:00:00', 'Ужин', 510, 100000), + ('2020-01-31 14:00:00', 'Админ ланч', 510, 100001), + ('2020-01-31 21:00:00', 'Админ ужин', 1500, 100001); \ No newline at end of file diff --git a/src/main/resources/db/postgres.properties b/src/main/resources/db/postgres.properties index 0e9abf404..5e657b44d 100644 --- a/src/main/resources/db/postgres.properties +++ b/src/main/resources/db/postgres.properties @@ -5,3 +5,10 @@ database.url=jdbc:postgresql://localhost:5432/topjava database.username=user database.password=password +database.driverClassName=org.postgresql.Driver + +database.init=true +jdbc.initLocation=classpath:db/initDB.sql +jpa.showSql=true +hibernate.format_sql=true +hibernate.use_sql_comments=true \ No newline at end of file diff --git a/src/main/resources/spring/spring-app.xml b/src/main/resources/spring/spring-app.xml index 4c05aea49..4c17228b7 100644 --- a/src/main/resources/spring/spring-app.xml +++ b/src/main/resources/spring/spring-app.xml @@ -9,7 +9,6 @@ --> - diff --git a/src/main/resources/spring/spring-db.xml b/src/main/resources/spring/spring-db.xml index 8ec56c9e6..970261d06 100644 --- a/src/main/resources/spring/spring-db.xml +++ b/src/main/resources/spring/spring-db.xml @@ -1,25 +1,78 @@ + http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd + http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc.xsd + http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd"> + + + + + + + + + + + + + + + - + - - - + + - - + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/MatcherFactory.java b/src/test/java/ru/javawebinar/topjava/MatcherFactory.java new file mode 100644 index 000000000..200e27e5f --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/MatcherFactory.java @@ -0,0 +1,37 @@ +package ru.javawebinar.topjava; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Factory for creating test matchers. + *

    + * Comparing actual and expected objects via AssertJ + */ +public class MatcherFactory { + public static Matcher usingIgnoringFieldsComparator(String... fieldsToIgnore) { + return new Matcher<>(fieldsToIgnore); + } + + public static class Matcher { + private final String[] fieldsToIgnore; + + private Matcher(String... fieldsToIgnore) { + this.fieldsToIgnore = fieldsToIgnore; + } + + public void assertMatch(T actual, T expected) { + assertThat(actual).usingRecursiveComparison().ignoringFields(fieldsToIgnore).isEqualTo(expected); + } + + @SafeVarargs + public final void assertMatch(Iterable actual, T... expected) { + assertMatch(actual, Arrays.asList(expected)); + } + + public void assertMatch(Iterable actual, Iterable expected) { + assertThat(actual).usingRecursiveFieldByFieldElementComparatorIgnoringFields(fieldsToIgnore).isEqualTo(expected); + } + } +} diff --git a/src/test/java/ru/javawebinar/topjava/MealTestData.java b/src/test/java/ru/javawebinar/topjava/MealTestData.java new file mode 100644 index 000000000..5190f5082 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/MealTestData.java @@ -0,0 +1,39 @@ +package ru.javawebinar.topjava; + +import ru.javawebinar.topjava.model.Meal; + +import java.time.Month; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; + +import static java.time.LocalDateTime.of; +import static ru.javawebinar.topjava.model.AbstractBaseEntity.START_SEQ; + +public class MealTestData { + public static final MatcherFactory.Matcher MEAL_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(); + + public static final int NOT_FOUND = 10; + public static final int MEAL1_ID = START_SEQ + 3; + public static final int ADMIN_MEAL_ID = START_SEQ + 10; + + public static final Meal meal1 = new Meal(MEAL1_ID, of(2020, Month.JANUARY, 30, 10, 0), "Завтрак", 500); + public static final Meal meal2 = new Meal(MEAL1_ID + 1, of(2020, Month.JANUARY, 30, 13, 0), "Обед", 1000); + public static final Meal meal3 = new Meal(MEAL1_ID + 2, of(2020, Month.JANUARY, 30, 20, 0), "Ужин", 500); + public static final Meal meal4 = new Meal(MEAL1_ID + 3, of(2020, Month.JANUARY, 31, 0, 0), "Еда на граничное значение", 100); + public static final Meal meal5 = new Meal(MEAL1_ID + 4, of(2020, Month.JANUARY, 31, 10, 0), "Завтрак", 500); + public static final Meal meal6 = new Meal(MEAL1_ID + 5, of(2020, Month.JANUARY, 31, 13, 0), "Обед", 1000); + public static final Meal meal7 = new Meal(MEAL1_ID + 6, of(2020, Month.JANUARY, 31, 20, 0), "Ужин", 510); + public static final Meal adminMeal1 = new Meal(ADMIN_MEAL_ID, of(2020, Month.JANUARY, 31, 14, 0), "Админ ланч", 510); + public static final Meal adminMeal2 = new Meal(ADMIN_MEAL_ID + 1, of(2020, Month.JANUARY, 31, 21, 0), "Админ ужин", 1500); + + public static final List meals = Arrays.asList(meal7, meal6, meal5, meal4, meal3, meal2, meal1); + + public static Meal getNew() { + return new Meal(null, of(2020, Month.FEBRUARY, 1, 18, 0), "Созданный ужин", 300); + } + + public static Meal getUpdated() { + return new Meal(MEAL1_ID, meal1.getDateTime().plus(2, ChronoUnit.MINUTES), "Обновленный завтрак", 200); + } +} diff --git a/src/test/java/ru/javawebinar/topjava/UserTestData.java b/src/test/java/ru/javawebinar/topjava/UserTestData.java index 125f43333..bfaba979d 100644 --- a/src/test/java/ru/javawebinar/topjava/UserTestData.java +++ b/src/test/java/ru/javawebinar/topjava/UserTestData.java @@ -3,14 +3,14 @@ import ru.javawebinar.topjava.model.Role; import ru.javawebinar.topjava.model.User; -import java.util.Arrays; import java.util.Collections; import java.util.Date; -import static org.assertj.core.api.Assertions.assertThat; import static ru.javawebinar.topjava.model.AbstractBaseEntity.START_SEQ; public class UserTestData { + public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator("registered", "roles"); + public static final int USER_ID = START_SEQ; public static final int ADMIN_ID = START_SEQ + 1; public static final int GUEST_ID = START_SEQ + 2; @@ -34,16 +34,4 @@ public static User getUpdated() { updated.setRoles(Collections.singletonList(Role.ADMIN)); return updated; } - - public static void assertMatch(User actual, User expected) { - assertThat(actual).usingRecursiveComparison().ignoringFields("registered", "roles").isEqualTo(expected); - } - - public static void assertMatch(Iterable actual, User... expected) { - assertMatch(actual, Arrays.asList(expected)); - } - - public static void assertMatch(Iterable actual, Iterable expected) { - assertThat(actual).usingRecursiveFieldByFieldElementComparatorIgnoringFields("registered", "roles").isEqualTo(expected); - } } diff --git a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryBaseRepository.java b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryBaseRepository.java index 7d9f43b98..03770da21 100644 --- a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryBaseRepository.java +++ b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryBaseRepository.java @@ -4,6 +4,7 @@ import java.util.Collection; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; @@ -16,6 +17,7 @@ public class InMemoryBaseRepository { final Map map = new ConcurrentHashMap<>(); public T save(T entity) { + Objects.requireNonNull(entity, "Entity must not be null"); if (entity.isNew()) { entity.setId(counter.incrementAndGet()); map.put(entity.getId(), entity); @@ -37,6 +39,7 @@ Collection getCollection() { } void put(T entity) { - map.put(entity.getId(), entity); + Objects.requireNonNull(entity, "Entity must not be null"); + map.put(entity.id(), entity); } } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java index d8a0fada5..05527fca8 100644 --- a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java +++ b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java @@ -3,26 +3,20 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Repository; +import ru.javawebinar.topjava.MealTestData; +import ru.javawebinar.topjava.UserTestData; import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.repository.MealRepository; -import ru.javawebinar.topjava.util.MealsUtil; import ru.javawebinar.topjava.util.Util; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.time.LocalDateTime; -import java.time.Month; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; import java.util.stream.Collectors; -import static ru.javawebinar.topjava.UserTestData.ADMIN_ID; -import static ru.javawebinar.topjava.UserTestData.USER_ID; - @Repository public class InMemoryMealRepository implements MealRepository { private static final Logger log = LoggerFactory.getLogger(InMemoryMealRepository.class); @@ -31,14 +25,15 @@ public class InMemoryMealRepository implements MealRepository { private final Map> usersMealsMap = new ConcurrentHashMap<>(); { - MealsUtil.meals.forEach(meal -> save(meal, USER_ID)); - save(new Meal(LocalDateTime.of(2015, Month.JUNE, 1, 14, 0), "Админ ланч", 510), ADMIN_ID); - save(new Meal(LocalDateTime.of(2015, Month.JUNE, 1, 21, 0), "Админ ужин", 1500), ADMIN_ID); + InMemoryBaseRepository userMeals = new InMemoryBaseRepository<>(); + MealTestData.meals.forEach(userMeals::put); + usersMealsMap.put(UserTestData.USER_ID, userMeals); } @Override public Meal save(Meal meal, int userId) { + Objects.requireNonNull(meal, "meal must not be null"); InMemoryBaseRepository meals = usersMealsMap.computeIfAbsent(userId, uId -> new InMemoryBaseRepository<>()); return meals.save(meal); } diff --git a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java index 9c128105e..7e2fadbf8 100644 --- a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java +++ b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java @@ -1,12 +1,12 @@ package ru.javawebinar.topjava.repository.inmemory; import org.springframework.stereotype.Repository; -import ru.javawebinar.topjava.UserTestData; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.repository.UserRepository; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import static ru.javawebinar.topjava.UserTestData.*; @@ -20,7 +20,7 @@ public void init() { put(user); put(admin); put(guest); - counter.getAndSet(UserTestData.GUEST_ID + 1); + counter.getAndSet(GUEST_ID + 1); } @Override @@ -32,6 +32,7 @@ public List getAll() { @Override public User getByEmail(String email) { + Objects.requireNonNull(email, "email must not be null"); return getCollection().stream() .filter(u -> email.equals(u.getEmail())) .findFirst() diff --git a/src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java new file mode 100644 index 000000000..12a4ba266 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java @@ -0,0 +1,111 @@ +package ru.javawebinar.topjava.service; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataAccessException; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.context.junit4.SpringRunner; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +import java.time.LocalDate; +import java.time.Month; + +import static org.junit.Assert.assertThrows; +import static ru.javawebinar.topjava.MealTestData.*; +import static ru.javawebinar.topjava.UserTestData.ADMIN_ID; +import static ru.javawebinar.topjava.UserTestData.USER_ID; + +@ContextConfiguration({ + "classpath:spring/spring-app.xml", + "classpath:spring/spring-db.xml" +}) +@RunWith(SpringRunner.class) +@Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8")) +public class MealServiceTest { + + @Autowired + private MealService service; + + @Test + public void delete() { + service.delete(MEAL1_ID, USER_ID); + assertThrows(NotFoundException.class, () -> service.get(MEAL1_ID, USER_ID)); + } + + @Test + public void deleteNotFound() { + assertThrows(NotFoundException.class, () -> service.delete(NOT_FOUND, USER_ID)); + } + + @Test + public void deleteNotOwn() { + assertThrows(NotFoundException.class, () -> service.delete(MEAL1_ID, ADMIN_ID)); + } + + @Test + public void create() { + Meal created = service.create(getNew(), USER_ID); + int newId = created.id(); + Meal newMeal = getNew(); + newMeal.setId(newId); + MEAL_MATCHER.assertMatch(created, newMeal); + MEAL_MATCHER.assertMatch(service.get(newId, USER_ID), newMeal); + } + + @Test + public void duplicateDateTimeCreate() { + assertThrows(DataAccessException.class, () -> + service.create(new Meal(null, meal1.getDateTime(), "duplicate", 100), USER_ID)); + } + + @Test + public void get() { + Meal actual = service.get(ADMIN_MEAL_ID, ADMIN_ID); + MEAL_MATCHER.assertMatch(actual, adminMeal1); + } + + @Test + public void getNotFound() { + assertThrows(NotFoundException.class, () -> service.get(NOT_FOUND, USER_ID)); + } + + @Test + public void getNotOwn() { + assertThrows(NotFoundException.class, () -> service.get(MEAL1_ID, ADMIN_ID)); + } + + @Test + public void update() { + Meal updated = getUpdated(); + service.update(updated, USER_ID); + MEAL_MATCHER.assertMatch(service.get(MEAL1_ID, USER_ID), getUpdated()); + } + + @Test + public void updateNotOwn() { + assertThrows(NotFoundException.class, () -> service.update(meal1, ADMIN_ID)); + MEAL_MATCHER.assertMatch(service.get(MEAL1_ID, USER_ID), meal1); + } + + @Test + public void getAll() { + MEAL_MATCHER.assertMatch(service.getAll(USER_ID), meals); + } + + @Test + public void getBetweenInclusive() { + MEAL_MATCHER.assertMatch(service.getBetweenInclusive( + LocalDate.of(2020, Month.JANUARY, 30), + LocalDate.of(2020, Month.JANUARY, 30), USER_ID), + meal3, meal2, meal1); + } + + @Test + public void getBetweenWithNullDates() { + MEAL_MATCHER.assertMatch(service.getBetweenInclusive(null, null, USER_ID), meals); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java index d10a5ff59..b318695cc 100644 --- a/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java @@ -2,7 +2,6 @@ import org.junit.Test; import org.junit.runner.RunWith; -import org.slf4j.bridge.SLF4JBridgeHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.test.context.ContextConfiguration; @@ -27,23 +26,17 @@ @Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8")) public class UserServiceTest { - static { - // Only for postgres driver logging - // It uses java.util.logging and logged via jul-to-slf4j bridge - SLF4JBridgeHandler.install(); - } - @Autowired private UserService service; @Test public void create() { User created = service.create(getNew()); - Integer newId = created.getId(); + int newId = created.id(); User newUser = getNew(); newUser.setId(newId); - assertMatch(created, newUser); - assertMatch(service.get(newId), newUser); + USER_MATCHER.assertMatch(created, newUser); + USER_MATCHER.assertMatch(service.get(newId), newUser); } @Test @@ -66,7 +59,7 @@ public void deletedNotFound() { @Test public void get() { User user = service.get(USER_ID); - assertMatch(user, UserTestData.user); + USER_MATCHER.assertMatch(user, UserTestData.user); } @Test @@ -77,19 +70,19 @@ public void getNotFound() { @Test public void getByEmail() { User user = service.getByEmail("admin@gmail.com"); - assertMatch(user, admin); + USER_MATCHER.assertMatch(user, admin); } @Test public void update() { User updated = getUpdated(); service.update(updated); - assertMatch(service.get(USER_ID), getUpdated()); + USER_MATCHER.assertMatch(service.get(USER_ID), getUpdated()); } @Test public void getAll() { List all = service.getAll(); - assertMatch(all, admin, guest, user); + USER_MATCHER.assertMatch(all, admin, guest, user); } } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java index bdd730c22..2386eeee4 100644 --- a/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java +++ b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java @@ -14,9 +14,8 @@ import static ru.javawebinar.topjava.UserTestData.NOT_FOUND; import static ru.javawebinar.topjava.UserTestData.USER_ID; -@ContextConfiguration("classpath:spring/spring-app.xml") +@ContextConfiguration({"classpath:spring/spring-app.xml", "classpath:spring/inmemory.xml"}) @RunWith(SpringRunner.class) -@Ignore public class InMemoryAdminRestControllerSpringTest { @Autowired diff --git a/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java index 9ba51812d..7cc2a833c 100644 --- a/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java +++ b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java @@ -13,7 +13,6 @@ import static ru.javawebinar.topjava.UserTestData.NOT_FOUND; import static ru.javawebinar.topjava.UserTestData.USER_ID; -@Ignore public class InMemoryAdminRestControllerTest { private static final Logger log = LoggerFactory.getLogger(InMemoryAdminRestControllerTest.class); @@ -23,7 +22,7 @@ public class InMemoryAdminRestControllerTest { @BeforeClass public static void beforeClass() { - appCtx = new ClassPathXmlApplicationContext("spring/spring-app.xml"); + appCtx = new ClassPathXmlApplicationContext("spring/spring-app.xml", "spring/inmemory.xml"); log.info("\n{}\n", Arrays.toString(appCtx.getBeanDefinitionNames())); controller = appCtx.getBean(AdminRestController.class); repository = appCtx.getBean(InMemoryUserRepository.class); diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index 428ceca2e..da4eed7b9 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -12,7 +12,7 @@ - + diff --git a/src/test/resources/spring/inmemory.xml b/src/test/resources/spring/inmemory.xml new file mode 100644 index 000000000..c6a2710cb --- /dev/null +++ b/src/test/resources/spring/inmemory.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file From 3c6c13f50d0739d74289e153064b457ec13400b8 Mon Sep 17 00:00:00 2001 From: art94timer Date: Sat, 3 Sep 2022 10:20:16 +0300 Subject: [PATCH 13/19] prepare HW5 --- pom.xml | 117 ++++++++++++++---- .../java/ru/javawebinar/topjava/Profiles.java | 27 ++++ .../topjava/model/AbstractBaseEntity.java | 10 +- .../ru/javawebinar/topjava/model/Meal.java | 33 ++++- .../datajpa/CrudMealRepository.java | 7 ++ .../datajpa/CrudUserRepository.java | 19 +++ .../datajpa/DataJpaMealRepository.java | 43 +++++++ .../datajpa/DataJpaUserRepository.java | 44 +++++++ .../repository/jdbc/JdbcUserRepository.java | 7 +- .../repository/jpa/JpaMealRepository.java | 37 +++++- .../topjava/service/UserService.java | 6 + .../javawebinar/topjava/util/MealsUtil.java | 2 +- .../javawebinar/topjava/web/MealServlet.java | 18 ++- src/main/resources/cache/ehcache.xml | 25 ++++ src/main/resources/db/hsqldb.properties | 1 - src/main/resources/db/initDB_hsql.sql | 2 +- src/main/resources/db/postgres.properties | 1 - src/main/resources/logback.xml | 2 +- src/main/resources/spring/spring-app.xml | 1 + src/main/resources/spring/spring-cache.xml | 20 +++ src/main/resources/spring/spring-db.xml | 70 +++++++---- .../topjava/ActiveDbProfileResolver.java | 13 ++ .../javawebinar/topjava/MatcherFactory.java | 4 +- .../ru/javawebinar/topjava/MealTestData.java | 5 +- .../inmemory/InMemoryMealRepository.java | 13 +- .../inmemory/InMemoryUserRepository.java | 3 +- .../topjava/service/MealServiceTest.java | 37 +++++- .../topjava/service/UserServiceTest.java | 13 ++ src/test/resources/logback-test.xml | 16 ++- 29 files changed, 501 insertions(+), 95 deletions(-) create mode 100644 src/main/java/ru/javawebinar/topjava/Profiles.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudMealRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java create mode 100644 src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaUserRepository.java create mode 100644 src/main/resources/cache/ehcache.xml create mode 100644 src/main/resources/spring/spring-cache.xml create mode 100644 src/test/java/ru/javawebinar/topjava/ActiveDbProfileResolver.java diff --git a/pom.xml b/pom.xml index 6a48ff25e..b2b42faa5 100644 --- a/pom.xml +++ b/pom.xml @@ -12,11 +12,13 @@ http://topjava.herokuapp.com/ - 1.8 + 17 UTF-8 UTF-8 5.3.20 + 2.7.1 + 9.0.64 1.2.11 @@ -30,6 +32,9 @@ 6.2.3.Final 3.0.1-b12 + + 3.10.0 + 4.13.2 3.23.1 @@ -48,6 +53,11 @@ ${java.version} + + org.apache.maven.plugins + maven-war-plugin + 3.3.2 + org.apache.maven.plugins maven-surefire-plugin @@ -68,13 +78,6 @@ compile - - org.slf4j - jul-to-slf4j - ${slf4j.version} - runtime - - ch.qos.logback logback-classic @@ -82,31 +85,23 @@ runtime - - org.springframework - spring-context - ${spring.version} - - - org.springframework - spring-orm - ${spring.version} + javax.annotation + javax.annotation-api + 1.3.2 - + - org.postgresql - postgresql - ${postgresql.version} + org.springframework + spring-context-support - org.hsqldb - hsqldb - 2.3.4 + org.springframework.data + spring-data-jpa + ${spring-data-jpa.version} - org.hibernate @@ -127,6 +122,32 @@ provided + + + javax.cache + cache-api + 1.1.0 + + + org.ehcache + ehcache + runtime + ${ehcache.version} + + + org.glassfish.jaxb + jaxb-runtime + + + + + + + org.glassfish.jaxb + jaxb-runtime + 2.4.0-b180830.0438 + + javax.servlet @@ -151,7 +172,6 @@ org.springframework spring-test - ${spring.version} test @@ -163,8 +183,53 @@ + + hsqldb + + + org.hsqldb + hsqldb + 2.3.4 + + + + + + postgres + + + org.postgresql + postgresql + ${postgresql.version} + + + org.apache.tomcat + tomcat-jdbc + ${tomcat.version} + provided + + + org.slf4j + jul-to-slf4j + ${slf4j.version} + runtime + + + + true + + + + + org.springframework + spring-framework-bom + ${spring.version} + pom + import + + diff --git a/src/main/java/ru/javawebinar/topjava/Profiles.java b/src/main/java/ru/javawebinar/topjava/Profiles.java new file mode 100644 index 000000000..e4111a2af --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/Profiles.java @@ -0,0 +1,27 @@ +package ru.javawebinar.topjava; + +import org.springframework.util.ClassUtils; + +public class Profiles { + public static final String + JDBC = "jdbc", + JPA = "jpa", + DATAJPA = "datajpa"; + + public static final String REPOSITORY_IMPLEMENTATION = DATAJPA; + + public static final String + POSTGRES_DB = "postgres", + HSQL_DB = "hsqldb"; + + // Get DB profile depending of DB driver in classpath + public static String getActiveDbProfile() { + if (ClassUtils.isPresent("org.postgresql.Driver", null)) { + return POSTGRES_DB; + } else if (ClassUtils.isPresent("org.hsqldb.jdbcDriver", null)) { + return HSQL_DB; + } else { + throw new IllegalStateException("Could not find DB driver"); + } + } +} diff --git a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java index d9343a074..2ab30f34f 100644 --- a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java @@ -1,5 +1,7 @@ package ru.javawebinar.topjava.model; +import org.hibernate.Hibernate; +import org.springframework.data.domain.Persistable; import org.springframework.util.Assert; import javax.persistence.*; @@ -7,12 +9,14 @@ @MappedSuperclass // http://stackoverflow.com/questions/594597/hibernate-annotations-which-is-better-field-or-property-access @Access(AccessType.FIELD) -public abstract class AbstractBaseEntity { +public abstract class AbstractBaseEntity implements Persistable { public static final int START_SEQ = 100000; @Id @SequenceGenerator(name = "global_seq", sequenceName = "global_seq", allocationSize = 1, initialValue = START_SEQ) @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "global_seq") +// See https://hibernate.atlassian.net/browse/HHH-3718 and https://hibernate.atlassian.net/browse/HHH-12034 +// Proxy initialization when accessing its identifier managed now by JPA_PROXY_COMPLIANCE setting protected Integer id; protected AbstractBaseEntity() { @@ -26,6 +30,7 @@ public void setId(Integer id) { this.id = id; } + @Override public Integer getId() { return id; } @@ -35,6 +40,7 @@ public int id() { return id; } + @Override public boolean isNew() { return this.id == null; } @@ -49,7 +55,7 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (o == null || getClass() != o.getClass()) { + if (o == null || !getClass().equals(Hibernate.getClass(o))) { return false; } AbstractBaseEntity that = (AbstractBaseEntity) o; diff --git a/src/main/java/ru/javawebinar/topjava/model/Meal.java b/src/main/java/ru/javawebinar/topjava/model/Meal.java index 788ae8f6f..2451c945a 100644 --- a/src/main/java/ru/javawebinar/topjava/model/Meal.java +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -1,19 +1,48 @@ package ru.javawebinar.topjava.model; -import javax.persistence.FetchType; -import javax.persistence.ManyToOne; +import org.hibernate.validator.constraints.Range; + +import javax.persistence.*; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +@NamedQueries({ + @NamedQuery(name = Meal.ALL_SORTED, query = "SELECT m FROM Meal m WHERE m.user.id=:userId ORDER BY m.dateTime DESC"), + @NamedQuery(name = Meal.DELETE, query = "DELETE FROM Meal m WHERE m.id=:id AND m.user.id=:userId"), + @NamedQuery(name = Meal.GET_BETWEEN, query = """ + SELECT m FROM Meal m + WHERE m.user.id=:userId AND m.dateTime >= :startDateTime AND m.dateTime < :endDateTime ORDER BY m.dateTime DESC + """), +// @NamedQuery(name = Meal.UPDATE, query = "UPDATE Meal m SET m.dateTime = :datetime, m.calories= :calories," + +// "m.description=:desc where m.id=:id and m.user.id=:userId") +}) +@Entity +@Table(name = "meals", uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "date_time"}, name = "meals_unique_user_datetime_idx")}) public class Meal extends AbstractBaseEntity { + public static final String ALL_SORTED = "Meal.getAll"; + public static final String DELETE = "Meal.delete"; + public static final String GET_BETWEEN = "Meal.getBetween"; + + @Column(name = "date_time", nullable = false) + @NotNull private LocalDateTime dateTime; + @Column(name = "description", nullable = false) + @NotBlank + @Size(min = 2, max = 120) private String description; + @Column(name = "calories", nullable = false) + @Range(min = 10, max = 5000) private int calories; @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + @NotNull private User user; public Meal() { diff --git a/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudMealRepository.java new file mode 100644 index 000000000..a3659675c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudMealRepository.java @@ -0,0 +1,7 @@ +package ru.javawebinar.topjava.repository.datajpa; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.javawebinar.topjava.model.Meal; + +public interface CrudMealRepository extends JpaRepository { +} diff --git a/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java new file mode 100644 index 000000000..24c42a814 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java @@ -0,0 +1,19 @@ +package ru.javawebinar.topjava.repository.datajpa; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import ru.javawebinar.topjava.model.User; + +@Transactional(readOnly = true) +public interface CrudUserRepository extends JpaRepository { + @Transactional + @Modifying +// @Query(name = User.DELETE) + @Query("DELETE FROM User u WHERE u.id=:id") + int delete(@Param("id") int id); + + User getByEmail(String email); +} diff --git a/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java new file mode 100644 index 000000000..d1b4c8efe --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java @@ -0,0 +1,43 @@ +package ru.javawebinar.topjava.repository.datajpa; + +import org.springframework.stereotype.Repository; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.repository.MealRepository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class DataJpaMealRepository implements MealRepository { + + private final CrudMealRepository crudRepository; + + public DataJpaMealRepository(CrudMealRepository crudRepository) { + this.crudRepository = crudRepository; + } + + @Override + public Meal save(Meal meal, int userId) { + return null; + } + + @Override + public boolean delete(int id, int userId) { + return false; + } + + @Override + public Meal get(int id, int userId) { + return null; + } + + @Override + public List getAll(int userId) { + return null; + } + + @Override + public List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId) { + return null; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaUserRepository.java new file mode 100644 index 000000000..bc240d2d5 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaUserRepository.java @@ -0,0 +1,44 @@ +package ru.javawebinar.topjava.repository.datajpa; + +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.repository.UserRepository; + +import java.util.List; + +@Repository +public class DataJpaUserRepository implements UserRepository { + private static final Sort SORT_NAME_EMAIL = Sort.by(Sort.Direction.ASC, "name", "email"); + + private final CrudUserRepository crudRepository; + + public DataJpaUserRepository(CrudUserRepository crudRepository) { + this.crudRepository = crudRepository; + } + + @Override + public User save(User user) { + return crudRepository.save(user); + } + + @Override + public boolean delete(int id) { + return crudRepository.delete(id) != 0; + } + + @Override + public User get(int id) { + return crudRepository.findById(id).orElse(null); + } + + @Override + public User getByEmail(String email) { + return crudRepository.getByEmail(email); + } + + @Override + public List getAll() { + return crudRepository.findAll(SORT_NAME_EMAIL); + } +} diff --git a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java index 412bfbebc..0d4baa50e 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java @@ -41,9 +41,10 @@ public User save(User user) { if (user.isNew()) { Number newKey = insertUser.executeAndReturnKey(parameterSource); user.setId(newKey.intValue()); - } else if (namedParameterJdbcTemplate.update( - "UPDATE users SET name=:name, email=:email, password=:password, " + - "registered=:registered, enabled=:enabled, calories_per_day=:caloriesPerDay WHERE id=:id", parameterSource) == 0) { + } else if (namedParameterJdbcTemplate.update(""" + UPDATE users SET name=:name, email=:email, password=:password, + registered=:registered, enabled=:enabled, calories_per_day=:caloriesPerDay WHERE id=:id + """, parameterSource) == 0) { return null; } return user; diff --git a/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaMealRepository.java index 9cc19a4dc..300a920ae 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaMealRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaMealRepository.java @@ -1,37 +1,64 @@ package ru.javawebinar.topjava.repository.jpa; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.repository.MealRepository; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; import java.time.LocalDateTime; import java.util.List; @Repository +@Transactional(readOnly = true) public class JpaMealRepository implements MealRepository { + @PersistenceContext + private EntityManager em; + @Override + @Transactional public Meal save(Meal meal, int userId) { - return null; + meal.setUser(em.getReference(User.class, userId)); + if (meal.isNew()) { + em.persist(meal); + return meal; + } else if (get(meal.id(), userId) == null) { + return null; + } + return em.merge(meal); } @Override + @Transactional public boolean delete(int id, int userId) { - return false; + return em.createNamedQuery(Meal.DELETE) + .setParameter("id", id) + .setParameter("userId", userId) + .executeUpdate() != 0; } @Override public Meal get(int id, int userId) { - return null; + Meal meal = em.find(Meal.class, id); + return meal != null && meal.getUser().getId() == userId ? meal : null; } @Override public List getAll(int userId) { - return null; + return em.createNamedQuery(Meal.ALL_SORTED, Meal.class) + .setParameter("userId", userId) + .getResultList(); } @Override public List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId) { - return null; + return em.createNamedQuery(Meal.GET_BETWEEN, Meal.class) + .setParameter("userId", userId) + .setParameter("startDateTime", startDateTime) + .setParameter("endDateTime", endDateTime) + .getResultList(); } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/service/UserService.java b/src/main/java/ru/javawebinar/topjava/service/UserService.java index 09ccee68c..e0979b7ee 100644 --- a/src/main/java/ru/javawebinar/topjava/service/UserService.java +++ b/src/main/java/ru/javawebinar/topjava/service/UserService.java @@ -1,5 +1,7 @@ package ru.javawebinar.topjava.service; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.util.Assert; import ru.javawebinar.topjava.model.User; @@ -19,11 +21,13 @@ public UserService(UserRepository repository) { this.repository = repository; } + @CacheEvict(value = "users", allEntries = true) public User create(User user) { Assert.notNull(user, "user must not be null"); return repository.save(user); } + @CacheEvict(value = "users", allEntries = true) public void delete(int id) { checkNotFoundWithId(repository.delete(id), id); } @@ -37,10 +41,12 @@ public User getByEmail(String email) { return checkNotFound(repository.getByEmail(email), "email=" + email); } + @Cacheable("users") public List getAll() { return repository.getAll(); } + @CacheEvict(value = "users", allEntries = true) public void update(User user) { Assert.notNull(user, "user must not be null"); checkNotFoundWithId(repository.save(user), user.id()); diff --git a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java index d767652eb..658671b9d 100644 --- a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java @@ -35,7 +35,7 @@ private static List filterByPredicate(Collection meals, int calori return meals.stream() .filter(filter) .map(meal -> createTo(meal, caloriesSumByDate.get(meal.getDate()) > caloriesPerDay)) - .collect(Collectors.toList()); + .toList(); } private static MealTo createTo(Meal meal, boolean excess) { diff --git a/src/main/java/ru/javawebinar/topjava/web/MealServlet.java b/src/main/java/ru/javawebinar/topjava/web/MealServlet.java index a8f609938..defeb1d70 100644 --- a/src/main/java/ru/javawebinar/topjava/web/MealServlet.java +++ b/src/main/java/ru/javawebinar/topjava/web/MealServlet.java @@ -58,32 +58,30 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) t String action = request.getParameter("action"); switch (action == null ? "all" : action) { - case "delete": + case "delete" -> { int id = getId(request); mealController.delete(id); response.sendRedirect("meals"); - break; - case "create": - case "update": + } + case "create", "update" -> { final Meal meal = "create".equals(action) ? new Meal(LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES), "", 1000) : mealController.get(getId(request)); request.setAttribute("meal", meal); request.getRequestDispatcher("/mealForm.jsp").forward(request, response); - break; - case "filter": + } + case "filter" -> { LocalDate startDate = parseLocalDate(request.getParameter("startDate")); LocalDate endDate = parseLocalDate(request.getParameter("endDate")); LocalTime startTime = parseLocalTime(request.getParameter("startTime")); LocalTime endTime = parseLocalTime(request.getParameter("endTime")); request.setAttribute("meals", mealController.getBetween(startDate, startTime, endDate, endTime)); request.getRequestDispatcher("/meals.jsp").forward(request, response); - break; - case "all": - default: + } + default -> { request.setAttribute("meals", mealController.getAll()); request.getRequestDispatcher("/meals.jsp").forward(request, response); - break; + } } } diff --git a/src/main/resources/cache/ehcache.xml b/src/main/resources/cache/ehcache.xml new file mode 100644 index 000000000..05589f71f --- /dev/null +++ b/src/main/resources/cache/ehcache.xml @@ -0,0 +1,25 @@ + + + + + + + + + + 5 + + 5000 + + + + + + + 1 + + + + diff --git a/src/main/resources/db/hsqldb.properties b/src/main/resources/db/hsqldb.properties index c7944e25c..17c03ef4e 100644 --- a/src/main/resources/db/hsqldb.properties +++ b/src/main/resources/db/hsqldb.properties @@ -3,7 +3,6 @@ database.url=jdbc:hsqldb:mem:topjava database.username=sa database.password= -database.driverClassName=org.hsqldb.jdbcDriver database.init=true jdbc.initLocation=classpath:db/initDB_hsql.sql diff --git a/src/main/resources/db/initDB_hsql.sql b/src/main/resources/db/initDB_hsql.sql index 37f2da1bf..f2bb54b1e 100644 --- a/src/main/resources/db/initDB_hsql.sql +++ b/src/main/resources/db/initDB_hsql.sql @@ -21,7 +21,7 @@ CREATE UNIQUE INDEX users_unique_email_idx CREATE TABLE user_roles ( user_id INTEGER NOT NULL, - role VARCHAR(255), + role VARCHAR(255) NOT NULL, CONSTRAINT user_roles_idx UNIQUE (user_id, role), FOREIGN KEY (user_id) REFERENCES USERS (id) ON DELETE CASCADE ); diff --git a/src/main/resources/db/postgres.properties b/src/main/resources/db/postgres.properties index 5e657b44d..c45b1b6b9 100644 --- a/src/main/resources/db/postgres.properties +++ b/src/main/resources/db/postgres.properties @@ -5,7 +5,6 @@ database.url=jdbc:postgresql://localhost:5432/topjava database.username=user database.password=password -database.driverClassName=org.postgresql.Driver database.init=true jdbc.initLocation=classpath:db/initDB.sql diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index c7bffc3a9..12a4f63c6 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -16,7 +16,7 @@ UTF-8 - %d{HH:mm:ss.SSS} %-5level %class{50}.%M:%L - %msg%n + %d{HH:mm:ss.SSS} %highlight(%-5level) %cyan(%class{50}.%M:%L) - %msg%n diff --git a/src/main/resources/spring/spring-app.xml b/src/main/resources/spring/spring-app.xml index 4c17228b7..d6c643e97 100644 --- a/src/main/resources/spring/spring-app.xml +++ b/src/main/resources/spring/spring-app.xml @@ -9,6 +9,7 @@ --> + diff --git a/src/main/resources/spring/spring-cache.xml b/src/main/resources/spring/spring-cache.xml new file mode 100644 index 000000000..73325fee0 --- /dev/null +++ b/src/main/resources/spring/spring-cache.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/spring/spring-db.xml b/src/main/resources/spring/spring-db.xml index 970261d06..4106dbf1f 100644 --- a/src/main/resources/spring/spring-db.xml +++ b/src/main/resources/spring/spring-db.xml @@ -4,41 +4,23 @@ xmlns:context="http://www.springframework.org/schema/context" xmlns:jdbc="http://www.springframework.org/schema/jdbc" xmlns:tx="http://www.springframework.org/schema/tx" + xmlns:jpa="http://www.springframework.org/schema/data/jpa" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc.xsd - http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd"> + http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd + http://www.springframework.org/schema/data/jpa http://www.springframework.org/schema/data/jpa/spring-jpa.xsd"> - + - - - - - - - - - + + - - - - - - - - @@ -48,6 +30,7 @@ + @@ -75,4 +58,43 @@ --> + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/ActiveDbProfileResolver.java b/src/test/java/ru/javawebinar/topjava/ActiveDbProfileResolver.java new file mode 100644 index 000000000..2d819f84e --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/ActiveDbProfileResolver.java @@ -0,0 +1,13 @@ +package ru.javawebinar.topjava; + +import org.springframework.lang.NonNull; +import org.springframework.test.context.ActiveProfilesResolver; + +//http://stackoverflow.com/questions/23871255/spring-profiles-simple-example-of-activeprofilesresolver +public class ActiveDbProfileResolver implements ActiveProfilesResolver { + @Override + public @NonNull + String[] resolve(@NonNull Class aClass) { + return new String[]{Profiles.getActiveDbProfile()}; + } +} diff --git a/src/test/java/ru/javawebinar/topjava/MatcherFactory.java b/src/test/java/ru/javawebinar/topjava/MatcherFactory.java index 200e27e5f..c5f04086a 100644 --- a/src/test/java/ru/javawebinar/topjava/MatcherFactory.java +++ b/src/test/java/ru/javawebinar/topjava/MatcherFactory.java @@ -1,6 +1,6 @@ package ru.javawebinar.topjava; -import java.util.Arrays; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -27,7 +27,7 @@ public void assertMatch(T actual, T expected) { @SafeVarargs public final void assertMatch(Iterable actual, T... expected) { - assertMatch(actual, Arrays.asList(expected)); + assertMatch(actual, List.of(expected)); } public void assertMatch(Iterable actual, Iterable expected) { diff --git a/src/test/java/ru/javawebinar/topjava/MealTestData.java b/src/test/java/ru/javawebinar/topjava/MealTestData.java index 5190f5082..d044e3f90 100644 --- a/src/test/java/ru/javawebinar/topjava/MealTestData.java +++ b/src/test/java/ru/javawebinar/topjava/MealTestData.java @@ -4,14 +4,13 @@ import java.time.Month; import java.time.temporal.ChronoUnit; -import java.util.Arrays; import java.util.List; import static java.time.LocalDateTime.of; import static ru.javawebinar.topjava.model.AbstractBaseEntity.START_SEQ; public class MealTestData { - public static final MatcherFactory.Matcher MEAL_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(); + public static final MatcherFactory.Matcher MEAL_MATCHER = MatcherFactory.usingIgnoringFieldsComparator("user"); public static final int NOT_FOUND = 10; public static final int MEAL1_ID = START_SEQ + 3; @@ -27,7 +26,7 @@ public class MealTestData { public static final Meal adminMeal1 = new Meal(ADMIN_MEAL_ID, of(2020, Month.JANUARY, 31, 14, 0), "Админ ланч", 510); public static final Meal adminMeal2 = new Meal(ADMIN_MEAL_ID + 1, of(2020, Month.JANUARY, 31, 21, 0), "Админ ужин", 1500); - public static final List meals = Arrays.asList(meal7, meal6, meal5, meal4, meal3, meal2, meal1); + public static final List meals = List.of(meal7, meal6, meal5, meal4, meal3, meal2, meal1); public static Meal getNew() { return new Meal(null, of(2020, Month.FEBRUARY, 1, 18, 0), "Созданный ужин", 300); diff --git a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java index 05527fca8..5c65ced86 100644 --- a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java +++ b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java @@ -15,7 +15,6 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; -import java.util.stream.Collectors; @Repository public class InMemoryMealRepository implements MealRepository { @@ -25,7 +24,7 @@ public class InMemoryMealRepository implements MealRepository { private final Map> usersMealsMap = new ConcurrentHashMap<>(); { - InMemoryBaseRepository userMeals = new InMemoryBaseRepository<>(); + var userMeals = new InMemoryBaseRepository(); MealTestData.meals.forEach(userMeals::put); usersMealsMap.put(UserTestData.USER_ID, userMeals); } @@ -34,7 +33,7 @@ public class InMemoryMealRepository implements MealRepository { @Override public Meal save(Meal meal, int userId) { Objects.requireNonNull(meal, "meal must not be null"); - InMemoryBaseRepository meals = usersMealsMap.computeIfAbsent(userId, uId -> new InMemoryBaseRepository<>()); + var meals = usersMealsMap.computeIfAbsent(userId, uId -> new InMemoryBaseRepository<>()); return meals.save(meal); } @@ -50,13 +49,13 @@ public void preDestroy() { @Override public boolean delete(int id, int userId) { - InMemoryBaseRepository meals = usersMealsMap.get(userId); + var meals = usersMealsMap.get(userId); return meals != null && meals.delete(id); } @Override public Meal get(int id, int userId) { - InMemoryBaseRepository meals = usersMealsMap.get(userId); + var meals = usersMealsMap.get(userId); return meals == null ? null : meals.get(id); } @@ -71,11 +70,11 @@ public List getAll(int userId) { } private List filterByPredicate(int userId, Predicate filter) { - InMemoryBaseRepository meals = usersMealsMap.get(userId); + var meals = usersMealsMap.get(userId); return meals == null ? Collections.emptyList() : meals.getCollection().stream() .filter(filter) .sorted(Comparator.comparing(Meal::getDateTime).reversed()) - .collect(Collectors.toList()); + .toList(); } } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java index 7e2fadbf8..f3585dfff 100644 --- a/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java +++ b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryUserRepository.java @@ -7,7 +7,6 @@ import java.util.Comparator; import java.util.List; import java.util.Objects; -import java.util.stream.Collectors; import static ru.javawebinar.topjava.UserTestData.*; @@ -27,7 +26,7 @@ public void init() { public List getAll() { return getCollection().stream() .sorted(Comparator.comparing(User::getName).thenComparing(User::getEmail)) - .collect(Collectors.toList()); + .toList(); } @Override diff --git a/src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java index 12a4ba266..f6c3bb48f 100644 --- a/src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java @@ -1,20 +1,30 @@ package ru.javawebinar.topjava.service; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.Stopwatch; +import org.junit.runner.Description; import org.junit.runner.RunWith; +import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.test.context.junit4.SpringRunner; +import ru.javawebinar.topjava.ActiveDbProfileResolver; import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.util.exception.NotFoundException; import java.time.LocalDate; import java.time.Month; +import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertThrows; +import static org.slf4j.LoggerFactory.getLogger; import static ru.javawebinar.topjava.MealTestData.*; import static ru.javawebinar.topjava.UserTestData.ADMIN_ID; import static ru.javawebinar.topjava.UserTestData.USER_ID; @@ -25,11 +35,35 @@ }) @RunWith(SpringRunner.class) @Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8")) +@ActiveProfiles(resolver = ActiveDbProfileResolver.class) public class MealServiceTest { + private static final Logger log = getLogger("result"); + + private static final StringBuilder results = new StringBuilder(); + + @Rule + // http://stackoverflow.com/questions/14892125/what-is-the-best-practice-to-determine-the-execution-time-of-the-bussiness-relev + public final Stopwatch stopwatch = new Stopwatch() { + @Override + protected void finished(long nanos, Description description) { + String result = String.format("\n%-25s %7d", description.getMethodName(), TimeUnit.NANOSECONDS.toMillis(nanos)); + results.append(result); + log.info(result + " ms\n"); + } + }; @Autowired private MealService service; + @AfterClass + public static void printResult() { + log.info("\n---------------------------------" + + "\nTest Duration, ms" + + "\n---------------------------------" + + results + + "\n---------------------------------"); + } + @Test public void delete() { service.delete(MEAL1_ID, USER_ID); @@ -87,7 +121,8 @@ public void update() { @Test public void updateNotOwn() { - assertThrows(NotFoundException.class, () -> service.update(meal1, ADMIN_ID)); + NotFoundException exception = assertThrows(NotFoundException.class, () -> service.update(getUpdated(), ADMIN_ID)); + Assert.assertEquals("Not found entity with id=" + MEAL1_ID, exception.getMessage()); MEAL_MATCHER.assertMatch(service.get(MEAL1_ID, USER_ID), meal1); } diff --git a/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java index b318695cc..edc3aed7b 100644 --- a/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java @@ -1,13 +1,17 @@ package ru.javawebinar.topjava.service; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; import org.springframework.dao.DataAccessException; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.test.context.junit4.SpringRunner; +import ru.javawebinar.topjava.ActiveDbProfileResolver; import ru.javawebinar.topjava.UserTestData; import ru.javawebinar.topjava.model.Role; import ru.javawebinar.topjava.model.User; @@ -24,11 +28,20 @@ }) @RunWith(SpringRunner.class) @Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8")) +@ActiveProfiles(resolver = ActiveDbProfileResolver.class) public class UserServiceTest { @Autowired private UserService service; + @Autowired + private CacheManager cacheManager; + + @Before + public void setup() { + cacheManager.getCache("users").clear(); + } + @Test public void create() { User created = service.create(getNew()); diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index da4eed7b9..803655475 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -7,12 +7,22 @@ UTF-8 - %d{HH:mm:ss.SSS} %-5level %class{50}.%M:%L - %msg%n + %d{HH:mm:ss.SSS} %highlight(%-5level) %cyan(%class{50}.%M:%L) - %msg%n - - + + + UTF-8 + %magenta(%msg%n) + + + + + + + + From 232aaca87d006450732bb6e84b2523a0698ac115 Mon Sep 17 00:00:00 2001 From: art94timer Date: Tue, 13 Sep 2022 21:44:54 +0300 Subject: [PATCH 14/19] prepared HW06 --- config/messages/app.properties | 12 ++ config/messages/app_ru.properties | 12 ++ pom.xml | 60 +++++++++- .../ru/javawebinar/topjava/SpringMain.java | 9 +- .../topjava/model/AbstractBaseEntity.java | 1 + .../ru/javawebinar/topjava/model/Meal.java | 3 + .../ru/javawebinar/topjava/model/User.java | 24 +++- .../topjava/repository/JpaUtil.java | 21 ++++ .../topjava/repository/MealRepository.java | 4 + .../topjava/repository/UserRepository.java | 4 + .../datajpa/CrudMealRepository.java | 24 +++- .../datajpa/CrudUserRepository.java | 6 +- .../datajpa/DataJpaMealRepository.java | 31 +++-- .../datajpa/DataJpaUserRepository.java | 5 + .../repository/jdbc/JdbcMealRepository.java | 2 - .../topjava/service/MealService.java | 4 + .../topjava/service/UserService.java | 4 + .../topjava/util/ValidationUtil.java | 9 ++ .../javawebinar/topjava/web/MealServlet.java | 13 +-- .../topjava/web/RootController.java | 41 +++++++ .../javawebinar/topjava/web/UserServlet.java | 28 ----- src/main/resources/db/tomcat.properties | 5 + src/main/resources/logback.xml | 1 + src/main/resources/spring/spring-app.xml | 3 - src/main/resources/spring/spring-db.xml | 107 +++++++++++------- src/main/resources/spring/spring-mvc.xml | 36 ++++++ src/main/resources/tomcat/context.xml | 57 ++++++++++ .../WEB-INF/jsp/fragments/bodyHeader.jsp | 6 + .../webapp/WEB-INF/jsp/fragments/footer.jsp | 4 + .../webapp/WEB-INF/jsp/fragments/headTag.jsp | 9 ++ src/main/webapp/WEB-INF/jsp/index.jsp | 21 ++++ .../webapp/{ => WEB-INF/jsp}/mealForm.jsp | 0 src/main/webapp/{ => WEB-INF/jsp}/meals.jsp | 2 +- src/main/webapp/WEB-INF/jsp/users.jsp | 38 +++++++ src/main/webapp/WEB-INF/web.xml | 40 ++++--- src/main/webapp/index.html | 18 --- src/main/webapp/{ => resources}/css/style.css | 8 ++ src/main/webapp/users.jsp | 11 -- .../ru/javawebinar/topjava/TimingRules.java | 42 +++++++ .../ru/javawebinar/topjava/UserTestData.java | 2 +- ...Test.java => AbstractMealServiceTest.java} | 58 ++-------- .../topjava/service/AbstractServiceTest.java | 44 +++++++ ...Test.java => AbstractUserServiceTest.java} | 35 +++--- .../datajpa/DataJpaMealServiceTest.java | 29 +++++ .../datajpa/DataJpaUserServiceTest.java | 30 +++++ .../service/jdbc/JdbcMealServiceTest.java | 10 ++ .../service/jdbc/JdbcUserServiceTest.java | 12 ++ .../service/jpa/JpaMealServiceTest.java | 10 ++ .../service/jpa/JpaUserServiceTest.java | 10 ++ 49 files changed, 753 insertions(+), 212 deletions(-) create mode 100644 config/messages/app.properties create mode 100644 config/messages/app_ru.properties create mode 100644 src/main/java/ru/javawebinar/topjava/repository/JpaUtil.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/RootController.java delete mode 100644 src/main/java/ru/javawebinar/topjava/web/UserServlet.java create mode 100644 src/main/resources/db/tomcat.properties create mode 100644 src/main/resources/spring/spring-mvc.xml create mode 100644 src/main/resources/tomcat/context.xml create mode 100644 src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/fragments/footer.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/index.jsp rename src/main/webapp/{ => WEB-INF/jsp}/mealForm.jsp (100%) rename src/main/webapp/{ => WEB-INF/jsp}/meals.jsp (98%) create mode 100644 src/main/webapp/WEB-INF/jsp/users.jsp delete mode 100644 src/main/webapp/index.html rename src/main/webapp/{ => resources}/css/style.css (68%) delete mode 100644 src/main/webapp/users.jsp create mode 100644 src/test/java/ru/javawebinar/topjava/TimingRules.java rename src/test/java/ru/javawebinar/topjava/service/{MealServiceTest.java => AbstractMealServiceTest.java} (62%) create mode 100644 src/test/java/ru/javawebinar/topjava/service/AbstractServiceTest.java rename src/test/java/ru/javawebinar/topjava/service/{UserServiceTest.java => AbstractUserServiceTest.java} (65%) create mode 100644 src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaMealServiceTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcMealServiceTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcUserServiceTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/service/jpa/JpaMealServiceTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/service/jpa/JpaUserServiceTest.java diff --git a/config/messages/app.properties b/config/messages/app.properties new file mode 100644 index 000000000..ccd4c66b5 --- /dev/null +++ b/config/messages/app.properties @@ -0,0 +1,12 @@ +app.title=Calories management +app.home=Home +app.footer=Internship Spring 5/JPA Enterprise (Topjava) application +app.login=Login as +user.title=Users +user.name=Name +user.email=Email +user.roles=Roles +user.active=Active +user.registered=Registered +meal.title=Meals +common.select=Select \ No newline at end of file diff --git a/config/messages/app_ru.properties b/config/messages/app_ru.properties new file mode 100644 index 000000000..f22faf3cc --- /dev/null +++ b/config/messages/app_ru.properties @@ -0,0 +1,12 @@ +app.title=Подсчет калорий +app.home=Главная +app.footer=Приложение стажировки Spring 5/JPA Enterprise (Topjava) +app.login=Зайти как +user.title=Пользователи +user.name=Имя +user.email=Почта +user.roles=Роли +user.active=Активный +user.registered=Зарегистрирован +meal.title=Моя еда +common.select=Выбрать \ No newline at end of file diff --git a/pom.xml b/pom.xml index b2b42faa5..f21035571 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,48 @@ -Dfile.encoding=UTF-8 + + + + + org.codehaus.cargo + cargo-maven3-plugin + 1.9.13 + + + tomcat9x + + UTF-8 + tomcat,datajpa + + + + org.postgresql + postgresql + + + + + + + src/main/resources/tomcat/context.xml + conf/Catalina/localhost/ + ${project.build.finalName}.xml + + + + + + ru.javawebinar + topjava + war + + ${project.build.finalName} + + + + + @@ -113,6 +155,11 @@ hibernate-validator ${hibernate-validator.version} + + org.hibernate + hibernate-jcache + ${hibernate.version} + @@ -150,9 +197,9 @@ - javax.servlet - javax.servlet-api - 4.0.1 + org.apache.tomcat + tomcat-servlet-api + ${tomcat.version} provided @@ -162,6 +209,11 @@ 1.2 + + org.springframework + spring-webmvc + + junit @@ -189,7 +241,7 @@ org.hsqldb hsqldb - 2.3.4 + 2.6.1 diff --git a/src/main/java/ru/javawebinar/topjava/SpringMain.java b/src/main/java/ru/javawebinar/topjava/SpringMain.java index e41f1ae11..d809f025d 100644 --- a/src/main/java/ru/javawebinar/topjava/SpringMain.java +++ b/src/main/java/ru/javawebinar/topjava/SpringMain.java @@ -1,7 +1,6 @@ package ru.javawebinar.topjava; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.support.ClassPathXmlApplicationContext; +import org.springframework.context.support.GenericXmlApplicationContext; import ru.javawebinar.topjava.model.Role; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.to.MealTo; @@ -17,7 +16,11 @@ public class SpringMain { public static void main(String[] args) { // java 7 automatic resource management (ARM) - try (ConfigurableApplicationContext appCtx = new ClassPathXmlApplicationContext("spring/spring-app.xml", "spring/inmemory.xml")) { + try (GenericXmlApplicationContext appCtx = new GenericXmlApplicationContext()) { + appCtx.getEnvironment().setActiveProfiles(Profiles.getActiveDbProfile(), Profiles.REPOSITORY_IMPLEMENTATION); + appCtx.load("spring/spring-app.xml", "spring/spring-db.xml"); + appCtx.refresh(); + System.out.println("Bean definition names: " + Arrays.toString(appCtx.getBeanDefinitionNames())); AdminRestController adminUserController = appCtx.getBean(AdminRestController.class); adminUserController.create(new User(null, "userName", "email@mail.ru", "password", Role.ADMIN)); diff --git a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java index 2ab30f34f..acb116122 100644 --- a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java @@ -14,6 +14,7 @@ public abstract class AbstractBaseEntity implements Persistable { @Id @SequenceGenerator(name = "global_seq", sequenceName = "global_seq", allocationSize = 1, initialValue = START_SEQ) + // @Column(name = "id", unique = true, nullable = false, columnDefinition = "integer default nextval('global_seq')") @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "global_seq") // See https://hibernate.atlassian.net/browse/HHH-3718 and https://hibernate.atlassian.net/browse/HHH-12034 // Proxy initialization when accessing its identifier managed now by JPA_PROXY_COMPLIANCE setting diff --git a/src/main/java/ru/javawebinar/topjava/model/Meal.java b/src/main/java/ru/javawebinar/topjava/model/Meal.java index 2451c945a..c00282b1e 100644 --- a/src/main/java/ru/javawebinar/topjava/model/Meal.java +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -1,5 +1,7 @@ package ru.javawebinar.topjava.model; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import org.hibernate.validator.constraints.Range; import javax.persistence.*; @@ -42,6 +44,7 @@ public class Meal extends AbstractBaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) @NotNull private User user; diff --git a/src/main/java/ru/javawebinar/topjava/model/User.java b/src/main/java/ru/javawebinar/topjava/model/User.java index 1b121fc08..4acda507b 100644 --- a/src/main/java/ru/javawebinar/topjava/model/User.java +++ b/src/main/java/ru/javawebinar/topjava/model/User.java @@ -1,8 +1,15 @@ package ru.javawebinar.topjava.model; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.*; import org.hibernate.validator.constraints.Range; import org.springframework.util.CollectionUtils; +import javax.persistence.Entity; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.OrderBy; +import javax.persistence.Table; import javax.persistence.*; import javax.validation.constraints.Email; import javax.validation.constraints.NotBlank; @@ -12,10 +19,11 @@ import static ru.javawebinar.topjava.util.MealsUtil.DEFAULT_CALORIES_PER_DAY; +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) @NamedQueries({ @NamedQuery(name = User.DELETE, query = "DELETE FROM User u WHERE u.id=:id"), @NamedQuery(name = User.BY_EMAIL, query = "SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email=?1"), - @NamedQuery(name = User.ALL_SORTED, query = "SELECT u FROM User u LEFT JOIN FETCH u.roles ORDER BY u.name, u.email"), + @NamedQuery(name = User.ALL_SORTED, query = "SELECT u FROM User u ORDER BY u.name, u.email"), }) @Entity @Table(name = "users") @@ -43,17 +51,27 @@ public class User extends AbstractNamedEntity { @NotNull private Date registered = new Date(); + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) @Enumerated(EnumType.STRING) @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"), uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "role"}, name = "uk_user_roles")}) @Column(name = "role") @ElementCollection(fetch = FetchType.EAGER) +// @Fetch(FetchMode.SUBSELECT) + @BatchSize(size = 200) + @JoinColumn(name = "user_id") //https://stackoverflow.com/a/62848296/548473 + @OnDelete(action = OnDeleteAction.CASCADE) private Set roles; @Column(name = "calories_per_day", nullable = false, columnDefinition = "int default 2000") @Range(min = 10, max = 10000) private int caloriesPerDay = DEFAULT_CALORIES_PER_DAY; + @OneToMany(fetch = FetchType.LAZY, mappedBy = "user")//, cascade = CascadeType.REMOVE, orphanRemoval = true) + @OrderBy("dateTime DESC") + @OnDelete(action = OnDeleteAction.CASCADE) //https://stackoverflow.com/a/44988100/548473 + private List meals; + public User() { } @@ -123,6 +141,10 @@ public String getPassword() { return password; } + public List getMeals() { + return meals; + } + @Override public String toString() { return "User{" + diff --git a/src/main/java/ru/javawebinar/topjava/repository/JpaUtil.java b/src/main/java/ru/javawebinar/topjava/repository/JpaUtil.java new file mode 100644 index 000000000..f3e51ad50 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/JpaUtil.java @@ -0,0 +1,21 @@ +package ru.javawebinar.topjava.repository; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +public class JpaUtil { + + @PersistenceContext + private EntityManager em; + + public void clear2ndLevelHibernateCache() { + Session s = (Session) em.getDelegate(); + SessionFactory sf = s.getSessionFactory(); +// sf.getCache().evictEntityData(User.class, AbstractBaseEntity.START_SEQ); +// sf.getCache().evictEntityData(User.class); + sf.getCache().evictAllRegions(); + } +} diff --git a/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java index 9461d5f9f..1ad7f8d94 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java @@ -20,4 +20,8 @@ public interface MealRepository { // ORDERED dateTime desc List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId); + + default Meal getWithUser(int id, int userId) { + throw new UnsupportedOperationException(); + } } diff --git a/src/main/java/ru/javawebinar/topjava/repository/UserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/UserRepository.java index 138369789..9fecbddaa 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/UserRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/UserRepository.java @@ -18,4 +18,8 @@ public interface UserRepository { User getByEmail(String email); List getAll(); + + default User getWithMeals(int id) { + throw new UnsupportedOperationException(); + } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudMealRepository.java index a3659675c..9aeef134f 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudMealRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudMealRepository.java @@ -1,7 +1,29 @@ package ru.javawebinar.topjava.repository.datajpa; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import ru.javawebinar.topjava.model.Meal; +import java.time.LocalDateTime; +import java.util.List; + +@Transactional(readOnly = true) public interface CrudMealRepository extends JpaRepository { -} + + @Modifying + @Transactional + @Query("DELETE FROM Meal m WHERE m.id=:id AND m.user.id=:userId") + int delete(@Param("id") int id, @Param("userId") int userId); + + @Query("SELECT m FROM Meal m WHERE m.user.id=:userId ORDER BY m.dateTime DESC") + List getAll(@Param("userId") int userId); + + @Query("SELECT m from Meal m WHERE m.user.id=:userId AND m.dateTime >= :startDate AND m.dateTime < :endDate ORDER BY m.dateTime DESC") + List getBetweenHalfOpen(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate, @Param("userId") int userId); + + @Query("SELECT m FROM Meal m JOIN FETCH m.user WHERE m.id = ?1 and m.user.id = ?2") + Meal getWithUser(int id, int userId); +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java index 24c42a814..806884a9c 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java @@ -1,5 +1,6 @@ package ru.javawebinar.topjava.repository.datajpa; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -11,9 +12,12 @@ public interface CrudUserRepository extends JpaRepository { @Transactional @Modifying -// @Query(name = User.DELETE) @Query("DELETE FROM User u WHERE u.id=:id") int delete(@Param("id") int id); User getByEmail(String email); + + @EntityGraph(attributePaths = {"meals", "roles"}) + @Query("SELECT u FROM User u WHERE u.id=?1") + User getWithMeals(int id); } diff --git a/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java index d1b4c8efe..dc93b47e5 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java @@ -1,6 +1,7 @@ package ru.javawebinar.topjava.repository.datajpa; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.repository.MealRepository; @@ -10,34 +11,48 @@ @Repository public class DataJpaMealRepository implements MealRepository { - private final CrudMealRepository crudRepository; + private final CrudMealRepository crudMealRepository; + private final CrudUserRepository crudUserRepository; - public DataJpaMealRepository(CrudMealRepository crudRepository) { - this.crudRepository = crudRepository; + public DataJpaMealRepository(CrudMealRepository crudMealRepository, CrudUserRepository crudUserRepository) { + this.crudMealRepository = crudMealRepository; + this.crudUserRepository = crudUserRepository; } @Override + @Transactional public Meal save(Meal meal, int userId) { - return null; + if (!meal.isNew() && get(meal.getId(), userId) == null) { + return null; + } + meal.setUser(crudUserRepository.getOne(userId)); + return crudMealRepository.save(meal); } @Override public boolean delete(int id, int userId) { - return false; + return crudMealRepository.delete(id, userId) != 0; } @Override public Meal get(int id, int userId) { - return null; + return crudMealRepository.findById(id) + .filter(meal -> meal.getUser().getId() == userId) + .orElse(null); } @Override public List getAll(int userId) { - return null; + return crudMealRepository.getAll(userId); } @Override public List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId) { - return null; + return crudMealRepository.getBetweenHalfOpen(startDateTime, endDateTime, userId); + } + + @Override + public Meal getWithUser(int id, int userId) { + return crudMealRepository.getWithUser(id, userId); } } diff --git a/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaUserRepository.java index bc240d2d5..608c855e0 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaUserRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaUserRepository.java @@ -41,4 +41,9 @@ public User getByEmail(String email) { public List getAll() { return crudRepository.findAll(SORT_NAME_EMAIL); } + + @Override + public User getWithMeals(int id) { + return crudRepository.getWithMeals(id); + } } 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 fa26d5663..1f3fe5ffe 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java @@ -1,6 +1,5 @@ package ru.javawebinar.topjava.repository.jdbc; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.support.DataAccessUtils; import org.springframework.jdbc.core.BeanPropertyRowMapper; import org.springframework.jdbc.core.JdbcTemplate; @@ -26,7 +25,6 @@ public class JdbcMealRepository implements MealRepository { private final SimpleJdbcInsert insertMeal; - @Autowired public JdbcMealRepository(JdbcTemplate jdbcTemplate, NamedParameterJdbcTemplate namedParameterJdbcTemplate) { this.insertMeal = new SimpleJdbcInsert(jdbcTemplate) .withTableName("meals") diff --git a/src/main/java/ru/javawebinar/topjava/service/MealService.java b/src/main/java/ru/javawebinar/topjava/service/MealService.java index df874378e..5e08c9e5a 100644 --- a/src/main/java/ru/javawebinar/topjava/service/MealService.java +++ b/src/main/java/ru/javawebinar/topjava/service/MealService.java @@ -47,4 +47,8 @@ public Meal create(Meal meal, int userId) { Assert.notNull(meal, "meal must not be null"); return repository.save(meal, userId); } + + public Meal getWithUser(int id, int userId) { + return checkNotFoundWithId(repository.getWithUser(id, userId), id); + } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/service/UserService.java b/src/main/java/ru/javawebinar/topjava/service/UserService.java index e0979b7ee..2fa248762 100644 --- a/src/main/java/ru/javawebinar/topjava/service/UserService.java +++ b/src/main/java/ru/javawebinar/topjava/service/UserService.java @@ -51,4 +51,8 @@ public void update(User user) { Assert.notNull(user, "user must not be null"); checkNotFoundWithId(repository.save(user), user.id()); } + + public User getWithMeals(int id) { + return checkNotFoundWithId(repository.getWithMeals(id), id); + } } \ 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 5212eea75..986c7d97d 100644 --- a/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java @@ -1,6 +1,8 @@ package ru.javawebinar.topjava.util; +import org.springframework.core.NestedExceptionUtils; +import org.springframework.lang.NonNull; import ru.javawebinar.topjava.model.AbstractBaseEntity; import ru.javawebinar.topjava.util.exception.NotFoundException; @@ -43,4 +45,11 @@ public static void assureIdConsistent(AbstractBaseEntity entity, int id) { throw new IllegalArgumentException(entity + " must be with id=" + id); } } + + // https://stackoverflow.com/a/65442410/548473 + @NonNull + public static Throwable getRootCause(@NonNull Throwable t) { + Throwable rootCause = NestedExceptionUtils.getRootCause(t); + return rootCause != null ? rootCause : t; + } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/MealServlet.java b/src/main/java/ru/javawebinar/topjava/web/MealServlet.java index defeb1d70..fb667d351 100644 --- a/src/main/java/ru/javawebinar/topjava/web/MealServlet.java +++ b/src/main/java/ru/javawebinar/topjava/web/MealServlet.java @@ -1,8 +1,8 @@ package ru.javawebinar.topjava.web; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.support.ClassPathXmlApplicationContext; import org.springframework.util.StringUtils; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.WebApplicationContextUtils; import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.web.meal.MealRestController; @@ -22,21 +22,14 @@ public class MealServlet extends HttpServlet { - private ConfigurableApplicationContext springContext; private MealRestController mealController; @Override public void init() { - springContext = new ClassPathXmlApplicationContext("spring/spring-app.xml", "spring/spring-db.xml"); + WebApplicationContext springContext = WebApplicationContextUtils.getRequiredWebApplicationContext(getServletContext()); mealController = springContext.getBean(MealRestController.class); } - @Override - public void destroy() { - springContext.close(); - super.destroy(); - } - @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); diff --git a/src/main/java/ru/javawebinar/topjava/web/RootController.java b/src/main/java/ru/javawebinar/topjava/web/RootController.java new file mode 100644 index 000000000..f432d93b1 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/RootController.java @@ -0,0 +1,41 @@ +package ru.javawebinar.topjava.web; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import ru.javawebinar.topjava.service.UserService; + +import javax.servlet.http.HttpServletRequest; + +@Controller +public class RootController { + private static final Logger log = LoggerFactory.getLogger(RootController.class); + + @Autowired + private UserService service; + + @GetMapping("/") + public String root() { + log.info("root"); + return "index"; + } + + @GetMapping("/users") + public String getUsers(Model model) { + log.info("users"); + model.addAttribute("users", service.getAll()); + return "users"; + } + + @PostMapping("/users") + public String setUser(HttpServletRequest request) { + int userId = Integer.parseInt(request.getParameter("userId")); + log.info("setUser {}", userId); + SecurityUtil.setAuthUserId(userId); + return "redirect:meals"; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java b/src/main/java/ru/javawebinar/topjava/web/UserServlet.java deleted file mode 100644 index 226023400..000000000 --- a/src/main/java/ru/javawebinar/topjava/web/UserServlet.java +++ /dev/null @@ -1,28 +0,0 @@ -package ru.javawebinar.topjava.web; - -import org.slf4j.Logger; - -import javax.servlet.ServletException; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; - -import static org.slf4j.LoggerFactory.getLogger; - -public class UserServlet extends HttpServlet { - private static final Logger log = getLogger(UserServlet.class); - - @Override - protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - int userId = Integer.parseInt(request.getParameter("userId")); - SecurityUtil.setAuthUserId(userId); - response.sendRedirect("meals"); - } - - @Override - protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - log.debug("forward to users"); - request.getRequestDispatcher("/users.jsp").forward(request, response); - } -} diff --git a/src/main/resources/db/tomcat.properties b/src/main/resources/db/tomcat.properties new file mode 100644 index 000000000..2e073681a --- /dev/null +++ b/src/main/resources/db/tomcat.properties @@ -0,0 +1,5 @@ +database.init=false +jdbc.initLocation=initDB.sql +jpa.showSql=true +hibernate.format_sql=true +hibernate.use_sql_comments=true \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 12a4f63c6..809d4c9c3 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -21,6 +21,7 @@ + diff --git a/src/main/resources/spring/spring-app.xml b/src/main/resources/spring/spring-app.xml index d6c643e97..d57b656ae 100644 --- a/src/main/resources/spring/spring-app.xml +++ b/src/main/resources/spring/spring-app.xml @@ -12,7 +12,4 @@ - - - \ No newline at end of file diff --git a/src/main/resources/spring/spring-db.xml b/src/main/resources/spring/spring-db.xml index 4106dbf1f..2b62f0748 100644 --- a/src/main/resources/spring/spring-db.xml +++ b/src/main/resources/spring/spring-db.xml @@ -5,59 +5,20 @@ xmlns:jdbc="http://www.springframework.org/schema/jdbc" xmlns:tx="http://www.springframework.org/schema/tx" xmlns:jpa="http://www.springframework.org/schema/data/jpa" + xmlns:jee="http://www.springframework.org/schema/jee" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd - http://www.springframework.org/schema/data/jpa http://www.springframework.org/schema/data/jpa/spring-jpa.xsd"> - - - - - + http://www.springframework.org/schema/data/jpa http://www.springframework.org/schema/data/jpa/spring-jpa.xsd + http://www.springframework.org/schema/jee http://www.springframework.org/schema/jee/spring-jee.xsd"> - - - - - - - - - - - - - - - - - - - - - - - - + @@ -91,10 +52,70 @@ p:password="${database.password}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/spring/spring-mvc.xml b/src/main/resources/spring/spring-mvc.xml new file mode 100644 index 000000000..aa5599fea --- /dev/null +++ b/src/main/resources/spring/spring-mvc.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/tomcat/context.xml b/src/main/resources/tomcat/context.xml new file mode 100644 index 000000000..9311d5904 --- /dev/null +++ b/src/main/resources/tomcat/context.xml @@ -0,0 +1,57 @@ + + + + + + + + WEB-INF/web.xml + ${catalina.base}/conf/web.xml + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp b/src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp new file mode 100644 index 000000000..5b5efe57e --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp @@ -0,0 +1,6 @@ +<%@page contentType="text/html" pageEncoding="UTF-8" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> + +

    + | | +
    \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/fragments/footer.jsp b/src/main/webapp/WEB-INF/jsp/fragments/footer.jsp new file mode 100644 index 000000000..0935c441a --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/fragments/footer.jsp @@ -0,0 +1,4 @@ +<%@page contentType="text/html" pageEncoding="UTF-8" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> +
    +
    \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp b/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp new file mode 100644 index 000000000..6d77694e3 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp @@ -0,0 +1,9 @@ +<%@page contentType="text/html" pageEncoding="UTF-8" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jstl/core" %> + + + + <spring:message code="app.title"/> + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/index.jsp b/src/main/webapp/WEB-INF/jsp/index.jsp new file mode 100644 index 000000000..847191965 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/index.jsp @@ -0,0 +1,21 @@ +<%@ page contentType="text/html;charset=UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> + + + + + +
    +
    +
    + : + + +
    + + + \ No newline at end of file diff --git a/src/main/webapp/mealForm.jsp b/src/main/webapp/WEB-INF/jsp/mealForm.jsp similarity index 100% rename from src/main/webapp/mealForm.jsp rename to src/main/webapp/WEB-INF/jsp/mealForm.jsp diff --git a/src/main/webapp/meals.jsp b/src/main/webapp/WEB-INF/jsp/meals.jsp similarity index 98% rename from src/main/webapp/meals.jsp rename to src/main/webapp/WEB-INF/jsp/meals.jsp index 7d9bf3e42..007453221 100644 --- a/src/main/webapp/meals.jsp +++ b/src/main/webapp/WEB-INF/jsp/meals.jsp @@ -9,7 +9,7 @@
    -

    Home

    +

    Home


    Meals

    diff --git a/src/main/webapp/WEB-INF/jsp/users.jsp b/src/main/webapp/WEB-INF/jsp/users.jsp new file mode 100644 index 000000000..4d3d86789 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/users.jsp @@ -0,0 +1,38 @@ +<%@ page contentType="text/html;charset=UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> + + + + + + +
    +

    + + + + + + + + + + + + + + + + + + + + + +
    ${user.email}${user.roles}${user.enabled}
    +
    + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index bd98d3bf3..b7558a705 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -6,24 +6,34 @@ Topjava - - userServlet - ru.javawebinar.topjava.web.UserServlet - 0 - - - userServlet - /users - + + spring.profiles.default + postgres,datajpa + + + + contextConfigLocation + + classpath:spring/spring-app.xml + classpath:spring/spring-db.xml + + + + + org.springframework.web.context.ContextLoaderListener + - mealServlet - ru.javawebinar.topjava.web.MealServlet - 0 + mvc-dispatcher + org.springframework.web.servlet.DispatcherServlet + + contextConfigLocation + classpath:spring/spring-mvc.xml + + 1 - mealServlet - /meals + mvc-dispatcher + / - diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html deleted file mode 100644 index 57d710a81..000000000 --- a/src/main/webapp/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - Java Enterprise (Topjava) - - -

    Проект Java Enterprise (Topjava)

    -
    - - Meals of  - - - - - diff --git a/src/main/webapp/css/style.css b/src/main/webapp/resources/css/style.css similarity index 68% rename from src/main/webapp/css/style.css rename to src/main/webapp/resources/css/style.css index cfffdcb53..a55147510 100644 --- a/src/main/webapp/css/style.css +++ b/src/main/webapp/resources/css/style.css @@ -22,3 +22,11 @@ tr[data-meal-excess="false"] { tr[data-meal-excess="true"] { color: red; } + +header, footer { + background: none repeat scroll 0 0 #A6C9E2; + color: #2E6E9E; + font-size: 20px; + padding: 5px 20px; + margin: 6px 0; +} diff --git a/src/main/webapp/users.jsp b/src/main/webapp/users.jsp deleted file mode 100644 index 650c8dda4..000000000 --- a/src/main/webapp/users.jsp +++ /dev/null @@ -1,11 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" %> - - - Users - - -

    Home

    -
    -

    Users

    - - \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/TimingRules.java b/src/test/java/ru/javawebinar/topjava/TimingRules.java new file mode 100644 index 000000000..fdd3d8779 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/TimingRules.java @@ -0,0 +1,42 @@ +package ru.javawebinar.topjava; + +import org.junit.rules.ExternalResource; +import org.junit.rules.Stopwatch; +import org.junit.runner.Description; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; + +public class TimingRules { + private static final Logger log = LoggerFactory.getLogger("result"); + + private static final StringBuilder results = new StringBuilder(); + + // http://stackoverflow.com/questions/14892125/what-is-the-best-practice-to-determine-the-execution-time-of-the-bussiness-relev + public static final Stopwatch STOPWATCH = new Stopwatch() { + @Override + protected void finished(long nanos, Description description) { + String result = String.format("%-95s %7d", description.getDisplayName(), TimeUnit.NANOSECONDS.toMillis(nanos)); + results.append(result).append('\n'); + log.info(result + " ms\n"); + } + }; + + // https://dzone.com/articles/applying-new-jdk-11-string-methods + private static final String DELIM = "-".repeat(103); + + public static final ExternalResource SUMMARY = new ExternalResource() { + @Override + protected void before() throws Throwable { + results.setLength(0); + } + + @Override + protected void after() { + log.info("\n" + DELIM + + "\nTest Duration, ms" + + "\n" + DELIM + "\n" + results + DELIM + "\n"); + } + }; +} diff --git a/src/test/java/ru/javawebinar/topjava/UserTestData.java b/src/test/java/ru/javawebinar/topjava/UserTestData.java index bfaba979d..3419c5215 100644 --- a/src/test/java/ru/javawebinar/topjava/UserTestData.java +++ b/src/test/java/ru/javawebinar/topjava/UserTestData.java @@ -9,7 +9,7 @@ import static ru.javawebinar.topjava.model.AbstractBaseEntity.START_SEQ; public class UserTestData { - public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator("registered", "roles"); + public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator("registered", "roles", "meals"); public static final int USER_ID = START_SEQ; public static final int ADMIN_ID = START_SEQ + 1; diff --git a/src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/AbstractMealServiceTest.java similarity index 62% rename from src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java rename to src/test/java/ru/javawebinar/topjava/service/AbstractMealServiceTest.java index f6c3bb48f..b19ffabe8 100644 --- a/src/test/java/ru/javawebinar/topjava/service/MealServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/AbstractMealServiceTest.java @@ -1,68 +1,26 @@ package ru.javawebinar.topjava.service; -import org.junit.AfterClass; import org.junit.Assert; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.Stopwatch; -import org.junit.runner.Description; -import org.junit.runner.RunWith; -import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.context.jdbc.SqlConfig; -import org.springframework.test.context.junit4.SpringRunner; -import ru.javawebinar.topjava.ActiveDbProfileResolver; import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.util.exception.NotFoundException; +import javax.validation.ConstraintViolationException; import java.time.LocalDate; import java.time.Month; -import java.util.concurrent.TimeUnit; +import static java.time.LocalDateTime.of; import static org.junit.Assert.assertThrows; -import static org.slf4j.LoggerFactory.getLogger; import static ru.javawebinar.topjava.MealTestData.*; import static ru.javawebinar.topjava.UserTestData.ADMIN_ID; import static ru.javawebinar.topjava.UserTestData.USER_ID; -@ContextConfiguration({ - "classpath:spring/spring-app.xml", - "classpath:spring/spring-db.xml" -}) -@RunWith(SpringRunner.class) -@Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8")) -@ActiveProfiles(resolver = ActiveDbProfileResolver.class) -public class MealServiceTest { - private static final Logger log = getLogger("result"); - - private static final StringBuilder results = new StringBuilder(); - - @Rule - // http://stackoverflow.com/questions/14892125/what-is-the-best-practice-to-determine-the-execution-time-of-the-bussiness-relev - public final Stopwatch stopwatch = new Stopwatch() { - @Override - protected void finished(long nanos, Description description) { - String result = String.format("\n%-25s %7d", description.getMethodName(), TimeUnit.NANOSECONDS.toMillis(nanos)); - results.append(result); - log.info(result + " ms\n"); - } - }; +public abstract class AbstractMealServiceTest extends AbstractServiceTest { @Autowired - private MealService service; - - @AfterClass - public static void printResult() { - log.info("\n---------------------------------" + - "\nTest Duration, ms" + - "\n---------------------------------" + - results + - "\n---------------------------------"); - } + protected MealService service; @Test public void delete() { @@ -143,4 +101,12 @@ public void getBetweenInclusive() { public void getBetweenWithNullDates() { MEAL_MATCHER.assertMatch(service.getBetweenInclusive(null, null, USER_ID), meals); } + + @Test + public void createWithException() throws Exception { + validateRootCause(ConstraintViolationException.class, () -> service.create(new Meal(null, of(2015, Month.JUNE, 1, 18, 0), " ", 300), USER_ID)); + validateRootCause(ConstraintViolationException.class, () -> service.create(new Meal(null, null, "Description", 300), USER_ID)); + validateRootCause(ConstraintViolationException.class, () -> service.create(new Meal(null, of(2015, Month.JUNE, 1, 18, 0), "Description", 9), USER_ID)); + validateRootCause(ConstraintViolationException.class, () -> service.create(new Meal(null, of(2015, Month.JUNE, 1, 18, 0), "Description", 5001), USER_ID)); + } } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/AbstractServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/AbstractServiceTest.java new file mode 100644 index 000000000..06d99de97 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/AbstractServiceTest.java @@ -0,0 +1,44 @@ +package ru.javawebinar.topjava.service; + +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.rules.ExternalResource; +import org.junit.rules.Stopwatch; +import org.junit.runner.RunWith; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.context.junit4.SpringRunner; +import ru.javawebinar.topjava.ActiveDbProfileResolver; +import ru.javawebinar.topjava.TimingRules; + +import static org.junit.Assert.assertThrows; +import static ru.javawebinar.topjava.util.ValidationUtil.getRootCause; + +@ContextConfiguration({ + "classpath:spring/spring-app.xml", + "classpath:spring/spring-db.xml" +}) +@RunWith(SpringRunner.class) +@Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8")) +@ActiveProfiles(resolver = ActiveDbProfileResolver.class) +public abstract class AbstractServiceTest { + + @ClassRule + public static ExternalResource summary = TimingRules.SUMMARY; + + @Rule + public Stopwatch stopwatch = TimingRules.STOPWATCH; + + // Check root cause in JUnit: https://github.com/junit-team/junit4/pull/778 + protected void validateRootCause(Class rootExceptionClass, Runnable runnable) { + assertThrows(rootExceptionClass, () -> { + try { + runnable.run(); + } catch (Exception e) { + throw getRootCause(e); + } + }); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/AbstractUserServiceTest.java similarity index 65% rename from src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java rename to src/test/java/ru/javawebinar/topjava/service/AbstractUserServiceTest.java index edc3aed7b..1ebd08f87 100644 --- a/src/test/java/ru/javawebinar/topjava/service/UserServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/AbstractUserServiceTest.java @@ -2,44 +2,38 @@ import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.CacheManager; import org.springframework.dao.DataAccessException; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.context.jdbc.SqlConfig; -import org.springframework.test.context.junit4.SpringRunner; -import ru.javawebinar.topjava.ActiveDbProfileResolver; import ru.javawebinar.topjava.UserTestData; import ru.javawebinar.topjava.model.Role; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.util.exception.NotFoundException; +import javax.validation.ConstraintViolationException; +import java.util.Date; import java.util.List; +import java.util.Set; +import ru.javawebinar.topjava.repository.JpaUtil; import static org.junit.Assert.assertThrows; import static ru.javawebinar.topjava.UserTestData.*; -@ContextConfiguration({ - "classpath:spring/spring-app.xml", - "classpath:spring/spring-db.xml" -}) -@RunWith(SpringRunner.class) -@Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8")) -@ActiveProfiles(resolver = ActiveDbProfileResolver.class) -public class UserServiceTest { +public abstract class AbstractUserServiceTest extends AbstractServiceTest { @Autowired - private UserService service; + protected UserService service; @Autowired private CacheManager cacheManager; + @Autowired + protected JpaUtil jpaUtil; + @Before public void setup() { cacheManager.getCache("users").clear(); + jpaUtil.clear2ndLevelHibernateCache(); } @Test @@ -98,4 +92,13 @@ public void getAll() { List all = service.getAll(); USER_MATCHER.assertMatch(all, admin, guest, user); } + + @Test + public void createWithException() throws Exception { + validateRootCause(ConstraintViolationException.class, () -> service.create(new User(null, " ", "mail@yandex.ru", "password", Role.USER))); + validateRootCause(ConstraintViolationException.class, () -> service.create(new User(null, "User", " ", "password", Role.USER))); + validateRootCause(ConstraintViolationException.class, () -> service.create(new User(null, "User", "mail@yandex.ru", " ", Role.USER))); + validateRootCause(ConstraintViolationException.class, () -> service.create(new User(null, "User", "mail@yandex.ru", "password", 9, true, new Date(), Set.of()))); + validateRootCause(ConstraintViolationException.class, () -> service.create(new User(null, "User", "mail@yandex.ru", "password", 10001, true, new Date(), Set.of()))); + } } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaMealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaMealServiceTest.java new file mode 100644 index 000000000..83cbd7cc2 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaMealServiceTest.java @@ -0,0 +1,29 @@ +package ru.javawebinar.topjava.service.datajpa; + +import org.junit.Assert; +import org.junit.Test; +import org.springframework.test.context.ActiveProfiles; +import ru.javawebinar.topjava.MealTestData; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.service.AbstractMealServiceTest; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +import static ru.javawebinar.topjava.MealTestData.*; +import static ru.javawebinar.topjava.Profiles.DATAJPA; +import static ru.javawebinar.topjava.UserTestData.*; + +@ActiveProfiles(DATAJPA) +public class DataJpaMealServiceTest extends AbstractMealServiceTest { + @Test + public void getWithUser() { + Meal adminMeal = service.getWithUser(ADMIN_MEAL_ID, ADMIN_ID); + MEAL_MATCHER.assertMatch(adminMeal, adminMeal1); + USER_MATCHER.assertMatch(adminMeal.getUser(), admin); + } + + @Test + public void getWithUserNotFound() { + Assert.assertThrows(NotFoundException.class, + () -> service.getWithUser(MealTestData.NOT_FOUND, ADMIN_ID)); + } +} diff --git a/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java new file mode 100644 index 000000000..7733b7bfc --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java @@ -0,0 +1,30 @@ +package ru.javawebinar.topjava.service.datajpa; + +import org.junit.Assert; +import org.junit.Test; +import org.springframework.test.context.ActiveProfiles; +import ru.javawebinar.topjava.MealTestData; +import ru.javawebinar.topjava.UserTestData; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.service.AbstractUserServiceTest; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +import static ru.javawebinar.topjava.MealTestData.MEAL_MATCHER; +import static ru.javawebinar.topjava.Profiles.DATAJPA; +import static ru.javawebinar.topjava.UserTestData.*; + +@ActiveProfiles(DATAJPA) +public class DataJpaUserServiceTest extends AbstractUserServiceTest { + @Test + public void getWithMeals() { + User user = service.getWithMeals(USER_ID); + USER_MATCHER.assertMatch(user, UserTestData.user); + MEAL_MATCHER.assertMatch(user.getMeals(), MealTestData.meals); + } + + @Test + public void getWithMealsNotFound() { + Assert.assertThrows(NotFoundException.class, + () -> service.getWithMeals(NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcMealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcMealServiceTest.java new file mode 100644 index 000000000..9ff4ae615 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcMealServiceTest.java @@ -0,0 +1,10 @@ +package ru.javawebinar.topjava.service.jdbc; + +import org.springframework.test.context.ActiveProfiles; +import ru.javawebinar.topjava.service.AbstractMealServiceTest; + +import static ru.javawebinar.topjava.Profiles.JDBC; + +@ActiveProfiles(JDBC) +public class JdbcMealServiceTest extends AbstractMealServiceTest { +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcUserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcUserServiceTest.java new file mode 100644 index 000000000..477b92cae --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcUserServiceTest.java @@ -0,0 +1,12 @@ +package ru.javawebinar.topjava.service.jdbc; + +import org.junit.Ignore; +import org.springframework.test.context.ActiveProfiles; +import ru.javawebinar.topjava.service.AbstractUserServiceTest; + +import static ru.javawebinar.topjava.Profiles.JDBC; + +@ActiveProfiles(JDBC) +@Ignore +public class JdbcUserServiceTest extends AbstractUserServiceTest { +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/jpa/JpaMealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/jpa/JpaMealServiceTest.java new file mode 100644 index 000000000..70e7bf865 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/jpa/JpaMealServiceTest.java @@ -0,0 +1,10 @@ +package ru.javawebinar.topjava.service.jpa; + +import org.springframework.test.context.ActiveProfiles; +import ru.javawebinar.topjava.service.AbstractMealServiceTest; + +import static ru.javawebinar.topjava.Profiles.JPA; + +@ActiveProfiles(JPA) +public class JpaMealServiceTest extends AbstractMealServiceTest { +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/jpa/JpaUserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/jpa/JpaUserServiceTest.java new file mode 100644 index 000000000..d1b3e4699 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/jpa/JpaUserServiceTest.java @@ -0,0 +1,10 @@ +package ru.javawebinar.topjava.service.jpa; + +import org.springframework.test.context.ActiveProfiles; +import ru.javawebinar.topjava.service.AbstractUserServiceTest; + +import static ru.javawebinar.topjava.Profiles.JPA; + +@ActiveProfiles(JPA) +public class JpaUserServiceTest extends AbstractUserServiceTest { +} \ No newline at end of file From 0cce9d4eaab7bc40dfc6539adf5fa09e3f1e4586 Mon Sep 17 00:00:00 2001 From: art94timer Date: Sat, 24 Sep 2022 19:44:57 +0300 Subject: [PATCH 15/19] prepare HW07 --- config/Topjava-soapui-project.xml | 395 ++++++++++ config/messages/app.properties | 19 +- config/messages/app_ru.properties | 19 +- doc/lesson07.md | 694 ++++++++++++++++++ pom.xml | 43 +- .../ru/javawebinar/topjava/SpringMain.java | 2 +- .../topjava/model/AbstractBaseEntity.java | 1 + .../ru/javawebinar/topjava/model/Meal.java | 2 +- .../ru/javawebinar/topjava/model/User.java | 5 +- .../topjava/repository/JpaUtil.java | 21 - .../datajpa/CrudUserRepository.java | 14 +- .../datajpa/DataJpaMealRepository.java | 4 +- .../repository/jdbc/JdbcMealRepository.java | 7 + .../repository/jdbc/JdbcUserRepository.java | 66 +- .../repository/jpa/JpaUserRepository.java | 2 + .../topjava/util/ValidationUtil.java | 20 + .../javawebinar/topjava/web/MealServlet.java | 85 --- .../topjava/web/RootController.java | 17 +- .../topjava/web/json/JacksonObjectMapper.java | 37 + .../topjava/web/json/JsonUtil.java | 37 + .../web/meal/AbstractMealController.java | 72 ++ .../topjava/web/meal/JspMealController.java | 70 ++ .../topjava/web/meal/MealRestController.java | 71 +- .../topjava/web/user/AdminRestController.java | 37 +- .../web/user/ProfileRestController.java | 20 +- src/main/resources/db/populateDB.sql | 3 +- src/main/resources/db/postgres.properties | 3 +- src/main/resources/spring/spring-db.xml | 11 +- src/main/resources/spring/spring-mvc.xml | 18 +- .../webapp/WEB-INF/jsp/fragments/headTag.jsp | 1 + src/main/webapp/WEB-INF/jsp/mealForm.jsp | 25 +- src/main/webapp/WEB-INF/jsp/meals.jsp | 44 +- src/main/webapp/WEB-INF/web.xml | 17 + .../topjava/ActiveDbProfileResolver.java | 12 +- .../javawebinar/topjava/MatcherFactory.java | 36 +- .../ru/javawebinar/topjava/MealTestData.java | 2 +- .../javawebinar/topjava/TimingExtension.java | 36 + .../ru/javawebinar/topjava/TimingRules.java | 42 -- .../ru/javawebinar/topjava/UserTestData.java | 4 +- .../service/AbstractMealServiceTest.java | 36 +- .../topjava/service/AbstractServiceTest.java | 26 +- .../service/AbstractUserServiceTest.java | 41 +- .../datajpa/DataJpaMealServiceTest.java | 12 +- .../datajpa/DataJpaUserServiceTest.java | 18 +- .../service/jdbc/JdbcMealServiceTest.java | 2 +- .../service/jdbc/JdbcUserServiceTest.java | 4 +- .../service/jpa/JpaMealServiceTest.java | 2 +- .../service/jpa/JpaUserServiceTest.java | 2 +- .../topjava/web/AbstractControllerTest.java | 52 ++ .../topjava/web/RootControllerTest.java | 35 + .../topjava/web/json/JsonUtilTest.java | 30 + .../web/user/AdminRestControllerTest.java | 87 +++ ...InMemoryAdminRestControllerSpringTest.java | 26 +- .../user/InMemoryAdminRestControllerTest.java | 30 +- .../web/user/ProfileRestControllerTest.java | 48 ++ src/test/resources/spring/inmemory.xml | 2 + src/test/resources/spring/spring-cache.xml | 27 + 57 files changed, 2071 insertions(+), 423 deletions(-) create mode 100644 config/Topjava-soapui-project.xml create mode 100644 doc/lesson07.md delete mode 100644 src/main/java/ru/javawebinar/topjava/repository/JpaUtil.java delete mode 100644 src/main/java/ru/javawebinar/topjava/web/MealServlet.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/json/JacksonObjectMapper.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/json/JsonUtil.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/meal/AbstractMealController.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/meal/JspMealController.java create mode 100644 src/test/java/ru/javawebinar/topjava/TimingExtension.java delete mode 100644 src/test/java/ru/javawebinar/topjava/TimingRules.java create mode 100644 src/test/java/ru/javawebinar/topjava/web/AbstractControllerTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/web/RootControllerTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/web/json/JsonUtilTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/web/user/AdminRestControllerTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/web/user/ProfileRestControllerTest.java create mode 100644 src/test/resources/spring/spring-cache.xml diff --git a/config/Topjava-soapui-project.xml b/config/Topjava-soapui-project.xml new file mode 100644 index 000000000..f9668d765 --- /dev/null +++ b/config/Topjava-soapui-project.xml @@ -0,0 +1,395 @@ + + + + + + + + http://localhost:8080 + + + + + + + + + text/html;charset=utf-8 + 500 401 + + html + + + application/json + 200 + + ns:Response + + + application/json;charset=UTF-8 + 200 + + Response + + + + <xml-fragment/> + + http://localhost:8080 + + http://localhost/topjava/rest/admin/users + + user@yandex.ru + password + No Authorization + Basic + No Authorization + + + + + + + + + + + application/json + + + + text/html;charset=utf-8 + 500 + + html + + + application/json + 201 + + user:Response + + + application/json;charset=UTF-8 + 201 + + user:Response + + + + <xml-fragment/> + + http://localhost:8080 + {"name": "New2", + "email": "new2@yandex.ru", + "password": "passwordNew", + "roles": ["USER"] + } + + http://localhost/topjava/rest/admin/users + + No Authorization + Basic + No Authorization + + + + + + + + + + + + + + + text/html;charset=utf-8 + 500 + + html + + + application/json + 200 + + ns:Response + + + application/json;charset=UTF-8 + 200 + + ns:Response + + + + <xml-fragment/> + + http://localhost:8080 + + http://localhost/topjava/rest/admin/users/100000 + + No Authorization + Basic + No Authorization + + + + + + + + + + + text/html;charset=utf-8 + 405 500 + + html + + + application/json + + + + + 200 + + data + + + + 200 + + data + + + + 200 + + data + + + + 200 + + data + + + + 200 + + data + + + + 200 + + data + + + + <xml-fragment/> + + http://localhost:8080 + {"name": "UserUpdated", + "email": "user@yandex.ru", + "password": "passwordNew", + "roles": ["USER"] + } + + http://localhost/topjava/rest/admin/users/100000 + + Basic + Basic + Global HTTP Settings + + + + + + + + + + + + + + + text/html;charset=utf-8 + 500 + + html + + + application/json + 200 + + ns:Response + + + application/json;charset=UTF-8 + 200 + + prof:Response + + + + <xml-fragment/> + + http://localhost:8080 + + http://localhost/topjava/rest/profile + + Basic + Basic + Global HTTP Settings + + + + + + + + + + + application/json + + + + text/html;charset=utf-8 + 500 405 + + html + + + application/json + 201 + + user:Response + + + + 200 + + data + + + + 200 + + data + + + + 200 + + data + + + + 200 + + data + + + + 200 + + data + + + + 200 + + data + + + + 200 + + data + + + + <xml-fragment/> + + http://localhost:8080 + {"name": "New777", + "email": "new777@yandex.ru", + "password": "passwordNew", + "roles": ["USER"] + } + + http://localhost/topjava/rest/profile + + No Authorization + Basic + No Authorization + + + + + + + + + + + + 200 + + data + + + text/html;charset=utf-8 + 500 + + html + + + application/json + + + + + 200 + + data + + + + 204 + + data + + + + <xml-fragment/> + + http://localhost:8080 + + http://localhost/topjava/rest/profile + + No Authorization + Basic + No Authorization + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/messages/app.properties b/config/messages/app.properties index ccd4c66b5..6b4d2d155 100644 --- a/config/messages/app.properties +++ b/config/messages/app.properties @@ -2,11 +2,28 @@ app.title=Calories management app.home=Home app.footer=Internship Spring 5/JPA Enterprise (Topjava) application app.login=Login as + user.title=Users user.name=Name user.email=Email user.roles=Roles user.active=Active user.registered=Registered + meal.title=Meals -common.select=Select \ No newline at end of file +meal.edit=Edit meal +meal.add=Add meal +meal.filter=Filter +meal.startDate=From date (inclusive) +meal.endDate=To date (inclusive) +meal.startTime=From time (inclusive) +meal.endTime=To time (exclusive) +meal.description=Description +meal.dateTime=Date/Time +meal.calories=Calories + +common.select=Select +common.delete=Delete +common.update=Update +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 f22faf3cc..97f3e9f05 100644 --- a/config/messages/app_ru.properties +++ b/config/messages/app_ru.properties @@ -2,11 +2,28 @@ app.title=Подсчет калорий app.home=Главная app.footer=Приложение стажировки Spring 5/JPA Enterprise (Topjava) app.login=Зайти как + user.title=Пользователи user.name=Имя user.email=Почта user.roles=Роли user.active=Активный user.registered=Зарегистрирован + meal.title=Моя еда -common.select=Выбрать \ No newline at end of file +meal.edit=Редактирование еды +meal.add=Добавление еды +meal.filter=Отфильтровать +meal.startDate=От даты (включая) +meal.endDate=До даты (включая) +meal.startTime=От времени (включая) +meal.endTime=До времени (исключая) +meal.description=Описание +meal.dateTime=Дата/Время +meal.calories=Калории + +common.select=Выбрать +common.delete=Удалить +common.update=Обновить +common.save=Сохранить +common.cancel=Отменить \ No newline at end of file diff --git a/doc/lesson07.md b/doc/lesson07.md new file mode 100644 index 000000000..0d3e09d53 --- /dev/null +++ b/doc/lesson07.md @@ -0,0 +1,694 @@ +# Стажировка Topjava + +## Материалы занятия + +### ![correction](https://cloud.githubusercontent.com/assets/13649199/13672935/ef09ec1e-e6e7-11e5-9f79-d1641c05cbe6.png) Правки в проекте + +#### Apply 7_0_fix.patch +- Мелкие правки. +Если вы уже применяли патч в прошлом уроке - он немного изменился. Поправьте в `User` вместо `@JoinColumn(name = "id")` просто `@JoinColumn` +Или можно сделать на патче `6_15_spring_i18n` [*Reset Current Branch to Here...->Hard*](https://github.com/JavaOPs/topjava/wiki/Git#user-content-revert) и применить новый `7_0_fix` + +## ![hw](https://cloud.githubusercontent.com/assets/13649199/13672719/09593080-e6e7-11e5-81d1-5cb629c438ca.png) Разбор домашнего задания HW6 + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 1. HW6 + +#### Apply 7_01_HW6_fix_tests.patch + +
    + Краткое содержание + +#### Починить InMemory и JDBC тесты + +InMemory-тесты перестали работать, т.к ранее мы перенесли сканирование каталога `web` из `spring-app.xml` в конфигурацию `spring-mvc.xml`, которой нет в тестах. В результате контроллеры перестали +попадать в спринг-контекст тестов. Для восстановления добавим сканирование каталога `web` в конфигурацию `inmemory.xml`. Теперь в классах, которые работают с InMemory-реализацией, для создания +контекста можно оставить импорт только конфигурации +`spring/inmemory.xml`. + +JDBC-тесты перестали работать, т.к в конфигурации `spring-db.xml` мы объявили бин `JpaUtil` только для профилей jpa и dataJpa, для других профилей (jdbc) этот бин создаваться не будет. +JDBC-тесты мы запускаем с профилем jdbc, но в абстрактном классе AbstractUserServiceTest (общем для всех тестов сервисного слоя User) для всех профилей мы указали необходимость создания переменной +типа `JpaUtil`. Соответственно, для профиля jdbc в контексте спринга будет отсутствовать этот бин, и спринг не сможет запустить приложение из-за неразрешенной зависимости. + +Чтобы спринг смог поднять контекст в профиле JDBC, нужно указать над переменной `jpaUtil` +аннотацию `@Autowired(required = false)` - мы указываем спрингу, что эта зависимость не является обязательной и можно ее проигнорировать. + +> В новой версии заменил аннотацию на ленивую инициализацию `@Lazy` + +И в `@Before` методе тестов используем этот бин только для JPA реализаций. +Для этого создадим утильный метод `isJpaBased()`, который будет проверять, относится ли текущая реализация к jpa. Чтобы проверить, с какими профилями запущен Spring, нам придется внедрить +в `AbstractServiceTest` +бин класса `Environment`. Это класс спринга, который позволит получить доступ к информации о том, с какими параметрами он был запущен, с помощью +```env.acceptsProfiles(org.springframework.core.env.Profiles.of(Profiles.JPA, Profiles.DATAJPA))``` +С помощью этого же утильного метода теперь мы можем проверить, что для `MealServiceTest` тесты на валидацию `validateRootCause()` будут выполняться только для jpa/dataJpa профилей (если этот тест +запустить для профиля jdbc, то он упадет, т.к. пока в JDBC у нас нет валидации). + +#### Локализация, jsp:include для meal*.jsp + +1. В файлы интернационализации `app.properties` добавляем дополнительные пары ключ-значение для русского и английского языка. В JSP страницах вместо текста, по аналогии со страницами для User, + указываем ключи, вместо которых спринг должен подставить локализованные сообщения. +2. Для каждой JSP страницы для включения фрагментов указываем теги: + +`` - в нем определены title страницы, ссылка на статические ресурсы и базовая ссылка на корень приложения. + +`` - верхняя часть страниц, в ней определены ссылки для навигации по приложению. +И в самом низу страниц: +`` + +Так как мы локализуем приложение с помощью Spring, на страницах нужно удалить тег: +`` - с ним работает только jstl. + +3. Для того, чтобы на страницах получить доступ к корню приложения, используется + `"${pageRequest.request.contextPath}"` - эту ссылку на root удобнее вынести в `headTag` в виде [`` элемента](https://stackoverflow.com/a/40228804/548473), чтобы она вместе с этим + фрагментом добавлялась к каждой странице, и не требовалось бы ее везде дублировать. + +4. Чтобы видеть, к каким URL были привязаны контроллеры во время работы приложения, в `logback.xml` настроим уровень логирования для Spring web: + `` + +#### Перенести функциональность из `MealServlet` в контроллеры + +Чтобы не дублировать одну и ту же функциональность для REST- и JSP-контроллеров, создадим абстрактный +`AbstractMealController` (от него будут наследоваться остальные Meal-контроллеры), куда перенесем все методы из +`MealRestController`. JSP-контроллер будет работать с jsp-страницами. Каждый метод этого контроллера будет делегировать основную функциональность в родительский абстрактный контроллер. + +> **Внимание!**. Не делайте без нужды абстрактных контроллеров в своих выпускных проектах! + +Так как каждый метод этого контроллера должен отвечать за единственное действие, разнесем функциональность по разным методам, а доступ к самим методам разделим с помощью +аннотации `@RequestMapping (@GetMapping / @PostMapping)`, в их параметрах укажем путь к endpoint, по которому можно обратиться к методу. + +При этом для всего контроллера также зададим `@RequestMapping("/meals")` (`value=` - параметр по умолчанию, можно не указывать). Это префикс запроса для всех методов контроллера. + +> Один из признаков "хорошего" контроллера, где не смешивается разная функциональность, - этот общий url. Для каждой функциональности в выпускных создавайте свой собственный контроллер! + +Для доступа к определенному методу контроллера нужно будет указать уникальный для нашего приложения "путь + http-метод", который складывается из маппинга к контроллеру, маппинга к нужному методу и +http-метода, например: +`GET {корень приложения} + "/meals" + "/delete"` +`GET {корень приложения} + "/meals"` +`POST {корень приложения} + "/meals"` +Для `mealList.jsp` теперь не нужно с запросом дополнительно передавать тип действия, которое мы хотим совершить с едой, мы можем просто обратиться к нужному методу по его уникальному пути (endpoint, +url). + +Если на этом шаге запустить приложение, то мы столкнемся с проблемой: при выполнении манипуляций и переходе по ссылкам путь портится. + +- путь к ресурсу по этой ссылке строится не от корня приложения (application context - topjava), а от текущего контекста сервлета (servlet context), например: + `localhost:8080/topjava/meals'+'/meals` + Также перестали работать стили, так как путь к статическим ресурсам тоже определяется неверно (посмотрите вкладку *Network* браузера). + Чтобы это исправить, добавим базовый URL в `headTag`: + `base href = "${pageContext.request.contextPath}/"`. **Теперь это станет url, от которой будут строиться все относительные ссылки на страницах**. + +Также некоторые методы контроллера в результате работы должны не просто вернуть название view, который Spring MVC должен отобразить, а совершить *redirect*. Для этого при возврате имени view +дополнительно укажем ключевое слово `redirect:`, например, `redirect: /meals`. + +Последняя проблема — некорректное отображение текста в кодировке UTF-8. Spring предоставляет для ее решения стандартный фильтр, который будет перехватывать все запросы и ответы сервера и устанавливать +им нужную кодировку: в `web.xml` подключим `encodingFilter`. + +
    + +> Инжекцию в `AbstractUserServiceTest.jpaUtil` сделал [`@Lazy`: не иннициализировать бин до первого использования](https://www.logicbig.com/tutorials/spring-framework/spring-core/lazy-at-injection-point.html). + +#### Apply 7_02_HW6_meals.patch + +> сделал фильтрацию еды через `get`: операция идемпотентная, можно делать в браузере обновление по F5 + +### Внимание: чиним пути в следующем патче + +При переходе на AJAX `JspMealController` удалим за ненадобностью, возвращение всей еды `meals()` останется в `RootController`. + +#### Apply 7_03_HW6_fix_relative_url_utf8.patch + +- + Relative paths in JSP +- + Spring redirect: prefix + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 2. HW6 Optional + +
    + Краткое содержание + +#### Добавление еще одной роли для Admin + +1. В файле популирования базы данных `populateDB.sql` добавим для admin дополнительную роль `ROLE_USER`. + +2. В тестовых данных для него также добавим аналогичную роль. + +> После этого тесты, которые связаны с методом `getAll()`, перестали работать, потому что для получения +> списка всех пользователей с их ролями в именованном запросе мы использовали **LEFT JOIN FETCH**. +> Происходит объединение таблиц, в результирующей таблице вместо одной записи для админа появляются дублирующие записи для одного и того же пользователя. +> - простой способ решения - исключить из запроса **LEFT JOIN FETCH**. Роли все равно будут загружены, так как они FetchType.EAGER. +> - также можно добавить в запрос ключевое слово **DISTINCT(u)** - теперь в результирующей таблице будут содержаться только уникальные записи. + +#### Добавление транзакционности в JDBC реализацию репозитория + +Чтобы аннотация `@Transactional` стала работать во всех профилях Spring - в файле `spring-db.xml` вынесем из профиля jpa, dataJpa в общую конфигурацию для всех профилей тег: +`````` + +Для профиля jdbc настроим DatasourceTransactionManager, который будет управлять транзакциями: +`` + + + +`` +После этого в JDBC-репозитории мы можем расставить аннотации `@Transactional` аналогично jpa репозиториям, и действия станут выполняться транзакционно ( +напомню: `` для логирования информации по транзакциям) + +#### Чтобы JDBC репозиторий смог работать с множественными ролями пользователя: + +У пользователя добавим сеттер для его ролей. Для JDBC-репозитория создадим вспомогательные методы для записи ролей в базу и их считывания из базы и установления пользователю. Запись ролей в базу будем +производить методом +`JdbcTemplate#batchUpdate`, в таком случае не будет обращения в базу для записи каждой конкретной роли, команды для записи ролей будут накоплены в один пакет и выполнятся за одно обращение к БД. Для +удобства работы с batch Spring предоставляет нам интерфейс `BatchPreparedStatementSetter`, с помощью которого мы определяем как будут устанавливаться параметры для запроса и количество запросов в +одном пакете. Также создадим метод `deleteRoles`, в котором будем удалять роли пользователя из базы (для обновления ролей в базе мы делаем просто: сначала удалим старые из базы и запишем туда новые). + +> PS: в JPA с `@ElementCollection` и с параметром *cascade* в `@OneToMany` слияние (merge) изменений в связанных коллекциях происходит автоматически. + +Если мы будем получать всех пользователей вместе с их ролями из базы с помощью JOIN, мы столкнемся с проблемой Декартова произведения: для каждого уникального пользователя количество записей в +результирующей таблице будет повторяться столько раз, сколько у него было ролей. Чтобы этого избежать, отдельным запросом получим из базы все роли, и сгруппируем их в `Map` по `userId`, где ключом +будет являться `userId`, а значением — набор ролей пользователя. После чего пройдемся по всем пользователям, загруженным из базы, и установим каждому его роли. +
    + +#### Apply 7_04_HW6_optional_add_role.patch + +> - Для доставания ролей у нас дублируется `fetch = EAGER` и `LEFT JOIN FETCH u.roles` (можно делать что-то одно). Запросы выполняются по-разному: проверьте. + +- Отключил `JdbcUserServiceTest` - роли не работают. Будем чинить в `7_06_HW6_jdbc_transaction_roles.patch` +- `DataJpaUserServiceTest.getWithMeals` не работает для admin (у админа 2 роли, и еда при JOIN дублируется). Чиним в следующем патче. + +#### Apply 7_05_fix_hint_graph.patch + +- В `DataJpaUserServiceTest.getWithMeals()` при дублировании еды `DISTINCT` при нескольких JOIN не помогает, оставил в `@EntityGraph` только `meals`. +- В `JpaUserRepositoryImpl.getByEmail` и `CrudUserRepository.getByEmail` DISTINCT попадает в запрос, хотя он там не нужен. Это просто указание Hibernate не дублировать данные. Для оптимизации можно + указать Hibernate делать запрос без distinct: [15.16.2. Using DISTINCT with entity queries](https://docs.jboss.org/hibernate/orm/5.2/userguide/html_single/Hibernate_User_Guide.html#hql-distinct) +- Бага [HINT_PASS_DISTINCT_THROUGH does not work if 'hibernate.use_sql_comments=true'](https://hibernate.atlassian.net/browse/HHH-13280). При `hibernate.use_sql_comments=false` все работает - в SELECT + нет DISTINCT. + +Еще один вариант решения - в `User` сделать `Set`. Интересно, что в ее реализации `PersistentSet`порядок соблюдается и `@OrderBy` работает. + +#### Apply 7_06_HW6_jdbc_transaction_roles.patch + +> - в `JdbcUserRepositoryImpl.getAll()` собираю роли из `ResultSet` напрямую в `map` +> - в `insertRoles` поменял метод `batchUpdate` и сделал проверку на empty +> - в `setRoles` достаю роли через `queryForList` + +Еще интересные JDBC реализации: + +- в `getAll()/ get()/ getByEmail()` делать запросы с `LEFT JOIN` и сделать реализацию `ResultSetExtractor` +- подключить зависимость `spring-data-jdbc-core`. Там есть готовый `OneToManyResultSetExtractor`. Можно посмотреть, как он реализован. +- реализация, зависимая от БД: доставать агрегированные роли и делать им `split(",")`. В этой реализации у нас ограничение - одно поле из зависимой таблицы. + +``` +SELECT u.*, string_agg(r.role, ',') AS roles +FROM users u + JOIN user_roles r ON u.id=r.user_id +GROUP BY u.id +``` + +### Валидация для `JdbcUserRepository` через Bean Validation API + +#### Apply 7_07_HW6_optional_jdbc_validation.patch + +- [Валидация данных при помощи Bean Validation API](https://alexkosarev.name/2018/07/30/bean-validation-api/). + +На данный момент у нас реализована валидация сущностей только для jpa- и dataJpa-репозиториев. При работе через JDBC-репозиторий может произойти попытка записи в БД некорректных данных, что приведет +к `SQLException` из-за нарушения ограничений, наложенных на столбцы базы данных. Для того, чтобы перехватить невалидные данные еще до обращения в базу, воспользуемся API *javax.validation* (ее +реализация `hibernate-validator` используется для проверки данных в Hibernate и будет использоваться в Spring Validation, которую подключим позже). В `ValidationUtil` создадим один потокобезопасный +валидатор, который можно переиспользовать (см. *javadoc*). +С его помощью в методах сохранения и обновления сущности в jdbc-репозиториях мы можем производить валидацию этой сущности: `ValidationUtil.validate(object);` +Чтобы проверка не падала, `@NotNull Meal.user` пришлось пока закомментировать. Починим в 10-м занятии через `@JsonView`. + +### Отключение кэша в тестах: + +Вместо наших приседаний с `JpaUtil` и проверкой профилей мы можем полностью отключить Spring-кэш в тестах через пустую реализацию `NoOpCacheManager`. +Кэш Hibernate второго уровня отключаем через переопределение свойства `entityManagerFactory.jpaPropertyMap: hibernate.cache.use_second_level_cache=false` (кроме стандартного использования файла +пропертей, можно задать их прямо в конфигурации, через автодополнение в xml можно смотреть все варианты). Подкладываем новый `spring-cache.xml` в ресурсы тестов, он перекроет настройки кэша в +приложении. Остается удалить наши уже ненужные `JpaUtil` и `AbstractServiceTest.isJpaBased()` + +#### Apply 7_08_HW06_optional2_disable_tests_cache.patch + +- [Example of PropertyOverrideConfigurer](https://www.concretepage.com/spring/example_propertyoverrideconfigurer_spring) +- [Spring util schema](https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#xsd-schemas-util) + +## Занятие 7: + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 3. Тестирование Spring MVC + +
    + Краткое содержание + +#### Тестирование Spring MVC + +Для более удобного сравнения объектов в тестах мы будем использовать библиотеку *Harmcrest* с Matcher'ами, которая позволяет делать сложные проверки. С *Junit* по умолчанию подтягивается *Harmcrest +core*, но нам потребуется расширенная версия: +в `pom.xml` из зависимости Junit исключим дочернюю `hamcrest-core` и добавим `hamcrest-all`. + +Для тестирования web создадим вспомогательный класс `AbstractControllerTest`, от которого будут наследоваться все тесты контроллеров. Его особенностью будет наличие `MockMvc` - эмуляции Spring MVC для +тестирования web-компонентов. Инициализируем ее в методе, отмеченном `@PostConstruct`: + + ``` +mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).addFilter(CHARACTER_ENCODING_FILTER).build(); + ``` + +Для того, чтобы в тестах контроллеров не популировать базу перед каждым тестом, пометим этот базовый тестовый класс аннотацией `@Transactional`. Теперь каждый тестовый метод будет выполняться в +транзакции, которая будет откатываться после окончания метода и возвращать базу данных в исходное состояние. Однако теперь в работе тестов могут возникнуть нюансы, связанные с пропагацией транзакций: +все транзакции репозиториев станут вложенными во внешнюю транзакцию теста. При этом, например, кэш первого уровня станет работать не так, как ожидается. Т.е при таком подходе нужно быть готовыми к +ошибкам: мы их увидим и поборем в тестах на обработку ошибок на последних занятиях TopJava. + +#### UserControllerTest + +Создадим тестовый класс для контроллера юзеров, он должен наследоваться от `AbstractControllerTest`. В `MockMvc` +используется [паттерн проектирования Builder](https://refactoring.guru/ru/design-patterns/builder). + + ``` + mockMvc.perform(get("/users")) // выполнить HTTP метод GET к "/users" + .andDo(print()) // распечатать содержимое ответа + .andExpect(status().isOk()) // от контроллера ожидается ответ со статусом HTTP 200(ok) + .andExpect(view().name("users")) // контроллер должен вернуть view с именем "users" + .andExpect(forwardedUrl("/WEB-INF/jsp/users.jsp")) // ожидается, что клиент должен быть перенаправлен на "/WEB-INF/jsp/users.jsp" + .andExpect(model().attribute("users", hasSize(2))) // в модели должен быть атрибут "users" размером = 2 + .andExpect(model().attribute("users", hasItem( // внутри которого есть элемент ... + allOf( + hasProperty("id", is(START_SEQ)), // ... с аттрибутом id = START_SEQ + hasProperty("name", is(USER.getName())) //... и name = user + ) + ))); +} + ``` + +В параметры метода `andExpect()` передается реализация `ResultMatcher`, в которой мы определяем как должен быть обработан ответ контроллера. + +
    + +#### Apply 7_09_controller_test.patch + +> - в `MockMvc` добавился `CharacterEncodingFilter` +> - поменял реализацию `ActiveDbProfileResolver`: в профили аттрибута `@ActiveProfiles(profiles=..)` он добавляет `Profiles.getActiveDbProfile()` +> - сделал вспомогательный метод `AbstractControllerTest.perform()` + +- Hamcrest +- Unit Testing of Spring MVC Controllers + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 4. [Миграция на JUnit 5](https://drive.google.com/open?id=16wi0AJLelso-dPuDj6xaGL7yJPmiO71e) + +
    + Краткое содержание + +Для миграции на 5-ю версию JUnit в файле `pom.xml` поменяем зависимость `junit` +на `junit-jupiter-engine` ([No need `junit-platform-surefire-provider` dependency in `maven-surefire-plugin`](https://junit.org/junit5/docs/current/user-guide/#running-tests-build-maven)). Актуальную +версию всегда можно посмотреть [в центральном maven репозитории](https://search.maven.org/search?q=junit-jupiter-engine), берем только релизы (..-Mx означают предварительные milestone версии) +Изменять конфигурацию плагина `maven-sureface-plugin` в новых версиях JUnit уже не требуется. Junit5 не содержит в себе зависимости от *Harmcrest* (которую нам приходилось вручную отключать для JUnit4 +в предыдущих шагах), поэтому исключение `hamcrest-core` просто удаляем. В итоге у нас останутся зависимости JUnit5 и расширенный Harmcrest. +Теперь мы можем применить все нововведения пятой версии в наших тестах: + +1. Для всех тестов теперь мы можем удалить `public`. +2. Аннотацию `@Before` исправим на `@BeforeEach` - теперь метод, который будет выполняться перед каждым тестом, помечается именно так. +3. В Junit5 работа с исключениями похожа на Junit4 версии 4.13: вместо ожидаемых исключений в параметрах аннотации `@Test(expected = Exception.class)` используется метод `assertThrows()`, в который + первым аргументом мы передаем ожидаемое исключение, а вторым аргументом — реализацию функционального интерфейса `Executable` (кода теста, в котором ожидается возникновение исключения). +4. Метод `assertThrows()` возвращает исключение, которое было выброшено в переданном ему коде. Теперь мы можем получить это исключение, извлечь из него сообщение с помощью + `e.getMessage()` и сравнить с ожидаемым. +5. Для теста на валидацию при проверке предусловия, только при выполнении которого будет выполняться следующий участок кода (например, в нашем случае тесты на валидацию выполнялись только в jpa + профиле), - теперь нужно пользоваться утильным методом `Assumptions` (нам уже не требуется). +6. Проверку Root Cause - причины, из-за которой было выброшено пойманное исключение, мы будем делать позднее, при тестах на ошибки. +7. Из JUnit5 исключена функциональность `@Rule`, вместо них теперь нужно использовать `Extensions`, которые могут встраиваться в любую фазу тестов. Чтобы добавить их в тесты, пометим базовый тестовый + класс аннотацией `@ExtendWith`. + +JUnit предоставляет нам набор коллбэков — интерфейсов, которые будут исполняться в определенный момент тестирования. Создадим класс `TimingExtension`, который будет засекать время выполнения тестовых +методов. +Этот класс будет имплементировать маркерные интерфейсы — коллбэки JUnit: + +- `BeforeTestExecutionCallback` - коллбэк, который будет вызывать методы этого интерфейса перед каждым тестовым методом. +- `AfterTestExecutionCallback` - методы этого интерфейса будут вызываться после каждого тестового метода; +- `BeforeAllCallback` - методы перед выполнением тестового класса; +- `AfterAllCallback` - методы после выполнения тестового класса; + +Осталось реализовать соответствующие методы, которые описываются в каждом из этих интерфейсов, они и будут вызываться JUnit в нужный момент: + +- в методе `beforeAll` (который будет вызван перед запуском тестового класса) создадим спринговый утильный секундомер `StopWatch` для текущего тестового класса; +- в методе `beforeTestExecution` (будет вызван перед тестовым методом) - запустим секундомер; +- в методе `afterTestExecution` (будет вызван после тестового метода) - остановим секундомер. +- в методе `afterAll` (который будет вызван по окончанию работы тестового класса) - выведем результат работы этого секундомера в консоль; + +8. Аннотации `@ContextConfiguration` и `@ExtendWith(SpringExtension.class)` (замена `@RunWith`) мы можем заменить одной `@SpringJUnitConfiguration` (в старых версиях IDEA ее не понимает) + +
    + +#### Apply 7_10_JUnit5.patch + +- [No need `junit-platform-surefire-provider` dependency in `maven-surefire-plugin`](https://junit.org/junit5/docs/current/user-guide/#running-tests-build-maven) +- [Наконец пофиксили баг с `@SpringJUnitConfig`](https://youtrack.jetbrains.com/issue/IDEA-166549) +- Добавил [`junit-platform-launcher` в pom для запуска JUnit 5 тестов из IDEA](https://youtrack.jetbrains.com/issue/IDEA-231927) + +- [JUnit 5 homepage](https://junit.org/junit5) +- [Overview](https://junit.org/junit5/docs/snapshot/user-guide/#overview) +- [Миграция с JUnit4 на JUnit5: важные отличия и преимущества](https://topjava.ru/blog/migratsiya-s-junit4-na-junit5) +- [10 интересных нововведений](https://habr.com/post/337700) +- Дополнительно: + - [Extension Model](https://junit.org/junit5/docs/current/user-guide/#extensions) + - [A Guide to JUnit 5](http://www.baeldung.com/junit-5) + - [Migrating from JUnit 4](http://www.baeldung.com/junit-5-migration) + - [Before and After Test Execution Callbacks](https://junit.org/junit5/docs/snapshot/user-guide/#extensions-lifecycle-callbacks-before-after-execution) + - [Conditional Test Execution](https://junit.org/junit5/docs/snapshot/user-guide/#writing-tests-conditional-execution) + - [Third party Extensions](https://github.com/junit-team/junit5/wiki/Third-party-Extensions) + - [Реализация assertThat](https://stackoverflow.com/questions/43280250) + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 5. [Принципы REST. REST контроллеры](https://drive.google.com/open?id=1e4ySjV15ZbswqzL29UkRSdGb4lcxXFm1) + +
    + Краткое содержание + +#### Принципы REST, REST-контроллеры + +> [REST](http://spring-projects.ru/understanding/rest/) - архитектурный стиль проектирования распределенных систем (типа клиент-сервер). + +Чаще всего в REST сервер и клиент общаются посредством обмена JSON-объектами через HTTP-методы GET/POST/PUT/DELETE/PATCH. +Особенностью REST является отсутствие состояния (контекста) взаимодействий клиента и сервера. + +В нашем приложении есть контроллеры для Admin и для User. Чтобы сделать их REST-контроллерами, заменим аннотацию `@Controller` на `@RestController` + +> Не поленитесь зайти чз Ctrl+Click в `@RestController`: к аннотации `@Controller` добавлена `@ResponseBody`. Т.е. ответ от нашего приложения будет не имя View, а данные в теле ответа. + +В `@RequestMapping`, кроме пути для методов контроллера (`value`) добавляем параметр `produces = MediaType.APPLICATION_JSON_VALUE`. Это означает, что в заголовки ответа будет добавлен +тип `ContentType="application/json"` - в ответе от контроллера будет приходить JSON-объект. + +> Чтобы было удобно использовать путь к этому контроллеру в приложении и в тестах, +> выделим путь к нему в константу REST_URL, к которой можно будет обращаться из других классов + +1. Метод `AdminRestController.getAll` пометим аннотацией `@GetMapping` - маршрутизация к методу по HTTP GET. + +2. Метод `AdminRestController.get` пометим аннотацией `@GetMapping("/{id}")`. + В скобках аннотации указано, что к основному URL контроллера будет добавляться `id` пользователя - переменная, которая передается в запросе непосредственно в URL. + Соответствующий параметр метода нужно пометить аннотацией `@PathVariable` (если имя в URL и имя аргумента метода не совпадают, в параметрах аннотации дополнительно нужно будет уточнить имя в URL. + Если они совпадают, [этого не требуется](https://habr.com/ru/post/440214/). + +3. Метод создания пользователя `create` отметим аннотацией `@PostMapping` - маршрутизация к методу по HTTP POST. В метод мы передаем объект `User` в теле запроса (аннотация `@RequestBody`) и в формате + JSON (`consumes = MediaType.APPLICATION_JSON_VALUE`). При создании нового ресурса правила хорошего тона - вернуть в заголовке ответа URL созданного ресурса. Для этого возвращем не `User`, + а `ResponseEntity`, который мы можем с помощью билдера `ServletUriComponentsBuilder` дополнить заголовком ответа `Location` и вернуть статус `CREATED(201)` + (если пойти в код `ResponseEntity.created` можно докопаться до сути, очень рекомендую смотреть в исходники кода). + +4. Метод `delete` помечаем `@DeleteMapping("/{id}")` - HTTP DELETE. Он ничего не возвращает, поэтому помечаем его аннотацией `@ResponseStatus(HttpStatus.NO_CONTENT)`. Статус ответа будет HTTP.204; + +5. Над методом обновления ставим `@PutMapping` (HTTP PUT). В аргументах метод принимает `@RequestBody User user` и `@PathVariable int id`. + +6. Метод поиска по `email` также помечаем `@GetMapping`, и, чтобы не было конфликта маршрутизации с методом `get()`, указываем в URL добавку "/by". В этот метод `email` передается как параметр + запроса, аннотация `@RequestParam`. + +> **Все это СТАНДАРТ архитектурного стиля REST. НЕ придумывайте ничего своего в своих выпускных проектах! Это очень большая ошибка - не придерживаться стандартов API.** + +7. `ProfileRestController` выполняем аналогичным способом с учетом того, что пользователь имеет доступ только к своим данным. + +Если на данном этапе попытаться запустить приложение и обратиться к какому-либо методу контроллера, сервер ответит нам ошибкой со статусом 406, так как Spring не знает, как преобразовать объект User в +JSON... + +
    + +#### Apply 7_11_rest_controller.patch +> - Переделал URL поиска по email на `/by-email` + +- Понимание REST +- JSON (JavaScript Object Notation) +- [15 тривиальных фактов о правильной работе с протоколом HTTP](https://habrahabr.ru/company/yandex/blog/265569/) +- [10 Best Practices for Better RESTful](https://medium.com/@mwaysolutions/10-best-practices-for-better-restful-api-cbe81b06f291) +- [Best practices for rest nested resources](https://stackoverflow.com/questions/20951419/what-are-best-practices-for-rest-nested-resources) +- + Request mapping +- [Лучшие практики разработки REST API: правила 1-7,15-17](https://tproger.ru/translations/luchshie-praktiki-razrabotki-rest-api-20-sovetov/) +- Дополнительно: + - [Подборка практик REST](https://gist.github.com/Londeren/838c8a223b92aa4017d3734d663a0ba3) + - JAX-RS vs Spring MVC + - RESTful API для сервера – делаем правильно (Часть 1) + - RESTful API для сервера – делаем правильно (Часть 2) + - И. Головач. RestAPI + - [value/name в аннотациях @PathVariable и @RequestParam](https://habr.com/ru/post/440214/) + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 6. [Тестирование REST контроллеров. Jackson.](https://drive.google.com/open?id=1aZm2qoMh4yL_-i3HhRoyZFjRAQx-15lO) + +
    + Краткое содержание + +Для работы с JSON добавляем в `pom.xml` зависимость `jackson-databind`. +Актуальную версию библиотеки можно посмотреть в [центральном maven-репозитории](https://search.maven.org/artifact/com.fasterxml.jackson.core/jackson-databind). +Теперь спринг будет автоматически использовать эту библиотеку для сериализации/десериализации объектов в JSON (найдя ее в *classpath*). +Если сейчас запустить приложение и обратиться к методам REST-контроллера, то оно выбросит `LazyInitializationException`. Оно возникает из-за того, что у наших сущностей есть лениво загружаемые поля, +отмеченные `FetchType.LAZY` - при загрузке сущности из базы, вместо этого поля подставится Proxy, который и должен вернуть реальный экземпляр этого поля при первом же обращении. Jackson при +сериализации в JSON использует все поля сущности, и при обращении к *Lazy* полям возникает исключение, так как сессия работы с БД в этот момент уже закрыта, и нужный объект не может быть +инициализирован. Чтобы Jackson игнорировал эти поля, пометим их аннотацией `@JsonIgnore`. + +Теперь при запуске приложения REST-контроллер будет работать. Но при получении JSON объектов мы можем увидеть, что Jackson сериализовал объект через геттеры (например в ответе есть поле `new` от +метода `Persistable.isNew()`). Чтобы учитывались только поля объектов, добавим над `AbstractBaseEntity`: + +````java +@JsonAutoDetect(fieldVisibility = ANY, // jackson видит все поля + getterVisibility = NONE, // ... но не видит геттеров + isGetterVisibility = NONE, //... не видит геттеров boolean полей + setterVisibility = NONE) // ... не видит сеттеров +```` + +Теперь все сущности, унаследованные от базового класса, будут сериализоваться/десериализоваться через поля. + +
    + +#### Apply 7_12_rest_test_jackson.patch + +- [Jackson databind github](https://github.com/FasterXML/jackson-databind) +- [Jackson Annotation Examples](https://www.baeldung.com/jackson-annotations) + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 7. [Кастомизация Jackson Object Mapper](https://drive.google.com/open?id=1CM6y1JhKG_yeLQE_iCDONnI7Agi4pBks) + +
    + Краткое содержание + +Сейчас, чтобы не сериализовать *Lazy* поля, мы должны пройтись по каждой сущности и вручную пометить их аннотацией `@JsonIgnore`. Это неудобно, засоряет код и допускает возможные ошибки. К тому же, +при некоторых условиях, нам иногда нужно загрузить и в ответе передать эти *Lazy* поля. +Чтобы запретить сериализацию Lazy полей для всего проекта, подключим в `pom.xml` библиотеку `jackson-datatype-hibernate`. +Также изменим сериализацию/десериализацию полей объектов в JSON: не через аннотацию `@JsonAutoDetect`, а в классе `JacksonObjectMapper`, который унаследуем от `ObjectMapper` (стандартный Mapper, +который использует Jackson) и сделаем в нем другие настройки. В конструкторе: + +- регистрируем `Hibernate5Module` - модуль `jackson-datatype-hibernate`, который не делает сериализацию ленивых полей. +- модуль для корректной сериализации `LocalDateTime` в поля JSON - `JavaTimeModule` модуль библиотеки `jackson-datatype-jsr310` +- запрещаем доступ ко всем полям и методам класса и потом разрешаем доступ только к полям +- не сериализуем null-поля (`setSerializationInclusion(JsonInclude.Include.NON_NULL)`) + +Чтобы подключить наш кастомный `JacksonObjectMapper` в проект, в конфигурации `spring-mvc.xml` к настройке `` добавим `MappingJackson2HttpMessageConverter`, который будет +использовать наш маппер. + +
    + +#### Apply 7_13_jackson_object_mapper.patch + +- Сериализация hibernate lazy-loading с помощью + jackson-datatype-hibernate +- Handle Java 8 dates with Jackson +- Дополнительно: + - Jackson JSON Serializer & Deserializer + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 8. [Тестирование REST контроллеров через JSONassert и Матчеры](https://drive.google.com/open?id=1oa3e0_tG57E71g6PW7_tfb3B61Qldctl) + +
    + Краткое содержание + +Сейчас в тестах REST-контроллера мы проводим проверку только на статус ответа и тип возвращаемого контента. Добавим проверку содержимого ответа. + +#### 7_14_json_assert_tests + +Чтобы сравнивать содержимое ответа контроллера в виде JSON и сущность, воспользуемся библиотекой +`jsonassert`, которую подключим в `pom.xml` со scope *test*. + +Эта библиотека при сравнении в тестах в качестве ожидаемого значения ожидает от нас объект в виде JSON-строки. Чтобы вручную не преобразовывать объекты в JSON и не хардкодить их в виде строк в наши +тесты, воспользуемся Jackson. +Для преобразования объектов в JSON и обратно создадим утильный класс `JsonUtil`, в котором с помощью нашего `JacksonObjectMapper` и будет конвертировать объекты. +И мы сталкиваемся с проблемой: `JsonUtil` - утильный класс и не является бином спринга, а для его работы требуется наш кастомный маппер, который находится под управлением спринга и расположен в +контейнере зависимостей. Поэтому, чтобы была возможность получить наш маппер из других классов - сделаем его синглтоном и сделаем в нем статический метод, который будет возвращать его экземпляр. +Теперь `JsonUtil` сможет его получить. +И нам нужно указать спрингу, чтобы он не создавал второй экземпляр этого объекта, а клал в свой контекст существующий. Для этого в конфигурации `spring-mvc.xml` определим factory-метод, с помощью +которого спринг должен получить экземпляр (instance) этого класса: + +```xml + + +``` + +а в конфигурации `message-converter` вместо создания бина просто сошлемся на сконфигурированный `objectMapper`. + +Метод `ContentResultMatchers.json()` из `spring-test` использует библиотеку `jsonassert` для сравнения 2-х JSON строк: одну из ответа контроллера и вторую - JSON-сериализация `admin` без +поля `registered` (это поле инициализируется в момент создания и отличается). В методе `JsonUtil.writeIgnoreProps` мы преобразуем объект `admin` в мапу, удаляем из нее игнорируемые поля и снова +сериализуем в JSON. + +Также сделаем тесты для утильного класса `JsonUtil`. В тестах мы записываем объект в JSON-строку, затем конвертируем эту строку обратно в объект и сравниваем с исходным. И то же самое делаем со +списком объектов. + +#### 7_15_tests_refactoring + +**`RootControllerTest`** + +Сделаем рефакторинг `RootControllerTest`. Ранее мы в тесте получали модель, доставали из нее сущности и с помощью `hamcrest-all` +производили по одному параметру их сравнение с ожидаемыми значениями. Метод `ResultActions.andExpect()` позволяет передавать реализацию интерфейса `Matcher`, в котором можно делать любые сравнения. +Функциональность сравнения списка юзеров по ВСЕМ полям у нас уже есть - мы просто делегируем сравнение объектов в `UserTestData.MATCHER`. При этом нам больше не нужен `harmcrest-all`, нам достаточно +только `harmcrest-core`. + +**`MatcherFactory`** + +Теперь вместо `jsonassert` и сравнения JSON-строк в тестах контроллеров сделаем сравнения JSON-объектов через `MatcherFactory`. Преобразуем ответ контроллера из JSON в объект и сравним с эталоном +через уже имеющийся у нас матчер. +Вместо сравнения JSON-строк в метод `andExpect()` мы будем передавать реализации интерфейса `ResultMatcher` из `MATCHER.contentJson(..)`. + +`MATCHER.contentJson(..)` принимают ожидаемый объект и возвращают для него `ResultMatcher` с реализацией единственного метода `match(MvcResult result)`, в котором делегируем сравнение уже существующим +у нас матчерам. Мы берем JSON-тело ответа (`MatcherFactory.getContent`), десериализуем его в объект (`JsonUtil.readValue/readValues`) и сравниваем через имеющийся `MATCHER.assertMatch` +десериализованный из тела контроллера объект и ожидаемое значение. + +> Методы из класса `TestUtil` перенес в `MatcherFactory`, лишние удалил. + +**`AdminRestControllerTest`** + +- `getByEmail()` - сделан по аналогии с тестом `get()`. Дополнительно нужно дополнить строку URL параметрами запроса. +- `delete()` - выполняем HTTP.DELETE. Проверяем статус ответа 204. Проверяем, что пользователь удален. + +> Раньше я получал всех users из базы и проверял, что среди них нет удаленного. При этом тесты становятся чувствительными ко всем users в базе и ломаются при добавлении-удалении новых тестовых данных. + +- `update()` - выполняем HTTP.PUT. В тело запроса подаем сериализованный `JsonUtil.writeValue(updated)`. После выполнения проверяем, что объект в базе обновился. +- `create()` - выполняем HTTP.POST аналогично `update()`. Но сравнить результат мы сразу не можем, т.к. при создании объекта ему присваивается `id`. + Поэтому мы извлекаем созданного пользователя из ответа (`MATCHER.readFromJson(action)`), получаем его `id`, и уже с этим `id` эталонный объект мы можем сравнить с объектом в ответе контроллера и со + значением в базе. +- `getAll()` - аналогично get(). Список пользователей из ответа в формате JSON сравниваем с эталонным списком (`MATCHER.contentJson(admin, user)`). + +Тесты для `ProfileRestController` выполнены аналогично. + +
    + +#### Apply 7_14_json_assert_tests.patch + +> - В `JsonUtil.writeIgnoreProps` вместо цикла по мапе сделал `map.keySet().removeAll` + +- [JSONassert](https://github.com/skyscreamer/JSONassert) +- [Java Code Examples for ObjectMapper](https://www.programcreek.com/java-api-examples/index.php?api=com.fasterxml.jackson.databind.ObjectMapper) + +#### Apply 7_15_tests_refactoring.patch +> - Сделал внутренний класс `MatcherFactory.Matcher`, который возвращается из фабрики матчеров. +> - Методы из класса `TestUtil` перенес в `MatcherFactory`, лишние удалил. +> - Раньше в тестах я для проверок получал всех users из базы и сравнивал с эталонным списком. При этом тесты становятся чувствительными ко всем users в базе и ломаются при добавлении-удалении новых тестовых данных. + +- [Java @SafeVarargs Annotation](https://www.baeldung.com/java-safevarargs) + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 9. [Тестирование через SoapUi. UTF-8](https://drive.google.com/open?id=0B9Ye2auQ_NsFVXNmOUdBbUxxWVU) + +
    + Краткое содержание + +SOAP UI - это один из инструментов для тестирования API приложений, которые работают по REST и по SOAP. +Он позволяет нам по HTTP протоколу дернуть методы нашего API и увидеть ответ контроллеров. + +Если в контроллер мы добавим метод, который в теле ответа будет возвращать текст на кириллице, то мы увидим кодировка теряться. Для сохранения кодировки используем `StringHttpMessageConverter`, +который конфигурируем в `spring-mvc.xml`. При этом мы должны явно указать, что конвертор будет работать только с текстом в кодировке *UTF-8*. + +
    + +#### Apply 7_16_soapui_utf8_converter.patch + +- Инструменты тестирования REST: + - SoapUi + - [Что такое Curl? Как работает эта команда?](https://highload.today/curl/) + - Написание HTTP-запросов с помощью Curl. + Для Windows 7 можно использовать Git Bash, с Windows 10 v1803 можно прямо из консоли. Возможны проблемы с UTF-8: + - [CURL doesn't encode UTF-8](https://stackoverflow.com/a/41384903/548473) + - [Нстройка кодировки в Windows](https://support.socialkit.ru/ru/knowledge-bases/4/articles/11110-preduprezhdenie-obnaruzhenyi-problemyi-svyazannyie-s-raspoznavaniem-russkih-simvolov) + - **[IDEA: Tools->HTTP Client->...](https://www.jetbrains.com/help/idea/rest-client-tool-window.html)** + - Postman + - [Insomnia REST client](https://insomnia.rest/) + +**Импортировать проект в SoapUi из `config\Topjava-soapui-project.xml`. Response смотреть в формате JSON.** + +> Проверка UTF-8: http://localhost:8080/topjava/rest/profile/text + +[ResponseBody and UTF-8](http://web.archive.org/web/20190102203042/http://forum.spring.io/forum/spring-projects/web/74209-responsebody-and-utf-8) + +## ![question](https://cloud.githubusercontent.com/assets/13649199/13672858/9cd58692-e6e7-11e5-905d-c295d2a456f1.png) Ваши вопросы + +> Зачем у нас и UIController'ы, и RestController'ы? То есть в общем случае backend-разработчику недостаточно предоставить REST-api и RestController? + +Часто используются и те и другие. REST обычно используют для отдельного UI например на React или Angular или для интеграции / мобильного приложения. +У нас REST контроллеры используются только для тестирования. UI мы используем для нашего приложения на JSP шаблонах. +Таких сайтов без богатой UI логики тоже немало. Например https://javaops.ru/ :) +Разница в обработке запросов: + +- из UI контроллеров возвращаются как готовые HTML странички, так и данные в формате JSON (будет для AJAX запросов в следующих занятиях) +- для UI мы используем только GET и POST запросы +- при создании-обновлении в UI мы принимаем данные из формы `application/x-www-form-urlencoded` (посмотрите вкладку `Network`, не в формате JSON) +- для REST запросы GET, POST, PUT, DELETE, PATCH и возвращают только данные (обычно JSON) + +И в способе авторизации: + +- для RESТ у нас будет базовая авторизация +- для UI - через cookies + +Также часто бывают смешанные сайты - где есть и отдельное JS приложение и шаблоны. + +> При выполнении тестов через MockMvc никаких изменений на базе не видно, почему оно не сохраняет? + +`AbstractControllerTest` аннотируется `@Transactional` - это означает, что тесты идут в транзакции, и после каждого теста JUnit делает rollback базы. + +> Что получается в результате выполнения запроса `SELECT DISTINCT(u) FROM User u LEFT JOIN FETCH u.roles ORDER BY u.name, u.email`? В чем разница в SQL без `DISTINCT`. + +Запросы SQL можно посмотреть в логах. Т.е. `DISTINCT` в `JPQL` влияет на то, как Hibernate обрабатывает дублирующиеся записи (с `DISTINCT` их исключает). Результат можно посмотреть в тестах или +приложении, поставив брекпойнт. По поводу `SQL DISTINCT` не стесняйтесь пользоваться google, например, [оператор SQL DISTINCT](http://2sql.ru/novosti/sql-distinct/) + +> В чем заключается расширение функциональности hamcrest в нашем тесте, что нам пришлось его отдельно от JUnit прописывать? + +`hamcrest-all` используется в проверках `RootControllerTest`: `org.hamcrest.Matchers.*`. JUnit 4 включает в себя `hamcrest-core`, в JUnit 5 его нужно подключать отдельно. + +> Jackson мы просто подключаем в помнике, и Spring будет с ним работать без любых других настроек? + +Да, Spring смотрит в classpath и если видит там Jackson, то подключает интеграцию с ним. + +> Где-то слышал, что любой ресурс по REST должен однозначно идентифицироваться через url без параметров. Правильно ли задавать URL для фильтрации в виде `http://localhost/topjava/rest/meals/filter/{startDate}/{startTime}/{endDate}/{endTime}` ? + +Так делают, только при отношении +агрегация, например, если давать админу право смотреть еду любого юзера, URL мог бы быть похож на `http://localhost/topjava/rest/users/{userId}/meals/{mealId}` (не рекомендуется, см ссылку ниже). +В случае критериев поиска или страничных данных они передаются как параметр. Смотри также: + +- [15 тривиальных фактов о правильной работе с протоколом HTTP](https://habrahabr.ru/company/yandex/blog/265569/) +- 10 Best Practices for Better RESTful +- [REST resource hierarchy (если кратко: не рекомендуется)](https://stackoverflow.com/questions/15259843/how-to-structure-rest-resource-hierarchy) + +> Что означает конструкция в `JsonUtil`: `reader.readValues(json)`; + +См. Generic Methods. Когда компилятор не может вывести тип, можно его уточнить при вызове generic метода. Неважно, +static или нет. + +## ![hw](https://cloud.githubusercontent.com/assets/13649199/13672719/09593080-e6e7-11e5-81d1-5cb629c438ca.png) Домашнее задание HW07 + +- 1: Добавить тесты контроллеров: + - 1.1 `RootControllerTest.getMeals` для `meals.jsp` + - 1.2 Сделать `ResourceControllerTest` для `style.css` (проверить `status` и `ContentType`) +- 2: Реализовать `MealRestController` и протестировать его через `MealRestControllerTest` + - 2.1 следите, чтобы url в тестах совпадал с параметрами в методе контроллера. Можно добавить логирование `` для проверки маршрутизации. + - 2.2 в параметрах `getBetween` принимать `LocalDateTime` (конвертировать через @DateTimeFormat with Java 8 + Date-Time API), пока без проверки на `null` (используя `toLocalDate()/toLocalTime()`, см. Optional п.3). В тестах передавать в формате `ISO_LOCAL_DATE_TIME` ( + например `'2011-12-03T10:15:30'`). + +### Optional + +- 3: Переделать `MealRestController.getBetween` на параметры `LocalDate/LocalTime` c раздельной фильтрацией по времени/дате, работающий при `null` значениях (см. демо и `JspMealController.getBetween`) + . Заменить `@DateTimeFormat` на свои LocalDate/LocalTime конверторы или форматтеры. + - Spring Type Conversion + - Spring Field Formatting + - + Difference between Spring MVC formatters and converters +- 4: Протестировать `MealRestController` (SoapUi, curl, IDEA Test RESTful Web Service, Postman). Запросы `curl` занести в отдельный `md` файл (или `README.md`) +- 5: Добавить в `AdminRestController` и `ProfileRestController` методы получения пользователя вместе с едой (`getWithMeals`, `/with-meals`). + - [Jackson – Bidirectional Relationships](https://www.baeldung.com/jackson-bidirectional-relationships-and-infinite-recursion) + +### Optional 2 + +- 6: Сделать тесты на методы контроллеров `getWithMeals()` (п.5) + +**На следующем занятии используется JavaScript/jQuery. Если у вас там пробелы, пройдите его основы** + +--------------------- + +## ![error](https://cloud.githubusercontent.com/assets/13649199/13672935/ef09ec1e-e6e7-11e5-9f79-d1641c05cbe6.png) Типичные ошибки и подсказки по реализации + +- 1: Ошибка в тесте _Invalid read array from JSON_ обычно расшифровывается немного ниже: читайте внимательно. +- 2: Jackson и неизменяемые объекты (для сериализации MealTo) +- 3: Если у meal, приходящий в контроллер, поля `null`, проверьте `@RequestBody` перед параметром (данные приходят в формате JSON) +- 4: При проблемах с собственным форматтером убедитесь, что в конфигурации `5.3.20 2.7.1 + 2.13.3 9.0.64 @@ -36,8 +37,9 @@ 3.10.0 - 4.13.2 + 5.8.2 3.23.1 + 2.2 @@ -59,6 +61,7 @@ 3.3.2 + org.apache.maven.plugins maven-surefire-plugin 2.22.2 @@ -214,13 +217,37 @@ spring-webmvc + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-hibernate5 + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + - junit - junit - ${junit.version} + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} test + + org.hamcrest + hamcrest-core + ${hamcrest.version} + test + + org.springframework spring-test @@ -232,6 +259,14 @@ ${assertj.version} test + + + + org.junit.platform + junit-platform-launcher + 1.8.2 + test + diff --git a/src/main/java/ru/javawebinar/topjava/SpringMain.java b/src/main/java/ru/javawebinar/topjava/SpringMain.java index d809f025d..66d4b30d8 100644 --- a/src/main/java/ru/javawebinar/topjava/SpringMain.java +++ b/src/main/java/ru/javawebinar/topjava/SpringMain.java @@ -18,7 +18,7 @@ public static void main(String[] args) { // java 7 automatic resource management (ARM) try (GenericXmlApplicationContext appCtx = new GenericXmlApplicationContext()) { appCtx.getEnvironment().setActiveProfiles(Profiles.getActiveDbProfile(), Profiles.REPOSITORY_IMPLEMENTATION); - appCtx.load("spring/spring-app.xml", "spring/spring-db.xml"); + appCtx.load("spring/inmemory.xml"); appCtx.refresh(); System.out.println("Bean definition names: " + Arrays.toString(appCtx.getBeanDefinitionNames())); diff --git a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java index acb116122..7cdc077bc 100644 --- a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java @@ -9,6 +9,7 @@ @MappedSuperclass // http://stackoverflow.com/questions/594597/hibernate-annotations-which-is-better-field-or-property-access @Access(AccessType.FIELD) +//@JsonAutoDetect(fieldVisibility = ANY, getterVisibility = NONE, isGetterVisibility = NONE, setterVisibility = NONE) public abstract class AbstractBaseEntity implements Persistable { public static final int START_SEQ = 100000; diff --git a/src/main/java/ru/javawebinar/topjava/model/Meal.java b/src/main/java/ru/javawebinar/topjava/model/Meal.java index c00282b1e..9033f6737 100644 --- a/src/main/java/ru/javawebinar/topjava/model/Meal.java +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -45,7 +45,7 @@ public class Meal extends AbstractBaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) - @NotNull +// @NotNull private User user; public Meal() { diff --git a/src/main/java/ru/javawebinar/topjava/model/User.java b/src/main/java/ru/javawebinar/topjava/model/User.java index 4acda507b..f768e5386 100644 --- a/src/main/java/ru/javawebinar/topjava/model/User.java +++ b/src/main/java/ru/javawebinar/topjava/model/User.java @@ -22,7 +22,7 @@ @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) @NamedQueries({ @NamedQuery(name = User.DELETE, query = "DELETE FROM User u WHERE u.id=:id"), - @NamedQuery(name = User.BY_EMAIL, query = "SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email=?1"), + @NamedQuery(name = User.BY_EMAIL, query = "SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email=?1"), @NamedQuery(name = User.ALL_SORTED, query = "SELECT u FROM User u ORDER BY u.name, u.email"), }) @Entity @@ -59,7 +59,7 @@ public class User extends AbstractNamedEntity { @ElementCollection(fetch = FetchType.EAGER) // @Fetch(FetchMode.SUBSELECT) @BatchSize(size = 200) - @JoinColumn(name = "user_id") //https://stackoverflow.com/a/62848296/548473 + @JoinColumn @OnDelete(action = OnDeleteAction.CASCADE) private Set roles; @@ -70,6 +70,7 @@ public class User extends AbstractNamedEntity { @OneToMany(fetch = FetchType.LAZY, mappedBy = "user")//, cascade = CascadeType.REMOVE, orphanRemoval = true) @OrderBy("dateTime DESC") @OnDelete(action = OnDeleteAction.CASCADE) //https://stackoverflow.com/a/44988100/548473 +// @JsonIgnore private List meals; public User() { diff --git a/src/main/java/ru/javawebinar/topjava/repository/JpaUtil.java b/src/main/java/ru/javawebinar/topjava/repository/JpaUtil.java deleted file mode 100644 index f3e51ad50..000000000 --- a/src/main/java/ru/javawebinar/topjava/repository/JpaUtil.java +++ /dev/null @@ -1,21 +0,0 @@ -package ru.javawebinar.topjava.repository; - -import org.hibernate.Session; -import org.hibernate.SessionFactory; - -import javax.persistence.EntityManager; -import javax.persistence.PersistenceContext; - -public class JpaUtil { - - @PersistenceContext - private EntityManager em; - - public void clear2ndLevelHibernateCache() { - Session s = (Session) em.getDelegate(); - SessionFactory sf = s.getSessionFactory(); -// sf.getCache().evictEntityData(User.class, AbstractBaseEntity.START_SEQ); -// sf.getCache().evictEntityData(User.class); - sf.getCache().evictAllRegions(); - } -} diff --git a/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java index 806884a9c..f3f362bdf 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java @@ -1,13 +1,12 @@ package ru.javawebinar.topjava.repository.datajpa; -import org.springframework.data.jpa.repository.EntityGraph; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import ru.javawebinar.topjava.model.User; +import javax.persistence.QueryHint; + @Transactional(readOnly = true) public interface CrudUserRepository extends JpaRepository { @Transactional @@ -15,9 +14,14 @@ public interface CrudUserRepository extends JpaRepository { @Query("DELETE FROM User u WHERE u.id=:id") int delete(@Param("id") int id); + // https://docs.jboss.org/hibernate/orm/5.2/userguide/html_single/Hibernate_User_Guide.html#hql-distinct + @QueryHints({ + @QueryHint(name = org.hibernate.jpa.QueryHints.HINT_PASS_DISTINCT_THROUGH, value = "false") + }) User getByEmail(String email); - @EntityGraph(attributePaths = {"meals", "roles"}) + // https://stackoverflow.com/a/46013654/548473 + @EntityGraph(attributePaths = {"meals"}, type = EntityGraph.EntityGraphType.LOAD) @Query("SELECT u FROM User u WHERE u.id=?1") User getWithMeals(int id); } diff --git a/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java index dc93b47e5..b5f4e3eeb 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java @@ -22,10 +22,10 @@ public DataJpaMealRepository(CrudMealRepository crudMealRepository, CrudUserRepo @Override @Transactional public Meal save(Meal meal, int userId) { - if (!meal.isNew() && get(meal.getId(), userId) == null) { + if (!meal.isNew() && get(meal.id(), userId) == null) { return null; } - meal.setUser(crudUserRepository.getOne(userId)); + meal.setUser(crudUserRepository.getReferenceById(userId)); return crudMealRepository.save(meal); } 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 1f3fe5ffe..82c80dd7b 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java @@ -8,13 +8,16 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.simple.SimpleJdbcInsert; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import ru.javawebinar.topjava.model.Meal; import ru.javawebinar.topjava.repository.MealRepository; +import ru.javawebinar.topjava.util.ValidationUtil; import java.time.LocalDateTime; import java.util.List; @Repository +@Transactional(readOnly = true) public class JdbcMealRepository implements MealRepository { private static final RowMapper ROW_MAPPER = BeanPropertyRowMapper.newInstance(Meal.class); @@ -35,7 +38,10 @@ public JdbcMealRepository(JdbcTemplate jdbcTemplate, NamedParameterJdbcTemplate } @Override + @Transactional public Meal save(Meal meal, int userId) { + ValidationUtil.validate(meal); + MapSqlParameterSource map = new MapSqlParameterSource() .addValue("id", meal.getId()) .addValue("description", meal.getDescription()) @@ -58,6 +64,7 @@ public Meal save(Meal meal, int userId) { } @Override + @Transactional public boolean delete(int id, int userId) { return jdbcTemplate.update("DELETE FROM meals WHERE id=? AND user_id=?", id, userId) != 0; } diff --git a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java index 0d4baa50e..dda61a5f4 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java @@ -8,12 +8,17 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.simple.SimpleJdbcInsert; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import ru.javawebinar.topjava.model.Role; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.repository.UserRepository; +import ru.javawebinar.topjava.util.ValidationUtil; -import java.util.List; +import java.util.*; @Repository +@Transactional(readOnly = true) public class JdbcUserRepository implements UserRepository { private static final BeanPropertyRowMapper ROW_MAPPER = BeanPropertyRowMapper.newInstance(User.class); @@ -35,22 +40,34 @@ public JdbcUserRepository(JdbcTemplate jdbcTemplate, NamedParameterJdbcTemplate } @Override + @Transactional public User save(User user) { + ValidationUtil.validate(user); + BeanPropertySqlParameterSource parameterSource = new BeanPropertySqlParameterSource(user); if (user.isNew()) { Number newKey = insertUser.executeAndReturnKey(parameterSource); user.setId(newKey.intValue()); - } else if (namedParameterJdbcTemplate.update(""" - UPDATE users SET name=:name, email=:email, password=:password, - registered=:registered, enabled=:enabled, calories_per_day=:caloriesPerDay WHERE id=:id - """, parameterSource) == 0) { - return null; + insertRoles(user); + } else { + if (namedParameterJdbcTemplate.update(""" + UPDATE users SET name=:name, email=:email, password=:password, + registered=:registered, enabled=:enabled, calories_per_day=:caloriesPerDay WHERE id=:id + """, parameterSource) == 0) { + return null; + } + // Simplest implementation. + // More complicated : get user roles from DB and compare them with user.roles (assume that roles are changed rarely). + // If roles are changed, calculate difference in java and delete/insert them. + deleteRoles(user); + insertRoles(user); } return user; } @Override + @Transactional public boolean delete(int id) { return jdbcTemplate.update("DELETE FROM users WHERE id=?", id) != 0; } @@ -58,18 +75,49 @@ public boolean delete(int id) { @Override public User get(int id) { List users = jdbcTemplate.query("SELECT * FROM users WHERE id=?", ROW_MAPPER, id); - return DataAccessUtils.singleResult(users); + return setRoles(DataAccessUtils.singleResult(users)); } @Override public User getByEmail(String email) { // return jdbcTemplate.queryForObject("SELECT * FROM users WHERE email=?", ROW_MAPPER, email); List users = jdbcTemplate.query("SELECT * FROM users WHERE email=?", ROW_MAPPER, email); - return DataAccessUtils.singleResult(users); + return setRoles(DataAccessUtils.singleResult(users)); } @Override public List getAll() { - return jdbcTemplate.query("SELECT * FROM users ORDER BY name, email", ROW_MAPPER); + List users = jdbcTemplate.query("SELECT * FROM users ORDER BY name, email", ROW_MAPPER); + + Map> map = new HashMap<>(); + jdbcTemplate.query("SELECT * FROM user_roles", rs -> { + map.computeIfAbsent(rs.getInt("user_id"), userId -> EnumSet.noneOf(Role.class)) + .add(Role.valueOf(rs.getString("role"))); + }); + users.forEach(u -> u.setRoles(map.get(u.getId()))); + return users; + } + + private void insertRoles(User u) { + Set roles = u.getRoles(); + if (!CollectionUtils.isEmpty(roles)) { + jdbcTemplate.batchUpdate("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", roles, roles.size(), + (ps, role) -> { + ps.setInt(1, u.id()); + ps.setString(2, role.name()); + }); + } + } + + private void deleteRoles(User u) { + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id=?", u.getId()); + } + + private User setRoles(User u) { + if (u != null) { + List roles = jdbcTemplate.queryForList("SELECT role FROM user_roles WHERE user_id=?", Role.class, u.getId()); + u.setRoles(roles); + } + return u; } } diff --git a/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaUserRepository.java index 3a1bbddc4..22fa8f4e3 100644 --- a/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaUserRepository.java +++ b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaUserRepository.java @@ -1,5 +1,6 @@ package ru.javawebinar.topjava.repository.jpa; +import org.hibernate.jpa.QueryHints; import org.springframework.dao.support.DataAccessUtils; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -61,6 +62,7 @@ public boolean delete(int id) { public User getByEmail(String email) { List users = em.createNamedQuery(User.BY_EMAIL, User.class) .setParameter(1, email) + .setHint(QueryHints.HINT_PASS_DISTINCT_THROUGH, false) .getResultList(); return DataAccessUtils.singleResult(users); } diff --git a/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java b/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java index 986c7d97d..fa79c4b95 100644 --- a/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java @@ -6,11 +6,31 @@ import ru.javawebinar.topjava.model.AbstractBaseEntity; import ru.javawebinar.topjava.util.exception.NotFoundException; +import javax.validation.*; +import java.util.Set; + public class ValidationUtil { + private static final Validator validator; + + static { + // From Javadoc: implementations are thread-safe and instances are typically cached and reused. + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + // From Javadoc: implementations of this interface must be thread-safe + validator = factory.getValidator(); + } + private ValidationUtil() { } + public static void validate(T bean) { + // https://alexkosarev.name/2018/07/30/bean-validation-api/ + Set> violations = validator.validate(bean); + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + } + public static T checkNotFoundWithId(T object, int id) { checkNotFoundWithId(object != null, id); return object; diff --git a/src/main/java/ru/javawebinar/topjava/web/MealServlet.java b/src/main/java/ru/javawebinar/topjava/web/MealServlet.java deleted file mode 100644 index fb667d351..000000000 --- a/src/main/java/ru/javawebinar/topjava/web/MealServlet.java +++ /dev/null @@ -1,85 +0,0 @@ -package ru.javawebinar.topjava.web; - -import org.springframework.util.StringUtils; -import org.springframework.web.context.WebApplicationContext; -import org.springframework.web.context.support.WebApplicationContextUtils; -import ru.javawebinar.topjava.model.Meal; -import ru.javawebinar.topjava.web.meal.MealRestController; - -import javax.servlet.ServletException; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.temporal.ChronoUnit; -import java.util.Objects; - -import static ru.javawebinar.topjava.util.DateTimeUtil.parseLocalDate; -import static ru.javawebinar.topjava.util.DateTimeUtil.parseLocalTime; - -public class MealServlet extends HttpServlet { - - private MealRestController mealController; - - @Override - public void init() { - WebApplicationContext springContext = WebApplicationContextUtils.getRequiredWebApplicationContext(getServletContext()); - mealController = springContext.getBean(MealRestController.class); - } - - @Override - protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - request.setCharacterEncoding("UTF-8"); - Meal meal = new Meal( - LocalDateTime.parse(request.getParameter("dateTime")), - request.getParameter("description"), - Integer.parseInt(request.getParameter("calories"))); - - if (StringUtils.hasLength(request.getParameter("id"))) { - mealController.update(meal, getId(request)); - } else { - mealController.create(meal); - } - response.sendRedirect("meals"); - } - - @Override - protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - String action = request.getParameter("action"); - - switch (action == null ? "all" : action) { - case "delete" -> { - int id = getId(request); - mealController.delete(id); - response.sendRedirect("meals"); - } - case "create", "update" -> { - final Meal meal = "create".equals(action) ? - new Meal(LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES), "", 1000) : - mealController.get(getId(request)); - request.setAttribute("meal", meal); - request.getRequestDispatcher("/mealForm.jsp").forward(request, response); - } - case "filter" -> { - LocalDate startDate = parseLocalDate(request.getParameter("startDate")); - LocalDate endDate = parseLocalDate(request.getParameter("endDate")); - LocalTime startTime = parseLocalTime(request.getParameter("startTime")); - LocalTime endTime = parseLocalTime(request.getParameter("endTime")); - request.setAttribute("meals", mealController.getBetween(startDate, startTime, endDate, endTime)); - request.getRequestDispatcher("/meals.jsp").forward(request, response); - } - default -> { - request.setAttribute("meals", mealController.getAll()); - request.getRequestDispatcher("/meals.jsp").forward(request, response); - } - } - } - - private int getId(HttpServletRequest request) { - String paramId = Objects.requireNonNull(request.getParameter("id")); - return Integer.parseInt(paramId); - } -} diff --git a/src/main/java/ru/javawebinar/topjava/web/RootController.java b/src/main/java/ru/javawebinar/topjava/web/RootController.java index f432d93b1..921462ca9 100644 --- a/src/main/java/ru/javawebinar/topjava/web/RootController.java +++ b/src/main/java/ru/javawebinar/topjava/web/RootController.java @@ -7,7 +7,9 @@ import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import ru.javawebinar.topjava.service.MealService; import ru.javawebinar.topjava.service.UserService; +import ru.javawebinar.topjava.util.MealsUtil; import javax.servlet.http.HttpServletRequest; @@ -16,7 +18,10 @@ public class RootController { private static final Logger log = LoggerFactory.getLogger(RootController.class); @Autowired - private UserService service; + private UserService userService; + + @Autowired + private MealService mealService; @GetMapping("/") public String root() { @@ -27,7 +32,7 @@ public String root() { @GetMapping("/users") public String getUsers(Model model) { log.info("users"); - model.addAttribute("users", service.getAll()); + model.addAttribute("users", userService.getAll()); return "users"; } @@ -38,4 +43,12 @@ public String setUser(HttpServletRequest request) { SecurityUtil.setAuthUserId(userId); return "redirect:meals"; } + + @GetMapping("/meals") + public String getMeals(Model model) { + 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/json/JacksonObjectMapper.java b/src/main/java/ru/javawebinar/topjava/web/json/JacksonObjectMapper.java new file mode 100644 index 000000000..8237df93b --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/json/JacksonObjectMapper.java @@ -0,0 +1,37 @@ +package ru.javawebinar.topjava.web.json; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + *

    + * Handling Hibernate lazy-loading + * + * @link https://github.com/FasterXML/jackson + * @link https://github.com/FasterXML/jackson-datatype-hibernate + * @link https://github.com/FasterXML/jackson-docs/wiki/JacksonHowToCustomSerializers + */ +public class JacksonObjectMapper extends ObjectMapper { + + private static final ObjectMapper MAPPER = new JacksonObjectMapper(); + + private JacksonObjectMapper() { + registerModule(new Hibernate5Module()); + + registerModule(new JavaTimeModule()); + configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + + setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); + setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + public static ObjectMapper getMapper() { + return MAPPER; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/json/JsonUtil.java b/src/main/java/ru/javawebinar/topjava/web/json/JsonUtil.java new file mode 100644 index 000000000..fda04590d --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/json/JsonUtil.java @@ -0,0 +1,37 @@ +package ru.javawebinar.topjava.web.json; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectReader; + +import java.io.IOException; +import java.util.List; + +import static ru.javawebinar.topjava.web.json.JacksonObjectMapper.getMapper; + +public class JsonUtil { + + public static List readValues(String json, Class clazz) { + ObjectReader reader = getMapper().readerFor(clazz); + try { + return reader.readValues(json).readAll(); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid read array from JSON:\n'" + json + "'", e); + } + } + + public static T readValue(String json, Class clazz) { + try { + return getMapper().readValue(json, clazz); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid read from JSON:\n'" + json + "'", e); + } + } + + public static String writeValue(T obj) { + try { + return getMapper().writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Invalid write to JSON:\n'" + obj + "'", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/meal/AbstractMealController.java b/src/main/java/ru/javawebinar/topjava/web/meal/AbstractMealController.java new file mode 100644 index 000000000..ec601c187 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/meal/AbstractMealController.java @@ -0,0 +1,72 @@ +package ru.javawebinar.topjava.web.meal; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.Nullable; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.service.MealService; +import ru.javawebinar.topjava.to.MealTo; +import ru.javawebinar.topjava.util.MealsUtil; +import ru.javawebinar.topjava.web.SecurityUtil; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import static ru.javawebinar.topjava.util.ValidationUtil.assureIdConsistent; +import static ru.javawebinar.topjava.util.ValidationUtil.checkNew; + +public abstract class AbstractMealController { + private final Logger log = LoggerFactory.getLogger(getClass()); + + @Autowired + private MealService service; + + public Meal get(int id) { + int userId = SecurityUtil.authUserId(); + log.info("get meal {} for user {}", id, userId); + return service.get(id, userId); + } + + public void delete(int id) { + int userId = SecurityUtil.authUserId(); + log.info("delete meal {} for user {}", id, userId); + service.delete(id, userId); + } + + public List getAll() { + int userId = SecurityUtil.authUserId(); + log.info("getAll for user {}", userId); + return MealsUtil.getTos(service.getAll(userId), SecurityUtil.authUserCaloriesPerDay()); + } + + public Meal create(Meal meal) { + int userId = SecurityUtil.authUserId(); + log.info("create {} for user {}", meal, userId); + checkNew(meal); + return service.create(meal, userId); + } + + public void update(Meal meal, int id) { + int userId = SecurityUtil.authUserId(); + log.info("update {} for user {}", meal, userId); + assureIdConsistent(meal, id); + service.update(meal, userId); + } + + /** + *

      Filter separately + *
    1. by date
    2. + *
    3. by time for every date
    4. + *
    + */ + public List getBetween(@Nullable LocalDate startDate, @Nullable LocalTime startTime, + @Nullable LocalDate endDate, @Nullable LocalTime endTime) { + int userId = SecurityUtil.authUserId(); + log.info("getBetween dates({} - {}) time({} - {}) for user {}", startDate, endDate, startTime, endTime, userId); + + List mealsDateFiltered = service.getBetweenInclusive(startDate, endDate, userId); + return MealsUtil.getFilteredTos(mealsDateFiltered, SecurityUtil.authUserCaloriesPerDay(), startTime, endTime); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/meal/JspMealController.java b/src/main/java/ru/javawebinar/topjava/web/meal/JspMealController.java new file mode 100644 index 000000000..abdfa0cf8 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/meal/JspMealController.java @@ -0,0 +1,70 @@ +package ru.javawebinar.topjava.web.meal; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import ru.javawebinar.topjava.model.Meal; + +import javax.servlet.http.HttpServletRequest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.Objects; + +import static ru.javawebinar.topjava.util.DateTimeUtil.parseLocalDate; +import static ru.javawebinar.topjava.util.DateTimeUtil.parseLocalTime; + +@Controller +@RequestMapping("/meals") +public class JspMealController extends AbstractMealController { + + @GetMapping("/delete") + public String delete(HttpServletRequest request) { + super.delete(getId(request)); + return "redirect:/meals"; + } + + @GetMapping("/update") + public String update(HttpServletRequest request, Model model) { + model.addAttribute("meal", super.get(getId(request))); + return "mealForm"; + } + + @GetMapping("/create") + public String create(Model model) { + model.addAttribute("meal", new Meal(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS), "", 1000)); + return "mealForm"; + } + + @PostMapping + public String updateOrCreate(HttpServletRequest request) { + Meal meal = new Meal(LocalDateTime.parse(request.getParameter("dateTime")), + request.getParameter("description"), + Integer.parseInt(request.getParameter("calories"))); + + if (request.getParameter("id").isEmpty()) { + super.create(meal); + } else { + super.update(meal, getId(request)); + } + return "redirect:/meals"; + } + + @GetMapping("/filter") + public String getBetween(HttpServletRequest request, Model model) { + LocalDate startDate = parseLocalDate(request.getParameter("startDate")); + LocalDate endDate = parseLocalDate(request.getParameter("endDate")); + LocalTime startTime = parseLocalTime(request.getParameter("startTime")); + LocalTime endTime = parseLocalTime(request.getParameter("endTime")); + model.addAttribute("meals", super.getBetween(startDate, startTime, endDate, endTime)); + return "meals"; + } + + private int getId(HttpServletRequest request) { + String paramId = Objects.requireNonNull(request.getParameter("id")); + return Integer.parseInt(paramId); + } +} diff --git a/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java b/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java index bbfe35e3f..c3daf6853 100644 --- a/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java +++ b/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java @@ -1,76 +1,7 @@ package ru.javawebinar.topjava.web.meal; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.Nullable; import org.springframework.stereotype.Controller; -import ru.javawebinar.topjava.model.Meal; -import ru.javawebinar.topjava.service.MealService; -import ru.javawebinar.topjava.to.MealTo; -import ru.javawebinar.topjava.util.MealsUtil; -import ru.javawebinar.topjava.web.SecurityUtil; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; - -import static ru.javawebinar.topjava.util.ValidationUtil.assureIdConsistent; -import static ru.javawebinar.topjava.util.ValidationUtil.checkNew; @Controller -public class MealRestController { - private static final Logger log = LoggerFactory.getLogger(MealRestController.class); - - private final MealService service; - - public MealRestController(MealService service) { - this.service = service; - } - - public Meal get(int id) { - int userId = SecurityUtil.authUserId(); - log.info("get meal {} for user {}", id, userId); - return service.get(id, userId); - } - - public void delete(int id) { - int userId = SecurityUtil.authUserId(); - log.info("delete meal {} for user {}", id, userId); - service.delete(id, userId); - } - - public List getAll() { - int userId = SecurityUtil.authUserId(); - log.info("getAll for user {}", userId); - return MealsUtil.getTos(service.getAll(userId), SecurityUtil.authUserCaloriesPerDay()); - } - - public Meal create(Meal meal) { - int userId = SecurityUtil.authUserId(); - checkNew(meal); - log.info("create {} for user {}", meal, userId); - return service.create(meal, userId); - } - - public void update(Meal meal, int id) { - int userId = SecurityUtil.authUserId(); - assureIdConsistent(meal, id); - log.info("update {} for user {}", meal, userId); - service.update(meal, userId); - } - - /** - *
      Filter separately - *
    1. by date
    2. - *
    3. by time for every date
    4. - *
    - */ - public List getBetween(@Nullable LocalDate startDate, @Nullable LocalTime startTime, - @Nullable LocalDate endDate, @Nullable LocalTime endTime) { - int userId = SecurityUtil.authUserId(); - log.info("getBetween dates({} - {}) time({} - {}) for user {}", startDate, endDate, startTime, endTime, userId); - - List mealsDateFiltered = service.getBetweenInclusive(startDate, endDate, userId); - return MealsUtil.getFilteredTos(mealsDateFiltered, SecurityUtil.authUserCaloriesPerDay(), startTime, endTime); - } +public class MealRestController extends AbstractMealController { } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java b/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java index b37a8ed6c..095ced3b0 100644 --- a/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java +++ b/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java @@ -1,40 +1,59 @@ package ru.javawebinar.topjava.web.user; -import org.springframework.stereotype.Controller; +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 java.net.URI; import java.util.List; -@Controller +@RestController +@RequestMapping(value = AdminRestController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) public class AdminRestController extends AbstractUserController { + static final String REST_URL = "/rest/admin/users"; + @Override + @GetMapping public List getAll() { return super.getAll(); } @Override - public User get(int id) { + @GetMapping("/{id}") + public User get(@PathVariable int id) { return super.get(id); } - @Override - public User create(User user) { - return super.create(user); + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createWithLocation(@RequestBody User user) { + User created = super.create(user); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL + "/{id}") + .buildAndExpand(created.getId()).toUri(); + return ResponseEntity.created(uriOfNewResource).body(created); } @Override - public void delete(int id) { + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable int id) { super.delete(id); } @Override - public void update(User user, int id) { + @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@RequestBody User user, @PathVariable int id) { super.update(user, id); } @Override - public User getByMail(String email) { + @GetMapping("/by-email") + public User getByMail(@RequestParam String email) { return super.getByMail(email); } } \ No newline at end of file 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 7d3702c31..14559e4cf 100644 --- a/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java +++ b/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java @@ -1,22 +1,36 @@ package ru.javawebinar.topjava.web.user; -import org.springframework.stereotype.Controller; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; import ru.javawebinar.topjava.model.User; import static ru.javawebinar.topjava.web.SecurityUtil.authUserId; -@Controller +@RestController +@RequestMapping(value = ProfileRestController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) public class ProfileRestController extends AbstractUserController { + static final String REST_URL = "/rest/profile"; + @GetMapping public User get() { return super.get(authUserId()); } + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) public void delete() { super.delete(authUserId()); } - public void update(User user) { + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@RequestBody User user) { super.update(user, authUserId()); } + + @GetMapping("/text") + public String testUTF() { + return "Русский текст"; + } } \ No newline at end of file diff --git a/src/main/resources/db/populateDB.sql b/src/main/resources/db/populateDB.sql index f29b325f2..ac3e47c94 100644 --- a/src/main/resources/db/populateDB.sql +++ b/src/main/resources/db/populateDB.sql @@ -10,7 +10,8 @@ VALUES ('User', 'user@yandex.ru', 'password'), INSERT INTO user_roles (role, user_id) VALUES ('USER', 100000), - ('ADMIN', 100001); + ('ADMIN', 100001), + ('USER', 100001); INSERT INTO meals (date_time, description, calories, user_id) VALUES ('2020-01-30 10:00:00', 'Завтрак', 500, 100000), diff --git a/src/main/resources/db/postgres.properties b/src/main/resources/db/postgres.properties index c45b1b6b9..ba40447d4 100644 --- a/src/main/resources/db/postgres.properties +++ b/src/main/resources/db/postgres.properties @@ -10,4 +10,5 @@ database.init=true jdbc.initLocation=classpath:db/initDB.sql jpa.showSql=true hibernate.format_sql=true -hibernate.use_sql_comments=true \ No newline at end of file +#https://hibernate.atlassian.net/browse/HHH-13280 +hibernate.use_sql_comments=false \ No newline at end of file diff --git a/src/main/resources/spring/spring-db.xml b/src/main/resources/spring/spring-db.xml index 2b62f0748..48afdb11a 100644 --- a/src/main/resources/spring/spring-db.xml +++ b/src/main/resources/spring/spring-db.xml @@ -20,6 +20,8 @@ + + @@ -66,6 +68,11 @@ + + + + @@ -101,13 +108,9 @@ - - - - diff --git a/src/main/resources/spring/spring-mvc.xml b/src/main/resources/spring/spring-mvc.xml index aa5599fea..68fe83a40 100644 --- a/src/main/resources/spring/spring-mvc.xml +++ b/src/main/resources/spring/spring-mvc.xml @@ -5,7 +5,23 @@ xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd"> - + + + + + + + + + + + text/plain;charset=UTF-8 + text/html;charset=UTF-8 + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp b/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp index 6d77694e3..0c77f1085 100644 --- a/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp +++ b/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp @@ -5,5 +5,6 @@ <spring:message code="app.title"/> + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/mealForm.jsp b/src/main/webapp/WEB-INF/jsp/mealForm.jsp index 98a6f4873..af6d7880e 100644 --- a/src/main/webapp/WEB-INF/jsp/mealForm.jsp +++ b/src/main/webapp/WEB-INF/jsp/mealForm.jsp @@ -1,34 +1,35 @@ <%@ page contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> - - Meal - - + + +
    -

    Home

    -
    -

    ${param.action == 'create' ? 'Create meal' : 'Edit meal'}

    +<%-- `meal.new` cause javax.el.ELException - bug tomcat --%> +

    +
    -
    DateTime:
    +
    :
    -
    Description:
    +
    :
    -
    Calories:
    +
    :
    - - + +
    + diff --git a/src/main/webapp/WEB-INF/jsp/meals.jsp b/src/main/webapp/WEB-INF/jsp/meals.jsp index 007453221..b42230b90 100644 --- a/src/main/webapp/WEB-INF/jsp/meals.jsp +++ b/src/main/webapp/WEB-INF/jsp/meals.jsp @@ -1,46 +1,43 @@ <%@ page contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> <%@ taglib prefix="fn" uri="http://topjava.javawebinar.ru/functions" %> - - Meals - - + + +
    -

    Home

    -
    -

    Meals

    -
    - +

    + +
    -
    From Date (inclusive):
    +
    :
    -
    To Date (inclusive):
    +
    :
    -
    From Time (inclusive):
    +
    :
    -
    To Time (exclusive):
    +
    :
    - +
    -
    - Add Meal -

    +
    + +
    - - - + + + @@ -56,11 +53,12 @@ - - + +
    DateDescriptionCalories
    ${meal.description} ${meal.calories}UpdateDelete
    + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index b7558a705..175ec350e 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -36,4 +36,21 @@ mvc-dispatcher / + + + encodingFilter + org.springframework.web.filter.CharacterEncodingFilter + + encoding + UTF-8 + + + forceEncoding + true + + + + encodingFilter + /* + diff --git a/src/test/java/ru/javawebinar/topjava/ActiveDbProfileResolver.java b/src/test/java/ru/javawebinar/topjava/ActiveDbProfileResolver.java index 2d819f84e..da3bec700 100644 --- a/src/test/java/ru/javawebinar/topjava/ActiveDbProfileResolver.java +++ b/src/test/java/ru/javawebinar/topjava/ActiveDbProfileResolver.java @@ -1,13 +1,19 @@ package ru.javawebinar.topjava; import org.springframework.lang.NonNull; -import org.springframework.test.context.ActiveProfilesResolver; +import org.springframework.test.context.support.DefaultActiveProfilesResolver; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; //http://stackoverflow.com/questions/23871255/spring-profiles-simple-example-of-activeprofilesresolver -public class ActiveDbProfileResolver implements ActiveProfilesResolver { +public class ActiveDbProfileResolver extends DefaultActiveProfilesResolver { @Override public @NonNull String[] resolve(@NonNull Class aClass) { - return new String[]{Profiles.getActiveDbProfile()}; + List profiles = new ArrayList<>(Arrays.asList(super.resolve(aClass))); + profiles.add(Profiles.getActiveDbProfile()); + return profiles.toArray(String[]::new); } } diff --git a/src/test/java/ru/javawebinar/topjava/MatcherFactory.java b/src/test/java/ru/javawebinar/topjava/MatcherFactory.java index c5f04086a..40f3d8c22 100644 --- a/src/test/java/ru/javawebinar/topjava/MatcherFactory.java +++ b/src/test/java/ru/javawebinar/topjava/MatcherFactory.java @@ -1,5 +1,11 @@ package ru.javawebinar.topjava; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.ResultMatcher; +import ru.javawebinar.topjava.web.json.JsonUtil; + +import java.io.UnsupportedEncodingException; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -8,16 +14,19 @@ * Factory for creating test matchers. *

    * Comparing actual and expected objects via AssertJ + * Support converting json MvcResult to objects for comparation. */ public class MatcherFactory { - public static Matcher usingIgnoringFieldsComparator(String... fieldsToIgnore) { - return new Matcher<>(fieldsToIgnore); + public static Matcher usingIgnoringFieldsComparator(Class clazz, String... fieldsToIgnore) { + return new Matcher<>(clazz, fieldsToIgnore); } public static class Matcher { + private final Class clazz; private final String[] fieldsToIgnore; - private Matcher(String... fieldsToIgnore) { + private Matcher(Class clazz, String... fieldsToIgnore) { + this.clazz = clazz; this.fieldsToIgnore = fieldsToIgnore; } @@ -33,5 +42,26 @@ public final void assertMatch(Iterable actual, T... expected) { public void assertMatch(Iterable actual, Iterable expected) { assertThat(actual).usingRecursiveFieldByFieldElementComparatorIgnoringFields(fieldsToIgnore).isEqualTo(expected); } + + public ResultMatcher contentJson(T expected) { + return result -> assertMatch(JsonUtil.readValue(getContent(result), clazz), expected); + } + + @SafeVarargs + public final ResultMatcher contentJson(T... expected) { + return contentJson(List.of(expected)); + } + + public ResultMatcher contentJson(Iterable expected) { + return result -> assertMatch(JsonUtil.readValues(getContent(result), clazz), expected); + } + + public T readFromJson(ResultActions action) throws UnsupportedEncodingException { + return JsonUtil.readValue(getContent(action.andReturn()), clazz); + } + + private static String getContent(MvcResult result) throws UnsupportedEncodingException { + return result.getResponse().getContentAsString(); + } } } diff --git a/src/test/java/ru/javawebinar/topjava/MealTestData.java b/src/test/java/ru/javawebinar/topjava/MealTestData.java index d044e3f90..c2697db04 100644 --- a/src/test/java/ru/javawebinar/topjava/MealTestData.java +++ b/src/test/java/ru/javawebinar/topjava/MealTestData.java @@ -10,7 +10,7 @@ import static ru.javawebinar.topjava.model.AbstractBaseEntity.START_SEQ; public class MealTestData { - public static final MatcherFactory.Matcher MEAL_MATCHER = MatcherFactory.usingIgnoringFieldsComparator("user"); + public static final MatcherFactory.Matcher MEAL_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(Meal.class, "user"); public static final int NOT_FOUND = 10; public static final int MEAL1_ID = START_SEQ + 3; diff --git a/src/test/java/ru/javawebinar/topjava/TimingExtension.java b/src/test/java/ru/javawebinar/topjava/TimingExtension.java new file mode 100644 index 000000000..cee6ae92c --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/TimingExtension.java @@ -0,0 +1,36 @@ +package ru.javawebinar.topjava; + +import org.junit.jupiter.api.extension.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.StopWatch; + +public class TimingExtension implements + BeforeTestExecutionCallback, AfterTestExecutionCallback, BeforeAllCallback, AfterAllCallback { + + private static final Logger log = LoggerFactory.getLogger("result"); + + private StopWatch stopWatch; + + @Override + public void beforeAll(ExtensionContext extensionContext) { + stopWatch = new StopWatch("Execution time of " + extensionContext.getRequiredTestClass().getSimpleName()); + } + + @Override + public void beforeTestExecution(ExtensionContext extensionContext) { + String testName = extensionContext.getDisplayName(); + log.info("\nStart " + testName); + stopWatch.start(testName); + } + + @Override + public void afterTestExecution(ExtensionContext extensionContext) { + stopWatch.stop(); + } + + @Override + public void afterAll(ExtensionContext extensionContext) { + log.info('\n' + stopWatch.prettyPrint() + '\n'); + } +} diff --git a/src/test/java/ru/javawebinar/topjava/TimingRules.java b/src/test/java/ru/javawebinar/topjava/TimingRules.java deleted file mode 100644 index fdd3d8779..000000000 --- a/src/test/java/ru/javawebinar/topjava/TimingRules.java +++ /dev/null @@ -1,42 +0,0 @@ -package ru.javawebinar.topjava; - -import org.junit.rules.ExternalResource; -import org.junit.rules.Stopwatch; -import org.junit.runner.Description; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.TimeUnit; - -public class TimingRules { - private static final Logger log = LoggerFactory.getLogger("result"); - - private static final StringBuilder results = new StringBuilder(); - - // http://stackoverflow.com/questions/14892125/what-is-the-best-practice-to-determine-the-execution-time-of-the-bussiness-relev - public static final Stopwatch STOPWATCH = new Stopwatch() { - @Override - protected void finished(long nanos, Description description) { - String result = String.format("%-95s %7d", description.getDisplayName(), TimeUnit.NANOSECONDS.toMillis(nanos)); - results.append(result).append('\n'); - log.info(result + " ms\n"); - } - }; - - // https://dzone.com/articles/applying-new-jdk-11-string-methods - private static final String DELIM = "-".repeat(103); - - public static final ExternalResource SUMMARY = new ExternalResource() { - @Override - protected void before() throws Throwable { - results.setLength(0); - } - - @Override - protected void after() { - log.info("\n" + DELIM + - "\nTest Duration, ms" + - "\n" + DELIM + "\n" + results + DELIM + "\n"); - } - }; -} diff --git a/src/test/java/ru/javawebinar/topjava/UserTestData.java b/src/test/java/ru/javawebinar/topjava/UserTestData.java index 3419c5215..285964552 100644 --- a/src/test/java/ru/javawebinar/topjava/UserTestData.java +++ b/src/test/java/ru/javawebinar/topjava/UserTestData.java @@ -9,7 +9,7 @@ import static ru.javawebinar.topjava.model.AbstractBaseEntity.START_SEQ; public class UserTestData { - public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator("registered", "roles", "meals"); + public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(User.class, "registered", "meals"); public static final int USER_ID = START_SEQ; public static final int ADMIN_ID = START_SEQ + 1; @@ -17,7 +17,7 @@ public class UserTestData { public static final int NOT_FOUND = 10; public static final User user = new User(USER_ID, "User", "user@yandex.ru", "password", Role.USER); - public static final User admin = new User(ADMIN_ID, "Admin", "admin@gmail.com", "admin", Role.ADMIN); + public static final User admin = new User(ADMIN_ID, "Admin", "admin@gmail.com", "admin", Role.ADMIN, Role.USER); public static final User guest = new User(GUEST_ID, "Guest", "guest@gmail.com", "guest"); public static User getNew() { diff --git a/src/test/java/ru/javawebinar/topjava/service/AbstractMealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/AbstractMealServiceTest.java index b19ffabe8..6b00e4481 100644 --- a/src/test/java/ru/javawebinar/topjava/service/AbstractMealServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/AbstractMealServiceTest.java @@ -1,7 +1,7 @@ package ru.javawebinar.topjava.service; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import ru.javawebinar.topjava.model.Meal; @@ -12,7 +12,7 @@ import java.time.Month; import static java.time.LocalDateTime.of; -import static org.junit.Assert.assertThrows; +import static org.junit.jupiter.api.Assertions.assertThrows; import static ru.javawebinar.topjava.MealTestData.*; import static ru.javawebinar.topjava.UserTestData.ADMIN_ID; import static ru.javawebinar.topjava.UserTestData.USER_ID; @@ -23,23 +23,23 @@ public abstract class AbstractMealServiceTest extends AbstractServiceTest { protected MealService service; @Test - public void delete() { + void delete() { service.delete(MEAL1_ID, USER_ID); assertThrows(NotFoundException.class, () -> service.get(MEAL1_ID, USER_ID)); } @Test - public void deleteNotFound() { + void deleteNotFound() { assertThrows(NotFoundException.class, () -> service.delete(NOT_FOUND, USER_ID)); } @Test - public void deleteNotOwn() { + void deleteNotOwn() { assertThrows(NotFoundException.class, () -> service.delete(MEAL1_ID, ADMIN_ID)); } @Test - public void create() { + void create() { Meal created = service.create(getNew(), USER_ID); int newId = created.id(); Meal newMeal = getNew(); @@ -49,48 +49,48 @@ public void create() { } @Test - public void duplicateDateTimeCreate() { + void duplicateDateTimeCreate() { assertThrows(DataAccessException.class, () -> service.create(new Meal(null, meal1.getDateTime(), "duplicate", 100), USER_ID)); } @Test - public void get() { + void get() { Meal actual = service.get(ADMIN_MEAL_ID, ADMIN_ID); MEAL_MATCHER.assertMatch(actual, adminMeal1); } @Test - public void getNotFound() { + void getNotFound() { assertThrows(NotFoundException.class, () -> service.get(NOT_FOUND, USER_ID)); } @Test - public void getNotOwn() { + void getNotOwn() { assertThrows(NotFoundException.class, () -> service.get(MEAL1_ID, ADMIN_ID)); } @Test - public void update() { + void update() { Meal updated = getUpdated(); service.update(updated, USER_ID); MEAL_MATCHER.assertMatch(service.get(MEAL1_ID, USER_ID), getUpdated()); } @Test - public void updateNotOwn() { + void updateNotOwn() { NotFoundException exception = assertThrows(NotFoundException.class, () -> service.update(getUpdated(), ADMIN_ID)); - Assert.assertEquals("Not found entity with id=" + MEAL1_ID, exception.getMessage()); + Assertions.assertEquals("Not found entity with id=" + MEAL1_ID, exception.getMessage()); MEAL_MATCHER.assertMatch(service.get(MEAL1_ID, USER_ID), meal1); } @Test - public void getAll() { + void getAll() { MEAL_MATCHER.assertMatch(service.getAll(USER_ID), meals); } @Test - public void getBetweenInclusive() { + void getBetweenInclusive() { MEAL_MATCHER.assertMatch(service.getBetweenInclusive( LocalDate.of(2020, Month.JANUARY, 30), LocalDate.of(2020, Month.JANUARY, 30), USER_ID), @@ -98,12 +98,12 @@ public void getBetweenInclusive() { } @Test - public void getBetweenWithNullDates() { + void getBetweenWithNullDates() { MEAL_MATCHER.assertMatch(service.getBetweenInclusive(null, null, USER_ID), meals); } @Test - public void createWithException() throws Exception { + void createWithException() throws Exception { validateRootCause(ConstraintViolationException.class, () -> service.create(new Meal(null, of(2015, Month.JUNE, 1, 18, 0), " ", 300), USER_ID)); validateRootCause(ConstraintViolationException.class, () -> service.create(new Meal(null, null, "Description", 300), USER_ID)); validateRootCause(ConstraintViolationException.class, () -> service.create(new Meal(null, of(2015, Month.JUNE, 1, 18, 0), "Description", 9), USER_ID)); diff --git a/src/test/java/ru/javawebinar/topjava/service/AbstractServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/AbstractServiceTest.java index 06d99de97..06f72ef86 100644 --- a/src/test/java/ru/javawebinar/topjava/service/AbstractServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/AbstractServiceTest.java @@ -1,36 +1,26 @@ package ru.javawebinar.topjava.service; -import org.junit.ClassRule; -import org.junit.Rule; -import org.junit.rules.ExternalResource; -import org.junit.rules.Stopwatch; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.SqlConfig; -import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import ru.javawebinar.topjava.ActiveDbProfileResolver; -import ru.javawebinar.topjava.TimingRules; +import ru.javawebinar.topjava.TimingExtension; -import static org.junit.Assert.assertThrows; +import static org.junit.jupiter.api.Assertions.assertThrows; import static ru.javawebinar.topjava.util.ValidationUtil.getRootCause; -@ContextConfiguration({ +@SpringJUnitConfig(locations = { "classpath:spring/spring-app.xml", "classpath:spring/spring-db.xml" }) -@RunWith(SpringRunner.class) -@Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8")) +//@ExtendWith(SpringExtension.class) @ActiveProfiles(resolver = ActiveDbProfileResolver.class) +@Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8")) +@ExtendWith(TimingExtension.class) public abstract class AbstractServiceTest { - @ClassRule - public static ExternalResource summary = TimingRules.SUMMARY; - - @Rule - public Stopwatch stopwatch = TimingRules.STOPWATCH; - // Check root cause in JUnit: https://github.com/junit-team/junit4/pull/778 protected void validateRootCause(Class rootExceptionClass, Runnable runnable) { assertThrows(rootExceptionClass, () -> { diff --git a/src/test/java/ru/javawebinar/topjava/service/AbstractUserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/AbstractUserServiceTest.java index 1ebd08f87..e17015d08 100644 --- a/src/test/java/ru/javawebinar/topjava/service/AbstractUserServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/AbstractUserServiceTest.java @@ -1,9 +1,7 @@ package ru.javawebinar.topjava.service; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.cache.CacheManager; import org.springframework.dao.DataAccessException; import ru.javawebinar.topjava.UserTestData; import ru.javawebinar.topjava.model.Role; @@ -14,9 +12,8 @@ import java.util.Date; import java.util.List; import java.util.Set; -import ru.javawebinar.topjava.repository.JpaUtil; -import static org.junit.Assert.assertThrows; +import static org.junit.jupiter.api.Assertions.assertThrows; import static ru.javawebinar.topjava.UserTestData.*; public abstract class AbstractUserServiceTest extends AbstractServiceTest { @@ -24,18 +21,6 @@ public abstract class AbstractUserServiceTest extends AbstractServiceTest { @Autowired protected UserService service; - @Autowired - private CacheManager cacheManager; - - @Autowired - protected JpaUtil jpaUtil; - - @Before - public void setup() { - cacheManager.getCache("users").clear(); - jpaUtil.clear2ndLevelHibernateCache(); - } - @Test public void create() { User created = service.create(getNew()); @@ -47,54 +32,54 @@ public void create() { } @Test - public void duplicateMailCreate() { + void duplicateMailCreate() { assertThrows(DataAccessException.class, () -> service.create(new User(null, "Duplicate", "user@yandex.ru", "newPass", Role.USER))); } @Test - public void delete() { + void delete() { service.delete(USER_ID); assertThrows(NotFoundException.class, () -> service.get(USER_ID)); } @Test - public void deletedNotFound() { + void deletedNotFound() { assertThrows(NotFoundException.class, () -> service.delete(NOT_FOUND)); } @Test - public void get() { - User user = service.get(USER_ID); - USER_MATCHER.assertMatch(user, UserTestData.user); + void get() { + User user = service.get(ADMIN_ID); + USER_MATCHER.assertMatch(user, admin); } @Test - public void getNotFound() { + void getNotFound() { assertThrows(NotFoundException.class, () -> service.get(NOT_FOUND)); } @Test - public void getByEmail() { + void getByEmail() { User user = service.getByEmail("admin@gmail.com"); USER_MATCHER.assertMatch(user, admin); } @Test - public void update() { + void update() { User updated = getUpdated(); service.update(updated); USER_MATCHER.assertMatch(service.get(USER_ID), getUpdated()); } @Test - public void getAll() { + void getAll() { List all = service.getAll(); USER_MATCHER.assertMatch(all, admin, guest, user); } @Test - public void createWithException() throws Exception { + void createWithException() throws Exception { validateRootCause(ConstraintViolationException.class, () -> service.create(new User(null, " ", "mail@yandex.ru", "password", Role.USER))); validateRootCause(ConstraintViolationException.class, () -> service.create(new User(null, "User", " ", "password", Role.USER))); validateRootCause(ConstraintViolationException.class, () -> service.create(new User(null, "User", "mail@yandex.ru", " ", Role.USER))); diff --git a/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaMealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaMealServiceTest.java index 83cbd7cc2..161c93fb5 100644 --- a/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaMealServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaMealServiceTest.java @@ -1,7 +1,7 @@ package ru.javawebinar.topjava.service.datajpa; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.springframework.test.context.ActiveProfiles; import ru.javawebinar.topjava.MealTestData; import ru.javawebinar.topjava.model.Meal; @@ -13,17 +13,17 @@ import static ru.javawebinar.topjava.UserTestData.*; @ActiveProfiles(DATAJPA) -public class DataJpaMealServiceTest extends AbstractMealServiceTest { +class DataJpaMealServiceTest extends AbstractMealServiceTest { @Test - public void getWithUser() { + void getWithUser() { Meal adminMeal = service.getWithUser(ADMIN_MEAL_ID, ADMIN_ID); MEAL_MATCHER.assertMatch(adminMeal, adminMeal1); USER_MATCHER.assertMatch(adminMeal.getUser(), admin); } @Test - public void getWithUserNotFound() { - Assert.assertThrows(NotFoundException.class, + void getWithUserNotFound() { + Assertions.assertThrows(NotFoundException.class, () -> service.getWithUser(MealTestData.NOT_FOUND, ADMIN_ID)); } } diff --git a/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java index 7733b7bfc..d8a1f4106 100644 --- a/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java @@ -1,7 +1,7 @@ package ru.javawebinar.topjava.service.datajpa; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.springframework.test.context.ActiveProfiles; import ru.javawebinar.topjava.MealTestData; import ru.javawebinar.topjava.UserTestData; @@ -14,17 +14,17 @@ import static ru.javawebinar.topjava.UserTestData.*; @ActiveProfiles(DATAJPA) -public class DataJpaUserServiceTest extends AbstractUserServiceTest { +class DataJpaUserServiceTest extends AbstractUserServiceTest { @Test - public void getWithMeals() { - User user = service.getWithMeals(USER_ID); - USER_MATCHER.assertMatch(user, UserTestData.user); - MEAL_MATCHER.assertMatch(user.getMeals(), MealTestData.meals); + void getWithMeals() { + User admin = service.getWithMeals(ADMIN_ID); + USER_MATCHER.assertMatch(admin, UserTestData.admin); + MEAL_MATCHER.assertMatch(admin.getMeals(), MealTestData.adminMeal2, MealTestData.adminMeal1); } @Test - public void getWithMealsNotFound() { - Assert.assertThrows(NotFoundException.class, + void getWithMealsNotFound() { + Assertions.assertThrows(NotFoundException.class, () -> service.getWithMeals(NOT_FOUND)); } } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcMealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcMealServiceTest.java index 9ff4ae615..aef588264 100644 --- a/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcMealServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcMealServiceTest.java @@ -6,5 +6,5 @@ import static ru.javawebinar.topjava.Profiles.JDBC; @ActiveProfiles(JDBC) -public class JdbcMealServiceTest extends AbstractMealServiceTest { +class JdbcMealServiceTest extends AbstractMealServiceTest { } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcUserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcUserServiceTest.java index 477b92cae..62ca7668c 100644 --- a/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcUserServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcUserServiceTest.java @@ -1,12 +1,10 @@ package ru.javawebinar.topjava.service.jdbc; -import org.junit.Ignore; import org.springframework.test.context.ActiveProfiles; import ru.javawebinar.topjava.service.AbstractUserServiceTest; import static ru.javawebinar.topjava.Profiles.JDBC; @ActiveProfiles(JDBC) -@Ignore -public class JdbcUserServiceTest extends AbstractUserServiceTest { +class JdbcUserServiceTest extends AbstractUserServiceTest { } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/jpa/JpaMealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/jpa/JpaMealServiceTest.java index 70e7bf865..aaf5dcda9 100644 --- a/src/test/java/ru/javawebinar/topjava/service/jpa/JpaMealServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/jpa/JpaMealServiceTest.java @@ -6,5 +6,5 @@ import static ru.javawebinar.topjava.Profiles.JPA; @ActiveProfiles(JPA) -public class JpaMealServiceTest extends AbstractMealServiceTest { +class JpaMealServiceTest extends AbstractMealServiceTest { } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/service/jpa/JpaUserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/jpa/JpaUserServiceTest.java index d1b3e4699..6d1cd9154 100644 --- a/src/test/java/ru/javawebinar/topjava/service/jpa/JpaUserServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/jpa/JpaUserServiceTest.java @@ -6,5 +6,5 @@ import static ru.javawebinar.topjava.Profiles.JPA; @ActiveProfiles(JPA) -public class JpaUserServiceTest extends AbstractUserServiceTest { +class JpaUserServiceTest extends AbstractUserServiceTest { } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/AbstractControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/AbstractControllerTest.java new file mode 100644 index 000000000..5c395ad19 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/AbstractControllerTest.java @@ -0,0 +1,52 @@ +package ru.javawebinar.topjava.web; + +import org.springframework.test.context.ActiveProfiles; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; +import ru.javawebinar.topjava.ActiveDbProfileResolver; +import ru.javawebinar.topjava.Profiles; + +import javax.annotation.PostConstruct; + +@SpringJUnitWebConfig(locations = { + "classpath:spring/spring-app.xml", + "classpath:spring/spring-mvc.xml", + "classpath:spring/spring-db.xml" +}) +//@WebAppConfiguration +//@ExtendWith(SpringExtension.class) +@Transactional +@ActiveProfiles(resolver = ActiveDbProfileResolver.class, profiles = Profiles.REPOSITORY_IMPLEMENTATION) +public abstract class AbstractControllerTest { + + private static final CharacterEncodingFilter CHARACTER_ENCODING_FILTER = new CharacterEncodingFilter(); + + static { + CHARACTER_ENCODING_FILTER.setEncoding("UTF-8"); + CHARACTER_ENCODING_FILTER.setForceEncoding(true); + } + + private MockMvc mockMvc; + + @Autowired + private WebApplicationContext webApplicationContext; + + @PostConstruct + private void postConstruct() { + mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .addFilter(CHARACTER_ENCODING_FILTER) + .build(); + } + + protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception { + return mockMvc.perform(builder); + } +} diff --git a/src/test/java/ru/javawebinar/topjava/web/RootControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/RootControllerTest.java new file mode 100644 index 000000000..6f15e2427 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/RootControllerTest.java @@ -0,0 +1,35 @@ +package ru.javawebinar.topjava.web; + +import org.assertj.core.matcher.AssertionMatcher; +import org.junit.jupiter.api.Test; +import ru.javawebinar.topjava.model.User; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; +import static ru.javawebinar.topjava.UserTestData.*; + +class RootControllerTest extends AbstractControllerTest { + + @Test + void getUsers() throws Exception { + perform(get("/users")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(view().name("users")) + .andExpect(forwardedUrl("/WEB-INF/jsp/users.jsp")) + .andExpect(model().attribute("users", + new AssertionMatcher>() { + @Override + public void assertion(List actual) throws AssertionError { + USER_MATCHER.assertMatch(actual, admin, guest, user); + } + } + )); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/json/JsonUtilTest.java b/src/test/java/ru/javawebinar/topjava/web/json/JsonUtilTest.java new file mode 100644 index 000000000..540586d11 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/json/JsonUtilTest.java @@ -0,0 +1,30 @@ +package ru.javawebinar.topjava.web.json; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import ru.javawebinar.topjava.model.Meal; + +import java.util.List; + +import static ru.javawebinar.topjava.MealTestData.*; + +class JsonUtilTest { + private static final Logger log = LoggerFactory.getLogger(JsonUtilTest.class); + + @Test + void readWriteValue() { + String json = JsonUtil.writeValue(adminMeal1); + log.info(json); + Meal meal = JsonUtil.readValue(json, Meal.class); + MEAL_MATCHER.assertMatch(meal, adminMeal1); + } + + @Test + void readWriteValues() { + String json = JsonUtil.writeValue(meals); + log.info(json); + List actual = JsonUtil.readValues(json, Meal.class); + MEAL_MATCHER.assertMatch(actual, meals); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/user/AdminRestControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/user/AdminRestControllerTest.java new file mode 100644 index 000000000..7563fe05c --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/user/AdminRestControllerTest.java @@ -0,0 +1,87 @@ +package ru.javawebinar.topjava.web.user; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javawebinar.topjava.UserTestData; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.service.UserService; +import ru.javawebinar.topjava.util.exception.NotFoundException; +import ru.javawebinar.topjava.web.AbstractControllerTest; +import ru.javawebinar.topjava.web.json.JsonUtil; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static ru.javawebinar.topjava.UserTestData.*; + +class AdminRestControllerTest extends AbstractControllerTest { + + private static final String REST_URL = AdminRestController.REST_URL + '/'; + + @Autowired + private UserService userService; + + @Test + void get() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + ADMIN_ID)) + .andExpect(status().isOk()) + .andDo(print()) + // https://jira.spring.io/browse/SPR-14472 + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin)); + } + + @Test + void getByEmail() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + "by-email?email=" + user.getEmail())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(user)); + } + + @Test + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL + USER_ID)) + .andDo(print()) + .andExpect(status().isNoContent()); + assertThrows(NotFoundException.class, () -> userService.get(USER_ID)); + } + + @Test + void update() throws Exception { + User updated = UserTestData.getUpdated(); + perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updated))) + .andExpect(status().isNoContent()); + + USER_MATCHER.assertMatch(userService.get(USER_ID), updated); + } + + @Test + void createWithLocation() throws Exception { + User newUser = UserTestData.getNew(); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newUser))) + .andExpect(status().isCreated()); + + User created = USER_MATCHER.readFromJson(action); + int newId = created.id(); + newUser.setId(newId); + USER_MATCHER.assertMatch(created, newUser); + USER_MATCHER.assertMatch(userService.get(newId), newUser); + } + + @Test + void getAll() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin, guest, user)); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java index 2386eeee4..40af802e0 100644 --- a/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java +++ b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java @@ -1,22 +1,18 @@ package ru.javawebinar.topjava.web.user; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import ru.javawebinar.topjava.repository.inmemory.InMemoryUserRepository; import ru.javawebinar.topjava.util.exception.NotFoundException; import static ru.javawebinar.topjava.UserTestData.NOT_FOUND; import static ru.javawebinar.topjava.UserTestData.USER_ID; -@ContextConfiguration({"classpath:spring/spring-app.xml", "classpath:spring/inmemory.xml"}) -@RunWith(SpringRunner.class) -public class InMemoryAdminRestControllerSpringTest { +@SpringJUnitConfig(locations = {"classpath:spring/inmemory.xml"}) +class InMemoryAdminRestControllerSpringTest { @Autowired private AdminRestController controller; @@ -24,19 +20,19 @@ public class InMemoryAdminRestControllerSpringTest { @Autowired private InMemoryUserRepository repository; - @Before + @BeforeEach public void setUp() { repository.init(); } @Test - public void delete() { + void delete() { controller.delete(USER_ID); - Assert.assertNull(repository.get(USER_ID)); + Assertions.assertNull(repository.get(USER_ID)); } @Test - public void deleteNotFound() { - Assert.assertThrows(NotFoundException.class, () -> controller.delete(NOT_FOUND)); + void deleteNotFound() { + Assertions.assertThrows(NotFoundException.class, () -> controller.delete(NOT_FOUND)); } } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java index 7cc2a833c..75b8a5a82 100644 --- a/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java +++ b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java @@ -1,6 +1,6 @@ package ru.javawebinar.topjava.web.user; -import org.junit.*; +import org.junit.jupiter.api.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ConfigurableApplicationContext; @@ -13,40 +13,42 @@ import static ru.javawebinar.topjava.UserTestData.NOT_FOUND; import static ru.javawebinar.topjava.UserTestData.USER_ID; -public class InMemoryAdminRestControllerTest { +class InMemoryAdminRestControllerTest { private static final Logger log = LoggerFactory.getLogger(InMemoryAdminRestControllerTest.class); private static ConfigurableApplicationContext appCtx; private static AdminRestController controller; private static InMemoryUserRepository repository; - @BeforeClass - public static void beforeClass() { - appCtx = new ClassPathXmlApplicationContext("spring/spring-app.xml", "spring/inmemory.xml"); + @BeforeAll + static void beforeClass() { + appCtx = new ClassPathXmlApplicationContext("spring/inmemory.xml"); log.info("\n{}\n", Arrays.toString(appCtx.getBeanDefinitionNames())); controller = appCtx.getBean(AdminRestController.class); repository = appCtx.getBean(InMemoryUserRepository.class); } - @AfterClass - public static void afterClass() { - appCtx.close(); + @AfterAll + static void afterClass() { +// May cause during JUnit "Cache is not alive (STATUS_SHUTDOWN)" as JUnit share Spring context for speed +// http://stackoverflow.com/questions/16281802/ehcache-shutdown-causing-an-exception-while-running-test-suite +// appCtx.close(); } - @Before - public void setUp() { + @BeforeEach + public void setup() { // re-initialize repository.init(); } @Test - public void delete() { + void delete() { controller.delete(USER_ID); - Assert.assertNull(repository.get(USER_ID)); + Assertions.assertNull(repository.get(USER_ID)); } @Test - public void deleteNotFound() { - Assert.assertThrows(NotFoundException.class, () -> controller.delete(NOT_FOUND)); + void deleteNotFound() { + Assertions.assertThrows(NotFoundException.class, () -> controller.delete(NOT_FOUND)); } } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/user/ProfileRestControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/user/ProfileRestControllerTest.java new file mode 100644 index 000000000..e8882742d --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/user/ProfileRestControllerTest.java @@ -0,0 +1,48 @@ +package ru.javawebinar.topjava.web.user; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.service.UserService; +import ru.javawebinar.topjava.web.AbstractControllerTest; +import ru.javawebinar.topjava.web.json.JsonUtil; + +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static ru.javawebinar.topjava.UserTestData.*; +import static ru.javawebinar.topjava.web.user.ProfileRestController.REST_URL; + +class ProfileRestControllerTest extends AbstractControllerTest { + + @Autowired + private UserService userService; + + @Test + void get() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(user)); + } + + @Test + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL)) + .andExpect(status().isNoContent()); + USER_MATCHER.assertMatch(userService.getAll(), admin, guest); + } + + @Test + void update() throws Exception { + User updated = getUpdated(); + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updated))) + .andDo(print()) + .andExpect(status().isNoContent()); + + USER_MATCHER.assertMatch(userService.get(USER_ID), updated); + } +} \ No newline at end of file diff --git a/src/test/resources/spring/inmemory.xml b/src/test/resources/spring/inmemory.xml index c6a2710cb..0c9d05028 100644 --- a/src/test/resources/spring/inmemory.xml +++ b/src/test/resources/spring/inmemory.xml @@ -4,4 +4,6 @@ xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd"> + + \ No newline at end of file diff --git a/src/test/resources/spring/spring-cache.xml b/src/test/resources/spring/spring-cache.xml new file mode 100644 index 000000000..ea51df903 --- /dev/null +++ b/src/test/resources/spring/spring-cache.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + false + + + + + + \ No newline at end of file From e49837c8032c26166e915ada15a4483afb1dd914 Mon Sep 17 00:00:00 2001 From: art94timer Date: Wed, 28 Sep 2022 21:12:25 +0300 Subject: [PATCH 16/19] prepared HW08 --- config/Topjava-soapui-project.xml | 214 +++++++ config/curl.md | 29 + config/messages/app.properties | 7 +- config/messages/app_ru.properties | 9 +- doc/lesson08.md | 580 ++++++++++++++++++ pom.xml | 65 ++ .../ru/javawebinar/topjava/model/Meal.java | 2 + .../ru/javawebinar/topjava/model/MealTo.java | 20 + .../ru/javawebinar/topjava/model/User.java | 7 +- .../javawebinar/topjava/util/MealsUtil.java | 2 +- .../web/converter/DateTimeFormatters.java | 39 ++ .../topjava/web/meal/JspMealController.java | 2 +- .../topjava/web/meal/MealRestController.java | 64 +- .../web/user/AbstractUserController.java | 5 + .../topjava/web/user/AdminRestController.java | 5 + .../topjava/web/user/AdminUIController.java | 35 ++ .../web/user/ProfileRestController.java | 5 + src/main/resources/logback.xml | 4 +- src/main/resources/spring/spring-app.xml | 1 + src/main/resources/spring/spring-mvc.xml | 14 +- src/main/resources/spring/spring-security.xml | 28 + .../WEB-INF/jsp/fragments/bodyHeader.jsp | 14 +- .../webapp/WEB-INF/jsp/fragments/footer.jsp | 8 +- .../webapp/WEB-INF/jsp/fragments/headTag.jsp | 15 +- src/main/webapp/WEB-INF/jsp/index.jsp | 22 +- src/main/webapp/WEB-INF/jsp/users.jsp | 101 ++- src/main/webapp/WEB-INF/web.xml | 10 + src/main/webapp/resources/css/style.css | 25 +- .../webapp/resources/images/icon-meal.png | Bin 0 -> 1898 bytes .../webapp/resources/js/topjava.common.js | 80 +++ src/main/webapp/resources/js/topjava.users.js | 47 ++ .../javawebinar/topjava/MatcherFactory.java | 28 +- .../ru/javawebinar/topjava/MealTestData.java | 2 + .../ru/javawebinar/topjava/UserTestData.java | 15 + .../datajpa/DataJpaUserServiceTest.java | 8 +- .../topjava/web/AbstractControllerTest.java | 11 +- .../topjava/web/ResourceControllerTest.java | 20 + .../topjava/web/RootControllerTest.java | 17 +- .../web/meal/MealRestControllerTest.java | 98 +++ .../web/user/AdminRestControllerTest.java | 15 +- .../web/user/ProfileRestControllerTest.java | 10 + 41 files changed, 1606 insertions(+), 77 deletions(-) create mode 100644 config/curl.md create mode 100644 doc/lesson08.md create mode 100644 src/main/java/ru/javawebinar/topjava/web/converter/DateTimeFormatters.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/user/AdminUIController.java create mode 100644 src/main/resources/spring/spring-security.xml create mode 100644 src/main/webapp/resources/images/icon-meal.png create mode 100644 src/main/webapp/resources/js/topjava.common.js create mode 100644 src/main/webapp/resources/js/topjava.users.js create mode 100644 src/test/java/ru/javawebinar/topjava/web/ResourceControllerTest.java create mode 100644 src/test/java/ru/javawebinar/topjava/web/meal/MealRestControllerTest.java diff --git a/config/Topjava-soapui-project.xml b/config/Topjava-soapui-project.xml index f9668d765..f4b00d936 100644 --- a/config/Topjava-soapui-project.xml +++ b/config/Topjava-soapui-project.xml @@ -386,6 +386,220 @@ + + + + + + + + + 0 + + data + + + application/json;charset=UTF-8 + 200 + + Response + + + + <xml-fragment/> + + + http://localhost:8080 + + http://localhost/topjava/rest/profile/meals + + No Authorization + + + + + + + + + + + application/json + + + + application/json;charset=UTF-8 + 201 + + meal:Response + + + + <xml-fragment/> + + + http://localhost:8080 + { + "dateTime": "2020-02-01T10:00", + "description": "Новый завтрак", + "calories": 777 + } + + http://localhost/topjava/rest/profile/meals + + No Authorization + + + + + + + + + + + + + + + application/json;charset=UTF-8 + 200 + + ns:Response + + + + <xml-fragment/> + + http://localhost:8080 + + http://localhost/topjava/rest/profile/meals/100002 + + No Authorization + + + + + + + + + + + + + 200 + + data + + + application/json + + + + + 200 + + data + + + + 200 + + data + + + + <xml-fragment/> + + UTF-8 + http://localhost:8080 + { + "id": 100002, + "dateTime": "2020-01-30T10:00", + "description": "Обновленный завтрак", + "calories": 500 + } + + http://localhost/topjava/rest/profile/meals/100002 + + No Authorization + + + + + + + + + + + + + startDate + + QUERY + + + + + startTime + + QUERY + + + + + endDate + + QUERY + + + + + endTime + + QUERY + + + + + + + + + application/json;charset=UTF-8 + 200 + + Response + + + + <xml-fragment/> + + http://localhost:8080 + + http://localhost/topjava/rest/profile/meals/filter + + No Authorization + + + + + + + + + + + startDate + startTime + endDate + endTime + + + + diff --git a/config/curl.md b/config/curl.md new file mode 100644 index 000000000..b80ca57f5 --- /dev/null +++ b/config/curl.md @@ -0,0 +1,29 @@ +### curl samples (application deployed at application context `topjava`). +> For windows use `Git Bash` + +#### get All Users +`curl -s http://localhost:8080/topjava/rest/admin/users --user admin@gmail.com:admin` + +#### get Users 100001 +`curl -s http://localhost:8080/topjava/rest/admin/users/100001 --user admin@gmail.com:admin` + +#### get All Meals +`curl -s http://localhost:8080/topjava/rest/profile/meals --user user@yandex.ru:password` + +#### get Meals 100003 +`curl -s http://localhost:8080/topjava/rest/profile/meals/100003 --user user@yandex.ru:password` + +#### filter Meals +`curl -s "http://localhost:8080/topjava/rest/profile/meals/filter?startDate=2020-01-30&startTime=07:00:00&endDate=2020-01-31&endTime=11:00:00" --user user@yandex.ru:password` + +#### get Meals not found +`curl -s -v http://localhost:8080/topjava/rest/profile/meals/100008 --user user@yandex.ru:password` + +#### delete Meals +`curl -s -X DELETE http://localhost:8080/topjava/rest/profile/meals/100002 --user user@yandex.ru:password` + +#### create Meals +`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 diff --git a/config/messages/app.properties b/config/messages/app.properties index 6b4d2d155..fc7665a88 100644 --- a/config/messages/app.properties +++ b/config/messages/app.properties @@ -1,14 +1,16 @@ app.title=Calories management -app.home=Home -app.footer=Internship Spring 5/JPA Enterprise (Topjava) application +app.footer=Spring 5/JPA Enterprise (Topjava) internship application app.login=Login as user.title=Users +user.edit=Edit user +user.add=Add user user.name=Name user.email=Email user.roles=Roles user.active=Active user.registered=Registered +user.password=Password meal.title=Meals meal.edit=Edit meal @@ -22,6 +24,7 @@ meal.description=Description meal.dateTime=Date/Time meal.calories=Calories +common.add=Add common.select=Select common.delete=Delete common.update=Update diff --git a/config/messages/app_ru.properties b/config/messages/app_ru.properties index 97f3e9f05..5ea49005a 100644 --- a/config/messages/app_ru.properties +++ b/config/messages/app_ru.properties @@ -1,18 +1,20 @@ app.title=Подсчет калорий -app.home=Главная app.footer=Приложение стажировки Spring 5/JPA Enterprise (Topjava) app.login=Зайти как user.title=Пользователи +user.edit=Редактировать пользователя +user.add=Добавить пользователя user.name=Имя user.email=Почта user.roles=Роли user.active=Активный user.registered=Зарегистрирован +user.password=Пароль meal.title=Моя еда -meal.edit=Редактирование еды -meal.add=Добавление еды +meal.edit=Редактировать еду +meal.add=Добавить еду meal.filter=Отфильтровать meal.startDate=От даты (включая) meal.endDate=До даты (включая) @@ -22,6 +24,7 @@ meal.description=Описание meal.dateTime=Дата/Время meal.calories=Калории +common.add=Добавить common.select=Выбрать common.delete=Удалить common.update=Обновить diff --git a/doc/lesson08.md b/doc/lesson08.md new file mode 100644 index 000000000..b2040afd0 --- /dev/null +++ b/doc/lesson08.md @@ -0,0 +1,580 @@ +# Стажировка Topjava + +## Материалы занятия + +- **Браузер кэширует javascript и css. Если изменения не работают, обновите приложение в браузере (в хроме `Ctrl+F5`)** +- **При удалении файлов не забывайте делать clean: `mvn clean`** + +### ![correction](https://cloud.githubusercontent.com/assets/13649199/13672935/ef09ec1e-e6e7-11e5-9f79-d1641c05cbe6.png) Правки в проекте + +#### Apply 8_0_fix.patch +Время еды приходит с UI с точностью до минут + +## ![hw](https://cloud.githubusercontent.com/assets/13649199/13672719/09593080-e6e7-11e5-81d1-5cb629c438ca.png) Разбор домашнего задания HW7 + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 1. [HW7](https://drive.google.com/file/d/1h6wg2V9yZoNX7fA7mNA7w7Kxp8IACsIJ) + +

    + Краткое содержание + +#### Тесты ResourceController +Прежде всего в настройках логирования для класса `ExceptionHandlerExceptionResolver` +установим уровень "debug". Теперь в логах мы сможем увидеть запросы, у которых проблемы с маппингом. +Чтобы протестировать доступ к ресурсам, создадим `ResourceControllerTest` с единственным тестовым методом. +Класс `MediaType` позволяет указать требуемый тип с помощью фабричного метода `valueOf`. +Начиная с [Spring 4.3 ожидаемый тип ответа нужно сравнивать с помощью `contentTypeCompatibleWith`](https://github.com/spring-projects/spring-framework/issues/19041), а не `contentType` +(в этом случае кодировка UTF-8 в типе ответа не учитывается в сравнении). + +#### Тесты для RootController на еду +Для `RootController` тесты на еду делаем точно так же, как и на `User`, с небольшим отличием. +Так как `MealTo` - это транспортный объект, который не является Entity и не находится под управлением +JPA, у него нет ограничений по методам `equals / hashCode`, и мы можем +добавить свои (сгенерировать с помощью IDEA). Теперь в тестах объекты `MealTo` мы можем сравнивать +через `equals()`. +Чтобы убедиться что два списка `MealTo` - ожидаемый, и полученный от контроллера, сравниваются поэлементно +через `equals`, мы можем установить в сравнении брекпоинт и запустить тест в режиме дебага. + +#### Реализовать MealRestController +`MealRestController` реализуем аналогично контроллерам пользователей. +В метод `MealRestController#getBetween` с параметрами запроса нужно передать +время и дату начала и конца диапазона, для которого будет найдена еда. Это можно сделать с помощью аннотации `@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)`. +Spring автоматически конвертирует параметры запроса в объекты типа `LocalDateTime`. + +В `MealRestControllerTest` нужно обратить внимание на тесты +для методов `get` и `getBetween` контроллера, так как они возвращают список `MealTo`, а не `Meal`. +Поэтому для сравнения списков еды создадим отдельный `TO_MATCHER` с помощью статического фабричного метода `usingEqualsComparator(MealTo.class)`: +``` +public static MatcherFactory.Matcher TO_MATCHER = MatcherFactory.usingEqualsComparator(MealTo.class) +``` +Он будет сравнивать `MealTo` уже не рекурсивно, а с помощью `MealTo#equals()` — сравнения в методах `assertMatch` переделал с использованием реализаций интерфейса `BiConsumer`: +*assertion* и *iterableAssertion*. Получается очень гибко (привет, паттерн "стратегия"): для создания матчера мы можем использовать любые собственные реализации сравнений. + +Для того чтобы для тестов создать объекты `MealTo`, используем утилитный метод `MealsUtil#createTo`, изменив у него модификатор доступа на *public*. + +Для некоторых методов с переменным количеством аргументов IDEA сообщает о небезопасности типов. Чтобы подавить эти +предупреждения, над методами у нас стоят аннотации `@SafeVarargs` (для использования этой аннотации метод должен быть `final`). + +Чтобы Jackson мог сериализовать/десериализовать объекты `MealTo`, нам нужно сделать для этого класса сеттеры, или создать конструктор, помеченный специальной аннотацией `@ConstructorProperties`, +в параметры которой передаем поля объекта json, соответствующие аргументам конструктора. + +
    + + + +#### Apply 8_01_HW07_controller_test.patch + +- [Persistent classes implementing equals and hashcode](https://docs.jboss.org/hibernate/orm/4.3/manual/en-US/html_single/#persistent-classes-equalshashcode): переопределять `equals()/hashCode()` + необходимо, если + - использовать Entity в `Set` (рекомендовано для many-ассоциаций) либо как ключи в `HashMap` + - использовать _reattachment of detached instances_ (т.е. манипулировать одним Entity в нескольких транзакциях/сессиях). +- Оптимально использовать уникальные неизменяемые бизнес-поля, но обычно таких нет, и чаще всего используется суррогатный PK с ограничением, что он может быть `null` у новых объектов и нельзя объекты сравнивать + через `equals` в бизнес-логике (например, в тестах). +- [Equals() and hashcode() when using JPA and Hibernate](https://stackoverflow.com/questions/1638723) + +------------------------ + +#### Apply 8_02_HW07_rest_controller.patch +> - В `MealTo` вместо изменяемых полей и конструктора без параметров сделал [`@ConstructorProperties`](https://www.logicbig.com/tutorials/misc/jackson/constructor-properties.html). `Immutable` классы + всегда предпочтительнее для данных. +- [Паттерн стратегия](https://refactoring.guru/ru/design-patterns/strategy). + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 2. HW7 Optional + +
    + Краткое содержание + +#### Собственный Spring-конвертер (форматтер) для даты и времени +Spring фраймворк с помощью встроенных конвертеров (реализующих интерфейс `org.springframework.core.convert.converter.Converter`) и форматтеров (интерфейс `org.springframework.format.Formatter`) делает автоматическое преобразование параметров запроса из одного типа в другой. +В нашем случае параметры фильтрации еды - дата и время - по REST приходят в виде строки, и мы можем добавить свой конвертер или форматтер, чтобы он автоматически приводил их к нужному нам типу. +> - Конвертер Spring преобразует объект одного типа в объект другого типа +> - Форматер преобразует объект типа String в объект нужного типа (при этом может поддерживать локаль) + +Сделаем собственные форматтеры для преобразования строки в дату и время `DateTimeFormatters`, добавим в `spring-mvc.xml` бин `conversionService` с перечнем наших форматтеров и сделаем на него ссылку: +``` + +``` +`LocalTimeFormatter` и `LocalDateFormatter` - наши кастомные форматтеры, которые будут парсить строку параметра. Для этого они должны реализовывать +интерфейс `Formatter<Целевой тип>` и переопределять его методы `#parse` и `#print`. Теперь мы можем убрать аннотации `@DateTimeFormat` из аргументов `MealRestController#getBetween`. `conversionService` будет +искать среди форматеров или конвертеров те, которые смогли бы преобразовать параметр-строку в объект соответствующего типа, объявленный в методе контроллера, и в результате будут использованы наши кастомные форматеры. +Для новой реализации метода `getBetween` теперь создадим несколько тестов - с различным набором параметров (в том числе и с пустыми параметрами). + +#### Протестировать сервисы с помощью SoapUI +Помимо SoapUI, для тестирования REST можно использовать команду *curl* через *Git Bash* (этот способ имеет свои недостатки - не поддерживается UTF8). +Для запросов требуется указывать Content-Type, иначе контроллер не сможет обработать запрос. +Также популярными средствами тестирования REST являются *Postman* и в IDEA: *Tools->HTTP Client*. +> Для тестирования REST у вас должен быть запущен Tomcat с вашим приложением! + +
    + + +#### Apply 8_03_HW07_formatters.patch + +> - Перенес форматтеры в подпакет `web`, т.к. они используются Spring MVC +> - Заменил `@RequestParam(required = false)` на `@RequestParam @Nullable` + +#### Apply 8_04_HW07_soapui_curl.patch + +> Добавил примеры запросов curl в `config/curl.md` + +- Написание HTTP-запросов с помощью Curl (для Windows можно использовать Git Bash) +- В IDEA появился отличный инструмент тестирования запросов. Для конвертации + в [Tools->HTTP Client->Test RESTful Web Service](https://www.jetbrains.com/help/idea/http-client-in-product-code-editor.html) скопируйте curl без флага `-s` + +### Внимание! curl команды, требуемые в ТЗ к выпускному проекту, сделайте в `readme.md`, НЕ НАДО делать в выпускном проекте отдельный `curl.md`. + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 3. [HW7 Optional: getWithMeals + тесты](https://drive.google.com/file/d/13cjenXzWDr52HTTzleomOd-yjPAEAbOA) + +
    + Краткое содержание + +В нашем приложении у `Meal` есть ссылка на `User`, а в `User` есть ссылка на коллекцию `Meal`. +Таким образом, мы имеем дело с *BiDirectional* циклической зависимостью. При сериализации через Jackson у нас возникнут проблемы, так как он перейдет в +бесконечный цикл при переходе по ссылкам сущностей друг на друга. +Возможно следующее разрешение циклических зависимостей: + +- над полем `Meal.user` добавить аннотацию `@JsonBackReference`, теперь для еды это поле не будет сериализоваться в json; +- над коллекцией `User.meals` добавить аннотацию `@JsonManagedReference`, поле будет сериализоваться. + +Теперь для получения пользователя с едой в методах контроллера можно просто вызвать соответствующий метод сервиса. + +Для новой функциональности создадим дополнительные тесты. В тестовых данных для пользователей заполним поля *meals*. +Чтобы сразу проверять пользователя вместе с его едой, создадим дополнительный `UserTestData.WITH_MEALS_MATCHER`, который будет сравнивать сущности с помощью переданных ему интерфейсов сравнения. +Коллекции пользователей с едой мы не реализуем, поэтому `iterableAssertion` также делать не нужно, бросаем `UnsupportedOperationException`. + +Так как метод получения пользователя с едой у нас реализован только в профиле datajpa, в тестах перед выполнением метода нужно проверить, что текущий профиль Spring - `dataJpa`, тесты будут пропускаться для других профилей. +Такую функциональность мы ранее уже реализовывали - внедряем в тестовый класс `Environment` и проверяем активный профиль с помощью `Assumptions#assumeTrue`. + +
    + +#### Apply 8_05_HW07_with_meals.patch +#### Apply 8_06_HW07_test_with_meals.patch +> Изменения в AssertJ: `ignoringAllOverriddenEquals` для рекурсивных сравнений не нужен. См. [overridden equals used before 3.17.0](https://assertj.github.io/doc/#assertj-core-recursive-comparison-ignoring-equals) + +## Занятие 8: + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 4. WebJars. jQuery and JavaScript frameworks + +
    + Краткое содержание + +**WebJars** — библиотеки на стороне клиента (JavaScript библиотека и/или CSS модуль), упакованные в JAR. + +Добавим в наш проект в `pom.xml` дополнительные зависимости - библиотеки JavaScript и css: +- *jQuery* - самая распространенная утилитная JavaScript-библиотека; +- *Bootstrap* - фреймворк CSS-стилей; +- *Datatables* - плагин для отрисовки таблиц; +- *datetimepicker* - плагин для работы с датой и временем; +- *noty* - для работы с уведомлениями; + +
    + +#### Apply 8_07_webjars.patch + +> - Обновил jQuery до 3.x, Bootstrap до 4.x + > - Новое в jQuery 3 +> - УБРАЛ из проекта Dandelion обертку к Datatables + > - не встречал нигде, кроме Spring Pet Clinic; + > - поддержка работы с Datatables через Dandelion оказалось гораздо более трудоемкой, чем работа с плагином напрямую. +> - Исключил ненужные зависимости + +- Подключение веб-ресурсов. WebJars. +- Introducing WebJars +- Document Object Model (DOM) +- What is the DOM? +- jQuery +- Is jQuery a javascript library or framework +- DataTables +- Working with jQuery DataTables + +## ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 5. [Bootstrap](https://drive.google.com/file/d/1RHtzw8OQt6guCu6xe3apT7F9EfiX96tr) + +
    + Краткое содержание + +Front-end нашего приложения будет строиться на основе фреймворка Bootstrap. +> В новой версии Bootstrap 5 из зависимостей исключена библиотека jQuery, и весь необходимый функционал Bootstrap делается на простом JavaScript. Однако JQuery нам нужна для *Datatables* и плагинов, поэтому не стал переходить на 5-ю версию. + +По ссылке [Bootstrap Examples](https://getbootstrap.com/docs/4.6/examples/) приведены примеры сайтов на Bootstrap. Из перечня уже готовых шаблонов можно выбрать +подходящий шаблон, скопировать из его исходного кода стили, форматирование и использовать в своем проекте. +- В `spring-mvc.xml` мы должны явно указать маппинг на *WebJars*-ресурсы, с которыми будет работать приложение: +````xml + +```` +- В `headTag.jsp`, который у нас сейчас добавляется через `jsp:include` в начало каждой JSP страницы, подтягиваем из *WebJars* нужные нам *css*-ресурсы и иконку для нашего приложения. +- Для отрисовывания стандартных иконок подключается ресурс ``. + В класс иконок `.fa` добавим `cursor: pointer` - это курсор-рука, который обычно используется для кнопок. +- В стили добавим sticky-footer - это footer, который будет включаться в конце JSP-страниц и приклеиваться к нижней части экрана. +- JSP-страницу со списком пользователей оформим с использованием элементов Bootstrap и добавим иконки на кнопки. +- на странице `index.jsp` форму выбора пользователя поместим в класс Bootstrap *jumbotron* - крупный выносной элемент с большим текстом и большими отступами +- таблицей пользователей в `users.jsp` поместим в аналогичный элемент *jumbotron* +
    + +#### Apply 8_08_bootstrap4.patch + +> - [WIKI Bootstrap](https://ru.wikipedia.org/wiki/Bootstrap_(фреймворк)) +> - Добавил Font Awesome + > - [Map glyphicon icons to font-awesome](https://gist.github.com/blowsie/15f8fe303383e361958bd53ecb7294f9) +> - В `headTag.jsp` в ссылку на `style.css` добавил `?v=2`. Стили изменились, изменяя версию в параметре мы заставляем браузер не брать их из кэша, а загружать заново. + +- [Bootstrap](https://getbootstrap.com/) + - [Navbar](https://getbootstrap.com/docs/4.1/components/navbar/) + - [Spacing](https://getbootstrap.com/docs/4.1/utilities/spacing/) + - [Forms](https://getbootstrap.com/docs/4.1/components/forms/) + - [Sticky footer](https://getbootstrap.com/docs/4.1/examples/sticky-footer/) +- [Документация Bootstrap на русском](https://bootstrap-4.ru/) +- Дополнительно + - Twitter Bootstrap Tutorial + - Видеоуроки Bootstrap 4 + - [Bootstrap верстка современного сайта за 45 минут](https://www.youtube.com/watch?v=46q2eB7xvXA) + +## ![question](https://cloud.githubusercontent.com/assets/13649199/13672858/9cd58692-e6e7-11e5-905d-c295d2a456f1.png) Ваши вопросы + +> А где реально этот путь "classpath:/META-INF/resources/webjars"? + +Внутри подключаемых webjars ресурсы лежат по пути `/META-INF/resources/webjars/...` Не поленитесь посмотреть на них через `Ctrl+Shift+N`. Все подключаемые jar попадают в classpath, и ресурсы доступны +по этому пути. + +> У меня webjars-зависимость лежит внутри ".m2\repository\org\webjars\". С чем это может быть связано? + +Maven скачивает все зависимости в local repository, который по умолчанию находится в `~/.m2`. Каталог по умолчанию можно переопределить в `APACHE-MAVEN-HOME\conf\settings.xml`, +элемент `localRepository`. + +> WEBJARS лежат вообще в другом месте WEB-INF\lib\. Биндим mapping="/webjars/*" на реальное положение jar в war-e, откуда Spring знает, где искать наш jQuery? + +В war в `WEB-INF/lib/*` лежат все jar, которые попадают к classpath. Spring при обращении по url `/webjars/` ищет по пути +биндинга `` +по всему classpath (то же самое, как распаковать все jar в один каталог) в `META-INF/resources/webjars/`. В этом месте во всех jar, которые мы подключили из webjars, лежат наши ресурсы. + +> Оптимально ли делать доступ к статическим ресурсам (css, js, html) через webjars ? + +На продакшене под нагрузкой статические ресурсы лучше всего держать не в war, а снаружи. Доступ к ним делается либо +через конфигурирование Tomcat. +Но чаще всего для доступа к статике ставят прокси, например Nginx + +## ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 6. AJAX. Datatables. jQuery + +
    + Краткое содержание + +**AJAX** (асинхронный JavaScript и XML) — подход к построению интерактивных пользовательских интерфейсов веб-приложений, заключающийся в "фоновом" обмене данными браузера с веб-сервером. + +#### AdminUIController +У нас будут отдельные от REST UI-контроллеры, так как в них будут отличаться обработка исключений, некоторая логика и авторизация. +В `AdminUIController` метод `#create` будет использоваться как для создания, так и для обновления пользователя в зависимости от значения `id`. + +#### Список пользователей +Оформляем таблицу пользователей с помощью js/css библиотеки `Datatables`. Таблица должна иметь id (в нашем случае "datatable"), чтобы к ней можно было обращаться. +Также на страницу добавляем форму, с помощью которой будем редактировать и добавлять пользователей. +Форма имеет скрытое поле `id`, которое будет использоваться в наших js-скриптах. + +#### topjava.users.js +> Код по сравнению с видео изменился! Про изменения я говорю в конце видео и перечислил их после *Краткого содержания* + +Для работы AJAX объявляем переменные: +- *ajaxUrl* - адрес нужного endpoint контроллера +- *datatableApi* - объект таблицы `datatable` + +Страница html имеет определенный жизненный цикл, в процессе которого с ней совершаются какие-то действия. +Одно из таких действий - загрузка, после которого мы можем производить какие-то манипуляции на странице. +С помощью jQuery мы определяем коллбэк-метод, который будет вызываться после загрузки страницы: +``` +$(function () { + ... +``` +Строчка +``` +datatableApi = $("#datatable").DataTable( +``` +преобразует HTML-элемент c *id=datatable* в javaScript-объект с помощью метода `DataTable` библиотеки *Datatables*. +Параметр этого метода - объект-конфигурация, который задает опции отображения таблицы и в "columns" задает соответствие колонок таблицы полям приходящего с сервера JSON-объекта пользователей. +Внизу конфигурации добавляется сортировка таблицы по первому столбцу. +После этого вызывается метод `makeEditable()` (он находится в `topjava.common.js`). + +#### topjava.common.js + +- В `makeEditable` к событию *click* всех объектов HTML c классом *delete* привязываем вызов метода `deleteRow`. Параметром берем аттрибут `id` текущего элемента `$(this)`. + +- Метод `add` вызывается из `users.jsp` по нажатию на кнопку "Добавить": `onclick="add()"`. В нем + - обнуляются все поля `input` формы `detailsForm`: `$("#detailsForm").find(":input").val("")` + - вызывается входящий в Bootstrap метод `modal()`, который преобразует HTML-элемент `id=editRow` в модальное окно. [Botstrap4 Modal](https://getbootstrap.com/docs/4.6/components/modal) + +- В методе `deleteRow` делаем AJAX-запросы к серверу и по после их успешного выполнения вызываем обновление таблицы. + +- В `updateTable` по AJAX запрашиваем с сервера массив пользователей, в случае успеха очищаем таблицу и заполняем ее данными, полученными с сервера. + +- В `save` средствами jQuery сериализуем форму `id=detailsForm` в JSON-объект и методом POST отдаем эти данные. После успешного выполнения запроса закрываем модальное окно и обновляем таблицу. + +Intellij IDEA предоставляет нам возможность дебага кода JavaScript. См. видео для примера. + +#### Загрузка HTML +По умолчанию при стандартной загрузке страницы с js-скриптами браузер будет: +- Парсить нужную HTML-страницу; +- Как только браузер сталкивается с тегом ` + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/index.jsp b/src/main/webapp/WEB-INF/jsp/index.jsp index 847191965..cc610bd72 100644 --- a/src/main/webapp/WEB-INF/jsp/index.jsp +++ b/src/main/webapp/WEB-INF/jsp/index.jsp @@ -6,16 +6,18 @@ -
    -
    -
    - : - - -
    +
    +
    +
    + + + +
    +
    +
    \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/users.jsp b/src/main/webapp/WEB-INF/jsp/users.jsp index 4d3d86789..35cf30926 100644 --- a/src/main/webapp/WEB-INF/jsp/users.jsp +++ b/src/main/webapp/WEB-INF/jsp/users.jsp @@ -6,33 +6,88 @@ + + -
    -

    - - - - - - - - - - - - - +
    +
    +

    + +
    + - - - - - + + + + + + + - -
    ${user.email}${user.roles}${user.enabled}
    -
    + + + +
    ${user.email}${user.roles}checked/>
    + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 175ec350e..9816e2a1b 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -37,6 +37,16 @@ / + + + springSecurityFilterChain + org.springframework.web.filter.DelegatingFilterProxy + + + springSecurityFilterChain + /* + + encodingFilter org.springframework.web.filter.CharacterEncodingFilter diff --git a/src/main/webapp/resources/css/style.css b/src/main/webapp/resources/css/style.css index a55147510..067c4cb1f 100644 --- a/src/main/webapp/resources/css/style.css +++ b/src/main/webapp/resources/css/style.css @@ -23,10 +23,23 @@ tr[data-meal-excess="true"] { color: red; } -header, footer { - background: none repeat scroll 0 0 #A6C9E2; - color: #2E6E9E; - font-size: 20px; - padding: 5px 20px; - margin: 6px 0; +.fa { + cursor: pointer; +} + +/*https://getbootstrap.com/docs/4.0/examples/sticky-footer/sticky-footer.css*/ +html { + position: relative; + min-height: 100%; +} +body { + margin-bottom: 60px !important; /* Margin bottom by footer height */ +} +.footer { + position: absolute; + bottom: 0; + width: 100%; + height: 60px; /* Set the fixed height of the footer here */ + line-height: 60px; /* Vertically center the text there */ + background-color: #f5f5f5; } diff --git a/src/main/webapp/resources/images/icon-meal.png b/src/main/webapp/resources/images/icon-meal.png new file mode 100644 index 0000000000000000000000000000000000000000..b4fc54ad01294abb103d9238c12b1826a9240dfd GIT binary patch literal 1898 zcmV-w2bK7VP)PS&+k>eS5@~O7WLfN z#z>r1)zQ7@JLlXpd}L<$FKLxT1mO7i`0((sD2l48LI|y1MB+H^bUOWhKS`1&t~@IA z1b~^D88e&NXf)c{*(uAi)iCqP{SZP3!OTRY*Xx}A^;#TU>54SHnYXW#g&y6RSh99^HTs$tZ#2`udlC*h;z;d0}zWj3TB}R zFn|flWT07)I)DiT%x1GFidI)w^E?kB5K$Y-9N@(I#>U3x=4P6v0K6d;BWwP3q;Kx$ z_a^B<71hQG`=w-hwO4#}p&VpZ25mf4sA(GS{pHJ-yWQ>y2H8Vl=B=%*jg5^g%R(>^ zpsL+&Chb!3l!F8yFZyq(Lj5wj%J{QS=1FV`-RXv|gz1JiLuH$O`+tkPjc%tTC8 zFh=;G>b&~)rPnv3Ze$)5$8pm%o12^M8_WSlqmlRCIcLNj^|#;a|88fwA6w$=xgmY{ z3@O6{xx>xRAXX4z=JADR>E-A6R2)3HpZxQ}ojuAO`G7P{_xJaw(g~evn6dlC zz2#Zsoia0v$b$zDh=_^k=;$bf;GAP-3;wnBGbR$umb>)D6^v`t0WV%eKS2{4Wl=yZli zQ3XsrIx+ew0zib-1)S^QbOHd(2!?BCD8SOl{xn)BYg1tn0Wg_N*vzV`a?TO4WBc1d zR;d)!V2&CN+Nj4+MfR07QZ`!jIp;_5>jO?C-d9fIKE* zu-I^;zq+&~{XOY%# z5#czF=Rpv~PA_$85Q)&-ghCj?bBuPEs6ZGZPzDp6%=1G+xi%NO7D!e2q_RG|zaIE4tDSi`uGlo$I_gH^9nL%#7Qm zwy>};o6WrUPPl}7=~DWQ)4QejGoqQdqOrEijv55RG{6antne1h4gzPPym{gH%CI-} zh+{V%kK;IQ>CAj(647`(-rL(-UVf&o8)A;F{=@y^)=no$GbJ3?U|1qpi10xPAv8v@ zz){Gm34shDAOyfb1Qrm7fW`-5YB|H<;o)E~NYk{<`O}$Qi%}HC?(S}yru}~3 zIcGo!W-x#-pHg5VCNi_KEDsJ2h-fevsOqEEp9DC8X(Ky0I4FujRns(`7d6kT&U;_i z^>jKd%aVwCy  " + text, + type: 'success', + layout: "bottomRight", + timeout: 1000 + }).show(); +} + +function failNoty(jqXHR) { + closeNoty(); + failedNote = new Noty({ + text: "  Error status: " + jqXHR.status, + type: "error", + layout: "bottomRight" + }); + failedNote.show() +} \ No newline at end of file diff --git a/src/main/webapp/resources/js/topjava.users.js b/src/main/webapp/resources/js/topjava.users.js new file mode 100644 index 000000000..10a3d301c --- /dev/null +++ b/src/main/webapp/resources/js/topjava.users.js @@ -0,0 +1,47 @@ +const userAjaxUrl = "admin/users/"; + +// https://stackoverflow.com/a/5064235/548473 +const ctx = { + ajaxUrl: userAjaxUrl +}; + +// $(document).ready(function () { +$(function () { + makeEditable( + $("#datatable").DataTable({ + "paging": false, + "info": true, + "columns": [ + { + "data": "name" + }, + { + "data": "email" + }, + { + "data": "roles" + }, + { + "data": "enabled" + }, + { + "data": "registered" + }, + { + "defaultContent": "Edit", + "orderable": false + }, + { + "defaultContent": "Delete", + "orderable": false + } + ], + "order": [ + [ + 0, + "asc" + ] + ] + }) + ); +}); \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/MatcherFactory.java b/src/test/java/ru/javawebinar/topjava/MatcherFactory.java index 40f3d8c22..15e01e155 100644 --- a/src/test/java/ru/javawebinar/topjava/MatcherFactory.java +++ b/src/test/java/ru/javawebinar/topjava/MatcherFactory.java @@ -7,6 +7,7 @@ import java.io.UnsupportedEncodingException; import java.util.List; +import java.util.function.BiConsumer; import static org.assertj.core.api.Assertions.assertThat; @@ -17,21 +18,36 @@ * Support converting json MvcResult to objects for comparation. */ public class MatcherFactory { + + public static Matcher usingAssertions(Class clazz, BiConsumer assertion, BiConsumer, Iterable> iterableAssertion) { + return new Matcher<>(clazz, assertion, iterableAssertion); + } + + public static Matcher usingEqualsComparator(Class clazz) { + return usingAssertions(clazz, + (a, e) -> assertThat(a).isEqualTo(e), + (a, e) -> assertThat(a).isEqualTo(e)); + } + public static Matcher usingIgnoringFieldsComparator(Class clazz, String... fieldsToIgnore) { - return new Matcher<>(clazz, fieldsToIgnore); + return usingAssertions(clazz, + (a, e) -> assertThat(a).usingRecursiveComparison().ignoringFields(fieldsToIgnore).isEqualTo(e), + (a, e) -> assertThat(a).usingRecursiveFieldByFieldElementComparatorIgnoringFields(fieldsToIgnore).isEqualTo(e)); } public static class Matcher { private final Class clazz; - private final String[] fieldsToIgnore; + private final BiConsumer assertion; + private final BiConsumer, Iterable> iterableAssertion; - private Matcher(Class clazz, String... fieldsToIgnore) { + private Matcher(Class clazz, BiConsumer assertion, BiConsumer, Iterable> iterableAssertion) { this.clazz = clazz; - this.fieldsToIgnore = fieldsToIgnore; + this.assertion = assertion; + this.iterableAssertion = iterableAssertion; } public void assertMatch(T actual, T expected) { - assertThat(actual).usingRecursiveComparison().ignoringFields(fieldsToIgnore).isEqualTo(expected); + assertion.accept(actual, expected); } @SafeVarargs @@ -40,7 +56,7 @@ public final void assertMatch(Iterable actual, T... expected) { } public void assertMatch(Iterable actual, Iterable expected) { - assertThat(actual).usingRecursiveFieldByFieldElementComparatorIgnoringFields(fieldsToIgnore).isEqualTo(expected); + iterableAssertion.accept(actual, expected); } public ResultMatcher contentJson(T expected) { diff --git a/src/test/java/ru/javawebinar/topjava/MealTestData.java b/src/test/java/ru/javawebinar/topjava/MealTestData.java index c2697db04..6ee8b66dd 100644 --- a/src/test/java/ru/javawebinar/topjava/MealTestData.java +++ b/src/test/java/ru/javawebinar/topjava/MealTestData.java @@ -1,6 +1,7 @@ package ru.javawebinar.topjava; import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.to.MealTo; import java.time.Month; import java.time.temporal.ChronoUnit; @@ -11,6 +12,7 @@ public class MealTestData { public static final MatcherFactory.Matcher MEAL_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(Meal.class, "user"); + public static MatcherFactory.Matcher TO_MATCHER = MatcherFactory.usingEqualsComparator(MealTo.class); public static final int NOT_FOUND = 10; public static final int MEAL1_ID = START_SEQ + 3; diff --git a/src/test/java/ru/javawebinar/topjava/UserTestData.java b/src/test/java/ru/javawebinar/topjava/UserTestData.java index 285964552..e271179f2 100644 --- a/src/test/java/ru/javawebinar/topjava/UserTestData.java +++ b/src/test/java/ru/javawebinar/topjava/UserTestData.java @@ -5,11 +5,21 @@ import java.util.Collections; import java.util.Date; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static ru.javawebinar.topjava.MealTestData.*; import static ru.javawebinar.topjava.model.AbstractBaseEntity.START_SEQ; public class UserTestData { public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(User.class, "registered", "meals"); + public static MatcherFactory.Matcher USER_WITH_MEALS_MATCHER = + MatcherFactory.usingAssertions(User.class, +// No need use ignoringAllOverriddenEquals, see https://assertj.github.io/doc/#breaking-changes + (a, e) -> assertThat(a).usingRecursiveComparison().ignoringFields("registered", "meals.user").isEqualTo(e), + (a, e) -> { + throw new UnsupportedOperationException(); + }); public static final int USER_ID = START_SEQ; public static final int ADMIN_ID = START_SEQ + 1; @@ -20,6 +30,11 @@ public class UserTestData { public static final User admin = new User(ADMIN_ID, "Admin", "admin@gmail.com", "admin", Role.ADMIN, Role.USER); public static final User guest = new User(GUEST_ID, "Guest", "guest@gmail.com", "guest"); + static { + user.setMeals(meals); + admin.setMeals(List.of(adminMeal2, adminMeal1)); + } + public static User getNew() { return new User(null, "New", "new@gmail.com", "newPass", 1555, false, new Date(), Collections.singleton(Role.USER)); } diff --git a/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java index d8a1f4106..3638e07e9 100644 --- a/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java +++ b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java @@ -3,13 +3,10 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.test.context.ActiveProfiles; -import ru.javawebinar.topjava.MealTestData; -import ru.javawebinar.topjava.UserTestData; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.service.AbstractUserServiceTest; import ru.javawebinar.topjava.util.exception.NotFoundException; -import static ru.javawebinar.topjava.MealTestData.MEAL_MATCHER; import static ru.javawebinar.topjava.Profiles.DATAJPA; import static ru.javawebinar.topjava.UserTestData.*; @@ -17,9 +14,8 @@ class DataJpaUserServiceTest extends AbstractUserServiceTest { @Test void getWithMeals() { - User admin = service.getWithMeals(ADMIN_ID); - USER_MATCHER.assertMatch(admin, UserTestData.admin); - MEAL_MATCHER.assertMatch(admin.getMeals(), MealTestData.adminMeal2, MealTestData.adminMeal1); + User actual = service.getWithMeals(ADMIN_ID); + USER_WITH_MEALS_MATCHER.assertMatch(actual, admin); } @Test diff --git a/src/test/java/ru/javawebinar/topjava/web/AbstractControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/AbstractControllerTest.java index 5c395ad19..7e08e3dde 100644 --- a/src/test/java/ru/javawebinar/topjava/web/AbstractControllerTest.java +++ b/src/test/java/ru/javawebinar/topjava/web/AbstractControllerTest.java @@ -1,7 +1,9 @@ package ru.javawebinar.topjava.web; -import org.springframework.test.context.ActiveProfiles; +import org.junit.jupiter.api.Assumptions; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -28,6 +30,9 @@ public abstract class AbstractControllerTest { private static final CharacterEncodingFilter CHARACTER_ENCODING_FILTER = new CharacterEncodingFilter(); + @Autowired + public Environment env; + static { CHARACTER_ENCODING_FILTER.setEncoding("UTF-8"); CHARACTER_ENCODING_FILTER.setForceEncoding(true); @@ -38,6 +43,10 @@ public abstract class AbstractControllerTest { @Autowired private WebApplicationContext webApplicationContext; + public void assumeDataJpa() { + Assumptions.assumeTrue(env.acceptsProfiles(org.springframework.core.env.Profiles.of(Profiles.DATAJPA)), "DATA-JPA only"); + } + @PostConstruct private void postConstruct() { mockMvc = MockMvcBuilders diff --git a/src/test/java/ru/javawebinar/topjava/web/ResourceControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/ResourceControllerTest.java new file mode 100644 index 000000000..244399662 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/ResourceControllerTest.java @@ -0,0 +1,20 @@ +package ru.javawebinar.topjava.web; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ResourceControllerTest extends AbstractControllerTest { + + @Test + void resources() throws Exception { + perform(get("/resources/css/style.css")) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.valueOf("text/css"))) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/RootControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/RootControllerTest.java index 6f15e2427..6832eaea9 100644 --- a/src/test/java/ru/javawebinar/topjava/web/RootControllerTest.java +++ b/src/test/java/ru/javawebinar/topjava/web/RootControllerTest.java @@ -8,11 +8,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static ru.javawebinar.topjava.MealTestData.meals; import static ru.javawebinar.topjava.UserTestData.*; +import static ru.javawebinar.topjava.util.MealsUtil.getTos; class RootControllerTest extends AbstractControllerTest { @@ -32,4 +31,14 @@ public void assertion(List actual) throws AssertionError { } )); } + + @Test + void getMeals() throws Exception { + perform(get("/meals")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(view().name("meals")) + .andExpect(forwardedUrl("/WEB-INF/jsp/meals.jsp")) + .andExpect(model().attribute("meals", getTos(meals, SecurityUtil.authUserCaloriesPerDay()))); + } } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/meal/MealRestControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/meal/MealRestControllerTest.java new file mode 100644 index 000000000..82bb7cd71 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/meal/MealRestControllerTest.java @@ -0,0 +1,98 @@ +package ru.javawebinar.topjava.web.meal; + + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.service.MealService; +import ru.javawebinar.topjava.util.exception.NotFoundException; +import ru.javawebinar.topjava.web.AbstractControllerTest; +import ru.javawebinar.topjava.web.json.JsonUtil; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static ru.javawebinar.topjava.MealTestData.*; +import static ru.javawebinar.topjava.UserTestData.USER_ID; +import static ru.javawebinar.topjava.UserTestData.user; +import static ru.javawebinar.topjava.util.MealsUtil.createTo; +import static ru.javawebinar.topjava.util.MealsUtil.getTos; + +class MealRestControllerTest extends AbstractControllerTest { + + private static final String REST_URL = MealRestController.REST_URL + '/'; + + @Autowired + private MealService mealService; + + @Test + void get() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + MEAL1_ID)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(MEAL_MATCHER.contentJson(meal1)); + } + + @Test + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL + MEAL1_ID)) + .andExpect(status().isNoContent()); + assertThrows(NotFoundException.class, () -> mealService.get(MEAL1_ID, USER_ID)); + } + + @Test + void update() throws Exception { + Meal updated = getUpdated(); + perform(MockMvcRequestBuilders.put(REST_URL + MEAL1_ID).contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updated))) + .andExpect(status().isNoContent()); + + MEAL_MATCHER.assertMatch(mealService.get(MEAL1_ID, USER_ID), updated); + } + + @Test + void createWithLocation() throws Exception { + Meal newMeal = getNew(); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newMeal))) + .andExpect(status().isCreated()); + + Meal created = MEAL_MATCHER.readFromJson(action); + int newId = created.id(); + newMeal.setId(newId); + MEAL_MATCHER.assertMatch(created, newMeal); + MEAL_MATCHER.assertMatch(mealService.get(newId, USER_ID), newMeal); + } + + @Test + void getAll() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(TO_MATCHER.contentJson(getTos(meals, user.getCaloriesPerDay()))); + } + + @Test + void getBetween() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + "filter") + .param("startDate", "2020-01-30").param("startTime", "07:00") + .param("endDate", "2020-01-31").param("endTime", "11:00")) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(TO_MATCHER.contentJson(createTo(meal5, true), createTo(meal1, false))); + } + + @Test + void getBetweenAll() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + "filter?startDate=&endTime=")) + .andExpect(status().isOk()) + .andExpect(TO_MATCHER.contentJson(getTos(meals, user.getCaloriesPerDay()))); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/user/AdminRestControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/user/AdminRestControllerTest.java index 7563fe05c..5b6b17ccf 100644 --- a/src/test/java/ru/javawebinar/topjava/web/user/AdminRestControllerTest.java +++ b/src/test/java/ru/javawebinar/topjava/web/user/AdminRestControllerTest.java @@ -5,7 +5,6 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import ru.javawebinar.topjava.UserTestData; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.service.UserService; import ru.javawebinar.topjava.util.exception.NotFoundException; @@ -53,7 +52,7 @@ void delete() throws Exception { @Test void update() throws Exception { - User updated = UserTestData.getUpdated(); + User updated = getUpdated(); perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) .contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(updated))) @@ -64,7 +63,7 @@ void update() throws Exception { @Test void createWithLocation() throws Exception { - User newUser = UserTestData.getNew(); + User newUser = getNew(); ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) .contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(newUser))) @@ -84,4 +83,14 @@ void getAll() throws Exception { .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(USER_MATCHER.contentJson(admin, guest, user)); } + + @Test + void getWithMeals() throws Exception { + assumeDataJpa(); + perform(MockMvcRequestBuilders.get(REST_URL + ADMIN_ID + "/with-meals")) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_WITH_MEALS_MATCHER.contentJson(admin)); + } } \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/user/ProfileRestControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/user/ProfileRestControllerTest.java index e8882742d..5ae339943 100644 --- a/src/test/java/ru/javawebinar/topjava/web/user/ProfileRestControllerTest.java +++ b/src/test/java/ru/javawebinar/topjava/web/user/ProfileRestControllerTest.java @@ -45,4 +45,14 @@ void update() throws Exception { USER_MATCHER.assertMatch(userService.get(USER_ID), updated); } + + @Test + void getWithMeals() throws Exception { + assumeDataJpa(); + perform(MockMvcRequestBuilders.get(REST_URL + "/with-meals")) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_WITH_MEALS_MATCHER.contentJson(user)); + } } \ No newline at end of file From 3e876d72417a2f69b7c92140682947da08a99141 Mon Sep 17 00:00:00 2001 From: art94timer Date: Sat, 15 Oct 2022 18:14:59 +0300 Subject: [PATCH 17/19] prepared HW09 --- config/messages/app.properties | 13 +- config/messages/app_ru.properties | 13 +- doc/lesson09.md | 482 ++++++++++++++++++ pom.xml | 7 + .../javawebinar/topjava/AuthorizedUser.java | 36 ++ .../java/ru/javawebinar/topjava/HasId.java | 19 + .../ru/javawebinar/topjava/SpringMain.java | 5 + .../topjava/model/AbstractBaseEntity.java | 15 +- .../ru/javawebinar/topjava/model/Meal.java | 4 - .../ru/javawebinar/topjava/model/MealTo.java | 9 +- .../ru/javawebinar/topjava/model/Role.java | 12 +- .../ru/javawebinar/topjava/model/User.java | 2 +- .../topjava/service/UserService.java | 41 +- .../ru/javawebinar/topjava/to/BaseTo.java | 24 + .../ru/javawebinar/topjava/to/UserTo.java | 82 +++ .../javawebinar/topjava/util/MealsUtil.java | 1 - .../ru/javawebinar/topjava/util/UserUtil.java | 26 + .../topjava/util/ValidationUtil.java | 18 +- .../topjava/web/RootController.java | 22 +- .../javawebinar/topjava/web/SecurityUtil.java | 27 +- .../topjava/web/meal/JspMealController.java | 70 --- .../topjava/web/meal/MealUIController.java | 50 ++ .../web/user/AbstractUserController.java | 19 + .../topjava/web/user/AdminRestController.java | 7 + .../topjava/web/user/AdminUIController.java | 36 +- .../web/user/ProfileRestController.java | 5 +- src/main/resources/spring/spring-mvc.xml | 28 +- src/main/resources/spring/spring-security.xml | 22 +- .../WEB-INF/jsp/fragments/bodyHeader.jsp | 4 +- src/main/webapp/WEB-INF/jsp/index.jsp | 23 - src/main/webapp/WEB-INF/jsp/login.jsp | 77 +++ src/main/webapp/WEB-INF/jsp/mealForm.jsp | 35 -- src/main/webapp/WEB-INF/jsp/meals.jsp | 167 ++++-- src/main/webapp/WEB-INF/jsp/users.jsp | 23 +- src/main/webapp/resources/css/style.css | 39 +- .../webapp/resources/js/topjava.common.js | 61 ++- src/main/webapp/resources/js/topjava.meals.js | 52 ++ src/main/webapp/resources/js/topjava.users.js | 67 ++- src/main/webapp/test.html | 13 + .../java/ru/javawebinar/topjava/TestUtil.java | 23 + .../topjava/service/AbstractServiceTest.java | 2 +- .../service/AbstractUserServiceTest.java | 10 +- .../topjava/web/AbstractControllerTest.java | 4 + .../topjava/web/RootControllerTest.java | 28 +- .../web/user/AdminRestControllerTest.java | 44 +- .../web/user/ProfileRestControllerTest.java | 25 +- 46 files changed, 1434 insertions(+), 358 deletions(-) create mode 100644 doc/lesson09.md create mode 100644 src/main/java/ru/javawebinar/topjava/AuthorizedUser.java create mode 100644 src/main/java/ru/javawebinar/topjava/HasId.java create mode 100644 src/main/java/ru/javawebinar/topjava/to/BaseTo.java create mode 100644 src/main/java/ru/javawebinar/topjava/to/UserTo.java create mode 100644 src/main/java/ru/javawebinar/topjava/util/UserUtil.java delete mode 100644 src/main/java/ru/javawebinar/topjava/web/meal/JspMealController.java create mode 100644 src/main/java/ru/javawebinar/topjava/web/meal/MealUIController.java delete mode 100644 src/main/webapp/WEB-INF/jsp/index.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/login.jsp delete mode 100644 src/main/webapp/WEB-INF/jsp/mealForm.jsp create mode 100644 src/main/webapp/resources/js/topjava.meals.js create mode 100644 src/main/webapp/test.html create mode 100644 src/test/java/ru/javawebinar/topjava/TestUtil.java diff --git a/config/messages/app.properties b/config/messages/app.properties index fc7665a88..ed2dde3cb 100644 --- a/config/messages/app.properties +++ b/config/messages/app.properties @@ -1,4 +1,9 @@ app.title=Calories management +app.stackTitle=Application stack: +app.description=Java Enterprise project with registration/authorization and role-based access rights (USER, ADMIN). \ +Admin could create/edit/delete users, users - manage your profile and data (meals) via UI (AJAX) and REST with basic authorization. \ +Meals could be filtered by date and time. Meal record color depends on daily calories sum exceeding "Daily calorie limit" (editable user's profile parameter). \ +All REST interface covered with JUnit tests by Spring MVC Test и Spring Security Test. app.footer=Spring 5/JPA Enterprise (Topjava) internship application app.login=Login as @@ -26,7 +31,11 @@ meal.calories=Calories common.add=Add common.select=Select -common.delete=Delete -common.update=Update +common.deleted=Record deleted +common.saved=Record saved +common.enabled=Record enabled +common.disabled=Record disabled +common.errorStatus=Error status +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 5ea49005a..4df962942 100644 --- a/config/messages/app_ru.properties +++ b/config/messages/app_ru.properties @@ -1,4 +1,9 @@ app.title=Подсчет калорий +app.stackTitle=Стек технологий: +app.description=Java Enterprise проект с регистрацией/авторизацией и правами доступа на основе ролей (USER, ADMIN). \ +Администратор может создавать/редактировать/удалять пользователей, а пользователи - управлять своим профилем и данными (едой) через UI (по AJAX) и по REST интерфейсу с базовой авторизацией. \ +Возможна фильтрация еды по датам и времени. Цвет записи таблицы еды зависит от того, превышает ли сумма калорий за день норму (редактируемый параметр в профиле пользователя). \ +Весь REST интерфейс покрывается JUnit тестами, используя Spring MVC Test и Spring Security Test. app.footer=Приложение стажировки Spring 5/JPA Enterprise (Topjava) app.login=Зайти как @@ -26,7 +31,11 @@ meal.calories=Калории common.add=Добавить common.select=Выбрать -common.delete=Удалить -common.update=Обновить +common.deleted=Запись удалена +common.saved=Запись сохранена +common.enabled=Запись активирована +common.disabled=Запись деактивирована +common.errorStatus=Статус ошибки +common.confirm=Вы уверены? common.save=Сохранить common.cancel=Отменить \ No newline at end of file diff --git a/doc/lesson09.md b/doc/lesson09.md new file mode 100644 index 000000000..a12345c02 --- /dev/null +++ b/doc/lesson09.md @@ -0,0 +1,482 @@ +# Стажировка TopJava + +## Материалы занятия + +- **[Запускать браузер с чистым кэшем в режиме ингогнито](https://github.com/JavaOPs/topjava/wiki/IDEA#cache)** +- **При удалении файлов не забывайте делать clean: `mvn clean package`** + +## ![hw](https://cloud.githubusercontent.com/assets/13649199/13672719/09593080-e6e7-11e5-81d1-5cb629c438ca.png) Разбор домашнего задания HW8 + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 1. [HW8 + Optional 2,3](https://drive.google.com/file/d/1ZxmXrhz3K4V-mLkOOrH-JVtl5x0KSpIj) + +
    + Краткое содержание + +#### Перевод таблицы еды на Datatables + +- Для удаления и обновления еды мы будем использовать иконки - теперь мы можем удалить `delete` и `update` из файлов локализации. +- Создавать/редактировать еду будем в модальном окне - удаляем форму `mealForm.jsp` +- Для полей фильтрации будем использовать форму Bootstrap "Grid System", поэтому css стили для формы фильтра (`dl, dd, dt`) также можем удалить. + +Вместо того, чтобы в `makeEditable` вешать обработчики событий на все элементы страницы с классом `delete`, сделаем обработчик события прямо в JSP: `onclick="deleteRow(${user.id})"`. Функция будет +вызываться при нажатии на кнопку и в нее автоматически будет передаваться `id` пользователя или еды. + +> Возможно тут мой выбор расходится с распространенным, где положено отделять html от JavaScript. Я опять склоняюсь в сторону KISS. + +Для таблицы еды, в отличие от таблицы пользователей, требуется обновление с учетом параметров фильтрации (*Optional2*), поэтому мы используем различные стратегии обновления для этих таблиц. Функции +обновления таблицы инициализируются в контексте `ctx.updateTable` и вызываться в `topjava.common.js`. Из `updateTable` будем вызывать функцию `updateTableByData(data)`, которая обновляет таблицу +переданными ей данными. +В `topjava.users.js` код + +``` + updateTable: function () { + $.get(userAjaxUrl, updateTableByData); + } +``` + +через jQuery делает AJAX GET запрос и полученные данные автоматически передает в `updateTableByData`. Для еды (в`topjava.meals.js`) `updateTable` по `id=filter` получает форму фильтрации, с помощью jQuery `serialize()` +сериализует ее поля и отправляет запросом GET в `MealUIController#getBetween`. Отфильтрованную еду в коллбэке `done` передаем в `updateTableByData`. Функцию `ctx.updateTable()` вешаем на `onclick` +кнопки фильтрации в `meals.jsp`. И она же будет вызываться из `topjava.common.js` при любом обновлении таблицы. + +Вместо `MealJspController` используем `MealUiController`, он маппиться по URL `/profile/meals`, так как еда принадлежит конкретному пользователю (находится в его профиле). + +> **Внимание! Не делайте в выпускном проекте путь `/profile/...` к ресурсам, которые НЕ принадлежат пользователю.** + +`MealUiController` будет реализован так же, как и `MealRestController`, за некоторыми исключениями: + +- для создания или обновления еды будет использоваться метод `#createOrUpdate`, который принимает информацию о еде в параметрах запроса `@RequestParameter`, приходящих из формы +- авторизации у этих контроллеров будут отличаться (будет ниже в этом занятии) + +`meals.jsp` изменяем по аналогии с `users.jsp`. Отличие этих страниц - для еды будет использоваться форма фильтрации таблицы, которую создадим с помощью Bootstrap Grid System + +> [Bootstrap Grid System](https://getbootstrap.com/docs/4.6/layout/grid/) - экран разбивается на 12 колонок и для каждого элемента страницы мы можем задать сколько колонок он может занимать. Колонки можно настраивать (отступы и т.д...) + +По 3 колонки на `startDate` и `endDate` (`col-3`), затем будет 2 колонки отступа (`offset-2`), и далее по 2 колонки на `startTime` и `endTime` (`col-2`). + +### Кнопка сброса фильтра + +В форму фильтрации добавим кнопку очистки формы. При нажатии на нее будет вызываться функция `clearFilter()`. +В `$('#filter')[0].reset()` берем массив всех элементов с указанным `id=filter` (нам вернется массив из одного элемента - нашей формы) и сбрасываем все ее поля через `reset()`. После этого обновляем +таблицу еды без учета фильтрации. + +
    + +#### Apply 9_01_HW8.patch + +- [Grid system](https://getbootstrap.com/docs/4.1/layout/grid/) +- [Difference among col-* in Bootstrap](https://stackoverflow.com/a/19865627/548473) +- [Bootstrap forms](https://getbootstrap.com/docs/4.1/components/forms/) + +#### ![question](https://cloud.githubusercontent.com/assets/13649199/13672858/9cd58692-e6e7-11e5-905d-c295d2a456f1.png) Вопрос: + +> Можно ли было удаление делать без перезагрузки таблицы (удалением строки) и для редактирования брать данные со страницы, а не с сервера? + +В многопользовательском приложении принято при изменении данных подтягивать все изменения с базы, иначе может быть несогласованность базы и UI (например когда пользователей редактируют несколько +администраторов одновременно). Для еды доставать из базы данные при редактировании нет необходимости, но лучше делать все универсально. В таблице часто представлены не все данные, которые можно +редактировать. Дополнительная нагрузка на базу тут совсем небольшая. Для еды нам при каждом добавлении-удалении-редактировании еще необходимо пересчитывать превышение `excess`. + +#### Apply 9_02_HW8_clear_filter.patch + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 2. [HW8 Optional: enable/disable](https://drive.google.com/file/d/1-2ekRtwd60Cbqq4LPEQ_MOsqWcR7d04a) + +
    + Краткое содержание + +Сделаем в `UserService` метод `enable`, который принимает `boolean` (вкл./выкл. пользователя). В методе загружаем из базы нужного пользователя, устанавливаем ему значение `enabled` и записываем +обновленного пользователя обратно в базу. `repository.save(user)` нужен только для JDBC реализации, в JPA изменения сущностей в `@Transactional` методах попадают в базу автоматически. Метод помечен +аннотацией `@Transactional`, чтобы все действия в методе выполнялись в одной транзакции. + +> Внимание! Не забываем в выпускных проектах ставить `@Transactional` над методами сервиса, где есть несколько обращений к базе. + +Теперь можно вызвать этот метод из контроллеров. В отличие от UI, в REST контроллере используем `@PatchMapping` - сущность изменяется не полностью, а частично. + +На странице `users.jsp` для строки таблицы пользователей добавляем атрибут `data-userEnabled`. Для случая, когда этот атрибут будет `false`, в css добавим еще один стиль - теперь строки для неактивных +пользователей будут становиться полупрозрачными. +На событие `onlick` на чекбокс вешаем функцию `enable($(this), ${user.id})`. В эту функцию передается `this` элемент - чекбокс и `id` пользователя. В функции получаем галочку флага `:checked`, и +передаем ее в POST запросе в контроллер. После успешного выполнения запроса меняем для текущей строки таблицы атрибут `data-userEnabled`, чтобы изменился стиль ее отображения и выводим уведомление. + +> Добавил коллбэк `fail` - если обновить базу не удалось, возвращаем флаг в прежнее положение. + +### Тесты для REST контроллера и сервиса + +Создадим тест `AbstractUserServiceTest#enable`: в нем сначала деактивируем пользователя, получаем его из базы и проверяем что он действительно не активен. Затем активируем этого же пользователя и +снова проверяем - теперь он должен быть активным. + +В тесте `AdminRestControllerTest#enable` делаем PATCH запрос деактивации, проверяем статус ответа и отсутствие контента. После чего получаем этого пользователя из базы и проверяем, что он +действительно деактивирован. + +
    + +#### Apply 9_03_HW8_enable_disable.patch + +> В тестах сервисов `AbstractServiceTest` базу восстанавливаем после теста (при старте приложения она популируется, если последний тест в сервисах ее меняет, тесты контроллеров могут не пройти) + +Примечание: [в публичном API выполнять PATH с параметрами нельзя](https://stackoverflow.com/questions/64390768/can-i-use-query-parameters-with-http-patch-method). But in a situation where your API is +only used by front ends that you control (for example, only called via your java script client downloaded from your web servers), and if you don't need to use any intermediate components (like a web +cache) in the middle, then you might get away with it (данные у нас не кешируются). + +## Занятие 9: + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 3. Spring Binding + +
    + Краткое содержание + +Spring Data Binding - функциональность Spring преобразовывать данные в параметрах или теле запроса в экземпляры класса. Формат данных может быть как `application/x-www-form-urlencoded` (из html формы), так и +JSON. +Для удобства обмена данными между frontend и сервером применяется объект (и паттерн) Transfer Objects. Объект TO содержит только те поля, которые нужны UI и в процессе работы приложения происходит +конвертация entity в TO и обратно. +Создадим объект `UserTo` только с теми полями, которые может редактировать администратор и пользователь +(ввод ролей админом у нас не будет реализован, по окончанию стажировки можете доработать наше приложение самостоятельно). В `AdminUIController#create` вместо набора параметров будем принимать `UserTo` + +- Spring автоматически извлечет из запроса нужные данные и, используя отражение, сделает из них объект. Для этого **объект должен иметь конструктор без параметров и сеттеры**. + +
    + +#### Apply 9_04_binding.patch + +> Перенес `ru.javawebinar.topjava.util.MealsUtil.DEFAULT_CALORIES_PER_DAY` в `ru.javawebinar.topjava.util.UserUtil` + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 4. Реализация update + +
    + Краткое содержание + +Записи в таблице будут обновляться с помощью js функции `updateRow`. В этой функции: + +- Запрашиваем у сервера данные о редактируемой сущности (на случай если к этому моменту данные уже были кем-то изменены). +- Функцией `.each` проходимся по всем полям принятых JSON данных, ищем в форме модального окна (`form = $('#detailsForm')`) соответствующие `input` элементы: + `form.find("input[name='" + key + "']")` +- Присваиваем полям значения `.val(value)`. Таким образом мы заполняем форму актуальными данными пользователя. +- Открываем модальное окно с нашей формой + +Переименовываем `AdminUIController#create` в `createOrUpdate`. Если в пришедшем объекте `id = null`, в базе создается новая сущность, иначе обновляем существующую с пришедшим `id`. +Дополнительно создаем `UserUtil#updateFromTo`, который обновляет сущность данными TO. + +В `topjava.common.js` методе `save` после обновления или добавления пользователя в базу в коллбэке `done` повторно запрашиваем с сервера список всех пользователей и обновляем таблицу. + +
    + +#### Apply 9_05_update.patch + +> - Сделал интерфейс `HasId` от которого наследуются `BaseTo` и `AbstractBaseEntity` +> - Сделал проверку `id` в `ValidationUtil` на основе `HasId` +> - Сделал в `ProfileRestController` обновление своего профиля через `UserTo` (нельзя изменять себе роли) и поправил тест + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 5. Spring Validation + +
    + Краткое содержание + +Проверка на корректность данных задается с помощью аннотаций валидации над полями объекта. Для большинства таких аннотаций в скобках можно указать дополнительные параметры, по которым будет +осуществляться проверка. Также можно переопределить стандартное уведомление, которое будет сообщать о неверных данных. Эти аннотации нельзя использовать непосредственно при вводе данных, так как формы +ввода данных находится на стороне клиента, а проверка происходит на сервере. +В `AdminUIController#createOrUpdate` перед `UserTo` укажем аннотацию `@Valid` (запустить функционал валидации) и добавим параметр `BindingResult` - результат валидации. Если в результате есть ошибки, +склеим их в строку и отдадим клиенту со статусом `UNPROCESSABLE_ENTITY`. На стороне клиента в `failedNote` будет выведено сообщение об ошибке (обработчик всех ошибок по AJAX задаем +в `$(document).ajaxError`), к тексту уведомления добавим ответ сервера. + +
    + +#### Apply 9_06_validation.patch + +> - `responseJSON` не выводится в случае его отсутствия (например при попытке добавить пользователя с дублирующимся email) + +- Spring Validation. +- Bean Validation +- Валидация формы по AJAX. +- JSR-303, 349 +- @Valid @RequestBody + Error handling +- [Java Bean Validation Basics](https://www.baeldung.com/javax-validation) + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 6. Перевод DataTables на Ajax + +
    + Краткое содержание + +В методе `DataTables` есть параметр конфигурации `ajax.url`. Если он присутствует, по этому URL выполнится AJAX запрос и таблица будет инициализирована полученными данными. + +- При этом JSP больше не требуется данные для таблицы (модели в контроллерах) +- Отрисовывать таблицу в JSP тоже больше не нужно, она строится автоматически, используя конфигурацию `DataTables`. +- В конфигурацию `DataTables` добавляем `ajax.url` - ендпойнт, по которому запрашиваются данные +- В `columns` добавляем метод `render` - функция отображения содержимого ячейки таблицы. Так как с сервера дата `registered` приходит в формате ISO, при отображении содержимого ячейки нужно + предварительно произвести ее конвертацию. +- Функции отрисовки кнопок удаления `renderDeleteBtn` и редактирования `renderEditBtn` будут общими для страниц пользователей и еды. +- В конфигурации `DataTable` можно настроить функцию отображения всей строки `createdRow`. Если пользователь в этой строке неактивен, задаем ей соответствующий css стиль. + +
    + +#### Apply 9_07_datatable_via_ajax.patch + +> - Перешли на [параметры Datatables в формате 1.10](https://datatables.net/upgrade/1.10-convert) +> - В `makeEditable()` больше нет манипуляций c DOM, которые требуются делать ПОСЛЕ отработки плагина `datatables`, поэтому нам не обязательно вызывать ее в коллбэке `initComplete`. Отображения строки меняем в параметре конфигурации `createdRow` + +- [DataTables Ajax](https://datatables.net/manual/ajax) + +#### ![question](https://cloud.githubusercontent.com/assets/13649199/13672858/9cd58692-e6e7-11e5-905d-c295d2a456f1.png) Вопрос: + +> Что за дополнительный параметр (который каждый раз инкрементируется) появляется при запросе datatables данных по ajax (например `http://localhost:8080/topjava/ajax/admin/users/?_=1496156621129`) ? + +Это защита `datatables` от кэширования запроса браузером. При изменении js, css и других статический ресурсов, также полезно добавлять в запрос версию, чтобы данные не брались из кэша (особенно когда +приложение уже вышло в продакшен). + +#### Apply 9_08_js_i18n.patch + +> - Добавил [простую интернационализацию в JavaScript](https://stackoverflow.com/questions/6218970/resolving-springmessages-in-javascript-for-i18n-internationalization). + +- на стороне сервера формируется `i18n` JavaScript массив с значениями, который затем используется для интернационализации в браузере + +> - в модальном окне заголовок подменяется через `$('#modalTitle').html(..title)` + +> Для тестирования локали +> - [можно поменять `Accept-Language`](https://stackoverflow.com/questions/7769061/how-to-add-custom-accept-languages-to-chrome-for-pseudolocalization-testing). Для хрома в `chrome://settings/languages` перетащить нужную локаль наверх. +> - можно поставить [Locale Switcher](https://chrome.google.com/webstore/detail/locale-switcher/kngfjpghaokedippaapkfihdlmmlafcc) хром плагин + +- JavaScript internationalization + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 7. Форма логина / логаут. + +
    + Краткое содержание + +Добавляем в `spring-security.xml` еще одну security-конфигурацию ``. URL `/admin/**` будет доступен только с ролью `ADMIN`, все остальные URL будут доступны только аутентифицированным +пользователям. В конфигурации указываем, что аутентификация будет проходить через Spring стандартные login и logout формы. +`RootControllerTest` перестал работать - Spring Security при каждом запросе к контроллеру будет делать перенаправление на страницу `login` - вместо ожидаемого в тестах статуса ответа HTTP.200 с +сервера будет возвращаться ответ со статусом HTTP.302 - redirect. Исправим тесты, указав ожидаемый `forwardedUrl`. + +#### Своя страница login + +Страница логина должна быть доступна для любого не аутентифицированного пользователя. Для этого в `spring-security.xml` добавляем путь `"/login"` с доступом для всех: `permitAll`. +И настраиваем `form-login`: + +- указываем ссылку на страницу логина; +- стандартные страницы, на которые будет осуществляться переход после успешного или неуспешного логина +- `login-processing-url` - это путь, по которому Spring будет обрабатывать запросы на сервер от формы логина. + +Cоздадим собственную страницу логина по Bootstrap шаблону - `login.jsp`. На ней расположена форма логина, в `action` которой указываем `login-processing-url` - путь к обработке Spring Security POST +запроса. +На странице сделаем элемент для отображения информации об ошибке в случае неправильных аутентификационных данных. Spring Security кладет в HTTP сессию сообщения об ошибке и при неуспешном +логине (`authentication-failure-url="/login?error=true"`) оно отображается на странице. +В `RootController#root` перенаправим запросы пользователей к руту ("/") на страницу еды: `redirect:meals`. Чтобы такие запросы обрабатывались корректно и при обращении к корню происходил редирект, +нужно удалить или переименовать `index.html/index.jsp`. +И еще добавим пример обработки статических ресурсов - `test.html`. Чтобы обратиться к нему из браузера, в `spring-mvc.xml` добавим ``, который мапить запросы к +статическим html страницам. +В `RootController` добавляем метод, который будет обрабатывать запросы по url-паттерну "/login" и перенаправлять их на страницу `login.jsp`. Информацию о неуспешной аутентификации или сообщения вместо +атрибутов передаем в параметрах запроса (`param.error/message`). + +
    + +#### Apply 9_09_min_form_login.patch + +> Добавил функциональность logout + +- [Минимальный form-login](https://docs.spring.io/spring-security/reference/servlet/configuration/xml-namespace.html#ns-minimal) +- Migrating <form-login> + +#### ![question](https://cloud.githubusercontent.com/assets/13649199/13672858/9cd58692-e6e7-11e5-905d-c295d2a456f1.png) Мои вопросы: + +- Почему при логине как admin еда отдаются для user? +- Почему при логине как user не отображается список пользователей? +- Почему еда не редактируется? + +> Подсказка: поглядите вкладку Network в браузере. + +#### Apply 9_10_jsp_form_login.patch + +> Рефакторинг +> - В `login.jsp` вместо атрибутов достаю параметры запроса (`param.error/message`). +> - Сделал i18n описания приложения +> - При нажатии кнопок `Зайти как ...` сделал вход в приложение + +- [Собственный form-login](https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html#servlet-authentication-form-custom) + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 8. Реализация собственного провайдера авторизации. + +
    + Краткое содержание + +#### Реализация собственного провайдера авторизации. + +Сейчас пользователи приложения и их аутентификационные данные жестко прописаны в конфигурации `spring-security.xml`, приемлимо только для тестового использования. +Чтобы получать данные креденшелов из базы вместо простого `user-service` настроим `jdbc-user-service`. +Для этого прямо в конфигурации указываем SQL запросы, которые Spring будет выполнять к базе для получения креденшелов и данных аутентифицированного пользователя. +Если аутентификация прошла успешно, Spring Security в `ThreadLocal` (стратегия хранения по умолчанию) сохраняет для текущего потока объект `Authentication`. Данные аутентифицированного пользователя +можно достать из `ThreadLocal` с помощью `SecurityContextHolder.getContext().getAuthentication()`. +Раньше в проекте для получения этих данных использовался утильный класс `LoggedUser`, теперь он переименован в `SecurityUtil`. В этом классе определены методы доступа к залогированному пользователю: + +- `safeGet()` - возвращается или `AuthorizedUser` или `null`, если аутентифицированного пользователя нет. + +Заменим `jdbc_user_service` и SQL в конфигурации `spring-security.xml` кодом Java: в `` +задаем бин, который реализует интерфейс Spring Security `UserDetailsService` и реализуем его метод `#loadUserByUserName`. В этот метод передается значение `username` из формы логина - в нашем +приложении это `email`. Если через `UserRepository#getByEmail` пользователь не найдется в базе, выбросим стандартное Spring Security исключение `UsernameNotFoundException`. Метод `#loadUserByUserName` +должен возвратить класс - данные аутентифицированного пользователя - который имплементирует Spring Security интерфейс `UserDetails`. Вместо самостоятельной реализации всех методов +интерфейса `UserDetails` проще всего сделать класс (`AuthorizedUser`), отнаследовав его от стандартной Spring Security имплементации этого +интерфейса `org.springframework.security.core.userdetails.User` +и в конструкторе передав ему все необходимые данные. +Роли он принимает как `Collection authorities`, поэтому `enum Role` отнаследуем от `GrantedAuthority` и реализуем его метод `getAuthority()`: +[права на основе ролей принято задавать с префиксом "ROLE_"](https://stackoverflow.com/a/19542316/548473). +Класс `AuthorizedUser` задает в нашем приложении аутентифицированного пользователя и мы будем хранить в нем `UserTo` - данные, которых нет в +стандартном `org.springframework.security.core.userdetails.User`, в частности `id` и `caloriesPerDay`. +Есть еще много разных способов реализации `UserDetails`, которые можно найти в интернете. На мой взгляд наше текущее решение самое простое. + +Еще - объект `AuthorizedUser` будет хранится в сессии (про нее видео ниже) и для этого ему требуется сериализация средствами Java. Это наследование его и всех классов-полей от маркерного +интерфейса `Serializable` и необязательный, но желательный `serialVersionUID`. + +> **Будьте внимательны в выпускных проектах с `Serializable`. Им нужно помечать ТОЛЬКО объекты, которые будут храниться в сессии** + +
    + +#### Apply 9_11_auth_via_user_service.patch + +> - В `UserService` добавил `@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)`, т.к. без этой аннотации для кэширования создается прокси над интерфейсом `UserDetailsService` (см. следующее видео по типам проксирования Spring). Можете проверить, что без этой аннотации приложение не поднимется. +> - `GrantedAuthority` это "разрешение" или "право". Если оно дается на основе роли, в Spring Security принято использовать префикс `ROLE_`. При этом сама роль не должна иметь префикс. +> - [Role and GrantedAuthority](https://stackoverflow.com/a/19542316/548473) + +- [UserDetailsService](https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/user-details-service.html) +- [serialVersionUID value](https://stackoverflow.com/a/605832/548473) + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 9. Принцип работы Spring Security. Проксирование. + +
    + Краткое содержание + +### Принцип работы Spring Security. Проксирование. + +Одна из основных функциональностей Spring Core, кроме IOC контейнера и связываний, это проксирование. Чаще всего оно задается аннотациями: при поднятии приложения и создании контекста на основе +пре-процессоров Spring анализирует аннотации бинов и, находя указание к проксированию, создает прокси (обертку) над исходным объектом. В контекст Spring попадает уже не исходный инстанс класса, а его +прокси. В Spring используется две стратегии проксирования: + +- на основе JDK 4 [Dynamic Proxy API](https://docs.oracle.com/javase/8/docs/technotes/guides/reflection/proxy.html) - прокси-объект создаются как обертка ко всем интерфейсам, которые имплементирует + сервис. +- на основе CGLib - когда нет интерфейсов, прокси объект создается на уровне модификации байт-кода класса. + +По умолчанию, если класс имплементирует интерфейсы, проксирование происходит по стратегии Dynamic Proxy и в прокси мы имеем только методы интерфейсов. Стратегию проксирования можно поменять на CGLib, задав явно в +конфигурациях параметра `proxy-target-class` или, как сделали мы, аннотацию `@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)`. В результате прокси нашего `UserService` сделано через CGLib ив нем доступны все его методы. +Второй путь - создать и реализовать интерфейс, в котором есть всем методы класса. + +Работа Spring Security основывается на цепочке Security-фильтров. HTTP запрос, перед тем как поступить в Dispatcher Servlet проходит цепочку фильтров (стандартная функциональность Servlet API). Spring +предоставляет собственную цепочку стандартных фильтров и возможность отключать/заменять любые фильтры из этой цепочки или внедрять в нее собственные фильтры. + +
    + +- Технический обзор Spring Security +- Типы проксирования +- Dynamic Proxy API +- [Security фильтры](https://docs.spring.io/spring-security/reference/servlet/configuration/xml-namespace.html#filter-stack) +- [Основы работы с Spring Security от Eugene Suleimanov](https://www.youtube.com/watch?v=7uxROJ1nduk) + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 10. Spring Security Test + +
    + Краткое содержание + +### Spring Security Test + +Для тестирования контроллеров, к запросам которого требуется аутентификация, будем использовать библиотеку `spring-security-test`. Для этого в `pom.xml` подключим эту зависимость и в `MockMvc` +добавить аналог цепочки security-фильтров: `.apply(springSecurity())`. Если сейчас запустить тесты, то они упадут, потому что в `mockMvc` происходит аутентификация, а в запросах, которые тесты +посылают серверу креденшелов пользователя нет. +Чтобы пройти аутентификацию в REST контроллерах, в каждом запросе укажем креденшелы пользователя через `...with(userHttpBasic(ADMIN))`. +`TestUtil#userHttpBasic` - наш утильный метод, который добавляет к запросу базовую аутентификацию (заголовок `Authorization` с данными *логина:пароля*). + +
    + +#### Apply 9_12_spring_security_test.patch + +> - Cделал "честную" аутентификацию для `RootControllerTest` (через `TestUtil#userAuth`) +> - Cделал `mockAuthorize` для `SpringMain`, в который не попадают фильтры + +- [Spring Security Testing](https://docs.spring.io/spring-security/reference/servlet/test/index.html) +- [Setting Up MockMvc and Spring Security](https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/setup.html) +- [HttpBasic авторизация](https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/http-basic.html) +- [Тестирование контроллеров в Spring Boot](https://javaops.ru/view/bootjava/lesson06#test) + +### ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 11. Cookie. Session. + +
    + Краткое содержание + +### Cookie. Session. + +Если в браузере с помощью инструментов разработчика внимательно посмотреть на запросы, которые уходят от клиента на сервер - можно увидеть что к каждому запросу прикрепляется Cookie `JSESSIONID`. +Эта Cookie - ключ к мапе, которая хранится в сессии и содержит аутентификационные данные. При аутентификации клиента в приложении создается объект `Authentication`, генерируется ключ, по нему объект кладется в сессию (мультимапа) и +этот ключ возвращается клиенту в ответе как значение cookie `JSESSIONID`. +Браузер хранит cookie на основе домена сайта и прикрепляет их ко всех запросам к этому домену. По значению cookie `JSESSIONID` Spring Security +хранит в сессии `Authentication`, из котрого мы уже можем достать нашего `AuthorizedUser`. + +Для REST контроллеров в конфигурации мы указали `create-session="stateless"` - при обращении к ним приложение +не будет создаваться HTTP сессии и сookie. В каждом запросе клиента к REST контроллеру вместо cookie есть заголовок `Authorization` с данными *логина:пароля* клиента. Каждый запрос проходит цепочку +Security фильтров и для базовой аутентификации при каждом запросе будет происходить обращение к БД для получения пользователя по email и проверка его креденшелов из заголовок `Authorization`. + +При некоторых условиях Tomcat сохраняет данные сессии и ему требуется возможность их сериализации, поэтому объекты в сеcсии (и объекты, которые в них содержатся) обязательно должны имплементировать +интерфейс `Serializable` (в нашем случае `AuthorizedUser` и `UserTo`). + +
    + +- HTTP cookie +- Under what conditions is a JSESSIONID created? +- Tomcat Session Serialization + +### Дополнительно: ![video](https://cloud.githubusercontent.com/assets/13649199/13672715/06dbc6ce-e6e7-11e5-81a9-04fbddb9e488.png) 12. [Новое в Spring 5. Миграция проекта](https://javaops.ru/view/resources/spring5) + +## ![question](https://cloud.githubusercontent.com/assets/13649199/13672858/9cd58692-e6e7-11e5-905d-c295d2a456f1.png) Ваши вопросы + +> В куки попадает обычная строка JSESSIONID. Куда сериализуется объект User? + +Для хранения состояния сессии (например корзины покупателя) в Servlet API есть механизм хранения объектов сессии (грубо - мультимапмапа, которая достается из хранилища по ключу). При создании сессии +на стороне сервера (через `request.getSession`) создается кука `JSESSIONID`, которая передается между клиентом и сервером в каждом запросе и является ключом в хранилище объектов сессий. +См. обработка сессий с помощью сервлетов + +> В `login.jsp` есть форма `` Где такой url используется? + +Он задается в `login-processing-url` конфигурации `spring-security.xml` и определяет URL к Spring Security, который принимает данные авторизационной формы (`username` и `password`). + +> Если не пользовать js, а писать UI на JSP, сообщения между ui и сервером будут в формате json? Это же будет JSON API? + +Есть данные, которые передаются между клиентом и сервером в формате json или get/post с параметрами, есть стили взаимодействия клиента и сервера (REST +, JSON API, JSON-RPC) и есть средства генерации HTML: JSP, Javascript фреймворк, Thymleaf и пр. Не надо эти вещи +путать между собой. + +> По умолчанию спринг работает с `UserDetailsService#loadUserByUsername`, который должен возвращать `UserDetails`. Но мы не хотим стандартные, мы хотим свои, поэтому просто наследуем наши `UserService` и `AuthorizedUser` от соответствующих интерфейсов и реализуем недостающие методы, которые spring security и будет использовать? + +В прошлых выпусках я сам реализовывал интерфейс `UserDetails`. Сейчас я считаю проще отнаследовать `AuthorizedUser` от `org.springframework.security.core.userdetails.User`, который уже имеет реализацию. +А в `UserService` мы реализуем `UserDetailsService#loadUserByUsername` и указываем этот сервис в `spring-security.xml` ``. +Также есть его стандартные реализации, которые использовались до нашей кастомной `UserService`, например `jdbc-user-service` использует реализацию `JdbcUserDetailsManager` + +## ![hw](https://cloud.githubusercontent.com/assets/13649199/13672719/09593080-e6e7-11e5-81d1-5cb629c438ca.png) Домашнее задание HW9 + +- 1: Реализовать для meal Binding/ Update/ Validation. Проверить работу при пустом значении `calories`. +- 2: Перевести `meals.jsp` на работу по ajax. Стиль строки таблицы сделать в зависимости от `excess`, время отображать без `T`. Добавить i18n. +- 3: Починить meals тесты, добавить тест на неавторизованный доступ. + +### Optional + +- 4: Подключить datetime-picker к фильтрам и модальному окну добавления/редактирования еды + - DateTimePicker jQuery plugin + - [jQuery: конверторы](https://jquery-docs.ru/jQuery.ajax/#using-converters) + +- Попробуйте при запросах по REST оставить стандартный ISO формат (с разделителем `T`). То есть: + - Отображение и редактирование еды на UI происходит без `T` (формат значений на UI можно увидеть во вкладке браузера Network) + - Когда мы работаем по REST, в json и запросах формат даты ISO (с разделителем `T`) + - Напомню, что параметры методов контроллера (в том числе собранные в объекты через Binding) парсятся конверторами спринга (`@DateTimeFormat`), а объекты json парсится Jackson и они никак не + влияют друг на друга. + +## ![error](https://cloud.githubusercontent.com/assets/13649199/13672935/ef09ec1e-e6e7-11e5-9f79-d1641c05cbe6.png) Проверка в HW09 + +- 1: Проверьте, что при добавлении и редактировании пользователя и еды у вас корректно отображаются заголовки модального окна: + "Добавить/Редактировать еду пользователя" +- 2: Не дублируйте + +``` +spring-test test + + org.springframework.security + spring-security-test + ${spring.security.version} + test + + org.assertj assertj-core diff --git a/src/main/java/ru/javawebinar/topjava/AuthorizedUser.java b/src/main/java/ru/javawebinar/topjava/AuthorizedUser.java new file mode 100644 index 000000000..0842dd2e8 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/AuthorizedUser.java @@ -0,0 +1,36 @@ +package ru.javawebinar.topjava; + +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.to.UserTo; +import ru.javawebinar.topjava.util.UserUtil; + +import java.io.Serial; + +public class AuthorizedUser extends org.springframework.security.core.userdetails.User { + @Serial + private static final long serialVersionUID = 1L; + + private UserTo userTo; + + public AuthorizedUser(User user) { + super(user.getEmail(), user.getPassword(), user.isEnabled(), true, true, true, user.getRoles()); + this.userTo = UserUtil.asTo(user); + } + + public int getId() { + return userTo.id(); + } + + public void update(UserTo newTo) { + userTo = newTo; + } + + public UserTo getUserTo() { + return userTo; + } + + @Override + public String toString() { + return userTo.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/HasId.java b/src/main/java/ru/javawebinar/topjava/HasId.java new file mode 100644 index 000000000..2ba30235c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/HasId.java @@ -0,0 +1,19 @@ +package ru.javawebinar.topjava; + +import org.springframework.util.Assert; + +public interface HasId { + Integer getId(); + + void setId(Integer id); + + default boolean isNew() { + return getId() == null; + } + + // doesn't work for hibernate lazy proxy + default int id() { + Assert.notNull(getId(), "Entity must has id"); + return getId(); + } +} diff --git a/src/main/java/ru/javawebinar/topjava/SpringMain.java b/src/main/java/ru/javawebinar/topjava/SpringMain.java index 66d4b30d8..2c914a5cb 100644 --- a/src/main/java/ru/javawebinar/topjava/SpringMain.java +++ b/src/main/java/ru/javawebinar/topjava/SpringMain.java @@ -13,6 +13,9 @@ import java.util.Arrays; import java.util.List; +import static ru.javawebinar.topjava.TestUtil.mockAuthorize; +import static ru.javawebinar.topjava.UserTestData.user; + public class SpringMain { public static void main(String[] args) { // java 7 automatic resource management (ARM) @@ -26,6 +29,8 @@ public static void main(String[] args) { adminUserController.create(new User(null, "userName", "email@mail.ru", "password", Role.ADMIN)); System.out.println(); + mockAuthorize(user); + MealRestController mealController = appCtx.getBean(MealRestController.class); List filteredMealsWithExcess = mealController.getBetween( diff --git a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java index 7cdc077bc..536c5c986 100644 --- a/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java @@ -1,8 +1,8 @@ package ru.javawebinar.topjava.model; import org.hibernate.Hibernate; -import org.springframework.data.domain.Persistable; import org.springframework.util.Assert; +import ru.javawebinar.topjava.HasId; import javax.persistence.*; @@ -10,7 +10,7 @@ // http://stackoverflow.com/questions/594597/hibernate-annotations-which-is-better-field-or-property-access @Access(AccessType.FIELD) //@JsonAutoDetect(fieldVisibility = ANY, getterVisibility = NONE, isGetterVisibility = NONE, setterVisibility = NONE) -public abstract class AbstractBaseEntity implements Persistable { +public abstract class AbstractBaseEntity implements HasId { public static final int START_SEQ = 100000; @Id @@ -28,6 +28,7 @@ protected AbstractBaseEntity(Integer id) { this.id = id; } + @Override public void setId(Integer id) { this.id = id; } @@ -37,16 +38,6 @@ public Integer getId() { return id; } - public int id() { - Assert.notNull(id, "Entity must have id"); - return id; - } - - @Override - public boolean isNew() { - return this.id == null; - } - @Override public String toString() { return getClass().getSimpleName() + ":" + id; diff --git a/src/main/java/ru/javawebinar/topjava/model/Meal.java b/src/main/java/ru/javawebinar/topjava/model/Meal.java index 7c5a9c202..14b3445cf 100644 --- a/src/main/java/ru/javawebinar/topjava/model/Meal.java +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -53,10 +53,6 @@ public class Meal extends AbstractBaseEntity { public Meal() { } - public Meal(LocalDateTime dateTime, String description, int calories) { - this(null, dateTime, description, calories); - } - public Meal(Integer id, LocalDateTime dateTime, String description, int calories) { super(id); this.dateTime = dateTime; diff --git a/src/main/java/ru/javawebinar/topjava/model/MealTo.java b/src/main/java/ru/javawebinar/topjava/model/MealTo.java index d0f786382..059f14a44 100644 --- a/src/main/java/ru/javawebinar/topjava/model/MealTo.java +++ b/src/main/java/ru/javawebinar/topjava/model/MealTo.java @@ -4,8 +4,7 @@ import java.time.LocalDateTime; import java.util.Objects; -public class MealTo { - private final Integer id; +public class MealTo extends BaseTo { private final LocalDateTime dateTime; @@ -17,17 +16,13 @@ public class MealTo { @ConstructorProperties({"id", "dateTime", "description", "calories", "excess"}) public MealTo(Integer id, LocalDateTime dateTime, String description, int calories, boolean excess) { - this.id = id; + super(id); this.dateTime = dateTime; this.description = description; this.calories = calories; this.excess = excess; } - public Integer getId() { - return id; - } - public LocalDateTime getDateTime() { return dateTime; } diff --git a/src/main/java/ru/javawebinar/topjava/model/Role.java b/src/main/java/ru/javawebinar/topjava/model/Role.java index acb7a276f..27f3e5231 100644 --- a/src/main/java/ru/javawebinar/topjava/model/Role.java +++ b/src/main/java/ru/javawebinar/topjava/model/Role.java @@ -1,6 +1,14 @@ package ru.javawebinar.topjava.model; -public enum Role { +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { USER, - ADMIN + ADMIN; + +// https://stackoverflow.com/a/19542316/548473 + @Override + public String getAuthority() { + return "ROLE_" + name(); + } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/model/User.java b/src/main/java/ru/javawebinar/topjava/model/User.java index 04cd79d58..8c4d39c0e 100644 --- a/src/main/java/ru/javawebinar/topjava/model/User.java +++ b/src/main/java/ru/javawebinar/topjava/model/User.java @@ -18,7 +18,7 @@ import javax.validation.constraints.Size; import java.util.*; -import static ru.javawebinar.topjava.util.MealsUtil.DEFAULT_CALORIES_PER_DAY; +import static ru.javawebinar.topjava.util.UserUtil.DEFAULT_CALORIES_PER_DAY; @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) @NamedQueries({ diff --git a/src/main/java/ru/javawebinar/topjava/service/UserService.java b/src/main/java/ru/javawebinar/topjava/service/UserService.java index 2fa248762..70c8bb528 100644 --- a/src/main/java/ru/javawebinar/topjava/service/UserService.java +++ b/src/main/java/ru/javawebinar/topjava/service/UserService.java @@ -2,18 +2,27 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.Assert; +import ru.javawebinar.topjava.AuthorizedUser; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.repository.UserRepository; +import ru.javawebinar.topjava.to.UserTo; +import ru.javawebinar.topjava.util.UserUtil; import java.util.List; import static ru.javawebinar.topjava.util.ValidationUtil.checkNotFound; import static ru.javawebinar.topjava.util.ValidationUtil.checkNotFoundWithId; -@Service -public class UserService { +@Service("userService") +@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS) +public class UserService implements UserDetailsService { private final UserRepository repository; @@ -49,7 +58,33 @@ public List getAll() { @CacheEvict(value = "users", allEntries = true) public void update(User user) { Assert.notNull(user, "user must not be null"); - checkNotFoundWithId(repository.save(user), user.id()); +// checkNotFoundWithId : check works only for JDBC, disabled + repository.save(user); + } + + @CacheEvict(value = "users", allEntries = true) + @Transactional + public void update(UserTo userTo) { + User user = get(userTo.id()); + User updatedUser = UserUtil.updateFromTo(user, userTo); + repository.save(updatedUser); // !! need only for JDBC implementation + } + + @CacheEvict(value = "users", allEntries = true) + @Transactional + public void enable(int id, boolean enabled) { + User user = get(id); + user.setEnabled(enabled); + repository.save(user); // !! need only for JDBC implementation + } + + @Override + public AuthorizedUser loadUserByUsername(String email) throws UsernameNotFoundException { + User user = repository.getByEmail(email.toLowerCase()); + if (user == null) { + throw new UsernameNotFoundException("User " + email + " is not found"); + } + return new AuthorizedUser(user); } public User getWithMeals(int id) { diff --git a/src/main/java/ru/javawebinar/topjava/to/BaseTo.java b/src/main/java/ru/javawebinar/topjava/to/BaseTo.java new file mode 100644 index 000000000..b7a7de6b7 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/to/BaseTo.java @@ -0,0 +1,24 @@ +package ru.javawebinar.topjava.to; + +import ru.javawebinar.topjava.HasId; + +public abstract class BaseTo implements HasId { + protected Integer id; + + public BaseTo() { + } + + public BaseTo(Integer id) { + this.id = id; + } + + @Override + public Integer getId() { + return id; + } + + @Override + public void setId(Integer id) { + this.id = id; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/to/UserTo.java b/src/main/java/ru/javawebinar/topjava/to/UserTo.java new file mode 100644 index 000000000..1afd48807 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/to/UserTo.java @@ -0,0 +1,82 @@ +package ru.javawebinar.topjava.to; + +import org.hibernate.validator.constraints.Range; +import ru.javawebinar.topjava.util.UserUtil; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.io.Serial; +import java.io.Serializable; + +public class UserTo extends BaseTo implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @NotBlank + @Size(min = 2, max = 100) + private String name; + + @Email + @NotBlank + @Size(max = 100) + private String email; + + @NotBlank + @Size(min = 5, max = 32, message = "length must be between 5 and 32 characters") + private String password; + + @Range(min = 10, max = 10000) + @NotNull + private Integer caloriesPerDay = UserUtil.DEFAULT_CALORIES_PER_DAY; + + public UserTo() { + } + + public UserTo(Integer id, String name, String email, String password, int caloriesPerDay) { + super(id); + this.name = name; + this.email = email; + this.password = password; + this.caloriesPerDay = caloriesPerDay; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Integer getCaloriesPerDay() { + return caloriesPerDay; + } + + @Override + public String toString() { + return "UserTo{" + + "id=" + id + + ", name='" + name + '\'' + + ", email='" + email + '\'' + + ", caloriesPerDay='" + caloriesPerDay + '\'' + + '}'; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java index 3c74215c0..af07e74d3 100644 --- a/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java @@ -12,7 +12,6 @@ import java.util.stream.Collectors; public class MealsUtil { - public static final int DEFAULT_CALORIES_PER_DAY = 2000; private MealsUtil() { } diff --git a/src/main/java/ru/javawebinar/topjava/util/UserUtil.java b/src/main/java/ru/javawebinar/topjava/util/UserUtil.java new file mode 100644 index 000000000..02b374bca --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/UserUtil.java @@ -0,0 +1,26 @@ +package ru.javawebinar.topjava.util; + +import ru.javawebinar.topjava.model.Role; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.to.UserTo; + +public class UserUtil { + + 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); + } + + public static UserTo asTo(User user) { + return new UserTo(user.getId(), user.getName(), user.getEmail(), user.getPassword(), user.getCaloriesPerDay()); + } + + public static User updateFromTo(User user, UserTo userTo) { + user.setName(userTo.getName()); + user.setEmail(userTo.getEmail().toLowerCase()); + user.setCaloriesPerDay(userTo.getCaloriesPerDay()); + user.setPassword(userTo.getPassword()); + 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 fa79c4b95..b77462d3f 100644 --- a/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java +++ b/src/main/java/ru/javawebinar/topjava/util/ValidationUtil.java @@ -3,7 +3,7 @@ import org.springframework.core.NestedExceptionUtils; import org.springframework.lang.NonNull; -import ru.javawebinar.topjava.model.AbstractBaseEntity; +import ru.javawebinar.topjava.HasId; import ru.javawebinar.topjava.util.exception.NotFoundException; import javax.validation.*; @@ -51,18 +51,18 @@ public static void checkNotFound(boolean found, String msg) { } } - public static void checkNew(AbstractBaseEntity entity) { - if (!entity.isNew()) { - throw new IllegalArgumentException(entity + " must be new (id=null)"); + public static void checkNew(HasId bean) { + if (!bean.isNew()) { + throw new IllegalArgumentException(bean + " must be new (id=null)"); } } - public static void assureIdConsistent(AbstractBaseEntity entity, int id) { + public static void assureIdConsistent(HasId bean, int id) { // conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473) - if (entity.isNew()) { - entity.setId(id); - } else if (entity.id() != id) { - throw new IllegalArgumentException(entity + " must be with id=" + id); + if (bean.isNew()) { + bean.setId(id); + } else if (bean.id() != id) { + throw new IllegalArgumentException(bean + " must be with id=" + id); } } diff --git a/src/main/java/ru/javawebinar/topjava/web/RootController.java b/src/main/java/ru/javawebinar/topjava/web/RootController.java index 921462ca9..3ca4591e0 100644 --- a/src/main/java/ru/javawebinar/topjava/web/RootController.java +++ b/src/main/java/ru/javawebinar/topjava/web/RootController.java @@ -6,42 +6,32 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; import ru.javawebinar.topjava.service.MealService; -import ru.javawebinar.topjava.service.UserService; import ru.javawebinar.topjava.util.MealsUtil; -import javax.servlet.http.HttpServletRequest; - @Controller public class RootController { private static final Logger log = LoggerFactory.getLogger(RootController.class); - @Autowired - private UserService userService; - @Autowired private MealService mealService; @GetMapping("/") public String root() { log.info("root"); - return "index"; + return "redirect:meals"; } @GetMapping("/users") - public String getUsers(Model model) { + public String getUsers() { log.info("users"); - model.addAttribute("users", userService.getAll()); return "users"; } - @PostMapping("/users") - public String setUser(HttpServletRequest request) { - int userId = Integer.parseInt(request.getParameter("userId")); - log.info("setUser {}", userId); - SecurityUtil.setAuthUserId(userId); - return "redirect:meals"; + @GetMapping("/login") + public String login() { + log.info("login"); + return "login"; } @GetMapping("/meals") diff --git a/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java b/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java index 4bad5863e..ac5b99f05 100644 --- a/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java +++ b/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java @@ -1,25 +1,34 @@ package ru.javawebinar.topjava.web; -import ru.javawebinar.topjava.model.AbstractBaseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import ru.javawebinar.topjava.AuthorizedUser; -import static ru.javawebinar.topjava.util.MealsUtil.DEFAULT_CALORIES_PER_DAY; +import static java.util.Objects.requireNonNull; public class SecurityUtil { - private static int id = AbstractBaseEntity.START_SEQ; - private SecurityUtil() { } - public static int authUserId() { - return id; + public static AuthorizedUser safeGet() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + return null; + } + Object principal = auth.getPrincipal(); + return (principal instanceof AuthorizedUser) ? (AuthorizedUser) principal : null; } - public static void setAuthUserId(int id) { - SecurityUtil.id = id; + public static AuthorizedUser get() { + return requireNonNull(safeGet(), "No authorized user found"); + } + + public static int authUserId() { + return get().getUserTo().id(); } public static int authUserCaloriesPerDay() { - return DEFAULT_CALORIES_PER_DAY; + return get().getUserTo().getCaloriesPerDay(); } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/meal/JspMealController.java b/src/main/java/ru/javawebinar/topjava/web/meal/JspMealController.java deleted file mode 100644 index 7e800f683..000000000 --- a/src/main/java/ru/javawebinar/topjava/web/meal/JspMealController.java +++ /dev/null @@ -1,70 +0,0 @@ -package ru.javawebinar.topjava.web.meal; - -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import ru.javawebinar.topjava.model.Meal; - -import javax.servlet.http.HttpServletRequest; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.temporal.ChronoUnit; -import java.util.Objects; - -import static ru.javawebinar.topjava.util.DateTimeUtil.parseLocalDate; -import static ru.javawebinar.topjava.util.DateTimeUtil.parseLocalTime; - -@Controller -@RequestMapping("/meals") -public class JspMealController extends AbstractMealController { - - @GetMapping("/delete") - public String delete(HttpServletRequest request) { - super.delete(getId(request)); - return "redirect:/meals"; - } - - @GetMapping("/update") - public String update(HttpServletRequest request, Model model) { - model.addAttribute("meal", super.get(getId(request))); - return "mealForm"; - } - - @GetMapping("/create") - public String create(Model model) { - model.addAttribute("meal", new Meal(LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES), "", 1000)); - return "mealForm"; - } - - @PostMapping - public String updateOrCreate(HttpServletRequest request) { - Meal meal = new Meal(LocalDateTime.parse(request.getParameter("dateTime")), - request.getParameter("description"), - Integer.parseInt(request.getParameter("calories"))); - - if (request.getParameter("id").isEmpty()) { - super.create(meal); - } else { - super.update(meal, getId(request)); - } - return "redirect:/meals"; - } - - @GetMapping("/filter") - public String getBetween(HttpServletRequest request, Model model) { - LocalDate startDate = parseLocalDate(request.getParameter("startDate")); - LocalDate endDate = parseLocalDate(request.getParameter("endDate")); - LocalTime startTime = parseLocalTime(request.getParameter("startTime")); - LocalTime endTime = parseLocalTime(request.getParameter("endTime")); - model.addAttribute("meals", super.getBetween(startDate, startTime, endDate, endTime)); - return "meals"; - } - - private int getId(HttpServletRequest request) { - String paramId = Objects.requireNonNull(request.getParameter("id")); - return Integer.parseInt(paramId); - } -} diff --git a/src/main/java/ru/javawebinar/topjava/web/meal/MealUIController.java b/src/main/java/ru/javawebinar/topjava/web/meal/MealUIController.java new file mode 100644 index 000000000..dafc7a70f --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/meal/MealUIController.java @@ -0,0 +1,50 @@ +package ru.javawebinar.topjava.web.meal; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.*; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.to.MealTo; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +@RestController +@RequestMapping(value = "/profile/meals", produces = MediaType.APPLICATION_JSON_VALUE) +public class MealUIController extends AbstractMealController { + + @Override + @GetMapping + public List getAll() { + return super.getAll(); + } + + @Override + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable int id) { + super.delete(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)); + } + + @Override + @GetMapping("/filter") + public List getBetween( + @RequestParam @Nullable LocalDate startDate, + @RequestParam @Nullable LocalTime startTime, + @RequestParam @Nullable LocalDate endDate, + @RequestParam @Nullable LocalTime endTime) { + return super.getBetween(startDate, startTime, endDate, endTime); + } +} \ No newline at end of file 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 ccd46fdf6..0f3a9ad12 100644 --- a/src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java +++ b/src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java @@ -5,6 +5,8 @@ import org.springframework.beans.factory.annotation.Autowired; import ru.javawebinar.topjava.model.User; import ru.javawebinar.topjava.service.UserService; +import ru.javawebinar.topjava.to.UserTo; +import ru.javawebinar.topjava.util.UserUtil; import java.util.List; @@ -27,6 +29,12 @@ public User get(int id) { return service.get(id); } + public void create(UserTo userTo) { + log.info("create {}", userTo); + checkNew(userTo); + service.create(UserUtil.createNewFromTo(userTo)); + } + public User create(User user) { log.info("create {}", user); checkNew(user); @@ -44,6 +52,12 @@ public void update(User user, int id) { service.update(user); } + public void update(UserTo userTo, int id) { + log.info("update {} with id={}", userTo, id); + assureIdConsistent(userTo, id); + service.update(userTo); + } + public User getByMail(String email) { log.info("getByEmail {}", email); return service.getByEmail(email); @@ -53,4 +67,9 @@ public User getWithMeals(int id) { log.info("getWithMeals {}", id); return service.getWithMeals(id); } + + public void enable(int id, boolean enabled) { + log.info(enabled ? "enable {}" : "disable {}", id); + service.enable(id, enabled); + } } \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java b/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java index 66a245275..dfc40e6c6 100644 --- a/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java +++ b/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java @@ -61,4 +61,11 @@ public User getByMail(@RequestParam String email) { public User getWithMeals(@PathVariable int id) { return super.getWithMeals(id); } + + @Override + @PatchMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void enable(@PathVariable int id, @RequestParam boolean enabled) { + super.enable(id, enabled); + } } \ No newline at end of file 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 887cd4717..199daa8f1 100644 --- a/src/main/java/ru/javawebinar/topjava/web/user/AdminUIController.java +++ b/src/main/java/ru/javawebinar/topjava/web/user/AdminUIController.java @@ -2,11 +2,15 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; -import ru.javawebinar.topjava.model.Role; import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.to.UserTo; +import javax.validation.Valid; import java.util.List; +import java.util.stream.Collectors; @RestController @RequestMapping(value = "/admin/users", produces = MediaType.APPLICATION_JSON_VALUE) @@ -18,6 +22,12 @@ public List getAll() { return super.getAll(); } + @Override + @GetMapping("/{id}") + public User get(@PathVariable int id) { + return super.get(id); + } + @Override @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) @@ -27,9 +37,25 @@ public void delete(@PathVariable int id) { @PostMapping @ResponseStatus(HttpStatus.NO_CONTENT) - public void create(@RequestParam String name, - @RequestParam String email, - @RequestParam String password) { - super.create(new User(null, name, email, password, Role.USER)); + 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); + } + if (userTo.isNew()) { + super.create(userTo); + } else { + super.update(userTo, userTo.id()); + } + return ResponseEntity.ok().build(); + } + + @Override + @PostMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void enable(@PathVariable int id, @RequestParam boolean enabled) { + super.enable(id, enabled); } } 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 c65926680..ccc43013a 100644 --- a/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java +++ b/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java @@ -4,6 +4,7 @@ import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.to.UserTo; import static ru.javawebinar.topjava.web.SecurityUtil.authUserId; @@ -25,8 +26,8 @@ public void delete() { @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.NO_CONTENT) - public void update(@RequestBody User user) { - super.update(user, authUserId()); + public void update(@RequestBody UserTo userTo) { + super.update(userTo, authUserId()); } @GetMapping("/text") diff --git a/src/main/resources/spring/spring-mvc.xml b/src/main/resources/spring/spring-mvc.xml index 1201341ab..592510332 100644 --- a/src/main/resources/spring/spring-mvc.xml +++ b/src/main/resources/spring/spring-mvc.xml @@ -1,12 +1,16 @@ + + + @@ -44,17 +48,17 @@ p:prefix="/WEB-INF/jsp/" p:suffix=".jsp"/> - + diff --git a/src/main/resources/spring/spring-security.xml b/src/main/resources/spring/spring-security.xml index 2b83a0d71..97b944b4e 100644 --- a/src/main/resources/spring/spring-security.xml +++ b/src/main/resources/spring/spring-security.xml @@ -16,13 +16,33 @@ + + + + + + + + + + + - + + \ 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 bba77445f..8e14ccf82 100644 --- a/src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp +++ b/src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp @@ -6,8 +6,8 @@
    - - + +
    diff --git a/src/main/webapp/WEB-INF/jsp/index.jsp b/src/main/webapp/WEB-INF/jsp/index.jsp deleted file mode 100644 index cc610bd72..000000000 --- a/src/main/webapp/WEB-INF/jsp/index.jsp +++ /dev/null @@ -1,23 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> - - - - - -
    -
    -
    - - - -
    -
    -
    - - - \ 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 new file mode 100644 index 000000000..443f6c434 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/login.jsp @@ -0,0 +1,77 @@ +<%@ 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" %> + + + + + + +
    +
    + +
    ${sessionScope["SPRING_SECURITY_LAST_EXCEPTION"].message}
    +
    + +
    +
    +
    +

    + + +

    +
    + +
    +
    +
    +
    + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/mealForm.jsp b/src/main/webapp/WEB-INF/jsp/mealForm.jsp deleted file mode 100644 index af6d7880e..000000000 --- a/src/main/webapp/WEB-INF/jsp/mealForm.jsp +++ /dev/null @@ -1,35 +0,0 @@ -<%@ page contentType="text/html;charset=UTF-8" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> - - - - - - -
    - -<%-- `meal.new` cause javax.el.ELException - bug tomcat --%> -

    -
    -
    - -
    -
    :
    -
    -
    -
    -
    :
    -
    -
    -
    -
    :
    -
    -
    - - -
    -
    - - - diff --git a/src/main/webapp/WEB-INF/jsp/meals.jsp b/src/main/webapp/WEB-INF/jsp/meals.jsp index b42230b90..6d5159a9d 100644 --- a/src/main/webapp/WEB-INF/jsp/meals.jsp +++ b/src/main/webapp/WEB-INF/jsp/meals.jsp @@ -5,60 +5,125 @@ + + -
    -

    - -
    -
    -
    :
    -
    -
    -
    -
    :
    -
    -
    -
    -
    :
    -
    -
    -
    -
    :
    -
    -
    - -
    -
    - -
    - - - - - - - - - - - - - - - - - - +
    +
    +

    + <%--https://getbootstrap.com/docs/4.0/components/card/--%> +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    + +
    - <%--${meal.dateTime.toLocalDate()} ${meal.dateTime.toLocalTime()}--%> - <%--<%=TimeUtil.toString(meal.getDateTime())%>--%> - <%--${fn:replace(meal.dateTime, 'T', ' ')}--%> - ${fn:formatDateTime(meal.dateTime)} - ${meal.description}${meal.calories}
    + + + + + + + - -
    -
    + + + + + + <%--${meal.dateTime.toLocalDate()} ${meal.dateTime.toLocalTime()}--%> + <%--<%=TimeUtil.toString(meal.getDateTime())%>--%> + <%--${fn:replace(meal.dateTime, 'T', ' ')}--%> + ${fn:formatDateTime(meal.dateTime)} + + ${meal.description} + ${meal.calories} + + + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/users.jsp b/src/main/webapp/WEB-INF/jsp/users.jsp index 35cf30926..4f1ea1d12 100644 --- a/src/main/webapp/WEB-INF/jsp/users.jsp +++ b/src/main/webapp/WEB-INF/jsp/users.jsp @@ -29,18 +29,6 @@ - - - - - ${user.email} - ${user.roles} - checked/> - - - - - @@ -49,7 +37,7 @@