diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 000000000000..311a8f4e0029 --- /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 73e6d9e3dd11..9d505ac1c99a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,7 @@ target *.iml log *.patch +.jpb +*.md - +/src/test/java/ru/javawebinar/topjava/examples/ diff --git a/Procfile b/Procfile new file mode 100644 index 000000000000..4afeb7900918 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: java $JAVA_OPTS -Dspring.profiles.active="datajpa,heroku" -DTOPJAVA_ROOT="." -jar target/dependency/webapp-runner.jar --port $PORT target/*.war \ No newline at end of file diff --git a/config/Topjava-soapui-project.xml b/config/Topjava-soapui-project.xml new file mode 100644 index 000000000000..f4b00d93669d --- /dev/null +++ b/config/Topjava-soapui-project.xml @@ -0,0 +1,609 @@ + + + + + + + + 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 + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + \ No newline at end of file diff --git a/config/curl.md b/config/curl.md new file mode 100644 index 000000000000..d970d80200c0 --- /dev/null +++ b/config/curl.md @@ -0,0 +1,42 @@ +### 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` + +#### register User +`curl -s -i -X POST -d '{"name":"New User","email":"test@mail.ru","password":"test-password"}' -H 'Content-Type:application/json;charset=UTF-8' http://localhost:8080/topjava/rest/profile` + +#### get Profile +`curl -s http://localhost:8080/topjava/rest/profile --user test@mail.ru:test-password` + +#### get All Meals +`curl -s http://localhost:8080/topjava/rest/profile/meals --user user@yandex.ru:password` + +#### 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` + +#### validate with Error +`curl -s -X POST -d '{}' -H 'Content-Type: application/json' http://localhost:8080/topjava/rest/admin/users --user admin@gmail.com:admin` +`curl -s -X PUT -d '{"dateTime":"2015-05-30T07:00"}' -H 'Content-Type: application/json' http://localhost:8080/topjava/rest/profile/meals/100003 --user user@yandex.ru:password` + +#### validate with Error +`curl -s -X POST -d '{}' -H 'Content-Type: application/json' http://localhost:8080/topjava/rest/admin/users --user admin@gmail.com:admin` diff --git a/config/messages/app.properties b/config/messages/app.properties new file mode 100644 index 000000000000..8391625f5106 --- /dev/null +++ b/config/messages/app.properties @@ -0,0 +1,68 @@ +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 \u0438 Spring Security Test. +app.footer=Spring 5/JPA Enterprise (Topjava) internship application +app.login=Login as +app.profile=profile +app.register=Registration +app.registered=You are registered. Please Sign in. + +user.title=Users +user.edit=Edit user +user.add=Add user +user.name=Name +user.email=Email +user.roles=Roles +user.active=Active +user.registered=Registered +user.password=Password +user.caloriesPerDay=Daily calorie limit + +userTo.name=Name +userTo.email=Email +userTo.password=Password +userTo.caloriesPerDay=Daily calorie limit + +meal.title=Meals +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.add=Add +common.select=Select +common.deleted=Record deleted +common.saved=Record saved +common.enabled=Record enabled +common.disabled=Record disabled +common.confirm=Are you sure? +common.save=Save +common.cancel=Cancel +common.search=Search + +exception.user.duplicateEmail=User with this email already exists +exception.user.updateRestriction=Admin/User update is forbidden +exception.meal.duplicateDateTime=You already have meal with this date/time + +error.appError=Application error +error.dataNotFound=Data not found +error.dataError=Data error +error.validationError=Validation error +error.wrongRequest=Wrong request + +NotEmpty=[{0}] must not be empty +NotBlank=[{0}] must not be empty +NotNull=[{0}] must not be empty +Email= Invalid format of [{0}] +Range=[{0}] must be between {2} and {1} +Length=[{0}] length must be between {2} and {1} +Size=[{0}] size must be between {2} and {1} \ 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 000000000000..2e5dc11b2d3d --- /dev/null +++ b/config/messages/app_ru.properties @@ -0,0 +1,68 @@ +app.title=\u041F\u043E\u0434\u0441\u0447\u0435\u0442 \u043A\u0430\u043B\u043E\u0440\u0438\u0439 +app.stackTitle=\u0421\u0442\u0435\u043A \u0442\u0435\u0445\u043D\u043E\u043B\u043E\u0433\u0438\u0439: +app.description=Java Enterprise \u043F\u0440\u043E\u0435\u043A\u0442 \u0441 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0435\u0439/\u0430\u0432\u0442\u043E\u0440\u0438\u0437\u0430\u0446\u0438\u0435\u0439 \u0438 \u043F\u0440\u0430\u0432\u0430\u043C\u0438 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043D\u0430 \u043E\u0441\u043D\u043E\u0432\u0435 \u0440\u043E\u043B\u0435\u0439 (USER, ADMIN). \ +\u0410\u0434\u043C\u0438\u043D\u0438\u0441\u0442\u0440\u0430\u0442\u043E\u0440 \u043C\u043E\u0436\u0435\u0442 \u0441\u043E\u0437\u0434\u0430\u0432\u0430\u0442\u044C/\u0440\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C/\u0443\u0434\u0430\u043B\u044F\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0435\u0439, \u0430 \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0438 - \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0442\u044C \u0441\u0432\u043E\u0438\u043C \u043F\u0440\u043E\u0444\u0438\u043B\u0435\u043C \u0438 \u0434\u0430\u043D\u043D\u044B\u043C\u0438 (\u0435\u0434\u043E\u0439) \u0447\u0435\u0440\u0435\u0437 UI (\u043F\u043E AJAX) \u0438 \u043F\u043E REST \u0438\u043D\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0443 \u0441 \u0431\u0430\u0437\u043E\u0432\u043E\u0439 \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u0430\u0446\u0438\u0435\u0439. \ +\u0412\u043E\u0437\u043C\u043E\u0436\u043D\u0430 \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u0446\u0438\u044F \u0435\u0434\u044B \u043F\u043E \u0434\u0430\u0442\u0430\u043C \u0438 \u0432\u0440\u0435\u043C\u0435\u043D\u0438. \u0426\u0432\u0435\u0442 \u0437\u0430\u043F\u0438\u0441\u0438 \u0442\u0430\u0431\u043B\u0438\u0446\u044B \u0435\u0434\u044B \u0437\u0430\u0432\u0438\u0441\u0438\u0442 \u043E\u0442 \u0442\u043E\u0433\u043E, \u043F\u0440\u0435\u0432\u044B\u0448\u0430\u0435\u0442 \u043B\u0438 \u0441\u0443\u043C\u043C\u0430 \u043A\u0430\u043B\u043E\u0440\u0438\u0439 \u0437\u0430 \u0434\u0435\u043D\u044C \u043D\u043E\u0440\u043C\u0443 (\u0440\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u0443\u0435\u043C\u044B\u0439 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 \u0432 \u043F\u0440\u043E\u0444\u0438\u043B\u0435 \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F). \ +\u0412\u0435\u0441\u044C REST \u0438\u043D\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u043F\u043E\u043A\u0440\u044B\u0432\u0430\u0435\u0442\u0441\u044F JUnit \u0442\u0435\u0441\u0442\u0430\u043C\u0438, \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u044F Spring MVC Test \u0438 Spring Security Test. +app.footer=\u041F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u0441\u0442\u0430\u0436\u0438\u0440\u043E\u0432\u043A\u0438 Spring 5/JPA Enterprise (Topjava) +app.login=\u0417\u0430\u0439\u0442\u0438 \u043A\u0430\u043A +app.profile=\u043F\u0440\u043E\u0444\u0438\u043B\u044C +app.register=\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044F +app.registered=\u0412\u044B \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u044B. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0430\u0448 \u043B\u043E\u0433\u0438\u043D/\u043F\u0430\u0440\u043E\u043B\u044C. + +user.title=\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0438 +user.edit=\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F +user.add=\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F +user.name=\u0418\u043C\u044F +user.email=\u041F\u043E\u0447\u0442\u0430 +user.roles=\u0420\u043E\u043B\u0438 +user.active=\u0410\u043A\u0442\u0438\u0432\u043D\u044B\u0439 +user.registered=\u0417\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043E\u0432\u0430\u043D +user.password=\u041F\u0430\u0440\u043E\u043B\u044C +user.caloriesPerDay=\u041D\u043E\u0440\u043C\u0430 \u043A\u0430\u043B\u043E\u0440\u0438\u0439 \u0432 \u0434\u0435\u043D\u044C + +userTo.name=\u0418\u043C\u044F +userTo.email=\u041F\u043E\u0447\u0442\u0430 +userTo.password=\u041F\u0430\u0440\u043E\u043B\u044C +userTo.caloriesPerDay=\u041D\u043E\u0440\u043C\u0430 \u043A\u0430\u043B\u043E\u0440\u0438\u0439 \u0432 \u0434\u0435\u043D\u044C + +meal.title=\u041C\u043E\u044F \u0435\u0434\u0430 +meal.edit=\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0435\u0434\u0443 +meal.add=\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C \u0435\u0434\u0443 +meal.filter=\u041E\u0442\u0444\u0438\u043B\u044C\u0442\u0440\u043E\u0432\u0430\u0442\u044C +meal.startDate=\u041E\u0442 \u0434\u0430\u0442\u044B (\u0432\u043A\u043B\u044E\u0447\u0430\u044F) +meal.endDate=\u0414\u043E \u0434\u0430\u0442\u044B (\u0432\u043A\u043B\u044E\u0447\u0430\u044F) +meal.startTime=\u041E\u0442 \u0432\u0440\u0435\u043C\u0435\u043D\u0438 (\u0432\u043A\u043B\u044E\u0447\u0430\u044F) +meal.endTime=\u0414\u043E \u0432\u0440\u0435\u043C\u0435\u043D\u0438 (\u0438\u0441\u043A\u043B\u044E\u0447\u0430\u044F) +meal.description=\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435 +meal.dateTime=\u0414\u0430\u0442\u0430/\u0412\u0440\u0435\u043C\u044F +meal.calories=\u041A\u0430\u043B\u043E\u0440\u0438\u0438 + +common.add=\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C +common.select=\u0412\u044B\u0431\u0440\u0430\u0442\u044C +common.deleted=\u0417\u0430\u043F\u0438\u0441\u044C \u0443\u0434\u0430\u043B\u0435\u043D\u0430 +common.saved=\u0417\u0430\u043F\u0438\u0441\u044C \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430 +common.enabled=\u0417\u0430\u043F\u0438\u0441\u044C \u0430\u043A\u0442\u0438\u0432\u0438\u0440\u043E\u0432\u0430\u043D\u0430 +common.disabled=\u0417\u0430\u043F\u0438\u0441\u044C \u0434\u0435\u0430\u043A\u0442\u0438\u0432\u0438\u0440\u043E\u0432\u0430\u043D\u0430 +common.confirm=\u0412\u044B \u0443\u0432\u0435\u0440\u0435\u043D\u044B? +common.save=\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C +common.cancel=\u041E\u0442\u043C\u0435\u043D\u0438\u0442\u044C +common.search=\u0418\u0441\u043A\u0430\u0442\u044C + +exception.user.duplicateEmail=\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044C \u0441 \u0442\u0430\u043A\u043E\u0439 \u043F\u043E\u0447\u0442\u043E\u0439 \u0443\u0436\u0435 \u0435\u0441\u0442\u044C \u0432 \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0438 +exception.user.updateRestriction=\u0418\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u0435 Admin/User \u0437\u0430\u043F\u0440\u0435\u0449\u0435\u043D\u043E +exception.meal.duplicateDateTime=\u0423 \u0432\u0430\u0441 \u0443\u0436\u0435 \u0435\u0441\u0442\u044C \u0435\u0434\u0430 \u0441 \u0442\u0430\u043A\u043E\u0439 \u0434\u0430\u0442\u043E\u0439/\u0432\u0440\u0435\u043C\u0435\u043D\u0435\u043C + +error.appError=\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F +error.dataNotFound=\u0414\u0430\u043D\u043D\u044B\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u044B +error.dataError=\u041E\u0448\u0438\u0431\u043A\u0430 \u0432 \u0434\u0430\u043D\u043D\u044B\u0445 +error.validationError=\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u0434\u0430\u043D\u043D\u044B\u0445 +error.wrongRequest=\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u0437\u0430\u043F\u0440\u043E\u0441 + +NotEmpty=[{0}] \u043D\u0435 \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u043C +NotBlank=[{0}] \u043D\u0435 \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u043C +NotNull=[{0}] \u043D\u0435 \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u043C +Email=\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u0444\u043E\u0440\u043C\u0430\u0442 [{0}] +Range= [{0}] \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043C\u0435\u0436\u0434\u0443 {2} \u0438 {1} +Length=\u0414\u043B\u0438\u043D\u043D\u0430 [{0}] \u0434\u043E\u043B\u0436\u043D\u0430 \u0431\u044B\u0442\u044C \u043C\u0435\u0436\u0434\u0443 {2} \u0438 {1} +Size=\u0420\u0430\u0437\u043C\u0435\u0440 [{0}] \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u043C\u0435\u0436\u0434\u0443 {2} \u0438 {1} \ No newline at end of file diff --git a/doc/lesson07.md b/doc/lesson07.md deleted file mode 100644 index 1a3b1fe72692..000000000000 --- a/doc/lesson07.md +++ /dev/null @@ -1,566 +0,0 @@ -# [Онлайн стажировка Spring 5/JPA Enterprise (Topjava)](http://javaops.ru/view/topjava) -## [Почему мы?](http://javaops.ru/#why) - -## REST, REST контроллеры, тестирование Spring MVC контроллеров -# Для просмотра открыты видео [4](#--4-миграция-на-junit-5), [5](#-5-принципы-rest-rest-контроллеры), [6](#-6-тестирование-rest-контроллеров-jackson), [7](#-7-кастомизация-jackson-object-mapper), [8](#user-content--8-тестирование-rest-контроллеров-через-jsonassert-и-матчеры) -- Не стоит стремиться прочитать все ссылки урока, их можно использовать как справочник. Гораздо важнее пройти основной материал урока и сделать Домашнее Задание -- Обязательно посмотри правила работы с патчами на проекте -- Делать Apply Patch лучше по одному, непосредственно перед видео на эту тему, а при просмотре видео сразу отслеживать все изменения кода проекта по изменению в патче (`Version Control->Local Changes-> Ctrl+D`) -- При первом Apply удобнее выбрать имя локального ченджлиста Name: Default. Далее все остальные патчи также будут в него попадать. -- Код проекта обновляется и не всегда совпадает с видео (можно увидеть как развивался проект). Изменения в проекте указываю после соответствующего патча. - -## ![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 - -#### Apply 7_02_HW6_meals.patch - -> сделал фильтрацию еды через `get`: операция идемпотентная, можно делать в браузере обновление по F5 - -### Внимание: чиним пути в следующем патче - -#### 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 - -#### Apply 7_04_HW6_optional_add_role.patch - -#### `JdbcUserServiceTest` отвалились. Будем чинить в `7_06_HW6_jdbc_transaction_roles.patch` - -#### Apply 7_05_fix_hint_graph.patch - -- В `JpaUserRepositoryImpl.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. -- Тест `DataJpaUserServiceTest.getWithMeals()` не работает для admin (у админа 2 роли, и еда при JOIN дублируется). ... - -#### Apply 7_06_HW6_jdbc_transaction_roles.patch - -Еще интересные JDBC реализации: ... - -### Валидация для `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` и проверкой профилей мы можем ... - -#### 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` -> - добавил [`AllActiveProfileResolver`](//http://stackoverflow.com/questions/23871255/spring-profiles-simple-example-of-activeprofilesresolver) для возвращения массива профилей -> - сделал вспомогательный метод `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 5 homepage](https://junit.org/junit5) -- [Overview](https://junit.org/junit5/docs/snapshot/user-guide/#overview) -- [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 - -- Понимание 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 - -> - Методы из класса `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 - -
- Краткое содержание - -SOAP UI - это один из инструментов для тестирования API приложений, которые работают по REST и по SOAP. -Он позволяет нам по HTTP протоколу дернуть методы нашего API и увидеть ответ контроллеров. - -Если в контроллер мы добавим метод, который в теле ответа будет возвращать текст на кириллице, то мы увидим кодировка теряться. -Для сохранения кодировки используем `StringHttpMessageConverter`, который конфигурируем в `spring-mvc.xml`. -При этом мы должны явно указать, что конвертор будет работать только с текстом в кодировке *UTF-8*. - -
- -#### Apply 7_16_soapui_utf8_converter.patch - -- Инструменты тестирования REST: - - SoapUi - - Написание 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 используются только 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.*` - -> 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: При проблемах с собственным форматтером убедитесь, что в конфигурации `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: Не дублируйте + +``` +ru.javawebinar topjava - jar + war 1.0-SNAPSHOT @@ -12,14 +12,49 @@ http://topjava.herokuapp.com/ - 1.8 + 17 UTF-8 UTF-8 + + 5.3.20 + 2.7.1 + 5.7.2 + + 2.13.3 + 9.0.59 + + + 1.2.11 + 1.7.36 + + + 42.4.0 + + + 5.6.9.Final + 6.2.3.Final + 3.0.1-b12 + + + 3.10.0 + + + 5.8.2 + 3.23.1 + 2.2 + 2.7.0 + + + 4.6.1 + 3.6.0 + 2.5.20-1 + 3.1.4 + 1.11.4 topjava - install + package org.apache.maven.plugins @@ -30,15 +65,405 @@ ${java.version} + + org.apache.maven.plugins + maven-war-plugin + 3.3.2 + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + -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} + + + + + + + + org.slf4j + slf4j-api + ${slf4j.version} + compile + + + + ch.qos.logback + logback-classic + ${logback.version} + runtime + + + + javax.annotation + javax.annotation-api + 1.3.2 + + + + + org.springframework + spring-context-support + + + org.springframework.data + spring-data-jpa + ${spring-data-jpa.version} + + + + io.springfox + springfox-swagger2 + 2.9.2 + + + io.springfox + springfox-swagger-ui + 2.9.2 + + + + + org.springframework.security + spring-security-web + ${spring.security.version} + + + org.springframework.security + spring-security-config + ${spring.security.version} + + + org.springframework.security + spring-security-taglibs + ${spring.security.version} + + + + + org.hibernate + hibernate-core + ${hibernate.version} + + + + + org.hibernate.validator + hibernate-validator + ${hibernate-validator.version} + + + org.jsoup + jsoup + 1.14.3 + + + + + org.glassfish + javax.el + ${javax-el.version} + provided + + + + + org.hibernate + hibernate-jcache + ${hibernate.version} + + + 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 + + + + + org.apache.tomcat + tomcat-servlet-api + ${tomcat.version} + provided + + + + javax.servlet + jstl + 1.2 + + + + org.springframework + spring-webmvc + + + + + org.webjars + jquery + ${webjars-jquery.version} + + + org.webjars + bootstrap + ${webjars-bootstrap.version} + + + org.webjars + jquery + + + + org.webjars + popper.js + + + + + org.webjars + datatables + ${webjars-datatables.version} + + + org.webjars + jquery + + + + + org.webjars + datetimepicker + ${webjars-datetimepicker.version} + + + org.webjars.bower + noty + ${webjars-noty.version} + + + + + 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} + + + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + org.hamcrest + hamcrest-core + ${hamcrest.version} + test + + + com.jayway.jsonpath + json-path + ${json-path.version} + test + + + + org.springframework + spring-test + test + + + + org.springframework.security + spring-security-test + ${spring.security.version} + test + + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + org.junit.platform + junit-platform-launcher + 1.8.2 + test + + + hsqldb + + + org.hsqldb + hsqldb + 2.6.1 + + + + + + postgres + + + org.postgresql + postgresql + ${postgresql.version} + + + org.apache.tomcat + tomcat-jdbc + ${tomcat.version} + provided + + + org.slf4j + jul-to-slf4j + ${slf4j.version} + runtime + + + + true + + + + heroku + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.3.0 + + + package + + copy + + + + + + com.heroku + webapp-runner-main + 9.0.52.1 + webapp-runner.jar + + + + + + + + + + + org.postgresql + postgresql + ${postgresql.version} + + + org.apache.tomcat + tomcat-jdbc + ${tomcat.version} + + + + + + org.springframework + spring-framework-bom + ${spring.version} + pom + import + + diff --git a/settings.xml b/settings.xml new file mode 100644 index 000000000000..9681d723272c --- /dev/null +++ b/settings.xml @@ -0,0 +1,9 @@ + + + + + heroku + + 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 000000000000..2126d687bde3 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/AuthorizedUser.java @@ -0,0 +1,37 @@ +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()); + setTo(UserUtil.asTo(user)); + } + + public int getId() { + return userTo.id(); + } + + public void setTo(UserTo newTo) { + newTo.setPassword(null); + 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 000000000000..2ba30235cb87 --- /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/HasIdAndEmail.java b/src/main/java/ru/javawebinar/topjava/HasIdAndEmail.java new file mode 100644 index 000000000000..6389876b5dc1 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/HasIdAndEmail.java @@ -0,0 +1,5 @@ +package ru.javawebinar.topjava; + +public interface HasIdAndEmail extends HasId { + String getEmail(); +} \ No newline at end of file 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 c2f9cc618f7c..000000000000 --- 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/Profiles.java b/src/main/java/ru/javawebinar/topjava/Profiles.java new file mode 100644 index 000000000000..19dc1d13ae3d --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/Profiles.java @@ -0,0 +1,28 @@ +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", + HEROKU = "heroku"; + + // 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/View.java b/src/main/java/ru/javawebinar/topjava/View.java new file mode 100644 index 000000000000..f1623c0536b0 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/View.java @@ -0,0 +1,11 @@ +package ru.javawebinar.topjava; + +import javax.validation.groups.Default; + +public class View { + // Validate only form UI/REST + public interface Web extends Default {} + + // Validate only when DB save/update + public interface Persist extends Default {} +} \ No newline at end of file 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 000000000000..536c5c986e55 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractBaseEntity.java @@ -0,0 +1,62 @@ +package ru.javawebinar.topjava.model; + +import org.hibernate.Hibernate; +import org.springframework.util.Assert; +import ru.javawebinar.topjava.HasId; + +import javax.persistence.*; + +@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 HasId { + public static final int START_SEQ = 100000; + + @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 + protected Integer id; + + protected AbstractBaseEntity() { + } + + protected AbstractBaseEntity(Integer id) { + this.id = id; + } + + @Override + public void setId(Integer id) { + this.id = id; + } + + @Override + public Integer getId() { + return id; + } + + @Override + public String toString() { + return getClass().getSimpleName() + ":" + id; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || !getClass().equals(Hibernate.getClass(o))) { + 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 new file mode 100644 index 000000000000..045b957ec077 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/AbstractNamedEntity.java @@ -0,0 +1,40 @@ +package ru.javawebinar.topjava.model; + +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.util.validation.NoHtml; + +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) + @NoHtml(groups = {View.Web.class}) + protected String name; + + protected AbstractNamedEntity() { + } + + 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 new file mode 100644 index 000000000000..2a6fc072898b --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/Meal.java @@ -0,0 +1,119 @@ +package ru.javawebinar.topjava.model; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.validator.constraints.Range; +import org.springframework.format.annotation.DateTimeFormat; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.util.DateTimeUtil; +import ru.javawebinar.topjava.util.validation.NoHtml; + +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 + @DateTimeFormat(pattern = DateTimeUtil.DATE_TIME_PATTERN) + private LocalDateTime dateTime; + + @Column(name = "description", nullable = false) + @NotBlank + @Size(min = 2, max = 120) + @NoHtml(groups = {View.Web.class}) + private String description; + + @Column(name = "calories", nullable = false) + @NotNull + @Range(min = 10, max = 5000) + private Integer calories; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JsonBackReference + @NotNull(groups = View.Persist.class) + private User user; + + public Meal() { + } + + public Meal(Integer id, LocalDateTime dateTime, String description, int calories) { + super(id); + this.dateTime = dateTime; + this.description = description; + this.calories = calories; + } + + public LocalDateTime getDateTime() { + return dateTime; + } + + public String getDescription() { + return description; + } + + public int getCalories() { + return calories; + } + + public LocalDate getDate() { + return dateTime.toLocalDate(); + } + + 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(Integer calories) { + this.calories = calories; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + @Override + public String toString() { + return "Meal{" + + "id=" + id + + ", dateTime=" + dateTime + + ", description='" + description + '\'' + + ", calories=" + calories + + '}'; + } +} 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 000000000000..27f3e52311f8 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/Role.java @@ -0,0 +1,14 @@ +package ru.javawebinar.topjava.model; + +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { + USER, + 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 new file mode 100644 index 000000000000..249ee4dd09ca --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/model/User.java @@ -0,0 +1,174 @@ +package ru.javawebinar.topjava.model; + +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.*; +import org.hibernate.validator.constraints.Range; +import org.springframework.util.CollectionUtils; +import ru.javawebinar.topjava.HasIdAndEmail; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.util.validation.NoHtml; + +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; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.util.*; + +import static ru.javawebinar.topjava.util.UserUtil.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 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 +@Table(name = "users") +public class User extends AbstractNamedEntity implements HasIdAndEmail { + + 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) + @NoHtml(groups = {View.Web.class}) // https://stackoverflow.com/questions/17480809 + private String email; + + @Column(name = "password", nullable = false) + @NotBlank + @Size(min = 5, max = 128) + // https://stackoverflow.com/a/12505165/548473 + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String password; + + @Column(name = "enabled", nullable = false, columnDefinition = "bool default true") + private boolean enabled = true; + + @Column(name = "registered", nullable = false, columnDefinition = "timestamp default now()", updatable = false) + @NotNull + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Date registered = new Date(); + + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + @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 + @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 + @JsonManagedReference + private List meals; + + 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, int caloriesPerDay, Role... roles) { + this(id, name, email, password, caloriesPerDay, true, new Date(), Arrays.asList((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); + } + + @Override + 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; + } + + public List getMeals() { + return meals; + } + + public void setMeals(List meals) { + this.meals = meals; + } + + @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 000000000000..1ad7f8d943d7 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/MealRepository.java @@ -0,0 +1,27 @@ +package ru.javawebinar.topjava.repository; + +import ru.javawebinar.topjava.model.Meal; + +import java.time.LocalDateTime; +import java.util.List; + +public interface MealRepository { + // null if updated meal does not belong to userId + Meal save(Meal meal, int userId); + + // false if meal does not belong to userId + boolean delete(int id, int userId); + + // null if meal does not belong to userId + Meal get(int id, int userId); + + // ORDERED dateTime desc + List getAll(int userId); + + // 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 new file mode 100644 index 000000000000..9fecbddaaa19 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/UserRepository.java @@ -0,0 +1,25 @@ +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(); + + 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 new file mode 100644 index 000000000000..9aeef134f236 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudMealRepository.java @@ -0,0 +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 new file mode 100644 index 000000000000..f3f362bdfaf0 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/CrudUserRepository.java @@ -0,0 +1,27 @@ +package ru.javawebinar.topjava.repository.datajpa; + +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 + @Modifying + @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); + + // 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 new file mode 100644 index 000000000000..b5f4e3eebfdd --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaMealRepository.java @@ -0,0 +1,58 @@ +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; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class DataJpaMealRepository implements MealRepository { + + private final CrudMealRepository crudMealRepository; + private final CrudUserRepository crudUserRepository; + + public DataJpaMealRepository(CrudMealRepository crudMealRepository, CrudUserRepository crudUserRepository) { + this.crudMealRepository = crudMealRepository; + this.crudUserRepository = crudUserRepository; + } + + @Override + @Transactional + public Meal save(Meal meal, int userId) { + if (!meal.isNew() && get(meal.id(), userId) == null) { + return null; + } + meal.setUser(crudUserRepository.getReferenceById(userId)); + return crudMealRepository.save(meal); + } + + @Override + public boolean delete(int id, int userId) { + return crudMealRepository.delete(id, userId) != 0; + } + + @Override + public Meal get(int id, int userId) { + return crudMealRepository.findById(id) + .filter(meal -> meal.getUser().getId() == userId) + .orElse(null); + } + + @Override + public List getAll(int userId) { + return crudMealRepository.getAll(userId); + } + + @Override + public List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId) { + 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 new file mode 100644 index 000000000000..608c855e0a05 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/datajpa/DataJpaUserRepository.java @@ -0,0 +1,49 @@ +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); + } + + @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 new file mode 100644 index 000000000000..e2e6e1c76ecd --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcMealRepository.java @@ -0,0 +1,91 @@ +package ru.javawebinar.topjava.repository.jdbc; + +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 org.springframework.transaction.annotation.Transactional; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.repository.MealRepository; +import ru.javawebinar.topjava.util.validation.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); + + private final JdbcTemplate jdbcTemplate; + + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + + private final SimpleJdbcInsert insertMeal; + + public JdbcMealRepository(JdbcTemplate jdbcTemplate, NamedParameterJdbcTemplate namedParameterJdbcTemplate) { + this.insertMeal = new SimpleJdbcInsert(jdbcTemplate) + .withTableName("meals") + .usingGeneratedKeyColumns("id"); + + this.jdbcTemplate = jdbcTemplate; + this.namedParameterJdbcTemplate = 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()) + .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 + @Transactional + public boolean delete(int id, int userId) { + return jdbcTemplate.update("DELETE FROM meals WHERE id=? AND user_id=?", id, userId) != 0; + } + + @Override + public Meal get(int id, int userId) { + 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 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 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 new file mode 100644 index 000000000000..bb7df590cb64 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/jdbc/JdbcUserRepository.java @@ -0,0 +1,123 @@ +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.BeanPropertySqlParameterSource; +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.validation.ValidationUtil; + +import java.util.*; + +@Repository +@Transactional(readOnly = true) +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 + @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()); + 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; + } + + @Override + public User get(int id) { + List users = jdbcTemplate.query("SELECT * FROM users WHERE id=?", ROW_MAPPER, id); + 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 setRoles(DataAccessUtils.singleResult(users)); + } + + @Override + public List getAll() { + 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/JpaMealRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaMealRepository.java new file mode 100644 index 000000000000..300a920aea2f --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaMealRepository.java @@ -0,0 +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) { + 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 em.createNamedQuery(Meal.DELETE) + .setParameter("id", id) + .setParameter("userId", userId) + .executeUpdate() != 0; + } + + @Override + public Meal get(int id, int userId) { + Meal meal = em.find(Meal.class, id); + return meal != null && meal.getUser().getId() == userId ? meal : null; + } + + @Override + public List getAll(int userId) { + return em.createNamedQuery(Meal.ALL_SORTED, Meal.class) + .setParameter("userId", userId) + .getResultList(); + } + + @Override + public List getBetweenHalfOpen(LocalDateTime startDateTime, LocalDateTime endDateTime, int userId) { + 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/repository/jpa/JpaUserRepository.java b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaUserRepository.java new file mode 100644 index 000000000000..22fa8f4e35e1 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/repository/jpa/JpaUserRepository.java @@ -0,0 +1,75 @@ +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; +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) + .setHint(QueryHints.HINT_PASS_DISTINCT_THROUGH, false) + .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 new file mode 100644 index 000000000000..29f1d36133a9 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/service/MealService.java @@ -0,0 +1,54 @@ +package ru.javawebinar.topjava.service; + +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; + +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.validation.ValidationUtil.checkNotFoundWithId; + +@Service +public class MealService { + + 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) { + 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); + } + + 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 new file mode 100644 index 000000000000..677a937bb7ec --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/service/UserService.java @@ -0,0 +1,123 @@ +package ru.javawebinar.topjava.service; + +import org.springframework.beans.factory.annotation.Autowired; +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.core.env.Environment; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; +import ru.javawebinar.topjava.AuthorizedUser; +import ru.javawebinar.topjava.Profiles; +import ru.javawebinar.topjava.model.AbstractBaseEntity; +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 ru.javawebinar.topjava.util.exception.UpdateRestrictionException; + +import java.util.List; + +import static ru.javawebinar.topjava.util.UserUtil.prepareToSave; +import static ru.javawebinar.topjava.util.validation.ValidationUtil.checkNotFound; +import static ru.javawebinar.topjava.util.validation.ValidationUtil.checkNotFoundWithId; + +@Service("userService") +@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS) +public class UserService implements UserDetailsService { + + private final UserRepository repository; + private final PasswordEncoder passwordEncoder; + + private boolean modificationRestriction; + + @Autowired + @SuppressWarnings("deprecation") + public void setEnvironment(Environment environment) { + modificationRestriction = environment.acceptsProfiles(Profiles.HEROKU); + } + + public UserService(UserRepository repository, PasswordEncoder passwordEncoder) { + this.repository = repository; + this.passwordEncoder = passwordEncoder; + } + + @CacheEvict(value = "users", allEntries = true) + public User create(User user) { + Assert.notNull(user, "user must not be null"); + return prepareAndSave(user); + } + + @CacheEvict(value = "users", allEntries = true) + public void delete(int id) { + checkModificationAllowed(id); + checkNotFoundWithId(repository.delete(id), id); + } + + public User get(int id) { + return checkNotFoundWithId(repository.get(id), id); + } + + public User getByEmail(String email) { + Assert.notNull(email, "email must not be null"); + 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 : check works only for JDBC, disabled + checkModificationAllowed(user.id()); + prepareAndSave(user); + } + + @CacheEvict(value = "users", allEntries = true) + @Transactional + public void update(UserTo userTo) { + checkModificationAllowed(userTo.id()); + User user = get(userTo.id()); + prepareAndSave(UserUtil.updateFromTo(user, userTo)); + } + + @CacheEvict(value = "users", allEntries = true) + @Transactional + public void enable(int id, boolean enabled) { + checkModificationAllowed(id); + 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); + } + + private User prepareAndSave(User user) { + return repository.save(prepareToSave(user, passwordEncoder)); + } + + public User getWithMeals(int id) { + return checkNotFoundWithId(repository.getWithMeals(id), id); + } + + protected void checkModificationAllowed(int id) { + if (modificationRestriction && id < AbstractBaseEntity.START_SEQ + 2) { + throw new UpdateRestrictionException(); + } + } +} \ No newline at end of file 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 000000000000..b7a7de6b7d66 --- /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/MealTo.java b/src/main/java/ru/javawebinar/topjava/to/MealTo.java new file mode 100644 index 000000000000..059f14a44f35 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/to/MealTo.java @@ -0,0 +1,69 @@ +package ru.javawebinar.topjava.to; + +import java.beans.ConstructorProperties; +import java.time.LocalDateTime; +import java.util.Objects; + +public class MealTo extends BaseTo { + + private final LocalDateTime dateTime; + + private final String description; + + private final int calories; + + private final boolean excess; + + @ConstructorProperties({"id", "dateTime", "description", "calories", "excess"}) + public MealTo(Integer id, LocalDateTime dateTime, String description, int calories, boolean excess) { + super(id); + this.dateTime = dateTime; + this.description = description; + this.calories = calories; + this.excess = excess; + } + + public LocalDateTime getDateTime() { + return dateTime; + } + + public String getDescription() { + return description; + } + + public int getCalories() { + return calories; + } + + public boolean isExcess() { + return excess; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MealTo mealTo = (MealTo) o; + return calories == mealTo.calories && + excess == mealTo.excess && + Objects.equals(id, mealTo.id) && + Objects.equals(dateTime, mealTo.dateTime) && + Objects.equals(description, mealTo.description); + } + + @Override + public int hashCode() { + return Objects.hash(id, dateTime, description, calories, excess); + } + + @Override + public String toString() { + return "MealTo{" + + "id=" + id + + ", dateTime=" + dateTime + + ", description='" + description + '\'' + + ", calories=" + calories + + ", excess=" + excess + + '}'; + } +} 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 000000000000..bf5296d69c53 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/to/UserTo.java @@ -0,0 +1,91 @@ +package ru.javawebinar.topjava.to; + +import org.hibernate.validator.constraints.Range; +import ru.javawebinar.topjava.HasIdAndEmail; +import ru.javawebinar.topjava.util.validation.NoHtml; +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 HasIdAndEmail, Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @NotBlank + @Size(min = 2, max = 100) + @NoHtml + private String name; + + @Email + @NotBlank + @Size(max = 100) + @NoHtml // https://stackoverflow.com/questions/17480809 + private String email; + + @NotBlank + @Size(min = 5, max = 32) + 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; + } + + @Override + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public void setCaloriesPerDay(Integer caloriesPerDay) { + this.caloriesPerDay = caloriesPerDay; + } + + 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/DateTimeUtil.java b/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java new file mode 100644 index 000000000000..1fb662b11dbf --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/DateTimeUtil.java @@ -0,0 +1,44 @@ +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 { + public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm"; + public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN); + + // DB doesn't support LocalDate.MIN/MAX + private static final LocalDateTime MIN_DATE = LocalDateTime.of(1, 1, 1, 0, 0); + 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; + } + + 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 new file mode 100644 index 000000000000..af07e74d3ec7 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/MealsUtil.java @@ -0,0 +1,43 @@ +package ru.javawebinar.topjava.util; + +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.to.MealTo; + +import java.time.LocalDate; +import java.time.LocalTime; +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 { + + private MealsUtil() { + } + + 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 -> Util.isBetweenHalfOpen(meal.getTime(), startTime, endTime)); + } + + private static List filterByPredicate(Collection meals, int caloriesPerDay, Predicate filter) { + 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(filter) + .map(meal -> createTo(meal, caloriesSumByDate.get(meal.getDate()) > caloriesPerDay)) + .toList(); + } + + public static MealTo createTo(Meal meal, boolean excess) { + return new MealTo(meal.getId(), meal.getDateTime(), meal.getDescription(), meal.getCalories(), excess); + } +} 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 000000000000..a0700dd4931c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/UserUtil.java @@ -0,0 +1,33 @@ +package ru.javawebinar.topjava.util; + +import org.springframework.security.crypto.password.PasswordEncoder; +import ru.javawebinar.topjava.model.Role; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.to.UserTo; + +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(), userTo.getCaloriesPerDay(), 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; + } + + public static User prepareToSave(User user, PasswordEncoder passwordEncoder) { + user.setPassword(passwordEncoder.encode(user.getPassword())); + user.setEmail(user.getEmail().toLowerCase()); + return user; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/Util.java b/src/main/java/ru/javawebinar/topjava/util/Util.java new file mode 100644 index 000000000000..9a083383d04e --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/Util.java @@ -0,0 +1,13 @@ +package ru.javawebinar.topjava.util; + +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); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/exception/ApplicationException.java b/src/main/java/ru/javawebinar/topjava/util/exception/ApplicationException.java new file mode 100644 index 000000000000..83bf0fed5626 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/exception/ApplicationException.java @@ -0,0 +1,20 @@ +package ru.javawebinar.topjava.util.exception; + +public class ApplicationException extends RuntimeException { + + private final ErrorType type; + private final String msgCode; + + public ApplicationException(String msgCode, ErrorType type) { + this.msgCode = msgCode; + this.type = type; + } + + public String getMsgCode() { + return msgCode; + } + + public ErrorType getType() { + return type; + } +} diff --git a/src/main/java/ru/javawebinar/topjava/util/exception/ErrorInfo.java b/src/main/java/ru/javawebinar/topjava/util/exception/ErrorInfo.java new file mode 100644 index 000000000000..6a1f7a16a247 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/exception/ErrorInfo.java @@ -0,0 +1,15 @@ +package ru.javawebinar.topjava.util.exception; + +public class ErrorInfo { + private final String url; + private final ErrorType type; + private final String typeMessage; + private final String[] details; + + public ErrorInfo(CharSequence url, ErrorType type, String typeMessage, String... details) { + this.url = url.toString(); + this.type = type; + this.typeMessage = typeMessage; + this.details = details; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/exception/ErrorType.java b/src/main/java/ru/javawebinar/topjava/util/exception/ErrorType.java new file mode 100644 index 000000000000..5c16cf8ffa6e --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/exception/ErrorType.java @@ -0,0 +1,28 @@ +package ru.javawebinar.topjava.util.exception; + +import org.springframework.http.HttpStatus; + +public enum ErrorType { + APP_ERROR("error.appError", HttpStatus.INTERNAL_SERVER_ERROR), + // http://stackoverflow.com/a/22358422/548473 + DATA_NOT_FOUND("error.dataNotFound", HttpStatus.UNPROCESSABLE_ENTITY), + DATA_ERROR("error.dataError", HttpStatus.CONFLICT), + VALIDATION_ERROR("error.validationError", HttpStatus.UNPROCESSABLE_ENTITY), + WRONG_REQUEST("error.wrongRequest", HttpStatus.BAD_REQUEST); + + private final String errorCode; + private final HttpStatus status; + + ErrorType(String errorCode, HttpStatus status) { + this.errorCode = errorCode; + this.status = status; + } + + public String getErrorCode() { + return errorCode; + } + + public HttpStatus getStatus() { + return status; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/exception/IllegalRequestDataException.java b/src/main/java/ru/javawebinar/topjava/util/exception/IllegalRequestDataException.java new file mode 100644 index 000000000000..2b144f91c91c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/exception/IllegalRequestDataException.java @@ -0,0 +1,7 @@ +package ru.javawebinar.topjava.util.exception; + +public class IllegalRequestDataException extends RuntimeException { + public IllegalRequestDataException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/util/exception/NotFoundException.java b/src/main/java/ru/javawebinar/topjava/util/exception/NotFoundException.java new file mode 100644 index 000000000000..f1e9b0e46376 --- /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/util/exception/UpdateRestrictionException.java b/src/main/java/ru/javawebinar/topjava/util/exception/UpdateRestrictionException.java new file mode 100644 index 000000000000..abf3d36fe4a7 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/exception/UpdateRestrictionException.java @@ -0,0 +1,9 @@ +package ru.javawebinar.topjava.util.exception; + +public class UpdateRestrictionException extends ApplicationException { + public static final String EXCEPTION_UPDATE_RESTRICTION = "exception.user.updateRestriction"; + + public UpdateRestrictionException() { + super(EXCEPTION_UPDATE_RESTRICTION, ErrorType.VALIDATION_ERROR); + } +} diff --git a/src/main/java/ru/javawebinar/topjava/util/validation/NoHtml.java b/src/main/java/ru/javawebinar/topjava/util/validation/NoHtml.java new file mode 100644 index 000000000000..b1b2a3b87531 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/validation/NoHtml.java @@ -0,0 +1,23 @@ +package ru.javawebinar.topjava.util.validation; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Documented +@Constraint(validatedBy = NoHtmlValidator.class) +@Target({METHOD, FIELD}) +@Retention(RUNTIME) +public @interface NoHtml { + String message() default "Unsafe html content"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/ru/javawebinar/topjava/util/validation/NoHtmlValidator.java b/src/main/java/ru/javawebinar/topjava/util/validation/NoHtmlValidator.java new file mode 100644 index 000000000000..26d52d7ef05e --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/validation/NoHtmlValidator.java @@ -0,0 +1,14 @@ +package ru.javawebinar.topjava.util.validation; + +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class NoHtmlValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext ctx) { + return value == null || Jsoup.isValid(value, Safelist.none()); + } +} diff --git a/src/main/java/ru/javawebinar/topjava/util/validation/ValidationUtil.java b/src/main/java/ru/javawebinar/topjava/util/validation/ValidationUtil.java new file mode 100644 index 000000000000..6b0384c35dfb --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/util/validation/ValidationUtil.java @@ -0,0 +1,92 @@ +package ru.javawebinar.topjava.util.validation; + +import org.slf4j.Logger; +import org.springframework.core.NestedExceptionUtils; +import org.springframework.lang.NonNull; +import ru.javawebinar.topjava.HasId; +import ru.javawebinar.topjava.util.exception.ErrorType; +import ru.javawebinar.topjava.util.exception.IllegalRequestDataException; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +import javax.servlet.http.HttpServletRequest; +import 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; + } + + 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(HasId bean) { + if (!bean.isNew()) { + throw new IllegalRequestDataException(bean + " must be new (id=null)"); + } + } + + public static void assureIdConsistent(HasId bean, int id) { +// conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473) + if (bean.isNew()) { + bean.setId(id); + } else if (bean.id() != id) { + throw new IllegalRequestDataException(bean + " 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; + } + + public static String getMessage(Throwable e) { + return e.getLocalizedMessage() != null ? e.getLocalizedMessage() : e.getClass().getName(); + } + + public static Throwable logAndGetRootCause(Logger log, HttpServletRequest req, Exception e, boolean logStackTrace, ErrorType errorType) { + Throwable rootCause = ValidationUtil.getRootCause(e); + if (logStackTrace) { + log.error(errorType + " at request " + req.getRequestURL(), rootCause); + } else { + log.warn("{} at request {}: {}", errorType, req.getRequestURL(), rootCause.toString()); + } + return rootCause; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/ExceptionInfoHandler.java b/src/main/java/ru/javawebinar/topjava/web/ExceptionInfoHandler.java new file mode 100644 index 000000000000..39ec6495fd76 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/ExceptionInfoHandler.java @@ -0,0 +1,94 @@ +package ru.javawebinar.topjava.web; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import ru.javawebinar.topjava.util.validation.ValidationUtil; +import ru.javawebinar.topjava.util.exception.*; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; + +import static ru.javawebinar.topjava.util.exception.ErrorType.*; + +@RestControllerAdvice(annotations = RestController.class) +@Order(Ordered.HIGHEST_PRECEDENCE + 5) +public class ExceptionInfoHandler { + private static final Logger log = LoggerFactory.getLogger(ExceptionInfoHandler.class); + + public static final String EXCEPTION_DUPLICATE_EMAIL = "exception.user.duplicateEmail"; + public static final String EXCEPTION_DUPLICATE_DATETIME = "exception.meal.duplicateDateTime"; + + private static final Map CONSTRAINTS_I18N_MAP = Map.of( + "users_unique_email_idx", EXCEPTION_DUPLICATE_EMAIL, + "meals_unique_user_datetime_idx", EXCEPTION_DUPLICATE_DATETIME); + + private final MessageSourceAccessor messageSourceAccessor; + + public ExceptionInfoHandler(MessageSourceAccessor messageSourceAccessor) { + this.messageSourceAccessor = messageSourceAccessor; + } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity notFoundError(HttpServletRequest req, NotFoundException e) { + return logAndGetErrorInfo(req, e, false, DATA_NOT_FOUND); + } + + @ExceptionHandler(ApplicationException.class) + public ResponseEntity updateRestrictionError(HttpServletRequest req, ApplicationException appEx) { + return logAndGetErrorInfo(req, appEx, false, appEx.getType(), messageSourceAccessor.getMessage(appEx.getMsgCode())); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity conflict(HttpServletRequest req, DataIntegrityViolationException e) { + String rootMsg = ValidationUtil.getRootCause(e).getMessage(); + if (rootMsg != null) { + String lowerCaseMsg = rootMsg.toLowerCase(); + for (Map.Entry entry : CONSTRAINTS_I18N_MAP.entrySet()) { + if (lowerCaseMsg.contains(entry.getKey())) { + return logAndGetErrorInfo(req, e, false, VALIDATION_ERROR, messageSourceAccessor.getMessage(entry.getValue())); + } + } + } + return logAndGetErrorInfo(req, e, true, DATA_ERROR); + } + + @ExceptionHandler(BindException.class) + public ResponseEntity bindValidationError(HttpServletRequest req, BindException e) { + String[] details = e.getBindingResult().getFieldErrors().stream() + .map(messageSourceAccessor::getMessage) + .toArray(String[]::new); + + return logAndGetErrorInfo(req, e, false, VALIDATION_ERROR, details); + } + + @ExceptionHandler({IllegalRequestDataException.class, MethodArgumentTypeMismatchException.class, HttpMessageNotReadableException.class}) + public ResponseEntity validationError(HttpServletRequest req, Exception e) { + return logAndGetErrorInfo(req, e, false, VALIDATION_ERROR); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity internalError(HttpServletRequest req, Exception e) { + return logAndGetErrorInfo(req, e, true, APP_ERROR); + } + + // https://stackoverflow.com/questions/538870/should-private-helper-methods-be-static-if-they-can-be-static + private ResponseEntity logAndGetErrorInfo(HttpServletRequest req, Exception e, boolean logStackTrace, ErrorType errorType, String... details) { + Throwable rootCause = ValidationUtil.logAndGetRootCause(log, req, e, logStackTrace, errorType); + return ResponseEntity.status(errorType.getStatus()) + .body(new ErrorInfo(req.getRequestURL(), errorType, + messageSourceAccessor.getMessage(errorType.getErrorCode()), + details.length != 0 ? details : new String[]{ValidationUtil.getMessage(rootCause)}) + ); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/GlobalExceptionHandler.java b/src/main/java/ru/javawebinar/topjava/web/GlobalExceptionHandler.java new file mode 100644 index 000000000000..521985ae074f --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package ru.javawebinar.topjava.web; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.NoHandlerFoundException; +import ru.javawebinar.topjava.util.exception.ApplicationException; +import ru.javawebinar.topjava.util.exception.ErrorType; +import ru.javawebinar.topjava.util.validation.ValidationUtil; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; + +@ControllerAdvice +public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + private final MessageSourceAccessor messageSourceAccessor; + + public GlobalExceptionHandler(MessageSourceAccessor messageSourceAccessor) { + this.messageSourceAccessor = messageSourceAccessor; + } + + @ExceptionHandler(NoHandlerFoundException.class) + public ModelAndView wrongRequest(HttpServletRequest req, NoHandlerFoundException e) { + return logAndGetExceptionView(req, e, false, ErrorType.WRONG_REQUEST, null); + } + + @ExceptionHandler(ApplicationException.class) + public ModelAndView updateRestrictionException(HttpServletRequest req, ApplicationException appEx) { + return logAndGetExceptionView(req, appEx, false, appEx.getType(), appEx.getMsgCode()); + } + + @ExceptionHandler(Exception.class) + public ModelAndView defaultErrorHandler(HttpServletRequest req, Exception e) throws Exception { + log.error("Exception at request " + req.getRequestURL(), e); + return logAndGetExceptionView(req, e, true, ErrorType.APP_ERROR, null); + } + + private ModelAndView logAndGetExceptionView(HttpServletRequest req, Exception e, boolean logException, ErrorType errorType, String code) { + Throwable rootCause = ValidationUtil.logAndGetRootCause(log, req, e, logException, errorType); + + ModelAndView mav = new ModelAndView("exception", + Map.of("exception", rootCause, "message", code != null ? messageSourceAccessor.getMessage(code) : ValidationUtil.getMessage(rootCause), + "typeMessage", messageSourceAccessor.getMessage(errorType.getErrorCode()), + "status", errorType.getStatus())); + mav.setStatus(errorType.getStatus()); + return mav; + } +} 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 000000000000..c6fbe3a78345 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/RootController.java @@ -0,0 +1,40 @@ +package ru.javawebinar.topjava.web; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import springfox.documentation.annotations.ApiIgnore; + +@ApiIgnore +@Controller +public class RootController { + private static final Logger log = LoggerFactory.getLogger(RootController.class); + + @GetMapping("/") + public String root() { + log.info("root"); + return "redirect:meals"; + } + + // @Secured("ROLE_ADMIN") + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/users") + public String getUsers() { + log.info("users"); + return "users"; + } + + @GetMapping("/login") + public String login() { + log.info("login"); + return "login"; + } + + @GetMapping("/meals") + public String getMeals() { + log.info("meals"); + return "meals"; + } +} 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 000000000000..ac5b99f052f1 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/SecurityUtil.java @@ -0,0 +1,34 @@ +package ru.javawebinar.topjava.web; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import ru.javawebinar.topjava.AuthorizedUser; + +import static java.util.Objects.requireNonNull; + +public class SecurityUtil { + + private SecurityUtil() { + } + + 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 AuthorizedUser get() { + return requireNonNull(safeGet(), "No authorized user found"); + } + + public static int authUserId() { + return get().getUserTo().id(); + } + + public static int authUserCaloriesPerDay() { + return get().getUserTo().getCaloriesPerDay(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/converter/DateTimeFormatters.java b/src/main/java/ru/javawebinar/topjava/web/converter/DateTimeFormatters.java new file mode 100644 index 000000000000..bc4409869cce --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/converter/DateTimeFormatters.java @@ -0,0 +1,39 @@ +package ru.javawebinar.topjava.web.converter; + +import org.springframework.format.Formatter; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +import static ru.javawebinar.topjava.util.DateTimeUtil.parseLocalDate; +import static ru.javawebinar.topjava.util.DateTimeUtil.parseLocalTime; + +public class DateTimeFormatters { + public static class LocalDateFormatter implements Formatter { + + @Override + public LocalDate parse(String text, Locale locale) { + return parseLocalDate(text); + } + + @Override + public String print(LocalDate lt, Locale locale) { + return lt.format(DateTimeFormatter.ISO_LOCAL_DATE); + } + } + + public static class LocalTimeFormatter implements Formatter { + + @Override + public LocalTime parse(String text, Locale locale) { + return parseLocalTime(text); + } + + @Override + public String print(LocalTime lt, Locale locale) { + return lt.format(DateTimeFormatter.ISO_LOCAL_TIME); + } + } +} 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 000000000000..8237df93bffe --- /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 000000000000..d3bf1ac0435f --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/json/JsonUtil.java @@ -0,0 +1,49 @@ +package ru.javawebinar.topjava.web.json; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectReader; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +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); + } + } + + public static String writeAdditionProps(T obj, String addName, Object addValue) { + return writeAdditionProps(obj, Map.of(addName, addValue)); + } + + public static String writeAdditionProps(T obj, Map addProps) { + Map map = getMapper().convertValue(obj, new TypeReference<>() {}); + map.putAll(addProps); + return writeValue(map); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/meal/AbstractMealController.java b/src/main/java/ru/javawebinar/topjava/web/meal/AbstractMealController.java new file mode 100644 index 000000000000..52b78a30f77f --- /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.validation.ValidationUtil.assureIdConsistent; +import static ru.javawebinar.topjava.util.validation.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/MealRestController.java b/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java new file mode 100644 index 000000000000..f9f1f5027576 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/meal/MealRestController.java @@ -0,0 +1,70 @@ +package ru.javawebinar.topjava.web.meal; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.to.MealTo; + +import java.net.URI; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +@RestController +@RequestMapping(value = MealRestController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) +public class MealRestController extends AbstractMealController { + static final String REST_URL = "/rest/profile/meals"; + + @Override + @GetMapping("/{id}") + public Meal get(@PathVariable int id) { + return super.get(id); + } + + @Override + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable int id) { + super.delete(id); + } + + @Override + @GetMapping + public List getAll() { + return super.getAll(); + } + + @Override + @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@Validated(View.Web.class) @RequestBody Meal meal, @PathVariable int id) { + super.update(meal, id); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createWithLocation(@Validated(View.Web.class) @RequestBody Meal meal) { + Meal created = super.create(meal); + + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL + "/{id}") + .buildAndExpand(created.getId()).toUri(); + + return ResponseEntity.created(uriOfNewResource).body(created); + } + + @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/meal/MealUIController.java b/src/main/java/ru/javawebinar/topjava/web/meal/MealUIController.java new file mode 100644 index 000000000000..28a59fca7ba2 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/meal/MealUIController.java @@ -0,0 +1,60 @@ +package ru.javawebinar.topjava.web.meal; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.model.Meal; +import ru.javawebinar.topjava.to.MealTo; +import springfox.documentation.annotations.ApiIgnore; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +@ApiIgnore +@RestController +@RequestMapping(value = "/profile/meals", produces = MediaType.APPLICATION_JSON_VALUE) +public class MealUIController extends AbstractMealController { + + @Override + @GetMapping + public List getAll() { + return super.getAll(); + } + + @Override + @GetMapping("/{id}") + public Meal get(@PathVariable int id) { + return super.get(id); + } + + @Override + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable int id) { + super.delete(id); + } + + @PostMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void createOrUpdate(@Validated(View.Web.class) Meal meal) { + if (meal.isNew()) { + super.create(meal); + } else { + super.update(meal, meal.getId()); + } + } + + @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 new file mode 100644 index 000000000000..6bc14232260a --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/user/AbstractUserController.java @@ -0,0 +1,85 @@ +package ru.javawebinar.topjava.web.user; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.InitBinder; +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; + +import static ru.javawebinar.topjava.util.validation.ValidationUtil.assureIdConsistent; +import static ru.javawebinar.topjava.util.validation.ValidationUtil.checkNew; + +public abstract class AbstractUserController { + protected final Logger log = LoggerFactory.getLogger(getClass()); + + @Autowired + private UserService service; + + @Autowired + private UniqueMailValidator emailValidator; + + @InitBinder + protected void initBinder(WebDataBinder binder) { + binder.addValidators(emailValidator); + } + + 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(UserTo userTo) { + log.info("create {}", userTo); + checkNew(userTo); + return service.create(UserUtil.createNewFromTo(userTo)); + } + + 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 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); + } + + 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 new file mode 100644 index 000000000000..896b22050173 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/user/AdminRestController.java @@ -0,0 +1,73 @@ +package ru.javawebinar.topjava.web.user; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.model.User; + +import java.net.URI; +import java.util.List; + +@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 + @GetMapping("/{id}") + public User get(@PathVariable int id) { + return super.get(id); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createWithLocation(@Validated(View.Web.class) @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 + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable int id) { + super.delete(id); + } + + @Override + @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@Validated(View.Web.class) @RequestBody User user, @PathVariable int id) { + super.update(user, id); + } + + @Override + @GetMapping("/by-email") + public User getByMail(@RequestParam String email) { + return super.getByMail(email); + } + + @GetMapping("/{id}/with-meals") + 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 new file mode 100644 index 000000000000..13d50695c85a --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/user/AdminUIController.java @@ -0,0 +1,54 @@ +package ru.javawebinar.topjava.web.user; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.to.UserTo; +import springfox.documentation.annotations.ApiIgnore; + +import java.util.List; + +@ApiIgnore +@RestController +@RequestMapping(value = "/admin/users", produces = MediaType.APPLICATION_JSON_VALUE) +public class AdminUIController extends AbstractUserController { + + @Override + @GetMapping + 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) + public void delete(@PathVariable int id) { + super.delete(id); + } + + @PostMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void createOrUpdate(@Validated(View.Web.class) UserTo userTo) { + if (userTo.isNew()) { + super.create(userTo); + } else { + super.update(userTo, userTo.id()); + } + } + + @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 new file mode 100644 index 000000000000..c33b8eb25e4c --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/user/ProfileRestController.java @@ -0,0 +1,58 @@ +package ru.javawebinar.topjava.web.user; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javawebinar.topjava.AuthorizedUser; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.to.UserTo; +import springfox.documentation.annotations.ApiIgnore; + +import java.net.URI; + +@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(@AuthenticationPrincipal @ApiIgnore AuthorizedUser authUser) { + return super.get(authUser.getId()); + } + + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@AuthenticationPrincipal @ApiIgnore AuthorizedUser authUser) { + super.delete(authUser.getId()); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public ResponseEntity register(@Validated(View.Web.class) @RequestBody UserTo userTo) { + User created = super.create(userTo); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL).build().toUri(); + return ResponseEntity.created(uriOfNewResource).body(created); + } + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@Validated(View.Web.class) @RequestBody UserTo userTo, @ApiIgnore @AuthenticationPrincipal AuthorizedUser authUser) { + super.update(userTo, authUser.getId()); + } + + @GetMapping("/text") + public String testUTF() { + return "Русский текст"; + } + + @GetMapping("/with-meals") + public User getWithMeals( @ApiIgnore @AuthenticationPrincipal AuthorizedUser authUser) { + return super.getWithMeals(authUser.getId()); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/user/ProfileUIController.java b/src/main/java/ru/javawebinar/topjava/web/user/ProfileUIController.java new file mode 100644 index 000000000000..e83ee01d1493 --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/user/ProfileUIController.java @@ -0,0 +1,56 @@ +package ru.javawebinar.topjava.web.user; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.validation.BindingResult; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.support.SessionStatus; +import ru.javawebinar.topjava.AuthorizedUser; +import ru.javawebinar.topjava.View; +import ru.javawebinar.topjava.to.UserTo; +import springfox.documentation.annotations.ApiIgnore; + +@ApiIgnore +@Controller +@RequestMapping("/profile") +public class ProfileUIController extends AbstractUserController { + + @GetMapping + public String profile(ModelMap model, @AuthenticationPrincipal AuthorizedUser authUser) { + model.addAttribute("userTo", authUser.getUserTo()); + return "profile"; + } + + @PostMapping + public String updateProfile(@Validated(View.Web.class) UserTo userTo, BindingResult result, SessionStatus status, @AuthenticationPrincipal AuthorizedUser authUser) { + if (result.hasErrors()) { + return "profile"; + } + super.update(userTo, authUser.getId()); + authUser.setTo(userTo); + status.setComplete(); + return "redirect:/meals"; + } + + @GetMapping("/register") + public String register(ModelMap model) { + model.addAttribute("userTo", new UserTo()); + model.addAttribute("register", true); + return "profile"; + } + + @PostMapping("/register") + public String saveRegister(@Validated(View.Web.class) UserTo userTo, BindingResult result, SessionStatus status, ModelMap model) { + if (result.hasErrors()) { + model.addAttribute("register", true); + return "profile"; + } + super.create(userTo); + status.setComplete(); + return "redirect:/login?message=app.registered&username=" + userTo.getEmail(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javawebinar/topjava/web/user/UniqueMailValidator.java b/src/main/java/ru/javawebinar/topjava/web/user/UniqueMailValidator.java new file mode 100644 index 000000000000..9a90091b77ad --- /dev/null +++ b/src/main/java/ru/javawebinar/topjava/web/user/UniqueMailValidator.java @@ -0,0 +1,54 @@ +package ru.javawebinar.topjava.web.user; + + +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.validation.Errors; +import ru.javawebinar.topjava.HasIdAndEmail; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.repository.UserRepository; +import ru.javawebinar.topjava.web.ExceptionInfoHandler; +import ru.javawebinar.topjava.web.SecurityUtil; + +import javax.servlet.http.HttpServletRequest; + +@Component +public class UniqueMailValidator implements org.springframework.validation.Validator { + + private final UserRepository repository; + private final HttpServletRequest request; + + public UniqueMailValidator(UserRepository repository, @Nullable HttpServletRequest request) { + this.repository = repository; + this.request = request; + } + + @Override + public boolean supports(Class clazz) { + return HasIdAndEmail.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + HasIdAndEmail user = ((HasIdAndEmail) target); + if (StringUtils.hasText(user.getEmail())) { + User dbUser = repository.getByEmail(user.getEmail().toLowerCase()); + if (dbUser != null) { + Assert.notNull(request, "HttpServletRequest missed"); + if (request.getMethod().equals("PUT") || (request.getMethod().equals("POST") && user.getId() != null)) { // update for REST(PUT) and UI(POST) + int dbId = dbUser.id(); + // it is ok, if update ourself + if (user.getId() != null && dbId == user.id()) return; + + // workaround for update with user.id=null in request body + // ValidationUtil.assureIdConsistent (id setter) called after this validation + String requestURI = request.getRequestURI(); + if (requestURI.endsWith("/" + dbId) || (dbId == SecurityUtil.get().getId() && requestURI.contains("/profile"))) return; + } + errors.rejectValue("email", ExceptionInfoHandler.EXCEPTION_DUPLICATE_EMAIL); + } + } + } +} diff --git a/src/main/resources/cache/ehcache.xml b/src/main/resources/cache/ehcache.xml new file mode 100644 index 000000000000..05589f71f06e --- /dev/null +++ b/src/main/resources/cache/ehcache.xml @@ -0,0 +1,25 @@ + + + + + + + + + + 5 + + 5000 + + + + + + + 1 + + + + diff --git a/src/main/resources/db/heroku.properties b/src/main/resources/db/heroku.properties new file mode 100644 index 000000000000..c8146ba6f139 --- /dev/null +++ b/src/main/resources/db/heroku.properties @@ -0,0 +1,5 @@ +jpa.showSql=false +hibernate.format_sql=false +hibernate.use_sql_comments=false +database.init=false +jdbc.initLocation=initDB.sql \ No newline at end of file diff --git a/src/main/resources/db/hsqldb.properties b/src/main/resources/db/hsqldb.properties new file mode 100644 index 000000000000..17c03ef4ebda --- /dev/null +++ b/src/main/resources/db/hsqldb.properties @@ -0,0 +1,11 @@ +#database.url=jdbc:hsqldb:file:D:/temp/topjava + +database.url=jdbc:hsqldb:mem:topjava +database.username=sa +database.password= + +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 new file mode 100644 index 000000000000..7644dc6100f4 --- /dev/null +++ b/src/main/resources/db/initDB.sql @@ -0,0 +1,37 @@ +DROP TABLE IF EXISTS user_roles; +DROP TABLE IF EXISTS meals; +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 NOT NULL, + CONSTRAINT user_roles_idx UNIQUE (user_id, role), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +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 000000000000..f2bb54b1ea6b --- /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) NOT NULL, + 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 new file mode 100644 index 000000000000..8265d3655bde --- /dev/null +++ b/src/main/resources/db/populateDB.sql @@ -0,0 +1,25 @@ +DELETE FROM user_roles; +DELETE FROM meals; +DELETE FROM users; +ALTER SEQUENCE global_seq RESTART WITH 100000; + +INSERT INTO users (name, email, password, calories_per_day) +VALUES ('User', 'user@yandex.ru', '{noop}password', 2005), + ('Admin', 'admin@gmail.com', '{noop}admin', 1900), + ('Guest', 'guest@gmail.com', '{noop}guest', 2000); + +INSERT INTO user_roles (role, user_id) +VALUES ('USER', 100000), + ('ADMIN', 100001), + ('USER', 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 new file mode 100644 index 000000000000..ba40447d4857 --- /dev/null +++ b/src/main/resources/db/postgres.properties @@ -0,0 +1,14 @@ +#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 + +database.init=true +jdbc.initLocation=classpath:db/initDB.sql +jpa.showSql=true +hibernate.format_sql=true +#https://hibernate.atlassian.net/browse/HHH-13280 +hibernate.use_sql_comments=false \ No newline at end of file diff --git a/src/main/resources/db/tomcat.properties b/src/main/resources/db/tomcat.properties new file mode 100644 index 000000000000..2e073681ad16 --- /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 new file mode 100644 index 000000000000..ab4cfe51e08a --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,33 @@ + + + + + + + + ${TOPJAVA_ROOT}/log/topjava.log + + + UTF-8 + %date %-5level %logger{50}.%M:%L - %msg%n + + + + + + UTF-8 + %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 new file mode 100644 index 000000000000..3a75ebb82910 --- /dev/null +++ b/src/main/resources/spring/spring-app.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/spring/spring-cache.xml b/src/main/resources/spring/spring-cache.xml new file mode 100644 index 000000000000..73325fee065f --- /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 new file mode 100644 index 000000000000..39f9cef70ba1 --- /dev/null +++ b/src/main/resources/spring/spring-db.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 000000000000..1610c627036e --- /dev/null +++ b/src/main/resources/spring/spring-mvc.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + text/plain;charset=UTF-8 + text/html;charset=UTF-8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/spring/spring-security.xml b/src/main/resources/spring/spring-security.xml new file mode 100644 index 000000000000..8988177c4c36 --- /dev/null +++ b/src/main/resources/spring/spring-security.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 000000000000..9311d5904aea --- /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/exception.jsp b/src/main/webapp/WEB-INF/jsp/exception.jsp new file mode 100644 index 000000000000..00a590c8751c --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/exception.jsp @@ -0,0 +1,26 @@ +<%@ page isErrorPage="true" contentType="text/html" pageEncoding="UTF-8" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + + + + + + +
+
+
+

${status}

+

${typeMessage}

+

${message}

+
+
+ + + + \ 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 new file mode 100644 index 000000000000..b355dc2547f0 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/fragments/bodyHeader.jsp @@ -0,0 +1,51 @@ +<%@page contentType="text/html" pageEncoding="UTF-8" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> +<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> +<%@taglib prefix="form" uri="http://www.springframework.org/tags/form" %> + + + diff --git a/src/main/webapp/WEB-INF/jsp/fragments/footer.jsp b/src/main/webapp/WEB-INF/jsp/fragments/footer.jsp new file mode 100644 index 000000000000..cf1331fd5523 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/fragments/footer.jsp @@ -0,0 +1,8 @@ +<%@page contentType="text/html" pageEncoding="UTF-8" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> +<%--https://getbootstrap.com/docs/4.0/examples/sticky-footer/--%> +
+
+ +
+
\ 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 000000000000..8c0600763cb4 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/fragments/headTag.jsp @@ -0,0 +1,29 @@ +<%@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"/> + + + + + + + + + + + <%--http://stackoverflow.com/a/24070373/548473--%> + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/fragments/i18n.jsp b/src/main/webapp/WEB-INF/jsp/fragments/i18n.jsp new file mode 100644 index 000000000000..ac1c7187f417 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/fragments/i18n.jsp @@ -0,0 +1,14 @@ +<%@ page contentType="text/html" pageEncoding="UTF-8" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/login.jsp b/src/main/webapp/WEB-INF/jsp/login.jsp new file mode 100644 index 000000000000..1944ccca336c --- /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" %> +<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> + + + + + + +
+
+ +
${sessionScope["SPRING_SECURITY_LAST_EXCEPTION"].message}
+
+ +
+
+ +
+ » + + +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/meals.jsp b/src/main/webapp/WEB-INF/jsp/meals.jsp new file mode 100644 index 000000000000..f66313bb3ed8 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/meals.jsp @@ -0,0 +1,117 @@ +<%@ 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" %> +<%@ taglib prefix="fn" uri="http://topjava.javawebinar.ru/functions" %> + + + + + + + +
+
+

+ <%--https://getbootstrap.com/docs/4.0/components/card/--%> +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + + + + + + + + + + +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/profile.jsp b/src/main/webapp/WEB-INF/jsp/profile.jsp new file mode 100644 index 000000000000..9b6f6af17e8c --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/profile.jsp @@ -0,0 +1,44 @@ +<%@ page contentType="text/html" pageEncoding="UTF-8" %> +<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> +<%@ taglib prefix="topjava" tagdir="/WEB-INF/tags" %> + + + + + + + +
+
+ <%--@elvariable id="userTo" type="ru.javawebinar.topjava.to.UserTo"--%> +
+
+

${userTo.name}

+ + + + + + + + +
+ + + + + +
+
+
+
+
+
+ + + \ 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 new file mode 100644 index 000000000000..498bc494365d --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/users.jsp @@ -0,0 +1,84 @@ +<%@ 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" %> + + + + + + + + +
+
+

+ + + + + + + + + + + + + +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/tags/inputField.tag b/src/main/webapp/WEB-INF/tags/inputField.tag new file mode 100644 index 000000000000..bc481be2dc84 --- /dev/null +++ b/src/main/webapp/WEB-INF/tags/inputField.tag @@ -0,0 +1,15 @@ +<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> + +<%@ attribute name="name" required="true" description="Name of corresponding property in bean object" %> +<%@ attribute name="labelCode" required="true" description="Field label" %> +<%@ attribute name="inputType" required="false" description="Input type" %> + + +
+ + +
${status.errorMessage}
+
+
\ 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 000000000000..d138fecdbfb5 --- /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 new file mode 100644 index 000000000000..834c26575d3a --- /dev/null +++ b/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,70 @@ + + + Topjava + + + spring.profiles.default + postgres,datajpa + + + + contextConfigLocation + + classpath:spring/spring-app.xml + classpath:spring/spring-db.xml + + + + + + org.springframework.web.context.ContextLoaderListener + + + mvc-dispatcher + org.springframework.web.servlet.DispatcherServlet + + contextConfigLocation + classpath:spring/spring-mvc.xml + + + throwExceptionIfNoHandlerFound + true + + 1 + + + mvc-dispatcher + / + + + + encodingFilter + org.springframework.web.filter.CharacterEncodingFilter + + encoding + UTF-8 + + + forceEncoding + true + + + + encodingFilter + /* + + + + + springSecurityFilterChain + org.springframework.web.filter.DelegatingFilterProxy + + + springSecurityFilterChain + /* + + diff --git a/src/main/webapp/resources/css/style.css b/src/main/webapp/resources/css/style.css new file mode 100644 index 000000000000..4d989107ecc3 --- /dev/null +++ b/src/main/webapp/resources/css/style.css @@ -0,0 +1,55 @@ +tr[data-meal-excess="false"] { + color: green; +} + +tr[data-meal-excess="true"] { + color: red; +} + +.fa { + cursor: pointer; +} + +tr[data-user-enabled="false"] { + opacity: 0.3; +} + +.error, .message { + padding: 10px; + border-radius: 4px; + font-size: 16px; +} + +.error { + color: #a94442; + background-color: #f2dede; + border: 1px solid #ebccd1; +} + +.message { + color: #2f9635; + background-color: #c6fbc2; + border: 1px solid #9feba6; +} + +/*https://stackoverflow.com/a/53855189/548473*/ +#noty_layout__bottomRight { + width: 385px !important; +} + +/*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 000000000000..b4fc54ad0129 Binary files /dev/null and b/src/main/webapp/resources/images/icon-meal.png differ diff --git a/src/main/webapp/resources/js/topjava.common.js b/src/main/webapp/resources/js/topjava.common.js new file mode 100644 index 000000000000..62f07335e6a6 --- /dev/null +++ b/src/main/webapp/resources/js/topjava.common.js @@ -0,0 +1,120 @@ +let form; + +function makeEditable(datatableOpts) { + ctx.datatableApi = $("#datatable").DataTable( + // https://api.jquery.com/jquery.extend/#jQuery-extend-deep-target-object1-objectN + $.extend(true, datatableOpts, + { + "ajax": { + "url": ctx.ajaxUrl, + "dataSrc": "" + }, + "paging": false, + "info": true, + "language": { + "search": i18n["common.search"] + } + } + )); + form = $('#detailsForm'); + + $(document).ajaxError(function (event, jqXHR, options, jsExc) { + failNoty(jqXHR); + }); + + // solve problem with cache in IE: https://stackoverflow.com/a/4303862/548473 + $.ajaxSetup({cache: false}); + + var token = $("meta[name='_csrf']").attr("content"); + var header = $("meta[name='_csrf_header']").attr("content"); + $(document).ajaxSend(function (e, xhr, options) { + xhr.setRequestHeader(header, token); + }); +} + +function add() { + $("#modalTitle").html(i18n["addTitle"]); + form.find(":input").val(""); + $("#editRow").modal(); +} + +function updateRow(id) { + form.find(":input").val(""); + $("#modalTitle").html(i18n["editTitle"]); + $.get(ctx.ajaxUrl + id, function (data) { + $.each(data, function (key, value) { + form.find("input[name='" + key + "']").val(value); + }); + $('#editRow').modal(); + }); +} + +function deleteRow(id) { + if (confirm(i18n['common.confirm'])) { + $.ajax({ + url: ctx.ajaxUrl + id, + type: "DELETE" + }).done(function () { + ctx.updateTable(); + successNoty("common.deleted"); + }); + } +} + +function updateTableByData(data) { + ctx.datatableApi.clear().rows.add(data).draw(); +} + +function save() { + $.ajax({ + type: "POST", + url: ctx.ajaxUrl, + data: form.serialize() + }).done(function () { + $("#editRow").modal("hide"); + ctx.updateTable(); + successNoty("common.saved"); + }); +} + +let failedNote; + +function closeNoty() { + if (failedNote) { + failedNote.close(); + failedNote = undefined; + } +} + +function successNoty(key) { + closeNoty(); + new Noty({ + text: "  " + i18n[key], + type: 'success', + layout: "bottomRight", + timeout: 1000 + }).show(); +} + +function renderEditBtn(data, type, row) { + if (type === "display") { + return ""; + } +} + +function renderDeleteBtn(data, type, row) { + if (type === "display") { + return ""; + } +} + +function failNoty(jqXHR) { + closeNoty(); + var errorInfo = jqXHR.responseJSON; + failedNote = new Noty({ + text: "  " + errorInfo.typeMessage + "
" + errorInfo.details.join("
"), + type: "error", + layout: "bottomRight" + }); + failedNote.show() +} \ No newline at end of file diff --git a/src/main/webapp/resources/js/topjava.meals.js b/src/main/webapp/resources/js/topjava.meals.js new file mode 100644 index 000000000000..26776b1a7581 --- /dev/null +++ b/src/main/webapp/resources/js/topjava.meals.js @@ -0,0 +1,121 @@ +const mealAjaxUrl = "profile/meals/"; + +// https://stackoverflow.com/a/5064235/548473 +const ctx = { + ajaxUrl: mealAjaxUrl, + updateTable: function () { + $.ajax({ + type: "GET", + url: mealAjaxUrl + "filter", + data: $("#filter").serialize() + }).done(updateTableByData); + } +} + +function clearFilter() { + $("#filter")[0].reset(); + $.get(mealAjaxUrl, updateTableByData); +} + +// http://api.jquery.com/jQuery.ajax/#using-converters +$.ajaxSetup({ + converters: { + "text json": function (stringData) { + var json = JSON.parse(stringData); + if (typeof json === 'object') { + $(json).each(function () { + if (this.hasOwnProperty('dateTime')) { + this.dateTime = this.dateTime.substr(0, 16).replace('T', ' '); + } + }); + } + return json; + } + } +}); + +$(function () { + makeEditable({ + "columns": [ + { + "data": "dateTime" + }, + { + "data": "description" + }, + { + "data": "calories" + }, + { + "render": renderEditBtn, + "defaultContent": "", + "orderable": false + }, + { + "render": renderDeleteBtn, + "defaultContent": "", + "orderable": false + } + ], + "order": [ + [ + 0, + "desc" + ] + ], + "createdRow": function (row, data, dataIndex) { + $(row).attr("data-meal-excess", data.excess); + } + }); + + $.datetimepicker.setLocale(localeCode); + +// http://xdsoft.net/jqplugins/datetimepicker/ + var startDate = $('#startDate'); + var endDate = $('#endDate'); + startDate.datetimepicker({ + timepicker: false, + format: 'Y-m-d', + formatDate: 'Y-m-d', + onShow: function (ct) { + this.setOptions({ + maxDate: endDate.val() ? endDate.val() : false + }) + } + }); + endDate.datetimepicker({ + timepicker: false, + format: 'Y-m-d', + formatDate: 'Y-m-d', + onShow: function (ct) { + this.setOptions({ + minDate: startDate.val() ? startDate.val() : false + }) + } + }); + + var startTime = $('#startTime'); + var endTime = $('#endTime'); + startTime.datetimepicker({ + datepicker: false, + format: 'H:i', + onShow: function (ct) { + this.setOptions({ + maxTime: endTime.val() ? endTime.val() : false + }) + } + }); + endTime.datetimepicker({ + datepicker: false, + format: 'H:i', + onShow: function (ct) { + this.setOptions({ + minTime: startTime.val() ? startTime.val() : false + }) + } + }); + + $('#dateTime').datetimepicker({ + format: 'Y-m-d H:i' + }); +}); \ 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 000000000000..bb8863a8f441 --- /dev/null +++ b/src/main/webapp/resources/js/topjava.users.js @@ -0,0 +1,86 @@ +const userAjaxUrl = "admin/users/"; + +// https://stackoverflow.com/a/5064235/548473 +const ctx = { + ajaxUrl: userAjaxUrl, + updateTable: function () { + $.get(userAjaxUrl, updateTableByData); + } +} + +function enable(chkbox, id) { + var enabled = chkbox.is(":checked"); +// https://stackoverflow.com/a/22213543/548473 + $.ajax({ + url: userAjaxUrl + id, + type: "POST", + data: "enabled=" + enabled + }).done(function () { + chkbox.closest("tr").attr("data-user-enabled", enabled); + successNoty(enabled ? "common.enabled" : "common.disabled"); + }).fail(function () { + $(chkbox).prop("checked", !enabled); + }); +} + +// $(document).ready(function () { +$(function () { + makeEditable({ + "columns": [ + { + "data": "name" + }, + { + "data": "email", + "render": function (data, type, row) { + if (type === "display") { + return "" + data + ""; + } + return data; + } + }, + { + "data": "roles" + }, + { + "data": "enabled", + "render": function (data, type, row) { + if (type === "display") { + return ""; + } + return data; + } + }, + { + "data": "registered", + "render": function (date, type, row) { + if (type === "display") { + return date.substring(0, 10); + } + return date; + } + }, + { + "orderable": false, + "defaultContent": "", + "render": renderEditBtn + }, + { + "orderable": false, + "defaultContent": "", + "render": renderDeleteBtn + } + ], + "order": [ + [ + 0, + "asc" + ] + ], + "createdRow": function (row, data, dataIndex) { + if (!data.enabled) { + $(row).attr("data-user-enabled", false); + } + } + }); +}); \ 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 000000000000..da3bec700df3 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/ActiveDbProfileResolver.java @@ -0,0 +1,19 @@ +package ru.javawebinar.topjava; + +import org.springframework.lang.NonNull; +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 extends DefaultActiveProfilesResolver { + @Override + public @NonNull + String[] resolve(@NonNull Class aClass) { + 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 new file mode 100644 index 000000000000..15e01e15535f --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/MatcherFactory.java @@ -0,0 +1,83 @@ +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 java.util.function.BiConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 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 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 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 BiConsumer assertion; + private final BiConsumer, Iterable> iterableAssertion; + + private Matcher(Class clazz, BiConsumer assertion, BiConsumer, Iterable> iterableAssertion) { + this.clazz = clazz; + this.assertion = assertion; + this.iterableAssertion = iterableAssertion; + } + + public void assertMatch(T actual, T expected) { + assertion.accept(actual, expected); + } + + @SafeVarargs + public final void assertMatch(Iterable actual, T... expected) { + assertMatch(actual, List.of(expected)); + } + + public void assertMatch(Iterable actual, Iterable expected) { + iterableAssertion.accept(actual, 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 new file mode 100644 index 000000000000..6ee8b66ddb1e --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/MealTestData.java @@ -0,0 +1,40 @@ +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; +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(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; + 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 = 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); + } + + 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/SpringMain.java b/src/test/java/ru/javawebinar/topjava/SpringMain.java new file mode 100644 index 000000000000..1aa3df13657e --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/SpringMain.java @@ -0,0 +1,44 @@ +package ru.javawebinar.topjava; + +import org.springframework.context.support.GenericXmlApplicationContext; +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; + +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) + try (GenericXmlApplicationContext appCtx = new GenericXmlApplicationContext()) { + appCtx.getEnvironment().setActiveProfiles(Profiles.getActiveDbProfile(), Profiles.REPOSITORY_IMPLEMENTATION); + appCtx.load("spring/inmemory.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", 2000, Role.ADMIN)); + System.out.println(); + + mockAuthorize(user); + + 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/test/java/ru/javawebinar/topjava/TestUtil.java b/src/test/java/ru/javawebinar/topjava/TestUtil.java new file mode 100644 index 000000000000..3688cd60efcd --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/TestUtil.java @@ -0,0 +1,23 @@ +package ru.javawebinar.topjava; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import ru.javawebinar.topjava.model.User; + +public class TestUtil { + + public static void mockAuthorize(User user) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new AuthorizedUser(user), null, user.getRoles())); + } + + public static RequestPostProcessor userHttpBasic(User user) { + return SecurityMockMvcRequestPostProcessors.httpBasic(user.getEmail(), user.getPassword()); + } + + public static RequestPostProcessor userAuth(User user) { + return SecurityMockMvcRequestPostProcessors.authentication(new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPassword())); + } +} 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 000000000000..cee6ae92c6b8 --- /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/UserTestData.java b/src/test/java/ru/javawebinar/topjava/UserTestData.java new file mode 100644 index 000000000000..d2b8aad6e101 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/UserTestData.java @@ -0,0 +1,60 @@ +package ru.javawebinar.topjava; + +import ru.javawebinar.topjava.model.Role; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.web.json.JsonUtil; + +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", "password"); + 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", "password").isEqualTo(e), + (a, e) -> { + throw new UnsupportedOperationException(); + }); + + 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", 2005, Role.USER); + public static final User admin = new User(ADMIN_ID, "Admin", "admin@gmail.com", "admin", 1900, Role.ADMIN, Role.USER); + public static final User guest = new User(GUEST_ID, "Guest", "guest@gmail.com", "guest", 2000); + + 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)); + } + + public static User getUpdated() { + User updated = new User(user); + +// In case of update with user.id=null in body needs workaround +// ValidationUtil.assureIdConsistent called after validation +// 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 String jsonWithPassword(User user, String passw) { + return JsonUtil.writeAdditionProps(user, "password", passw); + } +} 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 000000000000..03770da2134b --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryBaseRepository.java @@ -0,0 +1,45 @@ +package ru.javawebinar.topjava.repository.inmemory; + +import ru.javawebinar.topjava.model.AbstractBaseEntity; + +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +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) { + Objects.requireNonNull(entity, "Entity must not be null"); + 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) { + 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 new file mode 100644 index 000000000000..5c65ced864e4 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/repository/inmemory/InMemoryMealRepository.java @@ -0,0 +1,80 @@ +package ru.javawebinar.topjava.repository.inmemory; + +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.Util; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; + +@Repository +public class InMemoryMealRepository implements MealRepository { + private static final Logger log = LoggerFactory.getLogger(InMemoryMealRepository.class); + + // Map userId -> mealRepository + private final Map> usersMealsMap = new ConcurrentHashMap<>(); + + { + var 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"); + var 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) { + var meals = usersMealsMap.get(userId); + return meals != null && meals.delete(id); + } + + @Override + public Meal get(int id, int userId) { + var 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) { + var meals = usersMealsMap.get(userId); + return meals == null ? Collections.emptyList() : + meals.getCollection().stream() + .filter(filter) + .sorted(Comparator.comparing(Meal::getDateTime).reversed()) + .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 000000000000..f3585dfffd7f --- /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.model.User; +import ru.javawebinar.topjava.repository.UserRepository; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +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(GUEST_ID + 1); + } + + @Override + public List getAll() { + return getCollection().stream() + .sorted(Comparator.comparing(User::getName).thenComparing(User::getEmail)) + .toList(); + } + + @Override + public User getByEmail(String email) { + Objects.requireNonNull(email, "email must not be null"); + 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/AbstractMealServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/AbstractMealServiceTest.java new file mode 100644 index 000000000000..6b00e448152b --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/AbstractMealServiceTest.java @@ -0,0 +1,112 @@ +package ru.javawebinar.topjava.service; + +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; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +import javax.validation.ConstraintViolationException; +import java.time.LocalDate; +import java.time.Month; + +import static java.time.LocalDateTime.of; +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; + +public abstract class AbstractMealServiceTest extends AbstractServiceTest { + + @Autowired + protected MealService service; + + @Test + void delete() { + service.delete(MEAL1_ID, USER_ID); + assertThrows(NotFoundException.class, () -> service.get(MEAL1_ID, USER_ID)); + } + + @Test + void deleteNotFound() { + assertThrows(NotFoundException.class, () -> service.delete(NOT_FOUND, USER_ID)); + } + + @Test + void deleteNotOwn() { + assertThrows(NotFoundException.class, () -> service.delete(MEAL1_ID, ADMIN_ID)); + } + + @Test + 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 + void duplicateDateTimeCreate() { + assertThrows(DataAccessException.class, () -> + service.create(new Meal(null, meal1.getDateTime(), "duplicate", 100), USER_ID)); + } + + @Test + void get() { + Meal actual = service.get(ADMIN_MEAL_ID, ADMIN_ID); + MEAL_MATCHER.assertMatch(actual, adminMeal1); + } + + @Test + void getNotFound() { + assertThrows(NotFoundException.class, () -> service.get(NOT_FOUND, USER_ID)); + } + + @Test + void getNotOwn() { + assertThrows(NotFoundException.class, () -> service.get(MEAL1_ID, ADMIN_ID)); + } + + @Test + void update() { + Meal updated = getUpdated(); + service.update(updated, USER_ID); + MEAL_MATCHER.assertMatch(service.get(MEAL1_ID, USER_ID), getUpdated()); + } + + @Test + void updateNotOwn() { + NotFoundException exception = assertThrows(NotFoundException.class, () -> service.update(getUpdated(), ADMIN_ID)); + Assertions.assertEquals("Not found entity with id=" + MEAL1_ID, exception.getMessage()); + MEAL_MATCHER.assertMatch(service.get(MEAL1_ID, USER_ID), meal1); + } + + @Test + void getAll() { + MEAL_MATCHER.assertMatch(service.getAll(USER_ID), meals); + } + + @Test + 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 + void getBetweenWithNullDates() { + MEAL_MATCHER.assertMatch(service.getBetweenInclusive(null, null, USER_ID), meals); + } + + @Test + 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 000000000000..fb61d7a518a0 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/AbstractServiceTest.java @@ -0,0 +1,34 @@ +package ru.javawebinar.topjava.service; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import ru.javawebinar.topjava.ActiveDbProfileResolver; +import ru.javawebinar.topjava.TimingExtension; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static ru.javawebinar.topjava.util.validation.ValidationUtil.getRootCause; + +@SpringJUnitConfig(locations = { + "classpath:spring/spring-app.xml", + "classpath:spring/spring-db.xml" +}) +//@ExtendWith(SpringExtension.class) +@ActiveProfiles(resolver = ActiveDbProfileResolver.class) +@Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8"), executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +@ExtendWith(TimingExtension.class) +public abstract class AbstractServiceTest { + + // 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/AbstractUserServiceTest.java b/src/test/java/ru/javawebinar/topjava/service/AbstractUserServiceTest.java new file mode 100644 index 000000000000..2566c1c760e3 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/AbstractUserServiceTest.java @@ -0,0 +1,95 @@ +package ru.javawebinar.topjava.service; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataAccessException; +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 static org.junit.jupiter.api.Assertions.*; +import static ru.javawebinar.topjava.UserTestData.*; + +public abstract class AbstractUserServiceTest extends AbstractServiceTest { + + @Autowired + protected UserService service; + + @Test + public void create() { + User created = service.create(getNew()); + int newId = created.id(); + User newUser = getNew(); + newUser.setId(newId); + USER_MATCHER.assertMatch(created, newUser); + USER_MATCHER.assertMatch(service.get(newId), newUser); + } + + @Test + void duplicateMailCreate() { + assertThrows(DataAccessException.class, () -> + service.create(new User(null, "Duplicate", "user@yandex.ru", "newPass", 2000, Role.USER))); + } + + @Test + void delete() { + service.delete(USER_ID); + assertThrows(NotFoundException.class, () -> service.get(USER_ID)); + } + + @Test + void deletedNotFound() { + assertThrows(NotFoundException.class, () -> service.delete(NOT_FOUND)); + } + + @Test + void get() { + User user = service.get(ADMIN_ID); + USER_MATCHER.assertMatch(user, admin); + } + + @Test + void getNotFound() { + assertThrows(NotFoundException.class, () -> service.get(NOT_FOUND)); + } + + @Test + void getByEmail() { + User user = service.getByEmail("admin@gmail.com"); + USER_MATCHER.assertMatch(user, admin); + } + + @Test + void update() { + User updated = getUpdated(); + service.update(updated); + USER_MATCHER.assertMatch(service.get(USER_ID), getUpdated()); + } + + @Test + void getAll() { + List all = service.getAll(); + USER_MATCHER.assertMatch(all, admin, guest, user); + } + + @Test + void createWithException() throws Exception { + validateRootCause(ConstraintViolationException.class, () -> service.create(new User(null, " ", "mail@yandex.ru", "password", 2000, Role.USER))); + validateRootCause(ConstraintViolationException.class, () -> service.create(new User(null, "User", " ", "password", 2000, 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()))); + } + + @Test + void enable() { + service.enable(USER_ID, false); + assertFalse(service.get(USER_ID).isEnabled()); + service.enable(USER_ID, true); + assertTrue(service.get(USER_ID).isEnabled()); + } +} \ 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 000000000000..161c93fb5296 --- /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.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; +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) +class DataJpaMealServiceTest extends AbstractMealServiceTest { + @Test + void getWithUser() { + Meal adminMeal = service.getWithUser(ADMIN_MEAL_ID, ADMIN_ID); + MEAL_MATCHER.assertMatch(adminMeal, adminMeal1); + USER_MATCHER.assertMatch(adminMeal.getUser(), admin); + } + + @Test + 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 new file mode 100644 index 000000000000..3638e07e9813 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/datajpa/DataJpaUserServiceTest.java @@ -0,0 +1,26 @@ +package ru.javawebinar.topjava.service.datajpa; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; +import ru.javawebinar.topjava.model.User; +import ru.javawebinar.topjava.service.AbstractUserServiceTest; +import ru.javawebinar.topjava.util.exception.NotFoundException; + +import static ru.javawebinar.topjava.Profiles.DATAJPA; +import static ru.javawebinar.topjava.UserTestData.*; + +@ActiveProfiles(DATAJPA) +class DataJpaUserServiceTest extends AbstractUserServiceTest { + @Test + void getWithMeals() { + User actual = service.getWithMeals(ADMIN_ID); + USER_WITH_MEALS_MATCHER.assertMatch(actual, admin); + } + + @Test + 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 new file mode 100644 index 000000000000..aef588264f71 --- /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) +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 000000000000..62ca7668cf67 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/service/jdbc/JdbcUserServiceTest.java @@ -0,0 +1,10 @@ +package ru.javawebinar.topjava.service.jdbc; + +import org.springframework.test.context.ActiveProfiles; +import ru.javawebinar.topjava.service.AbstractUserServiceTest; + +import static ru.javawebinar.topjava.Profiles.JDBC; + +@ActiveProfiles(JDBC) +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 000000000000..aaf5dcda960e --- /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) +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 000000000000..6d1cd91543fc --- /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) +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 000000000000..962f6ba39fee --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/AbstractControllerTest.java @@ -0,0 +1,85 @@ +package ru.javawebinar.topjava.web; + +import org.junit.jupiter.api.Assumptions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.support.MessageSourceAccessor; +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; +import org.springframework.test.web.servlet.ResultMatcher; +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 ru.javawebinar.topjava.util.exception.ErrorType; + +import javax.annotation.PostConstruct; +import java.util.Locale; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + + +@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 Locale RU_LOCALE = new Locale("ru"); + 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 Environment env; + + @Autowired + private WebApplicationContext webApplicationContext; + + @Autowired + protected MessageSourceAccessor messageSourceAccessor; + + protected void assumeDataJpa() { + Assumptions.assumeTrue(env.acceptsProfiles(org.springframework.core.env.Profiles.of(Profiles.DATAJPA)), "DATA-JPA only"); + } + + @PostConstruct + private void postConstruct() { + mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .addFilter(CHARACTER_ENCODING_FILTER) + .apply(springSecurity()) + .build(); + } + + protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception { + return mockMvc.perform(builder); + } + + private String getMessage(String code) { + return messageSourceAccessor.getMessage(code, RU_LOCALE); + } + + protected ResultMatcher errorType(ErrorType type) { + return jsonPath("$.type").value(type.name()); + } + + protected ResultMatcher detailMessage(String code) { + return jsonPath("$.details").value(getMessage(code)); + } +} 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 000000000000..244399662c58 --- /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 new file mode 100644 index 000000000000..c200fb4e9197 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/RootControllerTest.java @@ -0,0 +1,41 @@ +package ru.javawebinar.topjava.web; + +import org.junit.jupiter.api.Test; + +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.*; +import static ru.javawebinar.topjava.TestUtil.userAuth; +import static ru.javawebinar.topjava.UserTestData.admin; +import static ru.javawebinar.topjava.UserTestData.user; + +class RootControllerTest extends AbstractControllerTest { + + @Test + void getUsers() throws Exception { + perform(get("/users") + .with(userAuth(admin))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(view().name("users")) + .andExpect(forwardedUrl("/WEB-INF/jsp/users.jsp")); + } + + @Test + void unAuth() throws Exception { + perform(get("/users")) + .andDo(print()) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + } + + @Test + void getMeals() throws Exception { + perform(get("/meals") + .with(userAuth(user))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(view().name("meals")) + .andExpect(forwardedUrl("/WEB-INF/jsp/meals.jsp")); + } +} \ 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 000000000000..d1a7a5d6ceb6 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/json/JsonUtilTest.java @@ -0,0 +1,48 @@ +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 ru.javawebinar.topjava.model.User; + +import java.util.List; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static ru.javawebinar.topjava.MealTestData.*; +import static ru.javawebinar.topjava.UserTestData.jsonWithPassword; +import static ru.javawebinar.topjava.UserTestData.user; + +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); + } + + @Test + void writeOnlyAccess() { + String json = JsonUtil.writeValue(user); + System.out.println(json); + assertThat(json, not(containsString("password"))); + String jsonWithPass = jsonWithPassword(user, "newPass"); + System.out.println(jsonWithPass); + User user = JsonUtil.readValue(jsonWithPass, User.class); + assertEquals(user.getPassword(), "newPass"); + } +} \ 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 000000000000..9b234d697eb4 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/meal/MealRestControllerTest.java @@ -0,0 +1,197 @@ +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 org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import ru.javawebinar.topjava.MealTestData; +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 java.time.LocalDateTime; + +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.TestUtil.userHttpBasic; +import static ru.javawebinar.topjava.UserTestData.*; +import static ru.javawebinar.topjava.util.MealsUtil.createTo; +import static ru.javawebinar.topjava.util.MealsUtil.getTos; +import static ru.javawebinar.topjava.util.exception.ErrorType.VALIDATION_ERROR; +import static ru.javawebinar.topjava.web.ExceptionInfoHandler.EXCEPTION_DUPLICATE_DATETIME; + +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) + .with(userHttpBasic(user))) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(MEAL_MATCHER.contentJson(meal1)); + } + + @Test + void getUnauth() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + MEAL1_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + void getNotFound() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + ADMIN_MEAL_ID) + .with(userHttpBasic(user))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL + MEAL1_ID) + .with(userHttpBasic(user))) + .andExpect(status().isNoContent()); + assertThrows(NotFoundException.class, () -> mealService.get(MEAL1_ID, USER_ID)); + } + + @Test + void deleteNotFound() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL + ADMIN_MEAL_ID) + .with(userHttpBasic(user))) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + void update() throws Exception { + Meal updated = MealTestData.getUpdated(); + perform(MockMvcRequestBuilders.put(REST_URL + MEAL1_ID).contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(user)) + .content(JsonUtil.writeValue(updated))) + .andExpect(status().isNoContent()); + + MEAL_MATCHER.assertMatch(mealService.get(MEAL1_ID, USER_ID), updated); + } + + @Test + void createWithLocation() throws Exception { + Meal newMeal = MealTestData.getNew(); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(user)) + .content(JsonUtil.writeValue(newMeal))); + + 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) + .with(userHttpBasic(user))) + .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") + .with(userHttpBasic(user))) + .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=") + .with(userHttpBasic(user))) + .andExpect(status().isOk()) + .andExpect(TO_MATCHER.contentJson(getTos(meals, user.getCaloriesPerDay()))); + } + + @Test + void createInvalid() throws Exception { + Meal invalid = new Meal(null, null, "Dummy", 200); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(invalid)) + .with(userHttpBasic(admin))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)); + } + + @Test + void updateInvalid() throws Exception { + Meal invalid = new Meal(MEAL1_ID, null, null, 6000); + perform(MockMvcRequestBuilders.put(REST_URL + MEAL1_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(invalid)) + .with(userHttpBasic(user))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)); + } + + @Test + void updateHtmlUnsafe() throws Exception { + Meal invalid = new Meal(MEAL1_ID, LocalDateTime.now(), "", 200); + perform(MockMvcRequestBuilders.put(REST_URL + MEAL1_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(invalid)) + .with(userHttpBasic(user))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)); + } + + @Test + @Transactional(propagation = Propagation.NEVER) + void updateDuplicate() throws Exception { + Meal invalid = new Meal(MEAL1_ID, meal2.getDateTime(), "Dummy", 200); + + perform(MockMvcRequestBuilders.put(REST_URL + MEAL1_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(invalid)) + .with(userHttpBasic(user))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)) + .andExpect(detailMessage(EXCEPTION_DUPLICATE_DATETIME)); + } + + @Test + @Transactional(propagation = Propagation.NEVER) + void createDuplicate() throws Exception { + Meal invalid = new Meal(null, adminMeal1.getDateTime(), "Dummy", 200); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(invalid)) + .with(userHttpBasic(admin))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)) + .andExpect(detailMessage(EXCEPTION_DUPLICATE_DATETIME)); + } +} \ 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 000000000000..2abfaa933b6f --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/user/AdminRestControllerTest.java @@ -0,0 +1,219 @@ +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 org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import ru.javawebinar.topjava.model.Role; +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 static org.junit.jupiter.api.Assertions.assertFalse; +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.TestUtil.userHttpBasic; +import static ru.javawebinar.topjava.UserTestData.*; +import static ru.javawebinar.topjava.util.exception.ErrorType.VALIDATION_ERROR; +import static ru.javawebinar.topjava.web.ExceptionInfoHandler.EXCEPTION_DUPLICATE_EMAIL; + +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) + .with(userHttpBasic(admin))) + .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 getNotFound() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + NOT_FOUND) + .with(userHttpBasic(admin))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + void getByEmail() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + "by-email?email=" + user.getEmail()) + .with(userHttpBasic(admin))) + .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) + .with(userHttpBasic(admin))) + .andDo(print()) + .andExpect(status().isNoContent()); + assertThrows(NotFoundException.class, () -> userService.get(USER_ID)); + } + + @Test + void deleteNotFound() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL + NOT_FOUND) + .with(userHttpBasic(admin))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + void getUnAuth() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isUnauthorized()); + } + + @Test + void getForbidden() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL) + .with(userHttpBasic(user))) + .andExpect(status().isForbidden()); + } + + @Test + void update() throws Exception { + User updated = getUpdated(); + updated.setId(null); + perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(admin)) + .content(jsonWithPassword(updated, updated.getPassword()))) + .andExpect(status().isNoContent()); + + USER_MATCHER.assertMatch(userService.get(USER_ID), getUpdated()); + } + + @Test + void createWithLocation() throws Exception { + User newUser = getNew(); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(admin)) + .content(jsonWithPassword(newUser, newUser.getPassword()))) + .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) + .with(userHttpBasic(admin))) + .andExpect(status().isOk()) + .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") + .with(userHttpBasic(admin))) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_WITH_MEALS_MATCHER.contentJson(admin)); + } + + @Test + void enable() throws Exception { + perform(MockMvcRequestBuilders.patch(REST_URL + USER_ID) + .param("enabled", "false") + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(admin))) + .andDo(print()) + .andExpect(status().isNoContent()); + + assertFalse(userService.get(USER_ID).isEnabled()); + } + + @Test + void createInvalid() throws Exception { + User invalid = new User(null, null, "", "newPass", 7300, Role.USER, Role.ADMIN); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(admin)) + .content(jsonWithPassword(invalid, "newPass"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)); + } + + @Test + void updateInvalid() throws Exception { + User invalid = new User(user); + invalid.setName(""); + perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(admin)) + .content(jsonWithPassword(invalid, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)); + } + + @Test + void updateHtmlUnsafe() throws Exception { + User updated = new User(user); + updated.setName(""); + perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(admin)) + .content(jsonWithPassword(updated, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)); + } + + @Test + @Transactional(propagation = Propagation.NEVER) + void updateDuplicate() throws Exception { + User updated = new User(user); + updated.setId(null); + updated.setEmail("admin@gmail.com"); + perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(admin)) + .content(jsonWithPassword(updated, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)) + .andExpect(detailMessage(EXCEPTION_DUPLICATE_EMAIL)); + } + + @Test + @Transactional(propagation = Propagation.NEVER) + void createDuplicate() throws Exception { + User expected = new User(null, "New", "user@yandex.ru", "newPass", 2300, Role.USER, Role.ADMIN); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(admin)) + .content(jsonWithPassword(expected, "newPass"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)) + .andExpect(detailMessage(EXCEPTION_DUPLICATE_EMAIL)); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javawebinar/topjava/web/user/HerokuRestControllerTest.java b/src/test/java/ru/javawebinar/topjava/web/user/HerokuRestControllerTest.java new file mode 100644 index 000000000000..c818a52116db --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/user/HerokuRestControllerTest.java @@ -0,0 +1,65 @@ +package ru.javawebinar.topjava.web.user; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourcePropertySource; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javawebinar.topjava.UserTestData; +import ru.javawebinar.topjava.util.exception.ErrorType; +import ru.javawebinar.topjava.web.AbstractControllerTest; + +import java.io.IOException; + +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static ru.javawebinar.topjava.Profiles.HEROKU; +import static ru.javawebinar.topjava.TestUtil.userHttpBasic; +import static ru.javawebinar.topjava.UserTestData.*; +import static ru.javawebinar.topjava.util.exception.UpdateRestrictionException.EXCEPTION_UPDATE_RESTRICTION; + +@ActiveProfiles(HEROKU) +class HerokuRestControllerTest extends AbstractControllerTest { + + private static final String REST_URL = AdminRestController.REST_URL + '/'; + + // Set DATABASE_URL environment for heroku profile + static { + Resource resource = new ClassPathResource("db/postgres.properties"); + try { + ResourcePropertySource propertySource = new ResourcePropertySource(resource); + String herokuDbUrl = String.format("postgres://%s:%s@%s", + propertySource.getProperty("database.username"), + propertySource.getProperty("database.password"), + ((String) propertySource.getProperty("database.url")).substring(18)); + System.out.println(herokuDbUrl); + + System.setProperty("DATABASE_URL", herokuDbUrl); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @Test + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL + USER_ID) + .with(userHttpBasic(admin))) + .andDo(print()) + .andExpect(errorType(ErrorType.VALIDATION_ERROR)) + .andExpect(detailMessage(EXCEPTION_UPDATE_RESTRICTION)) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + void update() throws Exception { + perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(admin)) + .content(UserTestData.jsonWithPassword(user, "password"))) + .andExpect(errorType(ErrorType.VALIDATION_ERROR)) + .andExpect(detailMessage(EXCEPTION_UPDATE_RESTRICTION)) + .andExpect(status().isUnprocessableEntity()); + } +} 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 000000000000..40af802e08f4 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerSpringTest.java @@ -0,0 +1,38 @@ +package ru.javawebinar.topjava.web.user; + +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.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; + +@SpringJUnitConfig(locations = {"classpath:spring/inmemory.xml"}) +class InMemoryAdminRestControllerSpringTest { + + @Autowired + private AdminRestController controller; + + @Autowired + private InMemoryUserRepository repository; + + @BeforeEach + public void setUp() { + repository.init(); + } + + @Test + void delete() { + controller.delete(USER_ID); + Assertions.assertNull(repository.get(USER_ID)); + } + + @Test + 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 new file mode 100644 index 000000000000..75b8a5a82087 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/user/InMemoryAdminRestControllerTest.java @@ -0,0 +1,54 @@ +package ru.javawebinar.topjava.web.user; + +import org.junit.jupiter.api.*; +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; + +class InMemoryAdminRestControllerTest { + private static final Logger log = LoggerFactory.getLogger(InMemoryAdminRestControllerTest.class); + + private static ConfigurableApplicationContext appCtx; + private static AdminRestController controller; + private static InMemoryUserRepository repository; + + @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); + } + + @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(); + } + + @BeforeEach + public void setup() { + // re-initialize + repository.init(); + } + + @Test + void delete() { + controller.delete(USER_ID); + Assertions.assertNull(repository.get(USER_ID)); + } + + @Test + 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 000000000000..1b375e662b32 --- /dev/null +++ b/src/test/java/ru/javawebinar/topjava/web/user/ProfileRestControllerTest.java @@ -0,0 +1,131 @@ +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 org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +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 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.TestUtil.userHttpBasic; +import static ru.javawebinar.topjava.UserTestData.*; +import static ru.javawebinar.topjava.util.exception.ErrorType.VALIDATION_ERROR; +import static ru.javawebinar.topjava.web.ExceptionInfoHandler.EXCEPTION_DUPLICATE_EMAIL; +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) + .with(userHttpBasic(user))) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(user)); + } + + @Test + void getUnAuth() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isUnauthorized()); + } + + @Test + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL) + .with(userHttpBasic(user))) + .andExpect(status().isNoContent()); + USER_MATCHER.assertMatch(userService.getAll(), admin, guest); + } + + @Test + void register() throws Exception { + UserTo newTo = new UserTo(null, "newName", "newemail@ya.ru", "newPassword", 1500); + User newUser = UserUtil.createNewFromTo(newTo); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newTo))) + .andDo(print()) + .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 update() throws Exception { + // ValidationUtil.assureIdConsistent called after validation, needs workaround + UserTo updatedTo = new UserTo(null, "newName", "user@yandex.ru", "newPassword", 1500); + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(user)) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isNoContent()); + + USER_MATCHER.assertMatch(userService.get(USER_ID), UserUtil.updateFromTo(new User(user), updatedTo)); + } + + @Test + void getWithMeals() throws Exception { + assumeDataJpa(); + perform(MockMvcRequestBuilders.get(REST_URL + "/with-meals") + .with(userHttpBasic(user))) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_WITH_MEALS_MATCHER.contentJson(user)); + } + + @Test + void registerInvalid() throws Exception { + UserTo newTo = new UserTo(null, null, null, null, 1); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newTo))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)); + } + + @Test + void updateInvalid() throws Exception { + UserTo updatedTo = new UserTo(null, null, "password", null, 1500); + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(user)) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)); + } + + @Test + @Transactional(propagation = Propagation.NEVER) + void updateDuplicate() throws Exception { + UserTo updatedTo = new UserTo(null, "newName", "admin@gmail.com", "newPassword", 1500); + + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(user)) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(errorType(VALIDATION_ERROR)) + .andExpect(detailMessage(EXCEPTION_DUPLICATE_EMAIL)); + } +} \ 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 000000000000..803655475302 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,32 @@ + + + + true + + + + + UTF-8 + %d{HH:mm:ss.SSS} %highlight(%-5level) %cyan(%class{50}.%M:%L) - %msg%n + + + + + + UTF-8 + %magenta(%msg%n) + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/spring/inmemory.xml b/src/test/resources/spring/inmemory.xml new file mode 100644 index 000000000000..1207399dd0ad --- /dev/null +++ b/src/test/resources/spring/inmemory.xml @@ -0,0 +1,11 @@ + + + + + + + + \ 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 000000000000..ea51df903c59 --- /dev/null +++ b/src/test/resources/spring/spring-cache.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + false + + + + + + \ No newline at end of file diff --git a/system.properties b/system.properties new file mode 100644 index 000000000000..0dc726cecebc --- /dev/null +++ b/system.properties @@ -0,0 +1 @@ +java.runtime.version=17 \ No newline at end of file