diff --git a/patch/1_01_user_with_lombok.patch b/patch/1_01_user_with_lombok.patch new file mode 100644 index 0000000..3ffcec1 --- /dev/null +++ b/patch/1_01_user_with_lombok.patch @@ -0,0 +1,69 @@ +Index: pom.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- pom.xml (revision 35a21d499357b464ebb5b571cb97ac0bc5e57f01) ++++ pom.xml (revision 9838d0cefa7b1babfb52ff1702bcb490e934761c) +@@ -53,6 +53,14 @@ + + org.springframework.boot + spring-boot-maven-plugin ++ ++ ++ ++ org.projectlombok ++ lombok ++ ++ ++ + + + +Index: src/main/java/ru/javaops/bootjava/model/Role.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/model/Role.java (revision 9838d0cefa7b1babfb52ff1702bcb490e934761c) ++++ src/main/java/ru/javaops/bootjava/model/Role.java (revision 9838d0cefa7b1babfb52ff1702bcb490e934761c) +@@ -0,0 +1,6 @@ ++package ru.javaops.bootjava.model; ++ ++public enum Role { ++ ROLE_USER, ++ ROLE_ADMIN ++} +\ No newline at end of file +Index: src/main/java/ru/javaops/bootjava/model/User.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/model/User.java (revision 9838d0cefa7b1babfb52ff1702bcb490e934761c) ++++ src/main/java/ru/javaops/bootjava/model/User.java (revision 9838d0cefa7b1babfb52ff1702bcb490e934761c) +@@ -0,0 +1,23 @@ ++package ru.javaops.bootjava.model; ++ ++import lombok.AllArgsConstructor; ++import lombok.Data; ++import lombok.NoArgsConstructor; ++ ++import java.util.Set; ++ ++@Data ++@NoArgsConstructor ++@AllArgsConstructor ++public class User { ++ ++ private String email; ++ ++ private String firstName; ++ ++ private String lastName; ++ ++ private String password; ++ ++ private Set roles; ++} +\ No newline at end of file diff --git a/patch/2_01_data_jpa.patch b/patch/2_01_data_jpa.patch new file mode 100644 index 0000000..1e749cf --- /dev/null +++ b/patch/2_01_data_jpa.patch @@ -0,0 +1,148 @@ +Index: src/main/java/ru/javaops/bootjava/repository/UserRepository.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/repository/UserRepository.java (date 1606213467289) ++++ src/main/java/ru/javaops/bootjava/repository/UserRepository.java (date 1606213467289) +@@ -0,0 +1,7 @@ ++package ru.javaops.bootjava.repository; ++ ++import org.springframework.data.jpa.repository.JpaRepository; ++import ru.javaops.bootjava.model.User; ++ ++public interface UserRepository extends JpaRepository { ++} +\ No newline at end of file +Index: src/main/java/ru/javaops/bootjava/model/User.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/model/User.java (revision 530c4e3c5d642911ba4cd5bcc0ed8938c3690a6d) ++++ src/main/java/ru/javaops/bootjava/model/User.java (date 1606213467285) +@@ -1,23 +1,44 @@ + package ru.javaops.bootjava.model; + +-import lombok.AllArgsConstructor; +-import lombok.Data; +-import lombok.NoArgsConstructor; ++import lombok.*; ++import org.springframework.data.jpa.domain.AbstractPersistable; + ++import javax.persistence.*; ++import javax.validation.constraints.Email; ++import javax.validation.constraints.NotEmpty; ++import javax.validation.constraints.Size; + import java.util.Set; + +-@Data +-@NoArgsConstructor ++@Entity ++@Table(name = "users") ++@Getter ++@Setter ++@NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor +-public class User { ++@ToString(callSuper = true, exclude = {"password"}) ++public class User extends AbstractPersistable { + ++ @Column(name = "email", nullable = false, unique = true) ++ @Email ++ @NotEmpty ++ @Size(max = 128) + private String email; + ++ @Column(name = "first_name") ++ @Size(max = 128) + private String firstName; + ++ @Column(name = "last_name") ++ @Size(max = 128) + private String lastName; + ++ @Column(name = "password") ++ @Size(max = 256) + private String password; + ++ @Enumerated(EnumType.STRING) ++ @CollectionTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"), uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "role"}, name = "user_roles_unique")}) ++ @Column(name = "role") ++ @ElementCollection(fetch = FetchType.EAGER) + private Set roles; + } +\ No newline at end of file +Index: pom.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- pom.xml (revision 530c4e3c5d642911ba4cd5bcc0ed8938c3690a6d) ++++ pom.xml (date 1606213518079) +@@ -28,6 +28,10 @@ + org.springframework.boot + spring-boot-starter-web + ++ ++ org.springframework.boot ++ spring-boot-starter-validation ++ + + + com.h2database +Index: src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java (revision 530c4e3c5d642911ba4cd5bcc0ed8938c3690a6d) ++++ src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java (date 1606213548848) +@@ -1,14 +1,29 @@ + package ru.javaops.bootjava; + + import lombok.AllArgsConstructor; ++import org.springframework.boot.ApplicationArguments; ++import org.springframework.boot.ApplicationRunner; + import org.springframework.boot.SpringApplication; + import org.springframework.boot.autoconfigure.SpringBootApplication; ++import ru.javaops.bootjava.model.Role; ++import ru.javaops.bootjava.model.User; ++import ru.javaops.bootjava.repository.UserRepository; ++ ++import java.util.Set; + + @SpringBootApplication + @AllArgsConstructor +-public class RestaurantVotingApplication { ++public class RestaurantVotingApplication implements ApplicationRunner { ++ private final UserRepository userRepository; + + public static void main(String[] args) { + SpringApplication.run(RestaurantVotingApplication.class, args); + } ++ ++ @Override ++ public void run(ApplicationArguments args) { ++ userRepository.save(new User("user@gmail.com", "User_First", "User_Last", "password", Set.of(Role.ROLE_USER))); ++ userRepository.save(new User("admin@javaops.ru", "Admin_First", "Admin_Last", "admin", Set.of(Role.ROLE_USER, Role.ROLE_ADMIN))); ++ System.out.println(userRepository.findAll()); ++ } + } +Index: src/main/resources/application.properties +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/resources/application.properties (revision 530c4e3c5d642911ba4cd5bcc0ed8938c3690a6d) ++++ src/main/resources/application.properties (date 1606213518073) +@@ -1,0 +1,9 @@ ++# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html ++# JPA ++spring.jpa.show-sql=true ++spring.jpa.open-in-view=false ++# http://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#configurations ++spring.jpa.properties.hibernate.default_batch_fetch_size=20 ++spring.jpa.properties.hibernate.format_sql=true ++# https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc ++spring.jpa.properties.hibernate.jdbc.batch_size=20 diff --git a/patch/2_02_h2_init.patch b/patch/2_02_h2_init.patch new file mode 100644 index 0000000..496eaa2 --- /dev/null +++ b/patch/2_02_h2_init.patch @@ -0,0 +1,149 @@ +Index: src/main/resources/application.properties +=================================================================== +--- src/main/resources/application.properties (revision ee6e8d0897d8dc93367b42cb03e86d937a979c26) ++++ src/main/resources/application.properties (revision ee6e8d0897d8dc93367b42cb03e86d937a979c26) +@@ -1,10 +0,0 @@ +-# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html +-# JPA +-spring.jpa.show-sql=true +-spring.jpa.open-in-view=false +-# http://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#configurations +-spring.jpa.properties.hibernate.default_batch_fetch_size=20 +-spring.jpa.properties.hibernate.format_sql=true +-# https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc +-spring.jpa.properties.hibernate.jdbc.batch_size=20 +- +Index: src/main/java/ru/javaops/bootjava/config/AppConfig.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/config/AppConfig.java (date 1602967405106) ++++ src/main/java/ru/javaops/bootjava/config/AppConfig.java (date 1602967405106) +@@ -0,0 +1,26 @@ ++package ru.javaops.bootjava.config; ++ ++import lombok.extern.slf4j.Slf4j; ++import org.h2.tools.Server; ++import org.springframework.context.annotation.Bean; ++import org.springframework.context.annotation.Configuration; ++ ++import java.sql.SQLException; ++ ++@Configuration ++@Slf4j ++public class AppConfig { ++ ++/* ++ @Bean(initMethod = "start", destroyMethod = "stop") ++ public Server h2WebServer() throws SQLException { ++ return Server.createWebServer("-web", "-webAllowOthers", "-webPort", "8082"); ++ } ++*/ ++ ++ @Bean(initMethod = "start", destroyMethod = "stop") ++ public Server h2Server() throws SQLException { ++ log.info("Start H2 TCP server"); ++ return Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", "9092"); ++ } ++} +Index: src/main/resources/application.yaml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/resources/application.yaml (date 1602967334236) ++++ src/main/resources/application.yaml (date 1602967334236) +@@ -0,0 +1,30 @@ ++# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html ++spring: ++ jpa: ++ show-sql: true ++ open-in-view: false ++ hibernate: ++ ddl-auto: create-drop ++ properties: ++ # http://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#configurations ++ hibernate: ++ format_sql: true ++ default_batch_fetch_size: 20 ++ # https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc ++ jdbc.batch_size: 20 ++ id.new_generator_mappings: false ++ datasource: ++ # ImMemory ++ url: jdbc:h2:mem:voting ++ # tcp: jdbc:h2:tcp://localhost:9092/mem:voting ++ # Absolute path ++ # url: jdbc:h2:C:/projects/bootjava/restorant-voting/db/voting ++ # tcp: jdbc:h2:tcp://localhost:9092/C:/projects/bootjava/restorant-voting/db/voting ++ # Relative path form current dir ++ # url: jdbc:h2:./db/voting ++ # Relative path from home ++ # url: jdbc:h2:~/voting ++ # tcp: jdbc:h2:tcp://localhost:9092/~/voting ++ username: sa ++ password: ++ h2.console.enabled: true +\ No newline at end of file +Index: src/main/resources/data.sql +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/resources/data.sql (date 1603033339840) ++++ src/main/resources/data.sql (date 1603033339840) +@@ -0,0 +1,8 @@ ++INSERT INTO USERS (EMAIL, FIRST_NAME, LAST_NAME, PASSWORD) ++VALUES ('user@gmail.com', 'User_First', 'User_Last', 'password'), ++ ('admin@javaops.ru', 'Admin_First', 'Admin_Last', 'admin'); ++ ++INSERT INTO USER_ROLE (ROLE, USER_ID) ++VALUES ('ROLE_USER', 1), ++ ('ROLE_ADMIN', 2), ++ ('ROLE_USER', 2); +\ No newline at end of file +Index: pom.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- pom.xml (revision ee6e8d0897d8dc93367b42cb03e86d937a979c26) ++++ pom.xml (date 1602967346468) +@@ -35,7 +35,6 @@ + + com.h2database + h2 +- runtime + + + org.projectlombok +Index: src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java (revision ee6e8d0897d8dc93367b42cb03e86d937a979c26) ++++ src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java (date 1602967366492) +@@ -5,12 +5,8 @@ + import org.springframework.boot.ApplicationRunner; + import org.springframework.boot.SpringApplication; + import org.springframework.boot.autoconfigure.SpringBootApplication; +-import ru.javaops.bootjava.model.Role; +-import ru.javaops.bootjava.model.User; + import ru.javaops.bootjava.repository.UserRepository; + +-import java.util.Set; +- + @SpringBootApplication + @AllArgsConstructor + public class RestaurantVotingApplication implements ApplicationRunner { +@@ -22,8 +18,6 @@ + + @Override + public void run(ApplicationArguments args) { +- userRepository.save(new User("user@gmail.com", "User_First", "User_Last", "password", Set.of(Role.ROLE_USER))); +- userRepository.save(new User("admin@javaops.ru", "Admin_First", "Admin_Last", "admin", Set.of(Role.ROLE_USER, Role.ROLE_ADMIN))); + System.out.println(userRepository.findAll()); + } + } diff --git a/patch/2_03_model_query.patch b/patch/2_03_model_query.patch new file mode 100644 index 0000000..02638e2 --- /dev/null +++ b/patch/2_03_model_query.patch @@ -0,0 +1,142 @@ +Index: src/main/java/ru/javaops/bootjava/repository/UserRepository.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/repository/UserRepository.java (revision 01dc572d2c3fc751ee61423290af0030e969d6a2) ++++ src/main/java/ru/javaops/bootjava/repository/UserRepository.java (date 1603531121779) +@@ -1,7 +1,18 @@ + package ru.javaops.bootjava.repository; + + import org.springframework.data.jpa.repository.JpaRepository; ++import org.springframework.data.jpa.repository.Query; ++import org.springframework.transaction.annotation.Transactional; + import ru.javaops.bootjava.model.User; + ++import java.util.List; ++import java.util.Optional; ++ ++@Transactional(readOnly = true) + public interface UserRepository extends JpaRepository { ++ ++ @Query("SELECT u FROM User u WHERE u.email = LOWER(:email)") ++ Optional findByEmailIgnoreCase(String email); ++ ++ List findByLastNameContainingIgnoreCase(String lastName); + } +\ No newline at end of file +Index: src/main/java/ru/javaops/bootjava/model/User.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/model/User.java (revision 01dc572d2c3fc751ee61423290af0030e969d6a2) ++++ src/main/java/ru/javaops/bootjava/model/User.java (date 1603531121773) +@@ -1,7 +1,6 @@ + package ru.javaops.bootjava.model; + + import lombok.*; +-import org.springframework.data.jpa.domain.AbstractPersistable; + + import javax.persistence.*; + import javax.validation.constraints.Email; +@@ -16,7 +15,7 @@ + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @ToString(callSuper = true, exclude = {"password"}) +-public class User extends AbstractPersistable { ++public class User extends BaseEntity { + + @Column(name = "email", nullable = false, unique = true) + @Email +Index: src/main/java/ru/javaops/bootjava/model/BaseEntity.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/model/BaseEntity.java (date 1603531133924) ++++ src/main/java/ru/javaops/bootjava/model/BaseEntity.java (date 1603531133924) +@@ -0,0 +1,52 @@ ++package ru.javaops.bootjava.model; ++ ++import lombok.*; ++import org.springframework.data.domain.Persistable; ++import org.springframework.data.util.ProxyUtils; ++import org.springframework.util.Assert; ++ ++import javax.persistence.*; ++ ++@MappedSuperclass ++// https://stackoverflow.com/a/6084701/548473 ++@Access(AccessType.FIELD) ++@Getter ++@Setter ++@NoArgsConstructor(access = AccessLevel.PROTECTED) ++@AllArgsConstructor(access = AccessLevel.PROTECTED) ++@ToString ++public abstract class BaseEntity implements Persistable { ++ ++ @Id ++ @GeneratedValue(strategy = GenerationType.IDENTITY) ++ protected Integer id; ++ ++ // doesn't work for hibernate lazy proxy ++ public int id() { ++ Assert.notNull(id, "Entity must have id"); ++ return id; ++ } ++ ++ @Override ++ public boolean isNew() { ++ return id == null; ++ } ++ ++ // https://stackoverflow.com/questions/1638723 ++ @Override ++ public boolean equals(Object o) { ++ if (this == o) { ++ return true; ++ } ++ if (o == null || !getClass().equals(ProxyUtils.getUserClass(o))) { ++ return false; ++ } ++ BaseEntity that = (BaseEntity) o; ++ return id != null && id.equals(that.id); ++ } ++ ++ @Override ++ public int hashCode() { ++ return id == null ? 0 : id; ++ } ++} +\ No newline at end of file +Index: src/main/resources/application.yaml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/resources/application.yaml (revision 01dc572d2c3fc751ee61423290af0030e969d6a2) ++++ src/main/resources/application.yaml (date 1603531121790) +@@ -12,7 +12,6 @@ + default_batch_fetch_size: 20 + # https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc + jdbc.batch_size: 20 +- id.new_generator_mappings: false + datasource: + # ImMemory + url: jdbc:h2:mem:voting +Index: src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java (revision 01dc572d2c3fc751ee61423290af0030e969d6a2) ++++ src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java (date 1603531121784) +@@ -18,6 +18,6 @@ + + @Override + public void run(ApplicationArguments args) { +- System.out.println(userRepository.findAll()); ++ System.out.println(userRepository.findByLastNameContainingIgnoreCase("last")); + } + } diff --git a/patch/3_01_jpa_data_rest.patch b/patch/3_01_jpa_data_rest.patch new file mode 100644 index 0000000..38d5211 --- /dev/null +++ b/patch/3_01_jpa_data_rest.patch @@ -0,0 +1,73 @@ +Index: src/main/resources/application.yaml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/resources/application.yaml (revision c6417e3dbeb9ae7f3a018bd7a569a03a9acb7290) ++++ src/main/resources/application.yaml (date 1606225829853) +@@ -26,4 +26,9 @@ + # tcp: jdbc:h2:tcp://localhost:9092/~/voting + username: sa + password: +- h2.console.enabled: true +\ No newline at end of file ++ h2.console.enabled: true ++ ++ data.rest: ++ # https://docs.spring.io/spring-data/rest/docs/current/reference/html/#getting-started.basic-settings ++ basePath: /api ++ returnBodyOnCreate: true +\ No newline at end of file +Index: src/main/java/ru/javaops/bootjava/repository/UserRepository.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/repository/UserRepository.java (revision c6417e3dbeb9ae7f3a018bd7a569a03a9acb7290) ++++ src/main/java/ru/javaops/bootjava/repository/UserRepository.java (date 1606225829845) +@@ -2,6 +2,7 @@ + + import org.springframework.data.jpa.repository.JpaRepository; + import org.springframework.data.jpa.repository.Query; ++import org.springframework.data.rest.core.annotation.RestResource; + import org.springframework.transaction.annotation.Transactional; + import ru.javaops.bootjava.model.User; + +@@ -11,8 +12,10 @@ + @Transactional(readOnly = true) + public interface UserRepository extends JpaRepository { + ++ @RestResource(rel = "by-email", path = "by-email") + @Query("SELECT u FROM User u WHERE u.email = LOWER(:email)") + Optional findByEmailIgnoreCase(String email); + ++ @RestResource(rel = "by-lastname", path = "by-lastname") + List findByLastNameContainingIgnoreCase(String lastName); + } +\ No newline at end of file +Index: pom.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- pom.xml (revision c6417e3dbeb9ae7f3a018bd7a569a03a9acb7290) ++++ pom.xml (date 1606225887996) +@@ -32,7 +32,17 @@ + org.springframework.boot + spring-boot-starter-validation + +- ++ ++ org.springframework.boot ++ spring-boot-starter-data-rest ++ ++ + + com.h2database + h2 diff --git a/patch/3_02_jackson.patch b/patch/3_02_jackson.patch new file mode 100644 index 0000000..4c00a8c --- /dev/null +++ b/patch/3_02_jackson.patch @@ -0,0 +1,44 @@ +Index: src/main/resources/application.yaml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/resources/application.yaml (revision 85d629e673e5882d2a9e25af0daaea372785d410) ++++ src/main/resources/application.yaml (date 1603040431182) +@@ -31,4 +31,11 @@ + data.rest: + # https://docs.spring.io/spring-data/rest/docs/current/reference/html/#getting-started.basic-settings + basePath: /api +- returnBodyOnCreate: true +\ No newline at end of file ++ returnBodyOnCreate: true ++ ++# Jackson Serialization Issue Resolver ++# jackson: ++# visibility.field: any ++# visibility.getter: none ++# visibility.setter: none ++# visibility.is-getter: none +\ No newline at end of file +Index: src/main/java/ru/javaops/bootjava/model/BaseEntity.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/ru/javaops/bootjava/model/BaseEntity.java (revision 85d629e673e5882d2a9e25af0daaea372785d410) ++++ src/main/java/ru/javaops/bootjava/model/BaseEntity.java (date 1603040410453) +@@ -1,5 +1,6 @@ + package ru.javaops.bootjava.model; + ++import com.fasterxml.jackson.annotation.JsonIgnore; + import lombok.*; + import org.springframework.data.domain.Persistable; + import org.springframework.data.util.ProxyUtils; +@@ -27,6 +28,7 @@ + return id; + } + ++ @JsonIgnore + @Override + public boolean isNew() { + return id == null; diff --git a/patch/4_01_add_security.patch b/patch/4_01_add_security.patch new file mode 100644 index 0000000..bc7842c --- /dev/null +++ b/patch/4_01_add_security.patch @@ -0,0 +1,19 @@ +Index: pom.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- pom.xml (revision 08321643a1a748d410a14aab8f8d7bdbb7586435) ++++ pom.xml (date 1606230418378) +@@ -36,6 +36,11 @@ + org.springframework.boot + spring-boot-starter-data-rest + ++ ++ org.springframework.boot ++ spring-boot-starter-security ++ ++ + ++ ++ ++ ++ org.springdoc ++ springdoc-openapi-ui ++ ${springdoc.version} ++ ++ ++ org.springdoc ++ springdoc-openapi-data-rest ++ ${springdoc.version} ++ ++ ++ org.springdoc ++ springdoc-openapi-security ++ ${springdoc.version} ++ + + com.h2database + h2 +@@ -58,7 +76,7 @@ + true + + +- ++ + + org.springframework.boot + spring-boot-starter-test +Index: src/main/java/ru/javaops/bootjava/web/AccountController.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java +--- a/src/main/java/ru/javaops/bootjava/web/AccountController.java (revision 24f7e7fc8451a22eebdef8431f271b6b7fb52941) ++++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java (date 1616948395033) +@@ -1,5 +1,6 @@ + package ru.javaops.bootjava.web; + ++import io.swagger.v3.oas.annotations.tags.Tag; + import lombok.AllArgsConstructor; + import lombok.extern.slf4j.Slf4j; + import org.springframework.data.rest.webmvc.RepositoryLinksResource; +@@ -38,6 +39,7 @@ + @RequestMapping("/api/account") + @AllArgsConstructor + @Slf4j ++@Tag(name = "Account Controller") + public class AccountController implements RepresentationModelProcessor { + @SuppressWarnings("unchecked") + private static final RepresentationModelAssemblerSupport> ASSEMBLER = diff --git a/patch/6_02_fix_update.patch b/patch/6_02_fix_update.patch new file mode 100644 index 0000000..71a16bd --- /dev/null +++ b/patch/6_02_fix_update.patch @@ -0,0 +1,168 @@ +Index: src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java +--- a/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java (revision 57c07cf5bf63765cb56b5649404f8b8c3628b583) ++++ b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java (date 1618249181223) +@@ -1,7 +1,9 @@ + package ru.javaops.bootjava.web.error; + + import lombok.AllArgsConstructor; ++import lombok.extern.slf4j.Slf4j; + import org.springframework.boot.web.servlet.error.ErrorAttributes; ++import org.springframework.http.HttpHeaders; + import org.springframework.http.HttpStatus; + import org.springframework.http.ResponseEntity; + import org.springframework.web.bind.annotation.ExceptionHandler; +@@ -14,15 +16,23 @@ + + @RestControllerAdvice + @AllArgsConstructor ++@Slf4j + public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private final ErrorAttributes errorAttributes; + + @ExceptionHandler(AppException.class) + public ResponseEntity> appException(AppException ex, WebRequest request) { ++ log.error("Application Exception", ex); + Map body = errorAttributes.getErrorAttributes(request, ex.getOptions()); + HttpStatus status = ex.getStatus(); + body.put("status", status.value()); + body.put("error", status.getReasonPhrase()); + return ResponseEntity.status(status).body(body); + } ++ ++ @Override ++ protected ResponseEntity handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) { ++ log.error("Exception", ex); ++ return super.handleExceptionInternal(ex, body, headers, status, request); ++ } + } +Index: pom.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/pom.xml b/pom.xml +--- a/pom.xml (revision 57c07cf5bf63765cb56b5649404f8b8c3628b583) ++++ b/pom.xml (date 1618249076704) +@@ -5,7 +5,7 @@ + + org.springframework.boot + spring-boot-starter-parent +- 2.4.0 ++ 2.4.4 + + + ru.javaops.bootjava +Index: src/main/resources/application.yaml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml +--- a/src/main/resources/application.yaml (revision 57c07cf5bf63765cb56b5649404f8b8c3628b583) ++++ b/src/main/resources/application.yaml (date 1618249181230) +@@ -4,7 +4,7 @@ + show-sql: true + open-in-view: false + hibernate: +- ddl-auto: create-drop ++ ddl-auto: create + properties: + # http://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#configurations + hibernate: +Index: src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java +--- a/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java (revision 57c07cf5bf63765cb56b5649404f8b8c3628b583) ++++ b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java (date 1618239250920) +@@ -5,10 +5,12 @@ + import com.fasterxml.jackson.databind.DeserializationContext; + import com.fasterxml.jackson.databind.JsonDeserializer; + import com.fasterxml.jackson.databind.JsonNode; ++import lombok.experimental.UtilityClass; + import ru.javaops.bootjava.config.WebSecurityConfig; + + import java.io.IOException; + ++@UtilityClass + public class JsonDeserializers { + + // https://stackoverflow.com/a/60995048/548473 +Index: src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +--- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java (revision 57c07cf5bf63765cb56b5649404f8b8c3628b583) ++++ b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java (date 1618239250940) +@@ -1,11 +1,9 @@ + package ru.javaops.bootjava; + +-import lombok.AllArgsConstructor; + import org.springframework.boot.SpringApplication; + import org.springframework.boot.autoconfigure.SpringBootApplication; + + @SpringBootApplication +-@AllArgsConstructor + public class RestaurantVotingApplication { + + public static void main(String[] args) { +Index: src/main/java/ru/javaops/bootjava/web/AccountController.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java +--- a/src/main/java/ru/javaops/bootjava/web/AccountController.java (revision 57c07cf5bf63765cb56b5649404f8b8c3628b583) ++++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java (date 1618249076708) +@@ -22,7 +22,7 @@ + + import javax.validation.Valid; + import java.net.URI; +-import java.util.Set; ++import java.util.EnumSet; + + import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; + +@@ -65,12 +65,12 @@ + userRepository.deleteById(authUser.id()); + } + +- @PostMapping(value = "/register", consumes = MediaTypes.HAL_JSON_VALUE) ++ @PostMapping(value = "/register", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(value = HttpStatus.CREATED) + public ResponseEntity> register(@Valid @RequestBody User user) { + log.info("register {}", user); + ValidationUtil.checkNew(user); +- user.setRoles(Set.of(Role.USER)); ++ user.setRoles(EnumSet.of(Role.USER)); + user = userRepository.save(user); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path("/api/account") +Index: src/main/java/ru/javaops/bootjava/util/ValidationUtil.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java +--- a/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java (revision 57c07cf5bf63765cb56b5649404f8b8c3628b583) ++++ b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java (date 1618239250926) +@@ -1,8 +1,10 @@ + package ru.javaops.bootjava.util; + ++import lombok.experimental.UtilityClass; + import ru.javaops.bootjava.error.IllegalRequestDataException; + import ru.javaops.bootjava.model.BaseEntity; + ++@UtilityClass + public class ValidationUtil { + + public static void checkNew(BaseEntity entity) { diff --git a/patch/6_03_add_tests.patch b/patch/6_03_add_tests.patch new file mode 100644 index 0000000..65c5472 --- /dev/null +++ b/patch/6_03_add_tests.patch @@ -0,0 +1,246 @@ +Index: src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java b/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java +deleted file mode 100644 +--- a/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java (revision 7985ad9843bcc32490f91be27cea73940f15af6b) ++++ /dev/null (revision 7985ad9843bcc32490f91be27cea73940f15af6b) +@@ -1,12 +0,0 @@ +-package ru.javaops.bootjava; +- +-import org.junit.jupiter.api.Test; +-import org.springframework.boot.test.context.SpringBootTest; +- +-@SpringBootTest +-class RestaurantVotingApplicationTests { +- +- @Test +- void contextLoads() { +- } +-} +Index: pom.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/pom.xml b/pom.xml +--- a/pom.xml (revision 7985ad9843bcc32490f91be27cea73940f15af6b) ++++ b/pom.xml (date 1618238843621) +@@ -82,6 +82,12 @@ + spring-boot-starter-test + test + ++ ++ ++ org.springframework.security ++ spring-security-test ++ test ++ + + + +Index: src/test/java/ru/javaops/bootjava/web/UserControllerTest.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java +new file mode 100644 +--- /dev/null (date 1618238555080) ++++ b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java (date 1618238555080) +@@ -0,0 +1,64 @@ ++package ru.javaops.bootjava.web; ++ ++import org.junit.jupiter.api.Assertions; ++import org.junit.jupiter.api.Test; ++import org.springframework.beans.factory.annotation.Autowired; ++import org.springframework.hateoas.MediaTypes; ++import org.springframework.security.test.context.support.WithUserDetails; ++import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; ++import ru.javaops.bootjava.repository.UserRepository; ++ ++import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; ++import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; ++import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; ++import static ru.javaops.bootjava.UserTestUtil.*; ++ ++class UserControllerTest extends AbstractControllerTest { ++ static final String URL = "/api/users/"; ++ ++ @Autowired ++ private UserRepository userRepository; ++ ++ @Test ++ @WithUserDetails(value = ADMIN_MAIL) ++ void get() throws Exception { ++ perform(MockMvcRequestBuilders.get(URL + USER_ID)) ++ .andExpect(status().isOk()) ++ .andDo(print()) ++ .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)); ++ } ++ ++ @Test ++ @WithUserDetails(value = ADMIN_MAIL) ++ void getAll() throws Exception { ++ perform(MockMvcRequestBuilders.get(URL)) ++ .andExpect(status().isOk()) ++ .andDo(print()) ++ .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)); ++ } ++ ++ @Test ++ @WithUserDetails(value = ADMIN_MAIL) ++ void getByEmail() throws Exception { ++ perform(MockMvcRequestBuilders.get(URL + "search/by-email?email=" + ADMIN_MAIL)) ++ .andExpect(status().isOk()) ++ .andDo(print()) ++ .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)); ++ } ++ ++ @Test ++ @WithUserDetails(value = USER_MAIL) ++ void getForbidden() throws Exception { ++ perform(MockMvcRequestBuilders.get(URL)) ++ .andExpect(status().isForbidden()); ++ } ++ ++ @Test ++ @WithUserDetails(value = ADMIN_MAIL) ++ void delete() throws Exception { ++ perform(MockMvcRequestBuilders.delete(URL + USER_ID)) ++ .andExpect(status().isNoContent()); ++ Assertions.assertFalse(userRepository.findById(USER_ID).isPresent()); ++ Assertions.assertTrue(userRepository.findById(ADMIN_ID).isPresent()); ++ } ++} +\ No newline at end of file +Index: src/test/java/ru/javaops/bootjava/UserTestUtil.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/UserTestUtil.java b/src/test/java/ru/javaops/bootjava/UserTestUtil.java +new file mode 100644 +--- /dev/null (date 1618238555090) ++++ b/src/test/java/ru/javaops/bootjava/UserTestUtil.java (date 1618238555090) +@@ -0,0 +1,8 @@ ++package ru.javaops.bootjava; ++ ++public class UserTestUtil { ++ public static final int USER_ID = 1; ++ public static final int ADMIN_ID = 2; ++ public static final String USER_MAIL = "user@gmail.com"; ++ public static final String ADMIN_MAIL = "admin@javaops.ru"; ++} +Index: src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java +new file mode 100644 +--- /dev/null (date 1618238884709) ++++ b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java (date 1618238884709) +@@ -0,0 +1,24 @@ ++package ru.javaops.bootjava.web; ++ ++import org.springframework.beans.factory.annotation.Autowired; ++import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; ++import org.springframework.boot.test.context.SpringBootTest; ++import org.springframework.test.web.servlet.MockMvc; ++import org.springframework.test.web.servlet.ResultActions; ++import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; ++import org.springframework.transaction.annotation.Transactional; ++ ++//https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications ++@SpringBootTest ++@Transactional ++@AutoConfigureMockMvc ++//https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications-testing-with-mock-environment ++public abstract class AbstractControllerTest { ++ ++ @Autowired ++ protected MockMvc mockMvc; ++ ++ protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception { ++ return mockMvc.perform(builder); ++ } ++} +Index: src/main/java/ru/javaops/bootjava/web/AccountController.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java +--- a/src/main/java/ru/javaops/bootjava/web/AccountController.java (revision 7985ad9843bcc32490f91be27cea73940f15af6b) ++++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java (date 1618238843604) +@@ -36,11 +36,13 @@ + * RequestMapping("/${spring.data.rest.basePath}/account") give "Not enough variable values" + */ + @RestController +-@RequestMapping("/api/account") ++@RequestMapping(AccountController.URL) + @AllArgsConstructor + @Slf4j + @Tag(name = "Account Controller") + public class AccountController implements RepresentationModelProcessor { ++ static final String URL = "/api/account"; ++ + @SuppressWarnings("unchecked") + private static final RepresentationModelAssemblerSupport> ASSEMBLER = + new RepresentationModelAssemblerSupport<>(AccountController.class, (Class>) (Class) EntityModel.class) { +Index: src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +new file mode 100644 +--- /dev/null (date 1618238555075) ++++ b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java (date 1618238555075) +@@ -0,0 +1,45 @@ ++package ru.javaops.bootjava.web; ++ ++import org.junit.jupiter.api.Assertions; ++import org.junit.jupiter.api.Test; ++import org.springframework.beans.factory.annotation.Autowired; ++import org.springframework.hateoas.MediaTypes; ++import org.springframework.security.test.context.support.WithUserDetails; ++import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; ++import ru.javaops.bootjava.repository.UserRepository; ++ ++import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; ++import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; ++import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; ++import static ru.javaops.bootjava.UserTestUtil.*; ++import static ru.javaops.bootjava.web.AccountController.URL; ++ ++class AccountControllerTest extends AbstractControllerTest { ++ ++ @Autowired ++ private UserRepository userRepository; ++ ++ @Test ++ @WithUserDetails(value = USER_MAIL) ++ void get() throws Exception { ++ perform(MockMvcRequestBuilders.get(URL)) ++ .andExpect(status().isOk()) ++ .andDo(print()) ++ .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)); ++ } ++ ++ @Test ++ void getUnAuth() throws Exception { ++ perform(MockMvcRequestBuilders.get(URL)) ++ .andExpect(status().isUnauthorized()); ++ } ++ ++ @Test ++ @WithUserDetails(value = USER_MAIL) ++ void delete() throws Exception { ++ perform(MockMvcRequestBuilders.delete(URL)) ++ .andExpect(status().isNoContent()); ++ Assertions.assertFalse(userRepository.findById(USER_ID).isPresent()); ++ Assertions.assertTrue(userRepository.findById(ADMIN_ID).isPresent()); ++ } ++} +\ No newline at end of file diff --git a/patch/6_04_json_support.patch b/patch/6_04_json_support.patch new file mode 100644 index 0000000..9545741 --- /dev/null +++ b/patch/6_04_json_support.patch @@ -0,0 +1,244 @@ +Index: src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +--- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java (revision 10188a9f795ad864534187e8520402e119cfec59) ++++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java (date 1618239089336) +@@ -1,5 +1,6 @@ + package ru.javaops.bootjava.config; + ++import com.fasterxml.jackson.databind.ObjectMapper; + import lombok.AllArgsConstructor; + import lombok.extern.slf4j.Slf4j; + import org.springframework.beans.factory.annotation.Autowired; +@@ -18,7 +19,9 @@ + import ru.javaops.bootjava.model.Role; + import ru.javaops.bootjava.model.User; + import ru.javaops.bootjava.repository.UserRepository; ++import ru.javaops.bootjava.util.JsonUtil; + ++import javax.annotation.PostConstruct; + import java.util.Optional; + + @Configuration +@@ -29,6 +32,12 @@ + + public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + private final UserRepository userRepository; ++ private final ObjectMapper objectMapper; ++ ++ @PostConstruct ++ void setMapper() { ++ JsonUtil.setObjectMapper(objectMapper); ++ } + + @Bean + public UserDetailsService userDetailsService() { +Index: src/main/java/ru/javaops/bootjava/util/JsonUtil.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/util/JsonUtil.java b/src/main/java/ru/javaops/bootjava/util/JsonUtil.java +new file mode 100644 +--- /dev/null (date 1618239089347) ++++ b/src/main/java/ru/javaops/bootjava/util/JsonUtil.java (date 1618239089347) +@@ -0,0 +1,31 @@ ++package ru.javaops.bootjava.util; ++ ++import com.fasterxml.jackson.core.JsonProcessingException; ++import com.fasterxml.jackson.databind.ObjectMapper; ++import com.fasterxml.jackson.databind.ObjectReader; ++import lombok.experimental.UtilityClass; ++ ++import java.io.IOException; ++import java.util.List; ++ ++@UtilityClass ++public class JsonUtil { ++ private static ObjectMapper objectMapper; ++ ++ public static void setObjectMapper(ObjectMapper objectMapper) { ++ JsonUtil.objectMapper = objectMapper; ++ } ++ ++ public static List readValues(String json, Class clazz) throws IOException { ++ ObjectReader reader = objectMapper.readerFor(clazz); ++ return reader.readValues(json).readAll(); ++ } ++ ++ public static T readValue(String json, Class clazz) throws JsonProcessingException { ++ return objectMapper.readValue(json, clazz); ++ } ++ ++ public static String writeValue(T obj) throws JsonProcessingException { ++ return objectMapper.writeValueAsString(obj); ++ } ++} +\ No newline at end of file +Index: src/test/java/ru/javaops/bootjava/web/UserControllerTest.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java +--- a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java (revision 10188a9f795ad864534187e8520402e119cfec59) ++++ b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java (date 1618239089364) +@@ -4,14 +4,18 @@ + import org.junit.jupiter.api.Test; + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.hateoas.MediaTypes; ++import org.springframework.http.MediaType; + import org.springframework.security.test.context.support.WithUserDetails; + import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; ++import ru.javaops.bootjava.UserTestUtil; ++import ru.javaops.bootjava.model.User; + import ru.javaops.bootjava.repository.UserRepository; + + import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; + import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import static ru.javaops.bootjava.UserTestUtil.*; ++import static ru.javaops.bootjava.util.JsonUtil.writeValue; + + class UserControllerTest extends AbstractControllerTest { + static final String URL = "/api/users/"; +@@ -61,4 +65,24 @@ + Assertions.assertFalse(userRepository.findById(USER_ID).isPresent()); + Assertions.assertTrue(userRepository.findById(ADMIN_ID).isPresent()); + } ++ ++ @Test ++ @WithUserDetails(value = ADMIN_MAIL) ++ void create() throws Exception { ++ User newUser = UserTestUtil.getNew(); ++ perform(MockMvcRequestBuilders.post(URL) ++ .contentType(MediaType.APPLICATION_JSON) ++ .content(writeValue(newUser))) ++ .andExpect(status().isCreated()); ++ } ++ ++ @Test ++ @WithUserDetails(value = ADMIN_MAIL) ++ void update() throws Exception { ++ User updated = UserTestUtil.getUpdated(); ++ perform(MockMvcRequestBuilders.put(URL + USER_ID) ++ .contentType(MediaType.APPLICATION_JSON) ++ .content(writeValue(updated))) ++ .andExpect(status().isNoContent()); ++ } + } +\ No newline at end of file +Index: src/test/java/ru/javaops/bootjava/UserTestUtil.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/UserTestUtil.java b/src/test/java/ru/javaops/bootjava/UserTestUtil.java +--- a/src/test/java/ru/javaops/bootjava/UserTestUtil.java (revision 10188a9f795ad864534187e8520402e119cfec59) ++++ b/src/test/java/ru/javaops/bootjava/UserTestUtil.java (date 1618239089369) +@@ -1,8 +1,21 @@ + package ru.javaops.bootjava; + ++import ru.javaops.bootjava.model.Role; ++import ru.javaops.bootjava.model.User; ++ ++import java.util.List; ++ + public class UserTestUtil { + public static final int USER_ID = 1; + public static final int ADMIN_ID = 2; + public static final String USER_MAIL = "user@gmail.com"; + public static final String ADMIN_MAIL = "admin@javaops.ru"; ++ ++ public static User getNew() { ++ return new User(null, "new@gmail.com", "New_First", "New_Last", "newpass", List.of(Role.USER)); ++ } ++ ++ public static User getUpdated() { ++ return new User(USER_ID, "user_update@gmail.com", "User_First_Update", "User_Last_Update", "password_update", List.of(Role.USER)); ++ } + } +Index: src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +--- a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java (revision 10188a9f795ad864534187e8520402e119cfec59) ++++ b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java (date 1618239089359) +@@ -4,14 +4,18 @@ + import org.junit.jupiter.api.Test; + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.hateoas.MediaTypes; ++import org.springframework.http.MediaType; + import org.springframework.security.test.context.support.WithUserDetails; + import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; ++import ru.javaops.bootjava.UserTestUtil; ++import ru.javaops.bootjava.model.User; + import ru.javaops.bootjava.repository.UserRepository; + + import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; + import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import static ru.javaops.bootjava.UserTestUtil.*; ++import static ru.javaops.bootjava.util.JsonUtil.writeValue; + import static ru.javaops.bootjava.web.AccountController.URL; + + class AccountControllerTest extends AbstractControllerTest { +@@ -42,4 +46,24 @@ + Assertions.assertFalse(userRepository.findById(USER_ID).isPresent()); + Assertions.assertTrue(userRepository.findById(ADMIN_ID).isPresent()); + } ++ ++ @Test ++ void register() throws Exception { ++ User newUser = UserTestUtil.getNew(); ++ perform(MockMvcRequestBuilders.post(URL + "/register") ++ .contentType(MediaType.APPLICATION_JSON) ++ .content(writeValue(newUser))) ++ .andExpect(status().isCreated()); ++ } ++ ++ @Test ++ @WithUserDetails(value = USER_MAIL) ++ void update() throws Exception { ++ User updated = UserTestUtil.getUpdated(); ++ perform(MockMvcRequestBuilders.put(URL) ++ .contentType(MediaType.APPLICATION_JSON) ++ .content(writeValue(updated))) ++ .andDo(print()) ++ .andExpect(status().isNoContent()); ++ } + } +\ No newline at end of file +Index: src/main/java/ru/javaops/bootjava/model/User.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java +--- a/src/main/java/ru/javaops/bootjava/model/User.java (revision 10188a9f795ad864534187e8520402e119cfec59) ++++ b/src/main/java/ru/javaops/bootjava/model/User.java (date 1618239089341) +@@ -11,6 +11,8 @@ + import javax.validation.constraints.NotEmpty; + import javax.validation.constraints.Size; + import java.io.Serializable; ++import java.util.Collection; ++import java.util.EnumSet; + import java.util.Set; + + @Entity +@@ -21,6 +23,10 @@ + @AllArgsConstructor + @ToString(callSuper = true, exclude = {"password"}) + public class User extends BaseEntity implements Serializable { ++ public User(Integer id, String email, String firstName, String lastName, String password, Collection roles) { ++ this(email, firstName, lastName, password, EnumSet.copyOf(roles)); ++ this.id = id; ++ } + + @Column(name = "email", nullable = false, unique = true) + @Email diff --git a/patch/6_05_test_body_check.patch b/patch/6_05_test_body_check.patch new file mode 100644 index 0000000..734628a --- /dev/null +++ b/patch/6_05_test_body_check.patch @@ -0,0 +1,152 @@ +Index: src/test/java/ru/javaops/bootjava/web/UserControllerTest.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java +--- a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java (revision 24400efd454541ac73b52233cdab2565bd12f0b1) ++++ b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java (date 1618188669468) +@@ -29,12 +29,14 @@ + perform(MockMvcRequestBuilders.get(URL + USER_ID)) + .andExpect(status().isOk()) + .andDo(print()) +- .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)); ++ .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) ++ .andExpect(jsonMatcher(user, UserTestUtil::assertNoIdEquals)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getAll() throws Exception { ++ // TODO check content yourself + perform(MockMvcRequestBuilders.get(URL)) + .andExpect(status().isOk()) + .andDo(print()) +@@ -47,7 +49,8 @@ + perform(MockMvcRequestBuilders.get(URL + "search/by-email?email=" + ADMIN_MAIL)) + .andExpect(status().isOk()) + .andDo(print()) +- .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)); ++ .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) ++ .andExpect(jsonMatcher(admin, UserTestUtil::assertNoIdEquals)); + } + + @Test +@@ -73,7 +76,8 @@ + perform(MockMvcRequestBuilders.post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(newUser))) +- .andExpect(status().isCreated()); ++ .andExpect(status().isCreated()) ++ .andExpect(jsonMatcher(newUser, UserTestUtil::assertNoIdEquals)); + } + + @Test +@@ -84,5 +88,6 @@ + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(updated))) + .andExpect(status().isNoContent()); ++ UserTestUtil.assertEquals(updated, userRepository.findById(USER_ID).orElseThrow()); + } + } +\ No newline at end of file +Index: src/test/java/ru/javaops/bootjava/UserTestUtil.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/UserTestUtil.java b/src/test/java/ru/javaops/bootjava/UserTestUtil.java +--- a/src/test/java/ru/javaops/bootjava/UserTestUtil.java (revision 24400efd454541ac73b52233cdab2565bd12f0b1) ++++ b/src/test/java/ru/javaops/bootjava/UserTestUtil.java (date 1618212524940) +@@ -1,15 +1,25 @@ + package ru.javaops.bootjava; + ++import com.fasterxml.jackson.core.JsonProcessingException; ++import org.springframework.test.web.servlet.MvcResult; ++import org.springframework.test.web.servlet.ResultMatcher; + import ru.javaops.bootjava.model.Role; + import ru.javaops.bootjava.model.User; ++import ru.javaops.bootjava.util.JsonUtil; + ++import java.io.UnsupportedEncodingException; + import java.util.List; ++import java.util.function.BiConsumer; ++ ++import static org.assertj.core.api.Assertions.assertThat; + + public class UserTestUtil { + public static final int USER_ID = 1; + public static final int ADMIN_ID = 2; + public static final String USER_MAIL = "user@gmail.com"; + public static final String ADMIN_MAIL = "admin@javaops.ru"; ++ public static final User user = new User(USER_ID, USER_MAIL, "User_First", "User_Last", "password", List.of(Role.USER)); ++ public static final User admin = new User(ADMIN_ID, ADMIN_MAIL, "Admin_First", "Admin_Last", "admin", List.of(Role.ADMIN, Role.USER)); + + public static User getNew() { + return new User(null, "new@gmail.com", "New_First", "New_Last", "newpass", List.of(Role.USER)); +@@ -18,4 +28,22 @@ + public static User getUpdated() { + return new User(USER_ID, "user_update@gmail.com", "User_First_Update", "User_Last_Update", "password_update", List.of(Role.USER)); + } ++ ++ public static void assertEquals(User actual, User expected) { ++ assertThat(actual).usingRecursiveComparison().ignoringFields("password").isEqualTo(expected); ++ } ++ ++ // No id in HATEOAS answer ++ public static void assertNoIdEquals(User actual, User expected) { ++ assertThat(actual).usingRecursiveComparison().ignoringFields("id", "password").isEqualTo(expected); ++ } ++ ++ public static User asUser(MvcResult mvcResult) throws UnsupportedEncodingException, JsonProcessingException { ++ String jsonActual = mvcResult.getResponse().getContentAsString(); ++ return JsonUtil.readValue(jsonActual, User.class); ++ } ++ ++ public static ResultMatcher jsonMatcher(User expected, BiConsumer equalsAssertion) { ++ return mvcResult -> equalsAssertion.accept(asUser(mvcResult), expected); ++ } + } +Index: src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +--- a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java (revision 24400efd454541ac73b52233cdab2565bd12f0b1) ++++ b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java (date 1618167196023) +@@ -29,7 +29,8 @@ + perform(MockMvcRequestBuilders.get(URL)) + .andExpect(status().isOk()) + .andDo(print()) +- .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)); ++ .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) ++ .andExpect(jsonMatcher(user, UserTestUtil::assertEquals)); + } + + @Test +@@ -50,10 +51,14 @@ + @Test + void register() throws Exception { + User newUser = UserTestUtil.getNew(); +- perform(MockMvcRequestBuilders.post(URL + "/register") ++ User registered = asUser(perform(MockMvcRequestBuilders.post(URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(newUser))) +- .andExpect(status().isCreated()); ++ .andExpect(status().isCreated()).andReturn()); ++ int newId = registered.id(); ++ newUser.setId(newId); ++ UserTestUtil.assertEquals(registered, newUser); ++ UserTestUtil.assertEquals(registered, userRepository.findById(newId).orElseThrow()); + } + + @Test +@@ -65,5 +70,6 @@ + .content(writeValue(updated))) + .andDo(print()) + .andExpect(status().isNoContent()); ++ UserTestUtil.assertEquals(updated, userRepository.findById(USER_ID).orElseThrow()); + } + } +\ No newline at end of file diff --git a/patch/6_06_add_cache.patch b/patch/6_06_add_cache.patch new file mode 100644 index 0000000..beab2c4 --- /dev/null +++ b/patch/6_06_add_cache.patch @@ -0,0 +1,135 @@ +Index: src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +--- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java (revision 249018bd51c05031eb93743044c48e1a9f774661) ++++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java (date 1618522947481) +@@ -43,7 +43,7 @@ + public UserDetailsService userDetailsService() { + return email -> { + log.debug("Authenticating '{}'", email); +- Optional optionalUser = userRepository.findByEmailIgnoreCase(email); ++ Optional optionalUser = userRepository.findByEmailIgnoreCase(email.toLowerCase()); + return new AuthUser(optionalUser.orElseThrow( + () -> new UsernameNotFoundException("User '" + email + "' was not found"))); + }; +Index: pom.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/pom.xml b/pom.xml +--- a/pom.xml (revision 249018bd51c05031eb93743044c48e1a9f774661) ++++ b/pom.xml (date 1618522947533) +@@ -66,6 +66,17 @@ + springdoc-openapi-security + ${springdoc.version} + ++ ++ ++ ++ org.springframework.boot ++ spring-boot-starter-cache ++ ++ ++ com.github.ben-manes.caffeine ++ caffeine ++ ++ + + com.h2database + h2 +Index: src/main/java/ru/javaops/bootjava/repository/UserRepository.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +--- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java (revision 249018bd51c05031eb93743044c48e1a9f774661) ++++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java (date 1618522972137) +@@ -1,6 +1,7 @@ + package ru.javaops.bootjava.repository; + + import io.swagger.v3.oas.annotations.tags.Tag; ++import org.springframework.cache.annotation.Cacheable; + import org.springframework.data.domain.Page; + import org.springframework.data.domain.Pageable; + import org.springframework.data.jpa.repository.JpaRepository; +@@ -17,6 +18,7 @@ + + @RestResource(rel = "by-email", path = "by-email") + @Query("SELECT u FROM User u WHERE u.email = LOWER(:email)") ++ @Cacheable("users") + Optional findByEmailIgnoreCase(String email); + + @RestResource(rel = "by-lastname", path = "by-lastname") +Index: src/main/java/ru/javaops/bootjava/web/AccountController.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java +--- a/src/main/java/ru/javaops/bootjava/web/AccountController.java (revision 249018bd51c05031eb93743044c48e1a9f774661) ++++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java (date 1618522947516) +@@ -3,6 +3,8 @@ + import io.swagger.v3.oas.annotations.tags.Tag; + import lombok.AllArgsConstructor; + import lombok.extern.slf4j.Slf4j; ++import org.springframework.cache.annotation.CacheEvict; ++import org.springframework.cache.annotation.CachePut; + import org.springframework.data.rest.webmvc.RepositoryLinksResource; + import org.springframework.hateoas.EntityModel; + import org.springframework.hateoas.MediaTypes; +@@ -62,6 +64,7 @@ + + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) ++ @CacheEvict(value = "users", key = "#authUser.username") + public void delete(@AuthenticationPrincipal AuthUser authUser) { + log.info("delete {}", authUser); + userRepository.deleteById(authUser.id()); +@@ -82,7 +85,8 @@ + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) +- public void update(@Valid @RequestBody User user, @AuthenticationPrincipal AuthUser authUser) { ++ @CachePut(value = "users", key = "#authUser.username") ++ public User update(@Valid @RequestBody User user, @AuthenticationPrincipal AuthUser authUser) { + log.info("update {} to {}", authUser, user); + User oldUser = authUser.getUser(); + ValidationUtil.assureIdConsistent(user, oldUser.id()); +@@ -90,7 +94,7 @@ + if (user.getPassword() == null) { + user.setPassword(oldUser.getPassword()); + } +- userRepository.save(user); ++ return userRepository.save(user); + } + + /* +Index: src/main/java/ru/javaops/bootjava/config/AppConfig.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/config/AppConfig.java +--- a/src/main/java/ru/javaops/bootjava/config/AppConfig.java (revision 249018bd51c05031eb93743044c48e1a9f774661) ++++ b/src/main/java/ru/javaops/bootjava/config/AppConfig.java (date 1618522947467) +@@ -2,6 +2,7 @@ + + import lombok.extern.slf4j.Slf4j; + import org.h2.tools.Server; ++import org.springframework.cache.annotation.EnableCaching; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + +@@ -9,6 +10,7 @@ + + @Configuration + @Slf4j ++@EnableCaching + public class AppConfig { + + /* diff --git a/patch/6_07_update_cache.patch b/patch/6_07_update_cache.patch new file mode 100644 index 0000000..159e3d9 --- /dev/null +++ b/patch/6_07_update_cache.patch @@ -0,0 +1,82 @@ +Index: src/main/java/ru/javaops/bootjava/repository/UserRepository.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +--- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java (revision b9b8521bf02499951ad568bab8d7d32ad9ca0962) ++++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java (date 1618523204614) +@@ -1,10 +1,13 @@ + package ru.javaops.bootjava.repository; + + import io.swagger.v3.oas.annotations.tags.Tag; ++import org.springframework.cache.annotation.CacheEvict; ++import org.springframework.cache.annotation.CachePut; + import org.springframework.cache.annotation.Cacheable; + import org.springframework.data.domain.Page; + import org.springframework.data.domain.Pageable; + import org.springframework.data.jpa.repository.JpaRepository; ++import org.springframework.data.jpa.repository.Modifying; + import org.springframework.data.jpa.repository.Query; + import org.springframework.data.rest.core.annotation.RestResource; + import org.springframework.transaction.annotation.Transactional; +@@ -23,4 +26,22 @@ + + @RestResource(rel = "by-lastname", path = "by-lastname") + Page findByLastNameContainingIgnoreCase(String lastName, Pageable page); ++ ++ @Override ++ @Modifying ++ @Transactional ++ @CachePut(value = "users", key = "#user.email") ++ User save(User user); ++ ++ @Override ++ @Modifying ++ @Transactional ++ @CacheEvict(value = "users", key = "#user.email") ++ void delete(User user); ++ ++ @Override ++ @Modifying ++ @Transactional ++ @CacheEvict(value = "users", allEntries = true) ++ void deleteById(Integer integer); + } +\ No newline at end of file +Index: src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java +--- a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java (revision b9b8521bf02499951ad568bab8d7d32ad9ca0962) ++++ b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java (date 1618523096769) +@@ -3,6 +3,7 @@ + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; + import org.springframework.boot.test.context.SpringBootTest; ++import org.springframework.test.context.ActiveProfiles; + import org.springframework.test.web.servlet.MockMvc; + import org.springframework.test.web.servlet.ResultActions; + import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +@@ -12,6 +13,7 @@ + @SpringBootTest + @Transactional + @AutoConfigureMockMvc ++@ActiveProfiles("test") + //https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications-testing-with-mock-environment + public abstract class AbstractControllerTest { + +Index: src/test/resources/application-test.yaml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml +new file mode 100644 +--- /dev/null (date 1618522947525) ++++ b/src/test/resources/application-test.yaml (date 1618522947525) +@@ -0,0 +1,1 @@ ++spring.cache.type: none +\ No newline at end of file diff --git a/pom.xml b/pom.xml index ca66a72..dd99c93 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 2.4.0 + 2.4.4 ru.javaops.bootjava @@ -17,6 +17,7 @@ 15 + 1.5.6 @@ -28,24 +29,77 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-rest + + + org.springframework.boot + spring-boot-starter-security + + + + + + + org.springdoc + springdoc-openapi-ui + ${springdoc.version} + + + org.springdoc + springdoc-openapi-data-rest + ${springdoc.version} + + + org.springdoc + springdoc-openapi-security + ${springdoc.version} + + + + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + com.h2database h2 - runtime org.projectlombok lombok + 1.18.20 true - + org.springframework.boot spring-boot-starter-test test + + + org.springframework.security + spring-security-test + test + @@ -53,6 +107,14 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + diff --git a/src/main/java/ru/javaops/bootjava/AuthUser.java b/src/main/java/ru/javaops/bootjava/AuthUser.java new file mode 100644 index 0000000..d4bf023 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/AuthUser.java @@ -0,0 +1,22 @@ +package ru.javaops.bootjava; + +import lombok.Getter; +import lombok.ToString; +import org.springframework.lang.NonNull; +import ru.javaops.bootjava.model.User; + +@Getter +@ToString(of = "user") +public class AuthUser extends org.springframework.security.core.userdetails.User { + + private final User user; + + public AuthUser(@NonNull User user) { + super(user.getEmail(), user.getPassword(), user.getRoles()); + this.user = user; + } + + public int id() { + return user.id(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java index 3326420..ee6a1ed 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java @@ -1,11 +1,9 @@ package ru.javaops.bootjava; -import lombok.AllArgsConstructor; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -@AllArgsConstructor public class RestaurantVotingApplication { public static void main(String[] args) { diff --git a/src/main/java/ru/javaops/bootjava/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/config/AppConfig.java new file mode 100644 index 0000000..4a9742b --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/AppConfig.java @@ -0,0 +1,28 @@ +package ru.javaops.bootjava.config; + +import lombok.extern.slf4j.Slf4j; +import org.h2.tools.Server; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.sql.SQLException; + +@Configuration +@Slf4j +@EnableCaching +public class AppConfig { + +/* + @Bean(initMethod = "start", destroyMethod = "stop") + public Server h2WebServer() throws SQLException { + return Server.createWebServer("-web", "-webAllowOthers", "-webPort", "8082"); + } +*/ + + @Bean(initMethod = "start", destroyMethod = "stop") + public Server h2Server() throws SQLException { + log.info("Start H2 TCP server"); + return Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", "9092"); + } +} diff --git a/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java b/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java new file mode 100644 index 0000000..4f6293d --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java @@ -0,0 +1,39 @@ +package ru.javaops.bootjava.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springdoc.core.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +//https://sabljakovich.medium.com/adding-basic-auth-authorization-option-to-openapi-swagger-documentation-java-spring-95abbede27e9 +@SecurityScheme( + name = "basicAuth", + type = SecuritySchemeType.HTTP, + scheme = "basic" +) +@OpenAPIDefinition( + info = @Info( + title = "REST API documentation", + version = "1.0", + description = "Приложение по курсу BootJava", + contact = @Contact(url = "https://javaops.ru/#contacts", name = "Grigory Kislin", email = "admin@javaops.ru") + ), + security = @SecurityRequirement(name = "basicAuth") +) +public class OpenApiConfig { + + @Bean + public GroupedOpenApi api() { + return GroupedOpenApi.builder() + .group("REST API") + .pathsToMatch("/api/**") + .pathsToExclude("/api/profile/**") + .build(); + } +} diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java new file mode 100644 index 0000000..2e18324 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -0,0 +1,68 @@ +package ru.javaops.bootjava.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +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.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import ru.javaops.bootjava.AuthUser; +import ru.javaops.bootjava.model.Role; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; +import ru.javaops.bootjava.util.JsonUtil; + +import javax.annotation.PostConstruct; +import java.util.Optional; + +@Configuration +@EnableWebSecurity +@Slf4j +@AllArgsConstructor +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + + @PostConstruct + void setMapper() { + JsonUtil.setObjectMapper(objectMapper); + } + + @Bean + public UserDetailsService userDetailsService() { + return email -> { + log.debug("Authenticating '{}'", email); + Optional optionalUser = userRepository.findByEmailIgnoreCase(email.toLowerCase()); + return new AuthUser(optionalUser.orElseThrow( + () -> new UsernameNotFoundException("User '" + email + "' was not found"))); + }; + } + + @Autowired + public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { + auth.userDetailsService(userDetailsService()) + .passwordEncoder(PASSWORD_ENCODER); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.authorizeRequests() + .antMatchers("/api/account/register").anonymous() + .antMatchers("/api/account").hasRole(Role.USER.name()) + .antMatchers("/api/**").hasRole(Role.ADMIN.name()) + .and().httpBasic() + .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and().csrf().disable(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/error/AppException.java b/src/main/java/ru/javaops/bootjava/error/AppException.java new file mode 100644 index 0000000..809caad --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/error/AppException.java @@ -0,0 +1,16 @@ +package ru.javaops.bootjava.error; + +import lombok.Getter; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Getter +public class AppException extends ResponseStatusException { + private final ErrorAttributeOptions options; + + public AppException(HttpStatus status, String message, ErrorAttributeOptions options) { + super(status, message); + this.options = options; + } +} diff --git a/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java b/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java new file mode 100644 index 0000000..d899069 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java @@ -0,0 +1,12 @@ +package ru.javaops.bootjava.error; + +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.http.HttpStatus; + +import static org.springframework.boot.web.error.ErrorAttributeOptions.Include.*; + +public class IllegalRequestDataException extends AppException { + public IllegalRequestDataException(String msg) { + super(HttpStatus.UNPROCESSABLE_ENTITY, msg, ErrorAttributeOptions.of(MESSAGE, STACK_TRACE, EXCEPTION)); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java new file mode 100644 index 0000000..72ed0fc --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -0,0 +1,54 @@ +package ru.javaops.bootjava.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.*; +import org.springframework.data.domain.Persistable; +import org.springframework.data.util.ProxyUtils; +import org.springframework.util.Assert; + +import javax.persistence.*; + +@MappedSuperclass +// https://stackoverflow.com/a/6084701/548473 +@Access(AccessType.FIELD) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@ToString +public abstract class BaseEntity implements Persistable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + protected Integer id; + + // doesn't work for hibernate lazy proxy + public int id() { + Assert.notNull(id, "Entity must have id"); + return id; + } + + @JsonIgnore + @Override + public boolean isNew() { + return id == null; + } + + // https://stackoverflow.com/questions/1638723 + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || !getClass().equals(ProxyUtils.getUserClass(o))) { + return false; + } + BaseEntity that = (BaseEntity) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return id == null ? 0 : id; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/model/Role.java b/src/main/java/ru/javaops/bootjava/model/Role.java new file mode 100644 index 0000000..08bc76d --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/Role.java @@ -0,0 +1,14 @@ +package ru.javaops.bootjava.model; + +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { + USER, + ADMIN; + + @Override + public String getAuthority() { + // https://stackoverflow.com/a/19542316/548473 + return "ROLE_" + name(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java new file mode 100644 index 0000000..effb0b1 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -0,0 +1,60 @@ +package ru.javaops.bootjava.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import lombok.*; +import org.springframework.util.StringUtils; +import ru.javaops.bootjava.util.JsonDeserializers; + +import javax.persistence.*; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Size; +import java.io.Serializable; +import java.util.Collection; +import java.util.EnumSet; +import java.util.Set; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@ToString(callSuper = true, exclude = {"password"}) +public class User extends BaseEntity implements Serializable { + public User(Integer id, String email, String firstName, String lastName, String password, Collection roles) { + this(email, firstName, lastName, password, EnumSet.copyOf(roles)); + this.id = id; + } + + @Column(name = "email", nullable = false, unique = true) + @Email + @NotEmpty + @Size(max = 128) + private String email; + + @Column(name = "first_name") + @Size(max = 128) + private String firstName; + + @Column(name = "last_name") + @Size(max = 128) + private String lastName; + + @Column(name = "password") + @Size(max = 256) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @JsonDeserialize(using = JsonDeserializers.PasswordDeserializer.class) + private String password; + + @Enumerated(EnumType.STRING) + @CollectionTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"), uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "role"}, name = "user_roles_unique")}) + @Column(name = "role") + @ElementCollection(fetch = FetchType.EAGER) + private Set roles; + + public void setEmail(String email) { + this.email = StringUtils.hasText(email) ? email.toLowerCase() : null; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java new file mode 100644 index 0000000..a579ae6 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -0,0 +1,47 @@ +package ru.javaops.bootjava.repository; + +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.rest.core.annotation.RestResource; +import org.springframework.transaction.annotation.Transactional; +import ru.javaops.bootjava.model.User; + +import java.util.Optional; + +@Transactional(readOnly = true) +@Tag(name = "User Controller") +public interface UserRepository extends JpaRepository { + + @RestResource(rel = "by-email", path = "by-email") + @Query("SELECT u FROM User u WHERE u.email = LOWER(:email)") + @Cacheable("users") + Optional findByEmailIgnoreCase(String email); + + @RestResource(rel = "by-lastname", path = "by-lastname") + Page findByLastNameContainingIgnoreCase(String lastName, Pageable page); + + @Override + @Modifying + @Transactional + @CachePut(value = "users", key = "#user.email") + User save(User user); + + @Override + @Modifying + @Transactional + @CacheEvict(value = "users", key = "#user.email") + void delete(User user); + + @Override + @Modifying + @Transactional + @CacheEvict(value = "users", allEntries = true) + void deleteById(Integer integer); +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java new file mode 100644 index 0000000..153afb4 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java @@ -0,0 +1,25 @@ +package ru.javaops.bootjava.util; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.experimental.UtilityClass; +import ru.javaops.bootjava.config.WebSecurityConfig; + +import java.io.IOException; + +@UtilityClass +public class JsonDeserializers { + + // https://stackoverflow.com/a/60995048/548473 + public static class PasswordDeserializer extends JsonDeserializer { + public String deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + ObjectCodec oc = jsonParser.getCodec(); + JsonNode node = oc.readTree(jsonParser); + String rawPassword = node.asText(); + return WebSecurityConfig.PASSWORD_ENCODER.encode(rawPassword); + } + } +} diff --git a/src/main/java/ru/javaops/bootjava/util/JsonUtil.java b/src/main/java/ru/javaops/bootjava/util/JsonUtil.java new file mode 100644 index 0000000..336088a --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/JsonUtil.java @@ -0,0 +1,31 @@ +package ru.javaops.bootjava.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import lombok.experimental.UtilityClass; + +import java.io.IOException; +import java.util.List; + +@UtilityClass +public class JsonUtil { + private static ObjectMapper objectMapper; + + public static void setObjectMapper(ObjectMapper objectMapper) { + JsonUtil.objectMapper = objectMapper; + } + + public static List readValues(String json, Class clazz) throws IOException { + ObjectReader reader = objectMapper.readerFor(clazz); + return reader.readValues(json).readAll(); + } + + public static T readValue(String json, Class clazz) throws JsonProcessingException { + return objectMapper.readValue(json, clazz); + } + + public static String writeValue(T obj) throws JsonProcessingException { + return objectMapper.writeValueAsString(obj); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java new file mode 100644 index 0000000..4d5c29c --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java @@ -0,0 +1,24 @@ +package ru.javaops.bootjava.util; + +import lombok.experimental.UtilityClass; +import ru.javaops.bootjava.error.IllegalRequestDataException; +import ru.javaops.bootjava.model.BaseEntity; + +@UtilityClass +public class ValidationUtil { + + public static void checkNew(BaseEntity entity) { + if (!entity.isNew()) { + throw new IllegalRequestDataException(entity.getClass().getSimpleName() + " must be new (id=null)"); + } + } + + // Conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473) + public static void assureIdConsistent(BaseEntity entity, int id) { + if (entity.isNew()) { + entity.setId(id); + } else if (entity.id() != id) { + throw new IllegalRequestDataException(entity.getClass().getSimpleName() + " must has id=" + id); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java new file mode 100644 index 0000000..aa62ae8 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -0,0 +1,115 @@ +package ru.javaops.bootjava.web; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.rest.webmvc.RepositoryLinksResource; +import org.springframework.data.web.PagedResourcesAssembler; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.PagedModel; +import org.springframework.hateoas.server.RepresentationModelProcessor; +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javaops.bootjava.AuthUser; +import ru.javaops.bootjava.model.Role; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; +import ru.javaops.bootjava.util.ValidationUtil; + +import javax.validation.Valid; +import java.net.URI; +import java.util.EnumSet; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; + +/** + * Do not use {@link org.springframework.data.rest.webmvc.RepositoryRestController (BasePathAwareController} + * Bugs: + * NPE with http://localhost:8080/api/account
+ * data.rest.base-path missed in HAL links
+ * Two endpoints created + *

