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 ca66a72..8def54e 100644 --- a/pom.xml +++ b/pom.xml @@ -5,18 +5,22 @@ org.springframework.boot spring-boot-starter-parent - 2.4.0 + 2.7.2 - ru.javaops.bootjava - restaurant-voting + ru.javaops + bootjava 1.0 - restaurant-voting + war + BootJava Spring Boot 2.x HATEOAS application (BootJava) https://javaops.ru/view/bootjava - 15 + 17 + 1.6.9 + UTF-8 + UTF-8 @@ -28,24 +32,83 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + + com.fasterxml.jackson.datatype + jackson-datatype-hibernate5 + + + + + org.springdoc + springdoc-openapi-ui + ${springdoc.version} + + + org.springdoc + springdoc-openapi-security + ${springdoc.version} + + + + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + com.h2database h2 - runtime org.projectlombok lombok true + + org.jsoup + jsoup + 1.15.1 + - + org.springframework.boot spring-boot-starter-test test + + + org.springframework.security + spring-security-test + test + + + + org.junit.platform + junit-platform-launcher + test + @@ -53,6 +116,22 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + 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 57% rename from src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java rename to src/main/java/ru/javaops/bootjava/BootJavaApplication.java index 3326420..fd874f1 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/BootJavaApplication.java @@ -1,14 +1,12 @@ package ru.javaops.bootjava; -import lombok.AllArgsConstructor; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -@AllArgsConstructor -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/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 new file mode 100644 index 0000000..fd48d8d --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/AppConfig.java @@ -0,0 +1,34 @@ +package ru.javaops.bootjava.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module; +import lombok.extern.slf4j.Slf4j; +import org.h2.tools.Server; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import ru.javaops.bootjava.util.JsonUtil; + +import java.sql.SQLException; + +@Configuration +@Slf4j +@EnableCaching +// TODO: cache only most requested data! +public class AppConfig { + + @Profile("!test") + @Bean(initMethod = "start", destroyMethod = "stop") + Server h2Server() throws SQLException { + log.info("Start H2 TCP server"); + return Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", "9092"); + } + + @Autowired + void configureAndStoreObjectMapper(ObjectMapper objectMapper) { + objectMapper.registerModule(new Hibernate5Module()); + 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 new file mode 100644 index 0000000..4e9aa5f --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/OpenApiConfig.java @@ -0,0 +1,43 @@ +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 +

