From 928bfcaf430fd0e52aad5fa8693611388c27f43f Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Tue, 24 Nov 2020 16:45:08 +0300 Subject: [PATCH 01/32] 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 12f1315fa0eb5253041bb7cdeed21f59b7934054 Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Tue, 24 Nov 2020 16:45:18 +0300 Subject: [PATCH 02/32] 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 778c0ac943f456eeeeedffe7acf29f8e630da6a2 Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Tue, 24 Nov 2020 16:45:30 +0300 Subject: [PATCH 03/32] 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 c6417e3dbeb9ae7f3a018bd7a569a03a9acb7290 Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Tue, 24 Nov 2020 16:45:43 +0300 Subject: [PATCH 04/32] 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 ddba8635b0aa9726287de64ab350dfad59472dfc Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Tue, 24 Nov 2020 16:51:36 +0300 Subject: [PATCH 05/32] 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 08321643a1a748d410a14aab8f8d7bdbb7586435 Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Tue, 24 Nov 2020 16:51:52 +0300 Subject: [PATCH 06/32] 3_02_jackson --- src/main/java/ru/javaops/bootjava/model/BaseEntity.java | 2 ++ src/main/resources/application.yaml | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) 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 820eb6e9cea7ce2316e5e69b3ea5e6c589227cce Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Tue, 24 Nov 2020 18:12:28 +0300 Subject: [PATCH 07/32] 4_01_add_security --- pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pom.xml b/pom.xml index 5aabae1..04dd5c3 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,11 @@ org.springframework.boot spring-boot-starter-data-rest + + org.springframework.boot + spring-boot-starter-security + + + + + + org.springdoc + springdoc-openapi-ui + ${springdoc.version} + + + org.springdoc + springdoc-openapi-data-rest + ${springdoc.version} + + + org.springdoc + springdoc-openapi-security + ${springdoc.version} + com.h2database h2 @@ -58,7 +76,7 @@ true - + org.springframework.boot spring-boot-starter-test 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 0ab428565f3693eaca888d9abaab0bc991e0cb7a Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Fri, 16 Apr 2021 01:09:06 +0300 Subject: [PATCH 17/32] 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 43ff567..d1ac589 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 fe0becc95392fbd1eba1049feb883ea06c11d329 Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Fri, 16 Apr 2021 01:09:48 +0300 Subject: [PATCH 18/32] 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 d1ac589..e7d5855 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 1f1123d303b16755682ce4847245673dae358f0d Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Fri, 16 Apr 2021 01:10:00 +0300 Subject: [PATCH 19/32] 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 8d443637a0414297729d695b0eb4a9f10da54acf Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Fri, 16 Apr 2021 01:10:17 +0300 Subject: [PATCH 20/32] 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..9295ca0 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, UserTestUtil::assertNoIdEquals)); } @Test @WithUserDetails(value = ADMIN_MAIL) void getAll() throws Exception { + // TODO check content yourself perform(MockMvcRequestBuilders.get(URL)) .andExpect(status().isOk()) .andDo(print()) @@ -47,7 +49,8 @@ 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, UserTestUtil::assertNoIdEquals)); } @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 0d3faa24f4fac73db6b48f60195de5395dc7627b Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Fri, 16 Apr 2021 01:10:30 +0300 Subject: [PATCH 21/32] 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 e7d5855..a78d752 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 22e33769e12758c8473390c6fbbcbf33cc827b61 Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Fri, 16 Apr 2021 01:11:06 +0300 Subject: [PATCH 22/32] 6_07_update_cache --- .../bootjava/repository/UserRepository.java | 21 +++++++++++++++++++ .../bootjava/web/AbstractControllerTest.java | 2 ++ src/test/resources/application-test.yaml | 1 + 3 files changed, 24 insertions(+) create mode 100644 src/test/resources/application-test.yaml 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 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 { 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 85e582905371cc39d9991188625c90d5bef7ff0c Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Wed, 27 Apr 2022 10:58:59 +0300 Subject: [PATCH 23/32] 7_01_upgrade_refactoring --- pom.xml | 36 +++++--- ...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(+), 103 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 a78d752..3736dc5 100644 --- a/pom.xml +++ b/pom.xml @@ -5,19 +5,21 @@ org.springframework.boot spring-boot-starter-parent - 2.4.4 + 2.6.7 - ru.javaops.bootjava - restaurant-voting + ru.javaops + bootjava 1.0 - 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 @@ -42,14 +44,6 @@ spring-boot-starter-security - - org.springdoc @@ -99,6 +93,12 @@ spring-security-test test + + + org.junit.platform + junit-platform-launcher + test + @@ -115,6 +115,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 8a1b273304e98af5305ab29f5f4280e9d2d34324 Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Wed, 27 Apr 2022 10:59:15 +0300 Subject: [PATCH 24/32] 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 3736dc5..95002a4 100644 --- a/pom.xml +++ b/pom.xml @@ -80,6 +80,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 3ee347827c8f1d278696d24195977caeeff0503e Mon Sep 17 00:00:00 2001 From: "admin@javaops.ru" Date: Wed, 27 Apr 2022 10:59:26 +0300 Subject: [PATCH 25/32] 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 95002a4..345b938 100644 --- a/pom.xml +++ b/pom.xml @@ -37,11 +37,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 @@ -50,11 +52,6 @@ springdoc-openapi-ui ${springdoc.version} - - org.springdoc - springdoc-openapi-data-rest - ${springdoc.version} - org.springdoc springdoc-openapi-security diff --git a/src/main/java/ru/javaops/bootjava/HasId.java b/src/main/java/ru/javaops/bootjava/HasId.java new file mode 100644 index 0000000..42ba142 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/HasId.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.springframework.util.Assert; + +public interface HasId { + Integer getId(); + + void setId(Integer id); + + @JsonIgnore + default boolean isNew() { + return getId() == null; + } + + // doesn't work for hibernate lazy proxy + default int id() { + Assert.notNull(getId(), "Entity must has id"); + return getId(); + } +} diff --git a/src/main/java/ru/javaops/bootjava/HasIdAndEmail.java b/src/main/java/ru/javaops/bootjava/HasIdAndEmail.java new file mode 100644 index 0000000..aa96c88 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/HasIdAndEmail.java @@ -0,0 +1,5 @@ +package ru.javaops.bootjava; + +public interface HasIdAndEmail extends HasId { + String getEmail(); +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/config/AppConfig.java index cf1e5e7..f775ee8 100644 --- a/src/main/java/ru/javaops/bootjava/config/AppConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/AppConfig.java @@ -1,5 +1,7 @@ package ru.javaops.bootjava.config; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module; import lombok.extern.slf4j.Slf4j; import org.h2.tools.Server; import org.springframework.cache.annotation.EnableCaching; @@ -12,6 +14,7 @@ @Configuration @Slf4j @EnableCaching +// TODO: cache only most requested data! public class AppConfig { @Profile("!test") @@ -20,4 +23,10 @@ Server h2Server() throws SQLException { log.info("Start H2 TCP server"); return Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", "9092"); } + + // https://stackoverflow.com/a/46947975/548473 + @Bean + Module module() { + return new Hibernate5Module(); + } } diff --git a/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java b/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java index 8445b18..4e9aa5f 100644 --- a/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java @@ -38,7 +38,6 @@ public GroupedOpenApi api() { return GroupedOpenApi.builder() .group("REST API") .pathsToMatch("/api/**") - .pathsToExclude("/api/profile/**") .build(); } } diff --git a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java index 76b25a3..aae64bd 100644 --- a/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java @@ -14,8 +14,6 @@ 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.model.Role; import ru.javaops.bootjava.model.User; import ru.javaops.bootjava.repository.UserRepository; @@ -24,18 +22,19 @@ import java.util.Optional; +import static ru.javaops.bootjava.util.UserUtil.PASSWORD_ENCODER; + @Configuration @EnableWebSecurity @Slf4j @AllArgsConstructor public class WebSecurityConfig extends WebSecurityConfigurerAdapter { - public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); private final UserRepository userRepository; @Autowired private void setMapper(ObjectMapper objectMapper) { - JsonUtil.setObjectMapper(objectMapper); + JsonUtil.setMapper(objectMapper); } @Bean @@ -59,9 +58,9 @@ public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() - .antMatchers(HttpMethod.POST, "/api/account").anonymous() - .antMatchers("/api/account").hasRole(Role.USER.name()) - .antMatchers("/api/**").hasRole(Role.ADMIN.name()) + .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(); diff --git a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java index c942ac3..44cb9af 100644 --- a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Persistable; import org.springframework.data.util.ProxyUtils; import org.springframework.util.Assert; +import ru.javaops.bootjava.HasId; import javax.persistence.*; @@ -16,7 +17,7 @@ @Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PROTECTED) -public abstract class BaseEntity implements Persistable { +public abstract class BaseEntity implements Persistable, HasId { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/ru/javaops/bootjava/model/NamedEntity.java b/src/main/java/ru/javaops/bootjava/model/NamedEntity.java new file mode 100644 index 0000000..68a743a --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/NamedEntity.java @@ -0,0 +1,36 @@ +package ru.javaops.bootjava.model; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import ru.javaops.bootjava.util.validation.NoHtml; + +import javax.persistence.Column; +import javax.persistence.MappedSuperclass; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class NamedEntity extends BaseEntity { + + @NotBlank + @Size(min = 2, max = 128) + @Column(name = "name", nullable = false) + @NoHtml + protected String name; + + protected NamedEntity(Integer id, String name) { + super(id); + this.name = name; + } + + @Override + public String toString() { + return super.toString() + '[' + 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 e0516bd..4e402cb 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -1,18 +1,25 @@ 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 lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.springframework.util.CollectionUtils; +import ru.javaops.bootjava.HasIdAndEmail; import ru.javaops.bootjava.util.validation.NoHtml; import javax.persistence.*; import javax.validation.constraints.Email; import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; +import java.io.Serial; import java.io.Serializable; import java.util.Collection; +import java.util.Date; import java.util.EnumSet; import java.util.Set; @@ -21,12 +28,9 @@ @Getter @Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -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, roles.isEmpty() ? EnumSet.noneOf(Role.class) : EnumSet.copyOf(roles)); - this.id = id; - } +public class User extends NamedEntity implements HasIdAndEmail, Serializable { + @Serial + private static final long serialVersionUID = 1L; @Column(name = "email", nullable = false, unique = true) @Email @@ -35,32 +39,50 @@ public User(Integer id, String email, String firstName, String lastName, String @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") + @Column(name = "password", nullable = false) + @NotBlank @Size(max = 256) + // https://stackoverflow.com/a/12505165/548473 @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) - @JsonDeserialize(using = JsonDeserializers.PasswordDeserializer.class) private String password; + @Column(name = "enabled", nullable = false, columnDefinition = "bool default true") + private boolean enabled = true; + + @Column(name = "registered", nullable = false, columnDefinition = "timestamp default now()", updatable = false) + @NotNull + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Date registered = new Date(); + @Enumerated(EnumType.STRING) @CollectionTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"), - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role"}, name = "uk_user_role")) + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role"}, name = "uk_user_roles")) @Column(name = "role") @ElementCollection(fetch = FetchType.EAGER) + @JoinColumn(name = "user_id") //https://stackoverflow.com/a/62848296/548473 + @OnDelete(action = OnDeleteAction.CASCADE) private Set roles; - public void setEmail(String email) { - this.email = StringUtils.hasText(email) ? email.toLowerCase() : null; + 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, boolean enabled, Date registered, Collection roles) { + super(id, name); + this.email = email; + this.password = password; + this.enabled = enabled; + this.registered = registered; + setRoles(roles); + } + + public void setRoles(Collection roles) { + this.roles = CollectionUtils.isEmpty(roles) ? EnumSet.noneOf(Role.class) : EnumSet.copyOf(roles); } @Override diff --git a/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java b/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java new file mode 100644 index 0000000..08ed1da --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java @@ -0,0 +1,24 @@ +package ru.javaops.bootjava.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.NoRepositoryBean; +import org.springframework.transaction.annotation.Transactional; + +import static ru.javaops.bootjava.util.validation.ValidationUtil.checkModification; + +// https://stackoverflow.com/questions/42781264/multiple-base-repositories-in-spring-data-jpa +@NoRepositoryBean +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") + int delete(int id); + + default void deleteExisted(int id) { + checkModification(delete(id), id); + } +} \ 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 index a579ae6..727497f 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -1,15 +1,7 @@ package ru.javaops.bootjava.repository; import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.CachePut; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.rest.core.annotation.RestResource; import org.springframework.transaction.annotation.Transactional; import ru.javaops.bootjava.model.User; @@ -17,31 +9,8 @@ @Transactional(readOnly = true) @Tag(name = "User Controller") -public interface UserRepository extends JpaRepository { +public interface UserRepository extends BaseRepository { - @RestResource(rel = "by-email", path = "by-email") @Query("SELECT u FROM User u WHERE u.email = LOWER(:email)") - @Cacheable("users") Optional findByEmailIgnoreCase(String email); - - @RestResource(rel = "by-lastname", path = "by-lastname") - Page findByLastNameContainingIgnoreCase(String lastName, Pageable page); - - @Override - @Modifying - @Transactional - @CachePut(value = "users", key = "#user.email") - User save(User user); - - @Override - @Modifying - @Transactional - @CacheEvict(value = "users", key = "#user.email") - void delete(User user); - - @Override - @Modifying - @Transactional - @CacheEvict(value = "users", allEntries = true) - void deleteById(Integer integer); } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/to/BaseTo.java b/src/main/java/ru/javaops/bootjava/to/BaseTo.java new file mode 100644 index 0000000..399a1cf --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/to/BaseTo.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.to; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.javaops.bootjava.HasId; + +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Data +public abstract class BaseTo implements HasId { + @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473 + protected Integer id; + + @Override + public String toString() { + return getClass().getSimpleName() + ":" + id; + } +} diff --git a/src/main/java/ru/javaops/bootjava/to/NamedTo.java b/src/main/java/ru/javaops/bootjava/to/NamedTo.java new file mode 100644 index 0000000..0b2e4b1 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/to/NamedTo.java @@ -0,0 +1,27 @@ +package ru.javaops.bootjava.to; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import ru.javaops.bootjava.util.validation.NoHtml; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +@EqualsAndHashCode(callSuper = true) +public class NamedTo extends BaseTo { + @NotBlank + @Size(min = 2, max = 128) + @NoHtml + protected String name; + + public NamedTo(Integer id, String name) { + super(id); + this.name = name; + } + + @Override + public String toString() { + return super.toString() + '[' + name + ']'; + } +} diff --git a/src/main/java/ru/javaops/bootjava/to/UserTo.java b/src/main/java/ru/javaops/bootjava/to/UserTo.java new file mode 100644 index 0000000..eb0317f --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/to/UserTo.java @@ -0,0 +1,35 @@ +package ru.javaops.bootjava.to; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import ru.javaops.bootjava.HasIdAndEmail; +import ru.javaops.bootjava.util.validation.NoHtml; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Value +@EqualsAndHashCode(callSuper = true) +public class UserTo extends NamedTo implements HasIdAndEmail { + @Email + @NotBlank + @Size(max = 128) + @NoHtml // https://stackoverflow.com/questions/17480809 + String email; + + @NotBlank + @Size(min = 5, max = 32) + String password; + + public UserTo(Integer id, String name, String email, String password) { + super(id, name); + this.email = email; + this.password = password; + } + + @Override + public String toString() { + return "UserTo:" + id + '[' + email + ']'; + } +} diff --git a/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java b/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java deleted file mode 100644 index 153afb4..0000000 --- a/src/main/java/ru/javaops/bootjava/util/JsonDeserializers.java +++ /dev/null @@ -1,25 +0,0 @@ -package ru.javaops.bootjava.util; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.ObjectCodec; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import lombok.experimental.UtilityClass; -import ru.javaops.bootjava.config.WebSecurityConfig; - -import java.io.IOException; - -@UtilityClass -public class JsonDeserializers { - - // https://stackoverflow.com/a/60995048/548473 - public static class PasswordDeserializer extends JsonDeserializer { - public String deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { - ObjectCodec oc = jsonParser.getCodec(); - JsonNode node = oc.readTree(jsonParser); - String rawPassword = node.asText(); - return WebSecurityConfig.PASSWORD_ENCODER.encode(rawPassword); - } - } -} diff --git a/src/main/java/ru/javaops/bootjava/util/JsonUtil.java b/src/main/java/ru/javaops/bootjava/util/JsonUtil.java index 336088a..9c37f33 100644 --- a/src/main/java/ru/javaops/bootjava/util/JsonUtil.java +++ b/src/main/java/ru/javaops/bootjava/util/JsonUtil.java @@ -1,31 +1,55 @@ package ru.javaops.bootjava.util; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import lombok.experimental.UtilityClass; import java.io.IOException; import java.util.List; +import java.util.Map; @UtilityClass public class JsonUtil { - private static ObjectMapper objectMapper; + private static ObjectMapper mapper; - public static void setObjectMapper(ObjectMapper objectMapper) { - JsonUtil.objectMapper = objectMapper; + public static void setMapper(ObjectMapper mapper) { + JsonUtil.mapper = mapper; } - public static List readValues(String json, Class clazz) throws IOException { - ObjectReader reader = objectMapper.readerFor(clazz); - return reader.readValues(json).readAll(); + public static List readValues(String json, Class clazz) { + ObjectReader reader = mapper.readerFor(clazz); + try { + return reader.readValues(json).readAll(); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid read array from JSON:\n'" + json + "'", e); + } } - public static T readValue(String json, Class clazz) throws JsonProcessingException { - return objectMapper.readValue(json, clazz); + public static T readValue(String json, Class clazz) { + try { + return mapper.readValue(json, clazz); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid read from JSON:\n'" + json + "'", e); + } } - public static String writeValue(T obj) throws JsonProcessingException { - return objectMapper.writeValueAsString(obj); + public static String writeValue(T obj) { + try { + return mapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Invalid write to JSON:\n'" + obj + "'", e); + } + } + + public static String writeAdditionProps(T obj, String addName, Object addValue) { + return writeAdditionProps(obj, Map.of(addName, addValue)); + } + + public static String writeAdditionProps(T obj, Map addProps) { + Map map = mapper.convertValue(obj, new TypeReference<>() {}); + map.putAll(addProps); + return writeValue(map); } } \ 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 new file mode 100644 index 0000000..4cf471b --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/UserUtil.java @@ -0,0 +1,31 @@ +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; + +@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); + } + + public static User updateFromTo(User user, UserTo userTo) { + user.setName(userTo.getName()); + user.setEmail(userTo.getEmail().toLowerCase()); + user.setPassword(userTo.getPassword()); + return user; + } + + public static User prepareToSave(User user) { + user.setPassword(PASSWORD_ENCODER.encode(user.getPassword())); + user.setEmail(user.getEmail().toLowerCase()); + return user; + } +} \ No newline at end of file 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 991c447..509b8a0 100644 --- a/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java +++ b/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java @@ -3,24 +3,30 @@ import lombok.experimental.UtilityClass; import org.springframework.core.NestedExceptionUtils; import org.springframework.lang.NonNull; +import ru.javaops.bootjava.HasId; import ru.javaops.bootjava.error.IllegalRequestDataException; -import ru.javaops.bootjava.model.BaseEntity; @UtilityClass public class ValidationUtil { - public static void checkNew(BaseEntity entity) { - if (!entity.isNew()) { - throw new IllegalRequestDataException(entity.getClass().getSimpleName() + " must be new (id=null)"); + public static void checkNew(HasId bean) { + if (!bean.isNew()) { + throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must be new (id=null)"); } } // Conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473) - public static void assureIdConsistent(BaseEntity entity, int id) { - if (entity.isNew()) { - entity.setId(id); - } else if (entity.id() != id) { - throw new IllegalRequestDataException(entity.getClass().getSimpleName() + " must has id=" + id); + public static void assureIdConsistent(HasId bean, int id) { + if (bean.isNew()) { + bean.setId(id); + } else if (bean.id() != id) { + throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must has id=" + id); + } + } + + public static void checkModification(int count, int id) { + if (count == 0) { + throw new IllegalRequestDataException("Entity with id=" + id + " not found"); } } diff --git a/src/main/java/ru/javaops/bootjava/web/AccountController.java b/src/main/java/ru/javaops/bootjava/web/AccountController.java deleted file mode 100644 index bba5c9c..0000000 --- a/src/main/java/ru/javaops/bootjava/web/AccountController.java +++ /dev/null @@ -1,112 +0,0 @@ -package ru.javaops.bootjava.web; - -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.CachePut; -import org.springframework.data.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; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; -import ru.javaops.bootjava.model.Role; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.repository.UserRepository; -import ru.javaops.bootjava.util.validation.ValidationUtil; - -import javax.validation.Valid; -import java.net.URI; -import java.util.EnumSet; - -import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; - -/** - * Do not use {@link org.springframework.data.rest.webmvc.RepositoryRestController (BasePathAwareController} - * Bugs: - * NPE with http://localhost:8080/api/account
- * data.rest.base-path missed in HAL links
- * Two endpoints created - *

- * RequestMapping("/${spring.data.rest.basePath}/account") give "Not enough variable values" - */ -@RestController -@RequestMapping(AccountController.URL) -@AllArgsConstructor -@Slf4j -@Tag(name = "Account Controller") -public class AccountController implements RepresentationModelProcessor { - static final String URL = "/api/account"; - - @SuppressWarnings("unchecked") - private static final RepresentationModelAssemblerSupport> ASSEMBLER = - new RepresentationModelAssemblerSupport<>(AccountController.class, (Class>) (Class) EntityModel.class) { - @Override - public EntityModel toModel(User user) { - return EntityModel.of(user, linkTo(AccountController.class).withSelfRel()); - } - }; - - private final UserRepository userRepository; - - @GetMapping(produces = MediaTypes.HAL_JSON_VALUE) - public EntityModel get(@AuthenticationPrincipal AuthUser authUser) { - log.info("get {}", authUser); - return ASSEMBLER.toModel(authUser.getUser()); - } - - @DeleteMapping - @ResponseStatus(HttpStatus.NO_CONTENT) - @CacheEvict(value = "users", key = "#authUser.username") - public void delete(@AuthenticationPrincipal AuthUser authUser) { - log.info("delete {}", authUser); - userRepository.deleteById(authUser.id()); - } - - @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) - @ResponseStatus(value = HttpStatus.CREATED) - public ResponseEntity> register(@Valid @RequestBody User user) { - log.info("register {}", user); - ValidationUtil.checkNew(user); - user.setRoles(EnumSet.of(Role.USER)); - user = userRepository.save(user); - URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() - .path("/api/account") - .build().toUri(); - return ResponseEntity.created(uriOfNewResource).body(ASSEMBLER.toModel(user)); - } - - @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) - @ResponseStatus(HttpStatus.NO_CONTENT) - @CachePut(value = "users", key = "#authUser.username") - public User update(@Valid @RequestBody User user, @AuthenticationPrincipal AuthUser authUser) { - log.info("update {} to {}", authUser, user); - User oldUser = authUser.getUser(); - ValidationUtil.assureIdConsistent(user, oldUser.id()); - user.setRoles(oldUser.getRoles()); - if (user.getPassword() == null) { - user.setPassword(oldUser.getPassword()); - } - return userRepository.save(user); - } - -/* - @GetMapping(value = "/pageDemo", produces = MediaTypes.HAL_JSON_VALUE) - public PagedModel> pageDemo(Pageable page, PagedResourcesAssembler pagedAssembler) { - Page users = userRepository.findAll(page); - return pagedAssembler.toModel(users, ASSEMBLER); - } -*/ - - @Override - public RepositoryLinksResource process(RepositoryLinksResource resource) { - resource.add(linkTo(AccountController.class).withRel("account")); - return resource; - } -} diff --git a/src/main/java/ru/javaops/bootjava/web/SecurityUtil.java b/src/main/java/ru/javaops/bootjava/web/SecurityUtil.java new file mode 100644 index 0000000..36fcf86 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/SecurityUtil.java @@ -0,0 +1,33 @@ +package ru.javaops.bootjava.web; + +import lombok.experimental.UtilityClass; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import ru.javaops.bootjava.model.User; + +import static java.util.Objects.requireNonNull; + +@UtilityClass +public class SecurityUtil { + + public static AuthUser safeGet() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + return null; + } + Object principal = auth.getPrincipal(); + return (principal instanceof AuthUser) ? (AuthUser) principal : null; + } + + public static AuthUser get() { + return requireNonNull(safeGet(), "No authorized user found"); + } + + public static User authUser() { + return get().getUser(); + } + + public static int authId() { + return get().getUser().id(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java b/src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java new file mode 100644 index 0000000..a41e626 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java @@ -0,0 +1,39 @@ +package ru.javaops.bootjava.web.user; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.InitBinder; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; +import ru.javaops.bootjava.util.UserUtil; + +@Slf4j +public abstract class AbstractUserController { + + @Autowired + protected UserRepository repository; + + @Autowired + private UniqueMailValidator emailValidator; + + @InitBinder + protected void initBinder(WebDataBinder binder) { + binder.addValidators(emailValidator); + } + + public ResponseEntity get(int id) { + log.info("get {}", id); + return ResponseEntity.of(repository.findById(id)); + } + + public void delete(int id) { + log.info("delete {}", id); + repository.deleteExisted(id); + } + + protected User prepareAndSave(User user) { + return repository.save(UserUtil.prepareToSave(user)); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java b/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java new file mode 100644 index 0000000..55bbf88 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java @@ -0,0 +1,79 @@ +package ru.javaops.bootjava.web.user; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javaops.bootjava.model.User; + +import javax.validation.Valid; +import java.net.URI; +import java.util.List; + +import static ru.javaops.bootjava.util.validation.ValidationUtil.assureIdConsistent; +import static ru.javaops.bootjava.util.validation.ValidationUtil.checkNew; + +@RestController +@RequestMapping(value = AdminUserController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) +@Slf4j +public class AdminUserController extends AbstractUserController { + + static final String REST_URL = "/api/admin/users"; + + @Override + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable int id) { + return super.get(id); + } + + @Override + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable int id) { + super.delete(id); + } + + @GetMapping + public List getAll() { + log.info("getAll"); + return repository.findAll(Sort.by(Sort.Direction.ASC, "name", "email")); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createWithLocation(@Valid @RequestBody User user) { + log.info("create {}", user); + checkNew(user); + User created = prepareAndSave(user); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL + "/{id}") + .buildAndExpand(created.getId()).toUri(); + return ResponseEntity.created(uriOfNewResource).body(created); + } + + @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@Valid @RequestBody User user, @PathVariable int id) { + log.info("update {} with id={}", user, id); + assureIdConsistent(user, id); + prepareAndSave(user); + } + + @GetMapping("/by-email") + public ResponseEntity getByEmail(@RequestParam String email) { + log.info("getByEmail {}", email); + return ResponseEntity.of(repository.findByEmailIgnoreCase(email)); + } + + @PatchMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + public void enable(@PathVariable int id, @RequestParam boolean enabled) { + log.info(enabled ? "enable {}" : "disable {}", id); + User user = repository.getById(id); + user.setEnabled(enabled); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/web/user/ProfileController.java b/src/main/java/ru/javaops/bootjava/web/user/ProfileController.java new file mode 100644 index 0000000..278324a --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/user/ProfileController.java @@ -0,0 +1,58 @@ +package ru.javaops.bootjava.web.user; + +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.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.to.UserTo; +import ru.javaops.bootjava.util.UserUtil; +import ru.javaops.bootjava.web.AuthUser; + +import javax.validation.Valid; +import java.net.URI; + +import static ru.javaops.bootjava.util.validation.ValidationUtil.assureIdConsistent; +import static ru.javaops.bootjava.util.validation.ValidationUtil.checkNew; + +@RestController +@RequestMapping(value = ProfileController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) +@Slf4j +public class ProfileController extends AbstractUserController { + static final String REST_URL = "/api/profile"; + + @GetMapping + public User get(@AuthenticationPrincipal AuthUser authUser) { + return authUser.getUser(); + } + + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@AuthenticationPrincipal AuthUser authUser) { + super.delete(authUser.id()); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public ResponseEntity register(@Valid @RequestBody UserTo userTo) { + log.info("register {}", userTo); + checkNew(userTo); + User created = prepareAndSave(UserUtil.createNewFromTo(userTo)); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL).build().toUri(); + return ResponseEntity.created(uriOfNewResource).body(created); + } + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + public void update(@RequestBody @Valid UserTo userTo, @AuthenticationPrincipal AuthUser authUser) { + assureIdConsistent(userTo, authUser.id()); + User user = authUser.getUser(); + prepareAndSave(UserUtil.updateFromTo(user, userTo)); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java b/src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java new file mode 100644 index 0000000..2d8011e --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java @@ -0,0 +1,49 @@ +package ru.javaops.bootjava.web.user; + +import lombok.AllArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.validation.Errors; +import ru.javaops.bootjava.HasIdAndEmail; +import ru.javaops.bootjava.repository.UserRepository; +import ru.javaops.bootjava.web.SecurityUtil; + +import javax.servlet.http.HttpServletRequest; + +@Component +@AllArgsConstructor +public class UniqueMailValidator implements org.springframework.validation.Validator { + public static final String EXCEPTION_DUPLICATE_EMAIL = "User with this email already exists"; + + private final UserRepository repository; + private final HttpServletRequest request; + + @Override + public boolean supports(@NonNull Class clazz) { + return HasIdAndEmail.class.isAssignableFrom(clazz); + } + + @Override + public void validate(@NonNull Object target, @NonNull Errors errors) { + HasIdAndEmail user = ((HasIdAndEmail) target); + if (StringUtils.hasText(user.getEmail())) { + repository.findByEmailIgnoreCase(user.getEmail()) + .ifPresent(dbUser -> { + if (request.getMethod().equals("PUT")) { // UPDATE + int dbId = dbUser.id(); + + // it is ok, if update ourself + if (user.getId() != null && dbId == user.id()) return; + + // Workaround for update with user.id=null in request body + // ValidationUtil.assureIdConsistent called after this validation + String requestURI = request.getRequestURI(); + if (requestURI.endsWith("/" + dbId) || (dbId == SecurityUtil.authId() && requestURI.contains("/profile"))) + return; + } + errors.rejectValue("email", "", EXCEPTION_DUPLICATE_EMAIL); + }); + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8467837..9f55124 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -28,26 +28,19 @@ spring: # tcp: jdbc:h2:tcp://localhost:9092/~/bootjava username: sa password: - h2.console.enabled: true - data.rest: - # https://docs.spring.io/spring-data/rest/docs/current/reference/html/#getting-started.basic-settings - basePath: /api - defaultPageSize: 20 - returnBodyOnCreate: true +# Jackson Serialization Issue Resolver + jackson.visibility: + field: any + getter: none + setter: none + is-getter: none # 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: -# name: user -# password: password -# roles: USER - logging: level: root: WARN @@ -58,11 +51,4 @@ server.servlet: encoding: charset: UTF-8 # Charset of HTTP requests and responses. Added to the "Content-Type" header if not set explicitly enabled: true # Enable http encoding support - force: true - -# Jackson Serialization Issue Resolver -# jackson: -# visibility.field: any -# visibility.getter: none -# visibility.setter: none -# visibility.is-getter: none \ No newline at end of file + force: true \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 778d2f3..a37617f 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', '{noop}password'), - ('admin@javaops.ru', 'Admin_First', 'Admin_Last', '{noop}admin'); +INSERT INTO USERS (NAME, EMAIL, PASSWORD) +VALUES ('User', 'user@gmail.com', '{noop}password'), + ('Admin', 'admin@javaops.ru', '{noop}admin'); INSERT INTO USER_ROLE (ROLE, USER_ID) VALUES ('USER', 1), diff --git a/src/test/java/ru/javaops/bootjava/UserTestUtil.java b/src/test/java/ru/javaops/bootjava/UserTestUtil.java deleted file mode 100644 index c0c5c78..0000000 --- a/src/test/java/ru/javaops/bootjava/UserTestUtil.java +++ /dev/null @@ -1,49 +0,0 @@ -package ru.javaops.bootjava; - -import com.fasterxml.jackson.core.JsonProcessingException; -import org.springframework.test.web.servlet.MvcResult; -import org.springframework.test.web.servlet.ResultMatcher; -import ru.javaops.bootjava.model.Role; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.util.JsonUtil; - -import java.io.UnsupportedEncodingException; -import java.util.List; -import java.util.function.BiConsumer; - -import static org.assertj.core.api.Assertions.assertThat; - -public class UserTestUtil { - public static final int USER_ID = 1; - public static final int ADMIN_ID = 2; - public static final String USER_MAIL = "user@gmail.com"; - public static final String ADMIN_MAIL = "admin@javaops.ru"; - public static final User user = new User(USER_ID, USER_MAIL, "User_First", "User_Last", "password", List.of(Role.USER)); - public static final User admin = new User(ADMIN_ID, ADMIN_MAIL, "Admin_First", "Admin_Last", "admin", List.of(Role.ADMIN, Role.USER)); - - public static User getNew() { - return new User(null, "new@gmail.com", "New_First", "New_Last", "newpass", List.of(Role.USER)); - } - - public static User getUpdated() { - return new User(USER_ID, "user_update@gmail.com", "User_First_Update", "User_Last_Update", "password_update", List.of(Role.USER)); - } - - public static void assertEquals(User actual, User expected) { - assertThat(actual).usingRecursiveComparison().ignoringFields("password").isEqualTo(expected); - } - - // No id in HATEOAS answer - public static void assertNoIdEquals(User actual, User expected) { - assertThat(actual).usingRecursiveComparison().ignoringFields("id", "password").isEqualTo(expected); - } - - public static User asUser(MvcResult mvcResult) throws UnsupportedEncodingException, JsonProcessingException { - String jsonActual = mvcResult.getResponse().getContentAsString(); - return JsonUtil.readValue(jsonActual, User.class); - } - - public static ResultMatcher jsonMatcher(User expected, BiConsumer equalsAssertion) { - return 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 deleted file mode 100644 index c552616..0000000 --- a/src/test/java/ru/javaops/bootjava/web/AccountControllerTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package ru.javaops.bootjava.web; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.hateoas.MediaTypes; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithUserDetails; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import ru.javaops.bootjava.UserTestUtil; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.repository.UserRepository; - -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static ru.javaops.bootjava.UserTestUtil.*; -import static ru.javaops.bootjava.util.JsonUtil.writeValue; -import static ru.javaops.bootjava.web.AccountController.URL; - -class AccountControllerTest extends AbstractControllerTest { - - @Autowired - private UserRepository userRepository; - - @Test - @WithUserDetails(value = USER_MAIL) - void get() throws Exception { - perform(MockMvcRequestBuilders.get(URL)) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) - .andExpect(jsonMatcher(user, UserTestUtil::assertNoIdEquals)); - } - - @Test - void getUnAuth() throws Exception { - perform(MockMvcRequestBuilders.get(URL)) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithUserDetails(value = USER_MAIL) - void delete() throws Exception { - perform(MockMvcRequestBuilders.delete(URL)) - .andExpect(status().isNoContent()); - Assertions.assertFalse(userRepository.findById(USER_ID).isPresent()); - Assertions.assertTrue(userRepository.findById(ADMIN_ID).isPresent()); - } - - @Test - void register() throws Exception { - User newUser = UserTestUtil.getNew(); - User registered = asUser(perform(MockMvcRequestBuilders.post(URL) - .contentType(MediaType.APPLICATION_JSON) - .content(writeValue(newUser))) - .andExpect(status().isCreated()).andReturn()); - UserTestUtil.assertNoIdEquals(registered, newUser); - } - - @Test - @WithUserDetails(value = USER_MAIL) - void update() throws Exception { - User updated = UserTestUtil.getUpdated(); - perform(MockMvcRequestBuilders.put(URL) - .contentType(MediaType.APPLICATION_JSON) - .content(writeValue(updated))) - .andDo(print()) - .andExpect(status().isNoContent()); - UserTestUtil.assertEquals(updated, userRepository.findById(USER_ID).orElseThrow()); - } - - @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 diff --git a/src/test/java/ru/javaops/bootjava/web/MatcherFactory.java b/src/test/java/ru/javaops/bootjava/web/MatcherFactory.java new file mode 100644 index 0000000..c510818 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/MatcherFactory.java @@ -0,0 +1,83 @@ +package ru.javaops.bootjava.web; + +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.ResultMatcher; +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; + +/** + * Factory for creating test matchers. + *

+ * Comparing actual and expected objects via AssertJ + * Support converting json MvcResult to objects for comparation. + */ +public class MatcherFactory { + + public static Matcher usingAssertions(Class clazz, BiConsumer assertion, BiConsumer, Iterable> iterableAssertion) { + return new Matcher<>(clazz, assertion, iterableAssertion); + } + + public static Matcher usingEqualsComparator(Class clazz) { + return usingAssertions(clazz, + (a, e) -> assertThat(a).isEqualTo(e), + (a, e) -> assertThat(a).isEqualTo(e)); + } + + public static Matcher usingIgnoringFieldsComparator(Class clazz, String... fieldsToIgnore) { + return usingAssertions(clazz, + (a, e) -> assertThat(a).usingRecursiveComparison().ignoringFields(fieldsToIgnore).isEqualTo(e), + (a, e) -> assertThat(a).usingRecursiveFieldByFieldElementComparatorIgnoringFields(fieldsToIgnore).isEqualTo(e)); + } + + public static class Matcher { + private final Class clazz; + private final BiConsumer assertion; + private final BiConsumer, Iterable> iterableAssertion; + + private Matcher(Class clazz, BiConsumer assertion, BiConsumer, Iterable> iterableAssertion) { + this.clazz = clazz; + this.assertion = assertion; + this.iterableAssertion = iterableAssertion; + } + + public void assertMatch(T actual, T expected) { + assertion.accept(actual, expected); + } + + @SafeVarargs + public final void assertMatch(Iterable actual, T... expected) { + assertMatch(actual, List.of(expected)); + } + + public void assertMatch(Iterable actual, Iterable expected) { + iterableAssertion.accept(actual, expected); + } + + public ResultMatcher contentJson(T expected) { + return result -> assertMatch(JsonUtil.readValue(getContent(result), clazz), expected); + } + + @SafeVarargs + public final ResultMatcher contentJson(T... expected) { + return contentJson(List.of(expected)); + } + + public ResultMatcher contentJson(Iterable expected) { + return result -> assertMatch(JsonUtil.readValues(getContent(result), clazz), expected); + } + + public T readFromJson(ResultActions action) throws UnsupportedEncodingException { + return JsonUtil.readValue(getContent(action.andReturn()), clazz); + } + + private static String getContent(MvcResult result) throws UnsupportedEncodingException { + return result.getResponse().getContentAsString(); + } + } +} diff --git a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java b/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java deleted file mode 100644 index 9295ca0..0000000 --- a/src/test/java/ru/javaops/bootjava/web/UserControllerTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package ru.javaops.bootjava.web; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.hateoas.MediaTypes; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithUserDetails; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import ru.javaops.bootjava.UserTestUtil; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.repository.UserRepository; - -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static ru.javaops.bootjava.UserTestUtil.*; -import static ru.javaops.bootjava.util.JsonUtil.writeValue; - -class UserControllerTest extends AbstractControllerTest { - static final String URL = "/api/users/"; - - @Autowired - private UserRepository userRepository; - - @Test - @WithUserDetails(value = ADMIN_MAIL) - void get() throws Exception { - perform(MockMvcRequestBuilders.get(URL + USER_ID)) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) - .andExpect(jsonMatcher(user, UserTestUtil::assertNoIdEquals)); - } - - @Test - @WithUserDetails(value = ADMIN_MAIL) - void getAll() throws Exception { - // TODO check content yourself - perform(MockMvcRequestBuilders.get(URL)) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)); - } - - @Test - @WithUserDetails(value = ADMIN_MAIL) - void getByEmail() throws Exception { - perform(MockMvcRequestBuilders.get(URL + "search/by-email?email=" + ADMIN_MAIL)) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(content().contentTypeCompatibleWith(MediaTypes.HAL_JSON_VALUE)) - .andExpect(jsonMatcher(admin, UserTestUtil::assertNoIdEquals)); - } - - @Test - @WithUserDetails(value = USER_MAIL) - void getForbidden() throws Exception { - perform(MockMvcRequestBuilders.get(URL)) - .andExpect(status().isForbidden()); - } - - @Test - @WithUserDetails(value = ADMIN_MAIL) - void delete() throws Exception { - perform(MockMvcRequestBuilders.delete(URL + USER_ID)) - .andExpect(status().isNoContent()); - Assertions.assertFalse(userRepository.findById(USER_ID).isPresent()); - Assertions.assertTrue(userRepository.findById(ADMIN_ID).isPresent()); - } - - @Test - @WithUserDetails(value = ADMIN_MAIL) - void create() throws Exception { - User newUser = UserTestUtil.getNew(); - perform(MockMvcRequestBuilders.post(URL) - .contentType(MediaType.APPLICATION_JSON) - .content(writeValue(newUser))) - .andExpect(status().isCreated()) - .andExpect(jsonMatcher(newUser, UserTestUtil::assertNoIdEquals)); - } - - @Test - @WithUserDetails(value = ADMIN_MAIL) - void update() throws Exception { - User updated = UserTestUtil.getUpdated(); - perform(MockMvcRequestBuilders.put(URL + USER_ID) - .contentType(MediaType.APPLICATION_JSON) - .content(writeValue(updated))) - .andExpect(status().isNoContent()); - UserTestUtil.assertEquals(updated, userRepository.findById(USER_ID).orElseThrow()); - } -} \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java b/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java new file mode 100644 index 0000000..541fafc --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java @@ -0,0 +1,211 @@ +package ru.javaops.bootjava.web.user; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import ru.javaops.bootjava.model.Role; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; +import ru.javaops.bootjava.web.AbstractControllerTest; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.web.user.UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL; +import static ru.javaops.bootjava.web.user.UserTestData.*; + +class AdminUserControllerTest extends AbstractControllerTest { + + private static final String REST_URL = AdminUserController.REST_URL + '/'; + + @Autowired + private UserRepository userRepository; + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void get() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + ADMIN_ID)) + .andExpect(status().isOk()) + .andDo(print()) + // https://jira.spring.io/browse/SPR-14472 + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getNotFound() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + NOT_FOUND)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getByEmail() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + "by-email?email=" + admin.getEmail())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL + USER_ID)) + .andDo(print()) + .andExpect(status().isNoContent()); + assertFalse(userRepository.findById(USER_ID).isPresent()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void deleteNotFound() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL + NOT_FOUND)) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void enableNotFound() throws Exception { + perform(MockMvcRequestBuilders.patch(REST_URL + NOT_FOUND) + .param("enabled", "false") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + void getUnAuth() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void getForbidden() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isForbidden()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void update() throws Exception { + User updated = getUpdated(); + updated.setId(null); + perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "newPass"))) + .andDo(print()) + .andExpect(status().isNoContent()); + + USER_MATCHER.assertMatch(userRepository.getById(USER_ID), getUpdated()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void createWithLocation() throws Exception { + User newUser = getNew(); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(newUser, "newPass"))) + .andExpect(status().isCreated()); + + User created = USER_MATCHER.readFromJson(action); + int newId = created.id(); + newUser.setId(newId); + USER_MATCHER.assertMatch(created, newUser); + USER_MATCHER.assertMatch(userRepository.getById(newId), newUser); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getAll() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin, user)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void enable() throws Exception { + perform(MockMvcRequestBuilders.patch(REST_URL + USER_ID) + .param("enabled", "false") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNoContent()); + + assertFalse(userRepository.getById(USER_ID).isEnabled()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void createInvalid() throws Exception { + User invalid = new User(null, null, "", "newPass", Role.USER, Role.ADMIN); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(invalid, "newPass"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void updateInvalid() throws Exception { + User invalid = new User(user); + invalid.setName(""); + perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(invalid, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void updateHtmlUnsafe() throws Exception { + User updated = new User(user); + updated.setName(""); + perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @Transactional(propagation = Propagation.NEVER) + @WithUserDetails(value = ADMIN_MAIL) + void updateDuplicate() throws Exception { + User updated = new User(user); + updated.setEmail(ADMIN_MAIL); + perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); + } + + @Test + @Transactional(propagation = Propagation.NEVER) + @WithUserDetails(value = ADMIN_MAIL) + void createDuplicate() throws Exception { + User expected = new User(null, "New", USER_MAIL, "newPass", Role.USER, Role.ADMIN); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(expected, "newPass"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java b/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java new file mode 100644 index 0000000..527c298 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java @@ -0,0 +1,112 @@ +package ru.javaops.bootjava.web.user; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; +import ru.javaops.bootjava.to.UserTo; +import ru.javaops.bootjava.util.JsonUtil; +import ru.javaops.bootjava.util.UserUtil; +import ru.javaops.bootjava.web.AbstractControllerTest; + +import static org.hamcrest.Matchers.containsString; +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.web.user.ProfileController.REST_URL; +import static ru.javaops.bootjava.web.user.UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL; +import static ru.javaops.bootjava.web.user.UserTestData.*; + +class ProfileControllerTest extends AbstractControllerTest { + + @Autowired + private UserRepository userRepository; + + @Test + @WithUserDetails(value = USER_MAIL) + void get() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(user)); + } + + @Test + void getUnAuth() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL)) + .andExpect(status().isNoContent()); + USER_MATCHER.assertMatch(userRepository.findAll(), admin); + } + + @Test + void register() throws Exception { + UserTo newTo = new UserTo(null, "newName", "newemail@ya.ru", "newPassword"); + User newUser = UserUtil.createNewFromTo(newTo); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newTo))) + .andDo(print()) + .andExpect(status().isCreated()); + + User created = USER_MATCHER.readFromJson(action); + int newId = created.id(); + newUser.setId(newId); + USER_MATCHER.assertMatch(created, newUser); + USER_MATCHER.assertMatch(userRepository.getById(newId), newUser); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void update() throws Exception { + UserTo updatedTo = new UserTo(null, "newName", USER_MAIL, "newPassword"); + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isNoContent()); + + USER_MATCHER.assertMatch(userRepository.getById(USER_ID), UserUtil.updateFromTo(new User(user), updatedTo)); + } + + @Test + void registerInvalid() throws Exception { + UserTo newTo = new UserTo(null, null, null, null); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newTo))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void updateInvalid() throws Exception { + UserTo updatedTo = new UserTo(null, null, "password", null); + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void updateDuplicate() throws Exception { + UserTo updatedTo = new UserTo(null, "newName", ADMIN_MAIL, "newPassword"); + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/web/user/UserTestData.java b/src/test/java/ru/javaops/bootjava/web/user/UserTestData.java new file mode 100644 index 0000000..f4841b6 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/user/UserTestData.java @@ -0,0 +1,34 @@ +package ru.javaops.bootjava.web.user; + +import ru.javaops.bootjava.model.Role; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.util.JsonUtil; +import ru.javaops.bootjava.web.MatcherFactory; + +import java.util.Collections; +import java.util.Date; + +public class UserTestData { + public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(User.class, "registered", "password"); + + public static final int USER_ID = 1; + public static final int ADMIN_ID = 2; + public static final int NOT_FOUND = 100; + 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", USER_MAIL, "password", Role.USER); + public static final User admin = new User(ADMIN_ID, "Admin", ADMIN_MAIL, "admin", Role.ADMIN, Role.USER); + + public static User getNew() { + return new User(null, "New", "new@gmail.com", "newPass", false, new Date(), Collections.singleton(Role.USER)); + } + + public static User getUpdated() { + return new User(USER_ID, "UpdatedName", USER_MAIL, "newPass", false, new Date(), Collections.singleton(Role.ADMIN)); + } + + public static String jsonWithPassword(User user, String passw) { + return JsonUtil.writeAdditionProps(user, "password", passw); + } +} From a58542e9656cd55610de81e6e053e00e3455c2d2 Mon Sep 17 00:00:00 2001 From: JavaOPs Date: Tue, 3 Sep 2024 14:14:48 +0300 Subject: [PATCH 26/32] 8_01_migrate_spring_boot_3_1_2 --- README.md | 17 ++- lombok.config | 1 + pom.xml | 30 ++-- .../ru/javaops/bootjava/config/AppConfig.java | 31 ++++- .../bootjava/config/OpenApiConfig.java | 7 +- .../config/RestAuthenticationEntryPoint.java | 22 +++ .../bootjava/config/RestExceptionHandler.java | 128 ++++++++++++++++++ .../bootjava/config/SecurityConfig.java | 71 ++++++++++ .../bootjava/config/WebSecurityConfig.java | 68 ---------- .../javaops/bootjava/error/AppException.java | 19 +-- .../bootjava/error/DataConflictException.java | 7 + .../ru/javaops/bootjava/error/ErrorType.java | 22 +++ .../error/IllegalRequestDataException.java | 7 +- .../bootjava/error/NotFoundException.java | 7 + .../ru/javaops/bootjava/model/BaseEntity.java | 5 +- .../javaops/bootjava/model/NamedEntity.java | 9 +- .../java/ru/javaops/bootjava/model/User.java | 36 +++-- .../bootjava/repository/BaseRepository.java | 15 +- .../bootjava/repository/UserRepository.java | 17 ++- .../java/ru/javaops/bootjava/to/NamedTo.java | 5 +- .../java/ru/javaops/bootjava/to/UserTo.java | 7 +- .../util/{UserUtil.java => UsersUtil.java} | 12 +- .../bootjava/util/validation/NoHtml.java | 5 +- .../util/validation/NoHtmlValidator.java | 5 +- .../util/validation/ValidationUtil.java | 15 -- .../ru/javaops/bootjava/web/AuthUser.java | 36 ++++- .../bootjava/web/GlobalExceptionHandler.java | 86 ------------ .../ru/javaops/bootjava/web/SecurityUtil.java | 33 ----- .../web/user/AbstractUserController.java | 16 +-- .../web/user/AdminUserController.java | 17 ++- .../bootjava/web/user/ProfileController.java | 11 +- .../web/user/UniqueMailValidator.java | 9 +- src/main/resources/application.yaml | 4 +- src/main/resources/data.sql | 9 +- .../web/user/AdminUserControllerTest.java | 47 +++---- .../web/user/ProfileControllerTest.java | 15 +- .../bootjava/web/user/UserTestData.java | 7 +- 37 files changed, 478 insertions(+), 380 deletions(-) create mode 100644 lombok.config create mode 100644 src/main/java/ru/javaops/bootjava/config/RestAuthenticationEntryPoint.java create mode 100644 src/main/java/ru/javaops/bootjava/config/RestExceptionHandler.java create mode 100644 src/main/java/ru/javaops/bootjava/config/SecurityConfig.java delete mode 100644 src/main/java/ru/javaops/bootjava/config/WebSecurityConfig.java create mode 100644 src/main/java/ru/javaops/bootjava/error/DataConflictException.java create mode 100644 src/main/java/ru/javaops/bootjava/error/ErrorType.java create mode 100644 src/main/java/ru/javaops/bootjava/error/NotFoundException.java rename src/main/java/ru/javaops/bootjava/util/{UserUtil.java => UsersUtil.java} (55%) delete mode 100644 src/main/java/ru/javaops/bootjava/web/GlobalExceptionHandler.java delete mode 100644 src/main/java/ru/javaops/bootjava/web/SecurityUtil.java diff --git a/README.md b/README.md index 22f19d8..f814dcb 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,19 @@ Открытый курс для всех желающих приобщиться к живой современной разработке на Java -# [Разработка Spring Boot 2.x HATEOAS приложения (BootJava)](http://javaops.ru/view/bootjava?ref=gh) +# [Разработка Spring Boot 3.x HATEOAS приложения (BootJava)](http://javaops.ru/view/bootjava?ref=gh) ## [Программа](http://javaops.ru/view/bootjava#program) -### Java приложения на самом современном и востребованном стеке: Spring Boot 2.x, Spring Data Rest/HATEOAS, Lombok, JPA, H2, .... -Мы создадим с нуля основу любого современного REST веб-приложения: аутентификация и авторизация на основе ролей, регистрация пользователя в приложении, управление своим профилем и администрирование пользователей. \ No newline at end of file +### Java приложения на самом современном и востребованном стеке: Spring Boot 3.x, Spring Data Rest/HATEOAS, Lombok, JPA, H2, .... +Мы создадим с нуля основу любого современного REST веб-приложения: аутентификация и авторизация на основе ролей, регистрация пользователя в приложении, управление своим профилем и администрирование пользователей. +------------------------------------------------------------- +- Stack: [JDK 17](http://jdk.java.net/17/), Spring Boot 3.x, Lombok, H2, Caffeine Cache, SpringDoc OpenApi 2.x +- Run: `mvn spring-boot:run` in root directory. +----------------------------------------------------- +[REST API documentation](http://localhost:8080/) +Креденшелы: +``` +User: user@yandex.ru / password +Admin: admin@gmail.com / admin +Guest: guest@gmail.com / guest +``` diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..eb6db90 --- /dev/null +++ b/lombok.config @@ -0,0 +1 @@ +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier \ No newline at end of file diff --git a/pom.xml b/pom.xml index 345b938..b269b1e 100644 --- a/pom.xml +++ b/pom.xml @@ -5,19 +5,20 @@ org.springframework.boot spring-boot-starter-parent - 2.6.7 + 3.1.2 ru.javaops bootjava 1.0 BootJava - Spring Boot 2.x HATEOAS application (BootJava) + Spring Boot 3.x HATEOAS application (BootJava) https://javaops.ru/view/bootjava 17 - 1.6.8 + 2.2.0 + 1.16.1 UTF-8 UTF-8 @@ -43,18 +44,13 @@ com.fasterxml.jackson.datatype - jackson-datatype-hibernate5 + jackson-datatype-hibernate5-jakarta - + org.springdoc - springdoc-openapi-ui - ${springdoc.version} - - - org.springdoc - springdoc-openapi-security + springdoc-openapi-starter-webmvc-ui ${springdoc.version} @@ -72,24 +68,22 @@ com.h2database h2 + + org.jsoup + jsoup + ${jsoup.version} + org.projectlombok lombok true - - org.jsoup - jsoup - 1.14.3 - - org.springframework.boot spring-boot-starter-test test - org.springframework.security spring-security-test diff --git a/src/main/java/ru/javaops/bootjava/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/config/AppConfig.java index f775ee8..44e6e52 100644 --- a/src/main/java/ru/javaops/bootjava/config/AppConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/AppConfig.java @@ -1,20 +1,28 @@ package ru.javaops.bootjava.config; -import com.fasterxml.jackson.databind.Module; -import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.hibernate5.jakarta.Hibernate5JakartaModule; 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 org.springframework.http.ProblemDetail; +import ru.javaops.bootjava.util.JsonUtil; import java.sql.SQLException; +import java.util.Map; + +import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY; +import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE; @Configuration @Slf4j @EnableCaching -// TODO: cache only most requested data! public class AppConfig { @Profile("!test") @@ -24,9 +32,18 @@ Server h2Server() throws SQLException { return Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", "9092"); } - // https://stackoverflow.com/a/46947975/548473 - @Bean - Module module() { - return new Hibernate5Module(); + // https://stackoverflow.com/a/74630129/548473 + @JsonAutoDetect(fieldVisibility = NONE, getterVisibility = ANY) + interface MixIn { + @JsonAnyGetter + Map getProperties(); + } + + @Autowired + void configureAndStoreObjectMapper(ObjectMapper objectMapper) { + objectMapper.registerModule(new Hibernate5JakartaModule()); + // ErrorHandling: https://stackoverflow.com/questions/7421474/548473 + objectMapper.addMixIn(ProblemDetail.class, MixIn.class); + JsonUtil.setMapper(objectMapper); } } diff --git a/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java b/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java index 4e9aa5f..546db4c 100644 --- a/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java +++ b/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java @@ -6,7 +6,7 @@ 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.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -24,8 +24,9 @@ description = """ Приложение по курсу BootJava

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

+ - user@yandex.ru / password
+ - admin@gmail.com / admin
+ - guest@gmail.com / guest

""", contact = @Contact(url = "https://javaops.ru/#contacts", name = "Grigory Kislin", email = "admin@javaops.ru") ), diff --git a/src/main/java/ru/javaops/bootjava/config/RestAuthenticationEntryPoint.java b/src/main/java/ru/javaops/bootjava/config/RestAuthenticationEntryPoint.java new file mode 100644 index 0000000..95f4ddc --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/RestAuthenticationEntryPoint.java @@ -0,0 +1,22 @@ +package ru.javaops.bootjava.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +@Component +@AllArgsConstructor +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Qualifier("handlerExceptionResolver") + private final HandlerExceptionResolver resolver; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) { + resolver.resolveException(request, response, null, authException); + } +} diff --git a/src/main/java/ru/javaops/bootjava/config/RestExceptionHandler.java b/src/main/java/ru/javaops/bootjava/config/RestExceptionHandler.java new file mode 100644 index 0000000..bc0f5d2 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/RestExceptionHandler.java @@ -0,0 +1,128 @@ +package ru.javaops.bootjava.config; + +import jakarta.persistence.EntityNotFoundException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ValidationException; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.NestedExceptionUtils; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ProblemDetail; +import org.springframework.lang.NonNull; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.firewall.RequestRejectedException; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.ErrorResponse; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; +import ru.javaops.bootjava.error.*; + +import java.io.FileNotFoundException; +import java.nio.file.AccessDeniedException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import static ru.javaops.bootjava.error.ErrorType.*; + +@RestControllerAdvice +@AllArgsConstructor +@Slf4j +public class RestExceptionHandler { + public static final String ERR_PFX = "ERR# "; + + @Getter + private final MessageSource messageSource; + + // https://stackoverflow.com/a/52254601/548473 + static final Map, ErrorType> HTTP_STATUS_MAP = new LinkedHashMap<>() { + { +// more specific first + put(NotFoundException.class, NOT_FOUND); + put(FileNotFoundException.class, NOT_FOUND); + put(NoHandlerFoundException.class, NOT_FOUND); + put(DataConflictException.class, DATA_CONFLICT); + put(IllegalRequestDataException.class, BAD_REQUEST); + put(AppException.class, APP_ERROR); + put(UnsupportedOperationException.class, APP_ERROR); + put(EntityNotFoundException.class, DATA_CONFLICT); + put(DataIntegrityViolationException.class, DATA_CONFLICT); + put(IllegalArgumentException.class, BAD_DATA); + put(BindException.class, BAD_REQUEST); + put(ValidationException.class, BAD_REQUEST); + put(HttpRequestMethodNotSupportedException.class, BAD_REQUEST); + put(MissingServletRequestParameterException.class, BAD_REQUEST); + put(RequestRejectedException.class, BAD_REQUEST); + put(AccessDeniedException.class, FORBIDDEN); + put(AuthenticationException.class, UNAUTHORIZED); + } + }; + + @ExceptionHandler(BindException.class) + ProblemDetail bindException(BindException ex, HttpServletRequest request) { + return processException(ex, request, Map.of("invalid_params", getErrorMap(ex.getBindingResult()))); + } + + // https://howtodoinjava.com/spring-mvc/spring-problemdetail-errorresponse/#5-adding-problemdetail-to-custom-exceptions + @ExceptionHandler(Exception.class) + ProblemDetail exception(Exception ex, HttpServletRequest request) { + return processException(ex, request, Map.of()); + } + + ProblemDetail processException(@NonNull Exception ex, HttpServletRequest request, Map additionalParams) { + String path = request.getRequestURI(); + Class exClass = ex.getClass(); + Optional optType = HTTP_STATUS_MAP.entrySet().stream() + .filter( + entry -> entry.getKey().isAssignableFrom(exClass) + ) + .findAny().map(Map.Entry::getValue); + if (optType.isPresent()) { + log.error(ERR_PFX + "Exception {} at request {}", ex, path); + return createProblemDetail(ex, optType.get(), ex.getMessage(), additionalParams); + } else { + Throwable root = getRootCause(ex); + log.error(ERR_PFX + "Exception " + root + " at request " + path, root); + return createProblemDetail(ex, APP_ERROR, "Exception " + root.getClass().getSimpleName(), additionalParams); + } + } + + private ProblemDetail createProblemDetail(Exception ex, ErrorType type, String defaultDetail, @NonNull Map additionalParams) { + ErrorResponse.Builder builder = ErrorResponse.builder(ex, type.status, defaultDetail); + ProblemDetail pd = builder.build().updateAndGetBody(messageSource, LocaleContextHolder.getLocale()); + additionalParams.forEach(pd::setProperty); + return pd; + } + + private Map getErrorMap(BindingResult result) { + Map invalidParams = new LinkedHashMap<>(); + for (ObjectError error : result.getGlobalErrors()) { + invalidParams.put(error.getObjectName(), getErrorMessage(error)); + } + for (FieldError error : result.getFieldErrors()) { + invalidParams.put(error.getField(), getErrorMessage(error)); + } + log.warn("BindingException: {}", invalidParams); + return invalidParams; + } + + private String getErrorMessage(ObjectError error) { + return messageSource.getMessage(error.getCode(), error.getArguments(), error.getDefaultMessage(), LocaleContextHolder.getLocale()); + } + + // https://stackoverflow.com/a/65442410/548473 + @NonNull + private static Throwable getRootCause(@NonNull Throwable t) { + Throwable rootCause = NestedExceptionUtils.getRootCause(t); + return rootCause != null ? rootCause : t; + } +} diff --git a/src/main/java/ru/javaops/bootjava/config/SecurityConfig.java b/src/main/java/ru/javaops/bootjava/config/SecurityConfig.java new file mode 100644 index 0000000..6fba44d --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/SecurityConfig.java @@ -0,0 +1,71 @@ +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.Customizer; +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.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +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 SecurityConfig { + public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + private final UserRepository userRepository; + private final RestAuthenticationEntryPoint authenticationEntryPoint; + + @Bean + PasswordEncoder passwordEncoder() { + return PASSWORD_ENCODER; + } + + @Bean + 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"))); + }; + } + + // https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter#configuring-websecurity + // https://stackoverflow.com/a/61147599/548473 + @Bean + WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring().requestMatchers("/", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**"); + } + + //https://stackoverflow.com/a/76538979/548473 + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.securityMatcher("/api/**").authorizeHttpRequests(authz -> authz + .requestMatchers("/api/admin/**").hasRole(Role.ADMIN.name()) + .requestMatchers(HttpMethod.POST, "/api/profile").anonymous() + .requestMatchers("/api/**").authenticated() + ).httpBasic(Customizer.withDefaults()) + .sessionManagement(smc -> smc + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ).csrf(AbstractHttpConfigurer::disable); + return http.build(); + } +} \ 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 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/error/AppException.java b/src/main/java/ru/javaops/bootjava/error/AppException.java index 9d00dd4..9f11453 100644 --- a/src/main/java/ru/javaops/bootjava/error/AppException.java +++ b/src/main/java/ru/javaops/bootjava/error/AppException.java @@ -1,21 +1,10 @@ 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; +import org.springframework.lang.NonNull; -@Getter -public class AppException extends ResponseStatusException { - private final ErrorAttributeOptions options; +public class AppException extends RuntimeException { - public AppException(HttpStatus status, String message, ErrorAttributeOptions options) { - super(status, message); - this.options = options; - } - - @Override - public String getMessage() { - return getReason(); + public AppException(@NonNull String message) { + super(message); } } diff --git a/src/main/java/ru/javaops/bootjava/error/DataConflictException.java b/src/main/java/ru/javaops/bootjava/error/DataConflictException.java new file mode 100644 index 0000000..b048af5 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/error/DataConflictException.java @@ -0,0 +1,7 @@ +package ru.javaops.bootjava.error; + +public class DataConflictException extends AppException { + public DataConflictException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/error/ErrorType.java b/src/main/java/ru/javaops/bootjava/error/ErrorType.java new file mode 100644 index 0000000..5fac6f3 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/error/ErrorType.java @@ -0,0 +1,22 @@ +package ru.javaops.bootjava.error; + +import org.springframework.http.HttpStatus; + +public enum ErrorType { + APP_ERROR("Application error", HttpStatus.INTERNAL_SERVER_ERROR), + BAD_DATA("Wrong data", HttpStatus.UNPROCESSABLE_ENTITY), + BAD_REQUEST("Bad request", HttpStatus.UNPROCESSABLE_ENTITY), + DATA_CONFLICT("DataBase conflict", HttpStatus.CONFLICT), + NOT_FOUND("Resource not found", HttpStatus.NOT_FOUND), + AUTH_ERROR("Authorization error", HttpStatus.FORBIDDEN), + UNAUTHORIZED("Request unauthorized", HttpStatus.UNAUTHORIZED), + FORBIDDEN("Request forbidden", HttpStatus.FORBIDDEN); + + ErrorType(String title, HttpStatus status) { + this.title = title; + this.status = status; + } + + public final String title; + public final HttpStatus status; +} diff --git a/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java b/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java index cb18581..1ca9eaf 100644 --- a/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java +++ b/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java @@ -1,12 +1,7 @@ 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)); + super(msg); } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/error/NotFoundException.java b/src/main/java/ru/javaops/bootjava/error/NotFoundException.java new file mode 100644 index 0000000..dab96dd --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/error/NotFoundException.java @@ -0,0 +1,7 @@ +package ru.javaops.bootjava.error; + +public class NotFoundException extends AppException { + public NotFoundException(String msg) { + super(msg); + } +} \ 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..9644938 100644 --- a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -1,15 +1,13 @@ package ru.javaops.bootjava.model; -import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; import lombok.*; import org.springframework.data.domain.Persistable; import org.springframework.data.util.ProxyUtils; import org.springframework.util.Assert; import ru.javaops.bootjava.HasId; -import javax.persistence.*; - @MappedSuperclass // https://stackoverflow.com/a/6084701/548473 @Access(AccessType.FIELD) @@ -30,7 +28,6 @@ public int id() { return id; } - @JsonIgnore @Override public boolean isNew() { return id == null; diff --git a/src/main/java/ru/javaops/bootjava/model/NamedEntity.java b/src/main/java/ru/javaops/bootjava/model/NamedEntity.java index 68a743a..d103aac 100644 --- a/src/main/java/ru/javaops/bootjava/model/NamedEntity.java +++ b/src/main/java/ru/javaops/bootjava/model/NamedEntity.java @@ -1,16 +1,15 @@ package ru.javaops.bootjava.model; +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import ru.javaops.bootjava.util.validation.NoHtml; -import javax.persistence.Column; -import javax.persistence.MappedSuperclass; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.Size; - @MappedSuperclass @Getter diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java index 4e402cb..558e55d 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -1,6 +1,11 @@ package ru.javaops.bootjava.model; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -11,26 +16,15 @@ import ru.javaops.bootjava.HasIdAndEmail; import ru.javaops.bootjava.util.validation.NoHtml; -import javax.persistence.*; -import javax.validation.constraints.Email; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; -import java.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") @Getter @Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class User extends NamedEntity implements HasIdAndEmail, Serializable { - @Serial - private static final long serialVersionUID = 1L; +public class User extends NamedEntity implements HasIdAndEmail { +// No session, no needs Serializable @Column(name = "email", nullable = false, unique = true) @Email @@ -41,7 +35,7 @@ public class User extends NamedEntity implements HasIdAndEmail, Serializable { @Column(name = "password", nullable = false) @NotBlank - @Size(max = 256) + @Size(max = 128) // https://stackoverflow.com/a/12505165/548473 @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String password; @@ -57,10 +51,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 +62,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) { @@ -85,6 +79,10 @@ public void setRoles(Collection roles) { this.roles = CollectionUtils.isEmpty(roles) ? EnumSet.noneOf(Role.class) : EnumSet.copyOf(roles); } + public boolean hasRole(Role role) { + return roles != null && roles.contains(role); + } + @Override public String toString() { return "User:" + id + '[' + email + ']'; diff --git a/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java b/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java index 08ed1da..1d8a191 100644 --- a/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java @@ -5,8 +5,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.NoRepositoryBean; import org.springframework.transaction.annotation.Transactional; - -import static ru.javaops.bootjava.util.validation.ValidationUtil.checkModification; +import ru.javaops.bootjava.error.NotFoundException; // https://stackoverflow.com/questions/42781264/multiple-base-repositories-in-spring-data-jpa @NoRepositoryBean @@ -15,10 +14,18 @@ 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); + // https://stackoverflow.com/a/60695301/548473 (existed delete code 204, not existed: 404) + @SuppressWarnings("all") // transaction invoked default void deleteExisted(int id) { - checkModification(delete(id), id); + if (delete(id) == 0) { + throw new NotFoundException("Entity with id=" + id + " not found"); + } + } + + default T getExisted(int id) { + return findById(id).orElseThrow(() -> new NotFoundException("Entity with id=" + id + " not found")); } } \ 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 index 727497f..6008879 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -1,16 +1,27 @@ package ru.javaops.bootjava.repository; -import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; +import ru.javaops.bootjava.error.NotFoundException; import ru.javaops.bootjava.model.User; import java.util.Optional; +import static ru.javaops.bootjava.config.SecurityConfig.PASSWORD_ENCODER; + @Transactional(readOnly = true) -@Tag(name = "User Controller") public interface UserRepository extends BaseRepository { - @Query("SELECT u FROM User u WHERE u.email = LOWER(:email)") Optional findByEmailIgnoreCase(String email); + + @Transactional + default User prepareAndSave(User user) { + user.setPassword(PASSWORD_ENCODER.encode(user.getPassword())); + user.setEmail(user.getEmail().toLowerCase()); + return save(user); + } + + default User getExistedByEmail(String email) { + return findByEmailIgnoreCase(email).orElseThrow(() -> new NotFoundException("User with email=" + email + " not found")); + } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/to/NamedTo.java b/src/main/java/ru/javaops/bootjava/to/NamedTo.java index 0b2e4b1..4b5a7da 100644 --- a/src/main/java/ru/javaops/bootjava/to/NamedTo.java +++ b/src/main/java/ru/javaops/bootjava/to/NamedTo.java @@ -1,12 +1,11 @@ package ru.javaops.bootjava.to; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Data; import lombok.EqualsAndHashCode; import ru.javaops.bootjava.util.validation.NoHtml; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.Size; - @Data @EqualsAndHashCode(callSuper = true) public class NamedTo extends BaseTo { diff --git a/src/main/java/ru/javaops/bootjava/to/UserTo.java b/src/main/java/ru/javaops/bootjava/to/UserTo.java index eb0317f..3b970f5 100644 --- a/src/main/java/ru/javaops/bootjava/to/UserTo.java +++ b/src/main/java/ru/javaops/bootjava/to/UserTo.java @@ -1,14 +1,13 @@ package ru.javaops.bootjava.to; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.EqualsAndHashCode; import lombok.Value; import ru.javaops.bootjava.HasIdAndEmail; import ru.javaops.bootjava.util.validation.NoHtml; -import javax.validation.constraints.Email; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.Size; - @Value @EqualsAndHashCode(callSuper = true) public class UserTo extends NamedTo implements HasIdAndEmail { diff --git a/src/main/java/ru/javaops/bootjava/util/UserUtil.java b/src/main/java/ru/javaops/bootjava/util/UsersUtil.java similarity index 55% rename from src/main/java/ru/javaops/bootjava/util/UserUtil.java rename to src/main/java/ru/javaops/bootjava/util/UsersUtil.java index 4cf471b..4986248 100644 --- a/src/main/java/ru/javaops/bootjava/util/UserUtil.java +++ b/src/main/java/ru/javaops/bootjava/util/UsersUtil.java @@ -1,16 +1,12 @@ 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; @UtilityClass -public class UserUtil { - - public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); +public class UsersUtil { public static User createNewFromTo(UserTo userTo) { return new User(null, userTo.getName(), userTo.getEmail().toLowerCase(), userTo.getPassword(), Role.USER); @@ -22,10 +18,4 @@ public static User updateFromTo(User user, UserTo userTo) { user.setPassword(userTo.getPassword()); return user; } - - public static User prepareToSave(User user) { - user.setPassword(PASSWORD_ENCODER.encode(user.getPassword())); - user.setEmail(user.getEmail().toLowerCase()); - return user; - } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java b/src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java index e2cba7e..40d3756 100644 --- a/src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java +++ b/src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java @@ -1,7 +1,8 @@ package ru.javaops.bootjava.util.validation; -import javax.validation.Constraint; -import javax.validation.Payload; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.Target; diff --git a/src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java b/src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java index b5d0536..6fbf5d3 100644 --- a/src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java +++ b/src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java @@ -1,11 +1,10 @@ package ru.javaops.bootjava.util.validation; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; 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) { 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..9ca3fb7 100644 --- a/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java +++ b/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java @@ -1,8 +1,6 @@ package ru.javaops.bootjava.util.validation; import lombok.experimental.UtilityClass; -import org.springframework.core.NestedExceptionUtils; -import org.springframework.lang.NonNull; import ru.javaops.bootjava.HasId; import ru.javaops.bootjava.error.IllegalRequestDataException; @@ -23,17 +21,4 @@ public static void assureIdConsistent(HasId bean, int id) { throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must has id=" + id); } } - - public static void checkModification(int count, int id) { - if (count == 0) { - throw new IllegalRequestDataException("Entity with id=" + id + " not found"); - } - } - - // 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/AuthUser.java b/src/main/java/ru/javaops/bootjava/web/AuthUser.java index d31db91..0a51442 100644 --- a/src/main/java/ru/javaops/bootjava/web/AuthUser.java +++ b/src/main/java/ru/javaops/bootjava/web/AuthUser.java @@ -1,12 +1,15 @@ package ru.javaops.bootjava.web; import lombok.Getter; -import lombok.ToString; import org.springframework.lang.NonNull; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import ru.javaops.bootjava.model.Role; import ru.javaops.bootjava.model.User; +import static java.util.Objects.requireNonNull; + @Getter -@ToString(of = "user") public class AuthUser extends org.springframework.security.core.userdetails.User { private final User user; @@ -19,4 +22,33 @@ public AuthUser(@NonNull User user) { public int id() { return user.id(); } + + public static AuthUser safeGet() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + return null; + } + return (auth.getPrincipal() instanceof AuthUser au) ? au : null; + } + + public static AuthUser get() { + return requireNonNull(safeGet(), "No authorized user found"); + } + + public static User authUser() { + return get().getUser(); + } + + public static int authId() { + return get().id(); + } + + public boolean hasRole(Role role) { + return user.hasRole(role); + } + + @Override + public String toString() { + return "AuthUser:" + id() + '[' + user.getEmail() + ']'; + } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/web/GlobalExceptionHandler.java b/src/main/java/ru/javaops/bootjava/web/GlobalExceptionHandler.java deleted file mode 100644 index 60a28c2..0000000 --- a/src/main/java/ru/javaops/bootjava/web/GlobalExceptionHandler.java +++ /dev/null @@ -1,86 +0,0 @@ -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/SecurityUtil.java b/src/main/java/ru/javaops/bootjava/web/SecurityUtil.java deleted file mode 100644 index 36fcf86..0000000 --- a/src/main/java/ru/javaops/bootjava/web/SecurityUtil.java +++ /dev/null @@ -1,33 +0,0 @@ -package ru.javaops.bootjava.web; - -import lombok.experimental.UtilityClass; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import ru.javaops.bootjava.model.User; - -import static java.util.Objects.requireNonNull; - -@UtilityClass -public class SecurityUtil { - - public static AuthUser safeGet() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null) { - return null; - } - Object principal = auth.getPrincipal(); - return (principal instanceof AuthUser) ? (AuthUser) principal : null; - } - - public static AuthUser get() { - return requireNonNull(safeGet(), "No authorized user found"); - } - - public static User authUser() { - return get().getUser(); - } - - public static int authId() { - return get().getUser().id(); - } -} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java b/src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java index a41e626..2a417e5 100644 --- a/src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java +++ b/src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java @@ -1,16 +1,16 @@ package ru.javaops.bootjava.web.user; -import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.InitBinder; import ru.javaops.bootjava.model.User; import ru.javaops.bootjava.repository.UserRepository; -import ru.javaops.bootjava.util.UserUtil; -@Slf4j +import static org.slf4j.LoggerFactory.getLogger; + public abstract class AbstractUserController { + protected final Logger log = getLogger(getClass()); @Autowired protected UserRepository repository; @@ -23,17 +23,13 @@ protected void initBinder(WebDataBinder binder) { binder.addValidators(emailValidator); } - public ResponseEntity get(int id) { + public User get(int id) { log.info("get {}", id); - return ResponseEntity.of(repository.findById(id)); + return repository.getExisted(id); } public void delete(int id) { log.info("delete {}", id); repository.deleteExisted(id); } - - protected User prepareAndSave(User user) { - return repository.save(UserUtil.prepareToSave(user)); - } } \ No newline at end of file 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..564a735 100644 --- a/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java +++ b/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java @@ -1,6 +1,6 @@ package ru.javaops.bootjava.web.user; -import lombok.extern.slf4j.Slf4j; +import jakarta.validation.Valid; import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -10,7 +10,6 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import ru.javaops.bootjava.model.User; -import javax.validation.Valid; import java.net.URI; import java.util.List; @@ -19,14 +18,14 @@ @RestController @RequestMapping(value = AdminUserController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) -@Slf4j +// TODO: cache only most requested, seldom changed data! public class AdminUserController extends AbstractUserController { static final String REST_URL = "/api/admin/users"; @Override @GetMapping("/{id}") - public ResponseEntity get(@PathVariable int id) { + public User get(@PathVariable int id) { return super.get(id); } @@ -47,7 +46,7 @@ public List getAll() { public ResponseEntity createWithLocation(@Valid @RequestBody User user) { log.info("create {}", user); checkNew(user); - User created = prepareAndSave(user); + User created = repository.prepareAndSave(user); URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() .path(REST_URL + "/{id}") .buildAndExpand(created.getId()).toUri(); @@ -59,13 +58,13 @@ public ResponseEntity createWithLocation(@Valid @RequestBody User user) { public void update(@Valid @RequestBody User user, @PathVariable int id) { log.info("update {} with id={}", user, id); assureIdConsistent(user, id); - prepareAndSave(user); + repository.prepareAndSave(user); } @GetMapping("/by-email") - public ResponseEntity getByEmail(@RequestParam String email) { + public User getByEmail(@RequestParam String email) { log.info("getByEmail {}", email); - return ResponseEntity.of(repository.findByEmailIgnoreCase(email)); + return repository.getExistedByEmail(email); } @PatchMapping("/{id}") @@ -73,7 +72,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/java/ru/javaops/bootjava/web/user/ProfileController.java b/src/main/java/ru/javaops/bootjava/web/user/ProfileController.java index 278324a..e71dcd9 100644 --- a/src/main/java/ru/javaops/bootjava/web/user/ProfileController.java +++ b/src/main/java/ru/javaops/bootjava/web/user/ProfileController.java @@ -1,5 +1,6 @@ package ru.javaops.bootjava.web.user; +import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -10,10 +11,9 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import ru.javaops.bootjava.model.User; import ru.javaops.bootjava.to.UserTo; -import ru.javaops.bootjava.util.UserUtil; +import ru.javaops.bootjava.util.UsersUtil; import ru.javaops.bootjava.web.AuthUser; -import javax.validation.Valid; import java.net.URI; import static ru.javaops.bootjava.util.validation.ValidationUtil.assureIdConsistent; @@ -22,11 +22,13 @@ @RestController @RequestMapping(value = ProfileController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) @Slf4j +// TODO: cache only most requested data! public class ProfileController extends AbstractUserController { static final String REST_URL = "/api/profile"; @GetMapping public User get(@AuthenticationPrincipal AuthUser authUser) { + log.info("get {}", authUser); return authUser.getUser(); } @@ -41,7 +43,7 @@ public void delete(@AuthenticationPrincipal AuthUser authUser) { public ResponseEntity register(@Valid @RequestBody UserTo userTo) { log.info("register {}", userTo); checkNew(userTo); - User created = prepareAndSave(UserUtil.createNewFromTo(userTo)); + User created = repository.prepareAndSave(UsersUtil.createNewFromTo(userTo)); URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() .path(REST_URL).build().toUri(); return ResponseEntity.created(uriOfNewResource).body(created); @@ -51,8 +53,9 @@ public ResponseEntity register(@Valid @RequestBody UserTo userTo) { @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional public void update(@RequestBody @Valid UserTo userTo, @AuthenticationPrincipal AuthUser authUser) { + log.info("update {} with id={}", userTo, authUser.id()); assureIdConsistent(userTo, authUser.id()); User user = authUser.getUser(); - prepareAndSave(UserUtil.updateFromTo(user, userTo)); + repository.prepareAndSave(UsersUtil.updateFromTo(user, userTo)); } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java b/src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java index 2d8011e..8d0ea22 100644 --- a/src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java +++ b/src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java @@ -1,5 +1,6 @@ package ru.javaops.bootjava.web.user; +import jakarta.servlet.http.HttpServletRequest; import lombok.AllArgsConstructor; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; @@ -7,9 +8,7 @@ import org.springframework.validation.Errors; import ru.javaops.bootjava.HasIdAndEmail; import ru.javaops.bootjava.repository.UserRepository; -import ru.javaops.bootjava.web.SecurityUtil; - -import javax.servlet.http.HttpServletRequest; +import ru.javaops.bootjava.web.AuthUser; @Component @AllArgsConstructor @@ -33,13 +32,13 @@ public void validate(@NonNull Object target, @NonNull Errors errors) { if (request.getMethod().equals("PUT")) { // UPDATE int dbId = dbUser.id(); - // it is ok, if update ourself + // it is ok, if update ourselves if (user.getId() != null && dbId == user.id()) return; // Workaround for update with user.id=null in request body // ValidationUtil.assureIdConsistent called after this validation String requestURI = request.getRequestURI(); - if (requestURI.endsWith("/" + dbId) || (dbId == SecurityUtil.authId() && requestURI.contains("/profile"))) + if (requestURI.endsWith("/" + dbId) || (dbId == AuthUser.authId() && requestURI.contains("/profile"))) return; } errors.rejectValue("email", "", EXCEPTION_DUPLICATE_EMAIL); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9f55124..53da502 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -51,4 +51,6 @@ 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 \ No newline at end of file + force: true + +springdoc.swagger-ui.path: / diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index a37617f..2fd1045 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,8 +1,9 @@ -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) +VALUES ('User', 'user@yandex.ru', '{noop}password'), + ('Admin', 'admin@gmail.com', '{noop}admin'), + ('Guest', 'guest@gmail.com', '{noop}guest'); -INSERT INTO USER_ROLE (ROLE, USER_ID) +INSERT INTO USER_ROLE (role, user_id) VALUES ('USER', 1), ('ADMIN', 2), ('USER', 2); \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java b/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java index 541fafc..ceb7a45 100644 --- a/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java @@ -18,20 +18,20 @@ 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.web.user.UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL; +import static ru.javaops.bootjava.web.user.AdminUserController.REST_URL; import static ru.javaops.bootjava.web.user.UserTestData.*; class AdminUserControllerTest extends AbstractControllerTest { - private static final String REST_URL = AdminUserController.REST_URL + '/'; + private static final String REST_URL_SLASH = REST_URL + '/'; @Autowired - private UserRepository userRepository; + private UserRepository repository; @Test @WithUserDetails(value = ADMIN_MAIL) void get() throws Exception { - perform(MockMvcRequestBuilders.get(REST_URL + ADMIN_ID)) + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + ADMIN_ID)) .andExpect(status().isOk()) .andDo(print()) // https://jira.spring.io/browse/SPR-14472 @@ -42,7 +42,7 @@ void get() throws Exception { @Test @WithUserDetails(value = ADMIN_MAIL) void getNotFound() throws Exception { - perform(MockMvcRequestBuilders.get(REST_URL + NOT_FOUND)) + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + NOT_FOUND)) .andDo(print()) .andExpect(status().isNotFound()); } @@ -50,7 +50,7 @@ void getNotFound() throws Exception { @Test @WithUserDetails(value = ADMIN_MAIL) void getByEmail() throws Exception { - perform(MockMvcRequestBuilders.get(REST_URL + "by-email?email=" + admin.getEmail())) + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + "by-email?email=" + admin.getEmail())) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(USER_MATCHER.contentJson(admin)); @@ -59,33 +59,34 @@ void getByEmail() throws Exception { @Test @WithUserDetails(value = ADMIN_MAIL) void delete() throws Exception { - perform(MockMvcRequestBuilders.delete(REST_URL + USER_ID)) + perform(MockMvcRequestBuilders.delete(REST_URL_SLASH + USER_ID)) .andDo(print()) .andExpect(status().isNoContent()); - assertFalse(userRepository.findById(USER_ID).isPresent()); + assertFalse(repository.findById(USER_ID).isPresent()); } @Test @WithUserDetails(value = ADMIN_MAIL) void deleteNotFound() throws Exception { - perform(MockMvcRequestBuilders.delete(REST_URL + NOT_FOUND)) + perform(MockMvcRequestBuilders.delete(REST_URL_SLASH + NOT_FOUND)) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isNotFound()); } @Test @WithUserDetails(value = ADMIN_MAIL) void enableNotFound() throws Exception { - perform(MockMvcRequestBuilders.patch(REST_URL + NOT_FOUND) + perform(MockMvcRequestBuilders.patch(REST_URL_SLASH + NOT_FOUND) .param("enabled", "false") .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isNotFound()); } @Test void getUnAuth() throws Exception { perform(MockMvcRequestBuilders.get(REST_URL)) + .andDo(print()) .andExpect(status().isUnauthorized()); } @@ -101,13 +102,13 @@ void getForbidden() throws Exception { void update() throws Exception { User updated = getUpdated(); updated.setId(null); - perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(updated, "newPass"))) .andDo(print()) .andExpect(status().isNoContent()); - USER_MATCHER.assertMatch(userRepository.getById(USER_ID), getUpdated()); + USER_MATCHER.assertMatch(repository.getExisted(USER_ID), getUpdated()); } @Test @@ -123,7 +124,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(repository.getExisted(newId), newUser); } @Test @@ -132,19 +133,19 @@ void getAll() throws Exception { perform(MockMvcRequestBuilders.get(REST_URL)) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(USER_MATCHER.contentJson(admin, user)); + .andExpect(USER_MATCHER.contentJson(admin, guest, user)); } @Test @WithUserDetails(value = ADMIN_MAIL) void enable() throws Exception { - perform(MockMvcRequestBuilders.patch(REST_URL + USER_ID) + perform(MockMvcRequestBuilders.patch(REST_URL_SLASH + USER_ID) .param("enabled", "false") .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) .andExpect(status().isNoContent()); - assertFalse(userRepository.getById(USER_ID).isEnabled()); + assertFalse(repository.getExisted(USER_ID).isEnabled()); } @Test @@ -163,7 +164,7 @@ void createInvalid() throws Exception { void updateInvalid() throws Exception { User invalid = new User(user); invalid.setName(""); - perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(invalid, "password"))) .andDo(print()) @@ -175,7 +176,7 @@ void updateInvalid() throws Exception { void updateHtmlUnsafe() throws Exception { User updated = new User(user); updated.setName(""); - perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(updated, "password"))) .andDo(print()) @@ -188,12 +189,12 @@ void updateHtmlUnsafe() throws Exception { void updateDuplicate() throws Exception { User updated = new User(user); updated.setEmail(ADMIN_MAIL); - perform(MockMvcRequestBuilders.put(REST_URL + USER_ID) + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(updated, "password"))) .andDo(print()) .andExpect(status().isUnprocessableEntity()) - .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); + .andExpect(content().string(containsString(UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL))); } @Test @@ -206,6 +207,6 @@ void createDuplicate() throws Exception { .content(jsonWithPassword(expected, "newPass"))) .andDo(print()) .andExpect(status().isUnprocessableEntity()) - .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); + .andExpect(content().string(containsString(UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL))); } } \ No newline at end of file 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..53a7a61 100644 --- a/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java @@ -10,7 +10,7 @@ import ru.javaops.bootjava.repository.UserRepository; import ru.javaops.bootjava.to.UserTo; import ru.javaops.bootjava.util.JsonUtil; -import ru.javaops.bootjava.util.UserUtil; +import ru.javaops.bootjava.util.UsersUtil; import ru.javaops.bootjava.web.AbstractControllerTest; import static org.hamcrest.Matchers.containsString; @@ -18,13 +18,12 @@ 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.web.user.ProfileController.REST_URL; -import static ru.javaops.bootjava.web.user.UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL; import static ru.javaops.bootjava.web.user.UserTestData.*; class ProfileControllerTest extends AbstractControllerTest { @Autowired - private UserRepository userRepository; + private UserRepository repository; @Test @WithUserDetails(value = USER_MAIL) @@ -46,13 +45,13 @@ void getUnAuth() throws Exception { void delete() throws Exception { perform(MockMvcRequestBuilders.delete(REST_URL)) .andExpect(status().isNoContent()); - USER_MATCHER.assertMatch(userRepository.findAll(), admin); + USER_MATCHER.assertMatch(repository.findAll(), admin, guest); } @Test void register() throws Exception { UserTo newTo = new UserTo(null, "newName", "newemail@ya.ru", "newPassword"); - User newUser = UserUtil.createNewFromTo(newTo); + User newUser = UsersUtil.createNewFromTo(newTo); ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) .contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(newTo))) @@ -63,7 +62,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(repository.getExisted(newId), newUser); } @Test @@ -75,7 +74,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(repository.getExisted(USER_ID), UsersUtil.updateFromTo(new User(user), updatedTo)); } @Test @@ -107,6 +106,6 @@ void updateDuplicate() throws Exception { .content(JsonUtil.writeValue(updatedTo))) .andDo(print()) .andExpect(status().isUnprocessableEntity()) - .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); + .andExpect(content().string(containsString(UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL))); } } \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/web/user/UserTestData.java b/src/test/java/ru/javaops/bootjava/web/user/UserTestData.java index f4841b6..a4a3af6 100644 --- a/src/test/java/ru/javaops/bootjava/web/user/UserTestData.java +++ b/src/test/java/ru/javaops/bootjava/web/user/UserTestData.java @@ -13,12 +13,15 @@ public class UserTestData { public static final int USER_ID = 1; public static final int ADMIN_ID = 2; + public static final int GUEST_ID = 3; public static final int NOT_FOUND = 100; - public static final String USER_MAIL = "user@gmail.com"; - public static final String ADMIN_MAIL = "admin@javaops.ru"; + public static final String USER_MAIL = "user@yandex.ru"; + public static final String ADMIN_MAIL = "admin@gmail.com"; + public static final String GUEST_MAIL = "guest@gmail.com"; public static final User user = new User(USER_ID, "User", USER_MAIL, "password", Role.USER); public static final User admin = new User(ADMIN_ID, "Admin", ADMIN_MAIL, "admin", Role.ADMIN, Role.USER); + public static final User guest = new User(GUEST_ID, "Guest", GUEST_MAIL, "guest"); public static User getNew() { return new User(null, "New", "new@gmail.com", "newPass", false, new Date(), Collections.singleton(Role.USER)); From e0f3ae3e6167be312f126a065fdf460942561241 Mon Sep 17 00:00:00 2001 From: JavaOPs Date: Tue, 5 Nov 2024 01:01:50 +0300 Subject: [PATCH 27/32] 8_02_update_fix_refactoring --- pom.xml | 18 +++- .../ru/javaops/bootjava/app/AuthUser.java | 30 ++++++ .../ru/javaops/bootjava/app/AuthUtil.java | 20 ++++ .../bootjava/{ => app}/config/AppConfig.java | 4 +- .../{ => app}/config/OpenApiConfig.java | 2 +- .../config/RestExceptionHandler.java | 96 +++++++++++-------- .../{ => app}/config/SecurityConfig.java | 38 +++----- .../BaseRepository.java | 4 +- .../javaops/bootjava/{ => common}/HasId.java | 2 +- .../bootjava/{ => common}/HasIdAndEmail.java | 2 +- .../bootjava/common/error/AppException.java | 14 +++ .../common/error/DataConflictException.java | 9 ++ .../{ => common}/error/ErrorType.java | 2 +- .../error/IllegalRequestDataException.java | 9 ++ .../common/error/NotFoundException.java | 9 ++ .../bootjava/common/model/BaseEntity.java | 41 ++++++++ .../{ => common}/model/NamedEntity.java | 4 +- .../bootjava/{ => common}/to/BaseTo.java | 4 +- .../bootjava/{ => common}/to/NamedTo.java | 6 +- .../common/util/HibernateProxyHelper.java | 24 +++++ .../bootjava/{ => common}/util/JsonUtil.java | 2 +- .../{util => common}/validation/NoHtml.java | 9 +- .../validation/NoHtmlValidator.java | 2 +- .../validation/ValidationUtil.java | 6 +- .../config/RestAuthenticationEntryPoint.java | 22 ----- .../javaops/bootjava/error/AppException.java | 10 -- .../bootjava/error/DataConflictException.java | 7 -- .../error/IllegalRequestDataException.java | 7 -- .../bootjava/error/NotFoundException.java | 7 -- .../ru/javaops/bootjava/model/BaseEntity.java | 58 ----------- .../bootjava/{util => user}/UsersUtil.java | 8 +- .../bootjava/{ => user}/model/Role.java | 2 +- .../bootjava/{ => user}/model/User.java | 23 ++--- .../{ => user}/repository/UserRepository.java | 9 +- .../bootjava/{ => user}/to/UserTo.java | 9 +- .../web}/AbstractUserController.java | 6 +- .../web}/AdminUserController.java | 8 +- .../user => user/web}/ProfileController.java | 14 +-- .../web}/UniqueMailValidator.java | 10 +- .../ru/javaops/bootjava/web/AuthUser.java | 54 ----------- .../{web => }/AbstractControllerTest.java | 2 +- .../bootjava/{web => }/MatcherFactory.java | 4 +- .../bootjava/{web => }/user/UserTestData.java | 13 +-- .../web}/AdminUserControllerTest.java | 23 ++--- .../web}/ProfileControllerTest.java | 18 ++-- 45 files changed, 338 insertions(+), 333 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/app/AuthUser.java create mode 100644 src/main/java/ru/javaops/bootjava/app/AuthUtil.java rename src/main/java/ru/javaops/bootjava/{ => app}/config/AppConfig.java (95%) rename src/main/java/ru/javaops/bootjava/{ => app}/config/OpenApiConfig.java (97%) rename src/main/java/ru/javaops/bootjava/{ => app}/config/RestExceptionHandler.java (63%) rename src/main/java/ru/javaops/bootjava/{ => app}/config/SecurityConfig.java (60%) rename src/main/java/ru/javaops/bootjava/{repository => common}/BaseRepository.java (92%) rename src/main/java/ru/javaops/bootjava/{ => common}/HasId.java (91%) rename src/main/java/ru/javaops/bootjava/{ => common}/HasIdAndEmail.java (66%) create mode 100644 src/main/java/ru/javaops/bootjava/common/error/AppException.java create mode 100644 src/main/java/ru/javaops/bootjava/common/error/DataConflictException.java rename src/main/java/ru/javaops/bootjava/{ => common}/error/ErrorType.java (94%) create mode 100644 src/main/java/ru/javaops/bootjava/common/error/IllegalRequestDataException.java create mode 100644 src/main/java/ru/javaops/bootjava/common/error/NotFoundException.java create mode 100644 src/main/java/ru/javaops/bootjava/common/model/BaseEntity.java rename src/main/java/ru/javaops/bootjava/{ => common}/model/NamedEntity.java (88%) rename src/main/java/ru/javaops/bootjava/{ => common}/to/BaseTo.java (86%) rename src/main/java/ru/javaops/bootjava/{ => common}/to/NamedTo.java (80%) create mode 100644 src/main/java/ru/javaops/bootjava/common/util/HibernateProxyHelper.java rename src/main/java/ru/javaops/bootjava/{ => common}/util/JsonUtil.java (97%) rename src/main/java/ru/javaops/bootjava/{util => common}/validation/NoHtml.java (66%) rename src/main/java/ru/javaops/bootjava/{util => common}/validation/NoHtmlValidator.java (89%) rename src/main/java/ru/javaops/bootjava/{util => common}/validation/ValidationUtil.java (81%) delete mode 100644 src/main/java/ru/javaops/bootjava/config/RestAuthenticationEntryPoint.java delete mode 100644 src/main/java/ru/javaops/bootjava/error/AppException.java delete mode 100644 src/main/java/ru/javaops/bootjava/error/DataConflictException.java delete mode 100644 src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java delete mode 100644 src/main/java/ru/javaops/bootjava/error/NotFoundException.java delete mode 100644 src/main/java/ru/javaops/bootjava/model/BaseEntity.java rename src/main/java/ru/javaops/bootjava/{util => user}/UsersUtil.java (75%) rename src/main/java/ru/javaops/bootjava/{ => user}/model/Role.java (87%) rename src/main/java/ru/javaops/bootjava/{ => user}/model/User.java (79%) rename src/main/java/ru/javaops/bootjava/{ => user}/repository/UserRepository.java (73%) rename src/main/java/ru/javaops/bootjava/{ => user}/to/UserTo.java (78%) rename src/main/java/ru/javaops/bootjava/{web/user => user/web}/AbstractUserController.java (85%) rename src/main/java/ru/javaops/bootjava/{web/user => user/web}/AdminUserController.java (91%) rename src/main/java/ru/javaops/bootjava/{web/user => user/web}/ProfileController.java (85%) rename src/main/java/ru/javaops/bootjava/{web/user => user/web}/UniqueMailValidator.java (87%) delete mode 100644 src/main/java/ru/javaops/bootjava/web/AuthUser.java rename src/test/java/ru/javaops/bootjava/{web => }/AbstractControllerTest.java (97%) rename src/test/java/ru/javaops/bootjava/{web => }/MatcherFactory.java (97%) rename src/test/java/ru/javaops/bootjava/{web => }/user/UserTestData.java (82%) rename src/test/java/ru/javaops/bootjava/{web/user => user/web}/AdminUserControllerTest.java (90%) rename src/test/java/ru/javaops/bootjava/{web/user => user/web}/ProfileControllerTest.java (90%) diff --git a/pom.xml b/pom.xml index b269b1e..8a475c3 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.1.2 + 3.3.3 ru.javaops @@ -16,9 +16,9 @@ https://javaops.ru/view/bootjava - 17 - 2.2.0 - 1.16.1 + 21 + 2.6.0 + 1.18.1 UTF-8 UTF-8 @@ -78,6 +78,12 @@ lombok true + + com.google.code.findbugs + annotations + 3.0.1 + compile + org.springframework.boot @@ -108,6 +114,10 @@ org.projectlombok lombok + + com.google.code.findbugs + annotations + diff --git a/src/main/java/ru/javaops/bootjava/app/AuthUser.java b/src/main/java/ru/javaops/bootjava/app/AuthUser.java new file mode 100644 index 0000000..51d96d5 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/app/AuthUser.java @@ -0,0 +1,30 @@ +package ru.javaops.bootjava.app; + +import lombok.Getter; +import org.springframework.lang.NonNull; +import ru.javaops.bootjava.user.model.Role; +import ru.javaops.bootjava.user.model.User; + +public class AuthUser extends org.springframework.security.core.userdetails.User { + + @Getter + private final User user; + + public AuthUser(@NonNull User user) { + super(user.getEmail(), user.getPassword(), user.isEnabled(), true, true, true, user.getRoles()); + this.user = user; + } + + public int id() { + return user.id(); + } + + public boolean hasRole(Role role) { + return user.hasRole(role); + } + + @Override + public String toString() { + return "AuthUser:" + id() + '[' + user.getEmail() + ']'; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/app/AuthUtil.java b/src/main/java/ru/javaops/bootjava/app/AuthUtil.java new file mode 100644 index 0000000..08fcbd0 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/app/AuthUtil.java @@ -0,0 +1,20 @@ +package ru.javaops.bootjava.app; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static java.util.Objects.requireNonNull; + +public class AuthUtil { + public static AuthUser safeGet() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + return null; + } + return (auth.getPrincipal() instanceof AuthUser au) ? au : null; + } + + public static AuthUser get() { + return requireNonNull(safeGet(), "No authorized user found"); + } +} diff --git a/src/main/java/ru/javaops/bootjava/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java similarity index 95% rename from src/main/java/ru/javaops/bootjava/config/AppConfig.java rename to src/main/java/ru/javaops/bootjava/app/config/AppConfig.java index 44e6e52..4828ce6 100644 --- a/src/main/java/ru/javaops/bootjava/config/AppConfig.java +++ b/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.config; +package ru.javaops.bootjava.app.config; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAutoDetect; @@ -12,7 +12,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.http.ProblemDetail; -import ru.javaops.bootjava.util.JsonUtil; +import ru.javaops.bootjava.common.util.JsonUtil; import java.sql.SQLException; import java.util.Map; diff --git a/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java b/src/main/java/ru/javaops/bootjava/app/config/OpenApiConfig.java similarity index 97% rename from src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java rename to src/main/java/ru/javaops/bootjava/app/config/OpenApiConfig.java index 546db4c..e06783e 100644 --- a/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java +++ b/src/main/java/ru/javaops/bootjava/app/config/OpenApiConfig.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.config; +package ru.javaops.bootjava.app.config; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; diff --git a/src/main/java/ru/javaops/bootjava/config/RestExceptionHandler.java b/src/main/java/ru/javaops/bootjava/app/config/RestExceptionHandler.java similarity index 63% rename from src/main/java/ru/javaops/bootjava/config/RestExceptionHandler.java rename to src/main/java/ru/javaops/bootjava/app/config/RestExceptionHandler.java index bc0f5d2..d383459 100644 --- a/src/main/java/ru/javaops/bootjava/config/RestExceptionHandler.java +++ b/src/main/java/ru/javaops/bootjava/app/config/RestExceptionHandler.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.config; +package ru.javaops.bootjava.app.config; import jakarta.persistence.EntityNotFoundException; import jakarta.servlet.http.HttpServletRequest; @@ -20,19 +20,22 @@ import org.springframework.validation.ObjectError; import org.springframework.web.ErrorResponse; import org.springframework.web.HttpRequestMethodNotSupportedException; -import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.ServletRequestBindingException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.NoHandlerFoundException; -import ru.javaops.bootjava.error.*; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import ru.javaops.bootjava.common.error.AppException; +import ru.javaops.bootjava.common.error.ErrorType; import java.io.FileNotFoundException; +import java.net.URI; import java.nio.file.AccessDeniedException; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; -import static ru.javaops.bootjava.error.ErrorType.*; +import static ru.javaops.bootjava.common.error.ErrorType.*; @RestControllerAdvice @AllArgsConstructor @@ -47,76 +50,89 @@ public class RestExceptionHandler { static final Map, ErrorType> HTTP_STATUS_MAP = new LinkedHashMap<>() { { // more specific first - put(NotFoundException.class, NOT_FOUND); + put(NoResourceFoundException.class, NOT_FOUND); + put(AuthenticationException.class, UNAUTHORIZED); put(FileNotFoundException.class, NOT_FOUND); put(NoHandlerFoundException.class, NOT_FOUND); - put(DataConflictException.class, DATA_CONFLICT); - put(IllegalRequestDataException.class, BAD_REQUEST); - put(AppException.class, APP_ERROR); put(UnsupportedOperationException.class, APP_ERROR); put(EntityNotFoundException.class, DATA_CONFLICT); put(DataIntegrityViolationException.class, DATA_CONFLICT); put(IllegalArgumentException.class, BAD_DATA); - put(BindException.class, BAD_REQUEST); put(ValidationException.class, BAD_REQUEST); put(HttpRequestMethodNotSupportedException.class, BAD_REQUEST); - put(MissingServletRequestParameterException.class, BAD_REQUEST); + put(ServletRequestBindingException.class, BAD_REQUEST); put(RequestRejectedException.class, BAD_REQUEST); put(AccessDeniedException.class, FORBIDDEN); - put(AuthenticationException.class, UNAUTHORIZED); } }; @ExceptionHandler(BindException.class) ProblemDetail bindException(BindException ex, HttpServletRequest request) { - return processException(ex, request, Map.of("invalid_params", getErrorMap(ex.getBindingResult()))); + Map invalidParams = getErrorMap(ex.getBindingResult()); + String path = request.getRequestURI(); + log.warn(ERR_PFX + "BindException with invalidParams {} at request {}", invalidParams, path); + return createProblemDetail(ex, path, BAD_REQUEST, "BindException", Map.of("invalid_params", invalidParams)); + } + + private Map getErrorMap(BindingResult result) { + Map invalidParams = new LinkedHashMap<>(); + for (ObjectError error : result.getGlobalErrors()) { + invalidParams.put(error.getObjectName(), getErrorMessage(error)); + } + for (FieldError error : result.getFieldErrors()) { + invalidParams.put(error.getField(), getErrorMessage(error)); + } + return invalidParams; + } + + private String getErrorMessage(ObjectError error) { + return error.getCode() == null ? error.getDefaultMessage() : + messageSource.getMessage(error.getCode(), error.getArguments(), error.getDefaultMessage(), LocaleContextHolder.getLocale()); } - // https://howtodoinjava.com/spring-mvc/spring-problemdetail-errorresponse/#5-adding-problemdetail-to-custom-exceptions @ExceptionHandler(Exception.class) ProblemDetail exception(Exception ex, HttpServletRequest request) { return processException(ex, request, Map.of()); } - ProblemDetail processException(@NonNull Exception ex, HttpServletRequest request, Map additionalParams) { + ProblemDetail processException(@NonNull Throwable ex, HttpServletRequest request, Map additionalParams) { + Optional optType = findErrorType(ex); + if (optType.isEmpty()) { + Throwable root = getRootCause(ex); + if (root != ex) { + optType = findErrorType(root); + ex = root; + } + } String path = request.getRequestURI(); - Class exClass = ex.getClass(); - Optional optType = HTTP_STATUS_MAP.entrySet().stream() - .filter( - entry -> entry.getKey().isAssignableFrom(exClass) - ) - .findAny().map(Map.Entry::getValue); if (optType.isPresent()) { log.error(ERR_PFX + "Exception {} at request {}", ex, path); - return createProblemDetail(ex, optType.get(), ex.getMessage(), additionalParams); + return createProblemDetail(ex, path, optType.get(), ex.getMessage(), additionalParams); } else { Throwable root = getRootCause(ex); log.error(ERR_PFX + "Exception " + root + " at request " + path, root); - return createProblemDetail(ex, APP_ERROR, "Exception " + root.getClass().getSimpleName(), additionalParams); + return createProblemDetail(ex, path, APP_ERROR, "Exception " + root.getClass().getSimpleName(), additionalParams); } } - private ProblemDetail createProblemDetail(Exception ex, ErrorType type, String defaultDetail, @NonNull Map additionalParams) { - ErrorResponse.Builder builder = ErrorResponse.builder(ex, type.status, defaultDetail); - ProblemDetail pd = builder.build().updateAndGetBody(messageSource, LocaleContextHolder.getLocale()); - additionalParams.forEach(pd::setProperty); - return pd; - } - - private Map getErrorMap(BindingResult result) { - Map invalidParams = new LinkedHashMap<>(); - for (ObjectError error : result.getGlobalErrors()) { - invalidParams.put(error.getObjectName(), getErrorMessage(error)); + private Optional findErrorType(Throwable ex) { + if (ex instanceof AppException aex) { + return Optional.of(aex.getErrorType()); } - for (FieldError error : result.getFieldErrors()) { - invalidParams.put(error.getField(), getErrorMessage(error)); - } - log.warn("BindingException: {}", invalidParams); - return invalidParams; + Class exClass = ex.getClass(); + return HTTP_STATUS_MAP.entrySet().stream() + .filter(entry -> entry.getKey().isAssignableFrom(exClass)) + .findAny().map(Map.Entry::getValue); } - private String getErrorMessage(ObjectError error) { - return messageSource.getMessage(error.getCode(), error.getArguments(), error.getDefaultMessage(), LocaleContextHolder.getLocale()); + // https://datatracker.ietf.org/doc/html/rfc7807 + private ProblemDetail createProblemDetail(Throwable ex, String path, ErrorType type, String defaultDetail, @NonNull Map additionalParams) { + ErrorResponse.Builder builder = ErrorResponse.builder(ex, type.status, defaultDetail); + ProblemDetail pd = builder + .title(type.title).instance(URI.create(path)) + .build().updateAndGetBody(messageSource, LocaleContextHolder.getLocale()); + additionalParams.forEach(pd::setProperty); + return pd; } // https://stackoverflow.com/a/65442410/548473 diff --git a/src/main/java/ru/javaops/bootjava/config/SecurityConfig.java b/src/main/java/ru/javaops/bootjava/app/config/SecurityConfig.java similarity index 60% rename from src/main/java/ru/javaops/bootjava/config/SecurityConfig.java rename to src/main/java/ru/javaops/bootjava/app/config/SecurityConfig.java index 6fba44d..4145004 100644 --- a/src/main/java/ru/javaops/bootjava/config/SecurityConfig.java +++ b/src/main/java/ru/javaops/bootjava/app/config/SecurityConfig.java @@ -1,14 +1,12 @@ -package ru.javaops.bootjava.config; +package ru.javaops.bootjava.app.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.Customizer; 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.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; @@ -16,13 +14,15 @@ 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 ru.javaops.bootjava.app.AuthUser; +import ru.javaops.bootjava.user.model.Role; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.repository.UserRepository; import java.util.Optional; +import static org.springframework.security.config.Customizer.withDefaults; + @Configuration @EnableWebSecurity @Slf4j @@ -31,7 +31,6 @@ public class SecurityConfig { public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); private final UserRepository userRepository; - private final RestAuthenticationEntryPoint authenticationEntryPoint; @Bean PasswordEncoder passwordEncoder() { @@ -48,24 +47,17 @@ UserDetailsService userDetailsService() { }; } - // https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter#configuring-websecurity - // https://stackoverflow.com/a/61147599/548473 - @Bean - WebSecurityCustomizer webSecurityCustomizer() { - return web -> web.ignoring().requestMatchers("/", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**"); - } - //https://stackoverflow.com/a/76538979/548473 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.securityMatcher("/api/**").authorizeHttpRequests(authz -> authz - .requestMatchers("/api/admin/**").hasRole(Role.ADMIN.name()) - .requestMatchers(HttpMethod.POST, "/api/profile").anonymous() - .requestMatchers("/api/**").authenticated() - ).httpBasic(Customizer.withDefaults()) - .sessionManagement(smc -> smc - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ).csrf(AbstractHttpConfigurer::disable); + http.securityMatcher("/api/**").authorizeHttpRequests(authz -> + authz.requestMatchers("/api/admin/**").hasRole(Role.ADMIN.name()) + .requestMatchers(HttpMethod.POST, "/api/profile").anonymous() + .requestMatchers("/", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**").permitAll() + .requestMatchers("/api/**").authenticated()) + .httpBasic(withDefaults()) + .sessionManagement(smc -> smc.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .csrf(AbstractHttpConfigurer::disable); return http.build(); } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java b/src/main/java/ru/javaops/bootjava/common/BaseRepository.java similarity index 92% rename from src/main/java/ru/javaops/bootjava/repository/BaseRepository.java rename to src/main/java/ru/javaops/bootjava/common/BaseRepository.java index 1d8a191..edcf4ab 100644 --- a/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java +++ b/src/main/java/ru/javaops/bootjava/common/BaseRepository.java @@ -1,11 +1,11 @@ -package ru.javaops.bootjava.repository; +package ru.javaops.bootjava.common; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.NoRepositoryBean; import org.springframework.transaction.annotation.Transactional; -import ru.javaops.bootjava.error.NotFoundException; +import ru.javaops.bootjava.common.error.NotFoundException; // https://stackoverflow.com/questions/42781264/multiple-base-repositories-in-spring-data-jpa @NoRepositoryBean diff --git a/src/main/java/ru/javaops/bootjava/HasId.java b/src/main/java/ru/javaops/bootjava/common/HasId.java similarity index 91% rename from src/main/java/ru/javaops/bootjava/HasId.java rename to src/main/java/ru/javaops/bootjava/common/HasId.java index 42ba142..2a3ac2b 100644 --- a/src/main/java/ru/javaops/bootjava/HasId.java +++ b/src/main/java/ru/javaops/bootjava/common/HasId.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava; +package ru.javaops.bootjava.common; import com.fasterxml.jackson.annotation.JsonIgnore; import org.springframework.util.Assert; diff --git a/src/main/java/ru/javaops/bootjava/HasIdAndEmail.java b/src/main/java/ru/javaops/bootjava/common/HasIdAndEmail.java similarity index 66% rename from src/main/java/ru/javaops/bootjava/HasIdAndEmail.java rename to src/main/java/ru/javaops/bootjava/common/HasIdAndEmail.java index aa96c88..1dd6819 100644 --- a/src/main/java/ru/javaops/bootjava/HasIdAndEmail.java +++ b/src/main/java/ru/javaops/bootjava/common/HasIdAndEmail.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava; +package ru.javaops.bootjava.common; public interface HasIdAndEmail extends HasId { String getEmail(); diff --git a/src/main/java/ru/javaops/bootjava/common/error/AppException.java b/src/main/java/ru/javaops/bootjava/common/error/AppException.java new file mode 100644 index 0000000..8c00a78 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/error/AppException.java @@ -0,0 +1,14 @@ +package ru.javaops.bootjava.common.error; + +import lombok.Getter; +import org.springframework.lang.NonNull; + +public class AppException extends RuntimeException { + @Getter + private final ErrorType errorType; + + public AppException(@NonNull String message, ErrorType errorType) { + super(message); + this.errorType = errorType; + } +} diff --git a/src/main/java/ru/javaops/bootjava/common/error/DataConflictException.java b/src/main/java/ru/javaops/bootjava/common/error/DataConflictException.java new file mode 100644 index 0000000..fa3085a --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/error/DataConflictException.java @@ -0,0 +1,9 @@ +package ru.javaops.bootjava.common.error; + +import static ru.javaops.bootjava.common.error.ErrorType.DATA_CONFLICT; + +public class DataConflictException extends AppException { + public DataConflictException(String msg) { + super(msg, DATA_CONFLICT); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/error/ErrorType.java b/src/main/java/ru/javaops/bootjava/common/error/ErrorType.java similarity index 94% rename from src/main/java/ru/javaops/bootjava/error/ErrorType.java rename to src/main/java/ru/javaops/bootjava/common/error/ErrorType.java index 5fac6f3..8d3d912 100644 --- a/src/main/java/ru/javaops/bootjava/error/ErrorType.java +++ b/src/main/java/ru/javaops/bootjava/common/error/ErrorType.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.error; +package ru.javaops.bootjava.common.error; import org.springframework.http.HttpStatus; diff --git a/src/main/java/ru/javaops/bootjava/common/error/IllegalRequestDataException.java b/src/main/java/ru/javaops/bootjava/common/error/IllegalRequestDataException.java new file mode 100644 index 0000000..79aaec9 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/error/IllegalRequestDataException.java @@ -0,0 +1,9 @@ +package ru.javaops.bootjava.common.error; + +import static ru.javaops.bootjava.common.error.ErrorType.BAD_REQUEST; + +public class IllegalRequestDataException extends AppException { + public IllegalRequestDataException(String msg) { + super(msg, BAD_REQUEST); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/error/NotFoundException.java b/src/main/java/ru/javaops/bootjava/common/error/NotFoundException.java new file mode 100644 index 0000000..f1dc318 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/error/NotFoundException.java @@ -0,0 +1,9 @@ +package ru.javaops.bootjava.common.error; + +import static ru.javaops.bootjava.common.error.ErrorType.NOT_FOUND; + +public class NotFoundException extends AppException { + public NotFoundException(String msg) { + super(msg, NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/model/BaseEntity.java b/src/main/java/ru/javaops/bootjava/common/model/BaseEntity.java new file mode 100644 index 0000000..ae8bd2f --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/model/BaseEntity.java @@ -0,0 +1,41 @@ +package ru.javaops.bootjava.common.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import ru.javaops.bootjava.common.HasId; + +import static ru.javaops.bootjava.common.util.HibernateProxyHelper.getClassWithoutInitializingProxy; + +@MappedSuperclass +// https://stackoverflow.com/a/6084701/548473 +@Access(AccessType.FIELD) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class BaseEntity implements HasId { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473 + protected Integer id; + + // https://stackoverflow.com/questions/1638723 + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClassWithoutInitializingProxy(this) != getClassWithoutInitializingProxy(o)) return false; + return getId() != null && getId().equals(((BaseEntity) o).getId()); + } + + @Override + public int hashCode() { + return getClassWithoutInitializingProxy(this).hashCode(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + ":" + getId(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/model/NamedEntity.java b/src/main/java/ru/javaops/bootjava/common/model/NamedEntity.java similarity index 88% rename from src/main/java/ru/javaops/bootjava/model/NamedEntity.java rename to src/main/java/ru/javaops/bootjava/common/model/NamedEntity.java index d103aac..a4835ab 100644 --- a/src/main/java/ru/javaops/bootjava/model/NamedEntity.java +++ b/src/main/java/ru/javaops/bootjava/common/model/NamedEntity.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.model; +package ru.javaops.bootjava.common.model; import jakarta.persistence.Column; import jakarta.persistence.MappedSuperclass; @@ -8,7 +8,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import ru.javaops.bootjava.util.validation.NoHtml; +import ru.javaops.bootjava.common.validation.NoHtml; @MappedSuperclass diff --git a/src/main/java/ru/javaops/bootjava/to/BaseTo.java b/src/main/java/ru/javaops/bootjava/common/to/BaseTo.java similarity index 86% rename from src/main/java/ru/javaops/bootjava/to/BaseTo.java rename to src/main/java/ru/javaops/bootjava/common/to/BaseTo.java index 399a1cf..71e05ff 100644 --- a/src/main/java/ru/javaops/bootjava/to/BaseTo.java +++ b/src/main/java/ru/javaops/bootjava/common/to/BaseTo.java @@ -1,11 +1,11 @@ -package ru.javaops.bootjava.to; +package ru.javaops.bootjava.common.to; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import ru.javaops.bootjava.HasId; +import ru.javaops.bootjava.common.HasId; @NoArgsConstructor @AllArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/ru/javaops/bootjava/to/NamedTo.java b/src/main/java/ru/javaops/bootjava/common/to/NamedTo.java similarity index 80% rename from src/main/java/ru/javaops/bootjava/to/NamedTo.java rename to src/main/java/ru/javaops/bootjava/common/to/NamedTo.java index 4b5a7da..f2308b5 100644 --- a/src/main/java/ru/javaops/bootjava/to/NamedTo.java +++ b/src/main/java/ru/javaops/bootjava/common/to/NamedTo.java @@ -1,16 +1,16 @@ -package ru.javaops.bootjava.to; +package ru.javaops.bootjava.common.to; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Data; import lombok.EqualsAndHashCode; -import ru.javaops.bootjava.util.validation.NoHtml; +import ru.javaops.bootjava.common.validation.NoHtml; @Data @EqualsAndHashCode(callSuper = true) public class NamedTo extends BaseTo { @NotBlank - @Size(min = 2, max = 128) + @Size(min = 2, max = 64) @NoHtml protected String name; diff --git a/src/main/java/ru/javaops/bootjava/common/util/HibernateProxyHelper.java b/src/main/java/ru/javaops/bootjava/common/util/HibernateProxyHelper.java new file mode 100644 index 0000000..b38606f --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/util/HibernateProxyHelper.java @@ -0,0 +1,24 @@ +/* + * Hibernate, Relational Persistence for Idiomatic Java + * + * License: GNU Lesser General Public License (LGPL), version 2.1 or later. + * See the lgpl.txt file in the root directory or . + */ +package ru.javaops.bootjava.common.util; + + +import lombok.experimental.UtilityClass; +import org.hibernate.proxy.HibernateProxy; + +@UtilityClass +public final class HibernateProxyHelper { + + /** + * Get the class of an instance or the underlying class + * of a proxy (without initializing the proxy!) + */ + public static Class getClassWithoutInitializingProxy(Object object) { + return (object instanceof HibernateProxy proxy) ? + proxy.getHibernateLazyInitializer().getPersistentClass() : object.getClass(); + } +} diff --git a/src/main/java/ru/javaops/bootjava/util/JsonUtil.java b/src/main/java/ru/javaops/bootjava/common/util/JsonUtil.java similarity index 97% rename from src/main/java/ru/javaops/bootjava/util/JsonUtil.java rename to src/main/java/ru/javaops/bootjava/common/util/JsonUtil.java index 9c37f33..d47fbee 100644 --- a/src/main/java/ru/javaops/bootjava/util/JsonUtil.java +++ b/src/main/java/ru/javaops/bootjava/common/util/JsonUtil.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.util; +package ru.javaops.bootjava.common.util; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; diff --git a/src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java b/src/main/java/ru/javaops/bootjava/common/validation/NoHtml.java similarity index 66% rename from src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java rename to src/main/java/ru/javaops/bootjava/common/validation/NoHtml.java index 40d3756..f730209 100644 --- a/src/main/java/ru/javaops/bootjava/util/validation/NoHtml.java +++ b/src/main/java/ru/javaops/bootjava/common/validation/NoHtml.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.util.validation; +package ru.javaops.bootjava.common.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; @@ -7,16 +7,15 @@ 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.ElementType.*; import static java.lang.annotation.RetentionPolicy.RUNTIME; @Documented @Constraint(validatedBy = NoHtmlValidator.class) -@Target({METHOD, FIELD}) +@Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE}) @Retention(RUNTIME) public @interface NoHtml { - String message() default "{error.noHtml}"; + String message() default "HTML tags forbidden"; Class[] groups() default {}; diff --git a/src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java b/src/main/java/ru/javaops/bootjava/common/validation/NoHtmlValidator.java similarity index 89% rename from src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java rename to src/main/java/ru/javaops/bootjava/common/validation/NoHtmlValidator.java index 6fbf5d3..68dd323 100644 --- a/src/main/java/ru/javaops/bootjava/util/validation/NoHtmlValidator.java +++ b/src/main/java/ru/javaops/bootjava/common/validation/NoHtmlValidator.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.util.validation; +package ru.javaops.bootjava.common.validation; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java b/src/main/java/ru/javaops/bootjava/common/validation/ValidationUtil.java similarity index 81% rename from src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java rename to src/main/java/ru/javaops/bootjava/common/validation/ValidationUtil.java index 9ca3fb7..d55e887 100644 --- a/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java +++ b/src/main/java/ru/javaops/bootjava/common/validation/ValidationUtil.java @@ -1,8 +1,8 @@ -package ru.javaops.bootjava.util.validation; +package ru.javaops.bootjava.common.validation; import lombok.experimental.UtilityClass; -import ru.javaops.bootjava.HasId; -import ru.javaops.bootjava.error.IllegalRequestDataException; +import ru.javaops.bootjava.common.HasId; +import ru.javaops.bootjava.common.error.IllegalRequestDataException; @UtilityClass public class ValidationUtil { diff --git a/src/main/java/ru/javaops/bootjava/config/RestAuthenticationEntryPoint.java b/src/main/java/ru/javaops/bootjava/config/RestAuthenticationEntryPoint.java deleted file mode 100644 index 95f4ddc..0000000 --- a/src/main/java/ru/javaops/bootjava/config/RestAuthenticationEntryPoint.java +++ /dev/null @@ -1,22 +0,0 @@ -package ru.javaops.bootjava.config; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.AllArgsConstructor; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.HandlerExceptionResolver; - -@Component -@AllArgsConstructor -public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { - @Qualifier("handlerExceptionResolver") - private final HandlerExceptionResolver resolver; - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) { - resolver.resolveException(request, response, null, authException); - } -} diff --git a/src/main/java/ru/javaops/bootjava/error/AppException.java b/src/main/java/ru/javaops/bootjava/error/AppException.java deleted file mode 100644 index 9f11453..0000000 --- a/src/main/java/ru/javaops/bootjava/error/AppException.java +++ /dev/null @@ -1,10 +0,0 @@ -package ru.javaops.bootjava.error; - -import org.springframework.lang.NonNull; - -public class AppException extends RuntimeException { - - public AppException(@NonNull String message) { - super(message); - } -} diff --git a/src/main/java/ru/javaops/bootjava/error/DataConflictException.java b/src/main/java/ru/javaops/bootjava/error/DataConflictException.java deleted file mode 100644 index b048af5..0000000 --- a/src/main/java/ru/javaops/bootjava/error/DataConflictException.java +++ /dev/null @@ -1,7 +0,0 @@ -package ru.javaops.bootjava.error; - -public class DataConflictException extends AppException { - public DataConflictException(String msg) { - super(msg); - } -} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java b/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java deleted file mode 100644 index 1ca9eaf..0000000 --- a/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java +++ /dev/null @@ -1,7 +0,0 @@ -package ru.javaops.bootjava.error; - -public class IllegalRequestDataException extends AppException { - public IllegalRequestDataException(String msg) { - super(msg); - } -} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/error/NotFoundException.java b/src/main/java/ru/javaops/bootjava/error/NotFoundException.java deleted file mode 100644 index dab96dd..0000000 --- a/src/main/java/ru/javaops/bootjava/error/NotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package ru.javaops.bootjava.error; - -public class NotFoundException extends AppException { - public NotFoundException(String msg) { - super(msg); - } -} \ 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 deleted file mode 100644 index 9644938..0000000 --- a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java +++ /dev/null @@ -1,58 +0,0 @@ -package ru.javaops.bootjava.model; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.persistence.*; -import lombok.*; -import org.springframework.data.domain.Persistable; -import org.springframework.data.util.ProxyUtils; -import org.springframework.util.Assert; -import ru.javaops.bootjava.HasId; - -@MappedSuperclass -// https://stackoverflow.com/a/6084701/548473 -@Access(AccessType.FIELD) -@Getter -@Setter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PROTECTED) -public abstract class BaseEntity implements Persistable, HasId { - - @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 - 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; - } - - @Override - public String toString() { - return getClass().getSimpleName() + ":" + id; - } -} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/util/UsersUtil.java b/src/main/java/ru/javaops/bootjava/user/UsersUtil.java similarity index 75% rename from src/main/java/ru/javaops/bootjava/util/UsersUtil.java rename to src/main/java/ru/javaops/bootjava/user/UsersUtil.java index 4986248..63f3bcf 100644 --- a/src/main/java/ru/javaops/bootjava/util/UsersUtil.java +++ b/src/main/java/ru/javaops/bootjava/user/UsersUtil.java @@ -1,9 +1,9 @@ -package ru.javaops.bootjava.util; +package ru.javaops.bootjava.user; import lombok.experimental.UtilityClass; -import ru.javaops.bootjava.model.Role; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.to.UserTo; +import ru.javaops.bootjava.user.model.Role; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.to.UserTo; @UtilityClass public class UsersUtil { diff --git a/src/main/java/ru/javaops/bootjava/model/Role.java b/src/main/java/ru/javaops/bootjava/user/model/Role.java similarity index 87% rename from src/main/java/ru/javaops/bootjava/model/Role.java rename to src/main/java/ru/javaops/bootjava/user/model/Role.java index 08bc76d..53c9c4e 100644 --- a/src/main/java/ru/javaops/bootjava/model/Role.java +++ b/src/main/java/ru/javaops/bootjava/user/model/Role.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.model; +package ru.javaops.bootjava.user.model; import org.springframework.security.core.GrantedAuthority; diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/user/model/User.java similarity index 79% rename from src/main/java/ru/javaops/bootjava/model/User.java rename to src/main/java/ru/javaops/bootjava/user/model/User.java index 558e55d..7dc34a0 100644 --- a/src/main/java/ru/javaops/bootjava/model/User.java +++ b/src/main/java/ru/javaops/bootjava/user/model/User.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.model; +package ru.javaops.bootjava.user.model; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.persistence.*; @@ -10,11 +10,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.hibernate.annotations.OnDelete; -import org.hibernate.annotations.OnDeleteAction; -import org.springframework.util.CollectionUtils; -import ru.javaops.bootjava.HasIdAndEmail; -import ru.javaops.bootjava.util.validation.NoHtml; +import org.springframework.lang.NonNull; +import ru.javaops.bootjava.common.HasIdAndEmail; +import ru.javaops.bootjava.common.model.NamedEntity; +import ru.javaops.bootjava.common.validation.NoHtml; import java.util.*; @@ -29,7 +28,7 @@ public class User extends NamedEntity implements HasIdAndEmail { @Column(name = "email", nullable = false, unique = true) @Email @NotBlank - @Size(max = 128) + @Size(max = 64) @NoHtml // https://stackoverflow.com/questions/17480809 private String email; @@ -54,9 +53,7 @@ public class User extends NamedEntity implements HasIdAndEmail { uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role"}, name = "uk_user_role")) @Column(name = "role") @ElementCollection(fetch = FetchType.EAGER) - @JoinColumn - @OnDelete(action = OnDeleteAction.CASCADE) - private Set roles; + private Set roles = EnumSet.noneOf(Role.class); public User(User u) { this(u.id, u.name, u.email, u.password, u.enabled, u.registered, u.roles); @@ -66,7 +63,7 @@ public User(Integer id, String name, String email, String password, Role... role 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) { + public User(Integer id, String name, String email, String password, boolean enabled, Date registered, @NonNull Collection roles) { super(id, name); this.email = email; this.password = password; @@ -76,11 +73,11 @@ public User(Integer id, String name, String email, String password, boolean enab } public void setRoles(Collection roles) { - this.roles = CollectionUtils.isEmpty(roles) ? EnumSet.noneOf(Role.class) : EnumSet.copyOf(roles); + this.roles = roles.isEmpty() ? EnumSet.noneOf(Role.class) : EnumSet.copyOf(roles); } public boolean hasRole(Role role) { - return roles != null && roles.contains(role); + return roles.contains(role); } @Override diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/user/repository/UserRepository.java similarity index 73% rename from src/main/java/ru/javaops/bootjava/repository/UserRepository.java rename to src/main/java/ru/javaops/bootjava/user/repository/UserRepository.java index 6008879..def57ea 100644 --- a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java +++ b/src/main/java/ru/javaops/bootjava/user/repository/UserRepository.java @@ -1,13 +1,14 @@ -package ru.javaops.bootjava.repository; +package ru.javaops.bootjava.user.repository; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; -import ru.javaops.bootjava.error.NotFoundException; -import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.common.BaseRepository; +import ru.javaops.bootjava.common.error.NotFoundException; +import ru.javaops.bootjava.user.model.User; import java.util.Optional; -import static ru.javaops.bootjava.config.SecurityConfig.PASSWORD_ENCODER; +import static ru.javaops.bootjava.app.config.SecurityConfig.PASSWORD_ENCODER; @Transactional(readOnly = true) public interface UserRepository extends BaseRepository { diff --git a/src/main/java/ru/javaops/bootjava/to/UserTo.java b/src/main/java/ru/javaops/bootjava/user/to/UserTo.java similarity index 78% rename from src/main/java/ru/javaops/bootjava/to/UserTo.java rename to src/main/java/ru/javaops/bootjava/user/to/UserTo.java index 3b970f5..33922a4 100644 --- a/src/main/java/ru/javaops/bootjava/to/UserTo.java +++ b/src/main/java/ru/javaops/bootjava/user/to/UserTo.java @@ -1,19 +1,20 @@ -package ru.javaops.bootjava.to; +package ru.javaops.bootjava.user.to; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.EqualsAndHashCode; import lombok.Value; -import ru.javaops.bootjava.HasIdAndEmail; -import ru.javaops.bootjava.util.validation.NoHtml; +import ru.javaops.bootjava.common.HasIdAndEmail; +import ru.javaops.bootjava.common.to.NamedTo; +import ru.javaops.bootjava.common.validation.NoHtml; @Value @EqualsAndHashCode(callSuper = true) public class UserTo extends NamedTo implements HasIdAndEmail { @Email @NotBlank - @Size(max = 128) + @Size(max = 64) @NoHtml // https://stackoverflow.com/questions/17480809 String email; diff --git a/src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java b/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java similarity index 85% rename from src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java rename to src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java index 2a417e5..77e5aa6 100644 --- a/src/main/java/ru/javaops/bootjava/web/user/AbstractUserController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java @@ -1,11 +1,11 @@ -package ru.javaops.bootjava.web.user; +package ru.javaops.bootjava.user.web; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.InitBinder; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.repository.UserRepository; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.repository.UserRepository; import static org.slf4j.LoggerFactory.getLogger; diff --git a/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java b/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java similarity index 91% rename from src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java rename to src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java index 564a735..d310014 100644 --- a/src/main/java/ru/javaops/bootjava/web/user/AdminUserController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.web.user; +package ru.javaops.bootjava.user.web; import jakarta.validation.Valid; import org.springframework.data.domain.Sort; @@ -8,13 +8,13 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; -import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.user.model.User; import java.net.URI; import java.util.List; -import static ru.javaops.bootjava.util.validation.ValidationUtil.assureIdConsistent; -import static ru.javaops.bootjava.util.validation.ValidationUtil.checkNew; +import static ru.javaops.bootjava.common.validation.ValidationUtil.assureIdConsistent; +import static ru.javaops.bootjava.common.validation.ValidationUtil.checkNew; @RestController @RequestMapping(value = AdminUserController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) diff --git a/src/main/java/ru/javaops/bootjava/web/user/ProfileController.java b/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java similarity index 85% rename from src/main/java/ru/javaops/bootjava/web/user/ProfileController.java rename to src/main/java/ru/javaops/bootjava/user/web/ProfileController.java index e71dcd9..96a06b6 100644 --- a/src/main/java/ru/javaops/bootjava/web/user/ProfileController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.web.user; +package ru.javaops.bootjava.user.web; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; @@ -9,15 +9,15 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.to.UserTo; -import ru.javaops.bootjava.util.UsersUtil; -import ru.javaops.bootjava.web.AuthUser; +import ru.javaops.bootjava.app.AuthUser; +import ru.javaops.bootjava.user.UsersUtil; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.to.UserTo; import java.net.URI; -import static ru.javaops.bootjava.util.validation.ValidationUtil.assureIdConsistent; -import static ru.javaops.bootjava.util.validation.ValidationUtil.checkNew; +import static ru.javaops.bootjava.common.validation.ValidationUtil.assureIdConsistent; +import static ru.javaops.bootjava.common.validation.ValidationUtil.checkNew; @RestController @RequestMapping(value = ProfileController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) diff --git a/src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java b/src/main/java/ru/javaops/bootjava/user/web/UniqueMailValidator.java similarity index 87% rename from src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java rename to src/main/java/ru/javaops/bootjava/user/web/UniqueMailValidator.java index 8d0ea22..78bd024 100644 --- a/src/main/java/ru/javaops/bootjava/web/user/UniqueMailValidator.java +++ b/src/main/java/ru/javaops/bootjava/user/web/UniqueMailValidator.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.web.user; +package ru.javaops.bootjava.user.web; import jakarta.servlet.http.HttpServletRequest; import lombok.AllArgsConstructor; @@ -6,9 +6,9 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.validation.Errors; -import ru.javaops.bootjava.HasIdAndEmail; -import ru.javaops.bootjava.repository.UserRepository; -import ru.javaops.bootjava.web.AuthUser; +import ru.javaops.bootjava.app.AuthUtil; +import ru.javaops.bootjava.common.HasIdAndEmail; +import ru.javaops.bootjava.user.repository.UserRepository; @Component @AllArgsConstructor @@ -38,7 +38,7 @@ public void validate(@NonNull Object target, @NonNull Errors errors) { // Workaround for update with user.id=null in request body // ValidationUtil.assureIdConsistent called after this validation String requestURI = request.getRequestURI(); - if (requestURI.endsWith("/" + dbId) || (dbId == AuthUser.authId() && requestURI.contains("/profile"))) + if (requestURI.endsWith("/" + dbId) || (dbId == AuthUtil.get().id() && requestURI.contains("/profile"))) return; } errors.rejectValue("email", "", EXCEPTION_DUPLICATE_EMAIL); diff --git a/src/main/java/ru/javaops/bootjava/web/AuthUser.java b/src/main/java/ru/javaops/bootjava/web/AuthUser.java deleted file mode 100644 index 0a51442..0000000 --- a/src/main/java/ru/javaops/bootjava/web/AuthUser.java +++ /dev/null @@ -1,54 +0,0 @@ -package ru.javaops.bootjava.web; - -import lombok.Getter; -import org.springframework.lang.NonNull; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import ru.javaops.bootjava.model.Role; -import ru.javaops.bootjava.model.User; - -import static java.util.Objects.requireNonNull; - -@Getter -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(); - } - - public static AuthUser safeGet() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null) { - return null; - } - return (auth.getPrincipal() instanceof AuthUser au) ? au : null; - } - - public static AuthUser get() { - return requireNonNull(safeGet(), "No authorized user found"); - } - - public static User authUser() { - return get().getUser(); - } - - public static int authId() { - return get().id(); - } - - public boolean hasRole(Role role) { - return user.hasRole(role); - } - - @Override - public String toString() { - return "AuthUser:" + id() + '[' + user.getEmail() + ']'; - } -} \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java similarity index 97% rename from src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java rename to src/test/java/ru/javaops/bootjava/AbstractControllerTest.java index b4d9cec..7320912 100644 --- a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.web; +package ru.javaops.bootjava; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; diff --git a/src/test/java/ru/javaops/bootjava/web/MatcherFactory.java b/src/test/java/ru/javaops/bootjava/MatcherFactory.java similarity index 97% rename from src/test/java/ru/javaops/bootjava/web/MatcherFactory.java rename to src/test/java/ru/javaops/bootjava/MatcherFactory.java index c510818..6a41537 100644 --- a/src/test/java/ru/javaops/bootjava/web/MatcherFactory.java +++ b/src/test/java/ru/javaops/bootjava/MatcherFactory.java @@ -1,9 +1,9 @@ -package ru.javaops.bootjava.web; +package ru.javaops.bootjava; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultMatcher; -import ru.javaops.bootjava.util.JsonUtil; +import ru.javaops.bootjava.common.util.JsonUtil; import java.io.UnsupportedEncodingException; import java.util.List; diff --git a/src/test/java/ru/javaops/bootjava/web/user/UserTestData.java b/src/test/java/ru/javaops/bootjava/user/UserTestData.java similarity index 82% rename from src/test/java/ru/javaops/bootjava/web/user/UserTestData.java rename to src/test/java/ru/javaops/bootjava/user/UserTestData.java index a4a3af6..a507385 100644 --- a/src/test/java/ru/javaops/bootjava/web/user/UserTestData.java +++ b/src/test/java/ru/javaops/bootjava/user/UserTestData.java @@ -1,12 +1,13 @@ -package ru.javaops.bootjava.web.user; +package ru.javaops.bootjava.user; -import ru.javaops.bootjava.model.Role; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.util.JsonUtil; -import ru.javaops.bootjava.web.MatcherFactory; +import ru.javaops.bootjava.MatcherFactory; +import ru.javaops.bootjava.common.util.JsonUtil; +import ru.javaops.bootjava.user.model.Role; +import ru.javaops.bootjava.user.model.User; import java.util.Collections; import java.util.Date; +import java.util.List; public class UserTestData { public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(User.class, "registered", "password"); @@ -28,7 +29,7 @@ public static User getNew() { } public static User getUpdated() { - return new User(USER_ID, "UpdatedName", USER_MAIL, "newPass", false, new Date(), Collections.singleton(Role.ADMIN)); + return new User(USER_ID, "UpdatedName", USER_MAIL, "newPass", false, new Date(), List.of(Role.ADMIN)); } public static String jsonWithPassword(User user, String passw) { diff --git a/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java b/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java similarity index 90% rename from src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java rename to src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java index ceb7a45..10489f2 100644 --- a/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.web.user; +package ru.javaops.bootjava.user.web; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -6,20 +6,19 @@ import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import ru.javaops.bootjava.model.Role; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.repository.UserRepository; -import ru.javaops.bootjava.web.AbstractControllerTest; +import ru.javaops.bootjava.AbstractControllerTest; +import ru.javaops.bootjava.user.model.Role; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.repository.UserRepository; import static org.hamcrest.Matchers.containsString; import static org.junit.jupiter.api.Assertions.assertFalse; 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.web.user.AdminUserController.REST_URL; -import static ru.javaops.bootjava.web.user.UserTestData.*; +import static ru.javaops.bootjava.user.UserTestData.*; +import static ru.javaops.bootjava.user.web.AdminUserController.REST_URL; +import static ru.javaops.bootjava.user.web.UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL; class AdminUserControllerTest extends AbstractControllerTest { @@ -184,7 +183,6 @@ void updateHtmlUnsafe() throws Exception { } @Test - @Transactional(propagation = Propagation.NEVER) @WithUserDetails(value = ADMIN_MAIL) void updateDuplicate() throws Exception { User updated = new User(user); @@ -194,11 +192,10 @@ void updateDuplicate() throws Exception { .content(jsonWithPassword(updated, "password"))) .andDo(print()) .andExpect(status().isUnprocessableEntity()) - .andExpect(content().string(containsString(UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL))); + .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); } @Test - @Transactional(propagation = Propagation.NEVER) @WithUserDetails(value = ADMIN_MAIL) void createDuplicate() throws Exception { User expected = new User(null, "New", USER_MAIL, "newPass", Role.USER, Role.ADMIN); @@ -207,6 +204,6 @@ void createDuplicate() throws Exception { .content(jsonWithPassword(expected, "newPass"))) .andDo(print()) .andExpect(status().isUnprocessableEntity()) - .andExpect(content().string(containsString(UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL))); + .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); } } \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java b/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java similarity index 90% rename from src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java rename to src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java index 53a7a61..7cc6614 100644 --- a/src/test/java/ru/javaops/bootjava/web/user/ProfileControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java @@ -1,4 +1,4 @@ -package ru.javaops.bootjava.web.user; +package ru.javaops.bootjava.user.web; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -6,19 +6,19 @@ import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import ru.javaops.bootjava.model.User; -import ru.javaops.bootjava.repository.UserRepository; -import ru.javaops.bootjava.to.UserTo; -import ru.javaops.bootjava.util.JsonUtil; -import ru.javaops.bootjava.util.UsersUtil; -import ru.javaops.bootjava.web.AbstractControllerTest; +import ru.javaops.bootjava.AbstractControllerTest; +import ru.javaops.bootjava.common.util.JsonUtil; +import ru.javaops.bootjava.user.UsersUtil; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.repository.UserRepository; +import ru.javaops.bootjava.user.to.UserTo; import static org.hamcrest.Matchers.containsString; 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.web.user.ProfileController.REST_URL; -import static ru.javaops.bootjava.web.user.UserTestData.*; +import static ru.javaops.bootjava.user.UserTestData.*; +import static ru.javaops.bootjava.user.web.ProfileController.REST_URL; class ProfileControllerTest extends AbstractControllerTest { From 0bb22676d6e47494493430e041af5d17448cc488 Mon Sep 17 00:00:00 2001 From: JavaOPs Date: Mon, 1 Dec 2025 15:46:52 +0300 Subject: [PATCH 28/32] Migrate to spring boot 4 --- README.md | 16 +++--- pom.xml | 57 ++++++++++--------- .../ru/javaops/bootjava/app/AuthUser.java | 2 +- .../bootjava/app/config/AppConfig.java | 36 +++++++----- .../app/config/RestExceptionHandler.java | 2 +- .../bootjava/common/error/AppException.java | 2 +- .../bootjava/common/error/ErrorType.java | 4 +- .../bootjava/common/util/JsonUtil.java | 15 +++-- .../ru/javaops/bootjava/user/model/User.java | 2 +- .../user/web/UniqueMailValidator.java | 2 +- src/main/resources/application.yaml | 18 ++---- .../bootjava/AbstractControllerTest.java | 2 +- .../user/web/AdminUserControllerTest.java | 10 ++-- .../user/web/ProfileControllerTest.java | 6 +- 14 files changed, 87 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index f814dcb..cf6f27c 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ -Открытый курс для всех желающих приобщиться к живой современной разработке на Java -# [Разработка Spring Boot 3.x HATEOAS приложения (BootJava)](http://javaops.ru/view/bootjava?ref=gh) -## [Программа](http://javaops.ru/view/bootjava#program) +# [Spring Boot 4 / Spring 7.0, JDK 25](https://javaops.pro/view/bootjava4?ref=gh) + +Migration of [Spring Boot 3.x + HATEOAS (BootJava)](https://javaops.pro/view/bootjava?ref=gh) to a new stack: Spring Boot 4, Spring 7, JDK 25 +Implementation of the functionality of any modern web application: authentication and authorization based on roles, user registration in the application, profile management and user administration. -### Java приложения на самом современном и востребованном стеке: Spring Boot 3.x, Spring Data Rest/HATEOAS, Lombok, JPA, H2, .... -Мы создадим с нуля основу любого современного REST веб-приложения: аутентификация и авторизация на основе ролей, регистрация пользователя в приложении, управление своим профилем и администрирование пользователей. ------------------------------------------------------------- -- Stack: [JDK 17](http://jdk.java.net/17/), Spring Boot 3.x, Lombok, H2, Caffeine Cache, SpringDoc OpenApi 2.x +- Stack: [JDK 25](http://jdk.java.net/25/), Spring Boot 4.x, Spring 7, SpringDoc OpenApi 3.x, Jackson 3, Lombok, H2, Caffeine Cache - Run: `mvn spring-boot:run` in root directory. ----------------------------------------------------- -[REST API documentation](http://localhost:8080/) -Креденшелы: +### [REST API documentation](http://localhost:8080/) + +Credentials: ``` User: user@yandex.ru / password Admin: admin@gmail.com / admin diff --git a/pom.xml b/pom.xml index 8a475c3..f815534 100644 --- a/pom.xml +++ b/pom.xml @@ -5,20 +5,20 @@ org.springframework.boot spring-boot-starter-parent - 3.3.3 + 4.0.0 ru.javaops bootjava - 1.0 + 1.1 BootJava - Spring Boot 3.x HATEOAS application (BootJava) - https://javaops.ru/view/bootjava + Spring Boot 4 + https://javaops.ru/view/bootjava4 - 21 - 2.6.0 - 1.18.1 + 25 + 3.0.0 + 1.21.2 UTF-8 UTF-8 @@ -41,10 +41,10 @@ spring-boot-starter-security - + - com.fasterxml.jackson.datatype - jackson-datatype-hibernate5-jakarta + tools.jackson.datatype + jackson-datatype-hibernate7 @@ -78,21 +78,22 @@ lombok true - - com.google.code.findbugs - annotations - 3.0.1 - compile - + org.springframework.boot spring-boot-starter-test test + - org.springframework.security - spring-security-test + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-security-test test @@ -106,21 +107,21 @@ - org.springframework.boot - spring-boot-maven-plugin + org.apache.maven.plugins + maven-compiler-plugin - - + + org.projectlombok lombok - - - com.google.code.findbugs - annotations - - + + + + org.springframework.boot + spring-boot-maven-plugin + org.apache.maven.plugins diff --git a/src/main/java/ru/javaops/bootjava/app/AuthUser.java b/src/main/java/ru/javaops/bootjava/app/AuthUser.java index 51d96d5..af7da00 100644 --- a/src/main/java/ru/javaops/bootjava/app/AuthUser.java +++ b/src/main/java/ru/javaops/bootjava/app/AuthUser.java @@ -1,7 +1,7 @@ package ru.javaops.bootjava.app; import lombok.Getter; -import org.springframework.lang.NonNull; +import org.jspecify.annotations.NonNull; import ru.javaops.bootjava.user.model.Role; import ru.javaops.bootjava.user.model.User; diff --git a/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java index 4828ce6..6985b1c 100644 --- a/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java +++ b/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java @@ -1,21 +1,21 @@ package ru.javaops.bootjava.app.config; -import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.hibernate5.jakarta.Hibernate5JakartaModule; +import com.fasterxml.jackson.annotation.PropertyAccessor; 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 org.springframework.http.ProblemDetail; +import org.springframework.http.converter.json.ProblemDetailJacksonMixin; import ru.javaops.bootjava.common.util.JsonUtil; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.datatype.hibernate7.Hibernate7Module; import java.sql.SQLException; -import java.util.Map; import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY; import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE; @@ -34,16 +34,24 @@ Server h2Server() throws SQLException { // https://stackoverflow.com/a/74630129/548473 @JsonAutoDetect(fieldVisibility = NONE, getterVisibility = ANY) - interface MixIn { - @JsonAnyGetter - Map getProperties(); + interface MixIn extends ProblemDetailJacksonMixin { } - @Autowired - void configureAndStoreObjectMapper(ObjectMapper objectMapper) { - objectMapper.registerModule(new Hibernate5JakartaModule()); - // ErrorHandling: https://stackoverflow.com/questions/7421474/548473 - objectMapper.addMixIn(ProblemDetail.class, MixIn.class); - JsonUtil.setMapper(objectMapper); + // https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide#upgrading-jackson + @Bean + ObjectMapper objectMapper() { + ObjectMapper mapper = JsonMapper.builder() + .changeDefaultVisibility(visibilityChecker -> visibilityChecker + .withVisibility(PropertyAccessor.FIELD, ANY) + .withVisibility(PropertyAccessor.GETTER, NONE) + .withVisibility(PropertyAccessor.SETTER, NONE) + .withVisibility(PropertyAccessor.IS_GETTER, NONE) + ) + .addModule(new Hibernate7Module()) + // ErrorHandling: https://stackoverflow.com/questions/7421474/548473 + .addMixIn(ProblemDetail.class, MixIn.class) + .build(); + JsonUtil.setMapper(mapper); + return mapper; } } diff --git a/src/main/java/ru/javaops/bootjava/app/config/RestExceptionHandler.java b/src/main/java/ru/javaops/bootjava/app/config/RestExceptionHandler.java index d383459..4fd63c5 100644 --- a/src/main/java/ru/javaops/bootjava/app/config/RestExceptionHandler.java +++ b/src/main/java/ru/javaops/bootjava/app/config/RestExceptionHandler.java @@ -6,12 +6,12 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.core.NestedExceptionUtils; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ProblemDetail; -import org.springframework.lang.NonNull; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.firewall.RequestRejectedException; import org.springframework.validation.BindException; diff --git a/src/main/java/ru/javaops/bootjava/common/error/AppException.java b/src/main/java/ru/javaops/bootjava/common/error/AppException.java index 8c00a78..799fcec 100644 --- a/src/main/java/ru/javaops/bootjava/common/error/AppException.java +++ b/src/main/java/ru/javaops/bootjava/common/error/AppException.java @@ -1,7 +1,7 @@ package ru.javaops.bootjava.common.error; import lombok.Getter; -import org.springframework.lang.NonNull; +import org.jspecify.annotations.NonNull; public class AppException extends RuntimeException { @Getter diff --git a/src/main/java/ru/javaops/bootjava/common/error/ErrorType.java b/src/main/java/ru/javaops/bootjava/common/error/ErrorType.java index 8d3d912..cbd53d4 100644 --- a/src/main/java/ru/javaops/bootjava/common/error/ErrorType.java +++ b/src/main/java/ru/javaops/bootjava/common/error/ErrorType.java @@ -4,8 +4,8 @@ public enum ErrorType { APP_ERROR("Application error", HttpStatus.INTERNAL_SERVER_ERROR), - BAD_DATA("Wrong data", HttpStatus.UNPROCESSABLE_ENTITY), - BAD_REQUEST("Bad request", HttpStatus.UNPROCESSABLE_ENTITY), + BAD_DATA("Wrong data", HttpStatus.UNPROCESSABLE_CONTENT), + BAD_REQUEST("Bad request", HttpStatus.UNPROCESSABLE_CONTENT), DATA_CONFLICT("DataBase conflict", HttpStatus.CONFLICT), NOT_FOUND("Resource not found", HttpStatus.NOT_FOUND), AUTH_ERROR("Authorization error", HttpStatus.FORBIDDEN), diff --git a/src/main/java/ru/javaops/bootjava/common/util/JsonUtil.java b/src/main/java/ru/javaops/bootjava/common/util/JsonUtil.java index d47fbee..5605471 100644 --- a/src/main/java/ru/javaops/bootjava/common/util/JsonUtil.java +++ b/src/main/java/ru/javaops/bootjava/common/util/JsonUtil.java @@ -1,12 +1,11 @@ package ru.javaops.bootjava.common.util; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectReader; import lombok.experimental.UtilityClass; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectReader; -import java.io.IOException; import java.util.List; import java.util.Map; @@ -22,7 +21,7 @@ public static List readValues(String json, Class clazz) { ObjectReader reader = mapper.readerFor(clazz); try { return reader.readValues(json).readAll(); - } catch (IOException e) { + } catch (JacksonException e) { throw new IllegalArgumentException("Invalid read array from JSON:\n'" + json + "'", e); } } @@ -30,7 +29,7 @@ public static List readValues(String json, Class clazz) { public static T readValue(String json, Class clazz) { try { return mapper.readValue(json, clazz); - } catch (IOException e) { + } catch (JacksonException e) { throw new IllegalArgumentException("Invalid read from JSON:\n'" + json + "'", e); } } @@ -38,7 +37,7 @@ public static T readValue(String json, Class clazz) { public static String writeValue(T obj) { try { return mapper.writeValueAsString(obj); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new IllegalStateException("Invalid write to JSON:\n'" + obj + "'", e); } } diff --git a/src/main/java/ru/javaops/bootjava/user/model/User.java b/src/main/java/ru/javaops/bootjava/user/model/User.java index 7dc34a0..aac25c9 100644 --- a/src/main/java/ru/javaops/bootjava/user/model/User.java +++ b/src/main/java/ru/javaops/bootjava/user/model/User.java @@ -10,7 +10,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.springframework.lang.NonNull; +import org.jspecify.annotations.NonNull; import ru.javaops.bootjava.common.HasIdAndEmail; import ru.javaops.bootjava.common.model.NamedEntity; import ru.javaops.bootjava.common.validation.NoHtml; diff --git a/src/main/java/ru/javaops/bootjava/user/web/UniqueMailValidator.java b/src/main/java/ru/javaops/bootjava/user/web/UniqueMailValidator.java index 78bd024..8ab5d23 100644 --- a/src/main/java/ru/javaops/bootjava/user/web/UniqueMailValidator.java +++ b/src/main/java/ru/javaops/bootjava/user/web/UniqueMailValidator.java @@ -2,7 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.AllArgsConstructor; -import org.springframework.lang.NonNull; +import org.jspecify.annotations.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.validation.Errors; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 53da502..6871752 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -29,17 +29,15 @@ spring: username: sa password: -# Jackson Serialization Issue Resolver - jackson.visibility: - field: any - getter: none - setter: none - is-getter: none - # 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 + servlet: + encoding: + charset: UTF-8 + enabled: true + force: true logging: level: @@ -47,10 +45,4 @@ logging: ru.javaops.bootjava: DEBUG org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: 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 - springdoc.swagger-ui.path: / diff --git a/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java index 7320912..a860b84 100644 --- a/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java @@ -1,8 +1,8 @@ package ru.javaops.bootjava; 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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; diff --git a/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java b/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java index 10489f2..b33475e 100644 --- a/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java @@ -155,7 +155,7 @@ void createInvalid() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(invalid, "newPass"))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isUnprocessableContent()); } @Test @@ -167,7 +167,7 @@ void updateInvalid() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(invalid, "password"))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isUnprocessableContent()); } @Test @@ -179,7 +179,7 @@ void updateHtmlUnsafe() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(updated, "password"))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isUnprocessableContent()); } @Test @@ -191,7 +191,7 @@ void updateDuplicate() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(updated, "password"))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()) + .andExpect(status().isUnprocessableContent()) .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); } @@ -203,7 +203,7 @@ void createDuplicate() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(expected, "newPass"))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()) + .andExpect(status().isUnprocessableContent()) .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); } } \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java b/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java index 7cc6614..af2add0 100644 --- a/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java @@ -84,7 +84,7 @@ void registerInvalid() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(newTo))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isUnprocessableContent()); } @Test @@ -95,7 +95,7 @@ void updateInvalid() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(updatedTo))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isUnprocessableContent()); } @Test @@ -105,7 +105,7 @@ void updateDuplicate() throws Exception { perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(updatedTo))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()) + .andExpect(status().isUnprocessableContent()) .andExpect(content().string(containsString(UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL))); } } \ No newline at end of file From 02d57fecf895c1e7d2edcda14e0a155fe6808617 Mon Sep 17 00:00:00 2001 From: JavaOPs Date: Mon, 1 Dec 2025 18:22:20 +0300 Subject: [PATCH 29/32] boot4_1_userCache --- .../bootjava/app/config/AppConfig.java | 2 -- .../bootjava/app/config/SecurityConfig.java | 26 +++++++++++++++++-- .../user/web/AbstractUserController.java | 4 +++ .../user/web/AdminUserController.java | 5 +++- .../bootjava/user/web/ProfileController.java | 3 ++- .../user/web/AdminUserControllerTest.java | 2 +- 6 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java index 6985b1c..402a036 100644 --- a/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java +++ b/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.PropertyAccessor; import lombok.extern.slf4j.Slf4j; import org.h2.tools.Server; -import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -22,7 +21,6 @@ @Configuration @Slf4j -@EnableCaching public class AppConfig { @Profile("!test") diff --git a/src/main/java/ru/javaops/bootjava/app/config/SecurityConfig.java b/src/main/java/ru/javaops/bootjava/app/config/SecurityConfig.java index 4145004..4b7aecc 100644 --- a/src/main/java/ru/javaops/bootjava/app/config/SecurityConfig.java +++ b/src/main/java/ru/javaops/bootjava/app/config/SecurityConfig.java @@ -2,15 +2,22 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.CachingUserDetailsService; +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.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserCache; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.core.userdetails.cache.SpringCacheBasedUserCache; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @@ -25,26 +32,41 @@ @Configuration @EnableWebSecurity +@EnableCaching @Slf4j @AllArgsConstructor public class SecurityConfig { public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); private final UserRepository userRepository; + private final CacheManager cacheManager; @Bean PasswordEncoder passwordEncoder() { return PASSWORD_ENCODER; } + @Bean + UserCache userCache() { + return new SpringCacheBasedUserCache(cacheManager.getCache("users")); + } + + // https://www.phind.com/search/cmihyvg060000356u4nci6bie @Bean UserDetailsService userDetailsService() { - return email -> { + CachingUserDetailsService service = new CachingUserDetailsService(email -> { log.debug("Authenticating '{}'", email); Optional optionalUser = userRepository.findByEmailIgnoreCase(email); return new AuthUser(optionalUser.orElseThrow( () -> new UsernameNotFoundException("User '" + email + "' was not found"))); - }; + }); + service.setUserCache(userCache()); + return service; + } + + @Autowired + public void configure(AuthenticationManagerBuilder builder) { + builder.eraseCredentials(false); } //https://stackoverflow.com/a/76538979/548473 diff --git a/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java b/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java index 77e5aa6..9667cd9 100644 --- a/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java @@ -2,6 +2,7 @@ import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserCache; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.InitBinder; import ru.javaops.bootjava.user.model.User; @@ -12,6 +13,9 @@ public abstract class AbstractUserController { protected final Logger log = getLogger(getClass()); + @Autowired + protected UserCache userCache; + @Autowired protected UserRepository repository; diff --git a/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java b/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java index d310014..743de5b 100644 --- a/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java @@ -18,7 +18,6 @@ @RestController @RequestMapping(value = AdminUserController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) -// TODO: cache only most requested, seldom changed data! public class AdminUserController extends AbstractUserController { static final String REST_URL = "/api/admin/users"; @@ -33,7 +32,9 @@ public User get(@PathVariable int id) { @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable int id) { + User user = repository.getExisted(id); super.delete(id); + userCache.removeUserFromCache(user.getEmail()); } @GetMapping @@ -59,6 +60,7 @@ public void update(@Valid @RequestBody User user, @PathVariable int id) { log.info("update {} with id={}", user, id); assureIdConsistent(user, id); repository.prepareAndSave(user); + userCache.removeUserFromCache(user.getEmail()); } @GetMapping("/by-email") @@ -74,5 +76,6 @@ public void enable(@PathVariable int id, @RequestParam boolean enabled) { log.info(enabled ? "enable {}" : "disable {}", id); User user = repository.getExisted(id); user.setEnabled(enabled); + userCache.removeUserFromCache(user.getEmail()); } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java b/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java index 96a06b6..2fbc831 100644 --- a/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java @@ -22,7 +22,6 @@ @RestController @RequestMapping(value = ProfileController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) @Slf4j -// TODO: cache only most requested data! public class ProfileController extends AbstractUserController { static final String REST_URL = "/api/profile"; @@ -36,6 +35,7 @@ public User get(@AuthenticationPrincipal AuthUser authUser) { @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@AuthenticationPrincipal AuthUser authUser) { super.delete(authUser.id()); + userCache.removeUserFromCache(authUser.getUsername()); } @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @@ -57,5 +57,6 @@ public void update(@RequestBody @Valid UserTo userTo, @AuthenticationPrincipal A assureIdConsistent(userTo, authUser.id()); User user = authUser.getUser(); repository.prepareAndSave(UsersUtil.updateFromTo(user, userTo)); + userCache.removeUserFromCache(authUser.getUsername()); } } \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java b/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java index b33475e..6e16bcc 100644 --- a/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java @@ -61,7 +61,7 @@ void delete() throws Exception { perform(MockMvcRequestBuilders.delete(REST_URL_SLASH + USER_ID)) .andDo(print()) .andExpect(status().isNoContent()); - assertFalse(repository.findById(USER_ID).isPresent()); + assertFalse(repository.findByEmailIgnoreCase(USER_MAIL).isPresent()); } @Test From 2012861d666434353edbea223ae499d85278324a Mon Sep 17 00:00:00 2001 From: JavaOPs Date: Mon, 1 Dec 2025 19:32:42 +0300 Subject: [PATCH 30/32] boot4_2_version --- .../bootjava/app/config/WebConfig.java | 21 +++++++++++++++++++ .../user/web/AdminUserController.java | 3 ++- .../bootjava/user/web/ProfileController.java | 3 ++- src/main/resources/application.yaml | 5 +++-- .../bootjava/AbstractControllerTest.java | 2 ++ 5 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 src/main/java/ru/javaops/bootjava/app/config/WebConfig.java diff --git a/src/main/java/ru/javaops/bootjava/app/config/WebConfig.java b/src/main/java/ru/javaops/bootjava/app/config/WebConfig.java new file mode 100644 index 0000000..e7bfc25 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/app/config/WebConfig.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.app.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + public static final String VERSION_HEADER = "API-Version"; + public static final String CURRENT_VERSION = "1.0"; + + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + configurer + .useRequestHeader(VERSION_HEADER) + .setVersionRequired(false) + .setDefaultVersion(CURRENT_VERSION) + .addSupportedVersions(CURRENT_VERSION); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java b/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java index 743de5b..c1bbd2c 100644 --- a/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java @@ -8,6 +8,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javaops.bootjava.app.config.WebConfig; import ru.javaops.bootjava.user.model.User; import java.net.URI; @@ -17,7 +18,7 @@ import static ru.javaops.bootjava.common.validation.ValidationUtil.checkNew; @RestController -@RequestMapping(value = AdminUserController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) +@RequestMapping(value = AdminUserController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE, version = WebConfig.CURRENT_VERSION) public class AdminUserController extends AbstractUserController { static final String REST_URL = "/api/admin/users"; diff --git a/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java b/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java index 2fbc831..20e850e 100644 --- a/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import ru.javaops.bootjava.app.AuthUser; +import ru.javaops.bootjava.app.config.WebConfig; import ru.javaops.bootjava.user.UsersUtil; import ru.javaops.bootjava.user.model.User; import ru.javaops.bootjava.user.to.UserTo; @@ -20,7 +21,7 @@ import static ru.javaops.bootjava.common.validation.ValidationUtil.checkNew; @RestController -@RequestMapping(value = ProfileController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) +@RequestMapping(value = ProfileController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE, version = WebConfig.CURRENT_VERSION) @Slf4j public class ProfileController extends AbstractUserController { static final String REST_URL = "/api/profile"; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 6871752..52d74f0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -39,10 +39,11 @@ spring: enabled: true force: true -logging: +# mvc.apiversion.use.header: API-Version + level: root: WARN ru.javaops.bootjava: DEBUG org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: DEBUG -springdoc.swagger-ui.path: / +#springdoc.swagger-ui.path: / diff --git a/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java index a860b84..8c8d213 100644 --- a/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java @@ -8,6 +8,7 @@ import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.transaction.annotation.Transactional; +import ru.javaops.bootjava.app.config.WebConfig; //https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications @SpringBootTest @@ -21,6 +22,7 @@ public abstract class AbstractControllerTest { private MockMvc mockMvc; protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception { + builder.header(WebConfig.VERSION_HEADER, WebConfig.CURRENT_VERSION); return mockMvc.perform(builder); } } From 97c957acb43aba3fea4fa1413ae0cc4b4a0e4395 Mon Sep 17 00:00:00 2001 From: JavaOPs Date: Mon, 1 Dec 2025 23:57:24 +0300 Subject: [PATCH 31/32] boot4_3_maven4 --- pom.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index f815534..36fd861 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,7 @@ - - 4.0.0 + + 4.1.0 org.springframework.boot spring-boot-starter-parent From 7ae218370997221f1fa7e8975dcd4775641ab697 Mon Sep 17 00:00:00 2001 From: JavaOPs Date: Fri, 12 Dec 2025 22:07:20 +0300 Subject: [PATCH 32/32] boot4_4_fix_userCache --- .../user/web/AbstractUserController.java | 8 ++++++- .../user/web/AdminUserController.java | 8 +++---- .../bootjava/user/web/ProfileController.java | 6 ++---- .../bootjava/AbstractControllerTest.java | 7 +++++++ .../javaops/bootjava/user/UserTestData.java | 1 + .../user/web/AdminUserControllerTest.java | 21 +++++++++++++++++++ .../user/web/ProfileControllerTest.java | 19 +++++++++++++++++ src/test/resources/application-test.yaml | 2 +- 8 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java b/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java index 9667cd9..efdf979 100644 --- a/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java @@ -32,8 +32,14 @@ public User get(int id) { return repository.getExisted(id); } - public void delete(int id) { + public void delete(int id, String invalidateEmail) { log.info("delete {}", id); repository.deleteExisted(id); + userCache.removeUserFromCache(invalidateEmail); + } + + public void update(User user, String invalidateEmail) { + repository.prepareAndSave(user); + userCache.removeUserFromCache(invalidateEmail); } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java b/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java index c1bbd2c..5bc7f76 100644 --- a/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java @@ -29,13 +29,11 @@ public User get(@PathVariable int id) { return super.get(id); } - @Override @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable int id) { User user = repository.getExisted(id); - super.delete(id); - userCache.removeUserFromCache(user.getEmail()); + super.delete(id, user.getEmail()); } @GetMapping @@ -60,8 +58,8 @@ public ResponseEntity createWithLocation(@Valid @RequestBody User user) { public void update(@Valid @RequestBody User user, @PathVariable int id) { log.info("update {} with id={}", user, id); assureIdConsistent(user, id); - repository.prepareAndSave(user); - userCache.removeUserFromCache(user.getEmail()); + User dbUser = repository.getExisted(id); + super.update(user, dbUser.getEmail()); } @GetMapping("/by-email") diff --git a/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java b/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java index 20e850e..26c98b7 100644 --- a/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java +++ b/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java @@ -35,8 +35,7 @@ public User get(@AuthenticationPrincipal AuthUser authUser) { @DeleteMapping @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@AuthenticationPrincipal AuthUser authUser) { - super.delete(authUser.id()); - userCache.removeUserFromCache(authUser.getUsername()); + super.delete(authUser.id(), authUser.getUsername()); } @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @@ -57,7 +56,6 @@ public void update(@RequestBody @Valid UserTo userTo, @AuthenticationPrincipal A log.info("update {} with id={}", userTo, authUser.id()); assureIdConsistent(userTo, authUser.id()); User user = authUser.getUser(); - repository.prepareAndSave(UsersUtil.updateFromTo(user, userTo)); - userCache.removeUserFromCache(authUser.getUsername()); + super.update(UsersUtil.updateFromTo(user, userTo), authUser.getUsername()); } } \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java index 8c8d213..c95b2e6 100644 --- a/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java @@ -3,12 +3,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.RequestPostProcessor; import org.springframework.transaction.annotation.Transactional; import ru.javaops.bootjava.app.config.WebConfig; +import ru.javaops.bootjava.user.model.User; //https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications @SpringBootTest @@ -25,4 +28,8 @@ protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Ex builder.header(WebConfig.VERSION_HEADER, WebConfig.CURRENT_VERSION); return mockMvc.perform(builder); } + + protected static RequestPostProcessor userHttpBasic(User user) { + return SecurityMockMvcRequestPostProcessors.httpBasic(user.getEmail(), user.getPassword()); + } } diff --git a/src/test/java/ru/javaops/bootjava/user/UserTestData.java b/src/test/java/ru/javaops/bootjava/user/UserTestData.java index a507385..3bb21fa 100644 --- a/src/test/java/ru/javaops/bootjava/user/UserTestData.java +++ b/src/test/java/ru/javaops/bootjava/user/UserTestData.java @@ -19,6 +19,7 @@ public class UserTestData { public static final String USER_MAIL = "user@yandex.ru"; public static final String ADMIN_MAIL = "admin@gmail.com"; public static final String GUEST_MAIL = "guest@gmail.com"; + public static final String NEW_MAIL = "new@gmail.com"; public static final User user = new User(USER_ID, "User", USER_MAIL, "password", Role.USER); public static final User admin = new User(ADMIN_ID, "Admin", ADMIN_MAIL, "admin", Role.ADMIN, Role.USER); diff --git a/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java b/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java index 6e16bcc..231195e 100644 --- a/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java @@ -110,6 +110,27 @@ void update() throws Exception { USER_MATCHER.assertMatch(repository.getExisted(USER_ID), getUpdated()); } + @Test + void updateEmail() throws Exception { + perform(MockMvcRequestBuilders.get(ProfileController.REST_URL) + .with(userHttpBasic(user))) + .andExpect(status().isOk()); + + User updated = getUpdated(); + updated.setEmail(NEW_MAIL); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) + .with(userHttpBasic(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "newPass"))) + .andDo(print()) + .andExpect(status().isNoContent()); + USER_MATCHER.assertMatch(repository.getExisted(USER_ID), updated); + + perform(MockMvcRequestBuilders.get(ProfileController.REST_URL) + .with(userHttpBasic(user))) + .andExpect(status().isUnauthorized()); + } + @Test @WithUserDetails(value = ADMIN_MAIL) void createWithLocation() throws Exception { diff --git a/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java b/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java index af2add0..3794e37 100644 --- a/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java +++ b/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java @@ -87,6 +87,25 @@ void registerInvalid() throws Exception { .andExpect(status().isUnprocessableContent()); } + @Test + void updateEmail() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL) + .with(userHttpBasic(user))) + .andExpect(status().isOk()); + + UserTo updatedTo = new UserTo(null, "newName", NEW_MAIL, "newPassword"); + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(user)) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isNoContent()); + USER_MATCHER.assertMatch(repository.getExisted(USER_ID), UsersUtil.updateFromTo(new User(user), updatedTo)); + + perform(MockMvcRequestBuilders.get(REST_URL) + .with(userHttpBasic(user))) + .andExpect(status().isUnauthorized()); + } + @Test @WithUserDetails(value = USER_MAIL) void updateInvalid() throws Exception { diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index be16632..41f9662 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -1 +1 @@ -spring.cache.type: none \ No newline at end of file +#spring.cache.type: none \ No newline at end of file