diff --git a/src/main/java/app/mealsmadeeasy/api/security/JpaUserDetailsService.java b/src/main/java/app/mealsmadeeasy/api/security/JpaUserDetailsService.java index 4e8ef8b..c8d5a85 100644 --- a/src/main/java/app/mealsmadeeasy/api/security/JpaUserDetailsService.java +++ b/src/main/java/app/mealsmadeeasy/api/security/JpaUserDetailsService.java @@ -1,11 +1,5 @@ package app.mealsmadeeasy.api.security; -import app.mealsmadeeasy.api.user.User; import org.springframework.security.core.userdetails.UserDetailsService; -public interface JpaUserDetailsService extends UserDetailsService { - User createUser(User user); - User updateUser(User user); - void deleteUser(String username); - void deleteUser(User user); -} +public interface JpaUserDetailsService extends UserDetailsService {} diff --git a/src/main/java/app/mealsmadeeasy/api/security/JpaUserDetailsServiceImpl.java b/src/main/java/app/mealsmadeeasy/api/security/JpaUserDetailsServiceImpl.java index d8b02f4..8fbadec 100644 --- a/src/main/java/app/mealsmadeeasy/api/security/JpaUserDetailsServiceImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/security/JpaUserDetailsServiceImpl.java @@ -1,7 +1,5 @@ package app.mealsmadeeasy.api.security; -import app.mealsmadeeasy.api.user.User; -import app.mealsmadeeasy.api.user.UserEntity; import app.mealsmadeeasy.api.user.UserRepository; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -16,28 +14,6 @@ public final class JpaUserDetailsServiceImpl implements JpaUserDetailsService { this.userRepository = userRepository; } - @Override - public User createUser(User user) { - return this.userRepository.save((UserEntity) user); - } - - @Override - public User updateUser(User user) { - return this.userRepository.save((UserEntity) user); - } - - @Override - public void deleteUser(String username) { - final UserEntity user = this.userRepository.findByUsername(username) - .orElseThrow(() -> new UsernameNotFoundException("No such User with username: " + username)); - this.userRepository.delete(user); - } - - @Override - public void deleteUser(User user) { - this.userRepository.delete((UserEntity) user); - } - @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return this.userRepository.findByUsername(username) diff --git a/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java b/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java index 83d00b6..686bbaa 100644 --- a/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java +++ b/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java @@ -32,7 +32,7 @@ public class SecurityConfiguration { @Bean public WebSecurityCustomizer webSecurityCustomizer() { - return web -> web.ignoring().requestMatchers("/greeting", "/auth/**"); + return web -> web.ignoring().requestMatchers("/greeting", "/auth/**", "/sign-up/**"); } @Bean diff --git a/src/main/java/app/mealsmadeeasy/api/signup/SignUpBody.java b/src/main/java/app/mealsmadeeasy/api/signup/SignUpBody.java new file mode 100644 index 0000000..380203b --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/signup/SignUpBody.java @@ -0,0 +1,33 @@ +package app.mealsmadeeasy.api.signup; + +public final class SignUpBody { + + private String username; + private String email; + private String password; + + public String getUsername() { + return this.username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return this.email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return this.password; + } + + public void setPassword(String password) { + this.password = password; + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/signup/SignUpController.java b/src/main/java/app/mealsmadeeasy/api/signup/SignUpController.java new file mode 100644 index 0000000..d88a41e --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/signup/SignUpController.java @@ -0,0 +1,53 @@ +package app.mealsmadeeasy.api.signup; + +import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.user.UserCreateException; +import app.mealsmadeeasy.api.user.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/sign-up") +public final class SignUpController { + + private final UserService userService; + + public SignUpController(UserService userService) { + this.userService = userService; + } + + @GetMapping("/check-username") + public ResponseEntity> checkUsername(@RequestBody Map body) { + final boolean usernameAvailable = this.userService.isUsernameAvailable((String) body.get("username")); + final Map result = Map.of("isAvailable", usernameAvailable); + return ResponseEntity.ok(result); + } + + @GetMapping("/check-email") + public ResponseEntity> checkEmail(@RequestBody Map body) { + final boolean emailAvailable = this.userService.isEmailAvailable((String) body.get("email")); + final Map result = Map.of("isAvailable", emailAvailable); + return ResponseEntity.ok(result); + } + + @PostMapping + public ResponseEntity> signUp(@RequestBody SignUpBody body) { + final User created; + try { + created = this.userService.createUser(body.getUsername(), body.getEmail(), body.getPassword()); + } catch (UserCreateException userCreateException) { + return ResponseEntity.badRequest().body(Map.of( + "error", Map.of( + "type", userCreateException.getType().toString(), + "message", userCreateException.getMessage() + ) + )); + } + final Map view = Map.of("username", created.getUsername()); + return ResponseEntity.status(HttpStatus.CREATED).body(view); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/user/User.java b/src/main/java/app/mealsmadeeasy/api/user/User.java index e8837a6..81369ac 100644 --- a/src/main/java/app/mealsmadeeasy/api/user/User.java +++ b/src/main/java/app/mealsmadeeasy/api/user/User.java @@ -4,7 +4,7 @@ import org.springframework.security.core.userdetails.UserDetails; import java.util.Set; -public sealed interface User extends UserDetails permits UserEntity { +public interface User extends UserDetails { Long getId(); diff --git a/src/main/java/app/mealsmadeeasy/api/user/UserCreateException.java b/src/main/java/app/mealsmadeeasy/api/user/UserCreateException.java new file mode 100644 index 0000000..9805f2a --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/user/UserCreateException.java @@ -0,0 +1,25 @@ +package app.mealsmadeeasy.api.user; + +public class UserCreateException extends Exception { + + public enum Type { + USERNAME_TAKEN, EMAIL_TAKEN, BAD_PASSWORD + } + + private final Type type; + + public UserCreateException(Type type, String message, Throwable cause) { + super(message, cause); + this.type = type; + } + + public UserCreateException(Type type, String message) { + super(message); + this.type = type; + } + + public Type getType() { + return this.type; + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/user/UserRepository.java b/src/main/java/app/mealsmadeeasy/api/user/UserRepository.java index a3a9d41..6e11353 100644 --- a/src/main/java/app/mealsmadeeasy/api/user/UserRepository.java +++ b/src/main/java/app/mealsmadeeasy/api/user/UserRepository.java @@ -6,4 +6,7 @@ import java.util.Optional; public interface UserRepository extends JpaRepository { Optional findByUsername(String username); + boolean existsByUsername(String username); + boolean existsByEmail(String email); + void deleteByUsername(String username); } diff --git a/src/main/java/app/mealsmadeeasy/api/user/UserService.java b/src/main/java/app/mealsmadeeasy/api/user/UserService.java index dad8c41..323b7bd 100644 --- a/src/main/java/app/mealsmadeeasy/api/user/UserService.java +++ b/src/main/java/app/mealsmadeeasy/api/user/UserService.java @@ -3,8 +3,19 @@ package app.mealsmadeeasy.api.user; import java.util.Set; public interface UserService { - User createUser(String username, String email, String rawPassword, Set authorities); + + User createUser(String username, String email, String rawPassword, Set authorities) + throws UserCreateException; + + default User createUser(String username, String email, String rawPassword) throws UserCreateException { + return this.createUser(username, email, rawPassword, Set.of()); + } + User updateUser(User user); void deleteUser(User user); void deleteUser(String username); + + boolean isUsernameAvailable(String username); + boolean isEmailAvailable(String email); + } diff --git a/src/main/java/app/mealsmadeeasy/api/user/UserServiceImpl.java b/src/main/java/app/mealsmadeeasy/api/user/UserServiceImpl.java index 8134b3e..b00ad08 100644 --- a/src/main/java/app/mealsmadeeasy/api/user/UserServiceImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/user/UserServiceImpl.java @@ -1,6 +1,5 @@ package app.mealsmadeeasy.api.user; -import app.mealsmadeeasy.api.security.JpaUserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -9,12 +8,12 @@ import java.util.Set; @Service public final class UserServiceImpl implements UserService { - private final JpaUserDetailsService jpaUserDetailsService; private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; - public UserServiceImpl(JpaUserDetailsService jpaUserDetailsService, PasswordEncoder passwordEncoder) { - this.jpaUserDetailsService = jpaUserDetailsService; + public UserServiceImpl(PasswordEncoder passwordEncoder, UserRepository userRepository) { this.passwordEncoder = passwordEncoder; + this.userRepository = userRepository; } @Override @@ -23,28 +22,47 @@ public final class UserServiceImpl implements UserService { String email, String rawPassword, Set authorities - ) { + ) throws UserCreateException { + if (this.userRepository.existsByUsername(username)) { + throw new UserCreateException( + UserCreateException.Type.USERNAME_TAKEN, + "Username " + username + " is taken." + ); + } + if (this.userRepository.existsByEmail(email)) { + throw new UserCreateException(UserCreateException.Type.EMAIL_TAKEN, "Email " + email + " is taken."); + } final UserEntity draft = UserEntity.getDefaultDraft(); draft.setUsername(username); draft.setEmail(email); draft.setPassword(this.passwordEncoder.encode(rawPassword)); draft.addAuthorities(authorities); - return this.jpaUserDetailsService.createUser(draft); + return this.userRepository.save(draft); } @Override public User updateUser(User user) { - return this.jpaUserDetailsService.updateUser(user); + return this.userRepository.save((UserEntity) user); } @Override public void deleteUser(User user) { - this.jpaUserDetailsService.deleteUser(user); + this.userRepository.delete((UserEntity) user); } @Override public void deleteUser(String username) { - this.jpaUserDetailsService.deleteUser(username); + this.userRepository.deleteByUsername(username); + } + + @Override + public boolean isUsernameAvailable(String username) { + return !this.userRepository.existsByUsername(username); + } + + @Override + public boolean isEmailAvailable(String email) { + return !this.userRepository.existsByEmail(email); } } diff --git a/src/test/java/app/mealsmadeeasy/api/signup/SignUpControllerTests.java b/src/test/java/app/mealsmadeeasy/api/signup/SignUpControllerTests.java new file mode 100644 index 0000000..cbcb371 --- /dev/null +++ b/src/test/java/app/mealsmadeeasy/api/signup/SignUpControllerTests.java @@ -0,0 +1,134 @@ +package app.mealsmadeeasy.api.signup; + +import app.mealsmadeeasy.api.user.UserCreateException.Type; +import app.mealsmadeeasy.api.user.UserService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +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.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +public class SignUpControllerTests { + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserService userService; + + private MockHttpServletRequestBuilder getCheckUsernameRequest(String usernameToCheck) + throws JsonProcessingException { + final Map body = Map.of("username", usernameToCheck); + return get("/sign-up/check-username") + .content(this.objectMapper.writeValueAsString(body)) + .contentType(MediaType.APPLICATION_JSON); + } + + @Test + public void checkUsernameExpectAvailable() throws Exception { + this.mockMvc.perform(this.getCheckUsernameRequest("isAvailable")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isAvailable").value(true)); + } + + @Test + @DirtiesContext + public void checkUsernameExpectNotAvailable() throws Exception { + this.userService.createUser("notAvailable", "not-available@notavailable.com", "test"); + this.mockMvc.perform(this.getCheckUsernameRequest("notAvailable")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isAvailable").value(false)); + } + + + private MockHttpServletRequestBuilder getCheckEmailRequest(String emailToCheck) throws JsonProcessingException { + final Map body = Map.of("email", emailToCheck); + return get("/sign-up/check-email") + .content(this.objectMapper.writeValueAsString(body)) + .contentType(MediaType.APPLICATION_JSON); + } + + @Test + public void checkEmailExpectAvailable() throws Exception { + this.mockMvc.perform(this.getCheckEmailRequest("available@available.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isAvailable").value(true)); + } + + @Test + @DirtiesContext + public void checkEmailExpectNotAvailable() throws Exception { + this.userService.createUser("notAvailable", "not-available@notavailable.com", "test"); + this.mockMvc.perform(this.getCheckEmailRequest("not-available@notavailable.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isAvailable").value(false)); + } + + @Test + @DirtiesContext + public void simpleSignUp() throws Exception { + final SignUpBody body = new SignUpBody(); + body.setUsername("newUser"); + body.setEmail("new@user.com"); + body.setPassword("test"); + final MockHttpServletRequestBuilder req = post("/sign-up") + .content(this.objectMapper.writeValueAsString(body)) + .contentType(MediaType.APPLICATION_JSON); + this.mockMvc.perform(req) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.username").value("newUser")); + } + + @Test + @DirtiesContext + public void signUpBadRequestWhenUsernameTaken() throws Exception { + this.userService.createUser("taken", "taken@taken.com", "test"); + final SignUpBody body = new SignUpBody(); + body.setUsername("taken"); + body.setEmail("not-taken@taken.com"); // n.b. + body.setPassword("test"); + final MockHttpServletRequestBuilder req = post("/sign-up") + .content(this.objectMapper.writeValueAsString(body)) + .contentType(MediaType.APPLICATION_JSON); + this.mockMvc.perform(req) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.type").value(Type.USERNAME_TAKEN.toString())) + .andExpect(jsonPath("$.error.message").value(containsString("taken"))); + } + + @Test + @DirtiesContext + public void signUpBadRequestWhenEmailTaken() throws Exception { + this.userService.createUser("taken", "taken@taken.com", "test"); + final SignUpBody body = new SignUpBody(); + body.setUsername("notTaken"); // n.b. + body.setEmail("taken@taken.com"); + body.setPassword("test"); + final MockHttpServletRequestBuilder req = post("/sign-up") + .content(this.objectMapper.writeValueAsString(body)) + .contentType(MediaType.APPLICATION_JSON); + this.mockMvc.perform(req) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.type").value(Type.EMAIL_TAKEN.toString())) + .andExpect(jsonPath("$.error.message").value(containsString("taken@taken.com"))); + } + +}