Тестовые креденшелы:
+ - 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") +) +public class OpenApiConfig { + + @Bean + public GroupedOpenApi api() { + return GroupedOpenApi.builder() + .group("REST API") + .pathsToMatch("/api/**") + .build(); + } +} diff --git a/src/main/java/ru/javaops/bootjava/config/SecurityConfiguration.java b/src/main/java/ru/javaops/bootjava/config/SecurityConfiguration.java new file mode 100644 index 0000000..53a8052 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/SecurityConfiguration.java @@ -0,0 +1,59 @@ +package ru.javaops.bootjava.config; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import ru.javaops.bootjava.model.Role; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.repository.UserRepository; +import ru.javaops.bootjava.web.AuthUser; + +import java.util.Optional; + +@Configuration +@EnableWebSecurity +@Slf4j +@AllArgsConstructor +public class SecurityConfiguration { + + public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + private final UserRepository userRepository; + + @Bean + public PasswordEncoder passwordEncoder() { + return PASSWORD_ENCODER; + } + + @Bean + public UserDetailsService userDetailsService() { + return email -> { + log.debug("Authenticating '{}'", email); + Optional optionalUser = userRepository.findByEmailIgnoreCase(email); + return new AuthUser(optionalUser.orElseThrow( + () -> new UsernameNotFoundException("User '" + email + "' was not found"))); + }; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.authorizeRequests() + .antMatchers("/api/admin/**").hasRole(Role.ADMIN.name()) + .antMatchers(HttpMethod.POST, "/api/profile").anonymous() + .antMatchers("/api/**").authenticated() + .and().httpBasic() + .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and().csrf().disable(); + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/config/WebConfig.java b/src/main/java/ru/javaops/bootjava/config/WebConfig.java new file mode 100644 index 0000000..6851bc5 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/config/WebConfig.java @@ -0,0 +1,14 @@ +package ru.javaops.bootjava.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addRedirectViewController("/", "/swagger-ui/index.html"); + } +} diff --git a/src/main/java/ru/javaops/bootjava/error/AppException.java b/src/main/java/ru/javaops/bootjava/error/AppException.java new file mode 100644 index 0000000..9d00dd4 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/error/AppException.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.error; + +import lombok.Getter; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Getter +public class AppException extends ResponseStatusException { + private final ErrorAttributeOptions options; + + public AppException(HttpStatus status, String message, ErrorAttributeOptions options) { + super(status, message); + this.options = options; + } + + @Override + public String getMessage() { + return getReason(); + } +} diff --git a/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java b/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java new file mode 100644 index 0000000..cb18581 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/error/IllegalRequestDataException.java @@ -0,0 +1,12 @@ +package ru.javaops.bootjava.error; + +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.http.HttpStatus; + +import static org.springframework.boot.web.error.ErrorAttributeOptions.Include.MESSAGE; + +public class IllegalRequestDataException extends AppException { + public IllegalRequestDataException(String msg) { + super(HttpStatus.UNPROCESSABLE_ENTITY, msg, ErrorAttributeOptions.of(MESSAGE)); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/model/BaseEntity.java b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java new file mode 100644 index 0000000..483bcf0 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/BaseEntity.java @@ -0,0 +1,60 @@ +package ru.javaops.bootjava.model; + +import io.swagger.v3.oas.annotations.media.Schema; +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) +@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; + } + + @Schema(hidden = true) + @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/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/Role.java b/src/main/java/ru/javaops/bootjava/model/Role.java new file mode 100644 index 0000000..08bc76d --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/Role.java @@ -0,0 +1,14 @@ +package ru.javaops.bootjava.model; + +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { + USER, + ADMIN; + + @Override + public String getAuthority() { + // https://stackoverflow.com/a/19542316/548473 + return "ROLE_" + name(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/model/User.java b/src/main/java/ru/javaops/bootjava/model/User.java new file mode 100644 index 0000000..3ccc55f --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/model/User.java @@ -0,0 +1,89 @@ +package ru.javaops.bootjava.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +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.*; + +@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; + + @Column(name = "email", nullable = false, unique = true) + @Email + @NotBlank + @Size(max = 128) + @NoHtml // https://stackoverflow.com/questions/17480809 + private String email; + + @Column(name = "password", nullable = false) + @NotBlank + @Size(max = 256) + // https://stackoverflow.com/a/12505165/548473 + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String password; + + @Column(name = "enabled", nullable = false, columnDefinition = "bool default true") + private boolean enabled = true; + + @Column(name = "registered", nullable = false, columnDefinition = "timestamp default now()", updatable = false) + @NotNull + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Date registered = new Date(); + + @Enumerated(EnumType.STRING) + @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) + @JoinColumn + @OnDelete(action = OnDeleteAction.CASCADE) + private Set roles; + + 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... 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) { + 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 + public String toString() { + return "User:" + id + '[' + email + ']'; + } +} \ 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/repository/BaseRepository.java new file mode 100644 index 0000000..014f7de --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/repository/BaseRepository.java @@ -0,0 +1,32 @@ +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.checkExisted; +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} e WHERE e.id=:id") + int delete(int id); + + default void deleteExisted(int id) { + checkModification(delete(id), id); + } + + @Query("SELECT e FROM #{#entityName} e WHERE e.id = :id") + T get(int id); + + default T getExisted(int id) { + return checkExisted(get(id), id); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java new file mode 100644 index 0000000..727497f --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/repository/UserRepository.java @@ -0,0 +1,16 @@ +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.model.User; + +import java.util.Optional; + +@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); +} \ 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/JsonUtil.java b/src/main/java/ru/javaops/bootjava/util/JsonUtil.java new file mode 100644 index 0000000..9c37f33 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/JsonUtil.java @@ -0,0 +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 mapper; + + public static void setMapper(ObjectMapper mapper) { + JsonUtil.mapper = mapper; + } + + 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) { + 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) { + 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..eaf2f8a --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/UserUtil.java @@ -0,0 +1,29 @@ +package ru.javaops.bootjava.util; + +import lombok.experimental.UtilityClass; +import ru.javaops.bootjava.model.Role; +import ru.javaops.bootjava.model.User; +import ru.javaops.bootjava.to.UserTo; + +import static ru.javaops.bootjava.config.SecurityConfiguration.PASSWORD_ENCODER; + +@UtilityClass +public class UserUtil { + + public static 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/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/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java b/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java new file mode 100644 index 0000000..9f43c16 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/util/validation/ValidationUtil.java @@ -0,0 +1,45 @@ +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; + +@UtilityClass +public class ValidationUtil { + + 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(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"); + } + } + public static T checkExisted(T obj, int id) { + if (obj == null) { + throw new IllegalRequestDataException("Entity with id=" + id + " not found"); + } + return obj; + } + + // https://stackoverflow.com/a/65442410/548473 + @NonNull + 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 new file mode 100644 index 0000000..d31db91 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/web/AuthUser.java @@ -0,0 +1,22 @@ +package ru.javaops.bootjava.web; + +import lombok.Getter; +import lombok.ToString; +import org.springframework.lang.NonNull; +import ru.javaops.bootjava.model.User; + +@Getter +@ToString(of = "user") +public class AuthUser extends org.springframework.security.core.userdetails.User { + + private final User user; + + public AuthUser(@NonNull User user) { + super(user.getEmail(), user.getPassword(), user.getRoles()); + this.user = user; + } + + public int id() { + return user.id(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/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/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..9044c8d --- /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.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 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.properties b/src/main/resources/application.properties deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..9f55124 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,54 @@ +# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html +spring: + jpa: + show-sql: true + open-in-view: false + # https://stackoverflow.com/a/67678945/548473 + defer-datasource-initialization: true + hibernate: + ddl-auto: create + properties: + # http://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#configurations + hibernate: + format_sql: true + default_batch_fetch_size: 20 + # https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc + jdbc.batch_size: 20 + datasource: + # ImMemory + url: jdbc:h2:mem:bootjava + # tcp: jdbc:h2:tcp://localhost:9092/mem:bootjava + # Absolute path + # 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/bootjava + # Relative path from home + # url: jdbc:h2:~/bootjava + # tcp: jdbc:h2:tcp://localhost:9092/~/bootjava + 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 + +logging: + level: + root: WARN + 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 \ 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..d229d8d --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,8 @@ +INSERT INTO USERS (NAME, EMAIL, PASSWORD, ENABLED, REGISTERED) +VALUES ('User', 'user@gmail.com', '{noop}password', 'true', '2022-04-04 11:33:30'), + ('Admin', 'admin@javaops.ru', '{noop}admin', 'true', '2022-04-04 11:33:30'); + +INSERT INTO USER_ROLE (ROLE, USER_ID) +VALUES ('USER', 1), + ('ADMIN', 2), + ('USER', 2); \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java b/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java deleted file mode 100644 index 52bba6d..0000000 --- a/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java +++ /dev/null @@ -1,12 +0,0 @@ -package ru.javaops.bootjava; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class RestaurantVotingApplicationTests { - - @Test - void contextLoads() { - } -} diff --git a/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java new file mode 100644 index 0000000..b4d9cec --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/web/AbstractControllerTest.java @@ -0,0 +1,26 @@ +package ru.javaops.bootjava.web; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; + +//https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +@ActiveProfiles("test") +//https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications-testing-with-mock-environment +public abstract class AbstractControllerTest { + + @Autowired + private MockMvc mockMvc; + + protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception { + return mockMvc.perform(builder); + } +} 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/user/AdminUserControllerTest.java b/src/test/java/ru/javaops/bootjava/web/user/AdminUserControllerTest.java new file mode 100644 index 0000000..65fbf14 --- /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.getExisted(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.getExisted(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.getExisted(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..ba8e516 --- /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.getExisted(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.getExisted(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); + } +} 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