+ * RequestMapping("/${spring.data.rest.basePath}/account") give "Not enough variable values" + */ +@RestController +@RequestMapping(AccountController.URL) +@AllArgsConstructor +@Slf4j +@Tag(name = "Account Controller") +public class AccountController implements RepresentationModelProcessor { + static final String URL = "/api/account"; + + @SuppressWarnings("unchecked") + private static final RepresentationModelAssemblerSupport> ASSEMBLER = + new RepresentationModelAssemblerSupport<>(AccountController.class, (Class>) (Class) EntityModel.class) { + @Override + public EntityModel toModel(User user) { + return EntityModel.of(user, linkTo(AccountController.class).withSelfRel()); + } + }; + + private final UserRepository userRepository; + + @GetMapping(produces = MediaTypes.HAL_JSON_VALUE) + public EntityModel get(@AuthenticationPrincipal AuthUser authUser) { + log.info("get {}", authUser); + return ASSEMBLER.toModel(authUser.getUser()); + } + + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + @CacheEvict(value = "users", key = "#authUser.username") + public void delete(@AuthenticationPrincipal AuthUser authUser) { + log.info("delete {}", authUser); + userRepository.deleteById(authUser.id()); + } + + @PostMapping(value = "/register", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(value = HttpStatus.CREATED) + public ResponseEntity> register(@Valid @RequestBody User user) { + log.info("register {}", user); + ValidationUtil.checkNew(user); + user.setRoles(EnumSet.of(Role.USER)); + user = userRepository.save(user); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path("/api/account") + .build().toUri(); + return ResponseEntity.created(uriOfNewResource).body(ASSEMBLER.toModel(user)); + } + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + @CachePut(value = "users", key = "#authUser.username") + public User update(@Valid @RequestBody User user, @AuthenticationPrincipal AuthUser authUser) { + log.info("update {} to {}", authUser, user); + User oldUser = authUser.getUser(); + ValidationUtil.assureIdConsistent(user, oldUser.id()); + user.setRoles(oldUser.getRoles()); + if (user.getPassword() == null) { + user.setPassword(oldUser.getPassword()); + } + return userRepository.save(user); + } + + @GetMapping(value = "/pageDemo", produces = MediaTypes.HAL_JSON_VALUE) + public PagedModel> pageDemo(Pageable page, PagedResourcesAssembler pagedAssembler) { + Page users = userRepository.findAll(page); + return pagedAssembler.toModel(users, ASSEMBLER); + } + + @Override + public RepositoryLinksResource process(RepositoryLinksResource resource) { + resource.add(linkTo(AccountController.class).withRel("account")); + return resource; + } +} diff --git a/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..d441662 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java @@ -0,0 +1,38 @@ +package ru.javaops.bootjava.web.error; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.servlet.error.ErrorAttributes; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import ru.javaops.bootjava.error.AppException; + +import java.util.Map; + +@RestControllerAdvice +@AllArgsConstructor +@Slf4j +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private final ErrorAttributes errorAttributes; + + @ExceptionHandler(AppException.class) + public ResponseEntity> appException(AppException ex, WebRequest request) { + log.error("Application Exception", ex); + Map body = errorAttributes.getErrorAttributes(request, ex.getOptions()); + HttpStatus status = ex.getStatus(); + body.put("status", status.value()); + body.put("error", status.getReasonPhrase()); + return ResponseEntity.status(status).body(body); + } + + @Override + protected ResponseEntity handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) { + log.error("Exception", ex); + return super.handleExceptionInternal(ex, body, headers, status, request); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..77f94f8 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,61 @@ +# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html +spring: + jpa: + show-sql: true + open-in-view: false + hibernate: + ddl-auto: create + properties: + # http://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#configurations + hibernate: + format_sql: true + default_batch_fetch_size: 20 + # https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc + jdbc.batch_size: 20 + datasource: + # ImMemory + url: jdbc:h2:mem:voting + # tcp: jdbc:h2:tcp://localhost:9092/mem:voting + # Absolute path + # url: jdbc:h2:C:/projects/bootjava/restorant-voting/db/voting + # tcp: jdbc:h2:tcp://localhost:9092/C:/projects/bootjava/restorant-voting/db/voting + # Relative path form current dir + # url: jdbc:h2:./db/voting + # Relative path from home + # url: jdbc:h2:~/voting + # tcp: jdbc:h2:tcp://localhost:9092/~/voting + username: sa + password: + h2.console.enabled: true + + data.rest: + # https://docs.spring.io/spring-data/rest/docs/current/reference/html/#getting-started.basic-settings + basePath: /api + defaultPageSize: 20 + returnBodyOnCreate: true + +# https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#security-properties +# security: +# user: +# name: user +# password: password +# roles: USER + +logging: + level: + root: WARN + ru.javaops.bootjava: DEBUG +# org.springframework.security.web.FilterChainProxy: DEBUG + +server.servlet: + encoding: + charset: UTF-8 # Charset of HTTP requests and responses. Added to the "Content-Type" header if not set explicitly + enabled: true # Enable http encoding support + force: true + +# Jackson Serialization Issue Resolver +# jackson: +# visibility.field: any +# visibility.getter: none +# visibility.setter: none +# visibility.is-getter: none \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..778d2f3 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,8 @@ +INSERT INTO USERS (EMAIL, FIRST_NAME, LAST_NAME, PASSWORD) +VALUES ('user@gmail.com', 'User_First', 'User_Last', '{noop}password'), + ('admin@javaops.ru', 'Admin_First', 'Admin_Last', '{noop}admin'); + +INSERT INTO USER_ROLE (ROLE, USER_ID) +VALUES ('USER', 1), + ('ADMIN', 2), + ('USER', 2); \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java b/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java deleted file mode 100644 index 52bba6d..0000000 --- a/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java +++ /dev/null @@ -1,12 +0,0 @@ -package ru.javaops.bootjava; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class RestaurantVotingApplicationTests { - - @Test - void contextLoads() { - } -} diff --git a/src/test/java/ru/javaops/bootjava/UserTestUtil.java b/src/test/java/ru/javaops/bootjava/UserTestUtil.java new file mode 100644 index 0000000..70defd4 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/UserTestUtil.java @@ -0,0 +1,54 @@ +package ru.javaops.bootjava; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultMatcher; +import ru.javaops.bootjava.model.Role; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.util.JsonUtil; + +import java.io.UnsupportedEncodingException; +import java.util.List; +import java.util.function.BiConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +public class UserTestUtil { + public static final int USER_ID = 1; + public static final int ADMIN_ID = 2; + public static final String USER_MAIL = "user@gmail.com"; + public static final String ADMIN_MAIL = "admin@javaops.ru"; + public static final User user = new User(USER_ID, USER_MAIL, "User_First", "User_Last", "password", List.of(Role.USER)); + public static final User admin = new User(ADMIN_ID, ADMIN_MAIL, "Admin_First", "Admin_Last", "admin", List.of(Role.ADMIN, Role.USER)); + + public static User getNew() { + return new User(null, "new@gmail.com", "New_First", "New_Last", "newpass", List.of(Role.USER)); + } + + public static User getUpdated() { + return new User(USER_ID, "user_update@gmail.com", "User_First_Update", "User_Last_Update", "password_update", List.of(Role.USER)); + } + + public static void assertEquals(User actual, User expected) { + assertThat(actual).usingRecursiveComparison().ignoringFields("password").isEqualTo(expected); + } + + // No id in HATEOAS answer + public static void assertNoIdEquals(User actual, User expected) { + assertThat(actual).usingRecursiveComparison().ignoringFields("id", "password").isEqualTo(expected); + } + + public static User asUser(MvcResult mvcResult) throws UnsupportedEncodingException, JsonProcessingException { + String jsonActual = mvcResult.getResponse().getContentAsString(); + return JsonUtil.readValue(jsonActual, User.class); + } + + public static ResultMatcher jsonMatcher(User expected, BiConsumer equalsAssertion) { + return new ResultMatcher() { + @Override + public void match(MvcResult mvcResult) throws Exception { + equalsAssertion.accept(asUser(mvcResult), expected); + } + }; + } +} diff --git a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java new file mode 100644 index 0000000..68cf79c --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java @@ -0,0 +1,26 @@ +package ru.javaops.bootjava.web; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; + +//https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +@ActiveProfiles("test") +//https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications-testing-with-mock-environment +public abstract class AbstractControllerTest { + + @Autowired + protected MockMvc mockMvc; + + protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception { + return mockMvc.perform(builder); + } +} diff --git a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java new file mode 100644 index 0000000..9185be8 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java @@ -0,0 +1,75 @@ +package ru.javaops.bootjava.web; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.MediaTypes; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javaops.bootjava.UserTestUtil; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; + +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static ru.javaops.bootjava.UserTestUtil.*; +import static ru.javaops.bootjava.util.JsonUtil.writeValue; +import static ru.javaops.bootjava.web.AccountController.URL; + +class AccountControllerTest extends AbstractControllerTest { + + @Autowired + private UserRepository userRepository; + + @Test + @WithUserDetails(value = USER_MAIL) + void get() throws Exception { + perform(MockMvcRequestBuilders.get(URL)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) + .andExpect(jsonMatcher(user, UserTestUtil::assertEquals)); + } + + @Test + void getUnAuth() throws Exception { + perform(MockMvcRequestBuilders.get(URL)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(URL)) + .andExpect(status().isNoContent()); + Assertions.assertFalse(userRepository.findById(USER_ID).isPresent()); + Assertions.assertTrue(userRepository.findById(ADMIN_ID).isPresent()); + } + + @Test + void register() throws Exception { + User newUser = UserTestUtil.getNew(); + User registered = asUser(perform(MockMvcRequestBuilders.post(URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(newUser))) + .andExpect(status().isCreated()).andReturn()); + int newId = registered.id(); + newUser.setId(newId); + UserTestUtil.assertEquals(registered, newUser); + UserTestUtil.assertEquals(registered, userRepository.findById(newId).orElseThrow()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void update() throws Exception { + User updated = UserTestUtil.getUpdated(); + perform(MockMvcRequestBuilders.put(URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(updated))) + .andDo(print()) + .andExpect(status().isNoContent()); + UserTestUtil.assertEquals(updated, userRepository.findById(USER_ID).orElseThrow()); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java new file mode 100644 index 0000000..9295ca0 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java @@ -0,0 +1,93 @@ +package ru.javaops.bootjava.web; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.MediaTypes; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javaops.bootjava.UserTestUtil; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; + +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static ru.javaops.bootjava.UserTestUtil.*; +import static ru.javaops.bootjava.util.JsonUtil.writeValue; + +class UserControllerTest extends AbstractControllerTest { + static final String URL = "/api/users/"; + + @Autowired + private UserRepository userRepository; + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void get() throws Exception { + perform(MockMvcRequestBuilders.get(URL + USER_ID)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) + .andExpect(jsonMatcher(user, UserTestUtil::assertNoIdEquals)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getAll() throws Exception { + // TODO check content yourself + perform(MockMvcRequestBuilders.get(URL)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getByEmail() throws Exception { + perform(MockMvcRequestBuilders.get(URL + "search/by-email?email=" + ADMIN_MAIL)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) + .andExpect(jsonMatcher(admin, UserTestUtil::assertNoIdEquals)); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void getForbidden() throws Exception { + perform(MockMvcRequestBuilders.get(URL)) + .andExpect(status().isForbidden()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(URL + USER_ID)) + .andExpect(status().isNoContent()); + Assertions.assertFalse(userRepository.findById(USER_ID).isPresent()); + Assertions.assertTrue(userRepository.findById(ADMIN_ID).isPresent()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void create() throws Exception { + User newUser = UserTestUtil.getNew(); + perform(MockMvcRequestBuilders.post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(newUser))) + .andExpect(status().isCreated()) + .andExpect(jsonMatcher(newUser, UserTestUtil::assertNoIdEquals)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void update() throws Exception { + User updated = UserTestUtil.getUpdated(); + perform(MockMvcRequestBuilders.put(URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(updated))) + .andExpect(status().isNoContent()); + UserTestUtil.assertEquals(updated, userRepository.findById(USER_ID).orElseThrow()); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..be16632 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1 @@ +spring.cache.type: none \ No newline at end of file