diff --git a/README.md b/README.md
index e6a968a..11b0365 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,7 @@
-spring-data-jpa-examples
-========================
+# Test With Spring Course
+
+If you are struggling to write good automated tests for Spring web applications, you are not alone! [I have launched a video course](https://www.testwithspring.com/?utm_source=github&utm_medium=social&utm_content=spring-data-jpa&utm_campaign=test-with-spring-course-presales) that describes how you can write automated tests which embrace change and help you to save your time (and nerves).
+
+# Spring Data JPA Tutorial
+
+This repository contains the example applications of my [Spring Data JPA tutorial](http://www.petrikainulainen.net/spring-data-jpa-tutorial/). The READMEs of the examples provide more information about the application in question.
diff --git a/criteria-api/.gitignore b/criteria-api/.gitignore
new file mode 100644
index 0000000..02895f1
--- /dev/null
+++ b/criteria-api/.gitignore
@@ -0,0 +1,10 @@
+.DS_Store
+.gradle
+.idea
+*.iml
+build
+h2db
+target
+node_modules
+bower_components
+build
\ No newline at end of file
diff --git a/tutorial-part-four/LICENSE b/criteria-api/LICENSE
similarity index 88%
rename from tutorial-part-four/LICENSE
rename to criteria-api/LICENSE
index b333aa5..642bfb3 100644
--- a/tutorial-part-four/LICENSE
+++ b/criteria-api/LICENSE
@@ -1,4 +1,4 @@
-Copyright 2011 Petri Kainulainen
+Copyright 2014 Petri Kainulainen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -10,4 +10,4 @@ Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
-limitations under the License.
\ No newline at end of file
+limitations under the License.
diff --git a/criteria-api/README.md b/criteria-api/README.md
new file mode 100644
index 0000000..fb07560
--- /dev/null
+++ b/criteria-api/README.md
@@ -0,0 +1,83 @@
+This blog post is the example application of the following blog posts:
+
+* [Spring Data JPA Tutorial: Creating Database Queries With the JPA Criteria API](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-four-jpa-criteria-queries/)
+* [Spring Data JPA Tutorial: Sorting]() - Not published yet
+
+You might also want to read the other parts of my Spring Data JPA Tutorial:
+
+
+* [Spring Data JPA Tutorial: Getting the Required Dependencies](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-getting-the-required-dependencies/)
+* [Spring Data JPA Tutorial: Configuration](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-one-configuration/)
+* [Spring Data JPA Tutorial: CRUD](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-two-crud/)
+* [Spring Data JPA Tutorial: Introduction to Query Methods](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-introduction-to-query-methods/)
+* [Spring Data JPA Tutorial: Creating Database Queries From Method Names](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-creating-database-queries-from-method-names/)
+* [Spring Data JPA Tutorial: Creating Database Queries With the @Query Annotation](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-creating-database-queries-with-the-query-annotation/)
+* [Spring Data JPA Tutorial: Creating Database Queries With Named Queries](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-creating-database-queries-with-named-queries/)
+* [Spring Data JPA Tutorial: Creating Database Queries With Querydsl](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-five-querydsl/)
+* [Spring Data JPA Tutorial: Auditing, Part One](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-auditing-part-one/)
+* [Spring Data JPA Tutorial: Auditing, Part Two](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-auditing-part-two/)
+
+
+**Note:** This application is still work in progress.
+
+Prerequisites
+=============
+
+You need to install the following tools if you want to run this application:
+
+Backend
+---------
+
+* [JDK 8](http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html)
+* [Maven](http://maven.apache.org/) (the application is tested with Maven 3.2.1)
+
+Frontend
+----------
+
+* [Node.js](http://nodejs.org/)
+* [NPM](https://www.npmjs.org/)
+* [Bower](http://bower.io/)
+* [Gulp](http://gulpjs.com/)
+
+You can install these tools by following these steps:
+
+1. Install Node.js by using a [downloaded binary](http://nodejs.org/download/) or a [package manager](https://github.com/joyent/node/wiki/Installing-Node.js-via-package-manager).
+ You can also read this blog post: [How to install Node.js and NPM](http://blog.nodeknockout.com/post/65463770933/how-to-install-node-js-and-npm)
+
+2. Install Bower by using the following command:
+
+ npm install -g bower
+
+3. Install Gulp by using the following command:
+
+ npm install -g gulp
+
+
+Running the Tests
+=================
+
+You can run the unit tests by using the following command:
+
+ mvn clean test -P dev
+
+You can run the integration tests by using the following command:
+
+ mvn clean verify -P integration-test
+
+Running the Application
+=======================
+
+You can run the application by using the following command:
+
+ mvn clean jetty:run -P dev
+
+Credits
+=========
+
+* Kyösti Herrala. The Gulp build script and its Maven integration are based on Kyösti's ideas.
+* [Techniques for authentication in AngularJS applications](https://medium.com/opinionated-angularjs/techniques-for-authentication-in-angularjs-applications-7bbf0346acec)
+
+Known Issues
+============
+
+* If you refresh the login page, you aren't redirected away from it after successful login.
\ No newline at end of file
diff --git a/criteria-api/frontend/.bowerrc b/criteria-api/frontend/.bowerrc
new file mode 100644
index 0000000..df4bcee
--- /dev/null
+++ b/criteria-api/frontend/.bowerrc
@@ -0,0 +1,3 @@
+{
+ "directory": "bower_components"
+}
\ No newline at end of file
diff --git a/criteria-api/frontend/.jshintrc b/criteria-api/frontend/.jshintrc
new file mode 100644
index 0000000..f648d46
--- /dev/null
+++ b/criteria-api/frontend/.jshintrc
@@ -0,0 +1,33 @@
+{
+ "globalstrict": true,
+ "browser": true,
+ "devel": true,
+ "node": true,
+ "esnext": true,
+ "bitwise": true,
+ "camelcase": true,
+ "curly": true,
+ "eqeqeq": true,
+ "immed": true,
+ "indent": 4,
+ "latedef": true,
+ "newcap": true,
+ "noarg": true,
+ "regexp": true,
+ "undef": true,
+ "unused": false,
+ "strict": true,
+ "trailing": true,
+ "smarttabs": true,
+ "white": true,
+ "globals": {
+ "describe": true,
+ "it": true,
+ "beforeEach": true,
+ "afterEach": true,
+ "angular": true,
+ "jQuery": true,
+ "_": true,
+ "$": true
+ }
+}
\ No newline at end of file
diff --git a/criteria-api/frontend/app/app.js b/criteria-api/frontend/app/app.js
new file mode 100644
index 0000000..3b12b71
--- /dev/null
+++ b/criteria-api/frontend/app/app.js
@@ -0,0 +1,98 @@
+'use strict';
+
+var App = angular.module('app', [
+ 'angular-logger',
+ 'http-auth-interceptor',
+ 'ngLocale',
+ 'ngCookies',
+ 'ngResource',
+ 'ngSanitize',
+ 'pascalprecht.translate',
+ 'ui.bootstrap',
+ 'ui.router',
+ 'ui.utils',
+ 'angular-growl',
+ 'angularMoment',
+ 'angularUtils.directives.dirPagination',
+ 'spring-security-csrf-token-interceptor',
+
+ //Partials
+ 'templates',
+
+ //Account
+ 'app.account.config', 'app.account.directives', 'app.account.controllers', 'app.account.services',
+
+ //Common
+ 'app.common.config', 'app.common.controllers', 'app.common.directives', 'app.common.services',
+
+ //Todo
+ 'app.todo.controllers', 'app.todo.directives', 'app.todo.services',
+
+ //Search
+ 'app.search.controllers', 'app.search.directives', 'app.search.services'
+
+]);
+
+App.run(['$log', '$rootScope', '$state', 'AUTH_EVENTS', 'AuthenticatedUser', 'authService', 'AuthenticationService', 'COMMON_EVENTS',
+ function ($log, $rootScope, $state, AUTH_EVENTS, AuthenticatedUser, authService, AuthenticationService, COMMON_EVENTS) {
+
+ var logger = $log.getInstance('app');
+
+ //This function retries all requests that were failed because of
+ //the 401 response.
+ function listenAuthenticationEvents() {
+ var confirmLogin = function() {
+ authService.loginConfirmed();
+ };
+
+ $rootScope.$on(AUTH_EVENTS.loginSuccess, confirmLogin);
+
+ var viewLogInPage = function() {
+ logger.info('User is not authenticated. Rendering login view.');
+ $state.go('todo.login');
+ };
+
+ $rootScope.$on(AUTH_EVENTS.notAuthenticated, viewLogInPage);
+
+ var viewTodoListPage = function() {
+ logger.info("User logged out. REndering todo list view.");
+ $state.go('todo.list', {}, {reload: true});
+ };
+
+ $rootScope.$on(AUTH_EVENTS.logoutSuccess, viewTodoListPage);
+
+ var viewForbiddenPage = function() {
+ logger.info('Permission was denied for user: %j', AuthenticatedUser);
+ $state.go('todo.forbidden');
+ };
+
+ $rootScope.$on(AUTH_EVENTS.notAuthorized, viewForbiddenPage);
+ }
+
+ function listenCommonEvents() {
+
+ var view404Page = function() {
+ logger.info('Requested page was not found.');
+ $state.go('todo.404');
+ };
+
+ $rootScope.$on(COMMON_EVENTS.notFound, view404Page);
+ }
+
+ //This function ensures that anonymous users cannot access states
+ //that marked as protected (i.e. the value of the authenticated
+ //property is set to true).
+ function secureProtectedStates() {
+ $rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
+ logger.trace('Moving to state: %s', toState.name);
+ AuthenticationService.authorizeStateChange(event, toState, toParams);
+ });
+ }
+
+ $rootScope.currentUser = AuthenticatedUser;
+
+ listenAuthenticationEvents();
+ listenCommonEvents();
+ secureProtectedStates();
+}]);
+
diff --git a/criteria-api/frontend/app/assets/i18n/en.json b/criteria-api/frontend/app/assets/i18n/en.json
new file mode 100644
index 0000000..869176b
--- /dev/null
+++ b/criteria-api/frontend/app/assets/i18n/en.json
@@ -0,0 +1,101 @@
+{
+ "app.title.label": "Spring Data JPA Tutorial - Query Methods",
+ "dialogs": {
+ "delete.dialog": {
+ "cancel.button.label": "Cancel",
+ "delete.button.label": "Delete",
+ "text": "Are you sure that you want to delete the todo entry with title: {{title}}?",
+ "title": "Delete todo entry?"
+ }
+ },
+ "directives": {
+ "login.form": {
+ "login.button": "Login",
+ "login.failed": "Login failed!"
+ },
+ "log.out.link.label": "Log Out",
+ "todo.form": {
+ "cancel.button": "Cancel",
+ "save.button": "Save"
+ }
+ },
+ "footer.message": "Spring Data JPA example application by Petri Kainulainen",
+ "header.brand.label": "Spring Data JPA Tutorial",
+ "pages": {
+ "add.page": {
+ "title": "Add new todo entry",
+ "link.label": "Add new todo entry"
+ },
+ "delete.link": "Delete",
+ "edit.page": {
+ "link.label": "Edit",
+ "title": "Edit todo entry"
+ },
+ "forbidden.page": {
+ "text": "Permission denied.",
+ "title": "Forbidden"
+ },
+ "not.found.page": {
+ "text": "The page that you were looking for was not found.",
+ "title": "Not Found"
+ },
+ "list.page": {
+ "title": "Things to do",
+ "texts": {
+ "no.todo.entries.found": "Nothing to do (yet)."
+ }
+ },
+ "login.page": {
+ "title": "Log In"
+ },
+ "search.results.page": {
+ "texts": {
+ "no.todo.entries.found": "No todo entries was found with the given search term."
+ },
+ "title": "Search Results"
+ },
+ "view.page": {
+ "title": "View Todo Entry"
+ }
+ },
+ "login": {
+ "help": "Log in by using username: 'user' and password: 'password'",
+ "username": "Username",
+ "username.placeholder": "Enter username",
+ "password": "Password",
+ "password.placeholder": "Enter password"
+ },
+ "search": {
+ "term.field.placeholder": "Search",
+ "missing.characters.text": "{{missingCharCount}} characters missing"
+ },
+ "todo": {
+ "created.by.prefix": "by",
+ "creation.time": "Created at",
+ "description": "Description",
+ "description.placeholder": "Enter description",
+ "messages": {
+ "description.maxLength": "Description cannot be longer than 500 characters",
+ "title.maxLength": "Title cannot be longer than 100 characters",
+ "title.required": "Title is required"
+ },
+ "modified.by.prefix": "by",
+ "modification.time": "Modified at",
+ "notifications": {
+ "add": {
+ "error": "Adding a new todo entry failed.",
+ "success": "A new todo entry was added."
+ },
+ "delete": {
+ "error": "Deleting the todo entry failed.",
+ "success": "Deleted the todo entry."
+ },
+ "edit": {
+ "error": "Updating the information of a todo entry failed.",
+ "success": "Updated the information of the todo entry."
+ }
+ },
+ "title": "Title",
+ "title.placeholder": "Enter title"
+ }
+}
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/account/forbidden-view.html b/criteria-api/frontend/app/assets/partials/account/forbidden-view.html
new file mode 100644
index 0000000..c761f3e
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/account/forbidden-view.html
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/account/login-form-directive.html b/criteria-api/frontend/app/assets/partials/account/login-form-directive.html
new file mode 100644
index 0000000..d2f14aa
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/account/login-form-directive.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/account/login-view.html b/criteria-api/frontend/app/assets/partials/account/login-view.html
new file mode 100644
index 0000000..199d339
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/account/login-view.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/account/logout-link-directive.html b/criteria-api/frontend/app/assets/partials/account/logout-link-directive.html
new file mode 100644
index 0000000..4d9550a
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/account/logout-link-directive.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/common/not-found-view.html b/criteria-api/frontend/app/assets/partials/common/not-found-view.html
new file mode 100644
index 0000000..7edf553
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/common/not-found-view.html
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/search/dirPagination.tpl.html b/criteria-api/frontend/app/assets/partials/search/dirPagination.tpl.html
new file mode 100644
index 0000000..558aa20
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/search/dirPagination.tpl.html
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/search/search-form-directive.html b/criteria-api/frontend/app/assets/partials/search/search-form-directive.html
new file mode 100644
index 0000000..674143e
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/search/search-form-directive.html
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/search/search-result-list-directive.html b/criteria-api/frontend/app/assets/partials/search/search-result-list-directive.html
new file mode 100644
index 0000000..c38f4f7
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/search/search-result-list-directive.html
@@ -0,0 +1,19 @@
+
diff --git a/criteria-api/frontend/app/assets/partials/search/search-result-view.html b/criteria-api/frontend/app/assets/partials/search/search-result-view.html
new file mode 100644
index 0000000..2d8cd39
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/search/search-result-view.html
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/todo/add-todo-view.html b/criteria-api/frontend/app/assets/partials/todo/add-todo-view.html
new file mode 100644
index 0000000..0a0406a
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/todo/add-todo-view.html
@@ -0,0 +1,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/todo/delete-todo-modal.html b/criteria-api/frontend/app/assets/partials/todo/delete-todo-modal.html
new file mode 100644
index 0000000..b390319
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/todo/delete-todo-modal.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/todo/edit-todo-view.html b/criteria-api/frontend/app/assets/partials/todo/edit-todo-view.html
new file mode 100644
index 0000000..1695ae6
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/todo/edit-todo-view.html
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/todo/todo-form-directive.html b/criteria-api/frontend/app/assets/partials/todo/todo-form-directive.html
new file mode 100644
index 0000000..c7815d0
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/todo/todo-form-directive.html
@@ -0,0 +1,52 @@
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/todo/todo-list-directive.html b/criteria-api/frontend/app/assets/partials/todo/todo-list-directive.html
new file mode 100644
index 0000000..60ed955
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/todo/todo-list-directive.html
@@ -0,0 +1,8 @@
+
diff --git a/criteria-api/frontend/app/assets/partials/todo/todo-list-view.html b/criteria-api/frontend/app/assets/partials/todo/todo-list-view.html
new file mode 100644
index 0000000..6a83ba4
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/todo/todo-list-view.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/frontend/app/assets/partials/todo/view-todo-view.html b/criteria-api/frontend/app/assets/partials/todo/view-todo-view.html
new file mode 100644
index 0000000..374c16d
--- /dev/null
+++ b/criteria-api/frontend/app/assets/partials/todo/view-todo-view.html
@@ -0,0 +1,25 @@
+
+ * Our application always returns the correct time because it uses the
+ * {@link CurrentTimeDateTimeService} class.
+ *
+ *
+ * When our integration tests are running, we can return a constant time which gives us the possibility
+ * to assert the creation and modification times saved to the database.
+ *
+ *
+ *
+ * @author Petri Kainulainen
+ */
+public class AuditingDateTimeProvider implements DateTimeProvider {
+
+ private final DateTimeService dateTimeService;
+
+ public AuditingDateTimeProvider(DateTimeService dateTimeService) {
+ this.dateTimeService = dateTimeService;
+ }
+
+ @Override
+ public Calendar getNow() {
+ return GregorianCalendar.from(dateTimeService.getCurrentDateAndTime());
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/ConstantDateTimeService.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/ConstantDateTimeService.java
new file mode 100644
index 0000000..424e1d4
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/ConstantDateTimeService.java
@@ -0,0 +1,47 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * This class is used in our integration tests and it always returns the
+ * same time. This gives us the possibility to verify that the correct
+ * timestamps are saved to the database.
+ *
+ * @author Petri Kainulainen
+ */
+public class ConstantDateTimeService implements DateTimeService {
+
+ public static final String CURRENT_DATE_AND_TIME = getConstantDateAndTime();
+
+ private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_ZONED_DATE_TIME;
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ConstantDateTimeService.class);
+
+ private static String getConstantDateAndTime() {
+ return "2015-07-19T12:52:28" +
+ getSystemZoneOffset() +
+ getSystemZoneId();
+ }
+
+ private static String getSystemZoneOffset() {
+ return ZonedDateTime.now().getOffset().toString();
+ }
+
+ private static String getSystemZoneId() {
+ return "[" + ZoneId.systemDefault().toString() + "]";
+ }
+
+ @Override
+ public ZonedDateTime getCurrentDateAndTime() {
+ ZonedDateTime constantDateAndTime = ZonedDateTime.from(FORMATTER.parse(CURRENT_DATE_AND_TIME));
+
+ LOGGER.info("Returning constant date and time: {}", constantDateAndTime);
+
+ return constantDateAndTime;
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/CurrentTimeDateTimeService.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/CurrentTimeDateTimeService.java
new file mode 100644
index 0000000..2812fb0
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/CurrentTimeDateTimeService.java
@@ -0,0 +1,25 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.ZonedDateTime;
+
+/**
+ * This class returns the current time.
+ *
+ * @author Petri Kainulainen
+ */
+public class CurrentTimeDateTimeService implements DateTimeService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(CurrentTimeDateTimeService.class);
+
+ @Override
+ public ZonedDateTime getCurrentDateAndTime() {
+ ZonedDateTime currentDateAndTime = ZonedDateTime.now();
+
+ LOGGER.info("Returning current date and time: {}", currentDateAndTime);
+
+ return currentDateAndTime;
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/DateTimeService.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/DateTimeService.java
new file mode 100644
index 0000000..a1e1a11
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/DateTimeService.java
@@ -0,0 +1,18 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import java.time.ZonedDateTime;
+
+/**
+ * This interface defines the methods used to get the current
+ * date and time.
+ *
+ * @author Petri Kainulainen
+ */
+public interface DateTimeService {
+
+ /**
+ * Returns the current date and time.
+ * @return
+ */
+ ZonedDateTime getCurrentDateAndTime();
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/FrontendLoaderController.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/FrontendLoaderController.java
new file mode 100644
index 0000000..46f2849
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/FrontendLoaderController.java
@@ -0,0 +1,29 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+
+/**
+ * This controller is responsible of starting the frontend application.
+ * @author Petri Kainulainen
+ */
+@Controller
+public class FrontendLoaderController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(FrontendLoaderController.class);
+
+ private static final String FRONTEND_APPLICATION_VIEW = "frontend/client";
+
+ /**
+ * Starts the AngularJS application.
+ * @return
+ */
+ @RequestMapping(value = "/", method = RequestMethod.GET)
+ public String startAngularJSApplication() {
+ LOGGER.debug("Starting frontend single page application.");
+ return FRONTEND_APPLICATION_VIEW;
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/PreCondition.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/PreCondition.java
new file mode 100644
index 0000000..d3cf557
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/PreCondition.java
@@ -0,0 +1,63 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+/**
+ * This class provides static utility methods that are used to ensure that a constructor or a method was invoked properly.
+ * These methods throw an exception if the specified precondition is violated.
+ *
+ * This class selects the thrown exception by using the guideline given in Effective Java by Joshua Bloch (Item 60).
+ *
+ * @author Petri Kainulainen
+ */
+public final class PreCondition {
+
+ private PreCondition() {}
+
+ /**
+ * Ensures that the expression given as a method parameter is true.
+ * @param expression The inspected expression.
+ * @param errorMessageTemplate The template that is used to construct the message of the exception thrown if the
+ * inspected exception is false. The template must use the syntax that is supported
+ * by the {@link java.lang.String#format(String, Object...)} method.
+ * @param errorMessageArguments The arguments that are used when the message of the thrown exception is constructed.
+ * @throws java.lang.IllegalArgumentException if the inspected exception is false.
+ */
+ public static void isTrue(boolean expression, String errorMessageTemplate, Object... errorMessageArguments) {
+ isTrue(expression, String.format(errorMessageTemplate, errorMessageArguments));
+ }
+ /**
+ * Ensures that the expression given as a method parameter is true.
+ * @param expression The inspected expression.
+ * @param errorMessage The error message that is passed forward to the exception that is thrown
+ * if the expression is false.
+ * @throws java.lang.IllegalArgumentException if the inspected expression is false.
+ */
+ public static void isTrue(boolean expression, String errorMessage) {
+ if (!expression) {
+ throw new IllegalArgumentException(errorMessage);
+ }
+ }
+ /**
+ * Ensures that the string given as a method parameter is not empty.
+ * @param string The inspected string.
+ * @param errorMessage The error message that is passed forward to the exception that is thrown if
+ * the string is empty.
+ * @throws java.lang.IllegalArgumentException if the inspected string is empty.
+ */
+ public static void notEmpty(String string, String errorMessage) {
+ if (string.isEmpty()) {
+ throw new IllegalArgumentException(errorMessage);
+ }
+ }
+ /**
+ * Ensures that the object given as a method parameter is not null.
+ * @param reference A reference to the inspected object.
+ * @param errorMessage The error message that is passed forward to the exception that is thrown if
+ * the object given as a method parameter is null.
+ * @throws java.lang.NullPointerException If the object given as a method parameter is null.
+ */
+ public static void notNull(Object reference, String errorMessage) {
+ if (reference == null) {
+ throw new NullPointerException(errorMessage);
+ }
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/UsernameAuditorAware.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/UsernameAuditorAware.java
new file mode 100644
index 0000000..ed511d8
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/common/UsernameAuditorAware.java
@@ -0,0 +1,34 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.domain.AuditorAware;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.User;
+
+/**
+ * This component returns the username of the authenticated user.
+ *
+ * @author Petri Kainulainen
+ */
+public class UsernameAuditorAware implements AuditorAware {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(UsernameAuditorAware.class);
+
+ @Override
+ public String getCurrentAuditor() {
+ LOGGER.debug("Getting the username of authenticated user.");
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+
+ if (authentication == null || !authentication.isAuthenticated()) {
+ LOGGER.debug("Current user is anonymous. Returning null.");
+ return null;
+ }
+
+ String username = ((User) authentication.getPrincipal()).getUsername();
+ LOGGER.debug("Returning username: {}", username);
+
+ return username;
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/ExampleApplicationContext.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/ExampleApplicationContext.java
new file mode 100644
index 0000000..0f922d8
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/ExampleApplicationContext.java
@@ -0,0 +1,66 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import net.petrikainulainen.springdata.jpa.common.ConstantDateTimeService;
+import net.petrikainulainen.springdata.jpa.common.CurrentTimeDateTimeService;
+import net.petrikainulainen.springdata.jpa.common.DateTimeService;
+import org.springframework.context.MessageSource;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Profile;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
+import org.springframework.context.support.ResourceBundleMessageSource;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Configuration
+@ComponentScan("net.petrikainulainen.springdata.jpa")
+@Import({WebMvcContext.class, PersistenceContext.class, SecurityContext.class})
+public class ExampleApplicationContext {
+
+ private static final String MESSAGE_SOURCE_BASE_NAME = "i18n/messages";
+
+ /**
+ * These static classes are required because it makes it possible to use
+ * different properties files for every Spring profile.
+ *
+ * See: This StackOverflow answer for more details.
+ */
+ @Profile(Profiles.APPLICATION)
+ @Configuration
+ @PropertySource("classpath:application.properties")
+ static class ApplicationProperties {}
+
+ @Profile(Profiles.APPLICATION)
+ @Bean
+ DateTimeService currentTimeDateTimeService() {
+ return new CurrentTimeDateTimeService();
+ }
+
+ @Profile(Profiles.INTEGRATION_TEST)
+ @Configuration
+ @PropertySource("classpath:integration-test.properties")
+ static class IntegrationTestProperties {}
+
+ @Profile(Profiles.INTEGRATION_TEST)
+ @Bean
+ DateTimeService constantDateTimeService() {
+ return new ConstantDateTimeService();
+ }
+
+ @Bean
+ MessageSource messageSource() {
+ ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
+ messageSource.setBasename(MESSAGE_SOURCE_BASE_NAME);
+ messageSource.setUseCodeAsDefaultMessage(true);
+ return messageSource;
+ }
+
+ @Bean
+ PropertySourcesPlaceholderConfigurer propertyPlaceHolderConfigurer() {
+ return new PropertySourcesPlaceholderConfigurer();
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/PersistenceContext.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/PersistenceContext.java
new file mode 100644
index 0000000..78a9a36
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/PersistenceContext.java
@@ -0,0 +1,136 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import net.petrikainulainen.springdata.jpa.common.AuditingDateTimeProvider;
+import net.petrikainulainen.springdata.jpa.common.DateTimeService;
+import net.petrikainulainen.springdata.jpa.common.UsernameAuditorAware;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.Environment;
+import org.springframework.data.auditing.DateTimeProvider;
+import org.springframework.data.domain.AuditorAware;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.data.web.config.EnableSpringDataWebSupport;
+import org.springframework.orm.jpa.JpaTransactionManager;
+import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
+import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import javax.persistence.EntityManagerFactory;
+import javax.sql.DataSource;
+import java.util.Properties;
+
+/**
+ * This configuration class configures the persistence layer of our example application and
+ * enables annotation driven transaction management.
+ *
+ * This configuration is put to a single class because this way we can write integration
+ * tests for our persistence layer by using the configuration used by our example
+ * application. In other words, we can ensure that the persistence layer of our application
+ * works as expected.
+ *
+ * @author Petri Kainulainen
+ */
+@Configuration
+@EnableJpaAuditing(dateTimeProviderRef = "dateTimeProvider")
+@EnableJpaRepositories(basePackages = {
+ "net.petrikainulainen.springdata.jpa.todo"
+})
+@EnableTransactionManagement
+@EnableSpringDataWebSupport
+class PersistenceContext {
+ private static final String[] ENTITY_PACKAGES = {
+ "net.petrikainulainen.springdata.jpa.todo"
+ };
+
+ private static final String PROPERTY_NAME_DB_DRIVER_CLASS = "db.driver";
+ private static final String PROPERTY_NAME_DB_PASSWORD = "db.password";
+ private static final String PROPERTY_NAME_DB_URL = "db.url";
+ private static final String PROPERTY_NAME_DB_USER = "db.username";
+ private static final String PROPERTY_NAME_HIBERNATE_DIALECT = "hibernate.dialect";
+ private static final String PROPERTY_NAME_HIBERNATE_FORMAT_SQL = "hibernate.format_sql";
+ private static final String PROPERTY_NAME_HIBERNATE_HBM2DDL_AUTO = "hibernate.hbm2ddl.auto";
+ private static final String PROPERTY_NAME_HIBERNATE_NAMING_STRATEGY = "hibernate.ejb.naming_strategy";
+ private static final String PROPERTY_NAME_HIBERNATE_SHOW_SQL = "hibernate.show_sql";
+
+ @Bean
+ AuditorAware auditorProvider() {
+ return new UsernameAuditorAware();
+ }
+
+ @Bean
+ DateTimeProvider dateTimeProvider(DateTimeService dateTimeService) {
+ return new AuditingDateTimeProvider(dateTimeService);
+ }
+
+ /**
+ * Creates and configures the HikariCP datasource bean.
+ * @param env The runtime environment of our application.
+ * @return
+ */
+ @Bean(destroyMethod = "close")
+ DataSource dataSource(Environment env) {
+ HikariConfig dataSourceConfig = new HikariConfig();
+ dataSourceConfig.setDriverClassName(env.getRequiredProperty(PROPERTY_NAME_DB_DRIVER_CLASS));
+ dataSourceConfig.setJdbcUrl(env.getRequiredProperty(PROPERTY_NAME_DB_URL));
+ dataSourceConfig.setUsername(env.getRequiredProperty(PROPERTY_NAME_DB_USER));
+ dataSourceConfig.setPassword(env.getRequiredProperty(PROPERTY_NAME_DB_PASSWORD));
+
+ return new HikariDataSource(dataSourceConfig);
+ }
+
+ /**
+ * Creates the bean that creates the JPA entity manager factory.
+ * @param dataSource The datasource that provides the database connections.
+ * @param env The runtime environment of our application.
+ * @return
+ */
+ @Bean
+ LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource, Environment env) {
+ LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
+ entityManagerFactoryBean.setDataSource(dataSource);
+ entityManagerFactoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
+ entityManagerFactoryBean.setPackagesToScan(ENTITY_PACKAGES);
+
+ Properties jpaProperties = new Properties();
+
+ //Configures the used database dialect. This allows Hibernate to create SQL
+ //that is optimized for the used database.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_DIALECT, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_DIALECT));
+
+ //Specifies the action that is invoked to the database when the Hibernate
+ //SessionFactory is created or closed.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_HBM2DDL_AUTO, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_HBM2DDL_AUTO));
+
+ //Configures the naming strategy that is used when Hibernate creates
+ //new database objects and schema elements
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_NAMING_STRATEGY, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_NAMING_STRATEGY));
+
+ //If the value of this property is true, Hibernate writes all SQL
+ //statements to the console.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_SHOW_SQL, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_SHOW_SQL));
+
+ //If the value of this property is true, Hibernate will use prettyprint
+ //when it writes SQL to the console.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_FORMAT_SQL, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_FORMAT_SQL));
+
+ entityManagerFactoryBean.setJpaProperties(jpaProperties);
+
+ return entityManagerFactoryBean;
+ }
+
+ /**
+ * Creates the transaction manager bean that integrates the used JPA provider with the
+ * Spring transaction mechanism.
+ * @param entityManagerFactory The used JPA entity manager factory.
+ * @return
+ */
+ @Bean
+ JpaTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
+ JpaTransactionManager transactionManager = new JpaTransactionManager();
+ transactionManager.setEntityManagerFactory(entityManagerFactory);
+ return transactionManager;
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/Profiles.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/Profiles.java
new file mode 100644
index 0000000..bda9711
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/Profiles.java
@@ -0,0 +1,21 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+/**
+ * This class defines the Spring profiles used in the project. The idea behind this class
+ * is that it helps us to avoid typos when we are using these profiles. At the moment
+ * there are two profiles which are described in the following:
+ *
+ *
The APPLICATION profile is used when we run our example application.
+ *
The INTEGRATION_TEST profile is used when we run the integration tests of our example application.
+ *
+ *
+ * @author Petri Kainulainen
+ */
+public class Profiles {
+ public static final String APPLICATION = "application";
+ public static final String INTEGRATION_TEST = "integrationtest";
+ /**
+ * Prevent instantiation.
+ */
+ private Profiles() {}
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/SecurityContext.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/SecurityContext.java
new file mode 100644
index 0000000..8aa95e4
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/SecurityContext.java
@@ -0,0 +1,99 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import net.petrikainulainen.springdata.jpa.web.security.CsrfHeaderFilter;
+import net.petrikainulainen.springdata.jpa.web.security.RestAuthenticationEntryPoint;
+import net.petrikainulainen.springdata.jpa.web.security.RestAuthenticationFailureHandler;
+import net.petrikainulainen.springdata.jpa.web.security.RestAuthenticationSuccessHandler;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.builders.WebSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.csrf.CsrfFilter;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Configuration
+@EnableWebSecurity
+class SecurityContext extends WebSecurityConfigurerAdapter {
+
+ @Bean
+ AuthenticationEntryPoint authenticationEntryPoint() {
+ return new RestAuthenticationEntryPoint();
+ }
+
+ @Bean
+ AuthenticationFailureHandler authenticationFailureHandler() {
+ return new RestAuthenticationFailureHandler();
+ }
+
+ @Bean
+ AuthenticationSuccessHandler authenticationSuccessHandler() {
+ return new RestAuthenticationSuccessHandler();
+ }
+
+ @Bean
+ protected UserDetailsService userDetailsService() {
+ return super.userDetailsService();
+ }
+
+ @Override
+ protected void configure(AuthenticationManagerBuilder auth) throws Exception {
+ auth
+ .inMemoryAuthentication()
+ .withUser("user")
+ .password("password")
+ .roles("USER");
+ }
+
+ @Override
+ protected void configure(HttpSecurity http) throws Exception {
+ http
+ //Use the custom authentication entry point.
+ .exceptionHandling()
+ .authenticationEntryPoint(authenticationEntryPoint())
+ .and()
+ //Configure form login.
+ .formLogin()
+ .loginProcessingUrl("/api/login")
+ .failureHandler(authenticationFailureHandler())
+ .successHandler(authenticationSuccessHandler())
+ .permitAll()
+ .and()
+ //Configure logout function.
+ .logout()
+ .deleteCookies("JSESSIONID")
+ .logoutUrl("/api/logout")
+ .logoutSuccessUrl("/")
+ .and()
+ //Configure url based authorization
+ .authorizeRequests()
+ .antMatchers(
+ "/",
+ "/api/csrf"
+ ).permitAll()
+ .anyRequest().hasRole("USER")
+ .and()
+ .addFilterAfter(new CsrfHeaderFilter(), CsrfFilter.class);
+ }
+
+ @Override
+ public void configure(WebSecurity web) throws Exception {
+ web
+ //Spring Security ignores request to static resources such as CSS or JS files.
+ .ignoring()
+ .antMatchers(
+ "/favicon.ico",
+ "/css/**",
+ "/i18n/**",
+ "/js/**"
+ );
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/WebAppConfig.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/WebAppConfig.java
new file mode 100644
index 0000000..f1861a6
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/WebAppConfig.java
@@ -0,0 +1,66 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import org.springframework.web.WebApplicationInitializer;
+import org.springframework.web.context.ContextLoaderListener;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
+import org.springframework.web.filter.CharacterEncodingFilter;
+import org.springframework.web.filter.DelegatingFilterProxy;
+import org.springframework.web.servlet.DispatcherServlet;
+
+import javax.servlet.DispatcherType;
+import javax.servlet.FilterRegistration;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRegistration;
+import java.util.EnumSet;
+
+/**
+ * @author Petri Kainulainen
+ */
+public class WebAppConfig implements WebApplicationInitializer {
+ private static final String CHARACTER_ENCODING_FILTER_ENCODING = "UTF-8";
+ private static final String CHARACTER_ENCODING_FILTER_NAME = "characterEncoding";
+ private static final String CHARACTER_ENCODING_FILTER_URL_PATTERN = "/*";
+
+ private static final String DISPATCHER_SERVLET_NAME = "dispatcher";
+ private static final String DISPATCHER_SERVLET_MAPPING = "/";
+
+ @Override
+ public void onStartup(ServletContext servletContext) throws ServletException {
+ AnnotationConfigWebApplicationContext rootContext = new AnnotationConfigWebApplicationContext();
+ rootContext.register(ExampleApplicationContext.class);
+
+ //XmlWebApplicationContext rootContext = new XmlWebApplicationContext();
+ //rootContext.setConfigLocation("classpath:applicationContext.xml");
+
+ configureDispatcherServlet(servletContext, rootContext);
+ EnumSet dispatcherTypes = EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD);
+
+ configureCharacterEncodingFilter(servletContext, dispatcherTypes);
+ configureSpringSecurityFilter(servletContext, dispatcherTypes);
+ servletContext.addListener(new ContextLoaderListener(rootContext));
+ }
+
+ private void configureDispatcherServlet(ServletContext servletContext, WebApplicationContext rootContext) {
+ ServletRegistration.Dynamic dispatcher = servletContext.addServlet(
+ DISPATCHER_SERVLET_NAME,
+ new DispatcherServlet(rootContext)
+ );
+ dispatcher.setLoadOnStartup(1);
+ dispatcher.addMapping(DISPATCHER_SERVLET_MAPPING);
+ }
+
+ private void configureCharacterEncodingFilter(ServletContext servletContext, EnumSet dispatcherTypes) {
+ CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
+ characterEncodingFilter.setEncoding(CHARACTER_ENCODING_FILTER_ENCODING);
+ characterEncodingFilter.setForceEncoding(true);
+ FilterRegistration.Dynamic characterEncoding = servletContext.addFilter(CHARACTER_ENCODING_FILTER_NAME, characterEncodingFilter);
+ characterEncoding.addMappingForUrlPatterns(dispatcherTypes, true, CHARACTER_ENCODING_FILTER_URL_PATTERN);
+ }
+
+ private void configureSpringSecurityFilter(ServletContext servletContext, EnumSet dispatcherTypes) {
+ FilterRegistration.Dynamic security = servletContext.addFilter("springSecurityFilterChain", new DelegatingFilterProxy());
+ security.addMappingForUrlPatterns(dispatcherTypes, true, "/*");
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/WebMvcContext.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/WebMvcContext.java
new file mode 100644
index 0000000..c016860
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/config/WebMvcContext.java
@@ -0,0 +1,48 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JSR310Module;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
+
+import java.util.List;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Configuration
+@EnableWebMvc
+class WebMvcContext extends WebMvcConfigurerAdapter {
+
+ @Override
+ public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
+ configurer.enable();
+ }
+
+
+ @Override
+ public void configureMessageConverters(List> converters) {
+ ObjectMapper objectMapper = new ObjectMapper();
+
+ objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ objectMapper.registerModule(new JSR310Module());
+
+ MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
+ converter.setObjectMapper(objectMapper);
+
+ converters.add(converter);
+ }
+
+ @Override
+ public void configureViewResolvers(ViewResolverRegistry registry) {
+ registry.jsp("/WEB-INF/jsp/", ".jsp");
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoSearchService.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoSearchService.java
new file mode 100644
index 0000000..bb18e3c
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoSearchService.java
@@ -0,0 +1,42 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import static net.petrikainulainen.springdata.jpa.todo.TodoSpecifications.titleOrDescriptionContainsIgnoreCase;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Service
+final class RepositoryTodoSearchService implements TodoSearchService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RepositoryTodoSearchService.class);
+
+ private final TodoRepository repository;
+
+ @Autowired
+ public RepositoryTodoSearchService(TodoRepository repository) {
+ this.repository = repository;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public Page findBySearchTerm(String searchTerm, Pageable pageRequest) {
+ LOGGER.info("Finding todo entries by search term: {} and page request: {}", searchTerm, pageRequest);
+
+ Page searchResultPage = repository.findAll(titleOrDescriptionContainsIgnoreCase(searchTerm), pageRequest);
+ LOGGER.info("Found {} todo entries. Returned page {} contains {} todo entries",
+ searchResultPage.getTotalElements(),
+ searchResultPage.getNumber(),
+ searchResultPage.getNumberOfElements()
+ );
+
+ return TodoMapper.mapEntityPageIntoDTOPage(pageRequest, searchResultPage);
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoService.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoService.java
new file mode 100644
index 0000000..f59be0d
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoService.java
@@ -0,0 +1,101 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Service
+final class RepositoryTodoService implements TodoCrudService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RepositoryTodoService.class);
+
+ private final TodoRepository repository;
+
+ @Autowired
+ RepositoryTodoService(TodoRepository repository) {
+ this.repository = repository;
+ }
+
+ @Transactional
+ @Override
+ public TodoDTO create(TodoDTO newTodoEntry) {
+ LOGGER.info("Creating a new todo entry by using information: {}", newTodoEntry);
+
+ Todo created = Todo.getBuilder()
+ .description(newTodoEntry.getDescription())
+ .title(newTodoEntry.getTitle())
+ .build();
+
+ created = repository.save(created);
+ LOGGER.info("Created a new todo entry: {}", created);
+
+ return TodoMapper.mapEntityIntoDTO(created);
+ }
+
+ @Transactional
+ @Override
+ public TodoDTO delete(Long id) {
+ LOGGER.info("Deleting a todo entry with id: {}", id);
+
+ Todo deleted = findTodoEntryById(id);
+ LOGGER.debug("Found todo entry: {}", deleted);
+
+ repository.delete(deleted);
+ LOGGER.info("Deleted todo entry: {}", deleted);
+
+ return TodoMapper.mapEntityIntoDTO(deleted);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List findAll() {
+ LOGGER.info("Finding all todo entries.");
+
+ List todoEntries = repository.findAll();
+
+ LOGGER.info("Found {} todo entries", todoEntries.size());
+
+ return TodoMapper.mapEntitiesIntoDTOs(todoEntries);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public TodoDTO findById(Long id) {
+ LOGGER.info("Finding todo entry by using id: {}", id);
+
+ Todo todoEntry = findTodoEntryById(id);
+ LOGGER.info("Found todo entry: {}", todoEntry);
+
+ return TodoMapper.mapEntityIntoDTO(todoEntry);
+ }
+
+ @Transactional
+ @Override
+ public TodoDTO update(TodoDTO updatedTodoEntry) {
+ LOGGER.info("Updating the information of a todo entry by using information: {}", updatedTodoEntry);
+
+ Todo updated = findTodoEntryById(updatedTodoEntry.getId());
+ updated.update(updatedTodoEntry.getTitle(), updatedTodoEntry.getDescription());
+
+ //We need to flush the changes or otherwise the returned object
+ //doesn't contain the updated audit information.
+ repository.flush();
+
+ LOGGER.info("Updated the information of the todo entry: {}", updated);
+
+ return TodoMapper.mapEntityIntoDTO(updated);
+ }
+
+ private Todo findTodoEntryById(Long id) {
+ Optional todoResult = repository.findOne(id);
+ return todoResult.orElseThrow(() -> new TodoNotFoundException(id));
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/Todo.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/Todo.java
new file mode 100644
index 0000000..4eccac6
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/Todo.java
@@ -0,0 +1,181 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.hibernate.annotations.Type;
+import org.springframework.data.annotation.CreatedBy;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedBy;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EntityListeners;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import javax.persistence.Version;
+import java.time.ZonedDateTime;
+
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.isTrue;
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notEmpty;
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notNull;
+
+/**
+ * This entity class contains the information of a single todo entry
+ * and the methods that are used to create new todo entries and to modify
+ * the information of an existing todo entry.
+ *
+ * @author Petri Kainulainen
+ */
+@Entity
+@EntityListeners(AuditingEntityListener.class)
+@Table(name = "todos")
+final class Todo {
+
+ static final int MAX_LENGTH_DESCRIPTION = 500;
+ static final int MAX_LENGTH_TITLE = 100;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.AUTO)
+ private Long id;
+
+ @Column(name = "created_by_user", nullable = false)
+ @CreatedBy
+ private String createdByUser;
+
+ @Column(name = "creation_time", nullable = false)
+ @Type(type = "org.jadira.usertype.dateandtime.threeten.PersistentZonedDateTime")
+ @CreatedDate
+ private ZonedDateTime creationTime;
+
+ @Column(name = "description", length = MAX_LENGTH_DESCRIPTION)
+ private String description;
+
+ @Column(name = "modified_by_user", nullable = false)
+ @LastModifiedBy
+ private String modifiedByUser;
+
+ @Column(name = "modification_time")
+ @Type(type = "org.jadira.usertype.dateandtime.threeten.PersistentZonedDateTime")
+ @LastModifiedDate
+ private ZonedDateTime modificationTime;
+
+ @Column(name = "title", nullable = false, length = MAX_LENGTH_TITLE)
+ private String title;
+
+ @Version
+ private long version;
+
+ /**
+ * Required by Hibernate.
+ */
+ private Todo() {}
+
+ private Todo(Builder builder) {
+ this.title = builder.title;
+ this.description = builder.description;
+ }
+
+ static Builder getBuilder() {
+ return new Builder();
+ }
+
+ Long getId() {
+ return id;
+ }
+
+ String getCreatedByUser() {
+ return createdByUser;
+ }
+
+ ZonedDateTime getCreationTime() {
+ return creationTime;
+ }
+
+ String getDescription() {
+ return description;
+ }
+
+ String getModifiedByUser() {
+ return modifiedByUser;
+ }
+
+ ZonedDateTime getModificationTime() {
+ return modificationTime;
+ }
+
+ String getTitle() {
+ return title;
+ }
+
+ long getVersion() {
+ return version;
+ }
+
+ void update(String newTitle, String newDescription) {
+ requireValidTitleAndDescription(newTitle, newDescription);
+
+ this.title = newTitle;
+ this.description = newDescription;
+ }
+
+ private void requireValidTitleAndDescription(String title, String description) {
+ notNull(title, "Title cannot be null.");
+ notEmpty(title, "Title cannot be empty.");
+ isTrue(title.length() <= MAX_LENGTH_TITLE,
+ "The maximum length of the title is <%d> characters.",
+ MAX_LENGTH_TITLE
+ );
+
+ isTrue((description == null) || (description.length() <= MAX_LENGTH_DESCRIPTION),
+ "The maximum length of the description is <%d> characters.",
+ MAX_LENGTH_DESCRIPTION
+ );
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this)
+ .append("createdByUser", this.createdByUser)
+ .append("creationTime", this.creationTime)
+ .append("description", this.description)
+ .append("id", this.id)
+ .append("modifiedByUser", this.modifiedByUser)
+ .append("modificationTime", this.modificationTime)
+ .append("title", this.title)
+ .append("version", this.version)
+ .toString();
+ }
+
+ /**
+ * This entity is so simple that you don't really need to use the builder pattern
+ * (use a constructor instead). I use the builder pattern here because it makes
+ * the code a bit more easier to read.
+ */
+ static class Builder {
+ private String description;
+ private String title;
+
+ private Builder() {}
+
+ Builder description(String description) {
+ this.description = description;
+ return this;
+ }
+
+ Builder title(String title) {
+ this.title = title;
+ return this;
+ }
+
+ Todo build() {
+ Todo build = new Todo(this);
+
+ build.requireValidTitleAndDescription(build.getTitle(), build.getDescription());
+
+ return build;
+ }
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoCrudService.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoCrudService.java
new file mode 100644
index 0000000..9e6fc09
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoCrudService.java
@@ -0,0 +1,49 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import java.util.List;
+
+/**
+ * This service provides CRUD operations for {@link net.petrikainulainen.springdata.jpa.todo.Todo}
+ * objects.
+ *
+ * @author Petri Kainulainen
+ */
+public interface TodoCrudService {
+
+ /**
+ * Creates a new todo entry.
+ * @param newTodoEntry The information of the created todo entry.
+ * @return The information of the created todo entry.
+ */
+ TodoDTO create(TodoDTO newTodoEntry);
+
+ /**
+ * Deletes a todo entry from the database.
+ * @param id The id of the deleted todo entry.
+ * @return The information of the deleted todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if the deleted todo entry is not found.
+ */
+ TodoDTO delete(Long id);
+
+ /**
+ * Finds all todo entries that are saved to the database.
+ * @return
+ */
+ List findAll();
+
+ /**
+ * Finds a todo entry by using the id given as a method parameter.
+ * @param id The id of the wanted todo entry.
+ * @return The information of the requested todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found with the given id.
+ */
+ TodoDTO findById(Long id);
+
+ /**
+ * Updates the information of an existing information.
+ * @param updatedTodoEntry The new information of an existing todo entry.
+ * @return The information of the updated todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found with the given id.
+ */
+ TodoDTO update(TodoDTO updatedTodoEntry);
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoDTO.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoDTO.java
new file mode 100644
index 0000000..7eea8d2
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoDTO.java
@@ -0,0 +1,101 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.hibernate.validator.constraints.NotEmpty;
+
+import javax.validation.constraints.Size;
+import java.time.ZonedDateTime;
+
+/**
+ * @author Petri Kainulainen
+ */
+public final class TodoDTO {
+
+ private String createdByUser;
+
+ private ZonedDateTime creationTime;
+
+ @Size(max = Todo.MAX_LENGTH_DESCRIPTION)
+ private String description;
+
+ private Long id;
+
+ private String modifiedByUser;
+
+ private ZonedDateTime modificationTime;
+
+ @NotEmpty
+ @Size(max = Todo.MAX_LENGTH_TITLE)
+ private String title;
+
+ public TodoDTO() {}
+
+ public String getCreatedByUser() {
+ return createdByUser;
+ }
+
+ public ZonedDateTime getCreationTime() {
+ return creationTime;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getModifiedByUser() {
+ return modifiedByUser;
+ }
+
+ public ZonedDateTime getModificationTime() {
+ return modificationTime;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setCreatedByUser(String createdByUser) {
+ this.createdByUser = createdByUser;
+ }
+
+ public void setCreationTime(ZonedDateTime creationTime) {
+ this.creationTime = creationTime;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public void setModifiedByUser(String modifiedByUser) {
+ this.modifiedByUser = modifiedByUser;
+ }
+
+ public void setModificationTime(ZonedDateTime modificationTime) {
+ this.modificationTime = modificationTime;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this)
+ .append("createdByUser", this.createdByUser)
+ .append("creationTime", this.creationTime)
+ .append("description", this.description)
+ .append("id", this.id)
+ .append("modifiedByUser", this.modifiedByUser)
+ .append("modificationTime", this.modificationTime)
+ .append("title", this.title)
+ .toString();
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoMapper.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoMapper.java
new file mode 100644
index 0000000..006c80a
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoMapper.java
@@ -0,0 +1,40 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+
+import java.util.List;
+
+import static java.util.stream.Collectors.toList;
+
+/**
+ * @author Petri Kainulainen
+ */
+final class TodoMapper {
+
+ static List mapEntitiesIntoDTOs(List entities) {
+ return entities.stream()
+ .map(TodoMapper::mapEntityIntoDTO)
+ .collect(toList());
+ }
+
+ static TodoDTO mapEntityIntoDTO(Todo entity) {
+ TodoDTO dto = new TodoDTO();
+
+ dto.setCreatedByUser(entity.getCreatedByUser());
+ dto.setCreationTime(entity.getCreationTime());
+ dto.setDescription(entity.getDescription());
+ dto.setId(entity.getId());
+ dto.setModifiedByUser(entity.getModifiedByUser());
+ dto.setModificationTime(entity.getModificationTime());
+ dto.setTitle(entity.getTitle());
+
+ return dto;
+ }
+
+ static Page mapEntityPageIntoDTOPage(Pageable page, Page source) {
+ List dtos = mapEntitiesIntoDTOs(source.getContent());
+ return new PageImpl<>(dtos, page, source.getTotalElements());
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoNotFoundException.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoNotFoundException.java
new file mode 100644
index 0000000..63f6948
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoNotFoundException.java
@@ -0,0 +1,21 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+/**
+ * This exception is thrown when a todo entry is not found by
+ * using the given id.
+ *
+ * @author Petri Kainulainen
+ */
+public class TodoNotFoundException extends RuntimeException {
+
+ private final Long id;
+
+ public TodoNotFoundException(Long id) {
+ super();
+ this.id = id;
+ }
+
+ public Long getId() {
+ return id;
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepository.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepository.java
new file mode 100644
index 0000000..88d51c5
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepository.java
@@ -0,0 +1,26 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.repository.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * This repository provides CRUD operations for {@link net.petrikainulainen.springdata.jpa.todo.Todo}
+ * objects.
+ *
+ * @author Petri Kainulainen
+ */
+interface TodoRepository extends Repository, JpaSpecificationExecutor {
+
+ void delete(Todo deleted);
+
+ List findAll();
+
+ Optional findOne(Long id);
+
+ void flush();
+
+ Todo save(Todo persisted);
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchService.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchService.java
new file mode 100644
index 0000000..41212eb
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchService.java
@@ -0,0 +1,21 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+/**
+ * This service provides finder methods for {@link net.petrikainulainen.springdata.jpa.todo.Todo} objects.
+ *
+ * @author Petri Kainulainen
+ */
+public interface TodoSearchService {
+
+ /**
+ * Finds todo entries whose title or description contains the given search term.
+ * This search is case insensitive.
+ * @param searchTerm The search term.
+ * @param pageRequest The information of the requested page.
+ * @return
+ */
+ Page findBySearchTerm(String searchTerm, Pageable pageRequest);
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSpecifications.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSpecifications.java
new file mode 100644
index 0000000..46a0388
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSpecifications.java
@@ -0,0 +1,47 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.springframework.data.jpa.domain.Specification;
+
+/**
+ * This specification builder class provides static methods that are used
+ * to create Specification<Todo> objects which specify
+ * the search criteria of dynamic database queries.
+ *
+ * @author Petri Kainulainen
+ */
+final class TodoSpecifications {
+
+ /**
+ * Prevent instantiation.
+ */
+ private TodoSpecifications() {}
+
+ /**
+ * Creates the search criteria that returns all todo entries whose title or description
+ * contains the given search term. The search is case insensitive.
+ *
+ * If the search term is null or empty, the created search criteria will return
+ * all todo entries.
+ *
+ * @param searchTerm The used search term.
+ * @return
+ */
+ static Specification titleOrDescriptionContainsIgnoreCase(String searchTerm) {
+ return (root, query, cb) -> {
+ String containsLikePattern = getContainsLikePattern(searchTerm);
+ return cb.or(
+ cb.like(cb.lower(root.get(Todo_.title)), containsLikePattern),
+ cb.like(cb.lower(root.get(Todo_.description)), containsLikePattern)
+ );
+ };
+ }
+
+ private static String getContainsLikePattern(String searchTerm) {
+ if (searchTerm == null || searchTerm.isEmpty()) {
+ return "%";
+ }
+ else {
+ return "%" + searchTerm.toLowerCase() + "%";
+ }
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoController.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoController.java
new file mode 100644
index 0000000..5205657
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoController.java
@@ -0,0 +1,116 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import net.petrikainulainen.springdata.jpa.todo.TodoCrudService;
+import net.petrikainulainen.springdata.jpa.todo.TodoDTO;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import java.util.List;
+
+/**
+ * This controller provides the public API that is used to perform
+ * CRUD operations for todo entries.
+ *
+ * @author Petri Kainulainen
+ */
+@RestController
+@RequestMapping("/api/todo")
+final class TodoController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TodoController.class);
+
+ private final TodoCrudService crudService;
+
+ @Autowired
+ TodoController(TodoCrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ /**
+ * Create a new todo entry.
+ * @param newTodoEntry The information of the created todo entry.
+ * @return The information of the created todo entry.
+ */
+ @RequestMapping(method = RequestMethod.POST)
+ @ResponseStatus(HttpStatus.CREATED)
+ TodoDTO create(@RequestBody @Valid TodoDTO newTodoEntry) {
+ LOGGER.info("Creating a new todo entry by using information: {}", newTodoEntry);
+
+ TodoDTO created = crudService.create(newTodoEntry);
+ LOGGER.info("Created a new todo entry: {}", created);
+
+ return created;
+ }
+
+ /**
+ * Deletes a todo entry.
+ * @param id The id of the deleted todo entry.
+ * @return The information of the deleted todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if the deleted todo entry is not found.
+ */
+ @RequestMapping(value = "{id}", method = RequestMethod.DELETE)
+ public TodoDTO delete(@PathVariable("id") Long id) {
+ LOGGER.info("Deleting a todo entry with id: {}", id);
+
+ TodoDTO deleted = crudService.delete(id);
+ LOGGER.info("Deleted the todo entry: {}", deleted);
+
+ return deleted;
+ }
+
+ /**
+ * Finds all todo entries.
+ *
+ * @return The information of all todo entries.
+ */
+ @RequestMapping(method = RequestMethod.GET)
+ List findAll() {
+ LOGGER.info("Finding all todo entries");
+
+ List todoEntries = crudService.findAll();
+ LOGGER.info("Found {} todo entries.", todoEntries.size());
+
+ return todoEntries;
+ }
+
+ /**
+ * Finds a single todo entry.
+ * @param id The id of the requested todo entry.
+ * @return The information of the requested todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found by using the given id.
+ */
+ @RequestMapping(value = "{id}", method = RequestMethod.GET)
+ TodoDTO findById(@PathVariable("id") Long id) {
+ LOGGER.info("Finding todo entry by using id: {}", id);
+
+ TodoDTO todoEntry = crudService.findById(id);
+ LOGGER.info("Found todo entry: {}", todoEntry);
+
+ return todoEntry;
+ }
+
+ /**
+ * Updates the information of an existing todo entry.
+ * @param updatedTodoEntry The new information of the updated todo entry.
+ * @return The updated information of the updated todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found by using the given id.
+ */
+ @RequestMapping(value = "{id}", method = RequestMethod.PUT)
+ TodoDTO update(@RequestBody @Valid TodoDTO updatedTodoEntry) {
+ LOGGER.info("Updating the information of a todo entry by using information: {}", updatedTodoEntry);
+
+ TodoDTO updated = crudService.update(updatedTodoEntry);
+ LOGGER.info("Updated the information of the todo entrY: {}", updated);
+
+ return updated;
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoSearchController.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoSearchController.java
new file mode 100644
index 0000000..6ea5cd7
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoSearchController.java
@@ -0,0 +1,53 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import net.petrikainulainen.springdata.jpa.todo.TodoDTO;
+import net.petrikainulainen.springdata.jpa.todo.TodoSearchService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * This controller provides the public API that is used to find todo entries by using
+ * different search criteria.
+ *
+ * @author Petri Kainulainen
+ */
+@RestController
+final class TodoSearchController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TodoSearchController.class);
+
+ private final TodoSearchService searchService;
+
+ @Autowired
+ public TodoSearchController(TodoSearchService searchService) {
+ this.searchService = searchService;
+ }
+
+ /**
+ * Finds todo entries whose title or description contains the given search term. This
+ * search is case insensitive.
+ * @param searchTerm The used search term.
+ * @param pageRequest The information of the requested page
+ * @return
+ */
+ @RequestMapping(value = "/api/todo/search", method = RequestMethod.GET)
+ public Page findBySearchTerm(@RequestParam("searchTerm") String searchTerm, Pageable pageRequest) {
+ LOGGER.info("Finding todo entries by search term: {} and page request: {}", searchTerm, pageRequest);
+
+ Page searchResultPage = searchService.findBySearchTerm(searchTerm, pageRequest);
+ LOGGER.info("Found {} todo entries. Returned page {} contains {} todo entries",
+ searchResultPage.getTotalElements(),
+ searchResultPage.getNumber(),
+ searchResultPage.getNumberOfElements()
+ );
+
+ return searchResultPage;
+ }
+}
diff --git a/query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/ErrorDTO.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTO.java
similarity index 94%
rename from query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/ErrorDTO.java
rename to criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTO.java
index 834b298..b02059e 100644
--- a/query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/ErrorDTO.java
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTO.java
@@ -1,4 +1,4 @@
-package net.petrikainulainen.springdata.jpa.web;
+package net.petrikainulainen.springdata.jpa.web.error;
import static net.petrikainulainen.springdata.jpa.common.PreCondition.notEmpty;
import static net.petrikainulainen.springdata.jpa.common.PreCondition.notNull;
diff --git a/query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/FieldErrorDTO.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTO.java
similarity index 93%
rename from query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/FieldErrorDTO.java
rename to criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTO.java
index b567f86..44234a5 100644
--- a/query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/FieldErrorDTO.java
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTO.java
@@ -1,4 +1,4 @@
-package net.petrikainulainen.springdata.jpa.web;
+package net.petrikainulainen.springdata.jpa.web.error;
import static net.petrikainulainen.springdata.jpa.common.PreCondition.notEmpty;
import static net.petrikainulainen.springdata.jpa.common.PreCondition.notNull;
diff --git a/query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/RestErrorHandler.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandler.java
similarity index 96%
rename from query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/RestErrorHandler.java
rename to criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandler.java
index 5303b61..5ad9e9b 100644
--- a/query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/RestErrorHandler.java
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandler.java
@@ -1,4 +1,4 @@
-package net.petrikainulainen.springdata.jpa.web;
+package net.petrikainulainen.springdata.jpa.web.error;
import net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException;
import org.slf4j.Logger;
@@ -25,7 +25,7 @@
* @author Petri Kainulainen
*/
@ControllerAdvice
-final class RestErrorHandler {
+public final class RestErrorHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(RestErrorHandler.class);
@@ -34,7 +34,7 @@ final class RestErrorHandler {
private final MessageSource messageSource;
@Autowired
- RestErrorHandler(MessageSource messageSource) {
+ public RestErrorHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
diff --git a/query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/ValidationErrorDTO.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTO.java
similarity index 93%
rename from query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/ValidationErrorDTO.java
rename to criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTO.java
index 6d4ad22..8355c7b 100644
--- a/query-methods/src/main/java/net/petrikainulainen/springdata/jpa/web/ValidationErrorDTO.java
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTO.java
@@ -1,4 +1,4 @@
-package net.petrikainulainen.springdata.jpa.web;
+package net.petrikainulainen.springdata.jpa.web.error;
import org.springframework.http.HttpStatus;
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfHeaderFilter.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfHeaderFilter.java
new file mode 100644
index 0000000..141a948
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfHeaderFilter.java
@@ -0,0 +1,46 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This filter reads the {@link org.springframework.security.web.csrf.CsrfToken} from the {@link HttpServletRequest} and
+ * sets its content to the {@link HttpServletResponse} headers.
+ *
+ * I borrowed this idea from this StackOverflow question.
+ *
+ * @author Petri Kainulainen
+ */
+public class CsrfHeaderFilter extends OncePerRequestFilter {
+
+ private static final Logger LOGGER = org.slf4j.LoggerFactory.getLogger(CsrfHeaderFilter.class);
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+
+ LOGGER.trace("Reading CSRF token from the request.");
+
+ CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
+
+ if (token != null) {
+ LOGGER.trace("CSRF token was found. Creating HTTP response headers.");
+ response.setHeader("X-CSRF-HEADER", token.getHeaderName());
+ response.setHeader("X-CSRF-PARAM", token.getParameterName());
+ response.setHeader("X-CSRF-TOKEN", token.getToken());
+ }
+ else {
+ LOGGER.trace("CSRF Token was not found. Doing nothing.");
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfTokenController.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfTokenController.java
new file mode 100644
index 0000000..f6e70cb
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfTokenController.java
@@ -0,0 +1,21 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RestController
+public class CsrfTokenController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(CsrfTokenController.class);
+
+ @RequestMapping(value = "/api/csrf", method = RequestMethod.HEAD)
+ public void getCsrfToken() {
+ LOGGER.info("Getting CSRF token.");
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationEntryPoint.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationEntryPoint.java
new file mode 100644
index 0000000..887e25b
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationEntryPoint.java
@@ -0,0 +1,28 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This authentication entry point returns the HTTP status code 401.
+ * @author Petri Kainulainen
+ */
+public final class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestAuthenticationEntryPoint.class);
+
+ @Override
+ public void commence(HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException authException) throws IOException, ServletException {
+ LOGGER.info("Authentication required. Returning HTTP status code 401.");
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationFailureHandler.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationFailureHandler.java
new file mode 100644
index 0000000..daf635b
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationFailureHandler.java
@@ -0,0 +1,28 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This authentication failure handler returns the HTTP status code 403.
+ * @author Petri Kainulainen
+ */
+public final class RestAuthenticationFailureHandler implements AuthenticationFailureHandler {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestAuthenticationFailureHandler.class);
+
+ @Override
+ public void onAuthenticationFailure(HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException e) throws IOException, ServletException {
+ LOGGER.info("Authentication failed with message: {}", e.getMessage());
+ response.sendError(HttpServletResponse.SC_FORBIDDEN, "Authentication failed.");
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationSuccessHandler.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationSuccessHandler.java
new file mode 100644
index 0000000..ff84785
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationSuccessHandler.java
@@ -0,0 +1,30 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This authentication success handler returns the information of the authenticated
+ * user as JSON.
+ *
+ * @author Petri Kainulainen
+ */
+public final class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestAuthenticationSuccessHandler.class);
+
+ @Override
+ public void onAuthenticationSuccess(HttpServletRequest request,
+ HttpServletResponse response,
+ Authentication authentication) throws IOException, ServletException {
+ LOGGER.info("Authentication was successful");
+ response.sendRedirect(response.encodeRedirectURL("/api/authenticated-user"));
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserController.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserController.java
new file mode 100644
index 0000000..ef7959d
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserController.java
@@ -0,0 +1,45 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * This controller provides the public API that is used to return the information
+ * of the authenticated user.
+ *
+ * @author Petri Kainulainen
+ */
+@RestController
+final class UserController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(UserController.class);
+
+ /**
+ * Returns the information of the authenticated user as JSON. The returned information
+ * contains the username and the user role of the authenticated user.
+ *
+ * @param authenticatedUser The information of the authenticated user.
+ * @return
+ */
+ @RequestMapping(value = "/api/authenticated-user", method = RequestMethod.GET)
+ public UserDTO getAuthenticatedUser(@AuthenticationPrincipal User authenticatedUser) {
+ LOGGER.info("Getting authenticated user.");
+
+ if (authenticatedUser == null) {
+ //If anonymous users can access this controller method, someone has changed
+ //the security configuration and it must be fixed.
+ LOGGER.error("Authenticated user is not found.");
+ throw new AccessDeniedException("Anonymous users cannot request the information of the authenticated user.");
+ }
+ else {
+ LOGGER.info("User with username: {} is authenticated", authenticatedUser.getUsername());
+ return new UserDTO(authenticatedUser.getUsername(), authenticatedUser.getAuthorities());
+ }
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserDTO.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserDTO.java
new file mode 100644
index 0000000..92b99ed
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserDTO.java
@@ -0,0 +1,35 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import net.petrikainulainen.springdata.jpa.common.PreCondition;
+import org.springframework.security.core.GrantedAuthority;
+
+import java.util.Collection;
+
+/**
+ * This class contains the information of the authenticated user.
+ *
+ * @author Petri Kainulainen
+ */
+public final class UserDTO {
+
+ private final String username;
+
+ private final UserRole role;
+
+ UserDTO(String username, Collection authorities) {
+ PreCondition.isTrue(!username.isEmpty(), "Username cannot be empty.");
+ PreCondition.isTrue(authorities.size() == 1, "User must have only one granted authority.");
+ this.username = username;
+
+ GrantedAuthority authority = authorities.iterator().next();
+ this.role = UserRole.valueOf(authority.getAuthority());
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public UserRole getRole() {
+ return role;
+ }
+}
diff --git a/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserRole.java b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserRole.java
new file mode 100644
index 0000000..8b3e6a6
--- /dev/null
+++ b/criteria-api/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserRole.java
@@ -0,0 +1,8 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+/**
+ * @author Petri Kainulainen
+ */
+enum UserRole {
+ ROLE_USER
+}
diff --git a/criteria-api/src/main/resources/application.properties b/criteria-api/src/main/resources/application.properties
new file mode 100644
index 0000000..7ac8298
--- /dev/null
+++ b/criteria-api/src/main/resources/application.properties
@@ -0,0 +1,12 @@
+#Database Configuration
+db.driver=org.h2.Driver
+db.url=jdbc:h2:mem:datajpa
+db.username=sa
+db.password=
+
+#Hibernate Configuration
+hibernate.dialect=org.hibernate.dialect.H2Dialect
+hibernate.format_sql=true
+hibernate.hbm2ddl.auto=create-drop
+hibernate.ejb.naming_strategy=org.hibernate.cfg.ImprovedNamingStrategy
+hibernate.show_sql=false
\ No newline at end of file
diff --git a/criteria-api/src/main/resources/applicationContext-persistence.xml b/criteria-api/src/main/resources/applicationContext-persistence.xml
new file mode 100644
index 0000000..e9149e9
--- /dev/null
+++ b/criteria-api/src/main/resources/applicationContext-persistence.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${hibernate.dialect}
+
+
+ ${hibernate.hbm2ddl.auto}
+
+
+ ${hibernate.ejb.naming_strategy}
+
+
+ ${hibernate.show_sql}
+
+
+ ${hibernate.format_sql}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/src/main/resources/applicationContext-web.xml b/criteria-api/src/main/resources/applicationContext-web.xml
new file mode 100644
index 0000000..db48af6
--- /dev/null
+++ b/criteria-api/src/main/resources/applicationContext-web.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+ WRITE_DATES_AS_TIMESTAMPS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/src/main/resources/applicationContext.xml b/criteria-api/src/main/resources/applicationContext.xml
new file mode 100644
index 0000000..b9ee424
--- /dev/null
+++ b/criteria-api/src/main/resources/applicationContext.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/criteria-api/src/main/resources/i18n/messages.properties b/criteria-api/src/main/resources/i18n/messages.properties
new file mode 100644
index 0000000..0e376f5
--- /dev/null
+++ b/criteria-api/src/main/resources/i18n/messages.properties
@@ -0,0 +1,5 @@
+error.todo.entry.not.found=No todo entry was found by using id: {0}
+
+NotEmpty.todoDTO.title=The title cannot be empty
+Size.todoDTO.description=The maximum length of description is 500 characters
+Size.todoDTO.title=The maximum length of title is 100 characters
\ No newline at end of file
diff --git a/criteria-api/src/main/resources/integration-test.properties b/criteria-api/src/main/resources/integration-test.properties
new file mode 100644
index 0000000..3605c55
--- /dev/null
+++ b/criteria-api/src/main/resources/integration-test.properties
@@ -0,0 +1,14 @@
+#Database Configuration
+db.driver=org.h2.Driver
+db.url=jdbc:h2:mem:datajpa;DB_CLOSE_ON_EXIT=FALSE
+db.username=sa
+db.password=
+
+#Hibernate Configuration
+hibernate.dialect=org.hibernate.dialect.H2Dialect
+hibernate.format_sql=true
+hibernate.hbm2ddl.auto=create-drop
+hibernate.ejb.naming_strategy=org.hibernate.cfg.ImprovedNamingStrategy
+hibernate.show_sql=false
+
+test.reset.sql.template=ALTER TABLE %s ALTER COLUMN id RESTART WITH 1
\ No newline at end of file
diff --git a/tutorial-part-four/src/main/resources/log4j.properties b/criteria-api/src/main/resources/log4j.properties
similarity index 75%
rename from tutorial-part-four/src/main/resources/log4j.properties
rename to criteria-api/src/main/resources/log4j.properties
index 5ad34eb..668d97a 100644
--- a/tutorial-part-four/src/main/resources/log4j.properties
+++ b/criteria-api/src/main/resources/log4j.properties
@@ -3,4 +3,6 @@ log4j.appender.Stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.Stdout.layout.conversionPattern=%-5p - %-26.26c{1} - %m\n
log4j.rootLogger=DEBUG,Stdout
-log4j.logger.org.springframework=DEBUG
+
+log4j.logger.org.hibernate=INFO
+log4j.logger.org.springframework=INFO
\ No newline at end of file
diff --git a/criteria-api/src/main/webapp/WEB-INF/jsp/frontend/client.jsp b/criteria-api/src/main/webapp/WEB-INF/jsp/frontend/client.jsp
new file mode 100644
index 0000000..84158d0
--- /dev/null
+++ b/criteria-api/src/main/webapp/WEB-INF/jsp/frontend/client.jsp
@@ -0,0 +1,74 @@
+<%@ page contentType="text/html;charset=UTF-8" language="java" session="false" %>
+<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ * The new Java 8 date objects are serialized in standard
+ * ISO-8601 string representation.
+ *
+ *
+ *
+ * @return
+ */
+ static MappingJackson2HttpMessageConverter jacksonDateTimeConverter() {
+ ObjectMapper objectMapper = new ObjectMapper();
+
+ objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ objectMapper.registerModule(new JSR310Module());
+
+ MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
+ converter.setObjectMapper(objectMapper);
+ return converter;
+ }
+
+ /**
+ * This method ensures that the {@link RestErrorHandler} class
+ * is used to handle the exceptions thrown by the tested controller. I borrowed this idea from
+ * this StackOverflow answer.
+ *
+ * @return an error handler component that delegates relevant exceptions forward to the {@link RestErrorHandler} class.
+ */
+ static ExceptionHandlerExceptionResolver restErrorHandler(MessageSource messageSource) {
+ final ExceptionHandlerExceptionResolver exceptionResolver = new ExceptionHandlerExceptionResolver() {
+ @Override
+ protected ServletInvocableHandlerMethod getExceptionHandlerMethod(final HandlerMethod handlerMethod,
+ final Exception exception) {
+ Method method = new ExceptionHandlerMethodResolver(RestErrorHandler.class).resolveMethod(exception);
+ if (method != null) {
+ return new ServletInvocableHandlerMethod(new RestErrorHandler(messageSource), method);
+ }
+ return super.getExceptionHandlerMethod(handlerMethod, exception);
+ }
+ };
+ exceptionResolver.setMessageConverters(Arrays.asList(jacksonDateTimeConverter()));
+ exceptionResolver.afterPropertiesSet();
+ return exceptionResolver;
+ }
+
+ /**
+ * This method returns a {@link org.springframework.web.method.support.HandlerMethodArgumentResolver} that can
+ * construct {@link org.springframework.data.domain.Sort} objects by using the request params of the
+ * incoming request.
+ * @return
+ */
+ static SortHandlerMethodArgumentResolver sortArgumentResolver() {
+ return new SortHandlerMethodArgumentResolver();
+ }
+
+ /**
+ * This method returns a {@link org.springframework.web.method.support.HandlerMethodArgumentResolver} that can
+ * construct {@link org.springframework.data.domain.Pageable} objects by using the request params of the
+ * incoming request.
+ * @return
+ */
+ static PageableHandlerMethodArgumentResolver pageRequestArgumentResolver() {
+ return new PageableHandlerMethodArgumentResolver(sortArgumentResolver());
+ }
+
+ /**
+ * This method creates a validator object that adds support for bean validation API 1.0 and 1.1.
+ *
+ * @return The created validator object.
+ */
+ static LocalValidatorFactoryBean validator() {
+ return new LocalValidatorFactoryBean();
+ }
+}
diff --git a/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestConstants.java b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestConstants.java
new file mode 100644
index 0000000..1bf538d
--- /dev/null
+++ b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestConstants.java
@@ -0,0 +1,37 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import org.springframework.http.MediaType;
+
+import java.nio.charset.Charset;
+
+/**
+ * @author Petri Kainulainen
+ */
+public final class WebTestConstants {
+
+ public static final MediaType APPLICATION_JSON_UTF8 = new MediaType(MediaType.APPLICATION_JSON.getType(),
+ MediaType.APPLICATION_JSON.getSubtype(),
+ Charset.forName("utf8")
+ );
+
+ static final String ERROR_CODE_TODO_ENTRY_NOT_FOUND = "NOT_FOUND";
+ static final String ERROR_CODE_VALIDATION_FAILED = "BAD_REQUEST";
+
+ static final String FIELD_NAME_DESCRIPTION = "description";
+ static final String FIELD_NAME_TITLE = "title";
+
+ static final int MAX_LENGTH_DESCRIPTION = 500;
+ static final int MAX_LENGTH_TITLE = 100;
+
+ static final String REQUEST_PARAM_PAGE_NUMBER = "page";
+ static final String REQUEST_PARAM_PAGE_SIZE = "size";
+ static final String REQUEST_PARAM_SEARCH_TERM = "searchTerm";
+ static final String REQUEST_PARAM_SORT = "sort";
+
+ static final String SORT_DIRECTION_ASC = "ASC";
+
+ /**
+ * Prevents instantiation.
+ */
+ private WebTestConstants() {}
+}
diff --git a/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestUtil.java b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestUtil.java
new file mode 100644
index 0000000..9340fe6
--- /dev/null
+++ b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestUtil.java
@@ -0,0 +1,29 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+
+/**
+ * @author Petri Kainulainen
+ */
+final class WebTestUtil {
+
+ /**
+ * Prevents instantiation
+ */
+ private WebTestUtil() {}
+
+ /**
+ * Transforms an object into JSON and returns the JSON as a byte array.
+ * @param object The object that is transformed into JSON.
+ * @return The JSON representation of an object as a byte array.
+ * @throws IOException
+ */
+ static byte[] convertObjectToJsonBytes(Object object) throws IOException {
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ return mapper.writeValueAsBytes(object);
+ }
+}
diff --git a/query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/ErrorDTOTest.java b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTOTest.java
similarity index 93%
rename from query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/ErrorDTOTest.java
rename to criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTOTest.java
index f8cb260..528a552 100644
--- a/query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/ErrorDTOTest.java
+++ b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTOTest.java
@@ -1,6 +1,7 @@
-package net.petrikainulainen.springdata.jpa.web;
+package net.petrikainulainen.springdata.jpa.web.error;
import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.web.error.ErrorDTO;
import org.junit.Test;
import org.junit.runner.RunWith;
diff --git a/query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/FieldErrorDTOTest.java b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTOTest.java
similarity index 93%
rename from query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/FieldErrorDTOTest.java
rename to criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTOTest.java
index fbf7f74..25fc6bf 100644
--- a/query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/FieldErrorDTOTest.java
+++ b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTOTest.java
@@ -1,6 +1,7 @@
-package net.petrikainulainen.springdata.jpa.web;
+package net.petrikainulainen.springdata.jpa.web.error;
import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.web.error.FieldErrorDTO;
import org.junit.Test;
import org.junit.runner.RunWith;
diff --git a/query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/RestErrorHandlerTest.java b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandlerTest.java
similarity index 73%
rename from query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/RestErrorHandlerTest.java
rename to criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandlerTest.java
index bf745a6..5ff8f34 100644
--- a/query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/RestErrorHandlerTest.java
+++ b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandlerTest.java
@@ -1,4 +1,4 @@
-package net.petrikainulainen.springdata.jpa.web;
+package net.petrikainulainen.springdata.jpa.web.error;
import com.nitorcreations.junit.runners.NestedRunner;
import net.petrikainulainen.springdata.jpa.todo.TodoDTO;
@@ -6,7 +6,6 @@
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
-import org.mockito.ArgumentCaptor;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.core.MethodParameter;
@@ -18,6 +17,7 @@
import java.util.List;
import java.util.Locale;
+import static info.solidsoft.mockito.java8.AssertionMatcher.assertArg;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Matchers.eq;
@@ -53,13 +53,16 @@ public class HandleTodoEntryNotFound {
private static final String ERROR_MESSAGE_CODE_TODO_ENTRY_NOT_FOUND = "error.todo.entry.not.found";
private static final String ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND = "No todo entry was found by using id: 99";
- @Test
- public void shouldFindErrorMessageByUsingCurrentLocale() {
+ @Before
+ public void returnErrorMessageNotFound() {
given(messageSource.getMessage(
isA(MessageSourceResolvable.class),
isA(Locale.class))
).willReturn(ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND);
+ }
+ @Test
+ public void shouldFindErrorMessageByUsingCurrentLocale() {
errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
verify(messageSource, times(1)).getMessage(isA(MessageSourceResolvable.class), eq(CURRENT_LOCALE));
@@ -67,45 +70,30 @@ public void shouldFindErrorMessageByUsingCurrentLocale() {
@Test
public void shouldFindErrorMessageByUsingCorrectId() {
- given(messageSource.getMessage(
- isA(MessageSourceResolvable.class),
- isA(Locale.class))
- ).willReturn(ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND);
-
errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
- ArgumentCaptor messageRequestArgument = ArgumentCaptor.forClass(MessageSourceResolvable.class);
- verify(messageSource, times(1)).getMessage(messageRequestArgument.capture(), eq(CURRENT_LOCALE));
-
- MessageSourceResolvable messageRequest = messageRequestArgument.getValue();
- assertThat(messageRequest.getArguments())
- .containsOnly(TODO_ID);
+ verify(messageSource, times(1)).getMessage(
+ assertArg(messageRequest -> assertThat(messageRequest.getArguments())
+ .containsOnly(TODO_ID)
+ ),
+ eq(CURRENT_LOCALE)
+ );
}
@Test
public void shouldFindErrorMessageByUsingCorrectMessageCode() {
- given(messageSource.getMessage(
- isA(MessageSourceResolvable.class),
- isA(Locale.class))
- ).willReturn(ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND);
-
errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
- ArgumentCaptor messageRequestArgument = ArgumentCaptor.forClass(MessageSourceResolvable.class);
- verify(messageSource, times(1)).getMessage(messageRequestArgument.capture(), eq(CURRENT_LOCALE));
-
- MessageSourceResolvable messageRequest = messageRequestArgument.getValue();
- assertThat(messageRequest.getCodes())
- .containsOnly(ERROR_MESSAGE_CODE_TODO_ENTRY_NOT_FOUND);
+ verify(messageSource, times(1)).getMessage(
+ assertArg(messageRequest -> assertThat(messageRequest.getCodes())
+ .containsOnly(ERROR_MESSAGE_CODE_TODO_ENTRY_NOT_FOUND)
+ ),
+ eq(CURRENT_LOCALE)
+ );
}
@Test
public void shouldReturnErrorThatHasCorrectErrorCode() {
- given(messageSource.getMessage(
- isA(MessageSourceResolvable.class),
- isA(Locale.class)
- )).willReturn(ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND);
-
ErrorDTO error = errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
assertThat(error.getCode()).isEqualTo(ERROR_CODE_TODO_ENTRY_NOT_FOUND);
@@ -113,11 +101,6 @@ public void shouldReturnErrorThatHasCorrectErrorCode() {
@Test
public void shouldReturnErrorThatHasCorrectMessage() {
- given(messageSource.getMessage(
- isA(MessageSourceResolvable.class),
- isA(Locale.class)
- )).willReturn(ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND);
-
ErrorDTO error = errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
assertThat(error.getMessage()).isEqualTo(ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND);
@@ -140,17 +123,21 @@ public class WhenOneValidationErrorIsFound {
public class WhenMessageIsFound {
- @Test
- public void shouldReturnErrorThatHasCorrectCode() {
+ private MethodArgumentNotValidException ex;
+
+ @Before
+ public void createValidationErrorAndReturnErrorMessage() {
FieldError fieldError = new FieldErrorBuilder()
.defaultMessage(FIELD_DEFAULT_MESSAGE)
.fieldName(FIELD_WITH_VALIDATION_ERROR)
.build();
-
- MethodArgumentNotValidException ex = createExceptionWithFieldErrors(fieldError);
-
given(messageSource.getMessage(fieldError, CURRENT_LOCALE)).willReturn(ERROR_MESSAGE_VALIDATION_ERROR);
+ ex = createExceptionWithFieldErrors(fieldError);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectCode() {
ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
assertThat(validationErrors.getCode()).isEqualTo(ERROR_CODE_VALIDATION_ERROR);
@@ -158,15 +145,6 @@ public void shouldReturnErrorThatHasCorrectCode() {
@Test
public void shouldReturnErrorThatHasCorrectFieldErrorWithMessage() {
- FieldError fieldError = new FieldErrorBuilder()
- .defaultMessage(FIELD_DEFAULT_MESSAGE)
- .fieldName(FIELD_WITH_VALIDATION_ERROR)
- .build();
-
- MethodArgumentNotValidException ex = createExceptionWithFieldErrors(fieldError);
-
- given(messageSource.getMessage(fieldError, CURRENT_LOCALE)).willReturn(ERROR_MESSAGE_VALIDATION_ERROR);
-
ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
List fieldErrors = validationErrors.getFieldErrors();
@@ -180,18 +158,22 @@ public void shouldReturnErrorThatHasCorrectFieldErrorWithMessage() {
public class WhenMessageIsNotFound {
- @Test
- public void shouldReturnErrorThatHasCorrectCode() {
+ private MethodArgumentNotValidException ex;
+
+ @Before
+ public void createValidationErrorAndReturnDefaultErrorMessage() {
FieldError fieldError = new FieldErrorBuilder()
.defaultMessage(FIELD_DEFAULT_MESSAGE)
.errorCodes(VALIDATION_ERROR_CODE_ACCURATE, VALIDATION_ERROR_CODE_LESS_ACCURATE)
.fieldName(FIELD_WITH_VALIDATION_ERROR)
.build();
-
- MethodArgumentNotValidException ex = createExceptionWithFieldErrors(fieldError);
-
given(messageSource.getMessage(fieldError, CURRENT_LOCALE)).willReturn(FIELD_DEFAULT_MESSAGE);
+ ex = createExceptionWithFieldErrors(fieldError);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectCode() {
ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
assertThat(validationErrors.getCode()).isEqualTo(ERROR_CODE_VALIDATION_ERROR);
@@ -199,16 +181,6 @@ public void shouldReturnErrorThatHasCorrectCode() {
@Test
public void shouldReturnErrorThatHasFieldErrorWithMostAccurateFieldErrorCode() {
- FieldError fieldError = new FieldErrorBuilder()
- .defaultMessage(FIELD_DEFAULT_MESSAGE)
- .errorCodes(VALIDATION_ERROR_CODE_ACCURATE, VALIDATION_ERROR_CODE_LESS_ACCURATE)
- .fieldName(FIELD_WITH_VALIDATION_ERROR)
- .build();
-
- MethodArgumentNotValidException ex = createExceptionWithFieldErrors(fieldError);
-
- given(messageSource.getMessage(fieldError, CURRENT_LOCALE)).willReturn(FIELD_DEFAULT_MESSAGE);
-
ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
List fieldErrors = validationErrors.getFieldErrors();
diff --git a/query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/ValidationErrorDTOTest.java b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTOTest.java
similarity index 87%
rename from query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/ValidationErrorDTOTest.java
rename to criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTOTest.java
index 4df800f..8ae069a 100644
--- a/query-methods/src/test/java/net/petrikainulainen/springdata/jpa/web/ValidationErrorDTOTest.java
+++ b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTOTest.java
@@ -1,13 +1,15 @@
-package net.petrikainulainen.springdata.jpa.web;
+package net.petrikainulainen.springdata.jpa.web.error;
import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.web.error.FieldErrorDTO;
+import net.petrikainulainen.springdata.jpa.web.error.ValidationErrorDTO;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.List;
-import static net.petrikainulainen.springdata.jpa.common.ThrowableCaptor.thrown;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
/**
* @author Petri Kainulainen
@@ -34,7 +36,7 @@ public void shouldThrowException() {
public void shouldNotCreateNewFieldError() {
ValidationErrorDTO validationErrors = new ValidationErrorDTO();
- thrown(() -> validationErrors.addFieldError(null, MESSAGE));
+ catchThrowable(() -> validationErrors.addFieldError(null, MESSAGE));
assertThat(validationErrors.getFieldErrors()).isEmpty();
}
@@ -52,7 +54,7 @@ public void shouldThrowException() {
public void shouldNotCreateNewFieldError() {
ValidationErrorDTO validationErrors = new ValidationErrorDTO();
- thrown(() -> validationErrors.addFieldError("", MESSAGE));
+ catchThrowable(() -> validationErrors.addFieldError("", MESSAGE));
assertThat(validationErrors.getFieldErrors()).isEmpty();
}
@@ -73,7 +75,7 @@ public void shouldThrowException() {
public void shouldNotCreateNewFieldError() {
ValidationErrorDTO validationErrors = new ValidationErrorDTO();
- thrown(() -> validationErrors.addFieldError(FIELD, null));
+ catchThrowable(() -> validationErrors.addFieldError(FIELD, null));
assertThat(validationErrors.getFieldErrors()).isEmpty();
}
@@ -91,7 +93,7 @@ public void shouldThrowException() {
public void shouldNotCreateNewFieldError() {
ValidationErrorDTO validationErrors = new ValidationErrorDTO();
- thrown(() -> validationErrors.addFieldError(FIELD, ""));
+ catchThrowable(() -> validationErrors.addFieldError(FIELD, ""));
assertThat(validationErrors.getFieldErrors()).isEmpty();
}
diff --git a/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/security/UserDTOTest.java b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/security/UserDTOTest.java
new file mode 100644
index 0000000..1928461
--- /dev/null
+++ b/criteria-api/src/test/java/net/petrikainulainen/springdata/jpa/web/security/UserDTOTest.java
@@ -0,0 +1,102 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class UserDTOTest {
+
+ public class CreateNew {
+
+ private final String ROLE_USER = UserRole.ROLE_USER.name();
+
+ public class WhenUsernameIsEmpty {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ Collection authorities = createAuthorities(ROLE_USER);
+ new UserDTO("", authorities);
+ }
+ }
+
+ public class WhenUserNameIsNotEmpty {
+
+ private final String USERNAME = "username";
+
+ public class WhenUserHasNoGrantedAuthorities {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ new UserDTO(USERNAME, new ArrayList<>());
+ }
+ }
+
+ public class WhenUserHasOneGrantedAuthority {
+
+ public class WhenGrantedAuthorityIsKnown {
+
+ private Collection authorities;
+
+ @Before
+ public void createKnownAuthority() {
+ authorities = createAuthorities(ROLE_USER);
+ }
+
+ @Test
+ public void shouldSetUsername() {
+ UserDTO user = new UserDTO(USERNAME, authorities);
+ assertThat(user.getUsername()).isEqualTo(USERNAME);
+ }
+
+ @Test
+ public void shouldSetRole() {
+ UserDTO user = new UserDTO(USERNAME, authorities);
+ assertThat(user.getRole()).isEqualTo(UserRole.ROLE_USER);
+ }
+ }
+
+ public class WhenGrantedAuthorityIsUnknown {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ Collection authorities = createAuthorities("UNKNOWN_ROLE");
+ new UserDTO(USERNAME, authorities);
+ }
+ }
+ }
+
+ public class WhenUserHasMoreThanOneGrantedAuthority {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ Collection authorities = createAuthorities(ROLE_USER, "ANOTHER_ROLE");
+ new UserDTO(USERNAME, authorities);
+ }
+ }
+ }
+ }
+
+ private Collection createAuthorities(String... roles) {
+ List authorities = new ArrayList<>();
+
+ for (String role: roles) {
+ SimpleGrantedAuthority authority = new SimpleGrantedAuthority(role);
+ authorities.add(authority);
+ }
+
+ return authorities;
+ }
+}
diff --git a/custom-method-all-repos/.gitignore b/custom-method-all-repos/.gitignore
new file mode 100644
index 0000000..02895f1
--- /dev/null
+++ b/custom-method-all-repos/.gitignore
@@ -0,0 +1,10 @@
+.DS_Store
+.gradle
+.idea
+*.iml
+build
+h2db
+target
+node_modules
+bower_components
+build
\ No newline at end of file
diff --git a/tutorial-part-five/LICENSE b/custom-method-all-repos/LICENSE
similarity index 88%
rename from tutorial-part-five/LICENSE
rename to custom-method-all-repos/LICENSE
index b333aa5..c2b516d 100644
--- a/tutorial-part-five/LICENSE
+++ b/custom-method-all-repos/LICENSE
@@ -1,4 +1,4 @@
-Copyright 2011 Petri Kainulainen
+Copyright 2015 Petri Kainulainen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -10,4 +10,4 @@ Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
-limitations under the License.
\ No newline at end of file
+limitations under the License.
diff --git a/custom-method-all-repos/README.md b/custom-method-all-repos/README.md
new file mode 100644
index 0000000..82c34db
--- /dev/null
+++ b/custom-method-all-repos/README.md
@@ -0,0 +1,70 @@
+This blog post is the example application of the following blog posts:
+
+* [Spring Data JPA Tutorial: Getting the Required Dependencies](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-getting-the-required-dependencies/)
+* [Spring Data JPA Tutorial: Configuration](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-one-configuration/)
+* [Spring Data JPA Tutorial: CRUD](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-two-crud/)
+* [Spring Data JPA Tutorial: Auditing, Part One](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-auditing-part-one/)
+* [Spring Data JPA Tutorial: Auditing, Part Two](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-auditing-part-two/)
+* [Spring Data JPA Tutorial: Adding Custom Methods Into All Repositories]() - Not published yet
+
+Prerequisites
+=============
+
+You need to install the following tools if you want to run this application:
+
+Backend
+---------
+
+* [JDK 8](http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html)
+* [Maven](http://maven.apache.org/) (the application is tested with Maven 3.2.1)
+
+Frontend
+----------
+
+* [Node.js](http://nodejs.org/)
+* [NPM](https://www.npmjs.org/)
+* [Bower](http://bower.io/)
+* [Gulp](http://gulpjs.com/)
+
+You can install these tools by following these steps:
+
+1. Install Node.js by using a [downloaded binary](http://nodejs.org/download/) or a [package manager](https://github.com/joyent/node/wiki/Installing-Node.js-via-package-manager).
+ You can also read this blog post: [How to install Node.js and NPM](http://blog.nodeknockout.com/post/65463770933/how-to-install-node-js-and-npm)
+
+2. Install Bower by using the following command:
+
+ npm install -g bower
+
+3. Install Gulp by using the following command:
+
+ npm install -g gulp
+
+
+Running the Tests
+=================
+
+You can run the unit tests by using the following command:
+
+ mvn clean test -P dev
+
+You can run the integration tests by using the following command:
+
+ mvn clean verify -P integration-test
+
+Running the Application
+=======================
+
+You can run the application by using the following command:
+
+ mvn clean jetty:run -P dev
+
+Credits
+=========
+
+* Kyösti Herrala. The Gulp build script and its Maven integration are based on Kyösti's ideas.
+* [Techniques for authentication in AngularJS applications](https://medium.com/opinionated-angularjs/techniques-for-authentication-in-angularjs-applications-7bbf0346acec)
+
+Known Issues
+============
+
+* If you refresh the login page, you aren't redirected away from it after successful login.
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/.bowerrc b/custom-method-all-repos/frontend/.bowerrc
new file mode 100644
index 0000000..df4bcee
--- /dev/null
+++ b/custom-method-all-repos/frontend/.bowerrc
@@ -0,0 +1,3 @@
+{
+ "directory": "bower_components"
+}
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/.jshintrc b/custom-method-all-repos/frontend/.jshintrc
new file mode 100644
index 0000000..f648d46
--- /dev/null
+++ b/custom-method-all-repos/frontend/.jshintrc
@@ -0,0 +1,33 @@
+{
+ "globalstrict": true,
+ "browser": true,
+ "devel": true,
+ "node": true,
+ "esnext": true,
+ "bitwise": true,
+ "camelcase": true,
+ "curly": true,
+ "eqeqeq": true,
+ "immed": true,
+ "indent": 4,
+ "latedef": true,
+ "newcap": true,
+ "noarg": true,
+ "regexp": true,
+ "undef": true,
+ "unused": false,
+ "strict": true,
+ "trailing": true,
+ "smarttabs": true,
+ "white": true,
+ "globals": {
+ "describe": true,
+ "it": true,
+ "beforeEach": true,
+ "afterEach": true,
+ "angular": true,
+ "jQuery": true,
+ "_": true,
+ "$": true
+ }
+}
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/app.js b/custom-method-all-repos/frontend/app/app.js
new file mode 100644
index 0000000..1840cd6
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/app.js
@@ -0,0 +1,97 @@
+'use strict';
+
+var App = angular.module('app', [
+ 'angular-logger',
+ 'http-auth-interceptor',
+ 'ngLocale',
+ 'ngCookies',
+ 'ngResource',
+ 'ngSanitize',
+ 'pascalprecht.translate',
+ 'ui.bootstrap',
+ 'ui.router',
+ 'ui.utils',
+ 'angular-growl',
+ 'angularMoment',
+ 'spring-security-csrf-token-interceptor',
+
+ //Partials
+ 'templates',
+
+ //Account
+ 'app.account.config', 'app.account.directives', 'app.account.controllers', 'app.account.services',
+
+ //Common
+ 'app.common.config', 'app.common.controllers', 'app.common.directives', 'app.common.services',
+
+ //Todo
+ 'app.todo.controllers', 'app.todo.directives', 'app.todo.services',
+
+ //Search
+ 'app.search.controllers', 'app.search.directives', 'app.search.services'
+
+]);
+
+App.run(['$log', '$rootScope', '$state', 'AUTH_EVENTS', 'AuthenticatedUser', 'authService', 'AuthenticationService', 'COMMON_EVENTS',
+ function ($log, $rootScope, $state, AUTH_EVENTS, AuthenticatedUser, authService, AuthenticationService, COMMON_EVENTS) {
+
+ var logger = $log.getInstance('app');
+
+ //This function retries all requests that were failed because of
+ //the 401 response.
+ function listenAuthenticationEvents() {
+ var confirmLogin = function() {
+ authService.loginConfirmed();
+ };
+
+ $rootScope.$on(AUTH_EVENTS.loginSuccess, confirmLogin);
+
+ var viewLogInPage = function() {
+ logger.info('User is not authenticated. Rendering login view.');
+ $state.go('todo.login');
+ };
+
+ $rootScope.$on(AUTH_EVENTS.notAuthenticated, viewLogInPage);
+
+ var viewTodoListPage = function() {
+ logger.info("User logged out. REndering todo list view.");
+ $state.go('todo.list', {}, {reload: true});
+ };
+
+ $rootScope.$on(AUTH_EVENTS.logoutSuccess, viewTodoListPage);
+
+ var viewForbiddenPage = function() {
+ logger.info('Permission was denied for user: %j', AuthenticatedUser);
+ $state.go('todo.forbidden');
+ };
+
+ $rootScope.$on(AUTH_EVENTS.notAuthorized, viewForbiddenPage);
+ }
+
+ function listenCommonEvents() {
+
+ var view404Page = function() {
+ logger.info('Requested page was not found.');
+ $state.go('todo.404');
+ };
+
+ $rootScope.$on(COMMON_EVENTS.notFound, view404Page);
+ }
+
+ //This function ensures that anonymous users cannot access states
+ //that marked as protected (i.e. the value of the authenticated
+ //property is set to true).
+ function secureProtectedStates() {
+ $rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
+ logger.trace('Moving to state: %s', toState.name);
+ AuthenticationService.authorizeStateChange(event, toState, toParams);
+ });
+ }
+
+ $rootScope.currentUser = AuthenticatedUser;
+
+ listenAuthenticationEvents();
+ listenCommonEvents();
+ secureProtectedStates();
+}]);
+
diff --git a/custom-method-all-repos/frontend/app/assets/i18n/en.json b/custom-method-all-repos/frontend/app/assets/i18n/en.json
new file mode 100644
index 0000000..71d4d0c
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/i18n/en.json
@@ -0,0 +1,96 @@
+{
+ "app.title.label": "Spring Data JPA Tutorial - Query Methods",
+ "dialogs": {
+ "delete.dialog": {
+ "cancel.button.label": "Cancel",
+ "delete.button.label": "Delete",
+ "text": "Are you sure that you want to delete the todo entry with title: {{title}}?",
+ "title": "Delete todo entry?"
+ }
+ },
+ "directives": {
+ "login.form": {
+ "login.button": "Login",
+ "login.failed": "Login failed!"
+ },
+ "log.out.link.label": "Log Out",
+ "todo.form": {
+ "cancel.button": "Cancel",
+ "save.button": "Save"
+ }
+ },
+ "footer.message": "Spring Data JPA example application by Petri Kainulainen",
+ "header.brand.label": "Spring Data JPA Tutorial",
+ "pages": {
+ "add.page": {
+ "title": "Add new todo entry",
+ "link.label": "Add new todo entry"
+ },
+ "delete.link": "Delete",
+ "edit.page": {
+ "link.label": "Edit",
+ "title": "Edit todo entry"
+ },
+ "forbidden.page": {
+ "text": "Permission denied.",
+ "title": "Forbidden"
+ },
+ "not.found.page": {
+ "text": "The page that you were looking for was not found.",
+ "title": "Not Found"
+ },
+ "list.page": {
+ "title": "Things to do",
+ "texts": {
+ "no.todo.entries.found": "Nothing to do (yet)."
+ }
+ },
+ "login.page": {
+ "title": "Log In"
+ },
+ "search.results.page.title": "Search Results",
+ "view.page": {
+ "title": "View Todo Entry"
+ }
+ },
+ "login": {
+ "help": "Log in by using username: 'user' and password: 'password'",
+ "username": "Username",
+ "username.placeholder": "Enter username",
+ "password": "Password",
+ "password.placeholder": "Enter password"
+ },
+ "search": {
+ "term.field.placeholder": "Search",
+ "missing.characters.text": "{{missingCharCount}} characters missing"
+ },
+ "todo": {
+ "created.by.prefix": "by",
+ "creation.time": "Created at",
+ "description": "Description",
+ "description.placeholder": "Enter description",
+ "messages": {
+ "description.maxLength": "Description cannot be longer than 500 characters",
+ "title.maxLength": "Title cannot be longer than 100 characters",
+ "title.required": "Title is required"
+ },
+ "modified.by.prefix": "by",
+ "modification.time": "Modified at",
+ "notifications": {
+ "add": {
+ "error": "Adding a new todo entry failed.",
+ "success": "A new todo entry was added."
+ },
+ "delete": {
+ "error": "Deleting the todo entry failed.",
+ "success": "Deleted the todo entry."
+ },
+ "edit": {
+ "error": "Updating the information of a todo entry failed.",
+ "success": "Updated the information of the todo entry."
+ }
+ },
+ "title": "Title",
+ "title.placeholder": "Enter title"
+ }
+}
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/account/forbidden-view.html b/custom-method-all-repos/frontend/app/assets/partials/account/forbidden-view.html
new file mode 100644
index 0000000..c761f3e
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/account/forbidden-view.html
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/account/login-form-directive.html b/custom-method-all-repos/frontend/app/assets/partials/account/login-form-directive.html
new file mode 100644
index 0000000..d2f14aa
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/account/login-form-directive.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/account/login-view.html b/custom-method-all-repos/frontend/app/assets/partials/account/login-view.html
new file mode 100644
index 0000000..199d339
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/account/login-view.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/account/logout-link-directive.html b/custom-method-all-repos/frontend/app/assets/partials/account/logout-link-directive.html
new file mode 100644
index 0000000..4d9550a
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/account/logout-link-directive.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/common/not-found-view.html b/custom-method-all-repos/frontend/app/assets/partials/common/not-found-view.html
new file mode 100644
index 0000000..7edf553
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/common/not-found-view.html
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/search/search-form-directive.html b/custom-method-all-repos/frontend/app/assets/partials/search/search-form-directive.html
new file mode 100644
index 0000000..674143e
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/search/search-form-directive.html
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/search/search-result-view.html b/custom-method-all-repos/frontend/app/assets/partials/search/search-result-view.html
new file mode 100644
index 0000000..e199e19
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/search/search-result-view.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/todo/add-todo-view.html b/custom-method-all-repos/frontend/app/assets/partials/todo/add-todo-view.html
new file mode 100644
index 0000000..0a0406a
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/todo/add-todo-view.html
@@ -0,0 +1,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/todo/delete-todo-modal.html b/custom-method-all-repos/frontend/app/assets/partials/todo/delete-todo-modal.html
new file mode 100644
index 0000000..b390319
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/todo/delete-todo-modal.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/todo/edit-todo-view.html b/custom-method-all-repos/frontend/app/assets/partials/todo/edit-todo-view.html
new file mode 100644
index 0000000..1695ae6
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/todo/edit-todo-view.html
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/todo/todo-form-directive.html b/custom-method-all-repos/frontend/app/assets/partials/todo/todo-form-directive.html
new file mode 100644
index 0000000..c7815d0
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/todo/todo-form-directive.html
@@ -0,0 +1,52 @@
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/todo/todo-list-directive.html b/custom-method-all-repos/frontend/app/assets/partials/todo/todo-list-directive.html
new file mode 100644
index 0000000..60ed955
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/todo/todo-list-directive.html
@@ -0,0 +1,8 @@
+
diff --git a/custom-method-all-repos/frontend/app/assets/partials/todo/todo-list-view.html b/custom-method-all-repos/frontend/app/assets/partials/todo/todo-list-view.html
new file mode 100644
index 0000000..6a83ba4
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/todo/todo-list-view.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/frontend/app/assets/partials/todo/view-todo-view.html b/custom-method-all-repos/frontend/app/assets/partials/todo/view-todo-view.html
new file mode 100644
index 0000000..374c16d
--- /dev/null
+++ b/custom-method-all-repos/frontend/app/assets/partials/todo/view-todo-view.html
@@ -0,0 +1,25 @@
+
+ * Our application always returns the correct time because it uses the
+ * {@link CurrentTimeDateTimeService} class.
+ *
+ *
+ * When our integration tests are running, we can return a constant time which gives us the possibility
+ * to assert the creation and modification times saved to the database.
+ *
+ *
+ *
+ * @author Petri Kainulainen
+ */
+public class AuditingDateTimeProvider implements DateTimeProvider {
+
+ private final DateTimeService dateTimeService;
+
+ public AuditingDateTimeProvider(DateTimeService dateTimeService) {
+ this.dateTimeService = dateTimeService;
+ }
+
+ @Override
+ public Calendar getNow() {
+ return GregorianCalendar.from(dateTimeService.getCurrentDateAndTime());
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/BaseRepository.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/BaseRepository.java
new file mode 100644
index 0000000..31f8316
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/BaseRepository.java
@@ -0,0 +1,26 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.springframework.data.repository.NoRepositoryBean;
+import org.springframework.data.repository.Repository;
+
+import java.io.Serializable;
+import java.util.Optional;
+
+/**
+ * This interface is the base interface that must be extended by all Spring Data JPA
+ * repositories of our example application. It also declares the custom methods that
+ * are added into every Spring Data JPA repository.
+ *
+ * @author Petri Kainulainen
+ */
+@NoRepositoryBean
+public interface BaseRepository extends Repository {
+
+ /**
+ * Deletes a managed entity.
+ * @param id The id of the deleted entity.
+ * @return an {@code Optional} that contains the deleted entity. If there
+ * is no entity that has the given id, this method returns an empty {@code Optional}.
+ */
+ Optional deleteById(ID id);
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/BaseRepositoryFactoryBean.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/BaseRepositoryFactoryBean.java
new file mode 100644
index 0000000..2b296f0
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/BaseRepositoryFactoryBean.java
@@ -0,0 +1,47 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.support.JpaRepositoryFactory;
+import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean;
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryFactorySupport;
+
+import javax.persistence.EntityManager;
+import java.io.Serializable;
+
+/**
+ * This factory bean replaces the default implementation of our repository interfaces
+ * with the {@link BaseRepositoryImpl}. This ensures that the {@link BaseRepository#deleteById(Serializable)}
+ * method is added into all Spring Data JPA repositories of our example application.
+ *
+ * @author Petri Kainulainen
+ */
+public class BaseRepositoryFactoryBean, T,
+ I extends Serializable> extends JpaRepositoryFactoryBean {
+
+ @Override
+ protected RepositoryFactorySupport createRepositoryFactory(EntityManager em) {
+ return new BaseRepositoryFactory(em);
+ }
+
+ private static class BaseRepositoryFactory
+ extends JpaRepositoryFactory {
+
+ private final EntityManager em;
+
+ public BaseRepositoryFactory(EntityManager em) {
+ super(em);
+ this.em = em;
+ }
+
+ @Override
+ protected Object getTargetRepository(RepositoryMetadata metadata) {
+ return new BaseRepositoryImpl((Class) metadata.getDomainType(), em);
+ }
+
+ @Override
+ protected Class> getRepositoryBaseClass(RepositoryMetadata metadata) {
+ return BaseRepositoryImpl.class;
+ }
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/BaseRepositoryImpl.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/BaseRepositoryImpl.java
new file mode 100644
index 0000000..76131e8
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/BaseRepositoryImpl.java
@@ -0,0 +1,49 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.jpa.repository.support.SimpleJpaRepository;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.persistence.EntityManager;
+import java.io.Serializable;
+import java.util.Optional;
+
+/**
+ * This class is our "custom" implementation of the {@link org.springframework.data.repository.CrudRepository}
+ * interface that replaces the default implementation provided by Spring Data JPA.
+ *
+ * @author Petri Kainulainen
+ */
+public class BaseRepositoryImpl
+ extends SimpleJpaRepository implements BaseRepository {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(BaseRepositoryImpl.class);
+
+ private final EntityManager entityManager;
+
+ public BaseRepositoryImpl(Class domainClass, EntityManager entityManager) {
+ super(domainClass, entityManager);
+ this.entityManager = entityManager;
+ }
+
+ @Transactional
+ @Override
+ public Optional deleteById(ID id) {
+ LOGGER.info("Deleting an entity by using id: {}", id);
+
+ T deleted = entityManager.find(this.getDomainClass(), id);
+ LOGGER.debug("Deleted entity is: {}", deleted);
+
+ Optional returned = Optional.empty();
+
+ if (deleted != null) {
+ entityManager.remove(deleted);
+ returned = Optional.of(deleted);
+ }
+
+ LOGGER.info("Returning deleted entity: {}", returned);
+
+ return returned;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/ConstantDateTimeService.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/ConstantDateTimeService.java
new file mode 100644
index 0000000..012085c
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/ConstantDateTimeService.java
@@ -0,0 +1,47 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * This class is used in our integration tests and it always returns the
+ * same time. This gives us the possibility to verify that the correct
+ * timestamps are saved to the database.
+ *
+ * @author Petri Kainulainen
+ */
+public class ConstantDateTimeService implements DateTimeService {
+
+ public static final String CURRENT_DATE_AND_TIME = getConstantDateAndTime();
+
+ private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_ZONED_DATE_TIME;
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ConstantDateTimeService.class);
+
+ private static String getConstantDateAndTime() {
+ return "2015-07-19T12:52:28" +
+ getSystemZoneOffset() +
+ getSystemZoneId();
+ }
+
+ private static String getSystemZoneOffset() {
+ return "+03:00";
+ }
+
+ private static String getSystemZoneId() {
+ return "[" + ZoneId.systemDefault().toString() + "]";
+ }
+
+ @Override
+ public ZonedDateTime getCurrentDateAndTime() {
+ ZonedDateTime constantDateAndTime = ZonedDateTime.from(FORMATTER.parse(CURRENT_DATE_AND_TIME));
+
+ LOGGER.info("Returning constant date and time: {}", constantDateAndTime);
+
+ return constantDateAndTime;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/CurrentTimeDateTimeService.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/CurrentTimeDateTimeService.java
new file mode 100644
index 0000000..2812fb0
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/CurrentTimeDateTimeService.java
@@ -0,0 +1,25 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.ZonedDateTime;
+
+/**
+ * This class returns the current time.
+ *
+ * @author Petri Kainulainen
+ */
+public class CurrentTimeDateTimeService implements DateTimeService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(CurrentTimeDateTimeService.class);
+
+ @Override
+ public ZonedDateTime getCurrentDateAndTime() {
+ ZonedDateTime currentDateAndTime = ZonedDateTime.now();
+
+ LOGGER.info("Returning current date and time: {}", currentDateAndTime);
+
+ return currentDateAndTime;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/DateTimeService.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/DateTimeService.java
new file mode 100644
index 0000000..a1e1a11
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/DateTimeService.java
@@ -0,0 +1,18 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import java.time.ZonedDateTime;
+
+/**
+ * This interface defines the methods used to get the current
+ * date and time.
+ *
+ * @author Petri Kainulainen
+ */
+public interface DateTimeService {
+
+ /**
+ * Returns the current date and time.
+ * @return
+ */
+ ZonedDateTime getCurrentDateAndTime();
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/FrontendLoaderController.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/FrontendLoaderController.java
new file mode 100644
index 0000000..46f2849
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/FrontendLoaderController.java
@@ -0,0 +1,29 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+
+/**
+ * This controller is responsible of starting the frontend application.
+ * @author Petri Kainulainen
+ */
+@Controller
+public class FrontendLoaderController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(FrontendLoaderController.class);
+
+ private static final String FRONTEND_APPLICATION_VIEW = "frontend/client";
+
+ /**
+ * Starts the AngularJS application.
+ * @return
+ */
+ @RequestMapping(value = "/", method = RequestMethod.GET)
+ public String startAngularJSApplication() {
+ LOGGER.debug("Starting frontend single page application.");
+ return FRONTEND_APPLICATION_VIEW;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/PreCondition.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/PreCondition.java
new file mode 100644
index 0000000..d3cf557
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/PreCondition.java
@@ -0,0 +1,63 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+/**
+ * This class provides static utility methods that are used to ensure that a constructor or a method was invoked properly.
+ * These methods throw an exception if the specified precondition is violated.
+ *
+ * This class selects the thrown exception by using the guideline given in Effective Java by Joshua Bloch (Item 60).
+ *
+ * @author Petri Kainulainen
+ */
+public final class PreCondition {
+
+ private PreCondition() {}
+
+ /**
+ * Ensures that the expression given as a method parameter is true.
+ * @param expression The inspected expression.
+ * @param errorMessageTemplate The template that is used to construct the message of the exception thrown if the
+ * inspected exception is false. The template must use the syntax that is supported
+ * by the {@link java.lang.String#format(String, Object...)} method.
+ * @param errorMessageArguments The arguments that are used when the message of the thrown exception is constructed.
+ * @throws java.lang.IllegalArgumentException if the inspected exception is false.
+ */
+ public static void isTrue(boolean expression, String errorMessageTemplate, Object... errorMessageArguments) {
+ isTrue(expression, String.format(errorMessageTemplate, errorMessageArguments));
+ }
+ /**
+ * Ensures that the expression given as a method parameter is true.
+ * @param expression The inspected expression.
+ * @param errorMessage The error message that is passed forward to the exception that is thrown
+ * if the expression is false.
+ * @throws java.lang.IllegalArgumentException if the inspected expression is false.
+ */
+ public static void isTrue(boolean expression, String errorMessage) {
+ if (!expression) {
+ throw new IllegalArgumentException(errorMessage);
+ }
+ }
+ /**
+ * Ensures that the string given as a method parameter is not empty.
+ * @param string The inspected string.
+ * @param errorMessage The error message that is passed forward to the exception that is thrown if
+ * the string is empty.
+ * @throws java.lang.IllegalArgumentException if the inspected string is empty.
+ */
+ public static void notEmpty(String string, String errorMessage) {
+ if (string.isEmpty()) {
+ throw new IllegalArgumentException(errorMessage);
+ }
+ }
+ /**
+ * Ensures that the object given as a method parameter is not null.
+ * @param reference A reference to the inspected object.
+ * @param errorMessage The error message that is passed forward to the exception that is thrown if
+ * the object given as a method parameter is null.
+ * @throws java.lang.NullPointerException If the object given as a method parameter is null.
+ */
+ public static void notNull(Object reference, String errorMessage) {
+ if (reference == null) {
+ throw new NullPointerException(errorMessage);
+ }
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/UsernameAuditorAware.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/UsernameAuditorAware.java
new file mode 100644
index 0000000..ed511d8
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/common/UsernameAuditorAware.java
@@ -0,0 +1,34 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.domain.AuditorAware;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.User;
+
+/**
+ * This component returns the username of the authenticated user.
+ *
+ * @author Petri Kainulainen
+ */
+public class UsernameAuditorAware implements AuditorAware {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(UsernameAuditorAware.class);
+
+ @Override
+ public String getCurrentAuditor() {
+ LOGGER.debug("Getting the username of authenticated user.");
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+
+ if (authentication == null || !authentication.isAuthenticated()) {
+ LOGGER.debug("Current user is anonymous. Returning null.");
+ return null;
+ }
+
+ String username = ((User) authentication.getPrincipal()).getUsername();
+ LOGGER.debug("Returning username: {}", username);
+
+ return username;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/ExampleApplicationContext.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/ExampleApplicationContext.java
new file mode 100644
index 0000000..0f922d8
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/ExampleApplicationContext.java
@@ -0,0 +1,66 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import net.petrikainulainen.springdata.jpa.common.ConstantDateTimeService;
+import net.petrikainulainen.springdata.jpa.common.CurrentTimeDateTimeService;
+import net.petrikainulainen.springdata.jpa.common.DateTimeService;
+import org.springframework.context.MessageSource;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Profile;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
+import org.springframework.context.support.ResourceBundleMessageSource;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Configuration
+@ComponentScan("net.petrikainulainen.springdata.jpa")
+@Import({WebMvcContext.class, PersistenceContext.class, SecurityContext.class})
+public class ExampleApplicationContext {
+
+ private static final String MESSAGE_SOURCE_BASE_NAME = "i18n/messages";
+
+ /**
+ * These static classes are required because it makes it possible to use
+ * different properties files for every Spring profile.
+ *
+ * See: This StackOverflow answer for more details.
+ */
+ @Profile(Profiles.APPLICATION)
+ @Configuration
+ @PropertySource("classpath:application.properties")
+ static class ApplicationProperties {}
+
+ @Profile(Profiles.APPLICATION)
+ @Bean
+ DateTimeService currentTimeDateTimeService() {
+ return new CurrentTimeDateTimeService();
+ }
+
+ @Profile(Profiles.INTEGRATION_TEST)
+ @Configuration
+ @PropertySource("classpath:integration-test.properties")
+ static class IntegrationTestProperties {}
+
+ @Profile(Profiles.INTEGRATION_TEST)
+ @Bean
+ DateTimeService constantDateTimeService() {
+ return new ConstantDateTimeService();
+ }
+
+ @Bean
+ MessageSource messageSource() {
+ ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
+ messageSource.setBasename(MESSAGE_SOURCE_BASE_NAME);
+ messageSource.setUseCodeAsDefaultMessage(true);
+ return messageSource;
+ }
+
+ @Bean
+ PropertySourcesPlaceholderConfigurer propertyPlaceHolderConfigurer() {
+ return new PropertySourcesPlaceholderConfigurer();
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/PersistenceContext.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/PersistenceContext.java
new file mode 100644
index 0000000..7648dc8
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/PersistenceContext.java
@@ -0,0 +1,146 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import net.petrikainulainen.springdata.jpa.common.AuditingDateTimeProvider;
+import net.petrikainulainen.springdata.jpa.common.DateTimeService;
+import net.petrikainulainen.springdata.jpa.common.UsernameAuditorAware;
+import net.petrikainulainen.springdata.jpa.common.BaseRepositoryFactoryBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.Environment;
+import org.springframework.data.auditing.DateTimeProvider;
+import org.springframework.data.domain.AuditorAware;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.orm.jpa.JpaTransactionManager;
+import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
+import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import javax.persistence.EntityManagerFactory;
+import javax.sql.DataSource;
+import java.util.Properties;
+
+/**
+ * This configuration class configures the persistence layer of our example application and
+ * enables annotation driven transaction management.
+ *
+ * This configuration is put to a single class because this way we can write integration
+ * tests for our persistence layer by using the configuration used by our example
+ * application. In other words, we can ensure that the persistence layer of our application
+ * works as expected.
+ *
+ * @author Petri Kainulainen
+ */
+@Configuration
+@EnableJpaAuditing(dateTimeProviderRef = "dateTimeProvider")
+@EnableJpaRepositories(basePackages = {"net.petrikainulainen.springdata.jpa.todo"},
+ repositoryFactoryBeanClass = BaseRepositoryFactoryBean.class
+)
+@EnableTransactionManagement
+class PersistenceContext {
+ private static final String[] ENTITY_PACKAGES = {
+ "net.petrikainulainen.springdata.jpa.todo"
+ };
+
+ private static final String PROPERTY_NAME_DB_DRIVER_CLASS = "db.driver";
+ private static final String PROPERTY_NAME_DB_PASSWORD = "db.password";
+ private static final String PROPERTY_NAME_DB_URL = "db.url";
+ private static final String PROPERTY_NAME_DB_USER = "db.username";
+ private static final String PROPERTY_NAME_HIBERNATE_DIALECT = "hibernate.dialect";
+ private static final String PROPERTY_NAME_HIBERNATE_FORMAT_SQL = "hibernate.format_sql";
+ private static final String PROPERTY_NAME_HIBERNATE_HBM2DDL_AUTO = "hibernate.hbm2ddl.auto";
+ private static final String PROPERTY_NAME_HIBERNATE_NAMING_STRATEGY = "hibernate.ejb.naming_strategy";
+ private static final String PROPERTY_NAME_HIBERNATE_SHOW_SQL = "hibernate.show_sql";
+
+ @Bean
+ AuditorAware auditorProvider() {
+ return new UsernameAuditorAware();
+ }
+
+ @Bean
+ DateTimeProvider dateTimeProvider(DateTimeService dateTimeService) {
+ return new AuditingDateTimeProvider(dateTimeService);
+ }
+
+ /**
+ * Creates and configures the HikariCP datasource bean.
+ * @param env The runtime environment of our application.
+ * @return
+ */
+ @Bean(destroyMethod = "close")
+ DataSource dataSource(Environment env) {
+ HikariConfig dataSourceConfig = new HikariConfig();
+ dataSourceConfig.setDriverClassName(env.getRequiredProperty(PROPERTY_NAME_DB_DRIVER_CLASS));
+ dataSourceConfig.setJdbcUrl(env.getRequiredProperty(PROPERTY_NAME_DB_URL));
+ dataSourceConfig.setUsername(env.getRequiredProperty(PROPERTY_NAME_DB_USER));
+ dataSourceConfig.setPassword(env.getRequiredProperty(PROPERTY_NAME_DB_PASSWORD));
+
+ return new HikariDataSource(dataSourceConfig);
+ }
+
+ /**
+ * Creates the bean that creates the JPA entity manager factory.
+ * @param dataSource The datasource that provides the database connections.
+ * @param env The runtime environment of our application.
+ * @return
+ */
+ @Bean
+ LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource, Environment env) {
+ LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
+ entityManagerFactoryBean.setDataSource(dataSource);
+ entityManagerFactoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
+ entityManagerFactoryBean.setPackagesToScan(ENTITY_PACKAGES);
+
+ Properties jpaProperties = new Properties();
+
+ //Configures the used database dialect. This allows Hibernate to create SQL
+ //that is optimized for the used database.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_DIALECT, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_DIALECT));
+
+ //Specifies the action that is invoked to the database when the Hibernate
+ //SessionFactory is created or closed.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_HBM2DDL_AUTO, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_HBM2DDL_AUTO));
+
+ //Configures the naming strategy that is used when Hibernate creates
+ //new database objects and schema elements
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_NAMING_STRATEGY, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_NAMING_STRATEGY));
+
+ //If the value of this property is true, Hibernate writes all SQL
+ //statements to the console.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_SHOW_SQL, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_SHOW_SQL));
+
+ //If the value of this property is true, Hibernate will use prettyprint
+ //when it writes SQL to the console.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_FORMAT_SQL, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_FORMAT_SQL));
+
+ entityManagerFactoryBean.setJpaProperties(jpaProperties);
+
+ return entityManagerFactoryBean;
+ }
+
+ /**
+ * Creates the jdbc template bean that we use to invoke SQL queries via JDBC.
+ * @param dataSource The datasource that provides the database connection.
+ * @return
+ */
+ @Bean
+ NamedParameterJdbcTemplate jdbcTemplate(DataSource dataSource) {
+ return new NamedParameterJdbcTemplate(dataSource);
+ }
+
+ /**
+ * Creates the transaction manager bean that integrates the used JPA provider with the
+ * Spring transaction mechanism.
+ * @param entityManagerFactory The used JPA entity manager factory.
+ * @return
+ */
+ @Bean
+ JpaTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
+ JpaTransactionManager transactionManager = new JpaTransactionManager();
+ transactionManager.setEntityManagerFactory(entityManagerFactory);
+ return transactionManager;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/Profiles.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/Profiles.java
new file mode 100644
index 0000000..bda9711
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/Profiles.java
@@ -0,0 +1,21 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+/**
+ * This class defines the Spring profiles used in the project. The idea behind this class
+ * is that it helps us to avoid typos when we are using these profiles. At the moment
+ * there are two profiles which are described in the following:
+ *
+ *
The APPLICATION profile is used when we run our example application.
+ *
The INTEGRATION_TEST profile is used when we run the integration tests of our example application.
+ *
+ *
+ * @author Petri Kainulainen
+ */
+public class Profiles {
+ public static final String APPLICATION = "application";
+ public static final String INTEGRATION_TEST = "integrationtest";
+ /**
+ * Prevent instantiation.
+ */
+ private Profiles() {}
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/SecurityContext.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/SecurityContext.java
new file mode 100644
index 0000000..8aa95e4
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/SecurityContext.java
@@ -0,0 +1,99 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import net.petrikainulainen.springdata.jpa.web.security.CsrfHeaderFilter;
+import net.petrikainulainen.springdata.jpa.web.security.RestAuthenticationEntryPoint;
+import net.petrikainulainen.springdata.jpa.web.security.RestAuthenticationFailureHandler;
+import net.petrikainulainen.springdata.jpa.web.security.RestAuthenticationSuccessHandler;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.builders.WebSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.csrf.CsrfFilter;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Configuration
+@EnableWebSecurity
+class SecurityContext extends WebSecurityConfigurerAdapter {
+
+ @Bean
+ AuthenticationEntryPoint authenticationEntryPoint() {
+ return new RestAuthenticationEntryPoint();
+ }
+
+ @Bean
+ AuthenticationFailureHandler authenticationFailureHandler() {
+ return new RestAuthenticationFailureHandler();
+ }
+
+ @Bean
+ AuthenticationSuccessHandler authenticationSuccessHandler() {
+ return new RestAuthenticationSuccessHandler();
+ }
+
+ @Bean
+ protected UserDetailsService userDetailsService() {
+ return super.userDetailsService();
+ }
+
+ @Override
+ protected void configure(AuthenticationManagerBuilder auth) throws Exception {
+ auth
+ .inMemoryAuthentication()
+ .withUser("user")
+ .password("password")
+ .roles("USER");
+ }
+
+ @Override
+ protected void configure(HttpSecurity http) throws Exception {
+ http
+ //Use the custom authentication entry point.
+ .exceptionHandling()
+ .authenticationEntryPoint(authenticationEntryPoint())
+ .and()
+ //Configure form login.
+ .formLogin()
+ .loginProcessingUrl("/api/login")
+ .failureHandler(authenticationFailureHandler())
+ .successHandler(authenticationSuccessHandler())
+ .permitAll()
+ .and()
+ //Configure logout function.
+ .logout()
+ .deleteCookies("JSESSIONID")
+ .logoutUrl("/api/logout")
+ .logoutSuccessUrl("/")
+ .and()
+ //Configure url based authorization
+ .authorizeRequests()
+ .antMatchers(
+ "/",
+ "/api/csrf"
+ ).permitAll()
+ .anyRequest().hasRole("USER")
+ .and()
+ .addFilterAfter(new CsrfHeaderFilter(), CsrfFilter.class);
+ }
+
+ @Override
+ public void configure(WebSecurity web) throws Exception {
+ web
+ //Spring Security ignores request to static resources such as CSS or JS files.
+ .ignoring()
+ .antMatchers(
+ "/favicon.ico",
+ "/css/**",
+ "/i18n/**",
+ "/js/**"
+ );
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/WebAppConfig.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/WebAppConfig.java
new file mode 100644
index 0000000..f1861a6
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/WebAppConfig.java
@@ -0,0 +1,66 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import org.springframework.web.WebApplicationInitializer;
+import org.springframework.web.context.ContextLoaderListener;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
+import org.springframework.web.filter.CharacterEncodingFilter;
+import org.springframework.web.filter.DelegatingFilterProxy;
+import org.springframework.web.servlet.DispatcherServlet;
+
+import javax.servlet.DispatcherType;
+import javax.servlet.FilterRegistration;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRegistration;
+import java.util.EnumSet;
+
+/**
+ * @author Petri Kainulainen
+ */
+public class WebAppConfig implements WebApplicationInitializer {
+ private static final String CHARACTER_ENCODING_FILTER_ENCODING = "UTF-8";
+ private static final String CHARACTER_ENCODING_FILTER_NAME = "characterEncoding";
+ private static final String CHARACTER_ENCODING_FILTER_URL_PATTERN = "/*";
+
+ private static final String DISPATCHER_SERVLET_NAME = "dispatcher";
+ private static final String DISPATCHER_SERVLET_MAPPING = "/";
+
+ @Override
+ public void onStartup(ServletContext servletContext) throws ServletException {
+ AnnotationConfigWebApplicationContext rootContext = new AnnotationConfigWebApplicationContext();
+ rootContext.register(ExampleApplicationContext.class);
+
+ //XmlWebApplicationContext rootContext = new XmlWebApplicationContext();
+ //rootContext.setConfigLocation("classpath:applicationContext.xml");
+
+ configureDispatcherServlet(servletContext, rootContext);
+ EnumSet dispatcherTypes = EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD);
+
+ configureCharacterEncodingFilter(servletContext, dispatcherTypes);
+ configureSpringSecurityFilter(servletContext, dispatcherTypes);
+ servletContext.addListener(new ContextLoaderListener(rootContext));
+ }
+
+ private void configureDispatcherServlet(ServletContext servletContext, WebApplicationContext rootContext) {
+ ServletRegistration.Dynamic dispatcher = servletContext.addServlet(
+ DISPATCHER_SERVLET_NAME,
+ new DispatcherServlet(rootContext)
+ );
+ dispatcher.setLoadOnStartup(1);
+ dispatcher.addMapping(DISPATCHER_SERVLET_MAPPING);
+ }
+
+ private void configureCharacterEncodingFilter(ServletContext servletContext, EnumSet dispatcherTypes) {
+ CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
+ characterEncodingFilter.setEncoding(CHARACTER_ENCODING_FILTER_ENCODING);
+ characterEncodingFilter.setForceEncoding(true);
+ FilterRegistration.Dynamic characterEncoding = servletContext.addFilter(CHARACTER_ENCODING_FILTER_NAME, characterEncodingFilter);
+ characterEncoding.addMappingForUrlPatterns(dispatcherTypes, true, CHARACTER_ENCODING_FILTER_URL_PATTERN);
+ }
+
+ private void configureSpringSecurityFilter(ServletContext servletContext, EnumSet dispatcherTypes) {
+ FilterRegistration.Dynamic security = servletContext.addFilter("springSecurityFilterChain", new DelegatingFilterProxy());
+ security.addMappingForUrlPatterns(dispatcherTypes, true, "/*");
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/WebMvcContext.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/WebMvcContext.java
new file mode 100644
index 0000000..c016860
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/config/WebMvcContext.java
@@ -0,0 +1,48 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JSR310Module;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
+
+import java.util.List;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Configuration
+@EnableWebMvc
+class WebMvcContext extends WebMvcConfigurerAdapter {
+
+ @Override
+ public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
+ configurer.enable();
+ }
+
+
+ @Override
+ public void configureMessageConverters(List> converters) {
+ ObjectMapper objectMapper = new ObjectMapper();
+
+ objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ objectMapper.registerModule(new JSR310Module());
+
+ MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
+ converter.setObjectMapper(objectMapper);
+
+ converters.add(converter);
+ }
+
+ @Override
+ public void configureViewResolvers(ViewResolverRegistry registry) {
+ registry.jsp("/WEB-INF/jsp/", ".jsp");
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoSearchService.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoSearchService.java
new file mode 100644
index 0000000..8e74147
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoSearchService.java
@@ -0,0 +1,36 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Service
+final class RepositoryTodoSearchService implements TodoSearchService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RepositoryTodoSearchService.class);
+
+ private final TodoRepository repository;
+
+ @Autowired
+ public RepositoryTodoSearchService(TodoRepository repository) {
+ this.repository = repository;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List findBySearchTerm(String searchTerm) {
+ LOGGER.info("Finding todo entries by search term: {}", searchTerm);
+
+ List searchResults = repository.findBySearchTerm(searchTerm);
+ LOGGER.info("Found {} todo entries", searchResults.size());
+
+ return TodoMapper.mapEntitiesIntoDTOs(searchResults);
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoService.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoService.java
new file mode 100644
index 0000000..777b427
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoService.java
@@ -0,0 +1,99 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Service
+final class RepositoryTodoService implements TodoCrudService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RepositoryTodoService.class);
+
+ private final TodoRepository repository;
+
+ @Autowired
+ RepositoryTodoService(TodoRepository repository) {
+ this.repository = repository;
+ }
+
+ @Transactional
+ @Override
+ public TodoDTO create(TodoDTO newTodoEntry) {
+ LOGGER.info("Creating a new todo entry by using information: {}", newTodoEntry);
+
+ Todo created = Todo.getBuilder()
+ .description(newTodoEntry.getDescription())
+ .title(newTodoEntry.getTitle())
+ .build();
+
+ created = repository.save(created);
+ LOGGER.info("Created a new todo entry: {}", created);
+
+ return TodoMapper.mapEntityIntoDTO(created);
+ }
+
+ @Transactional
+ @Override
+ public TodoDTO delete(Long id) {
+ LOGGER.info("Deleting a todo entry with id: {}", id);
+
+ Optional deleted = repository.deleteById(id);
+ LOGGER.info("Deleted todo entry: {}", deleted);
+
+ Todo returned = deleted.orElseThrow(() -> new TodoNotFoundException(id));
+ return TodoMapper.mapEntityIntoDTO(returned);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List findAll() {
+ LOGGER.info("Finding all todo entries.");
+
+ List todoEntries = repository.findAll();
+
+ LOGGER.info("Found {} todo entries", todoEntries.size());
+
+ return TodoMapper.mapEntitiesIntoDTOs(todoEntries);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public TodoDTO findById(Long id) {
+ LOGGER.info("Finding todo entry by using id: {}", id);
+
+ Todo todoEntry = findTodoEntryById(id);
+ LOGGER.info("Found todo entry: {}", todoEntry);
+
+ return TodoMapper.mapEntityIntoDTO(todoEntry);
+ }
+
+ @Transactional
+ @Override
+ public TodoDTO update(TodoDTO updatedTodoEntry) {
+ LOGGER.info("Updating the information of a todo entry by using information: {}", updatedTodoEntry);
+
+ Todo updated = findTodoEntryById(updatedTodoEntry.getId());
+ updated.update(updatedTodoEntry.getTitle(), updatedTodoEntry.getDescription());
+
+ //We need to flush the changes or otherwise the returned object
+ //doesn't contain the updated audit information.
+ repository.flush();
+
+ LOGGER.info("Updated the information of the todo entry: {}", updated);
+
+ return TodoMapper.mapEntityIntoDTO(updated);
+ }
+
+ private Todo findTodoEntryById(Long id) {
+ Optional todoResult = repository.findOne(id);
+ return todoResult.orElseThrow(() -> new TodoNotFoundException(id));
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/Todo.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/Todo.java
new file mode 100644
index 0000000..b3acfbb
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/Todo.java
@@ -0,0 +1,196 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.hibernate.annotations.Type;
+import org.springframework.data.annotation.CreatedBy;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedBy;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EntityListeners;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.NamedNativeQuery;
+import javax.persistence.NamedQuery;
+import javax.persistence.Table;
+import javax.persistence.Version;
+import java.time.ZonedDateTime;
+
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.isTrue;
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notEmpty;
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notNull;
+
+/**
+ * This entity class contains the information of a single todo entry
+ * and the methods that are used to create new todo entries and to modify
+ * the information of an existing todo entry.
+ *
+ * @author Petri Kainulainen
+ */
+@Entity
+@EntityListeners(AuditingEntityListener.class)
+@NamedNativeQuery(name = "Todo.findBySearchTermNamedNative",
+ query="SELECT * FROM todos t WHERE " +
+ "LOWER(t.title) LIKE LOWER(CONCAT('%',:searchTerm, '%')) OR " +
+ "LOWER(t.description) LIKE LOWER(CONCAT('%',:searchTerm, '%')) " +
+ "ORDER BY t.title ASC",
+ resultClass = Todo.class
+)
+@NamedQuery(name = "Todo.findBySearchTermNamed",
+ query = "SELECT t FROM Todo t WHERE " +
+ "LOWER(t.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR " +
+ "LOWER(t.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) " +
+ "ORDER BY t.title ASC"
+)
+@Table(name = "todos")
+final class Todo {
+
+ static final int MAX_LENGTH_DESCRIPTION = 500;
+ static final int MAX_LENGTH_TITLE = 100;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.AUTO)
+ private Long id;
+
+ @Column(name = "created_by_user", nullable = false)
+ @CreatedBy
+ private String createdByUser;
+
+ @Column(name = "creation_time", nullable = false)
+ @Type(type = "org.jadira.usertype.dateandtime.threeten.PersistentZonedDateTime")
+ @CreatedDate
+ private ZonedDateTime creationTime;
+
+ @Column(name = "description", length = MAX_LENGTH_DESCRIPTION)
+ private String description;
+
+ @Column(name = "modified_by_user", nullable = false)
+ @LastModifiedBy
+ private String modifiedByUser;
+
+ @Column(name = "modification_time")
+ @Type(type = "org.jadira.usertype.dateandtime.threeten.PersistentZonedDateTime")
+ @LastModifiedDate
+ private ZonedDateTime modificationTime;
+
+ @Column(name = "title", nullable = false, length = MAX_LENGTH_TITLE)
+ private String title;
+
+ @Version
+ private long version;
+
+ /**
+ * Required by Hibernate.
+ */
+ private Todo() {}
+
+ private Todo(Builder builder) {
+ this.title = builder.title;
+ this.description = builder.description;
+ }
+
+ static Builder getBuilder() {
+ return new Builder();
+ }
+
+ Long getId() {
+ return id;
+ }
+
+ String getCreatedByUser() {
+ return createdByUser;
+ }
+
+ ZonedDateTime getCreationTime() {
+ return creationTime;
+ }
+
+ String getDescription() {
+ return description;
+ }
+
+ String getModifiedByUser() {
+ return modifiedByUser;
+ }
+
+ ZonedDateTime getModificationTime() {
+ return modificationTime;
+ }
+
+ String getTitle() {
+ return title;
+ }
+
+ long getVersion() {
+ return version;
+ }
+
+ void update(String newTitle, String newDescription) {
+ requireValidTitleAndDescription(newTitle, newDescription);
+
+ this.title = newTitle;
+ this.description = newDescription;
+ }
+
+ private void requireValidTitleAndDescription(String title, String description) {
+ notNull(title, "Title cannot be null.");
+ notEmpty(title, "Title cannot be empty.");
+ isTrue(title.length() <= MAX_LENGTH_TITLE,
+ "The maximum length of the title is <%d> characters.",
+ MAX_LENGTH_TITLE
+ );
+
+ isTrue((description == null) || (description.length() <= MAX_LENGTH_DESCRIPTION),
+ "The maximum length of the description is <%d> characters.",
+ MAX_LENGTH_DESCRIPTION
+ );
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this)
+ .append("createdByUser", this.createdByUser)
+ .append("creationTime", this.creationTime)
+ .append("description", this.description)
+ .append("id", this.id)
+ .append("modifiedByUser", this.modifiedByUser)
+ .append("modificationTime", this.modificationTime)
+ .append("title", this.title)
+ .append("version", this.version)
+ .toString();
+ }
+
+ /**
+ * This entity is so simple that you don't really need to use the builder pattern
+ * (use a constructor instead). I use the builder pattern here because it makes
+ * the code a bit more easier to read.
+ */
+ static class Builder {
+ private String description;
+ private String title;
+
+ private Builder() {}
+
+ Builder description(String description) {
+ this.description = description;
+ return this;
+ }
+
+ Builder title(String title) {
+ this.title = title;
+ return this;
+ }
+
+ Todo build() {
+ Todo build = new Todo(this);
+
+ build.requireValidTitleAndDescription(build.getTitle(), build.getDescription());
+
+ return build;
+ }
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoCrudService.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoCrudService.java
new file mode 100644
index 0000000..9e6fc09
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoCrudService.java
@@ -0,0 +1,49 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import java.util.List;
+
+/**
+ * This service provides CRUD operations for {@link net.petrikainulainen.springdata.jpa.todo.Todo}
+ * objects.
+ *
+ * @author Petri Kainulainen
+ */
+public interface TodoCrudService {
+
+ /**
+ * Creates a new todo entry.
+ * @param newTodoEntry The information of the created todo entry.
+ * @return The information of the created todo entry.
+ */
+ TodoDTO create(TodoDTO newTodoEntry);
+
+ /**
+ * Deletes a todo entry from the database.
+ * @param id The id of the deleted todo entry.
+ * @return The information of the deleted todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if the deleted todo entry is not found.
+ */
+ TodoDTO delete(Long id);
+
+ /**
+ * Finds all todo entries that are saved to the database.
+ * @return
+ */
+ List findAll();
+
+ /**
+ * Finds a todo entry by using the id given as a method parameter.
+ * @param id The id of the wanted todo entry.
+ * @return The information of the requested todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found with the given id.
+ */
+ TodoDTO findById(Long id);
+
+ /**
+ * Updates the information of an existing information.
+ * @param updatedTodoEntry The new information of an existing todo entry.
+ * @return The information of the updated todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found with the given id.
+ */
+ TodoDTO update(TodoDTO updatedTodoEntry);
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoDTO.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoDTO.java
new file mode 100644
index 0000000..7eea8d2
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoDTO.java
@@ -0,0 +1,101 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.hibernate.validator.constraints.NotEmpty;
+
+import javax.validation.constraints.Size;
+import java.time.ZonedDateTime;
+
+/**
+ * @author Petri Kainulainen
+ */
+public final class TodoDTO {
+
+ private String createdByUser;
+
+ private ZonedDateTime creationTime;
+
+ @Size(max = Todo.MAX_LENGTH_DESCRIPTION)
+ private String description;
+
+ private Long id;
+
+ private String modifiedByUser;
+
+ private ZonedDateTime modificationTime;
+
+ @NotEmpty
+ @Size(max = Todo.MAX_LENGTH_TITLE)
+ private String title;
+
+ public TodoDTO() {}
+
+ public String getCreatedByUser() {
+ return createdByUser;
+ }
+
+ public ZonedDateTime getCreationTime() {
+ return creationTime;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getModifiedByUser() {
+ return modifiedByUser;
+ }
+
+ public ZonedDateTime getModificationTime() {
+ return modificationTime;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setCreatedByUser(String createdByUser) {
+ this.createdByUser = createdByUser;
+ }
+
+ public void setCreationTime(ZonedDateTime creationTime) {
+ this.creationTime = creationTime;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public void setModifiedByUser(String modifiedByUser) {
+ this.modifiedByUser = modifiedByUser;
+ }
+
+ public void setModificationTime(ZonedDateTime modificationTime) {
+ this.modificationTime = modificationTime;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this)
+ .append("createdByUser", this.createdByUser)
+ .append("creationTime", this.creationTime)
+ .append("description", this.description)
+ .append("id", this.id)
+ .append("modifiedByUser", this.modifiedByUser)
+ .append("modificationTime", this.modificationTime)
+ .append("title", this.title)
+ .toString();
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoMapper.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoMapper.java
new file mode 100644
index 0000000..aa36e28
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoMapper.java
@@ -0,0 +1,31 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import java.util.List;
+
+import static java.util.stream.Collectors.toList;
+
+/**
+ * @author Petri Kainulainen
+ */
+final class TodoMapper {
+
+ static List mapEntitiesIntoDTOs(List entities) {
+ return entities.stream()
+ .map(TodoMapper::mapEntityIntoDTO)
+ .collect(toList());
+ }
+
+ static TodoDTO mapEntityIntoDTO(Todo entity) {
+ TodoDTO dto = new TodoDTO();
+
+ dto.setCreatedByUser(entity.getCreatedByUser());
+ dto.setCreationTime(entity.getCreationTime());
+ dto.setDescription(entity.getDescription());
+ dto.setId(entity.getId());
+ dto.setModifiedByUser(entity.getModifiedByUser());
+ dto.setModificationTime(entity.getModificationTime());
+ dto.setTitle(entity.getTitle());
+
+ return dto;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoNotFoundException.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoNotFoundException.java
new file mode 100644
index 0000000..63f6948
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoNotFoundException.java
@@ -0,0 +1,21 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+/**
+ * This exception is thrown when a todo entry is not found by
+ * using the given id.
+ *
+ * @author Petri Kainulainen
+ */
+public class TodoNotFoundException extends RuntimeException {
+
+ private final Long id;
+
+ public TodoNotFoundException(Long id) {
+ super();
+ this.id = id;
+ }
+
+ public Long getId() {
+ return id;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepository.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepository.java
new file mode 100644
index 0000000..25b26a9
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepository.java
@@ -0,0 +1,36 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import net.petrikainulainen.springdata.jpa.common.BaseRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * This repository provides CRUD operations for {@link net.petrikainulainen.springdata.jpa.todo.Todo}
+ * objects.
+ *
+ * @author Petri Kainulainen
+ */
+interface TodoRepository extends BaseRepository {
+
+ List findAll();
+
+ /**
+ * Finds todo entries by using the search term given as a method parameter.
+ * @param searchTerm The given search term.
+ * @return A list of todo entries whose title or description contains with the given search term.
+ */
+ @Query("SELECT t FROM Todo t WHERE " +
+ "LOWER(t.title) LIKE LOWER(CONCAT('%',:searchTerm, '%')) OR " +
+ "LOWER(t.description) LIKE LOWER(CONCAT('%',:searchTerm, '%')) " +
+ "ORDER BY t.title ASC")
+ List findBySearchTerm(@Param("searchTerm") String searchTerm);
+
+ Optional findOne(Long id);
+
+ void flush();
+
+ Todo save(Todo persisted);
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchResultDTO.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchResultDTO.java
new file mode 100644
index 0000000..63dcd6c
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchResultDTO.java
@@ -0,0 +1,29 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+/**
+ * @author Petri Kainulainen
+ */
+public final class TodoSearchResultDTO {
+
+ private Long id;
+
+ private String title;
+
+ public TodoSearchResultDTO() {}
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchService.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchService.java
new file mode 100644
index 0000000..a968085
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchService.java
@@ -0,0 +1,19 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import java.util.List;
+
+/**
+ * This service provides finder methods for {@link net.petrikainulainen.springdata.jpa.todo.Todo} objects.
+ *
+ * @author Petri Kainulainen
+ */
+public interface TodoSearchService {
+
+ /**
+ * Finds todo entries whose title or description contains the given search term.
+ * This search is case insensitive.
+ * @param searchTerm The search term.
+ * @return A list of todo entries whose title or description contains the given search term.
+ */
+ List findBySearchTerm(String searchTerm);
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoController.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoController.java
new file mode 100644
index 0000000..5205657
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoController.java
@@ -0,0 +1,116 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import net.petrikainulainen.springdata.jpa.todo.TodoCrudService;
+import net.petrikainulainen.springdata.jpa.todo.TodoDTO;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import java.util.List;
+
+/**
+ * This controller provides the public API that is used to perform
+ * CRUD operations for todo entries.
+ *
+ * @author Petri Kainulainen
+ */
+@RestController
+@RequestMapping("/api/todo")
+final class TodoController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TodoController.class);
+
+ private final TodoCrudService crudService;
+
+ @Autowired
+ TodoController(TodoCrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ /**
+ * Create a new todo entry.
+ * @param newTodoEntry The information of the created todo entry.
+ * @return The information of the created todo entry.
+ */
+ @RequestMapping(method = RequestMethod.POST)
+ @ResponseStatus(HttpStatus.CREATED)
+ TodoDTO create(@RequestBody @Valid TodoDTO newTodoEntry) {
+ LOGGER.info("Creating a new todo entry by using information: {}", newTodoEntry);
+
+ TodoDTO created = crudService.create(newTodoEntry);
+ LOGGER.info("Created a new todo entry: {}", created);
+
+ return created;
+ }
+
+ /**
+ * Deletes a todo entry.
+ * @param id The id of the deleted todo entry.
+ * @return The information of the deleted todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if the deleted todo entry is not found.
+ */
+ @RequestMapping(value = "{id}", method = RequestMethod.DELETE)
+ public TodoDTO delete(@PathVariable("id") Long id) {
+ LOGGER.info("Deleting a todo entry with id: {}", id);
+
+ TodoDTO deleted = crudService.delete(id);
+ LOGGER.info("Deleted the todo entry: {}", deleted);
+
+ return deleted;
+ }
+
+ /**
+ * Finds all todo entries.
+ *
+ * @return The information of all todo entries.
+ */
+ @RequestMapping(method = RequestMethod.GET)
+ List findAll() {
+ LOGGER.info("Finding all todo entries");
+
+ List todoEntries = crudService.findAll();
+ LOGGER.info("Found {} todo entries.", todoEntries.size());
+
+ return todoEntries;
+ }
+
+ /**
+ * Finds a single todo entry.
+ * @param id The id of the requested todo entry.
+ * @return The information of the requested todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found by using the given id.
+ */
+ @RequestMapping(value = "{id}", method = RequestMethod.GET)
+ TodoDTO findById(@PathVariable("id") Long id) {
+ LOGGER.info("Finding todo entry by using id: {}", id);
+
+ TodoDTO todoEntry = crudService.findById(id);
+ LOGGER.info("Found todo entry: {}", todoEntry);
+
+ return todoEntry;
+ }
+
+ /**
+ * Updates the information of an existing todo entry.
+ * @param updatedTodoEntry The new information of the updated todo entry.
+ * @return The updated information of the updated todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found by using the given id.
+ */
+ @RequestMapping(value = "{id}", method = RequestMethod.PUT)
+ TodoDTO update(@RequestBody @Valid TodoDTO updatedTodoEntry) {
+ LOGGER.info("Updating the information of a todo entry by using information: {}", updatedTodoEntry);
+
+ TodoDTO updated = crudService.update(updatedTodoEntry);
+ LOGGER.info("Updated the information of the todo entrY: {}", updated);
+
+ return updated;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoSearchController.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoSearchController.java
new file mode 100644
index 0000000..6eede01
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoSearchController.java
@@ -0,0 +1,48 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import net.petrikainulainen.springdata.jpa.todo.TodoDTO;
+import net.petrikainulainen.springdata.jpa.todo.TodoSearchService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * This controller provides the public API that is used to find todo entries by using
+ * different search criteria.
+ *
+ * @author Petri Kainulainen
+ */
+@RestController
+final class TodoSearchController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TodoSearchController.class);
+
+ private final TodoSearchService searchService;
+
+ @Autowired
+ public TodoSearchController(TodoSearchService searchService) {
+ this.searchService = searchService;
+ }
+
+ /**
+ * Finds todo entries whose title or description contains the given search term. This
+ * search is case insensitive.
+ * @param searchTerm The used search term.
+ * @return
+ */
+ @RequestMapping(value = "/api/todo/search", method = RequestMethod.GET)
+ public List findBySearchTerm(@RequestParam("searchTerm") String searchTerm) {
+ LOGGER.info("Finding todo entries by search term: {}", searchTerm);
+
+ List searchResults = searchService.findBySearchTerm(searchTerm);
+ LOGGER.info("Found {} todo entries", searchResults.size());
+
+ return searchResults;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTO.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTO.java
new file mode 100644
index 0000000..b02059e
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTO.java
@@ -0,0 +1,35 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notEmpty;
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notNull;
+
+/**
+ * This class contains the information of an error that occurred when the API tried
+ * to perform the operation requested by the client.
+ *
+ * @author Petri Kainulainen
+ */
+final class ErrorDTO {
+
+ private final String code;
+ private final String message;
+
+ ErrorDTO(String code, String message) {
+ notNull(code, "Code cannot be null.");
+ notEmpty(code, "Code cannot be empty.");
+
+ notNull(message, "Message cannot be null.");
+ notEmpty(message, "Message cannot be empty");
+
+ this.code = code;
+ this.message = message;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTO.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTO.java
new file mode 100644
index 0000000..44234a5
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTO.java
@@ -0,0 +1,35 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notEmpty;
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notNull;
+
+/**
+ * This class contains the information of a single field error.
+ *
+ * @author Petri Kainulainen
+ */
+final class FieldErrorDTO {
+
+ private final String field;
+
+ private final String message;
+
+ FieldErrorDTO(String field, String message) {
+ notNull(field, "Field cannot be null.");
+ notEmpty(field, "Field cannot be empty");
+
+ notNull(message, "Message cannot be null.");
+ notEmpty(message, "Message cannot be empty.");
+
+ this.field = field;
+ this.message = message;
+ }
+
+ public String getField() {
+ return field;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandler.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandler.java
new file mode 100644
index 0000000..5ad9e9b
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandler.java
@@ -0,0 +1,106 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.MessageSource;
+import org.springframework.context.MessageSourceResolvable;
+import org.springframework.context.support.DefaultMessageSourceResolvable;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.BindingResult;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * This class handles the exceptions thrown by our REST API.
+ *
+ * @author Petri Kainulainen
+ */
+@ControllerAdvice
+public final class RestErrorHandler {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestErrorHandler.class);
+
+ private static final String ERROR_CODE_TODO_ENTRY_NOT_FOUND = "error.todo.entry.not.found";
+
+ private final MessageSource messageSource;
+
+ @Autowired
+ public RestErrorHandler(MessageSource messageSource) {
+ this.messageSource = messageSource;
+ }
+
+ /**
+ * Processes an error that occurs when the requested todo entry is not found.
+ * @param ex The exception that was thrown when the todo entry was not found.
+ * @param currentLocale The current locale.
+ * @return An error object that contains the error code and message.
+ */
+ @ExceptionHandler(TodoNotFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ @ResponseBody
+ ErrorDTO handleTodoEntryNotFound(TodoNotFoundException ex, Locale currentLocale) {
+ LOGGER.error("Todo entry was not found by using id: {}", ex.getId());
+
+ MessageSourceResolvable errorMessageRequest = createSingleErrorMessageRequest(
+ ERROR_CODE_TODO_ENTRY_NOT_FOUND,
+ ex.getId()
+ );
+
+ String errorMessage = messageSource.getMessage(errorMessageRequest, currentLocale);
+ return new ErrorDTO(HttpStatus.NOT_FOUND.name(), errorMessage);
+ }
+
+ private DefaultMessageSourceResolvable createSingleErrorMessageRequest(String errorMessageCode, Object... params) {
+ return new DefaultMessageSourceResolvable(new String[] {errorMessageCode}, params);
+ }
+
+ /**
+ * Processes an error that occurs when the validation of an object fails.
+ *
+ * @param ex The exception that was thrown when the validation failed.
+ * @return An error object that describes all validation errors.
+ */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ @ResponseBody
+ public ValidationErrorDTO handleValidationErrors(MethodArgumentNotValidException ex, Locale currentLocale) {
+ BindingResult result = ex.getBindingResult();
+ List fieldErrors = result.getFieldErrors();
+ LOGGER.error("Found {} validation errors", fieldErrors.size());
+
+ return constructValidationErrors(fieldErrors, currentLocale);
+ }
+
+ private ValidationErrorDTO constructValidationErrors(List fieldErrors, Locale currentLocale) {
+ ValidationErrorDTO dto = new ValidationErrorDTO();
+
+ for (FieldError fieldError: fieldErrors) {
+ String localizedErrorMessage = getValidationErrorMessage(fieldError, currentLocale);
+ dto.addFieldError(fieldError.getField(), localizedErrorMessage);
+ }
+
+ return dto;
+ }
+
+ private String getValidationErrorMessage(FieldError fieldError, Locale currentLocale) {
+ String localizedErrorMessage = messageSource.getMessage(fieldError, currentLocale);
+
+ //If the message was not found, return the most accurate field error code instead.
+ //You can remove this check if you prefer to get the default error message.
+ if (localizedErrorMessage.equals(fieldError.getDefaultMessage())) {
+ String[] fieldErrorCodes = fieldError.getCodes();
+ localizedErrorMessage = fieldErrorCodes[0];
+ }
+
+ return localizedErrorMessage;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTO.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTO.java
new file mode 100644
index 0000000..8355c7b
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTO.java
@@ -0,0 +1,36 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import org.springframework.http.HttpStatus;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This class contains the information of validation errors that are found
+ * from a controller method parameter that is annotated with the
+ * {@link javax.validation.Valid} annotation.
+ *
+ * @author Petri Kainulainen
+ */
+final class ValidationErrorDTO {
+
+ private final String code = HttpStatus.BAD_REQUEST.name();
+
+ private final List fieldErrors = new ArrayList<>();
+
+ ValidationErrorDTO() {
+ }
+
+ void addFieldError(String field, String message) {
+ FieldErrorDTO error = new FieldErrorDTO(field, message);
+ fieldErrors.add(error);
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public List getFieldErrors() {
+ return fieldErrors;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfHeaderFilter.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfHeaderFilter.java
new file mode 100644
index 0000000..141a948
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfHeaderFilter.java
@@ -0,0 +1,46 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This filter reads the {@link org.springframework.security.web.csrf.CsrfToken} from the {@link HttpServletRequest} and
+ * sets its content to the {@link HttpServletResponse} headers.
+ *
+ * I borrowed this idea from this StackOverflow question.
+ *
+ * @author Petri Kainulainen
+ */
+public class CsrfHeaderFilter extends OncePerRequestFilter {
+
+ private static final Logger LOGGER = org.slf4j.LoggerFactory.getLogger(CsrfHeaderFilter.class);
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+
+ LOGGER.trace("Reading CSRF token from the request.");
+
+ CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
+
+ if (token != null) {
+ LOGGER.trace("CSRF token was found. Creating HTTP response headers.");
+ response.setHeader("X-CSRF-HEADER", token.getHeaderName());
+ response.setHeader("X-CSRF-PARAM", token.getParameterName());
+ response.setHeader("X-CSRF-TOKEN", token.getToken());
+ }
+ else {
+ LOGGER.trace("CSRF Token was not found. Doing nothing.");
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfTokenController.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfTokenController.java
new file mode 100644
index 0000000..f6e70cb
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfTokenController.java
@@ -0,0 +1,21 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RestController
+public class CsrfTokenController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(CsrfTokenController.class);
+
+ @RequestMapping(value = "/api/csrf", method = RequestMethod.HEAD)
+ public void getCsrfToken() {
+ LOGGER.info("Getting CSRF token.");
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationEntryPoint.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationEntryPoint.java
new file mode 100644
index 0000000..887e25b
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationEntryPoint.java
@@ -0,0 +1,28 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This authentication entry point returns the HTTP status code 401.
+ * @author Petri Kainulainen
+ */
+public final class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestAuthenticationEntryPoint.class);
+
+ @Override
+ public void commence(HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException authException) throws IOException, ServletException {
+ LOGGER.info("Authentication required. Returning HTTP status code 401.");
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationFailureHandler.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationFailureHandler.java
new file mode 100644
index 0000000..daf635b
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationFailureHandler.java
@@ -0,0 +1,28 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This authentication failure handler returns the HTTP status code 403.
+ * @author Petri Kainulainen
+ */
+public final class RestAuthenticationFailureHandler implements AuthenticationFailureHandler {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestAuthenticationFailureHandler.class);
+
+ @Override
+ public void onAuthenticationFailure(HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException e) throws IOException, ServletException {
+ LOGGER.info("Authentication failed with message: {}", e.getMessage());
+ response.sendError(HttpServletResponse.SC_FORBIDDEN, "Authentication failed.");
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationSuccessHandler.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationSuccessHandler.java
new file mode 100644
index 0000000..ff84785
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationSuccessHandler.java
@@ -0,0 +1,30 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This authentication success handler returns the information of the authenticated
+ * user as JSON.
+ *
+ * @author Petri Kainulainen
+ */
+public final class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestAuthenticationSuccessHandler.class);
+
+ @Override
+ public void onAuthenticationSuccess(HttpServletRequest request,
+ HttpServletResponse response,
+ Authentication authentication) throws IOException, ServletException {
+ LOGGER.info("Authentication was successful");
+ response.sendRedirect(response.encodeRedirectURL("/api/authenticated-user"));
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserController.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserController.java
new file mode 100644
index 0000000..ef7959d
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserController.java
@@ -0,0 +1,45 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * This controller provides the public API that is used to return the information
+ * of the authenticated user.
+ *
+ * @author Petri Kainulainen
+ */
+@RestController
+final class UserController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(UserController.class);
+
+ /**
+ * Returns the information of the authenticated user as JSON. The returned information
+ * contains the username and the user role of the authenticated user.
+ *
+ * @param authenticatedUser The information of the authenticated user.
+ * @return
+ */
+ @RequestMapping(value = "/api/authenticated-user", method = RequestMethod.GET)
+ public UserDTO getAuthenticatedUser(@AuthenticationPrincipal User authenticatedUser) {
+ LOGGER.info("Getting authenticated user.");
+
+ if (authenticatedUser == null) {
+ //If anonymous users can access this controller method, someone has changed
+ //the security configuration and it must be fixed.
+ LOGGER.error("Authenticated user is not found.");
+ throw new AccessDeniedException("Anonymous users cannot request the information of the authenticated user.");
+ }
+ else {
+ LOGGER.info("User with username: {} is authenticated", authenticatedUser.getUsername());
+ return new UserDTO(authenticatedUser.getUsername(), authenticatedUser.getAuthorities());
+ }
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserDTO.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserDTO.java
new file mode 100644
index 0000000..92b99ed
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserDTO.java
@@ -0,0 +1,35 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import net.petrikainulainen.springdata.jpa.common.PreCondition;
+import org.springframework.security.core.GrantedAuthority;
+
+import java.util.Collection;
+
+/**
+ * This class contains the information of the authenticated user.
+ *
+ * @author Petri Kainulainen
+ */
+public final class UserDTO {
+
+ private final String username;
+
+ private final UserRole role;
+
+ UserDTO(String username, Collection authorities) {
+ PreCondition.isTrue(!username.isEmpty(), "Username cannot be empty.");
+ PreCondition.isTrue(authorities.size() == 1, "User must have only one granted authority.");
+ this.username = username;
+
+ GrantedAuthority authority = authorities.iterator().next();
+ this.role = UserRole.valueOf(authority.getAuthority());
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public UserRole getRole() {
+ return role;
+ }
+}
diff --git a/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserRole.java b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserRole.java
new file mode 100644
index 0000000..8b3e6a6
--- /dev/null
+++ b/custom-method-all-repos/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserRole.java
@@ -0,0 +1,8 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+/**
+ * @author Petri Kainulainen
+ */
+enum UserRole {
+ ROLE_USER
+}
diff --git a/custom-method-all-repos/src/main/resources/META-INF/jpa-named-queries.properties b/custom-method-all-repos/src/main/resources/META-INF/jpa-named-queries.properties
new file mode 100644
index 0000000..97d737e
--- /dev/null
+++ b/custom-method-all-repos/src/main/resources/META-INF/jpa-named-queries.properties
@@ -0,0 +1,2 @@
+Todo.findBySearchTermNamedFile=SELECT t FROM Todo t WHERE LOWER(t.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR LOWER(t.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) ORDER BY t.title ASC
+Todo.findBySearchTermNamedNativeFile=SELECT * FROM todos t WHERE LOWER(t.title) LIKE LOWER(CONCAT('%',:searchTerm, '%')) OR LOWER(t.description) LIKE LOWER(CONCAT('%',:searchTerm, '%')) ORDER BY t.title ASC
\ No newline at end of file
diff --git a/custom-method-all-repos/src/main/resources/META-INF/orm.xml b/custom-method-all-repos/src/main/resources/META-INF/orm.xml
new file mode 100644
index 0000000..cc2bf80
--- /dev/null
+++ b/custom-method-all-repos/src/main/resources/META-INF/orm.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ SELECT t FROM Todo t WHERE LOWER(t.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR LOWER(t.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) ORDER BY t.title ASC
+
+
+
+ SELECT * FROM todos t WHERE LOWER(t.title) LIKE LOWER(CONCAT('%',:searchTerm, '%')) OR LOWER(t.description) LIKE LOWER(CONCAT('%',:searchTerm, '%')) ORDER BY t.title ASC
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/src/main/resources/application.properties b/custom-method-all-repos/src/main/resources/application.properties
new file mode 100644
index 0000000..7ac8298
--- /dev/null
+++ b/custom-method-all-repos/src/main/resources/application.properties
@@ -0,0 +1,12 @@
+#Database Configuration
+db.driver=org.h2.Driver
+db.url=jdbc:h2:mem:datajpa
+db.username=sa
+db.password=
+
+#Hibernate Configuration
+hibernate.dialect=org.hibernate.dialect.H2Dialect
+hibernate.format_sql=true
+hibernate.hbm2ddl.auto=create-drop
+hibernate.ejb.naming_strategy=org.hibernate.cfg.ImprovedNamingStrategy
+hibernate.show_sql=false
\ No newline at end of file
diff --git a/custom-method-all-repos/src/main/resources/applicationContext-persistence.xml b/custom-method-all-repos/src/main/resources/applicationContext-persistence.xml
new file mode 100644
index 0000000..f143e51
--- /dev/null
+++ b/custom-method-all-repos/src/main/resources/applicationContext-persistence.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${hibernate.dialect}
+
+
+ ${hibernate.hbm2ddl.auto}
+
+
+ ${hibernate.ejb.naming_strategy}
+
+
+ ${hibernate.show_sql}
+
+
+ ${hibernate.format_sql}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/src/main/resources/applicationContext-web.xml b/custom-method-all-repos/src/main/resources/applicationContext-web.xml
new file mode 100644
index 0000000..db48af6
--- /dev/null
+++ b/custom-method-all-repos/src/main/resources/applicationContext-web.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+ WRITE_DATES_AS_TIMESTAMPS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/src/main/resources/applicationContext.xml b/custom-method-all-repos/src/main/resources/applicationContext.xml
new file mode 100644
index 0000000..b9ee424
--- /dev/null
+++ b/custom-method-all-repos/src/main/resources/applicationContext.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-all-repos/src/main/resources/i18n/messages.properties b/custom-method-all-repos/src/main/resources/i18n/messages.properties
new file mode 100644
index 0000000..0e376f5
--- /dev/null
+++ b/custom-method-all-repos/src/main/resources/i18n/messages.properties
@@ -0,0 +1,5 @@
+error.todo.entry.not.found=No todo entry was found by using id: {0}
+
+NotEmpty.todoDTO.title=The title cannot be empty
+Size.todoDTO.description=The maximum length of description is 500 characters
+Size.todoDTO.title=The maximum length of title is 100 characters
\ No newline at end of file
diff --git a/custom-method-all-repos/src/main/resources/integration-test.properties b/custom-method-all-repos/src/main/resources/integration-test.properties
new file mode 100644
index 0000000..3605c55
--- /dev/null
+++ b/custom-method-all-repos/src/main/resources/integration-test.properties
@@ -0,0 +1,14 @@
+#Database Configuration
+db.driver=org.h2.Driver
+db.url=jdbc:h2:mem:datajpa;DB_CLOSE_ON_EXIT=FALSE
+db.username=sa
+db.password=
+
+#Hibernate Configuration
+hibernate.dialect=org.hibernate.dialect.H2Dialect
+hibernate.format_sql=true
+hibernate.hbm2ddl.auto=create-drop
+hibernate.ejb.naming_strategy=org.hibernate.cfg.ImprovedNamingStrategy
+hibernate.show_sql=false
+
+test.reset.sql.template=ALTER TABLE %s ALTER COLUMN id RESTART WITH 1
\ No newline at end of file
diff --git a/tutorial-part-five/src/main/resources/log4j.properties b/custom-method-all-repos/src/main/resources/log4j.properties
similarity index 75%
rename from tutorial-part-five/src/main/resources/log4j.properties
rename to custom-method-all-repos/src/main/resources/log4j.properties
index 5ad34eb..668d97a 100644
--- a/tutorial-part-five/src/main/resources/log4j.properties
+++ b/custom-method-all-repos/src/main/resources/log4j.properties
@@ -3,4 +3,6 @@ log4j.appender.Stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.Stdout.layout.conversionPattern=%-5p - %-26.26c{1} - %m\n
log4j.rootLogger=DEBUG,Stdout
-log4j.logger.org.springframework=DEBUG
+
+log4j.logger.org.hibernate=INFO
+log4j.logger.org.springframework=INFO
\ No newline at end of file
diff --git a/custom-method-all-repos/src/main/webapp/WEB-INF/jsp/frontend/client.jsp b/custom-method-all-repos/src/main/webapp/WEB-INF/jsp/frontend/client.jsp
new file mode 100644
index 0000000..84158d0
--- /dev/null
+++ b/custom-method-all-repos/src/main/webapp/WEB-INF/jsp/frontend/client.jsp
@@ -0,0 +1,74 @@
+<%@ page contentType="text/html;charset=UTF-8" language="java" session="false" %>
+<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ * The new Java 8 date objects are serialized in standard
+ * ISO-8601 string representation.
+ *
+ *
+ *
+ * @return
+ */
+ static MappingJackson2HttpMessageConverter jacksonDateTimeConverter() {
+ ObjectMapper objectMapper = new ObjectMapper();
+
+ objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ objectMapper.registerModule(new JSR310Module());
+
+ MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
+ converter.setObjectMapper(objectMapper);
+ return converter;
+ }
+
+ /**
+ * This method ensures that the {@link RestErrorHandler} class
+ * is used to handle the exceptions thrown by the tested controller. I borrowed this idea from
+ * this StackOverflow answer.
+ *
+ * @return an error handler component that delegates relevant exceptions forward to the {@link RestErrorHandler} class.
+ */
+ static ExceptionHandlerExceptionResolver restErrorHandler(MessageSource messageSource) {
+ final ExceptionHandlerExceptionResolver exceptionResolver = new ExceptionHandlerExceptionResolver() {
+ @Override
+ protected ServletInvocableHandlerMethod getExceptionHandlerMethod(final HandlerMethod handlerMethod,
+ final Exception exception) {
+ Method method = new ExceptionHandlerMethodResolver(RestErrorHandler.class).resolveMethod(exception);
+ if (method != null) {
+ return new ServletInvocableHandlerMethod(new RestErrorHandler(messageSource), method);
+ }
+ return super.getExceptionHandlerMethod(handlerMethod, exception);
+ }
+ };
+ exceptionResolver.setMessageConverters(Arrays.asList(jacksonDateTimeConverter()));
+ exceptionResolver.afterPropertiesSet();
+ return exceptionResolver;
+ }
+
+ /**
+ * This method returns a {@link org.springframework.web.method.support.HandlerMethodArgumentResolver} that can
+ * construct {@link org.springframework.data.domain.Sort} objects by using the request params of the
+ * incoming request.
+ * @return
+ */
+ static SortHandlerMethodArgumentResolver sortArgumentResolver() {
+ return new SortHandlerMethodArgumentResolver();
+ }
+
+ /**
+ * This method creates a validator object that adds support for bean validation API 1.0 and 1.1.
+ *
+ * @return The created validator object.
+ */
+ static LocalValidatorFactoryBean validator() {
+ return new LocalValidatorFactoryBean();
+ }
+}
diff --git a/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestConstants.java b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestConstants.java
new file mode 100644
index 0000000..2246b2b
--- /dev/null
+++ b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestConstants.java
@@ -0,0 +1,32 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import org.springframework.http.MediaType;
+
+import java.nio.charset.Charset;
+
+/**
+ * @author Petri Kainulainen
+ */
+public final class WebTestConstants {
+
+ public static final MediaType APPLICATION_JSON_UTF8 = new MediaType(MediaType.APPLICATION_JSON.getType(),
+ MediaType.APPLICATION_JSON.getSubtype(),
+ Charset.forName("utf8")
+ );
+
+ static final String ERROR_CODE_TODO_ENTRY_NOT_FOUND = "NOT_FOUND";
+ static final String ERROR_CODE_VALIDATION_FAILED = "BAD_REQUEST";
+
+ static final String FIELD_NAME_DESCRIPTION = "description";
+ static final String FIELD_NAME_TITLE = "title";
+
+ static final int MAX_LENGTH_DESCRIPTION = 500;
+ static final int MAX_LENGTH_TITLE = 100;
+
+ static final String REQUEST_PARAM_SEARCH_TERM = "searchTerm";
+
+ /**
+ * Prevents instantiation.
+ */
+ private WebTestConstants() {}
+}
diff --git a/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestUtil.java b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestUtil.java
new file mode 100644
index 0000000..9340fe6
--- /dev/null
+++ b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestUtil.java
@@ -0,0 +1,29 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+
+/**
+ * @author Petri Kainulainen
+ */
+final class WebTestUtil {
+
+ /**
+ * Prevents instantiation
+ */
+ private WebTestUtil() {}
+
+ /**
+ * Transforms an object into JSON and returns the JSON as a byte array.
+ * @param object The object that is transformed into JSON.
+ * @return The JSON representation of an object as a byte array.
+ * @throws IOException
+ */
+ static byte[] convertObjectToJsonBytes(Object object) throws IOException {
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ return mapper.writeValueAsBytes(object);
+ }
+}
diff --git a/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTOTest.java b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTOTest.java
new file mode 100644
index 0000000..528a552
--- /dev/null
+++ b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTOTest.java
@@ -0,0 +1,61 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.web.error.ErrorDTO;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class ErrorDTOTest {
+
+ private static final String CODE = "code";
+ private static final String MESSAGE = "message";
+
+ public class CreateNew {
+
+ public class WhenCodeIsInvalid {
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowExceptionWhenCodeIsNull() {
+ new ErrorDTO(null, MESSAGE);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowExceptionWhenCodeIsEmpty() {
+ new ErrorDTO("", MESSAGE);
+ }
+ }
+
+ public class WhenMessageIsInvalid {
+
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowExceptionWhenMessageIsNull() {
+ new ErrorDTO(CODE, null);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowExceptionWhenMessageIsEmpty() {
+ new ErrorDTO(CODE, "");
+ }
+ }
+
+ public class WhenCodeAndMessageAreValid {
+
+ @Test
+ public void shouldCreateNewObjectAndSetCode() {
+ ErrorDTO error = new ErrorDTO(CODE, MESSAGE);
+ assertThat(error.getCode()).isEqualTo(CODE);
+ }
+
+ @Test
+ public void shouldCreateNewObjectAndSetMessage() {
+ ErrorDTO error = new ErrorDTO(CODE, MESSAGE);
+ assertThat(error.getMessage()).isEqualTo(MESSAGE);
+ }
+ }
+ }
+}
diff --git a/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTOTest.java b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTOTest.java
new file mode 100644
index 0000000..25fc6bf
--- /dev/null
+++ b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTOTest.java
@@ -0,0 +1,64 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.web.error.FieldErrorDTO;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class FieldErrorDTOTest {
+
+ private static final String FIELD = "field";
+ private static final String MESSAGE = "message";
+
+ public class CreateNew {
+
+ public class WhenFieldIsInvalid {
+
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowExceptionWhenFieldIsNull() {
+ new FieldErrorDTO(null, MESSAGE);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowExceptionWhenFieldIsEmpty() {
+ new FieldErrorDTO("", MESSAGE);
+ }
+ }
+
+ public class WhenMessageIsInvalid {
+
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowExceptionWhenMessageIsNull() {
+ new FieldErrorDTO(FIELD, null);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowExceptionWhenMessageIsEmpty() {
+ new FieldErrorDTO(FIELD, "");
+ }
+ }
+
+ public class WhenFieldAndMessageAreValid {
+
+ @Test
+ public void shouldCreateNewObjectAndSetField() {
+ FieldErrorDTO fieldError = new FieldErrorDTO(FIELD, MESSAGE);
+
+ assertThat(fieldError.getField()).isEqualTo(FIELD);
+ }
+
+ @Test
+ public void shouldCreateNewObjectAndSetMessage() {
+ FieldErrorDTO fieldError = new FieldErrorDTO(FIELD, MESSAGE);
+
+ assertThat(fieldError.getMessage()).isEqualTo(MESSAGE);
+ }
+ }
+ }
+}
diff --git a/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandlerTest.java b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandlerTest.java
new file mode 100644
index 0000000..5ff8f34
--- /dev/null
+++ b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandlerTest.java
@@ -0,0 +1,242 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.todo.TodoDTO;
+import net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.context.MessageSource;
+import org.springframework.context.MessageSourceResolvable;
+import org.springframework.core.MethodParameter;
+import org.springframework.validation.BeanPropertyBindingResult;
+import org.springframework.validation.BindingResult;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+
+import java.util.List;
+import java.util.Locale;
+
+import static info.solidsoft.mockito.java8.AssertionMatcher.assertArg;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Matchers.isA;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class RestErrorHandlerTest {
+
+ private static final Locale CURRENT_LOCALE = Locale.US;
+
+ private static final Long TODO_ID = 99L;
+
+ private MessageSource messageSource;
+
+ private RestErrorHandler errorHandler;
+
+ @Before
+ public void setUp() {
+ messageSource = mock(MessageSource.class);
+ this.errorHandler = new RestErrorHandler(messageSource);
+ }
+
+ public class HandleTodoEntryNotFound {
+
+ private static final String ERROR_CODE_TODO_ENTRY_NOT_FOUND = "NOT_FOUND";
+
+ private static final String ERROR_MESSAGE_CODE_TODO_ENTRY_NOT_FOUND = "error.todo.entry.not.found";
+ private static final String ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND = "No todo entry was found by using id: 99";
+
+ @Before
+ public void returnErrorMessageNotFound() {
+ given(messageSource.getMessage(
+ isA(MessageSourceResolvable.class),
+ isA(Locale.class))
+ ).willReturn(ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND);
+ }
+
+ @Test
+ public void shouldFindErrorMessageByUsingCurrentLocale() {
+ errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
+
+ verify(messageSource, times(1)).getMessage(isA(MessageSourceResolvable.class), eq(CURRENT_LOCALE));
+ }
+
+ @Test
+ public void shouldFindErrorMessageByUsingCorrectId() {
+ errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
+
+ verify(messageSource, times(1)).getMessage(
+ assertArg(messageRequest -> assertThat(messageRequest.getArguments())
+ .containsOnly(TODO_ID)
+ ),
+ eq(CURRENT_LOCALE)
+ );
+ }
+
+ @Test
+ public void shouldFindErrorMessageByUsingCorrectMessageCode() {
+ errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
+
+ verify(messageSource, times(1)).getMessage(
+ assertArg(messageRequest -> assertThat(messageRequest.getCodes())
+ .containsOnly(ERROR_MESSAGE_CODE_TODO_ENTRY_NOT_FOUND)
+ ),
+ eq(CURRENT_LOCALE)
+ );
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectErrorCode() {
+ ErrorDTO error = errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
+
+ assertThat(error.getCode()).isEqualTo(ERROR_CODE_TODO_ENTRY_NOT_FOUND);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectMessage() {
+ ErrorDTO error = errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
+
+ assertThat(error.getMessage()).isEqualTo(ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND);
+ }
+ }
+
+ public class HandleValidationErrors {
+
+ private static final String ERROR_CODE_VALIDATION_ERROR = "BAD_REQUEST";
+ private static final String ERROR_MESSAGE_VALIDATION_ERROR = "validationError";
+
+ private static final String FIELD_DEFAULT_MESSAGE = "DefaultMessage";
+ private static final String FIELD_WITH_VALIDATION_ERROR = "field";
+ private static final String OBJECT_WITH_VALIDATION_ERROR = "todoDTO";
+
+ private static final String VALIDATION_ERROR_CODE_ACCURATE = "Error";
+ private static final String VALIDATION_ERROR_CODE_LESS_ACCURATE = "Maybe";
+
+ public class WhenOneValidationErrorIsFound {
+
+ public class WhenMessageIsFound {
+
+ private MethodArgumentNotValidException ex;
+
+ @Before
+ public void createValidationErrorAndReturnErrorMessage() {
+ FieldError fieldError = new FieldErrorBuilder()
+ .defaultMessage(FIELD_DEFAULT_MESSAGE)
+ .fieldName(FIELD_WITH_VALIDATION_ERROR)
+ .build();
+ given(messageSource.getMessage(fieldError, CURRENT_LOCALE)).willReturn(ERROR_MESSAGE_VALIDATION_ERROR);
+
+ ex = createExceptionWithFieldErrors(fieldError);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectCode() {
+ ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
+
+ assertThat(validationErrors.getCode()).isEqualTo(ERROR_CODE_VALIDATION_ERROR);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectFieldErrorWithMessage() {
+ ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
+
+ List fieldErrors = validationErrors.getFieldErrors();
+ assertThat(fieldErrors).hasSize(1);
+
+ FieldErrorDTO actualFieldError = fieldErrors.iterator().next();
+ assertThat(actualFieldError.getField()).isEqualTo(FIELD_WITH_VALIDATION_ERROR);
+ assertThat(actualFieldError.getMessage()).isEqualTo(ERROR_MESSAGE_VALIDATION_ERROR);
+ }
+ }
+
+ public class WhenMessageIsNotFound {
+
+ private MethodArgumentNotValidException ex;
+
+ @Before
+ public void createValidationErrorAndReturnDefaultErrorMessage() {
+ FieldError fieldError = new FieldErrorBuilder()
+ .defaultMessage(FIELD_DEFAULT_MESSAGE)
+ .errorCodes(VALIDATION_ERROR_CODE_ACCURATE, VALIDATION_ERROR_CODE_LESS_ACCURATE)
+ .fieldName(FIELD_WITH_VALIDATION_ERROR)
+ .build();
+ given(messageSource.getMessage(fieldError, CURRENT_LOCALE)).willReturn(FIELD_DEFAULT_MESSAGE);
+
+ ex = createExceptionWithFieldErrors(fieldError);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectCode() {
+ ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
+
+ assertThat(validationErrors.getCode()).isEqualTo(ERROR_CODE_VALIDATION_ERROR);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasFieldErrorWithMostAccurateFieldErrorCode() {
+ ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
+
+ List fieldErrors = validationErrors.getFieldErrors();
+ assertThat(fieldErrors).hasSize(1);
+
+ FieldErrorDTO actualFieldError = fieldErrors.iterator().next();
+ assertThat(actualFieldError.getField()).isEqualTo(FIELD_WITH_VALIDATION_ERROR);
+ assertThat(actualFieldError.getMessage()).isEqualTo(VALIDATION_ERROR_CODE_ACCURATE);
+ }
+ }
+ }
+
+ private MethodArgumentNotValidException createExceptionWithFieldErrors(FieldError... fieldErrors) {
+ BindingResult bindingResult = new BeanPropertyBindingResult(new TodoDTO(), OBJECT_WITH_VALIDATION_ERROR);
+
+ for (FieldError fieldError: fieldErrors) {
+ bindingResult.addError(fieldError);
+ }
+
+ return new MethodArgumentNotValidException(mock(MethodParameter.class), bindingResult);
+ }
+
+
+ private final class FieldErrorBuilder {
+
+ private String defaultMessage;
+ private String[] errorCodes;
+ private String fieldName;
+
+ private FieldErrorBuilder() {}
+
+ private FieldErrorBuilder defaultMessage(String defaultMessage) {
+ this.defaultMessage = defaultMessage;
+ return this;
+ }
+
+ private FieldErrorBuilder errorCodes(String... errorCodes) {
+ this.errorCodes = errorCodes;
+ return this;
+ }
+
+ private FieldErrorBuilder fieldName(String fieldName) {
+ this.fieldName = fieldName;
+ return this;
+ }
+
+ private FieldError build() {
+ return new FieldError(OBJECT_WITH_VALIDATION_ERROR,
+ fieldName,
+ null,
+ false,
+ errorCodes,
+ new Object[]{},
+ defaultMessage
+ );
+ }
+ }
+ }
+}
diff --git a/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTOTest.java b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTOTest.java
new file mode 100644
index 0000000..8ae069a
--- /dev/null
+++ b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTOTest.java
@@ -0,0 +1,132 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.web.error.FieldErrorDTO;
+import net.petrikainulainen.springdata.jpa.web.error.ValidationErrorDTO;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class ValidationErrorDTOTest {
+
+ private static final String FIELD = "field";
+ private static final String MESSAGE = "message";
+
+ public class AddFieldError {
+
+ public class WhenFieldIsInvalid {
+
+ public class WhenFieldIsNull {
+
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowException() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError(null, MESSAGE);
+ }
+
+ @Test
+ public void shouldNotCreateNewFieldError() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+
+ catchThrowable(() -> validationErrors.addFieldError(null, MESSAGE));
+
+ assertThat(validationErrors.getFieldErrors()).isEmpty();
+ }
+ }
+
+ public class WhenFieldIsEmpty {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError("", MESSAGE);
+ }
+
+ @Test
+ public void shouldNotCreateNewFieldError() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+
+ catchThrowable(() -> validationErrors.addFieldError("", MESSAGE));
+
+ assertThat(validationErrors.getFieldErrors()).isEmpty();
+ }
+ }
+ }
+
+ public class WhenMessageIsInvalid {
+
+ public class WhenMessageIsNull {
+
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowException() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError(FIELD, null);
+ }
+
+ @Test
+ public void shouldNotCreateNewFieldError() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+
+ catchThrowable(() -> validationErrors.addFieldError(FIELD, null));
+
+ assertThat(validationErrors.getFieldErrors()).isEmpty();
+ }
+ }
+
+ public class WhenMessageIsEmpty {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError(FIELD, "");
+ }
+
+ @Test
+ public void shouldNotCreateNewFieldError() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+
+ catchThrowable(() -> validationErrors.addFieldError(FIELD, ""));
+
+ assertThat(validationErrors.getFieldErrors()).isEmpty();
+ }
+ }
+ }
+
+ public class WhenFieldAndMessageAreValid {
+
+ @Test
+ public void shouldCreateNewFieldErrorAndSetField() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError(FIELD, MESSAGE);
+
+ List fieldErrors = validationErrors.getFieldErrors();
+ assertThat(fieldErrors).hasSize(1);
+
+ FieldErrorDTO fieldError = fieldErrors.iterator().next();
+
+ assertThat(fieldError.getField()).isEqualTo(FIELD);
+ }
+
+ @Test
+ public void shouldCreateNewFieldErrorAndSetMessage() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError(FIELD, MESSAGE);
+
+ List fieldErrors = validationErrors.getFieldErrors();
+ assertThat(fieldErrors).hasSize(1);
+
+ FieldErrorDTO fieldError = fieldErrors.iterator().next();
+
+ assertThat(fieldError.getMessage()).isEqualTo(MESSAGE);
+ }
+ }
+ }
+}
diff --git a/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/security/UserDTOTest.java b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/security/UserDTOTest.java
new file mode 100644
index 0000000..1928461
--- /dev/null
+++ b/custom-method-all-repos/src/test/java/net/petrikainulainen/springdata/jpa/web/security/UserDTOTest.java
@@ -0,0 +1,102 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class UserDTOTest {
+
+ public class CreateNew {
+
+ private final String ROLE_USER = UserRole.ROLE_USER.name();
+
+ public class WhenUsernameIsEmpty {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ Collection authorities = createAuthorities(ROLE_USER);
+ new UserDTO("", authorities);
+ }
+ }
+
+ public class WhenUserNameIsNotEmpty {
+
+ private final String USERNAME = "username";
+
+ public class WhenUserHasNoGrantedAuthorities {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ new UserDTO(USERNAME, new ArrayList<>());
+ }
+ }
+
+ public class WhenUserHasOneGrantedAuthority {
+
+ public class WhenGrantedAuthorityIsKnown {
+
+ private Collection authorities;
+
+ @Before
+ public void createKnownAuthority() {
+ authorities = createAuthorities(ROLE_USER);
+ }
+
+ @Test
+ public void shouldSetUsername() {
+ UserDTO user = new UserDTO(USERNAME, authorities);
+ assertThat(user.getUsername()).isEqualTo(USERNAME);
+ }
+
+ @Test
+ public void shouldSetRole() {
+ UserDTO user = new UserDTO(USERNAME, authorities);
+ assertThat(user.getRole()).isEqualTo(UserRole.ROLE_USER);
+ }
+ }
+
+ public class WhenGrantedAuthorityIsUnknown {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ Collection authorities = createAuthorities("UNKNOWN_ROLE");
+ new UserDTO(USERNAME, authorities);
+ }
+ }
+ }
+
+ public class WhenUserHasMoreThanOneGrantedAuthority {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ Collection authorities = createAuthorities(ROLE_USER, "ANOTHER_ROLE");
+ new UserDTO(USERNAME, authorities);
+ }
+ }
+ }
+ }
+
+ private Collection createAuthorities(String... roles) {
+ List authorities = new ArrayList<>();
+
+ for (String role: roles) {
+ SimpleGrantedAuthority authority = new SimpleGrantedAuthority(role);
+ authorities.add(authority);
+ }
+
+ return authorities;
+ }
+}
diff --git a/custom-method-single-repo/.gitignore b/custom-method-single-repo/.gitignore
new file mode 100644
index 0000000..02895f1
--- /dev/null
+++ b/custom-method-single-repo/.gitignore
@@ -0,0 +1,10 @@
+.DS_Store
+.gradle
+.idea
+*.iml
+build
+h2db
+target
+node_modules
+bower_components
+build
\ No newline at end of file
diff --git a/tutorial-part-one/LICENSE b/custom-method-single-repo/LICENSE
similarity index 88%
rename from tutorial-part-one/LICENSE
rename to custom-method-single-repo/LICENSE
index b333aa5..642bfb3 100644
--- a/tutorial-part-one/LICENSE
+++ b/custom-method-single-repo/LICENSE
@@ -1,4 +1,4 @@
-Copyright 2011 Petri Kainulainen
+Copyright 2014 Petri Kainulainen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -10,4 +10,4 @@ Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
-limitations under the License.
\ No newline at end of file
+limitations under the License.
diff --git a/custom-method-single-repo/README.md b/custom-method-single-repo/README.md
new file mode 100644
index 0000000..9e29189
--- /dev/null
+++ b/custom-method-single-repo/README.md
@@ -0,0 +1,70 @@
+This blog post is the example application of the following blog posts:
+
+* [Spring Data JPA Tutorial: Getting the Required Dependencies](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-getting-the-required-dependencies/)
+* [Spring Data JPA Tutorial: Configuration](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-one-configuration/)
+* [Spring Data JPA Tutorial: CRUD](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-two-crud/)
+* [Spring Data JPA Tutorial: Auditing, Part One](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-auditing-part-one/)
+* [Spring Data JPA Tutorial: Auditing, Part Two](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-auditing-part-two/)
+* [Spring Data JPA Tutorial: Adding Custom Methods Into a Single Repository](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-eight-adding-functionality-to-a-repository/)
+
+Prerequisites
+=============
+
+You need to install the following tools if you want to run this application:
+
+Backend
+---------
+
+* [JDK 8](http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html)
+* [Maven](http://maven.apache.org/) (the application is tested with Maven 3.2.1)
+
+Frontend
+----------
+
+* [Node.js](http://nodejs.org/)
+* [NPM](https://www.npmjs.org/)
+* [Bower](http://bower.io/)
+* [Gulp](http://gulpjs.com/)
+
+You can install these tools by following these steps:
+
+1. Install Node.js by using a [downloaded binary](http://nodejs.org/download/) or a [package manager](https://github.com/joyent/node/wiki/Installing-Node.js-via-package-manager).
+ You can also read this blog post: [How to install Node.js and NPM](http://blog.nodeknockout.com/post/65463770933/how-to-install-node-js-and-npm)
+
+2. Install Bower by using the following command:
+
+ npm install -g bower
+
+3. Install Gulp by using the following command:
+
+ npm install -g gulp
+
+
+Running the Tests
+=================
+
+You can run the unit tests by using the following command:
+
+ mvn clean test -P dev
+
+You can run the integration tests by using the following command:
+
+ mvn clean verify -P integration-test
+
+Running the Application
+=======================
+
+You can run the application by using the following command:
+
+ mvn clean jetty:run -P dev
+
+Credits
+=========
+
+* Kyösti Herrala. The Gulp build script and its Maven integration are based on Kyösti's ideas.
+* [Techniques for authentication in AngularJS applications](https://medium.com/opinionated-angularjs/techniques-for-authentication-in-angularjs-applications-7bbf0346acec)
+
+Known Issues
+============
+
+* If you refresh the login page, you aren't redirected away from it after successful login.
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/.bowerrc b/custom-method-single-repo/frontend/.bowerrc
new file mode 100644
index 0000000..df4bcee
--- /dev/null
+++ b/custom-method-single-repo/frontend/.bowerrc
@@ -0,0 +1,3 @@
+{
+ "directory": "bower_components"
+}
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/.jshintrc b/custom-method-single-repo/frontend/.jshintrc
new file mode 100644
index 0000000..f648d46
--- /dev/null
+++ b/custom-method-single-repo/frontend/.jshintrc
@@ -0,0 +1,33 @@
+{
+ "globalstrict": true,
+ "browser": true,
+ "devel": true,
+ "node": true,
+ "esnext": true,
+ "bitwise": true,
+ "camelcase": true,
+ "curly": true,
+ "eqeqeq": true,
+ "immed": true,
+ "indent": 4,
+ "latedef": true,
+ "newcap": true,
+ "noarg": true,
+ "regexp": true,
+ "undef": true,
+ "unused": false,
+ "strict": true,
+ "trailing": true,
+ "smarttabs": true,
+ "white": true,
+ "globals": {
+ "describe": true,
+ "it": true,
+ "beforeEach": true,
+ "afterEach": true,
+ "angular": true,
+ "jQuery": true,
+ "_": true,
+ "$": true
+ }
+}
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/app.js b/custom-method-single-repo/frontend/app/app.js
new file mode 100644
index 0000000..1840cd6
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/app.js
@@ -0,0 +1,97 @@
+'use strict';
+
+var App = angular.module('app', [
+ 'angular-logger',
+ 'http-auth-interceptor',
+ 'ngLocale',
+ 'ngCookies',
+ 'ngResource',
+ 'ngSanitize',
+ 'pascalprecht.translate',
+ 'ui.bootstrap',
+ 'ui.router',
+ 'ui.utils',
+ 'angular-growl',
+ 'angularMoment',
+ 'spring-security-csrf-token-interceptor',
+
+ //Partials
+ 'templates',
+
+ //Account
+ 'app.account.config', 'app.account.directives', 'app.account.controllers', 'app.account.services',
+
+ //Common
+ 'app.common.config', 'app.common.controllers', 'app.common.directives', 'app.common.services',
+
+ //Todo
+ 'app.todo.controllers', 'app.todo.directives', 'app.todo.services',
+
+ //Search
+ 'app.search.controllers', 'app.search.directives', 'app.search.services'
+
+]);
+
+App.run(['$log', '$rootScope', '$state', 'AUTH_EVENTS', 'AuthenticatedUser', 'authService', 'AuthenticationService', 'COMMON_EVENTS',
+ function ($log, $rootScope, $state, AUTH_EVENTS, AuthenticatedUser, authService, AuthenticationService, COMMON_EVENTS) {
+
+ var logger = $log.getInstance('app');
+
+ //This function retries all requests that were failed because of
+ //the 401 response.
+ function listenAuthenticationEvents() {
+ var confirmLogin = function() {
+ authService.loginConfirmed();
+ };
+
+ $rootScope.$on(AUTH_EVENTS.loginSuccess, confirmLogin);
+
+ var viewLogInPage = function() {
+ logger.info('User is not authenticated. Rendering login view.');
+ $state.go('todo.login');
+ };
+
+ $rootScope.$on(AUTH_EVENTS.notAuthenticated, viewLogInPage);
+
+ var viewTodoListPage = function() {
+ logger.info("User logged out. REndering todo list view.");
+ $state.go('todo.list', {}, {reload: true});
+ };
+
+ $rootScope.$on(AUTH_EVENTS.logoutSuccess, viewTodoListPage);
+
+ var viewForbiddenPage = function() {
+ logger.info('Permission was denied for user: %j', AuthenticatedUser);
+ $state.go('todo.forbidden');
+ };
+
+ $rootScope.$on(AUTH_EVENTS.notAuthorized, viewForbiddenPage);
+ }
+
+ function listenCommonEvents() {
+
+ var view404Page = function() {
+ logger.info('Requested page was not found.');
+ $state.go('todo.404');
+ };
+
+ $rootScope.$on(COMMON_EVENTS.notFound, view404Page);
+ }
+
+ //This function ensures that anonymous users cannot access states
+ //that marked as protected (i.e. the value of the authenticated
+ //property is set to true).
+ function secureProtectedStates() {
+ $rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
+ logger.trace('Moving to state: %s', toState.name);
+ AuthenticationService.authorizeStateChange(event, toState, toParams);
+ });
+ }
+
+ $rootScope.currentUser = AuthenticatedUser;
+
+ listenAuthenticationEvents();
+ listenCommonEvents();
+ secureProtectedStates();
+}]);
+
diff --git a/custom-method-single-repo/frontend/app/assets/i18n/en.json b/custom-method-single-repo/frontend/app/assets/i18n/en.json
new file mode 100644
index 0000000..71d4d0c
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/i18n/en.json
@@ -0,0 +1,96 @@
+{
+ "app.title.label": "Spring Data JPA Tutorial - Query Methods",
+ "dialogs": {
+ "delete.dialog": {
+ "cancel.button.label": "Cancel",
+ "delete.button.label": "Delete",
+ "text": "Are you sure that you want to delete the todo entry with title: {{title}}?",
+ "title": "Delete todo entry?"
+ }
+ },
+ "directives": {
+ "login.form": {
+ "login.button": "Login",
+ "login.failed": "Login failed!"
+ },
+ "log.out.link.label": "Log Out",
+ "todo.form": {
+ "cancel.button": "Cancel",
+ "save.button": "Save"
+ }
+ },
+ "footer.message": "Spring Data JPA example application by Petri Kainulainen",
+ "header.brand.label": "Spring Data JPA Tutorial",
+ "pages": {
+ "add.page": {
+ "title": "Add new todo entry",
+ "link.label": "Add new todo entry"
+ },
+ "delete.link": "Delete",
+ "edit.page": {
+ "link.label": "Edit",
+ "title": "Edit todo entry"
+ },
+ "forbidden.page": {
+ "text": "Permission denied.",
+ "title": "Forbidden"
+ },
+ "not.found.page": {
+ "text": "The page that you were looking for was not found.",
+ "title": "Not Found"
+ },
+ "list.page": {
+ "title": "Things to do",
+ "texts": {
+ "no.todo.entries.found": "Nothing to do (yet)."
+ }
+ },
+ "login.page": {
+ "title": "Log In"
+ },
+ "search.results.page.title": "Search Results",
+ "view.page": {
+ "title": "View Todo Entry"
+ }
+ },
+ "login": {
+ "help": "Log in by using username: 'user' and password: 'password'",
+ "username": "Username",
+ "username.placeholder": "Enter username",
+ "password": "Password",
+ "password.placeholder": "Enter password"
+ },
+ "search": {
+ "term.field.placeholder": "Search",
+ "missing.characters.text": "{{missingCharCount}} characters missing"
+ },
+ "todo": {
+ "created.by.prefix": "by",
+ "creation.time": "Created at",
+ "description": "Description",
+ "description.placeholder": "Enter description",
+ "messages": {
+ "description.maxLength": "Description cannot be longer than 500 characters",
+ "title.maxLength": "Title cannot be longer than 100 characters",
+ "title.required": "Title is required"
+ },
+ "modified.by.prefix": "by",
+ "modification.time": "Modified at",
+ "notifications": {
+ "add": {
+ "error": "Adding a new todo entry failed.",
+ "success": "A new todo entry was added."
+ },
+ "delete": {
+ "error": "Deleting the todo entry failed.",
+ "success": "Deleted the todo entry."
+ },
+ "edit": {
+ "error": "Updating the information of a todo entry failed.",
+ "success": "Updated the information of the todo entry."
+ }
+ },
+ "title": "Title",
+ "title.placeholder": "Enter title"
+ }
+}
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/account/forbidden-view.html b/custom-method-single-repo/frontend/app/assets/partials/account/forbidden-view.html
new file mode 100644
index 0000000..c761f3e
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/account/forbidden-view.html
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/account/login-form-directive.html b/custom-method-single-repo/frontend/app/assets/partials/account/login-form-directive.html
new file mode 100644
index 0000000..d2f14aa
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/account/login-form-directive.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/account/login-view.html b/custom-method-single-repo/frontend/app/assets/partials/account/login-view.html
new file mode 100644
index 0000000..199d339
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/account/login-view.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/account/logout-link-directive.html b/custom-method-single-repo/frontend/app/assets/partials/account/logout-link-directive.html
new file mode 100644
index 0000000..4d9550a
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/account/logout-link-directive.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/common/not-found-view.html b/custom-method-single-repo/frontend/app/assets/partials/common/not-found-view.html
new file mode 100644
index 0000000..7edf553
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/common/not-found-view.html
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/search/search-form-directive.html b/custom-method-single-repo/frontend/app/assets/partials/search/search-form-directive.html
new file mode 100644
index 0000000..674143e
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/search/search-form-directive.html
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/search/search-result-view.html b/custom-method-single-repo/frontend/app/assets/partials/search/search-result-view.html
new file mode 100644
index 0000000..e199e19
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/search/search-result-view.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/todo/add-todo-view.html b/custom-method-single-repo/frontend/app/assets/partials/todo/add-todo-view.html
new file mode 100644
index 0000000..0a0406a
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/todo/add-todo-view.html
@@ -0,0 +1,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/todo/delete-todo-modal.html b/custom-method-single-repo/frontend/app/assets/partials/todo/delete-todo-modal.html
new file mode 100644
index 0000000..b390319
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/todo/delete-todo-modal.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/todo/edit-todo-view.html b/custom-method-single-repo/frontend/app/assets/partials/todo/edit-todo-view.html
new file mode 100644
index 0000000..1695ae6
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/todo/edit-todo-view.html
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/todo/todo-form-directive.html b/custom-method-single-repo/frontend/app/assets/partials/todo/todo-form-directive.html
new file mode 100644
index 0000000..c7815d0
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/todo/todo-form-directive.html
@@ -0,0 +1,52 @@
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/todo/todo-list-directive.html b/custom-method-single-repo/frontend/app/assets/partials/todo/todo-list-directive.html
new file mode 100644
index 0000000..60ed955
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/todo/todo-list-directive.html
@@ -0,0 +1,8 @@
+
diff --git a/custom-method-single-repo/frontend/app/assets/partials/todo/todo-list-view.html b/custom-method-single-repo/frontend/app/assets/partials/todo/todo-list-view.html
new file mode 100644
index 0000000..6a83ba4
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/todo/todo-list-view.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/frontend/app/assets/partials/todo/view-todo-view.html b/custom-method-single-repo/frontend/app/assets/partials/todo/view-todo-view.html
new file mode 100644
index 0000000..374c16d
--- /dev/null
+++ b/custom-method-single-repo/frontend/app/assets/partials/todo/view-todo-view.html
@@ -0,0 +1,25 @@
+
+ * Our application always returns the correct time because it uses the
+ * {@link CurrentTimeDateTimeService} class.
+ *
+ *
+ * When our integration tests are running, we can return a constant time which gives us the possibility
+ * to assert the creation and modification times saved to the database.
+ *
+ *
+ *
+ * @author Petri Kainulainen
+ */
+public class AuditingDateTimeProvider implements DateTimeProvider {
+
+ private final DateTimeService dateTimeService;
+
+ public AuditingDateTimeProvider(DateTimeService dateTimeService) {
+ this.dateTimeService = dateTimeService;
+ }
+
+ @Override
+ public Calendar getNow() {
+ return GregorianCalendar.from(dateTimeService.getCurrentDateAndTime());
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/ConstantDateTimeService.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/ConstantDateTimeService.java
new file mode 100644
index 0000000..424e1d4
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/ConstantDateTimeService.java
@@ -0,0 +1,47 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * This class is used in our integration tests and it always returns the
+ * same time. This gives us the possibility to verify that the correct
+ * timestamps are saved to the database.
+ *
+ * @author Petri Kainulainen
+ */
+public class ConstantDateTimeService implements DateTimeService {
+
+ public static final String CURRENT_DATE_AND_TIME = getConstantDateAndTime();
+
+ private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_ZONED_DATE_TIME;
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ConstantDateTimeService.class);
+
+ private static String getConstantDateAndTime() {
+ return "2015-07-19T12:52:28" +
+ getSystemZoneOffset() +
+ getSystemZoneId();
+ }
+
+ private static String getSystemZoneOffset() {
+ return ZonedDateTime.now().getOffset().toString();
+ }
+
+ private static String getSystemZoneId() {
+ return "[" + ZoneId.systemDefault().toString() + "]";
+ }
+
+ @Override
+ public ZonedDateTime getCurrentDateAndTime() {
+ ZonedDateTime constantDateAndTime = ZonedDateTime.from(FORMATTER.parse(CURRENT_DATE_AND_TIME));
+
+ LOGGER.info("Returning constant date and time: {}", constantDateAndTime);
+
+ return constantDateAndTime;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/CurrentTimeDateTimeService.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/CurrentTimeDateTimeService.java
new file mode 100644
index 0000000..2812fb0
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/CurrentTimeDateTimeService.java
@@ -0,0 +1,25 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.ZonedDateTime;
+
+/**
+ * This class returns the current time.
+ *
+ * @author Petri Kainulainen
+ */
+public class CurrentTimeDateTimeService implements DateTimeService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(CurrentTimeDateTimeService.class);
+
+ @Override
+ public ZonedDateTime getCurrentDateAndTime() {
+ ZonedDateTime currentDateAndTime = ZonedDateTime.now();
+
+ LOGGER.info("Returning current date and time: {}", currentDateAndTime);
+
+ return currentDateAndTime;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/DateTimeService.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/DateTimeService.java
new file mode 100644
index 0000000..a1e1a11
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/DateTimeService.java
@@ -0,0 +1,18 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import java.time.ZonedDateTime;
+
+/**
+ * This interface defines the methods used to get the current
+ * date and time.
+ *
+ * @author Petri Kainulainen
+ */
+public interface DateTimeService {
+
+ /**
+ * Returns the current date and time.
+ * @return
+ */
+ ZonedDateTime getCurrentDateAndTime();
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/FrontendLoaderController.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/FrontendLoaderController.java
new file mode 100644
index 0000000..46f2849
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/FrontendLoaderController.java
@@ -0,0 +1,29 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+
+/**
+ * This controller is responsible of starting the frontend application.
+ * @author Petri Kainulainen
+ */
+@Controller
+public class FrontendLoaderController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(FrontendLoaderController.class);
+
+ private static final String FRONTEND_APPLICATION_VIEW = "frontend/client";
+
+ /**
+ * Starts the AngularJS application.
+ * @return
+ */
+ @RequestMapping(value = "/", method = RequestMethod.GET)
+ public String startAngularJSApplication() {
+ LOGGER.debug("Starting frontend single page application.");
+ return FRONTEND_APPLICATION_VIEW;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/PreCondition.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/PreCondition.java
new file mode 100644
index 0000000..d3cf557
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/PreCondition.java
@@ -0,0 +1,63 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+/**
+ * This class provides static utility methods that are used to ensure that a constructor or a method was invoked properly.
+ * These methods throw an exception if the specified precondition is violated.
+ *
+ * This class selects the thrown exception by using the guideline given in Effective Java by Joshua Bloch (Item 60).
+ *
+ * @author Petri Kainulainen
+ */
+public final class PreCondition {
+
+ private PreCondition() {}
+
+ /**
+ * Ensures that the expression given as a method parameter is true.
+ * @param expression The inspected expression.
+ * @param errorMessageTemplate The template that is used to construct the message of the exception thrown if the
+ * inspected exception is false. The template must use the syntax that is supported
+ * by the {@link java.lang.String#format(String, Object...)} method.
+ * @param errorMessageArguments The arguments that are used when the message of the thrown exception is constructed.
+ * @throws java.lang.IllegalArgumentException if the inspected exception is false.
+ */
+ public static void isTrue(boolean expression, String errorMessageTemplate, Object... errorMessageArguments) {
+ isTrue(expression, String.format(errorMessageTemplate, errorMessageArguments));
+ }
+ /**
+ * Ensures that the expression given as a method parameter is true.
+ * @param expression The inspected expression.
+ * @param errorMessage The error message that is passed forward to the exception that is thrown
+ * if the expression is false.
+ * @throws java.lang.IllegalArgumentException if the inspected expression is false.
+ */
+ public static void isTrue(boolean expression, String errorMessage) {
+ if (!expression) {
+ throw new IllegalArgumentException(errorMessage);
+ }
+ }
+ /**
+ * Ensures that the string given as a method parameter is not empty.
+ * @param string The inspected string.
+ * @param errorMessage The error message that is passed forward to the exception that is thrown if
+ * the string is empty.
+ * @throws java.lang.IllegalArgumentException if the inspected string is empty.
+ */
+ public static void notEmpty(String string, String errorMessage) {
+ if (string.isEmpty()) {
+ throw new IllegalArgumentException(errorMessage);
+ }
+ }
+ /**
+ * Ensures that the object given as a method parameter is not null.
+ * @param reference A reference to the inspected object.
+ * @param errorMessage The error message that is passed forward to the exception that is thrown if
+ * the object given as a method parameter is null.
+ * @throws java.lang.NullPointerException If the object given as a method parameter is null.
+ */
+ public static void notNull(Object reference, String errorMessage) {
+ if (reference == null) {
+ throw new NullPointerException(errorMessage);
+ }
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/UsernameAuditorAware.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/UsernameAuditorAware.java
new file mode 100644
index 0000000..ed511d8
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/common/UsernameAuditorAware.java
@@ -0,0 +1,34 @@
+package net.petrikainulainen.springdata.jpa.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.domain.AuditorAware;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.User;
+
+/**
+ * This component returns the username of the authenticated user.
+ *
+ * @author Petri Kainulainen
+ */
+public class UsernameAuditorAware implements AuditorAware {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(UsernameAuditorAware.class);
+
+ @Override
+ public String getCurrentAuditor() {
+ LOGGER.debug("Getting the username of authenticated user.");
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+
+ if (authentication == null || !authentication.isAuthenticated()) {
+ LOGGER.debug("Current user is anonymous. Returning null.");
+ return null;
+ }
+
+ String username = ((User) authentication.getPrincipal()).getUsername();
+ LOGGER.debug("Returning username: {}", username);
+
+ return username;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/ExampleApplicationContext.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/ExampleApplicationContext.java
new file mode 100644
index 0000000..0f922d8
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/ExampleApplicationContext.java
@@ -0,0 +1,66 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import net.petrikainulainen.springdata.jpa.common.ConstantDateTimeService;
+import net.petrikainulainen.springdata.jpa.common.CurrentTimeDateTimeService;
+import net.petrikainulainen.springdata.jpa.common.DateTimeService;
+import org.springframework.context.MessageSource;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Profile;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
+import org.springframework.context.support.ResourceBundleMessageSource;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Configuration
+@ComponentScan("net.petrikainulainen.springdata.jpa")
+@Import({WebMvcContext.class, PersistenceContext.class, SecurityContext.class})
+public class ExampleApplicationContext {
+
+ private static final String MESSAGE_SOURCE_BASE_NAME = "i18n/messages";
+
+ /**
+ * These static classes are required because it makes it possible to use
+ * different properties files for every Spring profile.
+ *
+ * See: This StackOverflow answer for more details.
+ */
+ @Profile(Profiles.APPLICATION)
+ @Configuration
+ @PropertySource("classpath:application.properties")
+ static class ApplicationProperties {}
+
+ @Profile(Profiles.APPLICATION)
+ @Bean
+ DateTimeService currentTimeDateTimeService() {
+ return new CurrentTimeDateTimeService();
+ }
+
+ @Profile(Profiles.INTEGRATION_TEST)
+ @Configuration
+ @PropertySource("classpath:integration-test.properties")
+ static class IntegrationTestProperties {}
+
+ @Profile(Profiles.INTEGRATION_TEST)
+ @Bean
+ DateTimeService constantDateTimeService() {
+ return new ConstantDateTimeService();
+ }
+
+ @Bean
+ MessageSource messageSource() {
+ ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
+ messageSource.setBasename(MESSAGE_SOURCE_BASE_NAME);
+ messageSource.setUseCodeAsDefaultMessage(true);
+ return messageSource;
+ }
+
+ @Bean
+ PropertySourcesPlaceholderConfigurer propertyPlaceHolderConfigurer() {
+ return new PropertySourcesPlaceholderConfigurer();
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/PersistenceContext.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/PersistenceContext.java
new file mode 100644
index 0000000..6812c6b
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/PersistenceContext.java
@@ -0,0 +1,146 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import net.petrikainulainen.springdata.jpa.common.AuditingDateTimeProvider;
+import net.petrikainulainen.springdata.jpa.common.DateTimeService;
+import net.petrikainulainen.springdata.jpa.common.UsernameAuditorAware;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.Environment;
+import org.springframework.data.auditing.DateTimeProvider;
+import org.springframework.data.domain.AuditorAware;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.data.web.config.EnableSpringDataWebSupport;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.orm.jpa.JpaTransactionManager;
+import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
+import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import javax.persistence.EntityManagerFactory;
+import javax.sql.DataSource;
+import java.util.Properties;
+
+/**
+ * This configuration class configures the persistence layer of our example application and
+ * enables annotation driven transaction management.
+ *
+ * This configuration is put to a single class because this way we can write integration
+ * tests for our persistence layer by using the configuration used by our example
+ * application. In other words, we can ensure that the persistence layer of our application
+ * works as expected.
+ *
+ * @author Petri Kainulainen
+ */
+@Configuration
+@EnableJpaAuditing(dateTimeProviderRef = "dateTimeProvider")
+@EnableJpaRepositories(basePackages = {
+ "net.petrikainulainen.springdata.jpa.todo"
+})
+@EnableTransactionManagement
+class PersistenceContext {
+ private static final String[] ENTITY_PACKAGES = {
+ "net.petrikainulainen.springdata.jpa.todo"
+ };
+
+ private static final String PROPERTY_NAME_DB_DRIVER_CLASS = "db.driver";
+ private static final String PROPERTY_NAME_DB_PASSWORD = "db.password";
+ private static final String PROPERTY_NAME_DB_URL = "db.url";
+ private static final String PROPERTY_NAME_DB_USER = "db.username";
+ private static final String PROPERTY_NAME_HIBERNATE_DIALECT = "hibernate.dialect";
+ private static final String PROPERTY_NAME_HIBERNATE_FORMAT_SQL = "hibernate.format_sql";
+ private static final String PROPERTY_NAME_HIBERNATE_HBM2DDL_AUTO = "hibernate.hbm2ddl.auto";
+ private static final String PROPERTY_NAME_HIBERNATE_NAMING_STRATEGY = "hibernate.ejb.naming_strategy";
+ private static final String PROPERTY_NAME_HIBERNATE_SHOW_SQL = "hibernate.show_sql";
+
+ @Bean
+ AuditorAware auditorProvider() {
+ return new UsernameAuditorAware();
+ }
+
+ @Bean
+ DateTimeProvider dateTimeProvider(DateTimeService dateTimeService) {
+ return new AuditingDateTimeProvider(dateTimeService);
+ }
+
+ /**
+ * Creates and configures the HikariCP datasource bean.
+ * @param env The runtime environment of our application.
+ * @return
+ */
+ @Bean(destroyMethod = "close")
+ DataSource dataSource(Environment env) {
+ HikariConfig dataSourceConfig = new HikariConfig();
+ dataSourceConfig.setDriverClassName(env.getRequiredProperty(PROPERTY_NAME_DB_DRIVER_CLASS));
+ dataSourceConfig.setJdbcUrl(env.getRequiredProperty(PROPERTY_NAME_DB_URL));
+ dataSourceConfig.setUsername(env.getRequiredProperty(PROPERTY_NAME_DB_USER));
+ dataSourceConfig.setPassword(env.getRequiredProperty(PROPERTY_NAME_DB_PASSWORD));
+
+ return new HikariDataSource(dataSourceConfig);
+ }
+
+ /**
+ * Creates the bean that creates the JPA entity manager factory.
+ * @param dataSource The datasource that provides the database connections.
+ * @param env The runtime environment of our application.
+ * @return
+ */
+ @Bean
+ LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource, Environment env) {
+ LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
+ entityManagerFactoryBean.setDataSource(dataSource);
+ entityManagerFactoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
+ entityManagerFactoryBean.setPackagesToScan(ENTITY_PACKAGES);
+
+ Properties jpaProperties = new Properties();
+
+ //Configures the used database dialect. This allows Hibernate to create SQL
+ //that is optimized for the used database.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_DIALECT, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_DIALECT));
+
+ //Specifies the action that is invoked to the database when the Hibernate
+ //SessionFactory is created or closed.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_HBM2DDL_AUTO, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_HBM2DDL_AUTO));
+
+ //Configures the naming strategy that is used when Hibernate creates
+ //new database objects and schema elements
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_NAMING_STRATEGY, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_NAMING_STRATEGY));
+
+ //If the value of this property is true, Hibernate writes all SQL
+ //statements to the console.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_SHOW_SQL, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_SHOW_SQL));
+
+ //If the value of this property is true, Hibernate will use prettyprint
+ //when it writes SQL to the console.
+ jpaProperties.put(PROPERTY_NAME_HIBERNATE_FORMAT_SQL, env.getRequiredProperty(PROPERTY_NAME_HIBERNATE_FORMAT_SQL));
+
+ entityManagerFactoryBean.setJpaProperties(jpaProperties);
+
+ return entityManagerFactoryBean;
+ }
+
+ /**
+ * Creates the jdbc template bean that we use to invoke SQL queries via JDBC.
+ * @param dataSource The datasource that provides the database connection.
+ * @return
+ */
+ @Bean
+ NamedParameterJdbcTemplate jdbcTemplate(DataSource dataSource) {
+ return new NamedParameterJdbcTemplate(dataSource);
+ }
+
+ /**
+ * Creates the transaction manager bean that integrates the used JPA provider with the
+ * Spring transaction mechanism.
+ * @param entityManagerFactory The used JPA entity manager factory.
+ * @return
+ */
+ @Bean
+ JpaTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
+ JpaTransactionManager transactionManager = new JpaTransactionManager();
+ transactionManager.setEntityManagerFactory(entityManagerFactory);
+ return transactionManager;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/Profiles.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/Profiles.java
new file mode 100644
index 0000000..bda9711
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/Profiles.java
@@ -0,0 +1,21 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+/**
+ * This class defines the Spring profiles used in the project. The idea behind this class
+ * is that it helps us to avoid typos when we are using these profiles. At the moment
+ * there are two profiles which are described in the following:
+ *
+ *
The APPLICATION profile is used when we run our example application.
+ *
The INTEGRATION_TEST profile is used when we run the integration tests of our example application.
+ *
+ *
+ * @author Petri Kainulainen
+ */
+public class Profiles {
+ public static final String APPLICATION = "application";
+ public static final String INTEGRATION_TEST = "integrationtest";
+ /**
+ * Prevent instantiation.
+ */
+ private Profiles() {}
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/SecurityContext.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/SecurityContext.java
new file mode 100644
index 0000000..8aa95e4
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/SecurityContext.java
@@ -0,0 +1,99 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import net.petrikainulainen.springdata.jpa.web.security.CsrfHeaderFilter;
+import net.petrikainulainen.springdata.jpa.web.security.RestAuthenticationEntryPoint;
+import net.petrikainulainen.springdata.jpa.web.security.RestAuthenticationFailureHandler;
+import net.petrikainulainen.springdata.jpa.web.security.RestAuthenticationSuccessHandler;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.builders.WebSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.csrf.CsrfFilter;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Configuration
+@EnableWebSecurity
+class SecurityContext extends WebSecurityConfigurerAdapter {
+
+ @Bean
+ AuthenticationEntryPoint authenticationEntryPoint() {
+ return new RestAuthenticationEntryPoint();
+ }
+
+ @Bean
+ AuthenticationFailureHandler authenticationFailureHandler() {
+ return new RestAuthenticationFailureHandler();
+ }
+
+ @Bean
+ AuthenticationSuccessHandler authenticationSuccessHandler() {
+ return new RestAuthenticationSuccessHandler();
+ }
+
+ @Bean
+ protected UserDetailsService userDetailsService() {
+ return super.userDetailsService();
+ }
+
+ @Override
+ protected void configure(AuthenticationManagerBuilder auth) throws Exception {
+ auth
+ .inMemoryAuthentication()
+ .withUser("user")
+ .password("password")
+ .roles("USER");
+ }
+
+ @Override
+ protected void configure(HttpSecurity http) throws Exception {
+ http
+ //Use the custom authentication entry point.
+ .exceptionHandling()
+ .authenticationEntryPoint(authenticationEntryPoint())
+ .and()
+ //Configure form login.
+ .formLogin()
+ .loginProcessingUrl("/api/login")
+ .failureHandler(authenticationFailureHandler())
+ .successHandler(authenticationSuccessHandler())
+ .permitAll()
+ .and()
+ //Configure logout function.
+ .logout()
+ .deleteCookies("JSESSIONID")
+ .logoutUrl("/api/logout")
+ .logoutSuccessUrl("/")
+ .and()
+ //Configure url based authorization
+ .authorizeRequests()
+ .antMatchers(
+ "/",
+ "/api/csrf"
+ ).permitAll()
+ .anyRequest().hasRole("USER")
+ .and()
+ .addFilterAfter(new CsrfHeaderFilter(), CsrfFilter.class);
+ }
+
+ @Override
+ public void configure(WebSecurity web) throws Exception {
+ web
+ //Spring Security ignores request to static resources such as CSS or JS files.
+ .ignoring()
+ .antMatchers(
+ "/favicon.ico",
+ "/css/**",
+ "/i18n/**",
+ "/js/**"
+ );
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/WebAppConfig.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/WebAppConfig.java
new file mode 100644
index 0000000..f1861a6
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/WebAppConfig.java
@@ -0,0 +1,66 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import org.springframework.web.WebApplicationInitializer;
+import org.springframework.web.context.ContextLoaderListener;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
+import org.springframework.web.filter.CharacterEncodingFilter;
+import org.springframework.web.filter.DelegatingFilterProxy;
+import org.springframework.web.servlet.DispatcherServlet;
+
+import javax.servlet.DispatcherType;
+import javax.servlet.FilterRegistration;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRegistration;
+import java.util.EnumSet;
+
+/**
+ * @author Petri Kainulainen
+ */
+public class WebAppConfig implements WebApplicationInitializer {
+ private static final String CHARACTER_ENCODING_FILTER_ENCODING = "UTF-8";
+ private static final String CHARACTER_ENCODING_FILTER_NAME = "characterEncoding";
+ private static final String CHARACTER_ENCODING_FILTER_URL_PATTERN = "/*";
+
+ private static final String DISPATCHER_SERVLET_NAME = "dispatcher";
+ private static final String DISPATCHER_SERVLET_MAPPING = "/";
+
+ @Override
+ public void onStartup(ServletContext servletContext) throws ServletException {
+ AnnotationConfigWebApplicationContext rootContext = new AnnotationConfigWebApplicationContext();
+ rootContext.register(ExampleApplicationContext.class);
+
+ //XmlWebApplicationContext rootContext = new XmlWebApplicationContext();
+ //rootContext.setConfigLocation("classpath:applicationContext.xml");
+
+ configureDispatcherServlet(servletContext, rootContext);
+ EnumSet dispatcherTypes = EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD);
+
+ configureCharacterEncodingFilter(servletContext, dispatcherTypes);
+ configureSpringSecurityFilter(servletContext, dispatcherTypes);
+ servletContext.addListener(new ContextLoaderListener(rootContext));
+ }
+
+ private void configureDispatcherServlet(ServletContext servletContext, WebApplicationContext rootContext) {
+ ServletRegistration.Dynamic dispatcher = servletContext.addServlet(
+ DISPATCHER_SERVLET_NAME,
+ new DispatcherServlet(rootContext)
+ );
+ dispatcher.setLoadOnStartup(1);
+ dispatcher.addMapping(DISPATCHER_SERVLET_MAPPING);
+ }
+
+ private void configureCharacterEncodingFilter(ServletContext servletContext, EnumSet dispatcherTypes) {
+ CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
+ characterEncodingFilter.setEncoding(CHARACTER_ENCODING_FILTER_ENCODING);
+ characterEncodingFilter.setForceEncoding(true);
+ FilterRegistration.Dynamic characterEncoding = servletContext.addFilter(CHARACTER_ENCODING_FILTER_NAME, characterEncodingFilter);
+ characterEncoding.addMappingForUrlPatterns(dispatcherTypes, true, CHARACTER_ENCODING_FILTER_URL_PATTERN);
+ }
+
+ private void configureSpringSecurityFilter(ServletContext servletContext, EnumSet dispatcherTypes) {
+ FilterRegistration.Dynamic security = servletContext.addFilter("springSecurityFilterChain", new DelegatingFilterProxy());
+ security.addMappingForUrlPatterns(dispatcherTypes, true, "/*");
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/WebMvcContext.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/WebMvcContext.java
new file mode 100644
index 0000000..c016860
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/config/WebMvcContext.java
@@ -0,0 +1,48 @@
+package net.petrikainulainen.springdata.jpa.config;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JSR310Module;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
+
+import java.util.List;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Configuration
+@EnableWebMvc
+class WebMvcContext extends WebMvcConfigurerAdapter {
+
+ @Override
+ public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
+ configurer.enable();
+ }
+
+
+ @Override
+ public void configureMessageConverters(List> converters) {
+ ObjectMapper objectMapper = new ObjectMapper();
+
+ objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ objectMapper.registerModule(new JSR310Module());
+
+ MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
+ converter.setObjectMapper(objectMapper);
+
+ converters.add(converter);
+ }
+
+ @Override
+ public void configureViewResolvers(ViewResolverRegistry registry) {
+ registry.jsp("/WEB-INF/jsp/", ".jsp");
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/CustomTodoRepository.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/CustomTodoRepository.java
new file mode 100644
index 0000000..17007ca
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/CustomTodoRepository.java
@@ -0,0 +1,19 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import java.util.List;
+
+/**
+ * This interface declares the custom methods that can be added into a Spring
+ * Data JPA repository interface by extending this interface.
+ *
+ * @author Petri Kainulainen
+ */
+interface CustomTodoRepository {
+
+ /**
+ * Finds todo entries by using the search term given as a method parameter.
+ * @param searchTerm The given search term.
+ * @return A list of todo entries whose title or description contains with the given search term.
+ */
+ List findBySearchTerm(String searchTerm);
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoSearchService.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoSearchService.java
new file mode 100644
index 0000000..a6ea6f1
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoSearchService.java
@@ -0,0 +1,36 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Service
+final class RepositoryTodoSearchService implements TodoSearchService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RepositoryTodoSearchService.class);
+
+ private final TodoRepository repository;
+
+ @Autowired
+ public RepositoryTodoSearchService(TodoRepository repository) {
+ this.repository = repository;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List findBySearchTerm(String searchTerm) {
+ LOGGER.info("Finding todo entries by search term: {}", searchTerm);
+
+ List searchResults = repository.findBySearchTerm(searchTerm);
+ LOGGER.info("Found {} todo entries", searchResults.size());
+
+ return searchResults;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoService.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoService.java
new file mode 100644
index 0000000..f59be0d
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/RepositoryTodoService.java
@@ -0,0 +1,101 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Service
+final class RepositoryTodoService implements TodoCrudService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RepositoryTodoService.class);
+
+ private final TodoRepository repository;
+
+ @Autowired
+ RepositoryTodoService(TodoRepository repository) {
+ this.repository = repository;
+ }
+
+ @Transactional
+ @Override
+ public TodoDTO create(TodoDTO newTodoEntry) {
+ LOGGER.info("Creating a new todo entry by using information: {}", newTodoEntry);
+
+ Todo created = Todo.getBuilder()
+ .description(newTodoEntry.getDescription())
+ .title(newTodoEntry.getTitle())
+ .build();
+
+ created = repository.save(created);
+ LOGGER.info("Created a new todo entry: {}", created);
+
+ return TodoMapper.mapEntityIntoDTO(created);
+ }
+
+ @Transactional
+ @Override
+ public TodoDTO delete(Long id) {
+ LOGGER.info("Deleting a todo entry with id: {}", id);
+
+ Todo deleted = findTodoEntryById(id);
+ LOGGER.debug("Found todo entry: {}", deleted);
+
+ repository.delete(deleted);
+ LOGGER.info("Deleted todo entry: {}", deleted);
+
+ return TodoMapper.mapEntityIntoDTO(deleted);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List findAll() {
+ LOGGER.info("Finding all todo entries.");
+
+ List todoEntries = repository.findAll();
+
+ LOGGER.info("Found {} todo entries", todoEntries.size());
+
+ return TodoMapper.mapEntitiesIntoDTOs(todoEntries);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public TodoDTO findById(Long id) {
+ LOGGER.info("Finding todo entry by using id: {}", id);
+
+ Todo todoEntry = findTodoEntryById(id);
+ LOGGER.info("Found todo entry: {}", todoEntry);
+
+ return TodoMapper.mapEntityIntoDTO(todoEntry);
+ }
+
+ @Transactional
+ @Override
+ public TodoDTO update(TodoDTO updatedTodoEntry) {
+ LOGGER.info("Updating the information of a todo entry by using information: {}", updatedTodoEntry);
+
+ Todo updated = findTodoEntryById(updatedTodoEntry.getId());
+ updated.update(updatedTodoEntry.getTitle(), updatedTodoEntry.getDescription());
+
+ //We need to flush the changes or otherwise the returned object
+ //doesn't contain the updated audit information.
+ repository.flush();
+
+ LOGGER.info("Updated the information of the todo entry: {}", updated);
+
+ return TodoMapper.mapEntityIntoDTO(updated);
+ }
+
+ private Todo findTodoEntryById(Long id) {
+ Optional todoResult = repository.findOne(id);
+ return todoResult.orElseThrow(() -> new TodoNotFoundException(id));
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/Todo.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/Todo.java
new file mode 100644
index 0000000..b3acfbb
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/Todo.java
@@ -0,0 +1,196 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.hibernate.annotations.Type;
+import org.springframework.data.annotation.CreatedBy;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedBy;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EntityListeners;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.NamedNativeQuery;
+import javax.persistence.NamedQuery;
+import javax.persistence.Table;
+import javax.persistence.Version;
+import java.time.ZonedDateTime;
+
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.isTrue;
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notEmpty;
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notNull;
+
+/**
+ * This entity class contains the information of a single todo entry
+ * and the methods that are used to create new todo entries and to modify
+ * the information of an existing todo entry.
+ *
+ * @author Petri Kainulainen
+ */
+@Entity
+@EntityListeners(AuditingEntityListener.class)
+@NamedNativeQuery(name = "Todo.findBySearchTermNamedNative",
+ query="SELECT * FROM todos t WHERE " +
+ "LOWER(t.title) LIKE LOWER(CONCAT('%',:searchTerm, '%')) OR " +
+ "LOWER(t.description) LIKE LOWER(CONCAT('%',:searchTerm, '%')) " +
+ "ORDER BY t.title ASC",
+ resultClass = Todo.class
+)
+@NamedQuery(name = "Todo.findBySearchTermNamed",
+ query = "SELECT t FROM Todo t WHERE " +
+ "LOWER(t.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR " +
+ "LOWER(t.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) " +
+ "ORDER BY t.title ASC"
+)
+@Table(name = "todos")
+final class Todo {
+
+ static final int MAX_LENGTH_DESCRIPTION = 500;
+ static final int MAX_LENGTH_TITLE = 100;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.AUTO)
+ private Long id;
+
+ @Column(name = "created_by_user", nullable = false)
+ @CreatedBy
+ private String createdByUser;
+
+ @Column(name = "creation_time", nullable = false)
+ @Type(type = "org.jadira.usertype.dateandtime.threeten.PersistentZonedDateTime")
+ @CreatedDate
+ private ZonedDateTime creationTime;
+
+ @Column(name = "description", length = MAX_LENGTH_DESCRIPTION)
+ private String description;
+
+ @Column(name = "modified_by_user", nullable = false)
+ @LastModifiedBy
+ private String modifiedByUser;
+
+ @Column(name = "modification_time")
+ @Type(type = "org.jadira.usertype.dateandtime.threeten.PersistentZonedDateTime")
+ @LastModifiedDate
+ private ZonedDateTime modificationTime;
+
+ @Column(name = "title", nullable = false, length = MAX_LENGTH_TITLE)
+ private String title;
+
+ @Version
+ private long version;
+
+ /**
+ * Required by Hibernate.
+ */
+ private Todo() {}
+
+ private Todo(Builder builder) {
+ this.title = builder.title;
+ this.description = builder.description;
+ }
+
+ static Builder getBuilder() {
+ return new Builder();
+ }
+
+ Long getId() {
+ return id;
+ }
+
+ String getCreatedByUser() {
+ return createdByUser;
+ }
+
+ ZonedDateTime getCreationTime() {
+ return creationTime;
+ }
+
+ String getDescription() {
+ return description;
+ }
+
+ String getModifiedByUser() {
+ return modifiedByUser;
+ }
+
+ ZonedDateTime getModificationTime() {
+ return modificationTime;
+ }
+
+ String getTitle() {
+ return title;
+ }
+
+ long getVersion() {
+ return version;
+ }
+
+ void update(String newTitle, String newDescription) {
+ requireValidTitleAndDescription(newTitle, newDescription);
+
+ this.title = newTitle;
+ this.description = newDescription;
+ }
+
+ private void requireValidTitleAndDescription(String title, String description) {
+ notNull(title, "Title cannot be null.");
+ notEmpty(title, "Title cannot be empty.");
+ isTrue(title.length() <= MAX_LENGTH_TITLE,
+ "The maximum length of the title is <%d> characters.",
+ MAX_LENGTH_TITLE
+ );
+
+ isTrue((description == null) || (description.length() <= MAX_LENGTH_DESCRIPTION),
+ "The maximum length of the description is <%d> characters.",
+ MAX_LENGTH_DESCRIPTION
+ );
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this)
+ .append("createdByUser", this.createdByUser)
+ .append("creationTime", this.creationTime)
+ .append("description", this.description)
+ .append("id", this.id)
+ .append("modifiedByUser", this.modifiedByUser)
+ .append("modificationTime", this.modificationTime)
+ .append("title", this.title)
+ .append("version", this.version)
+ .toString();
+ }
+
+ /**
+ * This entity is so simple that you don't really need to use the builder pattern
+ * (use a constructor instead). I use the builder pattern here because it makes
+ * the code a bit more easier to read.
+ */
+ static class Builder {
+ private String description;
+ private String title;
+
+ private Builder() {}
+
+ Builder description(String description) {
+ this.description = description;
+ return this;
+ }
+
+ Builder title(String title) {
+ this.title = title;
+ return this;
+ }
+
+ Todo build() {
+ Todo build = new Todo(this);
+
+ build.requireValidTitleAndDescription(build.getTitle(), build.getDescription());
+
+ return build;
+ }
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoCrudService.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoCrudService.java
new file mode 100644
index 0000000..9e6fc09
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoCrudService.java
@@ -0,0 +1,49 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import java.util.List;
+
+/**
+ * This service provides CRUD operations for {@link net.petrikainulainen.springdata.jpa.todo.Todo}
+ * objects.
+ *
+ * @author Petri Kainulainen
+ */
+public interface TodoCrudService {
+
+ /**
+ * Creates a new todo entry.
+ * @param newTodoEntry The information of the created todo entry.
+ * @return The information of the created todo entry.
+ */
+ TodoDTO create(TodoDTO newTodoEntry);
+
+ /**
+ * Deletes a todo entry from the database.
+ * @param id The id of the deleted todo entry.
+ * @return The information of the deleted todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if the deleted todo entry is not found.
+ */
+ TodoDTO delete(Long id);
+
+ /**
+ * Finds all todo entries that are saved to the database.
+ * @return
+ */
+ List findAll();
+
+ /**
+ * Finds a todo entry by using the id given as a method parameter.
+ * @param id The id of the wanted todo entry.
+ * @return The information of the requested todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found with the given id.
+ */
+ TodoDTO findById(Long id);
+
+ /**
+ * Updates the information of an existing information.
+ * @param updatedTodoEntry The new information of an existing todo entry.
+ * @return The information of the updated todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found with the given id.
+ */
+ TodoDTO update(TodoDTO updatedTodoEntry);
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoDTO.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoDTO.java
new file mode 100644
index 0000000..7eea8d2
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoDTO.java
@@ -0,0 +1,101 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.hibernate.validator.constraints.NotEmpty;
+
+import javax.validation.constraints.Size;
+import java.time.ZonedDateTime;
+
+/**
+ * @author Petri Kainulainen
+ */
+public final class TodoDTO {
+
+ private String createdByUser;
+
+ private ZonedDateTime creationTime;
+
+ @Size(max = Todo.MAX_LENGTH_DESCRIPTION)
+ private String description;
+
+ private Long id;
+
+ private String modifiedByUser;
+
+ private ZonedDateTime modificationTime;
+
+ @NotEmpty
+ @Size(max = Todo.MAX_LENGTH_TITLE)
+ private String title;
+
+ public TodoDTO() {}
+
+ public String getCreatedByUser() {
+ return createdByUser;
+ }
+
+ public ZonedDateTime getCreationTime() {
+ return creationTime;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getModifiedByUser() {
+ return modifiedByUser;
+ }
+
+ public ZonedDateTime getModificationTime() {
+ return modificationTime;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setCreatedByUser(String createdByUser) {
+ this.createdByUser = createdByUser;
+ }
+
+ public void setCreationTime(ZonedDateTime creationTime) {
+ this.creationTime = creationTime;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public void setModifiedByUser(String modifiedByUser) {
+ this.modifiedByUser = modifiedByUser;
+ }
+
+ public void setModificationTime(ZonedDateTime modificationTime) {
+ this.modificationTime = modificationTime;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this)
+ .append("createdByUser", this.createdByUser)
+ .append("creationTime", this.creationTime)
+ .append("description", this.description)
+ .append("id", this.id)
+ .append("modifiedByUser", this.modifiedByUser)
+ .append("modificationTime", this.modificationTime)
+ .append("title", this.title)
+ .toString();
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoMapper.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoMapper.java
new file mode 100644
index 0000000..aa36e28
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoMapper.java
@@ -0,0 +1,31 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import java.util.List;
+
+import static java.util.stream.Collectors.toList;
+
+/**
+ * @author Petri Kainulainen
+ */
+final class TodoMapper {
+
+ static List mapEntitiesIntoDTOs(List entities) {
+ return entities.stream()
+ .map(TodoMapper::mapEntityIntoDTO)
+ .collect(toList());
+ }
+
+ static TodoDTO mapEntityIntoDTO(Todo entity) {
+ TodoDTO dto = new TodoDTO();
+
+ dto.setCreatedByUser(entity.getCreatedByUser());
+ dto.setCreationTime(entity.getCreationTime());
+ dto.setDescription(entity.getDescription());
+ dto.setId(entity.getId());
+ dto.setModifiedByUser(entity.getModifiedByUser());
+ dto.setModificationTime(entity.getModificationTime());
+ dto.setTitle(entity.getTitle());
+
+ return dto;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoNotFoundException.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoNotFoundException.java
new file mode 100644
index 0000000..63f6948
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoNotFoundException.java
@@ -0,0 +1,21 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+/**
+ * This exception is thrown when a todo entry is not found by
+ * using the given id.
+ *
+ * @author Petri Kainulainen
+ */
+public class TodoNotFoundException extends RuntimeException {
+
+ private final Long id;
+
+ public TodoNotFoundException(Long id) {
+ super();
+ this.id = id;
+ }
+
+ public Long getId() {
+ return id;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepository.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepository.java
new file mode 100644
index 0000000..b91d3ed
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepository.java
@@ -0,0 +1,25 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.springframework.data.repository.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * This repository provides CRUD operations for {@link net.petrikainulainen.springdata.jpa.todo.Todo}
+ * objects.
+ *
+ * @author Petri Kainulainen
+ */
+interface TodoRepository extends Repository, CustomTodoRepository {
+
+ void delete(Todo deleted);
+
+ List findAll();
+
+ Optional findOne(Long id);
+
+ void flush();
+
+ Todo save(Todo persisted);
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepositoryImpl.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepositoryImpl.java
new file mode 100644
index 0000000..3a49ab1
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoRepositoryImpl.java
@@ -0,0 +1,52 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.BeanPropertyRowMapper;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @author Petri Kainulainen
+ */
+@Repository
+final class TodoRepositoryImpl implements CustomTodoRepository {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TodoRepositoryImpl.class);
+
+ private static final String SEARCH_TODO_ENTRIES = "SELECT id, title FROM todos t WHERE " +
+ "LOWER(t.title) LIKE LOWER(CONCAT('%',:searchTerm, '%')) OR " +
+ "LOWER(t.description) LIKE LOWER(CONCAT('%',:searchTerm, '%')) " +
+ "ORDER BY t.title ASC";
+
+ private final NamedParameterJdbcTemplate jdbcTemplate;
+
+ @Autowired
+ TodoRepositoryImpl(NamedParameterJdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List findBySearchTerm(String searchTerm) {
+ LOGGER.info("Finding todo entries by using search term: {}", searchTerm);
+
+ Map queryParams = new HashMap<>();
+ queryParams.put("searchTerm", searchTerm);
+
+ List searchResults = jdbcTemplate.query(SEARCH_TODO_ENTRIES,
+ queryParams,
+ new BeanPropertyRowMapper<>(TodoSearchResultDTO.class)
+ );
+
+ LOGGER.info("Found {} todo entries", searchResults.size());
+
+ return searchResults;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchResultDTO.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchResultDTO.java
new file mode 100644
index 0000000..63dcd6c
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchResultDTO.java
@@ -0,0 +1,29 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+/**
+ * @author Petri Kainulainen
+ */
+public final class TodoSearchResultDTO {
+
+ private Long id;
+
+ private String title;
+
+ public TodoSearchResultDTO() {}
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchService.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchService.java
new file mode 100644
index 0000000..50efcd2
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/todo/TodoSearchService.java
@@ -0,0 +1,19 @@
+package net.petrikainulainen.springdata.jpa.todo;
+
+import java.util.List;
+
+/**
+ * This service provides finder methods for {@link net.petrikainulainen.springdata.jpa.todo.Todo} objects.
+ *
+ * @author Petri Kainulainen
+ */
+public interface TodoSearchService {
+
+ /**
+ * Finds todo entries whose title or description contains the given search term.
+ * This search is case insensitive.
+ * @param searchTerm The search term.
+ * @return A list of todo entries whose title or description contains the given search term.
+ */
+ public List findBySearchTerm(String searchTerm);
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoController.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoController.java
new file mode 100644
index 0000000..5205657
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoController.java
@@ -0,0 +1,116 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import net.petrikainulainen.springdata.jpa.todo.TodoCrudService;
+import net.petrikainulainen.springdata.jpa.todo.TodoDTO;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import java.util.List;
+
+/**
+ * This controller provides the public API that is used to perform
+ * CRUD operations for todo entries.
+ *
+ * @author Petri Kainulainen
+ */
+@RestController
+@RequestMapping("/api/todo")
+final class TodoController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TodoController.class);
+
+ private final TodoCrudService crudService;
+
+ @Autowired
+ TodoController(TodoCrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ /**
+ * Create a new todo entry.
+ * @param newTodoEntry The information of the created todo entry.
+ * @return The information of the created todo entry.
+ */
+ @RequestMapping(method = RequestMethod.POST)
+ @ResponseStatus(HttpStatus.CREATED)
+ TodoDTO create(@RequestBody @Valid TodoDTO newTodoEntry) {
+ LOGGER.info("Creating a new todo entry by using information: {}", newTodoEntry);
+
+ TodoDTO created = crudService.create(newTodoEntry);
+ LOGGER.info("Created a new todo entry: {}", created);
+
+ return created;
+ }
+
+ /**
+ * Deletes a todo entry.
+ * @param id The id of the deleted todo entry.
+ * @return The information of the deleted todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if the deleted todo entry is not found.
+ */
+ @RequestMapping(value = "{id}", method = RequestMethod.DELETE)
+ public TodoDTO delete(@PathVariable("id") Long id) {
+ LOGGER.info("Deleting a todo entry with id: {}", id);
+
+ TodoDTO deleted = crudService.delete(id);
+ LOGGER.info("Deleted the todo entry: {}", deleted);
+
+ return deleted;
+ }
+
+ /**
+ * Finds all todo entries.
+ *
+ * @return The information of all todo entries.
+ */
+ @RequestMapping(method = RequestMethod.GET)
+ List findAll() {
+ LOGGER.info("Finding all todo entries");
+
+ List todoEntries = crudService.findAll();
+ LOGGER.info("Found {} todo entries.", todoEntries.size());
+
+ return todoEntries;
+ }
+
+ /**
+ * Finds a single todo entry.
+ * @param id The id of the requested todo entry.
+ * @return The information of the requested todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found by using the given id.
+ */
+ @RequestMapping(value = "{id}", method = RequestMethod.GET)
+ TodoDTO findById(@PathVariable("id") Long id) {
+ LOGGER.info("Finding todo entry by using id: {}", id);
+
+ TodoDTO todoEntry = crudService.findById(id);
+ LOGGER.info("Found todo entry: {}", todoEntry);
+
+ return todoEntry;
+ }
+
+ /**
+ * Updates the information of an existing todo entry.
+ * @param updatedTodoEntry The new information of the updated todo entry.
+ * @return The updated information of the updated todo entry.
+ * @throws net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException if no todo entry is found by using the given id.
+ */
+ @RequestMapping(value = "{id}", method = RequestMethod.PUT)
+ TodoDTO update(@RequestBody @Valid TodoDTO updatedTodoEntry) {
+ LOGGER.info("Updating the information of a todo entry by using information: {}", updatedTodoEntry);
+
+ TodoDTO updated = crudService.update(updatedTodoEntry);
+ LOGGER.info("Updated the information of the todo entrY: {}", updated);
+
+ return updated;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoSearchController.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoSearchController.java
new file mode 100644
index 0000000..270565e
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/TodoSearchController.java
@@ -0,0 +1,48 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import net.petrikainulainen.springdata.jpa.todo.TodoSearchResultDTO;
+import net.petrikainulainen.springdata.jpa.todo.TodoSearchService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * This controller provides the public API that is used to find todo entries by using
+ * different search criteria.
+ *
+ * @author Petri Kainulainen
+ */
+@RestController
+final class TodoSearchController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TodoSearchController.class);
+
+ private final TodoSearchService searchService;
+
+ @Autowired
+ public TodoSearchController(TodoSearchService searchService) {
+ this.searchService = searchService;
+ }
+
+ /**
+ * Finds todo entries whose title or description contains the given search term. This
+ * search is case insensitive.
+ * @param searchTerm The used search term.
+ * @return
+ */
+ @RequestMapping(value = "/api/todo/search", method = RequestMethod.GET)
+ public List findBySearchTerm(@RequestParam("searchTerm") String searchTerm) {
+ LOGGER.info("Finding todo entries by search term: {}", searchTerm);
+
+ List searchResults = searchService.findBySearchTerm(searchTerm);
+ LOGGER.info("Found {} todo entries", searchResults.size());
+
+ return searchResults;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTO.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTO.java
new file mode 100644
index 0000000..b02059e
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTO.java
@@ -0,0 +1,35 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notEmpty;
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notNull;
+
+/**
+ * This class contains the information of an error that occurred when the API tried
+ * to perform the operation requested by the client.
+ *
+ * @author Petri Kainulainen
+ */
+final class ErrorDTO {
+
+ private final String code;
+ private final String message;
+
+ ErrorDTO(String code, String message) {
+ notNull(code, "Code cannot be null.");
+ notEmpty(code, "Code cannot be empty.");
+
+ notNull(message, "Message cannot be null.");
+ notEmpty(message, "Message cannot be empty");
+
+ this.code = code;
+ this.message = message;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTO.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTO.java
new file mode 100644
index 0000000..44234a5
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTO.java
@@ -0,0 +1,35 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notEmpty;
+import static net.petrikainulainen.springdata.jpa.common.PreCondition.notNull;
+
+/**
+ * This class contains the information of a single field error.
+ *
+ * @author Petri Kainulainen
+ */
+final class FieldErrorDTO {
+
+ private final String field;
+
+ private final String message;
+
+ FieldErrorDTO(String field, String message) {
+ notNull(field, "Field cannot be null.");
+ notEmpty(field, "Field cannot be empty");
+
+ notNull(message, "Message cannot be null.");
+ notEmpty(message, "Message cannot be empty.");
+
+ this.field = field;
+ this.message = message;
+ }
+
+ public String getField() {
+ return field;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandler.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandler.java
new file mode 100644
index 0000000..5ad9e9b
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandler.java
@@ -0,0 +1,106 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.MessageSource;
+import org.springframework.context.MessageSourceResolvable;
+import org.springframework.context.support.DefaultMessageSourceResolvable;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.BindingResult;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * This class handles the exceptions thrown by our REST API.
+ *
+ * @author Petri Kainulainen
+ */
+@ControllerAdvice
+public final class RestErrorHandler {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestErrorHandler.class);
+
+ private static final String ERROR_CODE_TODO_ENTRY_NOT_FOUND = "error.todo.entry.not.found";
+
+ private final MessageSource messageSource;
+
+ @Autowired
+ public RestErrorHandler(MessageSource messageSource) {
+ this.messageSource = messageSource;
+ }
+
+ /**
+ * Processes an error that occurs when the requested todo entry is not found.
+ * @param ex The exception that was thrown when the todo entry was not found.
+ * @param currentLocale The current locale.
+ * @return An error object that contains the error code and message.
+ */
+ @ExceptionHandler(TodoNotFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ @ResponseBody
+ ErrorDTO handleTodoEntryNotFound(TodoNotFoundException ex, Locale currentLocale) {
+ LOGGER.error("Todo entry was not found by using id: {}", ex.getId());
+
+ MessageSourceResolvable errorMessageRequest = createSingleErrorMessageRequest(
+ ERROR_CODE_TODO_ENTRY_NOT_FOUND,
+ ex.getId()
+ );
+
+ String errorMessage = messageSource.getMessage(errorMessageRequest, currentLocale);
+ return new ErrorDTO(HttpStatus.NOT_FOUND.name(), errorMessage);
+ }
+
+ private DefaultMessageSourceResolvable createSingleErrorMessageRequest(String errorMessageCode, Object... params) {
+ return new DefaultMessageSourceResolvable(new String[] {errorMessageCode}, params);
+ }
+
+ /**
+ * Processes an error that occurs when the validation of an object fails.
+ *
+ * @param ex The exception that was thrown when the validation failed.
+ * @return An error object that describes all validation errors.
+ */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ @ResponseBody
+ public ValidationErrorDTO handleValidationErrors(MethodArgumentNotValidException ex, Locale currentLocale) {
+ BindingResult result = ex.getBindingResult();
+ List fieldErrors = result.getFieldErrors();
+ LOGGER.error("Found {} validation errors", fieldErrors.size());
+
+ return constructValidationErrors(fieldErrors, currentLocale);
+ }
+
+ private ValidationErrorDTO constructValidationErrors(List fieldErrors, Locale currentLocale) {
+ ValidationErrorDTO dto = new ValidationErrorDTO();
+
+ for (FieldError fieldError: fieldErrors) {
+ String localizedErrorMessage = getValidationErrorMessage(fieldError, currentLocale);
+ dto.addFieldError(fieldError.getField(), localizedErrorMessage);
+ }
+
+ return dto;
+ }
+
+ private String getValidationErrorMessage(FieldError fieldError, Locale currentLocale) {
+ String localizedErrorMessage = messageSource.getMessage(fieldError, currentLocale);
+
+ //If the message was not found, return the most accurate field error code instead.
+ //You can remove this check if you prefer to get the default error message.
+ if (localizedErrorMessage.equals(fieldError.getDefaultMessage())) {
+ String[] fieldErrorCodes = fieldError.getCodes();
+ localizedErrorMessage = fieldErrorCodes[0];
+ }
+
+ return localizedErrorMessage;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTO.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTO.java
new file mode 100644
index 0000000..8355c7b
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTO.java
@@ -0,0 +1,36 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import org.springframework.http.HttpStatus;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This class contains the information of validation errors that are found
+ * from a controller method parameter that is annotated with the
+ * {@link javax.validation.Valid} annotation.
+ *
+ * @author Petri Kainulainen
+ */
+final class ValidationErrorDTO {
+
+ private final String code = HttpStatus.BAD_REQUEST.name();
+
+ private final List fieldErrors = new ArrayList<>();
+
+ ValidationErrorDTO() {
+ }
+
+ void addFieldError(String field, String message) {
+ FieldErrorDTO error = new FieldErrorDTO(field, message);
+ fieldErrors.add(error);
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public List getFieldErrors() {
+ return fieldErrors;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfHeaderFilter.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfHeaderFilter.java
new file mode 100644
index 0000000..141a948
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfHeaderFilter.java
@@ -0,0 +1,46 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This filter reads the {@link org.springframework.security.web.csrf.CsrfToken} from the {@link HttpServletRequest} and
+ * sets its content to the {@link HttpServletResponse} headers.
+ *
+ * I borrowed this idea from this StackOverflow question.
+ *
+ * @author Petri Kainulainen
+ */
+public class CsrfHeaderFilter extends OncePerRequestFilter {
+
+ private static final Logger LOGGER = org.slf4j.LoggerFactory.getLogger(CsrfHeaderFilter.class);
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+
+ LOGGER.trace("Reading CSRF token from the request.");
+
+ CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
+
+ if (token != null) {
+ LOGGER.trace("CSRF token was found. Creating HTTP response headers.");
+ response.setHeader("X-CSRF-HEADER", token.getHeaderName());
+ response.setHeader("X-CSRF-PARAM", token.getParameterName());
+ response.setHeader("X-CSRF-TOKEN", token.getToken());
+ }
+ else {
+ LOGGER.trace("CSRF Token was not found. Doing nothing.");
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfTokenController.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfTokenController.java
new file mode 100644
index 0000000..f6e70cb
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/CsrfTokenController.java
@@ -0,0 +1,21 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RestController
+public class CsrfTokenController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(CsrfTokenController.class);
+
+ @RequestMapping(value = "/api/csrf", method = RequestMethod.HEAD)
+ public void getCsrfToken() {
+ LOGGER.info("Getting CSRF token.");
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationEntryPoint.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationEntryPoint.java
new file mode 100644
index 0000000..887e25b
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationEntryPoint.java
@@ -0,0 +1,28 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This authentication entry point returns the HTTP status code 401.
+ * @author Petri Kainulainen
+ */
+public final class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestAuthenticationEntryPoint.class);
+
+ @Override
+ public void commence(HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException authException) throws IOException, ServletException {
+ LOGGER.info("Authentication required. Returning HTTP status code 401.");
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationFailureHandler.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationFailureHandler.java
new file mode 100644
index 0000000..daf635b
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationFailureHandler.java
@@ -0,0 +1,28 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This authentication failure handler returns the HTTP status code 403.
+ * @author Petri Kainulainen
+ */
+public final class RestAuthenticationFailureHandler implements AuthenticationFailureHandler {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestAuthenticationFailureHandler.class);
+
+ @Override
+ public void onAuthenticationFailure(HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException e) throws IOException, ServletException {
+ LOGGER.info("Authentication failed with message: {}", e.getMessage());
+ response.sendError(HttpServletResponse.SC_FORBIDDEN, "Authentication failed.");
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationSuccessHandler.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationSuccessHandler.java
new file mode 100644
index 0000000..ff84785
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/RestAuthenticationSuccessHandler.java
@@ -0,0 +1,30 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * This authentication success handler returns the information of the authenticated
+ * user as JSON.
+ *
+ * @author Petri Kainulainen
+ */
+public final class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RestAuthenticationSuccessHandler.class);
+
+ @Override
+ public void onAuthenticationSuccess(HttpServletRequest request,
+ HttpServletResponse response,
+ Authentication authentication) throws IOException, ServletException {
+ LOGGER.info("Authentication was successful");
+ response.sendRedirect(response.encodeRedirectURL("/api/authenticated-user"));
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserController.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserController.java
new file mode 100644
index 0000000..ef7959d
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserController.java
@@ -0,0 +1,45 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * This controller provides the public API that is used to return the information
+ * of the authenticated user.
+ *
+ * @author Petri Kainulainen
+ */
+@RestController
+final class UserController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(UserController.class);
+
+ /**
+ * Returns the information of the authenticated user as JSON. The returned information
+ * contains the username and the user role of the authenticated user.
+ *
+ * @param authenticatedUser The information of the authenticated user.
+ * @return
+ */
+ @RequestMapping(value = "/api/authenticated-user", method = RequestMethod.GET)
+ public UserDTO getAuthenticatedUser(@AuthenticationPrincipal User authenticatedUser) {
+ LOGGER.info("Getting authenticated user.");
+
+ if (authenticatedUser == null) {
+ //If anonymous users can access this controller method, someone has changed
+ //the security configuration and it must be fixed.
+ LOGGER.error("Authenticated user is not found.");
+ throw new AccessDeniedException("Anonymous users cannot request the information of the authenticated user.");
+ }
+ else {
+ LOGGER.info("User with username: {} is authenticated", authenticatedUser.getUsername());
+ return new UserDTO(authenticatedUser.getUsername(), authenticatedUser.getAuthorities());
+ }
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserDTO.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserDTO.java
new file mode 100644
index 0000000..92b99ed
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserDTO.java
@@ -0,0 +1,35 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import net.petrikainulainen.springdata.jpa.common.PreCondition;
+import org.springframework.security.core.GrantedAuthority;
+
+import java.util.Collection;
+
+/**
+ * This class contains the information of the authenticated user.
+ *
+ * @author Petri Kainulainen
+ */
+public final class UserDTO {
+
+ private final String username;
+
+ private final UserRole role;
+
+ UserDTO(String username, Collection authorities) {
+ PreCondition.isTrue(!username.isEmpty(), "Username cannot be empty.");
+ PreCondition.isTrue(authorities.size() == 1, "User must have only one granted authority.");
+ this.username = username;
+
+ GrantedAuthority authority = authorities.iterator().next();
+ this.role = UserRole.valueOf(authority.getAuthority());
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public UserRole getRole() {
+ return role;
+ }
+}
diff --git a/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserRole.java b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserRole.java
new file mode 100644
index 0000000..8b3e6a6
--- /dev/null
+++ b/custom-method-single-repo/src/main/java/net/petrikainulainen/springdata/jpa/web/security/UserRole.java
@@ -0,0 +1,8 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+/**
+ * @author Petri Kainulainen
+ */
+enum UserRole {
+ ROLE_USER
+}
diff --git a/custom-method-single-repo/src/main/resources/META-INF/jpa-named-queries.properties b/custom-method-single-repo/src/main/resources/META-INF/jpa-named-queries.properties
new file mode 100644
index 0000000..97d737e
--- /dev/null
+++ b/custom-method-single-repo/src/main/resources/META-INF/jpa-named-queries.properties
@@ -0,0 +1,2 @@
+Todo.findBySearchTermNamedFile=SELECT t FROM Todo t WHERE LOWER(t.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR LOWER(t.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) ORDER BY t.title ASC
+Todo.findBySearchTermNamedNativeFile=SELECT * FROM todos t WHERE LOWER(t.title) LIKE LOWER(CONCAT('%',:searchTerm, '%')) OR LOWER(t.description) LIKE LOWER(CONCAT('%',:searchTerm, '%')) ORDER BY t.title ASC
\ No newline at end of file
diff --git a/custom-method-single-repo/src/main/resources/META-INF/orm.xml b/custom-method-single-repo/src/main/resources/META-INF/orm.xml
new file mode 100644
index 0000000..cc2bf80
--- /dev/null
+++ b/custom-method-single-repo/src/main/resources/META-INF/orm.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ SELECT t FROM Todo t WHERE LOWER(t.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR LOWER(t.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) ORDER BY t.title ASC
+
+
+
+ SELECT * FROM todos t WHERE LOWER(t.title) LIKE LOWER(CONCAT('%',:searchTerm, '%')) OR LOWER(t.description) LIKE LOWER(CONCAT('%',:searchTerm, '%')) ORDER BY t.title ASC
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/src/main/resources/application.properties b/custom-method-single-repo/src/main/resources/application.properties
new file mode 100644
index 0000000..7ac8298
--- /dev/null
+++ b/custom-method-single-repo/src/main/resources/application.properties
@@ -0,0 +1,12 @@
+#Database Configuration
+db.driver=org.h2.Driver
+db.url=jdbc:h2:mem:datajpa
+db.username=sa
+db.password=
+
+#Hibernate Configuration
+hibernate.dialect=org.hibernate.dialect.H2Dialect
+hibernate.format_sql=true
+hibernate.hbm2ddl.auto=create-drop
+hibernate.ejb.naming_strategy=org.hibernate.cfg.ImprovedNamingStrategy
+hibernate.show_sql=false
\ No newline at end of file
diff --git a/custom-method-single-repo/src/main/resources/applicationContext-persistence.xml b/custom-method-single-repo/src/main/resources/applicationContext-persistence.xml
new file mode 100644
index 0000000..e9149e9
--- /dev/null
+++ b/custom-method-single-repo/src/main/resources/applicationContext-persistence.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${hibernate.dialect}
+
+
+ ${hibernate.hbm2ddl.auto}
+
+
+ ${hibernate.ejb.naming_strategy}
+
+
+ ${hibernate.show_sql}
+
+
+ ${hibernate.format_sql}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/src/main/resources/applicationContext-web.xml b/custom-method-single-repo/src/main/resources/applicationContext-web.xml
new file mode 100644
index 0000000..db48af6
--- /dev/null
+++ b/custom-method-single-repo/src/main/resources/applicationContext-web.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+ WRITE_DATES_AS_TIMESTAMPS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/src/main/resources/applicationContext.xml b/custom-method-single-repo/src/main/resources/applicationContext.xml
new file mode 100644
index 0000000..b9ee424
--- /dev/null
+++ b/custom-method-single-repo/src/main/resources/applicationContext.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom-method-single-repo/src/main/resources/i18n/messages.properties b/custom-method-single-repo/src/main/resources/i18n/messages.properties
new file mode 100644
index 0000000..0e376f5
--- /dev/null
+++ b/custom-method-single-repo/src/main/resources/i18n/messages.properties
@@ -0,0 +1,5 @@
+error.todo.entry.not.found=No todo entry was found by using id: {0}
+
+NotEmpty.todoDTO.title=The title cannot be empty
+Size.todoDTO.description=The maximum length of description is 500 characters
+Size.todoDTO.title=The maximum length of title is 100 characters
\ No newline at end of file
diff --git a/custom-method-single-repo/src/main/resources/integration-test.properties b/custom-method-single-repo/src/main/resources/integration-test.properties
new file mode 100644
index 0000000..3605c55
--- /dev/null
+++ b/custom-method-single-repo/src/main/resources/integration-test.properties
@@ -0,0 +1,14 @@
+#Database Configuration
+db.driver=org.h2.Driver
+db.url=jdbc:h2:mem:datajpa;DB_CLOSE_ON_EXIT=FALSE
+db.username=sa
+db.password=
+
+#Hibernate Configuration
+hibernate.dialect=org.hibernate.dialect.H2Dialect
+hibernate.format_sql=true
+hibernate.hbm2ddl.auto=create-drop
+hibernate.ejb.naming_strategy=org.hibernate.cfg.ImprovedNamingStrategy
+hibernate.show_sql=false
+
+test.reset.sql.template=ALTER TABLE %s ALTER COLUMN id RESTART WITH 1
\ No newline at end of file
diff --git a/tutorial-part-six/src/main/resources/log4j.properties b/custom-method-single-repo/src/main/resources/log4j.properties
similarity index 75%
rename from tutorial-part-six/src/main/resources/log4j.properties
rename to custom-method-single-repo/src/main/resources/log4j.properties
index 5ad34eb..668d97a 100644
--- a/tutorial-part-six/src/main/resources/log4j.properties
+++ b/custom-method-single-repo/src/main/resources/log4j.properties
@@ -3,4 +3,6 @@ log4j.appender.Stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.Stdout.layout.conversionPattern=%-5p - %-26.26c{1} - %m\n
log4j.rootLogger=DEBUG,Stdout
-log4j.logger.org.springframework=DEBUG
+
+log4j.logger.org.hibernate=INFO
+log4j.logger.org.springframework=INFO
\ No newline at end of file
diff --git a/custom-method-single-repo/src/main/webapp/WEB-INF/jsp/frontend/client.jsp b/custom-method-single-repo/src/main/webapp/WEB-INF/jsp/frontend/client.jsp
new file mode 100644
index 0000000..84158d0
--- /dev/null
+++ b/custom-method-single-repo/src/main/webapp/WEB-INF/jsp/frontend/client.jsp
@@ -0,0 +1,74 @@
+<%@ page contentType="text/html;charset=UTF-8" language="java" session="false" %>
+<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ * The new Java 8 date objects are serialized in standard
+ * ISO-8601 string representation.
+ *
+ *
+ *
+ * @return
+ */
+ static MappingJackson2HttpMessageConverter jacksonDateTimeConverter() {
+ ObjectMapper objectMapper = new ObjectMapper();
+
+ objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ objectMapper.registerModule(new JSR310Module());
+
+ MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
+ converter.setObjectMapper(objectMapper);
+ return converter;
+ }
+
+ /**
+ * This method ensures that the {@link RestErrorHandler} class
+ * is used to handle the exceptions thrown by the tested controller. I borrowed this idea from
+ * this StackOverflow answer.
+ *
+ * @return an error handler component that delegates relevant exceptions forward to the {@link RestErrorHandler} class.
+ */
+ static ExceptionHandlerExceptionResolver restErrorHandler(MessageSource messageSource) {
+ final ExceptionHandlerExceptionResolver exceptionResolver = new ExceptionHandlerExceptionResolver() {
+ @Override
+ protected ServletInvocableHandlerMethod getExceptionHandlerMethod(final HandlerMethod handlerMethod,
+ final Exception exception) {
+ Method method = new ExceptionHandlerMethodResolver(RestErrorHandler.class).resolveMethod(exception);
+ if (method != null) {
+ return new ServletInvocableHandlerMethod(new RestErrorHandler(messageSource), method);
+ }
+ return super.getExceptionHandlerMethod(handlerMethod, exception);
+ }
+ };
+ exceptionResolver.setMessageConverters(Arrays.asList(jacksonDateTimeConverter()));
+ exceptionResolver.afterPropertiesSet();
+ return exceptionResolver;
+ }
+
+ /**
+ * This method returns a {@link org.springframework.web.method.support.HandlerMethodArgumentResolver} that can
+ * construct {@link org.springframework.data.domain.Sort} objects by using the request params of the
+ * incoming request.
+ * @return
+ */
+ static SortHandlerMethodArgumentResolver sortArgumentResolver() {
+ return new SortHandlerMethodArgumentResolver();
+ }
+
+ /**
+ * This method creates a validator object that adds support for bean validation API 1.0 and 1.1.
+ *
+ * @return The created validator object.
+ */
+ static LocalValidatorFactoryBean validator() {
+ return new LocalValidatorFactoryBean();
+ }
+}
diff --git a/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestConstants.java b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestConstants.java
new file mode 100644
index 0000000..2246b2b
--- /dev/null
+++ b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestConstants.java
@@ -0,0 +1,32 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import org.springframework.http.MediaType;
+
+import java.nio.charset.Charset;
+
+/**
+ * @author Petri Kainulainen
+ */
+public final class WebTestConstants {
+
+ public static final MediaType APPLICATION_JSON_UTF8 = new MediaType(MediaType.APPLICATION_JSON.getType(),
+ MediaType.APPLICATION_JSON.getSubtype(),
+ Charset.forName("utf8")
+ );
+
+ static final String ERROR_CODE_TODO_ENTRY_NOT_FOUND = "NOT_FOUND";
+ static final String ERROR_CODE_VALIDATION_FAILED = "BAD_REQUEST";
+
+ static final String FIELD_NAME_DESCRIPTION = "description";
+ static final String FIELD_NAME_TITLE = "title";
+
+ static final int MAX_LENGTH_DESCRIPTION = 500;
+ static final int MAX_LENGTH_TITLE = 100;
+
+ static final String REQUEST_PARAM_SEARCH_TERM = "searchTerm";
+
+ /**
+ * Prevents instantiation.
+ */
+ private WebTestConstants() {}
+}
diff --git a/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestUtil.java b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestUtil.java
new file mode 100644
index 0000000..9340fe6
--- /dev/null
+++ b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/WebTestUtil.java
@@ -0,0 +1,29 @@
+package net.petrikainulainen.springdata.jpa.web;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+
+/**
+ * @author Petri Kainulainen
+ */
+final class WebTestUtil {
+
+ /**
+ * Prevents instantiation
+ */
+ private WebTestUtil() {}
+
+ /**
+ * Transforms an object into JSON and returns the JSON as a byte array.
+ * @param object The object that is transformed into JSON.
+ * @return The JSON representation of an object as a byte array.
+ * @throws IOException
+ */
+ static byte[] convertObjectToJsonBytes(Object object) throws IOException {
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ return mapper.writeValueAsBytes(object);
+ }
+}
diff --git a/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTOTest.java b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTOTest.java
new file mode 100644
index 0000000..528a552
--- /dev/null
+++ b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ErrorDTOTest.java
@@ -0,0 +1,61 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.web.error.ErrorDTO;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class ErrorDTOTest {
+
+ private static final String CODE = "code";
+ private static final String MESSAGE = "message";
+
+ public class CreateNew {
+
+ public class WhenCodeIsInvalid {
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowExceptionWhenCodeIsNull() {
+ new ErrorDTO(null, MESSAGE);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowExceptionWhenCodeIsEmpty() {
+ new ErrorDTO("", MESSAGE);
+ }
+ }
+
+ public class WhenMessageIsInvalid {
+
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowExceptionWhenMessageIsNull() {
+ new ErrorDTO(CODE, null);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowExceptionWhenMessageIsEmpty() {
+ new ErrorDTO(CODE, "");
+ }
+ }
+
+ public class WhenCodeAndMessageAreValid {
+
+ @Test
+ public void shouldCreateNewObjectAndSetCode() {
+ ErrorDTO error = new ErrorDTO(CODE, MESSAGE);
+ assertThat(error.getCode()).isEqualTo(CODE);
+ }
+
+ @Test
+ public void shouldCreateNewObjectAndSetMessage() {
+ ErrorDTO error = new ErrorDTO(CODE, MESSAGE);
+ assertThat(error.getMessage()).isEqualTo(MESSAGE);
+ }
+ }
+ }
+}
diff --git a/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTOTest.java b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTOTest.java
new file mode 100644
index 0000000..25fc6bf
--- /dev/null
+++ b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/FieldErrorDTOTest.java
@@ -0,0 +1,64 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.web.error.FieldErrorDTO;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class FieldErrorDTOTest {
+
+ private static final String FIELD = "field";
+ private static final String MESSAGE = "message";
+
+ public class CreateNew {
+
+ public class WhenFieldIsInvalid {
+
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowExceptionWhenFieldIsNull() {
+ new FieldErrorDTO(null, MESSAGE);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowExceptionWhenFieldIsEmpty() {
+ new FieldErrorDTO("", MESSAGE);
+ }
+ }
+
+ public class WhenMessageIsInvalid {
+
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowExceptionWhenMessageIsNull() {
+ new FieldErrorDTO(FIELD, null);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowExceptionWhenMessageIsEmpty() {
+ new FieldErrorDTO(FIELD, "");
+ }
+ }
+
+ public class WhenFieldAndMessageAreValid {
+
+ @Test
+ public void shouldCreateNewObjectAndSetField() {
+ FieldErrorDTO fieldError = new FieldErrorDTO(FIELD, MESSAGE);
+
+ assertThat(fieldError.getField()).isEqualTo(FIELD);
+ }
+
+ @Test
+ public void shouldCreateNewObjectAndSetMessage() {
+ FieldErrorDTO fieldError = new FieldErrorDTO(FIELD, MESSAGE);
+
+ assertThat(fieldError.getMessage()).isEqualTo(MESSAGE);
+ }
+ }
+ }
+}
diff --git a/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandlerTest.java b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandlerTest.java
new file mode 100644
index 0000000..5ff8f34
--- /dev/null
+++ b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/RestErrorHandlerTest.java
@@ -0,0 +1,242 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.todo.TodoDTO;
+import net.petrikainulainen.springdata.jpa.todo.TodoNotFoundException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.context.MessageSource;
+import org.springframework.context.MessageSourceResolvable;
+import org.springframework.core.MethodParameter;
+import org.springframework.validation.BeanPropertyBindingResult;
+import org.springframework.validation.BindingResult;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+
+import java.util.List;
+import java.util.Locale;
+
+import static info.solidsoft.mockito.java8.AssertionMatcher.assertArg;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Matchers.isA;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class RestErrorHandlerTest {
+
+ private static final Locale CURRENT_LOCALE = Locale.US;
+
+ private static final Long TODO_ID = 99L;
+
+ private MessageSource messageSource;
+
+ private RestErrorHandler errorHandler;
+
+ @Before
+ public void setUp() {
+ messageSource = mock(MessageSource.class);
+ this.errorHandler = new RestErrorHandler(messageSource);
+ }
+
+ public class HandleTodoEntryNotFound {
+
+ private static final String ERROR_CODE_TODO_ENTRY_NOT_FOUND = "NOT_FOUND";
+
+ private static final String ERROR_MESSAGE_CODE_TODO_ENTRY_NOT_FOUND = "error.todo.entry.not.found";
+ private static final String ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND = "No todo entry was found by using id: 99";
+
+ @Before
+ public void returnErrorMessageNotFound() {
+ given(messageSource.getMessage(
+ isA(MessageSourceResolvable.class),
+ isA(Locale.class))
+ ).willReturn(ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND);
+ }
+
+ @Test
+ public void shouldFindErrorMessageByUsingCurrentLocale() {
+ errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
+
+ verify(messageSource, times(1)).getMessage(isA(MessageSourceResolvable.class), eq(CURRENT_LOCALE));
+ }
+
+ @Test
+ public void shouldFindErrorMessageByUsingCorrectId() {
+ errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
+
+ verify(messageSource, times(1)).getMessage(
+ assertArg(messageRequest -> assertThat(messageRequest.getArguments())
+ .containsOnly(TODO_ID)
+ ),
+ eq(CURRENT_LOCALE)
+ );
+ }
+
+ @Test
+ public void shouldFindErrorMessageByUsingCorrectMessageCode() {
+ errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
+
+ verify(messageSource, times(1)).getMessage(
+ assertArg(messageRequest -> assertThat(messageRequest.getCodes())
+ .containsOnly(ERROR_MESSAGE_CODE_TODO_ENTRY_NOT_FOUND)
+ ),
+ eq(CURRENT_LOCALE)
+ );
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectErrorCode() {
+ ErrorDTO error = errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
+
+ assertThat(error.getCode()).isEqualTo(ERROR_CODE_TODO_ENTRY_NOT_FOUND);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectMessage() {
+ ErrorDTO error = errorHandler.handleTodoEntryNotFound(new TodoNotFoundException(TODO_ID), CURRENT_LOCALE);
+
+ assertThat(error.getMessage()).isEqualTo(ERROR_MESSAGE_TODO_ENTRY_NOT_FOUND);
+ }
+ }
+
+ public class HandleValidationErrors {
+
+ private static final String ERROR_CODE_VALIDATION_ERROR = "BAD_REQUEST";
+ private static final String ERROR_MESSAGE_VALIDATION_ERROR = "validationError";
+
+ private static final String FIELD_DEFAULT_MESSAGE = "DefaultMessage";
+ private static final String FIELD_WITH_VALIDATION_ERROR = "field";
+ private static final String OBJECT_WITH_VALIDATION_ERROR = "todoDTO";
+
+ private static final String VALIDATION_ERROR_CODE_ACCURATE = "Error";
+ private static final String VALIDATION_ERROR_CODE_LESS_ACCURATE = "Maybe";
+
+ public class WhenOneValidationErrorIsFound {
+
+ public class WhenMessageIsFound {
+
+ private MethodArgumentNotValidException ex;
+
+ @Before
+ public void createValidationErrorAndReturnErrorMessage() {
+ FieldError fieldError = new FieldErrorBuilder()
+ .defaultMessage(FIELD_DEFAULT_MESSAGE)
+ .fieldName(FIELD_WITH_VALIDATION_ERROR)
+ .build();
+ given(messageSource.getMessage(fieldError, CURRENT_LOCALE)).willReturn(ERROR_MESSAGE_VALIDATION_ERROR);
+
+ ex = createExceptionWithFieldErrors(fieldError);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectCode() {
+ ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
+
+ assertThat(validationErrors.getCode()).isEqualTo(ERROR_CODE_VALIDATION_ERROR);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectFieldErrorWithMessage() {
+ ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
+
+ List fieldErrors = validationErrors.getFieldErrors();
+ assertThat(fieldErrors).hasSize(1);
+
+ FieldErrorDTO actualFieldError = fieldErrors.iterator().next();
+ assertThat(actualFieldError.getField()).isEqualTo(FIELD_WITH_VALIDATION_ERROR);
+ assertThat(actualFieldError.getMessage()).isEqualTo(ERROR_MESSAGE_VALIDATION_ERROR);
+ }
+ }
+
+ public class WhenMessageIsNotFound {
+
+ private MethodArgumentNotValidException ex;
+
+ @Before
+ public void createValidationErrorAndReturnDefaultErrorMessage() {
+ FieldError fieldError = new FieldErrorBuilder()
+ .defaultMessage(FIELD_DEFAULT_MESSAGE)
+ .errorCodes(VALIDATION_ERROR_CODE_ACCURATE, VALIDATION_ERROR_CODE_LESS_ACCURATE)
+ .fieldName(FIELD_WITH_VALIDATION_ERROR)
+ .build();
+ given(messageSource.getMessage(fieldError, CURRENT_LOCALE)).willReturn(FIELD_DEFAULT_MESSAGE);
+
+ ex = createExceptionWithFieldErrors(fieldError);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasCorrectCode() {
+ ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
+
+ assertThat(validationErrors.getCode()).isEqualTo(ERROR_CODE_VALIDATION_ERROR);
+ }
+
+ @Test
+ public void shouldReturnErrorThatHasFieldErrorWithMostAccurateFieldErrorCode() {
+ ValidationErrorDTO validationErrors = errorHandler.handleValidationErrors(ex, CURRENT_LOCALE);
+
+ List fieldErrors = validationErrors.getFieldErrors();
+ assertThat(fieldErrors).hasSize(1);
+
+ FieldErrorDTO actualFieldError = fieldErrors.iterator().next();
+ assertThat(actualFieldError.getField()).isEqualTo(FIELD_WITH_VALIDATION_ERROR);
+ assertThat(actualFieldError.getMessage()).isEqualTo(VALIDATION_ERROR_CODE_ACCURATE);
+ }
+ }
+ }
+
+ private MethodArgumentNotValidException createExceptionWithFieldErrors(FieldError... fieldErrors) {
+ BindingResult bindingResult = new BeanPropertyBindingResult(new TodoDTO(), OBJECT_WITH_VALIDATION_ERROR);
+
+ for (FieldError fieldError: fieldErrors) {
+ bindingResult.addError(fieldError);
+ }
+
+ return new MethodArgumentNotValidException(mock(MethodParameter.class), bindingResult);
+ }
+
+
+ private final class FieldErrorBuilder {
+
+ private String defaultMessage;
+ private String[] errorCodes;
+ private String fieldName;
+
+ private FieldErrorBuilder() {}
+
+ private FieldErrorBuilder defaultMessage(String defaultMessage) {
+ this.defaultMessage = defaultMessage;
+ return this;
+ }
+
+ private FieldErrorBuilder errorCodes(String... errorCodes) {
+ this.errorCodes = errorCodes;
+ return this;
+ }
+
+ private FieldErrorBuilder fieldName(String fieldName) {
+ this.fieldName = fieldName;
+ return this;
+ }
+
+ private FieldError build() {
+ return new FieldError(OBJECT_WITH_VALIDATION_ERROR,
+ fieldName,
+ null,
+ false,
+ errorCodes,
+ new Object[]{},
+ defaultMessage
+ );
+ }
+ }
+ }
+}
diff --git a/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTOTest.java b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTOTest.java
new file mode 100644
index 0000000..8ae069a
--- /dev/null
+++ b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/error/ValidationErrorDTOTest.java
@@ -0,0 +1,132 @@
+package net.petrikainulainen.springdata.jpa.web.error;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import net.petrikainulainen.springdata.jpa.web.error.FieldErrorDTO;
+import net.petrikainulainen.springdata.jpa.web.error.ValidationErrorDTO;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class ValidationErrorDTOTest {
+
+ private static final String FIELD = "field";
+ private static final String MESSAGE = "message";
+
+ public class AddFieldError {
+
+ public class WhenFieldIsInvalid {
+
+ public class WhenFieldIsNull {
+
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowException() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError(null, MESSAGE);
+ }
+
+ @Test
+ public void shouldNotCreateNewFieldError() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+
+ catchThrowable(() -> validationErrors.addFieldError(null, MESSAGE));
+
+ assertThat(validationErrors.getFieldErrors()).isEmpty();
+ }
+ }
+
+ public class WhenFieldIsEmpty {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError("", MESSAGE);
+ }
+
+ @Test
+ public void shouldNotCreateNewFieldError() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+
+ catchThrowable(() -> validationErrors.addFieldError("", MESSAGE));
+
+ assertThat(validationErrors.getFieldErrors()).isEmpty();
+ }
+ }
+ }
+
+ public class WhenMessageIsInvalid {
+
+ public class WhenMessageIsNull {
+
+ @Test(expected = NullPointerException.class)
+ public void shouldThrowException() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError(FIELD, null);
+ }
+
+ @Test
+ public void shouldNotCreateNewFieldError() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+
+ catchThrowable(() -> validationErrors.addFieldError(FIELD, null));
+
+ assertThat(validationErrors.getFieldErrors()).isEmpty();
+ }
+ }
+
+ public class WhenMessageIsEmpty {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError(FIELD, "");
+ }
+
+ @Test
+ public void shouldNotCreateNewFieldError() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+
+ catchThrowable(() -> validationErrors.addFieldError(FIELD, ""));
+
+ assertThat(validationErrors.getFieldErrors()).isEmpty();
+ }
+ }
+ }
+
+ public class WhenFieldAndMessageAreValid {
+
+ @Test
+ public void shouldCreateNewFieldErrorAndSetField() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError(FIELD, MESSAGE);
+
+ List fieldErrors = validationErrors.getFieldErrors();
+ assertThat(fieldErrors).hasSize(1);
+
+ FieldErrorDTO fieldError = fieldErrors.iterator().next();
+
+ assertThat(fieldError.getField()).isEqualTo(FIELD);
+ }
+
+ @Test
+ public void shouldCreateNewFieldErrorAndSetMessage() {
+ ValidationErrorDTO validationErrors = new ValidationErrorDTO();
+ validationErrors.addFieldError(FIELD, MESSAGE);
+
+ List fieldErrors = validationErrors.getFieldErrors();
+ assertThat(fieldErrors).hasSize(1);
+
+ FieldErrorDTO fieldError = fieldErrors.iterator().next();
+
+ assertThat(fieldError.getMessage()).isEqualTo(MESSAGE);
+ }
+ }
+ }
+}
diff --git a/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/security/UserDTOTest.java b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/security/UserDTOTest.java
new file mode 100644
index 0000000..1928461
--- /dev/null
+++ b/custom-method-single-repo/src/test/java/net/petrikainulainen/springdata/jpa/web/security/UserDTOTest.java
@@ -0,0 +1,102 @@
+package net.petrikainulainen.springdata.jpa.web.security;
+
+import com.nitorcreations.junit.runners.NestedRunner;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Petri Kainulainen
+ */
+@RunWith(NestedRunner.class)
+public class UserDTOTest {
+
+ public class CreateNew {
+
+ private final String ROLE_USER = UserRole.ROLE_USER.name();
+
+ public class WhenUsernameIsEmpty {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ Collection authorities = createAuthorities(ROLE_USER);
+ new UserDTO("", authorities);
+ }
+ }
+
+ public class WhenUserNameIsNotEmpty {
+
+ private final String USERNAME = "username";
+
+ public class WhenUserHasNoGrantedAuthorities {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ new UserDTO(USERNAME, new ArrayList<>());
+ }
+ }
+
+ public class WhenUserHasOneGrantedAuthority {
+
+ public class WhenGrantedAuthorityIsKnown {
+
+ private Collection authorities;
+
+ @Before
+ public void createKnownAuthority() {
+ authorities = createAuthorities(ROLE_USER);
+ }
+
+ @Test
+ public void shouldSetUsername() {
+ UserDTO user = new UserDTO(USERNAME, authorities);
+ assertThat(user.getUsername()).isEqualTo(USERNAME);
+ }
+
+ @Test
+ public void shouldSetRole() {
+ UserDTO user = new UserDTO(USERNAME, authorities);
+ assertThat(user.getRole()).isEqualTo(UserRole.ROLE_USER);
+ }
+ }
+
+ public class WhenGrantedAuthorityIsUnknown {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ Collection authorities = createAuthorities("UNKNOWN_ROLE");
+ new UserDTO(USERNAME, authorities);
+ }
+ }
+ }
+
+ public class WhenUserHasMoreThanOneGrantedAuthority {
+
+ @Test(expected = IllegalArgumentException.class)
+ public void shouldThrowException() {
+ Collection authorities = createAuthorities(ROLE_USER, "ANOTHER_ROLE");
+ new UserDTO(USERNAME, authorities);
+ }
+ }
+ }
+ }
+
+ private Collection createAuthorities(String... roles) {
+ List authorities = new ArrayList<>();
+
+ for (String role: roles) {
+ SimpleGrantedAuthority authority = new SimpleGrantedAuthority(role);
+ authorities.add(authority);
+ }
+
+ return authorities;
+ }
+}
diff --git a/integration-testing/src/integration-test/java/net/petrikainulainen/spring/datajpa/todo/repository/ITTodoRepositoryTest.java b/integration-testing/src/integration-test/java/net/petrikainulainen/spring/datajpa/todo/repository/ITTodoRepositoryTest.java
index 1b5fa7c..7a1b857 100644
--- a/integration-testing/src/integration-test/java/net/petrikainulainen/spring/datajpa/todo/repository/ITTodoRepositoryTest.java
+++ b/integration-testing/src/integration-test/java/net/petrikainulainen/spring/datajpa/todo/repository/ITTodoRepositoryTest.java
@@ -32,7 +32,7 @@
DirtiesContextTestExecutionListener.class,
TransactionalTestExecutionListener.class,
DbUnitTestExecutionListener.class })
-@DatabaseSetup("todoData.xml")
+@DatabaseSetup("toDoData.xml")
public class ITTodoRepositoryTest {
@Autowired
diff --git a/query-methods/.gitignore b/query-methods/.gitignore
index 1c28079..02895f1 100644
--- a/query-methods/.gitignore
+++ b/query-methods/.gitignore
@@ -4,4 +4,7 @@
*.iml
build
h2db
-target
\ No newline at end of file
+target
+node_modules
+bower_components
+build
\ No newline at end of file
diff --git a/query-methods/README.md b/query-methods/README.md
index 0ff8c05..3ba8a24 100644
--- a/query-methods/README.md
+++ b/query-methods/README.md
@@ -3,7 +3,13 @@ This blog post is the example application of the following blog posts:
* [Spring Data JPA Tutorial: Getting the Required Dependencies](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-getting-the-required-dependencies/)
* [Spring Data JPA Tutorial: Configuration](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-one-configuration/)
* [Spring Data JPA Tutorial: CRUD](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-part-two-crud/)
-* Spring Data JPA Tutorial: Query Methods (TBD)
+* [Spring Data JPA Tutorial: Introduction to Query Methods](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-introduction-to-query-methods/)
+* [Spring Data JPA Tutorial: Creating Database Queries From Method Names](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-creating-database-queries-from-method-names/)
+* [Spring Data JPA Tutorial: Creating Database Queries With the @Query Annotation](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-creating-database-queries-with-the-query-annotation/)
+* [Spring Data JPA Tutorial: Creating Database Queries With Named Queries](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-creating-database-queries-with-named-queries/)
+* [Spring Data JPA Tutorial: Auditing, Part One](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-auditing-part-one/)
+* [Spring Data JPA Tutorial: Auditing, Part Two](http://www.petrikainulainen.net/programming/spring-framework/spring-data-jpa-tutorial-auditing-part-two/)
+* [Spring Data JPA Tutorial: Sorting]() - Not published yet
**Note:** This application is still work in progress.
@@ -12,9 +18,34 @@ Prerequisites
You need to install the following tools if you want to run this application:
+Backend
+---------
+
* [JDK 8](http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html)
* [Maven](http://maven.apache.org/) (the application is tested with Maven 3.2.1)
+Frontend
+----------
+
+* [Node.js](http://nodejs.org/)
+* [NPM](https://www.npmjs.org/)
+* [Bower](http://bower.io/)
+* [Gulp](http://gulpjs.com/)
+
+You can install these tools by following these steps:
+
+1. Install Node.js by using a [downloaded binary](http://nodejs.org/download/) or a [package manager](https://github.com/joyent/node/wiki/Installing-Node.js-via-package-manager).
+ You can also read this blog post: [How to install Node.js and NPM](http://blog.nodeknockout.com/post/65463770933/how-to-install-node-js-and-npm)
+
+2. Install Bower by using the following command:
+
+ npm install -g bower
+
+3. Install Gulp by using the following command:
+
+ npm install -g gulp
+
+
Running the Tests
=================
@@ -32,3 +63,14 @@ Running the Application
You can run the application by using the following command:
mvn clean jetty:run -P dev
+
+Credits
+=========
+
+* Kyösti Herrala. The Gulp build script and its Maven integration are based on Kyösti's ideas.
+* [Techniques for authentication in AngularJS applications](https://medium.com/opinionated-angularjs/techniques-for-authentication-in-angularjs-applications-7bbf0346acec)
+
+Known Issues
+============
+
+* If you refresh the login page, you aren't redirected away from it after successful login.
\ No newline at end of file
diff --git a/query-methods/frontend/.bowerrc b/query-methods/frontend/.bowerrc
new file mode 100644
index 0000000..df4bcee
--- /dev/null
+++ b/query-methods/frontend/.bowerrc
@@ -0,0 +1,3 @@
+{
+ "directory": "bower_components"
+}
\ No newline at end of file
diff --git a/query-methods/frontend/.jshintrc b/query-methods/frontend/.jshintrc
new file mode 100644
index 0000000..f648d46
--- /dev/null
+++ b/query-methods/frontend/.jshintrc
@@ -0,0 +1,33 @@
+{
+ "globalstrict": true,
+ "browser": true,
+ "devel": true,
+ "node": true,
+ "esnext": true,
+ "bitwise": true,
+ "camelcase": true,
+ "curly": true,
+ "eqeqeq": true,
+ "immed": true,
+ "indent": 4,
+ "latedef": true,
+ "newcap": true,
+ "noarg": true,
+ "regexp": true,
+ "undef": true,
+ "unused": false,
+ "strict": true,
+ "trailing": true,
+ "smarttabs": true,
+ "white": true,
+ "globals": {
+ "describe": true,
+ "it": true,
+ "beforeEach": true,
+ "afterEach": true,
+ "angular": true,
+ "jQuery": true,
+ "_": true,
+ "$": true
+ }
+}
\ No newline at end of file
diff --git a/query-methods/frontend/app/app.js b/query-methods/frontend/app/app.js
new file mode 100644
index 0000000..074fd4f
--- /dev/null
+++ b/query-methods/frontend/app/app.js
@@ -0,0 +1,98 @@
+'use strict';
+
+var App = angular.module('app', [
+ 'angular-logger',
+ 'http-auth-interceptor',
+ 'ngLocale',
+ 'ngCookies',
+ 'ngResource',
+ 'ngSanitize',
+ 'pascalprecht.translate',
+ 'ui.bootstrap',
+ 'ui.router',
+ 'ui.utils',
+ 'angular-growl',
+ 'angularMoment',
+ 'angularUtils.directives.dirPagination',
+ 'spring-security-csrf-token-interceptor',
+
+ //Partials
+ 'templates',
+
+ //Account
+ 'app.account.config', 'app.account.directives', 'app.account.controllers', 'app.account.services',
+
+ //Common
+ 'app.common.config', 'app.common.controllers', 'app.common.directives', 'app.common.services',
+
+ //Todo
+ 'app.todo.controllers', 'app.todo.directives', 'app.todo.services',
+
+ //Search
+ 'app.search.controllers', 'app.search.directives', 'app.search.services'
+
+]);
+
+App.run(['$log', '$rootScope', '$state', 'AUTH_EVENTS', 'AuthenticatedUser', 'authService', 'AuthenticationService', 'COMMON_EVENTS',
+ function ($log, $rootScope, $state, AUTH_EVENTS, AuthenticatedUser, authService, AuthenticationService, COMMON_EVENTS) {
+
+ var logger = $log.getInstance('app');
+
+ //This function retries all requests that were failed because of
+ //the 401 response.
+ function listenAuthenticationEvents() {
+ var confirmLogin = function() {
+ authService.loginConfirmed();
+ };
+
+ $rootScope.$on(AUTH_EVENTS.loginSuccess, confirmLogin);
+
+ var viewLogInPage = function() {
+ logger.info('User is not authenticated. Rendering login view.');
+ $state.go('todo.login');
+ };
+
+ $rootScope.$on(AUTH_EVENTS.notAuthenticated, viewLogInPage);
+
+ var viewTodoListPage = function() {
+ logger.info("User logged out. REndering todo list view.");
+ $state.go('todo.list', {}, {reload: true});
+ };
+
+ $rootScope.$on(AUTH_EVENTS.logoutSuccess, viewTodoListPage);
+
+ var viewForbiddenPage = function() {
+ logger.info('Permission was denied for user: %j', AuthenticatedUser);
+ $state.go('todo.forbidden');
+ };
+
+ $rootScope.$on(AUTH_EVENTS.notAuthorized, viewForbiddenPage);
+ }
+
+ function listenCommonEvents() {
+
+ var view404Page = function() {
+ logger.info('Requested page was not found.');
+ $state.go('todo.404');
+ };
+
+ $rootScope.$on(COMMON_EVENTS.notFound, view404Page);
+ }
+
+ //This function ensures that anonymous users cannot access states
+ //that marked as protected (i.e. the value of the authenticated
+ //property is set to true).
+ function secureProtectedStates() {
+ $rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
+ logger.trace('Moving to state: %s', toState.name);
+ AuthenticationService.authorizeStateChange(event, toState, toParams);
+ });
+ }
+
+ $rootScope.currentUser = AuthenticatedUser;
+
+ listenAuthenticationEvents();
+ listenCommonEvents();
+ secureProtectedStates();
+ }]);
+
diff --git a/query-methods/frontend/app/assets/i18n/en.json b/query-methods/frontend/app/assets/i18n/en.json
new file mode 100644
index 0000000..869176b
--- /dev/null
+++ b/query-methods/frontend/app/assets/i18n/en.json
@@ -0,0 +1,101 @@
+{
+ "app.title.label": "Spring Data JPA Tutorial - Query Methods",
+ "dialogs": {
+ "delete.dialog": {
+ "cancel.button.label": "Cancel",
+ "delete.button.label": "Delete",
+ "text": "Are you sure that you want to delete the todo entry with title: {{title}}?",
+ "title": "Delete todo entry?"
+ }
+ },
+ "directives": {
+ "login.form": {
+ "login.button": "Login",
+ "login.failed": "Login failed!"
+ },
+ "log.out.link.label": "Log Out",
+ "todo.form": {
+ "cancel.button": "Cancel",
+ "save.button": "Save"
+ }
+ },
+ "footer.message": "Spring Data JPA example application by Petri Kainulainen",
+ "header.brand.label": "Spring Data JPA Tutorial",
+ "pages": {
+ "add.page": {
+ "title": "Add new todo entry",
+ "link.label": "Add new todo entry"
+ },
+ "delete.link": "Delete",
+ "edit.page": {
+ "link.label": "Edit",
+ "title": "Edit todo entry"
+ },
+ "forbidden.page": {
+ "text": "Permission denied.",
+ "title": "Forbidden"
+ },
+ "not.found.page": {
+ "text": "The page that you were looking for was not found.",
+ "title": "Not Found"
+ },
+ "list.page": {
+ "title": "Things to do",
+ "texts": {
+ "no.todo.entries.found": "Nothing to do (yet)."
+ }
+ },
+ "login.page": {
+ "title": "Log In"
+ },
+ "search.results.page": {
+ "texts": {
+ "no.todo.entries.found": "No todo entries was found with the given search term."
+ },
+ "title": "Search Results"
+ },
+ "view.page": {
+ "title": "View Todo Entry"
+ }
+ },
+ "login": {
+ "help": "Log in by using username: 'user' and password: 'password'",
+ "username": "Username",
+ "username.placeholder": "Enter username",
+ "password": "Password",
+ "password.placeholder": "Enter password"
+ },
+ "search": {
+ "term.field.placeholder": "Search",
+ "missing.characters.text": "{{missingCharCount}} characters missing"
+ },
+ "todo": {
+ "created.by.prefix": "by",
+ "creation.time": "Created at",
+ "description": "Description",
+ "description.placeholder": "Enter description",
+ "messages": {
+ "description.maxLength": "Description cannot be longer than 500 characters",
+ "title.maxLength": "Title cannot be longer than 100 characters",
+ "title.required": "Title is required"
+ },
+ "modified.by.prefix": "by",
+ "modification.time": "Modified at",
+ "notifications": {
+ "add": {
+ "error": "Adding a new todo entry failed.",
+ "success": "A new todo entry was added."
+ },
+ "delete": {
+ "error": "Deleting the todo entry failed.",
+ "success": "Deleted the todo entry."
+ },
+ "edit": {
+ "error": "Updating the information of a todo entry failed.",
+ "success": "Updated the information of the todo entry."
+ }
+ },
+ "title": "Title",
+ "title.placeholder": "Enter title"
+ }
+}
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/account/forbidden-view.html b/query-methods/frontend/app/assets/partials/account/forbidden-view.html
new file mode 100644
index 0000000..c761f3e
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/account/forbidden-view.html
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/account/login-form-directive.html b/query-methods/frontend/app/assets/partials/account/login-form-directive.html
new file mode 100644
index 0000000..d2f14aa
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/account/login-form-directive.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/account/login-view.html b/query-methods/frontend/app/assets/partials/account/login-view.html
new file mode 100644
index 0000000..199d339
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/account/login-view.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/account/logout-link-directive.html b/query-methods/frontend/app/assets/partials/account/logout-link-directive.html
new file mode 100644
index 0000000..4d9550a
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/account/logout-link-directive.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/common/not-found-view.html b/query-methods/frontend/app/assets/partials/common/not-found-view.html
new file mode 100644
index 0000000..7edf553
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/common/not-found-view.html
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/search/dirPagination.tpl.html b/query-methods/frontend/app/assets/partials/search/dirPagination.tpl.html
new file mode 100644
index 0000000..558aa20
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/search/dirPagination.tpl.html
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/search/search-form-directive.html b/query-methods/frontend/app/assets/partials/search/search-form-directive.html
new file mode 100644
index 0000000..674143e
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/search/search-form-directive.html
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/search/search-result-list-directive.html b/query-methods/frontend/app/assets/partials/search/search-result-list-directive.html
new file mode 100644
index 0000000..c38f4f7
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/search/search-result-list-directive.html
@@ -0,0 +1,19 @@
+
diff --git a/query-methods/frontend/app/assets/partials/search/search-result-view.html b/query-methods/frontend/app/assets/partials/search/search-result-view.html
new file mode 100644
index 0000000..2d8cd39
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/search/search-result-view.html
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/todo/add-todo-view.html b/query-methods/frontend/app/assets/partials/todo/add-todo-view.html
new file mode 100644
index 0000000..0a0406a
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/todo/add-todo-view.html
@@ -0,0 +1,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/todo/delete-todo-modal.html b/query-methods/frontend/app/assets/partials/todo/delete-todo-modal.html
new file mode 100644
index 0000000..b390319
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/todo/delete-todo-modal.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/todo/edit-todo-view.html b/query-methods/frontend/app/assets/partials/todo/edit-todo-view.html
new file mode 100644
index 0000000..1695ae6
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/todo/edit-todo-view.html
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/todo/todo-form-directive.html b/query-methods/frontend/app/assets/partials/todo/todo-form-directive.html
new file mode 100644
index 0000000..c7815d0
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/todo/todo-form-directive.html
@@ -0,0 +1,52 @@
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/todo/todo-list-directive.html b/query-methods/frontend/app/assets/partials/todo/todo-list-directive.html
new file mode 100644
index 0000000..60ed955
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/todo/todo-list-directive.html
@@ -0,0 +1,8 @@
+
diff --git a/query-methods/frontend/app/assets/partials/todo/todo-list-view.html b/query-methods/frontend/app/assets/partials/todo/todo-list-view.html
new file mode 100644
index 0000000..6a83ba4
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/todo/todo-list-view.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/query-methods/frontend/app/assets/partials/todo/view-todo-view.html b/query-methods/frontend/app/assets/partials/todo/view-todo-view.html
new file mode 100644
index 0000000..374c16d
--- /dev/null
+++ b/query-methods/frontend/app/assets/partials/todo/view-todo-view.html
@@ -0,0 +1,25 @@
+