From 8be9fddda41233a553dfe0027893dfdb0512506b Mon Sep 17 00:00:00 2001 From: user Date: Tue, 20 Dec 2022 11:10:24 +0300 Subject: [PATCH 01/26] 1_01_user_with_lombok --- pom.xml | 8 +++++++ .../java/ru/javaops/bootjava/model/Role.java | 6 +++++ .../java/ru/javaops/bootjava/model/User.java | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 src/main/java/ru/javaops/bootjava/model/Role.java create mode 100644 src/main/java/ru/javaops/bootjava/model/User.java diff --git a/pom.xml b/pom.xml index ca66a72..5be7a42 100644 --- a/pom.xml +++ b/pom.xml @@ -53,6 +53,14 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + 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..432dde8 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/Role.java @@ -0,0 +1,6 @@ +package ru.javaops.bootjava.model; + +public enum Role { + ROLE_USER, + ROLE_ADMIN +} \ 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..b475761 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -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 From 1670fc8e11eedc9ee03afd0a3732300420e63146 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 20 Dec 2022 11:16:41 +0300 Subject: [PATCH 02/26] 2_01_data_jpa --- pom.xml | 4 +++ .../bootjava/RestaurantVotingApplication.java | 17 +++++++++- .../java/ru/javaops/bootjava/model/User.java | 33 +++++++++++++++---- .../bootjava/repository/UserRepository.java | 7 ++++ src/main/resources/application.properties | 9 +++++ 5 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/repository/UserRepository.java diff --git a/pom.xml b/pom.xml index 5be7a42..bf8aa32 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + com.h2database diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java index 3326420..61d8ff8 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java @@ -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()); + } } diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java index b475761..284f632 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -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 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..590c614 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -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 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e69de29..3492ea9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -0,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 From 324580e1c5d863ed6dba920928c1918f26c74a4c Mon Sep 17 00:00:00 2001 From: user Date: Tue, 20 Dec 2022 11:25:59 +0300 Subject: [PATCH 03/26] 2_02_h2_init --- pom.xml | 1 - .../bootjava/RestaurantVotingApplication.java | 6 ---- .../ru/javaops/bootjava/config/AppConfig.java | 26 ++++++++++++++++ src/main/resources/application.properties | 9 ------ src/main/resources/application.yaml | 30 +++++++++++++++++++ src/main/resources/data.sql | 8 +++++ 6 files changed, 64 insertions(+), 16 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/config/AppConfig.java delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/data.sql diff --git a/pom.xml b/pom.xml index bf8aa32..0fe4458 100644 --- a/pom.xml +++ b/pom.xml @@ -36,7 +36,6 @@ com.h2database h2 - runtime org.projectlombok diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java index 61d8ff8..d3b1792 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java @@ -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 @@ public static void main(String[] 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()); } } 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..19dbc45 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/AppConfig.java @@ -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"); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 3492ea9..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,9 +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 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..6c6343d --- /dev/null +++ b/src/main/resources/application.yaml @@ -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 diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..0fe391f --- /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', '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 From 09869755c6e145e56922b6bd56b54d3391ff8fb7 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 20 Dec 2022 11:39:25 +0300 Subject: [PATCH 04/26] 2_03_model_query --- .../bootjava/RestaurantVotingApplication.java | 2 +- .../ru/javaops/bootjava/model/BaseEntity.java | 52 +++++++++++++++++++ .../java/ru/javaops/bootjava/model/User.java | 3 +- .../bootjava/repository/UserRepository.java | 11 ++++ src/main/resources/application.yaml | 1 - 5 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/model/BaseEntity.java diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java index d3b1792..fa56af5 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java @@ -18,6 +18,6 @@ public static void main(String[] args) { @Override public void run(ApplicationArguments args) { - System.out.println(userRepository.findAll()); + System.out.println(userRepository.findByLastNameContainingIgnoreCase("last")); } } 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..4a697e5 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -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 diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java index 284f632..575aaff 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -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 diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java index 590c614..f5d1f0e 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -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 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 6c6343d..7a0d777 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -12,7 +12,6 @@ spring: 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 From 5349f60119dc6fd25cf218beacec27792a8dae25 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 20 Dec 2022 12:47:02 +0300 Subject: [PATCH 05/26] 3_01_jpa_data_rest --- pom.xml | 12 +++++++++++- .../javaops/bootjava/repository/UserRepository.java | 3 +++ src/main/resources/application.yaml | 7 ++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 0fe4458..5aabae1 100644 --- a/pom.xml +++ b/pom.xml @@ -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/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java index f5d1f0e..dc2a413 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -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 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7a0d777..98beb56 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -26,4 +26,9 @@ spring: # 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 From 1b61920b8ff84b0ebeaa6d38385f31a9ecae4488 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 20 Dec 2022 13:54:35 +0300 Subject: [PATCH 06/26] 3_02_jackson --- pom.xml | 9 ++++++--- src/main/java/ru/javaops/bootjava/model/BaseEntity.java | 2 ++ src/main/resources/application.yaml | 9 ++++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 5aabae1..e057c77 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,7 @@ ru.javaops.bootjava restaurant-voting 1.0 + war restaurant-voting Spring Boot 2.x HATEOAS application (BootJava) https://javaops.ru/view/bootjava @@ -28,6 +29,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-tomcat + org.springframework.boot spring-boot-starter-validation @@ -36,13 +41,11 @@ org.springframework.boot spring-boot-starter-data-rest - com.h2database h2 diff --git a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java index 4a697e5..72ed0fc 100644 --- a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -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 @@ public int id() { return id; } + @JsonIgnore @Override public boolean isNew() { return id == null; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 98beb56..9700512 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -31,4 +31,11 @@ spring: 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 From aedb756ae4c3f857d41cdfad7edd5b2e2a7a454e Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 10:47:13 +0300 Subject: [PATCH 07/26] 4_03_javaconfig_encoding --- pom.xml | 21 +++++++++++-------- .../bootjava/config/WebSecurityConfig.java | 21 +++++++++++++++++++ .../bootjava/web/AccountController.java | 17 +++++++++++++++ src/main/resources/application.yaml | 13 ++++++++++++ 4 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java create mode 100644 src/main/java/ru/javaops/bootjava/web/AccountController.java diff --git a/pom.xml b/pom.xml index e057c77..e3437cd 100644 --- a/pom.xml +++ b/pom.xml @@ -31,21 +31,24 @@ org.springframework.boot - spring-boot-starter-tomcat + spring-boot-starter-validation org.springframework.boot - spring-boot-starter-validation + spring-boot-starter-data-rest org.springframework.boot - spring-boot-starter-data-rest + spring-boot-starter-security - - org.springframework.data - spring-data-rest-hal-explorer - runtime - + com.h2database h2 @@ -56,7 +59,7 @@ true - + org.springframework.boot spring-boot-starter-test 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..671e38e --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; + +@Configuration +@EnableWebSecurity +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + @Autowired + public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { + auth.inMemoryAuthentication() + .passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()) + .withUser("user@gmail.com").password("{noop}password").roles("USER").and() + .withUser("admin@javaops.ru").password("{noop}admin").roles("USER", "ADMIN"); + } +} \ 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..f8e0692 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -0,0 +1,17 @@ +package ru.javaops.bootjava.web; + +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = "/api/account") +public class AccountController { + + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public Object get(@AuthenticationPrincipal Object authUser) { + return authUser; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9700512..993777a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -33,6 +33,19 @@ spring: basePath: /api 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 + # Jackson Serialization Issue Resolver # jackson: # visibility.field: any From a2c14fd70761a56c3ec29b290aa0088a6d77be43 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 11:02:25 +0300 Subject: [PATCH 08/26] 4_04_db_security_auth_user --- .../java/ru/javaops/bootjava/AuthUser.java | 22 ++++++++++++++ .../bootjava/config/WebSecurityConfig.java | 30 ++++++++++++++++--- .../java/ru/javaops/bootjava/model/Role.java | 11 +++++-- .../java/ru/javaops/bootjava/model/User.java | 3 +- .../bootjava/web/AccountController.java | 6 ++-- src/main/resources/data.sql | 4 +-- 6 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/AuthUser.java 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/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java index 671e38e..fe291a4 100644 --- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -1,21 +1,43 @@ package ru.javaops.bootjava.config; +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.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import ru.javaops.bootjava.AuthUser; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; + +import java.util.Optional; @Configuration @EnableWebSecurity +@Slf4j +@AllArgsConstructor public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + private final UserRepository userRepository; + + @Bean + public UserDetailsService userDetailsService() { + return email -> { + log.debug("Authenticating '{}'", email); + Optional optionalUser = userRepository.findByEmailIgnoreCase(email); + return new AuthUser(optionalUser.orElseThrow( + () -> new UsernameNotFoundException("User '" + email + "' was not found"))); + }; + } + @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { - auth.inMemoryAuthentication() - .passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()) - .withUser("user@gmail.com").password("{noop}password").roles("USER").and() - .withUser("admin@javaops.ru").password("{noop}admin").roles("USER", "ADMIN"); + auth.userDetailsService(userDetailsService()) + .passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); } } \ 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 index 432dde8..665471f 100644 --- a/src/main/java/ru/javaops/bootjava/model/Role.java +++ b/src/main/java/ru/javaops/bootjava/model/Role.java @@ -1,6 +1,13 @@ package ru.javaops.bootjava.model; -public enum Role { +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { ROLE_USER, - ROLE_ADMIN + ROLE_ADMIN; + + @Override + public String getAuthority() { + return 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 index 575aaff..84ff8ad 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -6,6 +6,7 @@ import javax.validation.constraints.Email; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Size; +import java.io.Serializable; import java.util.Set; @Entity @@ -15,7 +16,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @ToString(callSuper = true, exclude = {"password"}) -public class User extends BaseEntity { +public class User extends BaseEntity implements Serializable { @Column(name = "email", nullable = false, unique = true) @Email diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java index f8e0692..a971c85 100644 --- a/src/main/java/ru/javaops/bootjava/web/AccountController.java +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -5,13 +5,15 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import ru.javaops.bootjava.AuthUser; +import ru.javaops.bootjava.model.User; @RestController @RequestMapping(value = "/api/account") public class AccountController { @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) - public Object get(@AuthenticationPrincipal Object authUser) { - return authUser; + public User get(@AuthenticationPrincipal AuthUser authUser) { + return authUser.getUser(); } } diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 0fe391f..c3541b4 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,6 +1,6 @@ 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'); +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 ('ROLE_USER', 1), From 009e72774e39e5e827ffc60be2b453c82c7e915f Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 11:20:39 +0300 Subject: [PATCH 09/26] 4_05_role_auth --- .../javaops/bootjava/RestaurantVotingApplication.java | 11 +---------- .../ru/javaops/bootjava/config/WebSecurityConfig.java | 10 ++++++++++ src/main/java/ru/javaops/bootjava/model/Role.java | 7 ++++--- src/main/resources/data.sql | 6 +++--- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java index fa56af5..3326420 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java @@ -1,23 +1,14 @@ 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.repository.UserRepository; @SpringBootApplication @AllArgsConstructor -public class RestaurantVotingApplication implements ApplicationRunner { - private final UserRepository userRepository; +public class RestaurantVotingApplication { public static void main(String[] args) { SpringApplication.run(RestaurantVotingApplication.class, args); } - - @Override - public void run(ApplicationArguments args) { - System.out.println(userRepository.findByLastNameContainingIgnoreCase("last")); - } } diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java index fe291a4..158a55c 100644 --- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -6,12 +6,14 @@ 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.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import ru.javaops.bootjava.AuthUser; +import ru.javaops.bootjava.model.Role; import ru.javaops.bootjava.model.User; import ru.javaops.bootjava.repository.UserRepository; @@ -40,4 +42,12 @@ public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception auth.userDetailsService(userDetailsService()) .passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.authorizeRequests() + .antMatchers("/api/account").hasRole(Role.USER.name()) + .antMatchers("/api/**").hasRole(Role.ADMIN.name()) + .and().formLogin(); + } } \ 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 index 665471f..08bc76d 100644 --- a/src/main/java/ru/javaops/bootjava/model/Role.java +++ b/src/main/java/ru/javaops/bootjava/model/Role.java @@ -3,11 +3,12 @@ import org.springframework.security.core.GrantedAuthority; public enum Role implements GrantedAuthority { - ROLE_USER, - ROLE_ADMIN; + USER, + ADMIN; @Override public String getAuthority() { - return name(); + // https://stackoverflow.com/a/19542316/548473 + return "ROLE_" + name(); } } \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index c3541b4..778d2f3 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -3,6 +3,6 @@ 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 ('ROLE_USER', 1), - ('ROLE_ADMIN', 2), - ('ROLE_USER', 2); \ No newline at end of file +VALUES ('USER', 1), + ('ADMIN', 2), + ('USER', 2); \ No newline at end of file From 810cbaea1137cad99f33ef9640c240262e718185 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 12:23:13 +0300 Subject: [PATCH 10/26] 4_06_basic_auth_fix --- .../bootjava/config/WebSecurityConfig.java | 9 ++++++-- .../java/ru/javaops/bootjava/model/User.java | 10 ++++++++ .../bootjava/util/JsonDeserializers.java | 23 +++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java index 158a55c..c65c1d3 100644 --- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -9,9 +9,11 @@ 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; @@ -25,6 +27,7 @@ @AllArgsConstructor public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); private final UserRepository userRepository; @Bean @@ -40,7 +43,7 @@ public UserDetailsService userDetailsService() { @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService()) - .passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); + .passwordEncoder(PASSWORD_ENCODER); } @Override @@ -48,6 +51,8 @@ protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/api/account").hasRole(Role.USER.name()) .antMatchers("/api/**").hasRole(Role.ADMIN.name()) - .and().formLogin(); + .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/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java index 84ff8ad..c61b200 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -1,6 +1,10 @@ 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; @@ -34,6 +38,8 @@ public class User extends BaseEntity implements Serializable { @Column(name = "password") @Size(max = 256) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @JsonDeserialize(using = JsonDeserializers.PasswordDeserializer.class) private String password; @Enumerated(EnumType.STRING) @@ -41,4 +47,8 @@ public class User extends BaseEntity implements Serializable { @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/util/JsonDeserializers.java b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java new file mode 100644 index 0000000..a567a40 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java @@ -0,0 +1,23 @@ +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 ru.javaops.bootjava.config.WebSecurityConfig; + +import java.io.IOException; + +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); + } + } +} From 11d09f2041d32ad5153f0767e7b6fdfe63a79ca9 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 12:56:56 +0300 Subject: [PATCH 11/26] 5_01_account_controller --- .../bootjava/config/WebSecurityConfig.java | 1 + .../javaops/bootjava/util/ValidationUtil.java | 21 ++++++++ .../bootjava/web/AccountController.java | 54 +++++++++++++++++-- 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/util/ValidationUtil.java diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java index c65c1d3..b6438fc 100644 --- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -49,6 +49,7 @@ public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception @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() 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..33d764e --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.util; + +import ru.javaops.bootjava.model.BaseEntity; + +public class ValidationUtil { + + public static void checkNew(BaseEntity entity) { + if (!entity.isNew()) { + throw new IllegalArgumentException(entity + " 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 IllegalArgumentException(entity + " 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 index a971c85..a7c9a40 100644 --- a/src/main/java/ru/javaops/bootjava/web/AccountController.java +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -1,19 +1,67 @@ package ru.javaops.bootjava.web; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +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.Set; @RestController @RequestMapping(value = "/api/account") +@AllArgsConstructor +@Slf4j public class AccountController { + private final UserRepository userRepository; + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) public User get(@AuthenticationPrincipal AuthUser authUser) { + log.info("get {}", authUser); return authUser.getUser(); } + + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + 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(Set.of(Role.USER)); + user = userRepository.save(user); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path("/api/account") + .build().toUri(); + return ResponseEntity.created(uriOfNewResource).body(user); + } + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void 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()); + } + userRepository.save(user); + } } From ef2a34ea25942b1888f3d2dad6e9c590b3e8f9aa Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 13:09:47 +0300 Subject: [PATCH 12/26] 5_02_error_handling --- .../javaops/bootjava/error/AppException.java | 16 +++++++++++ .../error/IllegalRequestDataException.java | 12 ++++++++ .../javaops/bootjava/util/ValidationUtil.java | 5 ++-- .../web/error/GlobalExceptionHandler.java | 28 +++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/error/AppException.java create mode 100644 src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java create mode 100644 src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java 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..cb18581 --- /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.MESSAGE; + +public class IllegalRequestDataException extends AppException { + public IllegalRequestDataException(String msg) { + super(HttpStatus.UNPROCESSABLE_ENTITY, msg, ErrorAttributeOptions.of(MESSAGE)); + } +} \ 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 index 33d764e..5f709d4 100644 --- a/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java +++ b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java @@ -1,12 +1,13 @@ package ru.javaops.bootjava.util; +import ru.javaops.bootjava.error.IllegalRequestDataException; import ru.javaops.bootjava.model.BaseEntity; public class ValidationUtil { public static void checkNew(BaseEntity entity) { if (!entity.isNew()) { - throw new IllegalArgumentException(entity + " must be new (id=null)"); + throw new IllegalRequestDataException(entity.getClass().getSimpleName() + " must be new (id=null)"); } } @@ -15,7 +16,7 @@ public static void assureIdConsistent(BaseEntity entity, int id) { if (entity.isNew()) { entity.setId(id); } else if (entity.id() != id) { - throw new IllegalArgumentException(entity + " must has 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/error/GlobalExceptionHandler.java b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..9a917cb --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java @@ -0,0 +1,28 @@ +package ru.javaops.bootjava.web.error; + +import lombok.AllArgsConstructor; +import org.springframework.boot.web.servlet.error.ErrorAttributes; +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 +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private final ErrorAttributes errorAttributes; + + @ExceptionHandler(AppException.class) + public ResponseEntity> appException(AppException ex, WebRequest request) { + 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); + } +} From 9af93e546c798e38d2597e176844785918beb93a Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 13:33:03 +0300 Subject: [PATCH 13/26] 5_03_account_hateoas --- .../bootjava/repository/UserRepository.java | 5 +- .../bootjava/web/AccountController.java | 54 ++++++++++++++++--- src/main/resources/application.yaml | 7 +++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java index dc2a413..b808c1d 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -1,12 +1,13 @@ package ru.javaops.bootjava.repository; +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.Query; import org.springframework.data.rest.core.annotation.RestResource; import org.springframework.transaction.annotation.Transactional; import ru.javaops.bootjava.model.User; -import java.util.List; import java.util.Optional; @Transactional(readOnly = true) @@ -17,5 +18,5 @@ public interface UserRepository extends JpaRepository { Optional findByEmailIgnoreCase(String email); @RestResource(rel = "by-lastname", path = "by-lastname") - List findByLastNameContainingIgnoreCase(String lastName); + Page findByLastNameContainingIgnoreCase(String lastName, Pageable page); } \ 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 index a7c9a40..f26e562 100644 --- a/src/main/java/ru/javaops/bootjava/web/AccountController.java +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -2,6 +2,11 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.rest.webmvc.RepositoryLinksResource; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.MediaTypes; +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; @@ -18,18 +23,37 @@ import java.net.URI; import java.util.Set; +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(value = "/api/account") +@RequestMapping("/api/account") @AllArgsConstructor @Slf4j -public class AccountController { +public class AccountController implements RepresentationModelProcessor { + @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 = MediaType.APPLICATION_JSON_VALUE) - public User get(@AuthenticationPrincipal AuthUser authUser) { + @GetMapping(produces = MediaTypes.HAL_JSON_VALUE) + public EntityModel get(@AuthenticationPrincipal AuthUser authUser) { log.info("get {}", authUser); - return authUser.getUser(); + return ASSEMBLER.toModel(authUser.getUser()); } @DeleteMapping @@ -39,9 +63,9 @@ public void delete(@AuthenticationPrincipal AuthUser authUser) { userRepository.deleteById(authUser.id()); } - @PostMapping(value = "/register", consumes = MediaType.APPLICATION_JSON_VALUE) + @PostMapping(value = "/register", consumes = MediaTypes.HAL_JSON_VALUE) @ResponseStatus(value = HttpStatus.CREATED) - public ResponseEntity register(@Valid @RequestBody User user) { + public ResponseEntity> register(@Valid @RequestBody User user) { log.info("register {}", user); ValidationUtil.checkNew(user); user.setRoles(Set.of(Role.USER)); @@ -49,7 +73,7 @@ public ResponseEntity register(@Valid @RequestBody User user) { URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() .path("/api/account") .build().toUri(); - return ResponseEntity.created(uriOfNewResource).body(user); + return ResponseEntity.created(uriOfNewResource).body(ASSEMBLER.toModel(user)); } @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @@ -64,4 +88,18 @@ public void update(@Valid @RequestBody User user, @AuthenticationPrincipal AuthU } 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/resources/application.yaml b/src/main/resources/application.yaml index 993777a..ac07ee9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -31,6 +31,7 @@ spring: 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 @@ -46,6 +47,12 @@ logging: 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 From 6375a2f7141c7d46b3fe040c09b450625c24b566 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 14:48:50 +0300 Subject: [PATCH 14/26] 6_01_oas3_swagger --- pom.xml | 17 ++++++++ .../bootjava/config/OpenApiConfig.java | 39 +++++++++++++++++++ .../bootjava/repository/UserRepository.java | 2 + .../bootjava/web/AccountController.java | 2 + 4 files changed, 60 insertions(+) create mode 100644 src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java diff --git a/pom.xml b/pom.xml index e3437cd..b998a4c 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ 15 + 1.5.6 @@ -49,6 +50,22 @@ --> + + + 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 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/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java index b808c1d..5c71376 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -1,5 +1,6 @@ package ru.javaops.bootjava.repository; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,6 +12,7 @@ import java.util.Optional; @Transactional(readOnly = true) +@Tag(name = "User Controller") public interface UserRepository extends JpaRepository { @RestResource(rel = "by-email", path = "by-email") diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java index f26e562..ee5806e 100644 --- a/src/main/java/ru/javaops/bootjava/web/AccountController.java +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -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 = From d297d650738242f6aedffa6d0e84097ae5552f1d Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 16:04:28 +0300 Subject: [PATCH 15/26] 6_02_fix_update --- pom.xml | 2 +- .../javaops/bootjava/RestaurantVotingApplication.java | 2 -- .../ru/javaops/bootjava/util/JsonDeserializers.java | 2 ++ .../java/ru/javaops/bootjava/util/ValidationUtil.java | 2 ++ .../ru/javaops/bootjava/web/AccountController.java | 6 +++--- .../bootjava/web/error/GlobalExceptionHandler.java | 10 ++++++++++ src/main/resources/application.yaml | 2 +- 7 files changed, 19 insertions(+), 7 deletions(-) diff --git a/pom.xml b/pom.xml index b998a4c..5850399 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 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/util/JsonDeserializers.java b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java index a567a40..153afb4 100644 --- a/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java +++ b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java @@ -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 diff --git a/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java index 5f709d4..4d5c29c 100644 --- a/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java +++ b/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java @@ -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/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java index ee5806e..b05cfb9 100644 --- a/src/main/java/ru/javaops/bootjava/web/AccountController.java +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -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 @@ public void delete(@AuthenticationPrincipal AuthUser authUser) { 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") diff --git a/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java index 9a917cb..d441662 100644 --- a/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java +++ b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java @@ -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); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ac07ee9..77f94f8 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -4,7 +4,7 @@ spring: 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: From 7f2d83efe0dab524386e793152399dead26857de Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 17:21:11 +0300 Subject: [PATCH 16/26] 6_03_add_tests --- pom.xml | 6 ++ .../bootjava/web/AccountController.java | 4 +- .../RestaurantVotingApplicationTests.java | 12 ---- .../ru/javaops/bootjava/UserTestUtil.java | 8 +++ .../bootjava/web/AbstractControllerTest.java | 24 +++++++ .../bootjava/web/AccountControllerTest.java | 45 +++++++++++++ .../bootjava/web/UserControllerTest.java | 64 +++++++++++++++++++ 7 files changed, 150 insertions(+), 13 deletions(-) delete mode 100644 src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java create mode 100644 src/test/java/ru/javaops/bootjava/UserTestUtil.java create mode 100644 src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java create mode 100644 src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java create mode 100644 src/test/java/ru/javaops/bootjava/web/UserControllerTest.java diff --git a/pom.xml b/pom.xml index 5850399..b075f5c 100644 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,12 @@ spring-boot-starter-test test + + + org.springframework.security + spring-security-test + test + diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java index b05cfb9..e13c485 100644 --- a/src/main/java/ru/javaops/bootjava/web/AccountController.java +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -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) { 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..8e2123a --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/UserTestUtil.java @@ -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"; +} 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..b5a26c0 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java @@ -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); + } +} 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..c977623 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java @@ -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/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..c90354e --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java @@ -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 From 4bd3633cca74cd4178d5b8de62525ed510584f2b Mon Sep 17 00:00:00 2001 From: user Date: Mon, 26 Dec 2022 17:35:20 +0300 Subject: [PATCH 17/26] 6_04_json_support --- .../bootjava/config/WebSecurityConfig.java | 9 ++++++ .../java/ru/javaops/bootjava/model/User.java | 6 ++++ .../ru/javaops/bootjava/util/JsonUtil.java | 31 +++++++++++++++++++ .../ru/javaops/bootjava/UserTestUtil.java | 13 ++++++++ .../bootjava/web/AccountControllerTest.java | 24 ++++++++++++++ .../bootjava/web/UserControllerTest.java | 24 ++++++++++++++ 6 files changed, 107 insertions(+) create mode 100644 src/main/java/ru/javaops/bootjava/util/JsonUtil.java diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java index b6438fc..29fe8ee 100644 --- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -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 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() { diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java index c61b200..effb0b1 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -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/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/test/java/ru/javaops/bootjava/UserTestUtil.java b/src/test/java/ru/javaops/bootjava/UserTestUtil.java index 8e2123a..1a6bb63 100644 --- a/src/test/java/ru/javaops/bootjava/UserTestUtil.java +++ b/src/test/java/ru/javaops/bootjava/UserTestUtil.java @@ -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)); + } } diff --git a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java index c977623..aa6ead2 100644 --- a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java @@ -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 @@ void delete() throws Exception { 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 diff --git a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java index c90354e..9950437 100644 --- a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java @@ -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 @@ void delete() throws Exception { 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 From 857ff3f8d501b6e12cd30a9d4753c0fcfe6c2507 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 27 Dec 2022 12:48:36 +0300 Subject: [PATCH 18/26] 6_05_test_body_check --- .../ru/javaops/bootjava/UserTestUtil.java | 28 +++++++++++++++++++ .../bootjava/web/AccountControllerTest.java | 12 ++++++-- .../bootjava/web/UserControllerTest.java | 11 ++++++-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/test/java/ru/javaops/bootjava/UserTestUtil.java b/src/test/java/ru/javaops/bootjava/UserTestUtil.java index 1a6bb63..c0c5c78 100644 --- a/src/test/java/ru/javaops/bootjava/UserTestUtil.java +++ b/src/test/java/ru/javaops/bootjava/UserTestUtil.java @@ -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 getNew() { 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); + } } diff --git a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java index aa6ead2..9185be8 100644 --- a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java @@ -29,7 +29,8 @@ void get() throws Exception { 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 @@ void delete() throws Exception { @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 @@ void update() throws Exception { .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 index 9950437..891a165 100644 --- a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java @@ -29,12 +29,14 @@ void get() throws Exception { 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, (actual, expected) -> UserTestUtil.assertNoIdEquals(actual, expected))); } @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 @@ 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(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) + .andExpect(jsonMatcher(admin, UserTestUtil::assertNoIdEquals)); } @Test @@ -73,7 +76,8 @@ void create() throws Exception { perform(MockMvcRequestBuilders.post(URL) .contentType(MediaType.APPLICATION_JSON) .content(writeValue(newUser))) - .andExpect(status().isCreated()); + .andExpect(status().isCreated()) + .andExpect(jsonMatcher(newUser, (actual, expected) -> UserTestUtil.assertNoIdEquals(actual, expected))); } @Test @@ -84,5 +88,6 @@ void update() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(writeValue(updated))) .andExpect(status().isNoContent()); + UserTestUtil.assertEquals(updated, userRepository.findById(USER_ID).orElseThrow()); } } \ No newline at end of file From 79dac944dd5ab0817022b8d84d56aa0f57084890 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 27 Dec 2022 16:31:47 +0300 Subject: [PATCH 19/26] 6_06_add_cache --- pom.xml | 11 +++++++++++ .../java/ru/javaops/bootjava/config/AppConfig.java | 2 ++ .../ru/javaops/bootjava/config/WebSecurityConfig.java | 2 +- .../javaops/bootjava/repository/UserRepository.java | 2 ++ .../ru/javaops/bootjava/web/AccountController.java | 8 ++++++-- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index b075f5c..50daa5d 100644 --- a/pom.xml +++ b/pom.xml @@ -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 diff --git a/src/main/java/ru/javaops/bootjava/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/config/AppConfig.java index 19dbc45..4a9742b 100644 --- a/src/main/java/ru/javaops/bootjava/config/AppConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/AppConfig.java @@ -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/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java index 29fe8ee..2e18324 100644 --- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -43,7 +43,7 @@ void setMapper() { 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"))); }; diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java index 5c71376..b676a7f 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -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 @@ 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") diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java index e13c485..a43eb3e 100644 --- a/src/main/java/ru/javaops/bootjava/web/AccountController.java +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -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 @@ public EntityModel get(@AuthenticationPrincipal AuthUser authUser) { @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 @@ public ResponseEntity> register(@Valid @RequestBody User user) @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 @@ public void update(@Valid @RequestBody User user, @AuthenticationPrincipal AuthU if (user.getPassword() == null) { user.setPassword(oldUser.getPassword()); } - userRepository.save(user); + return userRepository.save(user); } /* From 25129051fd658e791b8939e6db48e396c4667261 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 27 Dec 2022 16:41:55 +0300 Subject: [PATCH 20/26] 6_07_update_cache --- .../bootjava/repository/UserRepository.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java index b676a7f..a579ae6 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -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 @@ public interface UserRepository extends JpaRepository { @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 From cc524f13abd1a9ef003c9a63774300322034e386 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 27 Dec 2022 17:36:26 +0300 Subject: [PATCH 21/26] 6_07_update_cache --- .../java/ru/javaops/bootjava/web/AbstractControllerTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java index b5a26c0..68cf79c 100644 --- a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java @@ -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 { From 7a962ea988d6386579d175d641ba4ed89ed8b0a4 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 27 Dec 2022 17:36:37 +0300 Subject: [PATCH 22/26] 6_07_update_cache --- src/test/resources/application-test.yaml | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/test/resources/application-test.yaml 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 From 730ef85ad7679384bae0796bd1a5867e2293be74 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 28 Dec 2022 13:17:54 +0300 Subject: [PATCH 23/26] 7_01_upgrade_refactoring --- pom.xml | 28 ++++-- ...lication.java => BootJavaApplication.java} | 4 +- .../ru/javaops/bootjava/config/AppConfig.java | 11 +-- .../bootjava/config/OpenApiConfig.java | 7 +- .../bootjava/config/WebSecurityConfig.java | 29 ++++--- .../javaops/bootjava/error/AppException.java | 5 ++ .../ru/javaops/bootjava/model/BaseEntity.java | 8 +- .../java/ru/javaops/bootjava/model/User.java | 16 ++-- .../util/{ => validation}/ValidationUtil.java | 11 ++- .../bootjava/web/AccountController.java | 5 +- .../javaops/bootjava/{ => web}/AuthUser.java | 2 +- .../bootjava/web/GlobalExceptionHandler.java | 86 +++++++++++++++++++ .../web/error/GlobalExceptionHandler.java | 38 -------- src/main/resources/application.yaml | 23 +++-- .../bootjava/web/AbstractControllerTest.java | 2 +- .../bootjava/web/AccountControllerTest.java | 9 +- 16 files changed, 189 insertions(+), 95 deletions(-) rename src/main/java/ru/javaops/bootjava/{RestaurantVotingApplication.java => BootJavaApplication.java} (66%) rename src/main/java/ru/javaops/bootjava/util/{ => validation}/ValidationUtil.java (67%) rename src/main/java/ru/javaops/bootjava/{ => web}/AuthUser.java (93%) create mode 100644 src/main/java/ru/javaops/bootjava/web/GlobalExceptionHandler.java delete mode 100644 src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java diff --git a/pom.xml b/pom.xml index 50daa5d..f074b38 100644 --- a/pom.xml +++ b/pom.xml @@ -5,20 +5,22 @@ org.springframework.boot spring-boot-starter-parent - 2.4.4 + 2.6.7 - ru.javaops.bootjava - restaurant-voting + ru.javaops + bootjava 1.0 war - restaurant-voting + BootJava Spring Boot 2.x HATEOAS application (BootJava) https://javaops.ru/view/bootjava - 15 - 1.5.6 + 17 + 1.6.8 + UTF-8 + UTF-8 @@ -99,6 +101,12 @@ spring-security-test test + + + org.junit.platform + junit-platform-launcher + test + @@ -115,6 +123,14 @@ + + + org.apache.maven.plugins + maven-surefire-plugin + + -Dfile.encoding=UTF-8 + + diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/BootJavaApplication.java similarity index 66% rename from src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java rename to src/main/java/ru/javaops/bootjava/BootJavaApplication.java index ee6a1ed..fd874f1 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/BootJavaApplication.java @@ -4,9 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class RestaurantVotingApplication { +public class BootJavaApplication { public static void main(String[] args) { - SpringApplication.run(RestaurantVotingApplication.class, args); + SpringApplication.run(BootJavaApplication.class, args); } } diff --git a/src/main/java/ru/javaops/bootjava/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/config/AppConfig.java index 4a9742b..cf1e5e7 100644 --- a/src/main/java/ru/javaops/bootjava/config/AppConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/AppConfig.java @@ -5,6 +5,7 @@ import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import java.sql.SQLException; @@ -13,15 +14,9 @@ @EnableCaching public class AppConfig { -/* + @Profile("!test") @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 { + 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 index 4f6293d..8445b18 100644 --- a/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java @@ -21,7 +21,12 @@ info = @Info( title = "REST API documentation", version = "1.0", - description = "Приложение по курсу BootJava", + description = """ + Приложение по курсу BootJava +

Тестовые креденшелы:
+ - user@gmail.com / password
+ - admin@javaops.ru / admin

+ """, contact = @Contact(url = "https://javaops.ru/#contacts", name = "Grigory Kislin", email = "admin@javaops.ru") ), security = @SecurityRequirement(name = "basicAuth") diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java index 2e18324..76b25a3 100644 --- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; 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; @@ -15,13 +16,12 @@ 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 ru.javaops.bootjava.web.AuthUser; -import javax.annotation.PostConstruct; import java.util.Optional; @Configuration @@ -32,33 +32,34 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); private final UserRepository userRepository; - private final ObjectMapper objectMapper; - @PostConstruct - void setMapper() { + @Autowired + private void setMapper(ObjectMapper objectMapper) { 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"))); - }; + @Override + public UserDetailsService userDetailsServiceBean() throws Exception { + return super.userDetailsServiceBean(); } @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { - auth.userDetailsService(userDetailsService()) + auth.userDetailsService( + email -> { + log.debug("Authenticating '{}'", email); + Optional optionalUser = userRepository.findByEmailIgnoreCase(email); + return new AuthUser(optionalUser.orElseThrow( + () -> new UsernameNotFoundException("User '" + email + "' was not found"))); + }) .passwordEncoder(PASSWORD_ENCODER); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() - .antMatchers("/api/account/register").anonymous() + .antMatchers(HttpMethod.POST, "/api/account").anonymous() .antMatchers("/api/account").hasRole(Role.USER.name()) .antMatchers("/api/**").hasRole(Role.ADMIN.name()) .and().httpBasic() diff --git a/src/main/java/ru/javaops/bootjava/error/AppException.java b/src/main/java/ru/javaops/bootjava/error/AppException.java index 809caad..9d00dd4 100644 --- a/src/main/java/ru/javaops/bootjava/error/AppException.java +++ b/src/main/java/ru/javaops/bootjava/error/AppException.java @@ -13,4 +13,9 @@ public AppException(HttpStatus status, String message, ErrorAttributeOptions opt super(status, message); this.options = options; } + + @Override + public String getMessage() { + return getReason(); + } } diff --git a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java index 72ed0fc..c942ac3 100644 --- a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -1,6 +1,7 @@ package ru.javaops.bootjava.model; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; import org.springframework.data.domain.Persistable; import org.springframework.data.util.ProxyUtils; @@ -15,11 +16,11 @@ @Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PROTECTED) -@ToString public abstract class BaseEntity implements Persistable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473 protected Integer id; // doesn't work for hibernate lazy proxy @@ -51,4 +52,9 @@ public boolean equals(Object o) { public int hashCode() { return id == null ? 0 : id; } + + @Override + public String toString() { + return getClass().getSimpleName() + ":" + id; + } } \ 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 index effb0b1..4878c62 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -8,7 +8,7 @@ import javax.persistence.*; import javax.validation.constraints.Email; -import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotBlank; import javax.validation.constraints.Size; import java.io.Serializable; import java.util.Collection; @@ -21,16 +21,15 @@ @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(email, firstName, lastName, password, roles.isEmpty() ? EnumSet.noneOf(Role.class) : EnumSet.copyOf(roles)); this.id = id; } @Column(name = "email", nullable = false, unique = true) @Email - @NotEmpty + @NotBlank @Size(max = 128) private String email; @@ -49,7 +48,9 @@ public User(Integer id, String email, String firstName, String lastName, String 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")}) + @CollectionTable(name = "user_role", + joinColumns = @JoinColumn(name = "user_id"), + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role"}, name = "uk_user_role")) @Column(name = "role") @ElementCollection(fetch = FetchType.EAGER) private Set roles; @@ -57,4 +58,9 @@ public User(Integer id, String email, String firstName, String lastName, String public void setEmail(String email) { this.email = StringUtils.hasText(email) ? email.toLowerCase() : null; } + + @Override + public String toString() { + return "User:" + id + '[' + email + ']'; + } } \ 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/validation/ValidationUtil.java similarity index 67% rename from src/main/java/ru/javaops/bootjava/util/ValidationUtil.java rename to src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java index 4d5c29c..991c447 100644 --- a/src/main/java/ru/javaops/bootjava/util/ValidationUtil.java +++ b/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java @@ -1,6 +1,8 @@ -package ru.javaops.bootjava.util; +package ru.javaops.bootjava.util.validation; import lombok.experimental.UtilityClass; +import org.springframework.core.NestedExceptionUtils; +import org.springframework.lang.NonNull; import ru.javaops.bootjava.error.IllegalRequestDataException; import ru.javaops.bootjava.model.BaseEntity; @@ -21,4 +23,11 @@ public static void assureIdConsistent(BaseEntity entity, int id) { throw new IllegalRequestDataException(entity.getClass().getSimpleName() + " must has id=" + id); } } + + // https://stackoverflow.com/a/65442410/548473 + @NonNull + public static Throwable getRootCause(@NonNull Throwable t) { + Throwable rootCause = NestedExceptionUtils.getRootCause(t); + return rootCause != null ? rootCause : t; + } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java index a43eb3e..bba5c9c 100644 --- a/src/main/java/ru/javaops/bootjava/web/AccountController.java +++ b/src/main/java/ru/javaops/bootjava/web/AccountController.java @@ -16,11 +16,10 @@ 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 ru.javaops.bootjava.util.validation.ValidationUtil; import javax.validation.Valid; import java.net.URI; @@ -70,7 +69,7 @@ public void delete(@AuthenticationPrincipal AuthUser authUser) { userRepository.deleteById(authUser.id()); } - @PostMapping(value = "/register", consumes = MediaType.APPLICATION_JSON_VALUE) + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(value = HttpStatus.CREATED) public ResponseEntity> register(@Valid @RequestBody User user) { log.info("register {}", user); diff --git a/src/main/java/ru/javaops/bootjava/AuthUser.java b/src/main/java/ru/javaops/bootjava/web/AuthUser.java similarity index 93% rename from src/main/java/ru/javaops/bootjava/AuthUser.java rename to src/main/java/ru/javaops/bootjava/web/AuthUser.java index d4bf023..d31db91 100644 --- a/src/main/java/ru/javaops/bootjava/AuthUser.java +++ b/src/main/java/ru/javaops/bootjava/web/AuthUser.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava; +package ru.javaops.bootjava.web; import lombok.Getter; import lombok.ToString; diff --git a/src/main/java/ru/javaops/bootjava/web/GlobalExceptionHandler.java b/src/main/java/ru/javaops/bootjava/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..60a28c2 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/GlobalExceptionHandler.java @@ -0,0 +1,86 @@ +package ru.javaops.bootjava.web; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.error.ErrorAttributeOptions; +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.lang.NonNull; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; +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 ru.javaops.bootjava.util.validation.ValidationUtil; + +import javax.persistence.EntityNotFoundException; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.springframework.boot.web.error.ErrorAttributeOptions.Include.MESSAGE; + +@RestControllerAdvice +@AllArgsConstructor +@Slf4j +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private final ErrorAttributes errorAttributes; + + @ExceptionHandler(AppException.class) + public ResponseEntity appException(WebRequest request, AppException ex) { + log.error("ApplicationException: {}", ex.getMessage()); + return createResponseEntity(request, ex.getOptions(), null, ex.getStatus()); + } + + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity entityNotFoundException(WebRequest request, EntityNotFoundException ex) { + log.error("EntityNotFoundException: {}", ex.getMessage()); + return createResponseEntity(request, ErrorAttributeOptions.of(MESSAGE), null, HttpStatus.UNPROCESSABLE_ENTITY); + } + + @NonNull + @Override + protected ResponseEntity handleExceptionInternal( + @NonNull Exception ex, Object body, @NonNull HttpHeaders headers, @NonNull HttpStatus status, @NonNull WebRequest request) { + log.error("Exception", ex); + super.handleExceptionInternal(ex, body, headers, status, request); + return createResponseEntity(request, ErrorAttributeOptions.of(), ValidationUtil.getRootCause(ex).getMessage(), status); + } + + @NonNull + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + @NonNull HttpHeaders headers, @NonNull HttpStatus status, @NonNull WebRequest request) { + return handleBindingErrors(ex.getBindingResult(), request); + } + + @NonNull + @Override + protected ResponseEntity handleBindException( + BindException ex, @NonNull HttpHeaders headers, @NonNull HttpStatus status, @NonNull WebRequest request) { + return handleBindingErrors(ex.getBindingResult(), request); + } + + private ResponseEntity handleBindingErrors(BindingResult result, WebRequest request) { + String msg = result.getFieldErrors().stream() + .map(fe -> String.format("[%s] %s", fe.getField(), fe.getDefaultMessage())) + .collect(Collectors.joining("\n")); + return createResponseEntity(request, ErrorAttributeOptions.defaults(), msg, HttpStatus.UNPROCESSABLE_ENTITY); + } + + @SuppressWarnings("unchecked") + private ResponseEntity createResponseEntity(WebRequest request, ErrorAttributeOptions options, String msg, HttpStatus status) { + Map body = errorAttributes.getErrorAttributes(request, options); + if (msg != null) { + body.put("message", msg); + } + body.put("status", status.value()); + body.put("error", status.getReasonPhrase()); + return (ResponseEntity) ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java b/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java deleted file mode 100644 index d441662..0000000 --- a/src/main/java/ru/javaops/bootjava/web/error/GlobalExceptionHandler.java +++ /dev/null @@ -1,38 +0,0 @@ -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.yaml b/src/main/resources/application.yaml index 77f94f8..8467837 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -3,6 +3,8 @@ spring: jpa: show-sql: true open-in-view: false + # https://stackoverflow.com/a/67678945/548473 + defer-datasource-initialization: true hibernate: ddl-auto: create properties: @@ -14,16 +16,16 @@ spring: jdbc.batch_size: 20 datasource: # ImMemory - url: jdbc:h2:mem:voting - # tcp: jdbc:h2:tcp://localhost:9092/mem:voting + url: jdbc:h2:mem:bootjava + # tcp: jdbc:h2:tcp://localhost:9092/mem:bootjava # 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 + # url: jdbc:h2:C:/projects/bootjava/db/bootjava + # tcp: jdbc:h2:tcp://localhost:9092/C:/projects/bootjava/db/bootjava # Relative path form current dir - # url: jdbc:h2:./db/voting + # url: jdbc:h2:./db/bootjava # Relative path from home - # url: jdbc:h2:~/voting - # tcp: jdbc:h2:tcp://localhost:9092/~/voting + # url: jdbc:h2:~/bootjava + # tcp: jdbc:h2:tcp://localhost:9092/~/bootjava username: sa password: h2.console.enabled: true @@ -34,6 +36,11 @@ spring: defaultPageSize: 20 returnBodyOnCreate: true + # https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#common-application-properties-cache + cache: + cache-names: users + caffeine.spec: maximumSize=5000,expireAfterAccess=60s + # https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#security-properties # security: # user: @@ -45,7 +52,7 @@ logging: level: root: WARN ru.javaops.bootjava: DEBUG -# org.springframework.security.web.FilterChainProxy: DEBUG + org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: DEBUG server.servlet: encoding: diff --git a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java index 68cf79c..b4d9cec 100644 --- a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java @@ -18,7 +18,7 @@ public abstract class AbstractControllerTest { @Autowired - protected MockMvc mockMvc; + private 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 index 9185be8..2514542 100644 --- a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java @@ -30,7 +30,7 @@ void get() throws Exception { .andExpect(status().isOk()) .andDo(print()) .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) - .andExpect(jsonMatcher(user, UserTestUtil::assertEquals)); + .andExpect(jsonMatcher(user, UserTestUtil::assertNoIdEquals)); } @Test @@ -51,14 +51,11 @@ void delete() throws Exception { @Test void register() throws Exception { User newUser = UserTestUtil.getNew(); - User registered = asUser(perform(MockMvcRequestBuilders.post(URL + "/register") + User registered = asUser(perform(MockMvcRequestBuilders.post(URL) .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()); + UserTestUtil.assertNoIdEquals(registered, newUser); } @Test From c53400c530108719fb312c8d87ce6693a37bdc9b Mon Sep 17 00:00:00 2001 From: user Date: Wed, 28 Dec 2022 14:23:17 +0300 Subject: [PATCH 24/26] 7_02_xss_validation --- pom.xml | 5 ++++ .../java/ru/javaops/bootjava/model/User.java | 4 ++++ .../bootjava/util/validation/NoHtml.java | 23 +++++++++++++++++++ .../util/validation/NoHtmlValidator.java | 14 +++++++++++ .../bootjava/web/AccountControllerTest.java | 12 ++++++++++ 5 files changed, 58 insertions(+) create mode 100644 src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java create mode 100644 src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java diff --git a/pom.xml b/pom.xml index f074b38..5561214 100644 --- a/pom.xml +++ b/pom.xml @@ -88,6 +88,11 @@ lombok true + + org.jsoup + jsoup + 1.14.3 + diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java index 4878c62..e0516bd 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -5,6 +5,7 @@ import lombok.*; import org.springframework.util.StringUtils; import ru.javaops.bootjava.util.JsonDeserializers; +import ru.javaops.bootjava.util.validation.NoHtml; import javax.persistence.*; import javax.validation.constraints.Email; @@ -31,14 +32,17 @@ public User(Integer id, String email, String firstName, String lastName, String @Email @NotBlank @Size(max = 128) + @NoHtml // https://stackoverflow.com/questions/17480809 private String email; @Column(name = "first_name") @Size(max = 128) + @NoHtml private String firstName; @Column(name = "last_name") @Size(max = 128) + @NoHtml private String lastName; @Column(name = "password") diff --git a/src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java b/src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java new file mode 100644 index 0000000..e2cba7e --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java @@ -0,0 +1,23 @@ +package ru.javaops.bootjava.util.validation; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Documented +@Constraint(validatedBy = NoHtmlValidator.class) +@Target({METHOD, FIELD}) +@Retention(RUNTIME) +public @interface NoHtml { + String message() default "{error.noHtml}"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java b/src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java new file mode 100644 index 0000000..b5d0536 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java @@ -0,0 +1,14 @@ +package ru.javaops.bootjava.util.validation; + +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class NoHtmlValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext ctx) { + return value == null || Jsoup.isValid(value, Safelist.none()); + } +} diff --git a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java index 2514542..c552616 100644 --- a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java @@ -69,4 +69,16 @@ void update() throws Exception { .andExpect(status().isNoContent()); UserTestUtil.assertEquals(updated, userRepository.findById(USER_ID).orElseThrow()); } + + @Test + @WithUserDetails(value = USER_MAIL) + void updateHtmlUnsafe() throws Exception { + User updated = UserTestUtil.getUpdated(); + updated.setFirstName(""); + perform(MockMvcRequestBuilders.put(URL) + .contentType(MediaType.APPLICATION_JSON) + .content(writeValue(updated))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } } \ No newline at end of file From b202cba0101778e208930a3f56368431f31793d2 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 28 Dec 2022 14:37:02 +0300 Subject: [PATCH 25/26] 7_03_remove_data_rest_refactoring --- .gitignore | 12 +- pom.xml | 13 +- src/main/java/ru/javaops/bootjava/HasId.java | 21 ++ .../ru/javaops/bootjava/HasIdAndEmail.java | 5 + .../ru/javaops/bootjava/config/AppConfig.java | 9 + .../bootjava/config/OpenApiConfig.java | 1 - .../bootjava/config/WebSecurityConfig.java | 13 +- .../ru/javaops/bootjava/model/BaseEntity.java | 3 +- .../javaops/bootjava/model/NamedEntity.java | 36 +++ .../java/ru/javaops/bootjava/model/User.java | 72 +++--- .../bootjava/repository/BaseRepository.java | 24 ++ .../bootjava/repository/UserRepository.java | 33 +-- .../java/ru/javaops/bootjava/to/BaseTo.java | 21 ++ .../java/ru/javaops/bootjava/to/NamedTo.java | 27 +++ .../java/ru/javaops/bootjava/to/UserTo.java | 35 +++ .../bootjava/util/JsonDeserializers.java | 25 --- .../ru/javaops/bootjava/util/JsonUtil.java | 44 +++- .../ru/javaops/bootjava/util/UserUtil.java | 31 +++ .../util/validation/ValidationUtil.java | 24 +- .../bootjava/web/AccountController.java | 112 ---------- .../ru/javaops/bootjava/web/SecurityUtil.java | 33 +++ .../web/user/AbstractUserController.java | 39 ++++ .../web/user/AdminUserController.java | 79 +++++++ .../bootjava/web/user/ProfileController.java | 58 +++++ .../web/user/UniqueMailValidator.java | 49 ++++ src/main/resources/application.yaml | 28 +-- src/main/resources/data.sql | 6 +- .../ru/javaops/bootjava/UserTestUtil.java | 49 ---- .../bootjava/web/AccountControllerTest.java | 84 ------- .../javaops/bootjava/web/MatcherFactory.java | 83 +++++++ .../bootjava/web/UserControllerTest.java | 93 -------- .../web/user/AdminUserControllerTest.java | 211 ++++++++++++++++++ .../web/user/ProfileControllerTest.java | 112 ++++++++++ .../bootjava/web/user/UserTestData.java | 34 +++ 34 files changed, 1033 insertions(+), 486 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/HasId.java create mode 100644 src/main/java/ru/javaops/bootjava/HasIdAndEmail.java create mode 100644 src/main/java/ru/javaops/bootjava/model/NamedEntity.java create mode 100644 src/main/java/ru/javaops/bootjava/repository/BaseRepository.java create mode 100644 src/main/java/ru/javaops/bootjava/to/BaseTo.java create mode 100644 src/main/java/ru/javaops/bootjava/to/NamedTo.java create mode 100644 src/main/java/ru/javaops/bootjava/to/UserTo.java delete mode 100644 src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java create mode 100644 src/main/java/ru/javaops/bootjava/util/UserUtil.java delete mode 100644 src/main/java/ru/javaops/bootjava/web/AccountController.java create mode 100644 src/main/java/ru/javaops/bootjava/web/SecurityUtil.java create mode 100644 src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java create mode 100644 src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java create mode 100644 src/main/java/ru/javaops/bootjava/web/user/ProfileController.java create mode 100644 src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java delete mode 100644 src/test/java/ru/javaops/bootjava/UserTestUtil.java delete mode 100644 src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java create mode 100644 src/test/java/ru/javaops/bootjava/web/MatcherFactory.java delete mode 100644 src/test/java/ru/javaops/bootjava/web/UserControllerTest.java create mode 100644 src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java create mode 100644 src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java create mode 100644 src/test/java/ru/javaops/bootjava/web/user/UserTestData.java diff --git a/.gitignore b/.gitignore index a5d9a78..73e6d9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ -HELP.md -/target/ - -### IntelliJ IDEA ### .idea -*.iws +out +target *.iml -*.ipr \ No newline at end of file +log +*.patch + + diff --git a/pom.xml b/pom.xml index 5561214..58cfd48 100644 --- a/pom.xml +++ b/pom.xml @@ -38,11 +38,13 @@ org.springframework.boot - spring-boot-starter-data-rest + spring-boot-starter-security + + - org.springframework.boot - spring-boot-starter-security + com.fasterxml.jackson.datatype + jackson-datatype-hibernate5 ru.javaops @@ -18,7 +18,7 @@ 17 - 1.6.8 + 1.6.9 UTF-8 UTF-8 @@ -88,7 +88,7 @@ org.jsoup jsoup - 1.14.3 + 1.15.1 diff --git a/src/main/java/ru/javaops/bootjava/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/config/AppConfig.java index f775ee8..fd48d8d 100644 --- a/src/main/java/ru/javaops/bootjava/config/AppConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/AppConfig.java @@ -1,13 +1,15 @@ package ru.javaops.bootjava.config; -import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module; import lombok.extern.slf4j.Slf4j; import org.h2.tools.Server; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import ru.javaops.bootjava.util.JsonUtil; import java.sql.SQLException; @@ -24,9 +26,9 @@ Server h2Server() throws SQLException { return Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", "9092"); } - // https://stackoverflow.com/a/46947975/548473 - @Bean - Module module() { - return new Hibernate5Module(); + @Autowired + void configureAndStoreObjectMapper(ObjectMapper objectMapper) { + objectMapper.registerModule(new Hibernate5Module()); + JsonUtil.setMapper(objectMapper); } } diff --git a/src/main/java/ru/javaops/bootjava/config/SecurityConfiguration.java b/src/main/java/ru/javaops/bootjava/config/SecurityConfiguration.java new file mode 100644 index 0000000..53a8052 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/SecurityConfiguration.java @@ -0,0 +1,59 @@ +package ru.javaops.bootjava.config; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +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 org.springframework.security.web.SecurityFilterChain; +import ru.javaops.bootjava.model.Role; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; +import ru.javaops.bootjava.web.AuthUser; + +import java.util.Optional; + +@Configuration +@EnableWebSecurity +@Slf4j +@AllArgsConstructor +public class SecurityConfiguration { + + public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + private final UserRepository userRepository; + + @Bean + public PasswordEncoder passwordEncoder() { + return PASSWORD_ENCODER; + } + + @Bean + public UserDetailsService userDetailsService() { + return email -> { + log.debug("Authenticating '{}'", email); + Optional optionalUser = userRepository.findByEmailIgnoreCase(email); + return new AuthUser(optionalUser.orElseThrow( + () -> new UsernameNotFoundException("User '" + email + "' was not found"))); + }; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.authorizeRequests() + .antMatchers("/api/admin/**").hasRole(Role.ADMIN.name()) + .antMatchers(HttpMethod.POST, "/api/profile").anonymous() + .antMatchers("/api/**").authenticated() + .and().httpBasic() + .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and().csrf().disable(); + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/config/WebConfig.java b/src/main/java/ru/javaops/bootjava/config/WebConfig.java new file mode 100644 index 0000000..6851bc5 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/WebConfig.java @@ -0,0 +1,14 @@ +package ru.javaops.bootjava.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addRedirectViewController("/", "/swagger-ui/index.html"); + } +} diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java deleted file mode 100644 index aae64bd..0000000 --- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +++ /dev/null @@ -1,68 +0,0 @@ -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.http.HttpMethod; -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 ru.javaops.bootjava.model.Role; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.repository.UserRepository; -import ru.javaops.bootjava.util.JsonUtil; -import ru.javaops.bootjava.web.AuthUser; - -import java.util.Optional; - -import static ru.javaops.bootjava.util.UserUtil.PASSWORD_ENCODER; - -@Configuration -@EnableWebSecurity -@Slf4j -@AllArgsConstructor -public class WebSecurityConfig extends WebSecurityConfigurerAdapter { - - private final UserRepository userRepository; - - @Autowired - private void setMapper(ObjectMapper objectMapper) { - JsonUtil.setMapper(objectMapper); - } - - @Bean - @Override - public UserDetailsService userDetailsServiceBean() throws Exception { - return super.userDetailsServiceBean(); - } - - @Autowired - public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { - auth.userDetailsService( - email -> { - log.debug("Authenticating '{}'", email); - Optional optionalUser = userRepository.findByEmailIgnoreCase(email); - return new AuthUser(optionalUser.orElseThrow( - () -> new UsernameNotFoundException("User '" + email + "' was not found"))); - }) - .passwordEncoder(PASSWORD_ENCODER); - } - - @Override - protected void configure(HttpSecurity http) throws Exception { - http.authorizeRequests() - .antMatchers("/api/admin/**").hasRole(Role.ADMIN.name()) - .antMatchers(HttpMethod.POST, "/api/profile").anonymous() - .antMatchers("/api/**").authenticated() - .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/model/BaseEntity.java b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java index 44cb9af..483bcf0 100644 --- a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -1,6 +1,5 @@ package ru.javaops.bootjava.model; -import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; import org.springframework.data.domain.Persistable; @@ -30,7 +29,7 @@ public int id() { return id; } - @JsonIgnore + @Schema(hidden = true) @Override public boolean isNew() { return id == null; diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java index 4e402cb..3ccc55f 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -18,10 +18,7 @@ import javax.validation.constraints.Size; import java.io.Serial; import java.io.Serializable; -import java.util.Collection; -import java.util.Date; -import java.util.EnumSet; -import java.util.Set; +import java.util.*; @Entity @Table(name = "users") @@ -57,10 +54,10 @@ public class User extends NamedEntity implements HasIdAndEmail, Serializable { @Enumerated(EnumType.STRING) @CollectionTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"), - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role"}, name = "uk_user_roles")) + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role"}, name = "uk_user_role")) @Column(name = "role") @ElementCollection(fetch = FetchType.EAGER) - @JoinColumn(name = "user_id") //https://stackoverflow.com/a/62848296/548473 + @JoinColumn @OnDelete(action = OnDeleteAction.CASCADE) private Set roles; @@ -68,8 +65,8 @@ public User(User u) { this(u.id, u.name, u.email, u.password, u.enabled, u.registered, u.roles); } - public User(Integer id, String name, String email, String password, Role role, Role... roles) { - this(id, name, email, password, true, new Date(), EnumSet.of(role, roles)); + public User(Integer id, String name, String email, String password, Role... roles) { + this(id, name, email, password, true, new Date(), Arrays.asList(roles)); } public User(Integer id, String name, String email, String password, boolean enabled, Date registered, Collection roles) { diff --git a/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java b/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java index 08ed1da..014f7de 100644 --- a/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.repository.NoRepositoryBean; import org.springframework.transaction.annotation.Transactional; +import static ru.javaops.bootjava.util.validation.ValidationUtil.checkExisted; import static ru.javaops.bootjava.util.validation.ValidationUtil.checkModification; // https://stackoverflow.com/questions/42781264/multiple-base-repositories-in-spring-data-jpa @@ -15,10 +16,17 @@ public interface BaseRepository extends JpaRepository { // https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query.spel-expressions @Transactional @Modifying - @Query("DELETE FROM #{#entityName} u WHERE u.id=:id") + @Query("DELETE FROM #{#entityName} e WHERE e.id=:id") int delete(int id); default void deleteExisted(int id) { checkModification(delete(id), id); } + + @Query("SELECT e FROM #{#entityName} e WHERE e.id = :id") + T get(int id); + + default T getExisted(int id) { + return checkExisted(get(id), id); + } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/util/UserUtil.java b/src/main/java/ru/javaops/bootjava/util/UserUtil.java index 4cf471b..eaf2f8a 100644 --- a/src/main/java/ru/javaops/bootjava/util/UserUtil.java +++ b/src/main/java/ru/javaops/bootjava/util/UserUtil.java @@ -1,17 +1,15 @@ package ru.javaops.bootjava.util; import lombok.experimental.UtilityClass; -import org.springframework.security.crypto.factory.PasswordEncoderFactories; -import org.springframework.security.crypto.password.PasswordEncoder; import ru.javaops.bootjava.model.Role; import ru.javaops.bootjava.model.User; import ru.javaops.bootjava.to.UserTo; +import static ru.javaops.bootjava.config.SecurityConfiguration.PASSWORD_ENCODER; + @UtilityClass public class UserUtil { - public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); - public static User createNewFromTo(UserTo userTo) { return new User(null, userTo.getName(), userTo.getEmail().toLowerCase(), userTo.getPassword(), Role.USER); } diff --git a/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java b/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java index 509b8a0..9f43c16 100644 --- a/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java +++ b/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java @@ -29,6 +29,12 @@ public static void checkModification(int count, int id) { throw new IllegalRequestDataException("Entity with id=" + id + " not found"); } } + public static T checkExisted(T obj, int id) { + if (obj == null) { + throw new IllegalRequestDataException("Entity with id=" + id + " not found"); + } + return obj; + } // https://stackoverflow.com/a/65442410/548473 @NonNull diff --git a/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java b/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java index 55bbf88..9044c8d 100644 --- a/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java +++ b/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java @@ -73,7 +73,7 @@ public ResponseEntity getByEmail(@RequestParam String email) { @Transactional public void enable(@PathVariable int id, @RequestParam boolean enabled) { log.info(enabled ? "enable {}" : "disable {}", id); - User user = repository.getById(id); + User user = repository.getExisted(id); user.setEnabled(enabled); } } \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index a37617f..d229d8d 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,6 +1,6 @@ -INSERT INTO USERS (NAME, EMAIL, PASSWORD) -VALUES ('User', 'user@gmail.com', '{noop}password'), - ('Admin', 'admin@javaops.ru', '{noop}admin'); +INSERT INTO USERS (NAME, EMAIL, PASSWORD, ENABLED, REGISTERED) +VALUES ('User', 'user@gmail.com', '{noop}password', 'true', '2022-04-04 11:33:30'), + ('Admin', 'admin@javaops.ru', '{noop}admin', 'true', '2022-04-04 11:33:30'); INSERT INTO USER_ROLE (ROLE, USER_ID) VALUES ('USER', 1), diff --git a/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java b/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java index 541fafc..65fbf14 100644 --- a/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java @@ -107,7 +107,7 @@ void update() throws Exception { .andDo(print()) .andExpect(status().isNoContent()); - USER_MATCHER.assertMatch(userRepository.getById(USER_ID), getUpdated()); + USER_MATCHER.assertMatch(userRepository.getExisted(USER_ID), getUpdated()); } @Test @@ -123,7 +123,7 @@ void createWithLocation() throws Exception { int newId = created.id(); newUser.setId(newId); USER_MATCHER.assertMatch(created, newUser); - USER_MATCHER.assertMatch(userRepository.getById(newId), newUser); + USER_MATCHER.assertMatch(userRepository.getExisted(newId), newUser); } @Test @@ -144,7 +144,7 @@ void enable() throws Exception { .andDo(print()) .andExpect(status().isNoContent()); - assertFalse(userRepository.getById(USER_ID).isEnabled()); + assertFalse(userRepository.getExisted(USER_ID).isEnabled()); } @Test diff --git a/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java b/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java index 527c298..ba8e516 100644 --- a/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java @@ -63,7 +63,7 @@ void register() throws Exception { int newId = created.id(); newUser.setId(newId); USER_MATCHER.assertMatch(created, newUser); - USER_MATCHER.assertMatch(userRepository.getById(newId), newUser); + USER_MATCHER.assertMatch(userRepository.getExisted(newId), newUser); } @Test @@ -75,7 +75,7 @@ void update() throws Exception { .andDo(print()) .andExpect(status().isNoContent()); - USER_MATCHER.assertMatch(userRepository.getById(USER_ID), UserUtil.updateFromTo(new User(user), updatedTo)); + USER_MATCHER.assertMatch(userRepository.getExisted(USER_ID), UserUtil.updateFromTo(new User(user), updatedTo)); } @Test