obtainRolesAllowed(Object handler) {
25 | if (handler instanceof HandlerMethod handlerMethod) {
26 | var annotation = handlerMethod.getMethodAnnotation(RolesAllowed.class);
27 | if (Objects.isNull(annotation)) {
28 | annotation = AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getBeanType(), RolesAllowed.class);
29 | }
30 | return Optional.ofNullable(annotation);
31 | }
32 | return Optional.empty();
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/security/web/servlet/HttpUserSessionHolder.java:
--------------------------------------------------------------------------------
1 | package todoapp.security.web.servlet;
2 |
3 | import org.slf4j.Logger;
4 | import org.slf4j.LoggerFactory;
5 | import org.springframework.stereotype.Component;
6 | import org.springframework.web.context.request.RequestAttributes;
7 | import org.springframework.web.context.request.RequestContextHolder;
8 | import todoapp.security.UserSession;
9 | import todoapp.security.UserSessionHolder;
10 |
11 | import java.util.Objects;
12 |
13 | import static org.springframework.web.context.request.RequestAttributes.SCOPE_SESSION;
14 |
15 | /**
16 | * {@link jakarta.servlet.http.HttpSession}을 사용자 세션 저장소로 사용하는 구현체이다.
17 | *
18 | * @author springrunner.kr@gmail.com
19 | */
20 | @Component
21 | class HttpUserSessionHolder implements UserSessionHolder {
22 |
23 | static final String USER_SESSION_KEY = HttpUserSessionHolder.class.getName();
24 |
25 | private final Logger log = LoggerFactory.getLogger(getClass());
26 |
27 | @Override
28 | public UserSession get() {
29 | return (UserSession) currentRequestAttributes().getAttribute(USER_SESSION_KEY, SCOPE_SESSION);
30 | }
31 |
32 | @Override
33 | public void set(UserSession session) {
34 | Objects.requireNonNull(session, "session object must be not null");
35 | currentRequestAttributes().setAttribute(USER_SESSION_KEY, session, SCOPE_SESSION);
36 | log.info("saved new session. username is `{}`", session.getName());
37 | }
38 |
39 | @Override
40 | public void reset() {
41 | UserSession session = get();
42 | if (Objects.nonNull(session)) {
43 | currentRequestAttributes().removeAttribute(USER_SESSION_KEY, SCOPE_SESSION);
44 | log.info("reset session. username is `{}`", session.getName());
45 | }
46 | }
47 |
48 | private RequestAttributes currentRequestAttributes() {
49 | return Objects.requireNonNull(RequestContextHolder.getRequestAttributes());
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/security/web/servlet/RolesVerifyHandlerInterceptor.java:
--------------------------------------------------------------------------------
1 | package todoapp.security.web.servlet;
2 |
3 | import jakarta.servlet.http.HttpServletRequest;
4 | import jakarta.servlet.http.HttpServletResponse;
5 | import org.slf4j.Logger;
6 | import org.slf4j.LoggerFactory;
7 | import org.springframework.web.servlet.HandlerInterceptor;
8 | import todoapp.security.AccessDeniedException;
9 | import todoapp.security.UnauthorizedAccessException;
10 | import todoapp.security.support.RolesAllowedSupport;
11 |
12 | import java.util.Objects;
13 | import java.util.stream.Collectors;
14 | import java.util.stream.Stream;
15 |
16 | /**
17 | * Role(역할) 기반으로 사용자가 사용 권한을 확인하는 인터셉터 구현체이다.
18 | *
19 | * @author springrunner.kr@gmail.com
20 | */
21 | public class RolesVerifyHandlerInterceptor implements HandlerInterceptor, RolesAllowedSupport {
22 |
23 | private final Logger log = LoggerFactory.getLogger(this.getClass());
24 |
25 | @Override
26 | public final boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
27 | obtainRolesAllowed(handler).ifPresent(rolesAllowed -> {
28 | log.debug("verify roles-allowed: {}", rolesAllowed);
29 |
30 | // 1. 로그인이 되어 있나요?
31 | if (Objects.isNull(request.getUserPrincipal())) {
32 | throw new UnauthorizedAccessException();
33 | }
34 |
35 | // 2. 권한은 적절한가요?
36 | var matchedRoles = Stream.of(rolesAllowed.value())
37 | .filter(request::isUserInRole)
38 | .collect(Collectors.toSet());
39 |
40 | log.debug("matched roles: {}", matchedRoles);
41 | if (matchedRoles.isEmpty()) {
42 | throw new AccessDeniedException();
43 | }
44 | });
45 |
46 | return true;
47 | }
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/security/web/servlet/UserSessionFilter.java:
--------------------------------------------------------------------------------
1 | package todoapp.security.web.servlet;
2 |
3 | import jakarta.servlet.FilterChain;
4 | import jakarta.servlet.ServletException;
5 | import jakarta.servlet.http.HttpServletRequest;
6 | import jakarta.servlet.http.HttpServletRequestWrapper;
7 | import jakarta.servlet.http.HttpServletResponse;
8 | import org.slf4j.Logger;
9 | import org.slf4j.LoggerFactory;
10 | import org.springframework.web.filter.OncePerRequestFilter;
11 | import todoapp.security.UserSession;
12 | import todoapp.security.UserSessionHolder;
13 |
14 | import java.io.IOException;
15 | import java.security.Principal;
16 | import java.util.Objects;
17 |
18 | /**
19 | * HttpServletRequest가 로그인 사용자 세션({@link UserSession}을 사용 할 수 있도록 지원하는 필터 구현체이다.
20 | *
21 | * @author springrunner.kr@gmail.com
22 | */
23 | public class UserSessionFilter extends OncePerRequestFilter {
24 |
25 | private final UserSessionHolder userSessionHolder;
26 | private final Logger log = LoggerFactory.getLogger(this.getClass());
27 |
28 | public UserSessionFilter(UserSessionHolder userSessionHolder) {
29 | this.userSessionHolder = Objects.requireNonNull(userSessionHolder);
30 | }
31 |
32 | @Override
33 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
34 | log.info("processing user-session filter");
35 |
36 | var userSession = userSessionHolder.get();
37 | var requestWrapper = new UserSessionRequestWrapper(request, userSession);
38 |
39 | filterChain.doFilter(requestWrapper, response);
40 | }
41 |
42 | /**
43 | * 로그인 사용자 세션을 기반으로 인증 객체와 역할 확인 기능을 제공한다.
44 | */
45 | final static class UserSessionRequestWrapper extends HttpServletRequestWrapper {
46 |
47 | final UserSession userSession;
48 |
49 | private UserSessionRequestWrapper(HttpServletRequest request, UserSession userSession) {
50 | super(request);
51 | this.userSession = userSession;
52 | }
53 |
54 | @Override
55 | public Principal getUserPrincipal() {
56 | return userSession;
57 | }
58 |
59 | @Override
60 | public boolean isUserInRole(String role) {
61 | if (Objects.isNull(userSession)) {
62 | return false;
63 | }
64 | return userSession.hasRole(role);
65 | }
66 |
67 | }
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/FeatureTogglesRestController.java:
--------------------------------------------------------------------------------
1 | package todoapp.web;
2 |
3 | import org.springframework.web.bind.annotation.GetMapping;
4 | import org.springframework.web.bind.annotation.RestController;
5 | import todoapp.web.model.FeatureTogglesProperties;
6 |
7 | import java.util.Objects;
8 |
9 | /**
10 | * `5) 확장 기능 활성화` 요구사항을 구현해보세요.
11 | *
12 | * 확장 기능 활성화 Web API를 만들어보세요.
13 | * - 지금까지 배웠던 스프링 MVC 애노테이션을 사용해서 만드실 수 있있어요.
14 | *
15 | * 모델 클래스는 todoapp.web.model.FeatureTogglesProperties 를 사용하세요.
16 | * 모델 클래스를 애플리케이션 외부 환경설정(application.yml) 정보로 구성되도록 만들어보세요.
17 | * - todoapp.web.model.SiteProperties 다루던 방법을 떠올려보세요.
18 | *
19 | * url: GET /api/feature-toggles
20 | * response body:
21 | * {
22 | * "auth": true,
23 | * "onlineUsersCounter": false
24 | * }
25 | */
26 | @RestController
27 | public class FeatureTogglesRestController {
28 |
29 | private final FeatureTogglesProperties featureTogglesProperties;
30 |
31 | public FeatureTogglesRestController(FeatureTogglesProperties featureTogglesProperties) {
32 | this.featureTogglesProperties = Objects.requireNonNull(featureTogglesProperties);
33 | }
34 |
35 | @GetMapping("/api/feature-toggles")
36 | public FeatureTogglesProperties featureToggles() {
37 | return featureTogglesProperties;
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/LoginController.java:
--------------------------------------------------------------------------------
1 | package todoapp.web;
2 |
3 | import jakarta.validation.Valid;
4 | import jakarta.validation.constraints.Size;
5 | import org.slf4j.Logger;
6 | import org.slf4j.LoggerFactory;
7 | import org.springframework.stereotype.Controller;
8 | import org.springframework.ui.Model;
9 | import org.springframework.validation.BindException;
10 | import org.springframework.web.bind.annotation.ExceptionHandler;
11 | import org.springframework.web.bind.annotation.GetMapping;
12 | import org.springframework.web.bind.annotation.PostMapping;
13 | import org.springframework.web.bind.annotation.RequestMapping;
14 | import org.springframework.web.servlet.View;
15 | import org.springframework.web.servlet.view.RedirectView;
16 | import todoapp.core.user.application.RegisterUser;
17 | import todoapp.core.user.application.VerifyUserPassword;
18 | import todoapp.core.user.domain.User;
19 | import todoapp.core.user.domain.UserNotFoundException;
20 | import todoapp.core.user.domain.UserPasswordNotMatchedException;
21 | import todoapp.security.UserSession;
22 | import todoapp.security.UserSessionHolder;
23 |
24 | import java.util.Objects;
25 |
26 | /**
27 | * @author springrunner.kr@gmail.com
28 | */
29 | @Controller
30 | public class LoginController {
31 |
32 | private final VerifyUserPassword verifyUserPassword;
33 | private final RegisterUser registerUser;
34 | private final UserSessionHolder userSessionHolder;
35 | private final Logger log = LoggerFactory.getLogger(getClass());
36 |
37 | public LoginController(VerifyUserPassword verifyUserPassword, RegisterUser registerUser, UserSessionHolder userSessionHolder) {
38 | this.verifyUserPassword = Objects.requireNonNull(verifyUserPassword);
39 | this.registerUser = Objects.requireNonNull(registerUser);
40 | this.userSessionHolder = Objects.requireNonNull(userSessionHolder);
41 | }
42 |
43 | @GetMapping("/login")
44 | public String loginForm() {
45 | if (Objects.nonNull(userSessionHolder.get())) {
46 | return "redirect:/todos";
47 | }
48 | return "login";
49 | }
50 |
51 | @PostMapping("/login")
52 | public String loginProcess(@Valid LoginCommand command, Model model) {
53 | log.debug("login command: {}", command);
54 |
55 | User user;
56 | try {
57 | // 1. 사용자 저장소에 사용자가 있을 경우: 비밀번호 확인 후 로그인 처리
58 | user = verifyUserPassword.verify(command.username(), command.password());
59 | } catch (UserNotFoundException error) {
60 | // 2. 사용자가 없는 경우: 회원가입 처리 후 로그인 처리
61 | user = registerUser.register(command.username(), command.password());
62 | } catch (UserPasswordNotMatchedException error) {
63 | // 3. 비밀번호가 틀린 경우: login 페이지로 돌려보내고, 오류 메시지 노출
64 | model.addAttribute("message", error.getMessage());
65 | return "login";
66 | }
67 | userSessionHolder.set(new UserSession(user));
68 |
69 | return "redirect:/todos";
70 | }
71 |
72 | @RequestMapping("/logout")
73 | public View logout() {
74 | userSessionHolder.reset();
75 | return new RedirectView("/todos");
76 | }
77 |
78 | @ExceptionHandler(BindException.class)
79 | public String handleBindException(BindException error, Model model) {
80 | model.addAttribute("bindingResult", error.getBindingResult());
81 | model.addAttribute("message", "입력 값이 없거나 올바르지 않아요.");
82 | return "login";
83 | }
84 |
85 | record LoginCommand(@Size(min = 4, max = 20) String username, String password) {
86 |
87 | }
88 |
89 | }
90 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/OnlineUsersCounterController.java:
--------------------------------------------------------------------------------
1 | package todoapp.web;
2 |
3 | import jakarta.servlet.http.HttpServletResponse;
4 | import org.springframework.stereotype.Controller;
5 | import org.springframework.web.bind.annotation.RequestMapping;
6 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
7 | import todoapp.web.support.ConnectedClientCountBroadcaster;
8 |
9 | /**
10 | * 실시간 사이트 접속 사용자 수 카운터 컨트롤러이다.
11 | *
12 | * @author springrunner.kr@gmail.com
13 | */
14 | @Controller
15 | public class OnlineUsersCounterController {
16 |
17 | private final ConnectedClientCountBroadcaster broadcaster = new ConnectedClientCountBroadcaster();
18 |
19 | /*
20 | * HTML5 Server-sent events(https://en.wikipedia.org/wiki/Server-sent_events) 명세를 구현했다.
21 | */
22 | @RequestMapping(path = "/stream/online-users-counter", produces = "text/event-stream")
23 | public SseEmitter counter(HttpServletResponse response) throws Exception {
24 | return broadcaster.subscribe();
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/TodoController.java:
--------------------------------------------------------------------------------
1 | package todoapp.web;
2 |
3 | import org.springframework.stereotype.Controller;
4 | import org.springframework.ui.Model;
5 | import org.springframework.web.bind.annotation.RequestMapping;
6 | import todoapp.core.todo.application.FindTodos;
7 | import todoapp.core.todo.domain.support.SpreadsheetConverter;
8 |
9 | import java.util.Objects;
10 |
11 | @Controller
12 | public class TodoController {
13 |
14 | private final FindTodos findTodos;
15 |
16 | public TodoController(FindTodos findTodos) {
17 | this.findTodos = Objects.requireNonNull(findTodos);
18 | }
19 |
20 | @RequestMapping("/todos")
21 | public void todos() {
22 |
23 | }
24 |
25 | @RequestMapping(value = "/todos", produces = "text/csv")
26 | public void downloadTodos(Model model) {
27 | model.addAttribute(SpreadsheetConverter.convert(findTodos.all()));
28 | }
29 |
30 | }
31 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/TodoRestController.java:
--------------------------------------------------------------------------------
1 | package todoapp.web;
2 |
3 | import jakarta.annotation.security.RolesAllowed;
4 | import jakarta.validation.Valid;
5 | import jakarta.validation.constraints.NotBlank;
6 | import jakarta.validation.constraints.Size;
7 | import org.slf4j.Logger;
8 | import org.slf4j.LoggerFactory;
9 | import org.springframework.http.HttpStatus;
10 | import org.springframework.web.bind.annotation.*;
11 | import todoapp.core.shared.identifier.TodoId;
12 | import todoapp.core.todo.application.AddTodo;
13 | import todoapp.core.todo.application.FindTodos;
14 | import todoapp.core.todo.application.ModifyTodo;
15 | import todoapp.core.todo.application.RemoveTodo;
16 | import todoapp.core.todo.domain.Todo;
17 | import todoapp.security.UserSession;
18 |
19 | import java.util.List;
20 | import java.util.Objects;
21 |
22 | /**
23 | * @author springrunner.kr@gmail.com
24 | */
25 | @RolesAllowed(UserSession.ROLE_USER)
26 | @RestController
27 | @RequestMapping("/api/todos")
28 | public class TodoRestController {
29 |
30 | private final Logger log = LoggerFactory.getLogger(getClass());
31 |
32 | private final FindTodos findTodos;
33 | private final AddTodo addTodo;
34 | private final ModifyTodo modifyTodo;
35 | private final RemoveTodo removeTodo;
36 |
37 | public TodoRestController(FindTodos findTodos, AddTodo addTodo, ModifyTodo modifyTodo, RemoveTodo removeTodo) {
38 | this.findTodos = Objects.requireNonNull(findTodos);
39 | this.addTodo = Objects.requireNonNull(addTodo);
40 | this.modifyTodo = Objects.requireNonNull(modifyTodo);
41 | this.removeTodo = Objects.requireNonNull(removeTodo);
42 | }
43 |
44 | @GetMapping
45 | public List readAll() {
46 | return findTodos.all();
47 | }
48 |
49 | @PostMapping
50 | @ResponseStatus(HttpStatus.CREATED)
51 | public void create(@RequestBody @Valid WriteTodoCommand command) {
52 | log.debug("request command: {}", command);
53 |
54 | addTodo.add(command.text());
55 | }
56 |
57 | @PutMapping("/{id}")
58 | public void update(@PathVariable("id") String id, @RequestBody @Valid WriteTodoCommand command) {
59 | log.debug("request id: {}, command: {}", id, command);
60 |
61 | modifyTodo.modify(TodoId.of(id), command.text(), command.completed());
62 | }
63 |
64 | @DeleteMapping("/{id}")
65 | public void delete(@PathVariable("id") String id) {
66 | log.debug("request id: {}", id);
67 |
68 | removeTodo.remove(TodoId.of(id));
69 | }
70 |
71 | record WriteTodoCommand(@NotBlank @Size(min = 4, max = 140) String text, boolean completed) {
72 |
73 | }
74 |
75 | }
76 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/UserController.java:
--------------------------------------------------------------------------------
1 | package todoapp.web;
2 |
3 | import jakarta.annotation.security.RolesAllowed;
4 | import org.springframework.stereotype.Controller;
5 | import org.springframework.web.bind.annotation.RequestMapping;
6 | import todoapp.core.user.domain.ProfilePicture;
7 | import todoapp.security.UserSession;
8 |
9 | /**
10 | * @author springrunner.kr@gmail.com
11 | */
12 | @Controller
13 | public class UserController {
14 |
15 | @RolesAllowed(UserSession.ROLE_USER)
16 | @RequestMapping("/user/profile-picture")
17 | public ProfilePicture profilePicture(UserSession session) {
18 | return session.getUser().getProfilePicture();
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/UserRestController.java:
--------------------------------------------------------------------------------
1 | package todoapp.web;
2 |
3 | import jakarta.annotation.security.RolesAllowed;
4 | import org.slf4j.Logger;
5 | import org.slf4j.LoggerFactory;
6 | import org.springframework.web.bind.annotation.GetMapping;
7 | import org.springframework.web.bind.annotation.PostMapping;
8 | import org.springframework.web.bind.annotation.RestController;
9 | import org.springframework.web.multipart.MultipartFile;
10 | import todoapp.core.user.application.ChangeUserProfilePicture;
11 | import todoapp.core.user.domain.ProfilePicture;
12 | import todoapp.core.user.domain.ProfilePictureStorage;
13 | import todoapp.security.UserSession;
14 | import todoapp.security.UserSessionHolder;
15 | import todoapp.web.model.UserProfile;
16 |
17 | import java.util.Objects;
18 |
19 | /**
20 | * @author springrunner.kr@gmail.com
21 | */
22 | @RolesAllowed(UserSession.ROLE_USER)
23 | @RestController
24 | public class UserRestController {
25 |
26 | private final Logger log = LoggerFactory.getLogger(getClass());
27 |
28 | private final ProfilePictureStorage profilePictureStorage;
29 | private final ChangeUserProfilePicture changeUserProfilePicture;
30 | private final UserSessionHolder userSessionHolder;
31 |
32 | public UserRestController(ProfilePictureStorage profilePictureStorage, ChangeUserProfilePicture changeUserProfilePicture, UserSessionHolder userSessionHolder) {
33 | this.profilePictureStorage = Objects.requireNonNull(profilePictureStorage);
34 | this.changeUserProfilePicture = Objects.requireNonNull(changeUserProfilePicture);
35 | this.userSessionHolder = Objects.requireNonNull(userSessionHolder);
36 | }
37 |
38 | @GetMapping("/api/user/profile")
39 | public UserProfile userProfile(UserSession userSession) {
40 | return new UserProfile(userSession.getUser());
41 | }
42 |
43 | @PostMapping("/api/user/profile-picture")
44 | public UserProfile changeProfilePicture(MultipartFile profilePicture, UserSession session) {
45 | log.debug("profilePicture: {}, {}", profilePicture.getOriginalFilename(), profilePicture.getContentType());
46 |
47 | // 업로드된 프로필 이미지 파일 저장하기
48 | var profilePictureUri = profilePictureStorage.save(profilePicture.getResource());
49 |
50 | // 프로필 이미지 변경 후 세션 갱신하기
51 | var updatedUser = changeUserProfilePicture.change(session.getName(), new ProfilePicture(profilePictureUri));
52 | userSessionHolder.set(new UserSession(updatedUser));
53 |
54 | return new UserProfile(updatedUser);
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/config/GlobalControllerAdvice.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.config;
2 |
3 | import org.springframework.web.bind.annotation.ControllerAdvice;
4 | import org.springframework.web.bind.annotation.ModelAttribute;
5 | import todoapp.web.model.SiteProperties;
6 |
7 | import java.util.Objects;
8 |
9 | /**
10 | * @author springrunner.kr@gmail.com
11 | */
12 | @ControllerAdvice
13 | public class GlobalControllerAdvice {
14 |
15 | private final SiteProperties siteProperties;
16 |
17 | public GlobalControllerAdvice(SiteProperties siteProperties) {
18 | this.siteProperties = Objects.requireNonNull(siteProperties);
19 | }
20 |
21 | @ModelAttribute("site")
22 | public SiteProperties siteProperties() {
23 | return siteProperties;
24 | }
25 |
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/config/WebMvcConfiguration.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.config;
2 |
3 | import org.springframework.beans.factory.annotation.Autowired;
4 | import org.springframework.boot.web.servlet.FilterRegistrationBean;
5 | import org.springframework.boot.web.servlet.error.ErrorAttributes;
6 | import org.springframework.context.MessageSource;
7 | import org.springframework.context.annotation.Bean;
8 | import org.springframework.context.annotation.Configuration;
9 | import org.springframework.web.method.support.HandlerMethodArgumentResolver;
10 | import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
11 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
12 | import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
13 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
14 | import org.springframework.web.servlet.view.ContentNegotiatingViewResolver;
15 | import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
16 | import todoapp.core.user.domain.ProfilePictureStorage;
17 | import todoapp.security.UserSessionHolder;
18 | import todoapp.security.web.servlet.RolesVerifyHandlerInterceptor;
19 | import todoapp.security.web.servlet.UserSessionFilter;
20 | import todoapp.web.support.method.ProfilePictureReturnValueHandler;
21 | import todoapp.web.support.method.UserSessionHandlerMethodArgumentResolver;
22 | import todoapp.web.support.servlet.error.ReadableErrorAttributes;
23 | import todoapp.web.support.servlet.handler.ExecutionTimeHandlerInterceptor;
24 | import todoapp.web.support.servlet.handler.LoggingHandlerInterceptor;
25 | import todoapp.web.support.servlet.view.CommaSeparatedValuesView;
26 |
27 | import java.util.ArrayList;
28 | import java.util.List;
29 |
30 | /**
31 | * Spring Web MVC 설정 정보이다.
32 | *
33 | * @author springrunner.kr@gmail.com
34 | */
35 | @Configuration
36 | public class WebMvcConfiguration implements WebMvcConfigurer {
37 |
38 | @Autowired
39 | private ProfilePictureStorage profilePictureStorage;
40 |
41 | @Autowired
42 | private UserSessionHolder userSessionHolder;
43 |
44 | @Override
45 | public void configureViewResolvers(ViewResolverRegistry registry) {
46 | // registry.enableContentNegotiation();
47 | // 위와 같이 직접 설정하면, 스프링부트가 구성한 ContentNegotiatingViewResolver 전략이 무시된다.
48 | }
49 |
50 | @Override
51 | public void addInterceptors(InterceptorRegistry registry) {
52 | registry.addInterceptor(new LoggingHandlerInterceptor());
53 | registry.addInterceptor(new ExecutionTimeHandlerInterceptor());
54 | registry.addInterceptor(new RolesVerifyHandlerInterceptor());
55 | }
56 |
57 | @Override
58 | public void addArgumentResolvers(List resolvers) {
59 | resolvers.add(new UserSessionHandlerMethodArgumentResolver(userSessionHolder));
60 | }
61 |
62 | @Override
63 | public void addReturnValueHandlers(List handlers) {
64 | handlers.add(new ProfilePictureReturnValueHandler(profilePictureStorage));
65 | }
66 |
67 | @Bean
68 | ErrorAttributes errorAttributes(MessageSource messageSource) {
69 | return new ReadableErrorAttributes(messageSource);
70 | }
71 |
72 | @Bean
73 | FilterRegistrationBean userSessionFilterRegistrationBean() {
74 | var registrationBean = new FilterRegistrationBean();
75 | registrationBean.setFilter(new UserSessionFilter(userSessionHolder));
76 | return registrationBean;
77 | }
78 |
79 | /**
80 | * 스프링부트가 생성한 `ContentNegotiatingViewResolver`를 조작할 목적으로 작성된 설정 정보이다.
81 | */
82 | @Configuration
83 | static class ContentNegotiationCustomizer {
84 |
85 | @Autowired
86 | void configure(ContentNegotiatingViewResolver viewResolver) {
87 | var defaultViews = new ArrayList<>(viewResolver.getDefaultViews());
88 | defaultViews.add(new CommaSeparatedValuesView());
89 | defaultViews.add(new MappingJackson2JsonView());
90 |
91 | viewResolver.setDefaultViews(defaultViews);
92 | }
93 |
94 | }
95 |
96 | }
97 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/config/json/TodoModule.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.config.json;
2 |
3 | import com.fasterxml.jackson.core.JsonGenerator;
4 | import com.fasterxml.jackson.core.JsonParser;
5 | import com.fasterxml.jackson.databind.DeserializationContext;
6 | import com.fasterxml.jackson.databind.SerializerProvider;
7 | import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
8 | import com.fasterxml.jackson.databind.module.SimpleModule;
9 | import com.fasterxml.jackson.databind.ser.std.StdSerializer;
10 | import org.springframework.stereotype.Component;
11 | import todoapp.core.shared.identifier.TodoId;
12 |
13 | import java.io.IOException;
14 |
15 | /**
16 | * todo 모듈을 지원하기 위해 작성된 Jackson2 확장 모듈이다.
17 | *
18 | * @author springrunner.kr@gmail.com
19 | */
20 | @Component
21 | public class TodoModule extends SimpleModule {
22 |
23 | public TodoModule() {
24 | super("todo-module");
25 |
26 | addSerializer(TodoId.class, Jackson2TodoIdSerdes.SERIALIZER);
27 | addDeserializer(TodoId.class, Jackson2TodoIdSerdes.DESERIALIZER);
28 | }
29 |
30 | /**
31 | * Jackson2 라이브러리에서 사용할 할일 식별자 직렬화/역직렬화 처리기
32 | *
33 | * @author springrunner.kr@gmail.com
34 | */
35 | static class Jackson2TodoIdSerdes {
36 |
37 | static final TodoIdSerializer SERIALIZER = new TodoIdSerializer();
38 | static final TodoIdDeserializer DESERIALIZER = new TodoIdDeserializer();
39 |
40 | static class TodoIdSerializer extends StdSerializer {
41 |
42 | TodoIdSerializer() {
43 | super(TodoId.class);
44 | }
45 |
46 | @Override
47 | public void serialize(TodoId id, JsonGenerator generator, SerializerProvider provider) throws IOException {
48 | generator.writeString(id.toString());
49 | }
50 |
51 | }
52 |
53 | static class TodoIdDeserializer extends StdDeserializer {
54 |
55 | TodoIdDeserializer() {
56 | super(TodoId.class);
57 | }
58 |
59 | @Override
60 | public TodoId deserialize(JsonParser parser, DeserializationContext context) throws IOException {
61 | return TodoId.of(parser.readValueAs(String.class));
62 | }
63 |
64 | }
65 |
66 | }
67 |
68 | }
69 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/config/json/UserModule.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.config.json;
2 |
3 | import com.fasterxml.jackson.core.JsonGenerator;
4 | import com.fasterxml.jackson.core.JsonParser;
5 | import com.fasterxml.jackson.databind.DeserializationContext;
6 | import com.fasterxml.jackson.databind.SerializerProvider;
7 | import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
8 | import com.fasterxml.jackson.databind.module.SimpleModule;
9 | import com.fasterxml.jackson.databind.ser.std.StdSerializer;
10 | import org.springframework.stereotype.Component;
11 | import todoapp.core.shared.identifier.UserId;
12 |
13 | import java.io.IOException;
14 |
15 | /**
16 | * user 모듈을 지원하기 위해 작성된 Jackson2 확장 모듈이다.
17 | *
18 | * @author springrunner.kr@gmail.com
19 | */
20 | @Component
21 | public class UserModule extends SimpleModule {
22 |
23 | UserModule() {
24 | super("user-module");
25 |
26 | addSerializer(UserId.class, Jackson2UserIdSerdes.SERIALIZER);
27 | addDeserializer(UserId.class, Jackson2UserIdSerdes.DESERIALIZER);
28 | }
29 |
30 | /**
31 | * Jackson2 라이브러리에서 사용할 할일 식별자 직렬화/역직렬화 처리기
32 | *
33 | * @author springrunner.kr@gmail.com
34 | */
35 | static class Jackson2UserIdSerdes {
36 |
37 | static final UserIdSerializer SERIALIZER = new UserIdSerializer();
38 | static final UserIdDeserializer DESERIALIZER = new UserIdDeserializer();
39 |
40 | static class UserIdSerializer extends StdSerializer {
41 |
42 | UserIdSerializer() {
43 | super(UserId.class);
44 | }
45 |
46 | @Override
47 | public void serialize(UserId id, JsonGenerator generator, SerializerProvider provider) throws IOException {
48 | generator.writeString(id.toString());
49 | }
50 |
51 | }
52 |
53 | static class UserIdDeserializer extends StdDeserializer {
54 |
55 | UserIdDeserializer() {
56 | super(UserId.class);
57 | }
58 |
59 | @Override
60 | public UserId deserialize(JsonParser parser, DeserializationContext context) throws IOException {
61 | return UserId.of(parser.readValueAs(String.class));
62 | }
63 |
64 | }
65 |
66 | }
67 |
68 | }
69 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/model/FeatureTogglesProperties.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.model;
2 |
3 | import org.springframework.boot.context.properties.ConfigurationProperties;
4 |
5 | /**
6 | * 기능 토글 모델
7 | *
8 | * @author springrunner.kr@gmail.com
9 | */
10 | @ConfigurationProperties("todoapp.feature-toggles")
11 | public record FeatureTogglesProperties(boolean auth, boolean onlineUsersCounter) {
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/model/SiteProperties.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.model;
2 |
3 | import org.springframework.boot.context.properties.ConfigurationProperties;
4 |
5 | /**
6 | * 사이트 정보 모델
7 | *
8 | * @author springrunner.kr@gmail.com
9 | */
10 | @ConfigurationProperties("todoapp.site")
11 | public record SiteProperties(String author, String description) {
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/model/UserProfile.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.model;
2 |
3 | import todoapp.core.user.domain.User;
4 |
5 | import java.util.Objects;
6 |
7 | /**
8 | * 사용자 프로필 모델
9 | *
10 | * @author springrunner.kr@gmail.com
11 | */
12 | public class UserProfile {
13 |
14 | private static final String DEFAULT_PROFILE_PICTURE_URL = "/profile-picture.png";
15 | private static final String USER_PROFILE_PICTURE_URL = "/user/profile-picture";
16 |
17 | private final User user;
18 |
19 | public UserProfile(User user) {
20 | this.user = Objects.requireNonNull(user, "user object must be not null");
21 | }
22 |
23 | public String getName() {
24 | return user.getUsername();
25 | }
26 |
27 | public String getProfilePictureUrl() {
28 | if (user.hasProfilePicture()) {
29 | return USER_PROFILE_PICTURE_URL;
30 | }
31 |
32 | // 프로필 이미지가 없으면 기본 프로필 이미지를 사용한다.
33 | return DEFAULT_PROFILE_PICTURE_URL;
34 | }
35 |
36 | @Override
37 | public String toString() {
38 | return "UserProfile [name=%s, profilePictureUrl=%s]".formatted(getName(), getProfilePictureUrl());
39 | }
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/support/ConnectedClientCountBroadcaster.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.support;
2 |
3 | import org.apache.catalina.connector.ClientAbortException;
4 | import org.slf4j.Logger;
5 | import org.slf4j.LoggerFactory;
6 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
7 |
8 | import java.util.List;
9 | import java.util.concurrent.CopyOnWriteArrayList;
10 |
11 | /**
12 | * Server-Sent Events 방식으로 연결된 클라이언트 수를 전파하는 컴포넌트이다.
13 | *
14 | * @author springrunner.kr@gmail.com
15 | */
16 | public class ConnectedClientCountBroadcaster {
17 |
18 | private static final Long DEFAULT_TIMEOUT = 60L * 1000;
19 |
20 | private final List emitters = new CopyOnWriteArrayList<>();
21 | private final Logger log = LoggerFactory.getLogger(this.getClass());
22 |
23 | public SseEmitter subscribe() {
24 | var emitter = new SseEmitter(DEFAULT_TIMEOUT);
25 | emitter.onCompletion(() -> {
26 | emitters.remove(emitter);
27 | broadcast();
28 | });
29 | emitter.onTimeout(() -> {
30 | emitters.remove(emitter);
31 | broadcast();
32 | });
33 |
34 | emitters.add(emitter);
35 | broadcast();
36 |
37 | return emitter;
38 | }
39 |
40 | private void broadcast() {
41 | for (SseEmitter emitter : emitters) {
42 | try {
43 | emitter.send(SseEmitter.event().data(emitters.size()));
44 | } catch (IllegalStateException | ClientAbortException ignore) {
45 | // timeout or completion state
46 | log.warn("unstable event stream connection (reason: {})", ignore.getMessage());
47 | emitters.remove(emitter);
48 | } catch (Exception ignore) {
49 | log.error("failed to broadcast event to emitter (reason: {})", ignore.getMessage());
50 | emitters.remove(emitter);
51 | }
52 | }
53 | }
54 |
55 | }
56 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/support/context/ExceptionMessageTranslator.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.support.context;
2 |
3 | import java.util.Locale;
4 |
5 | /**
6 | * 예외를 메시지로 번역하는 역할을 수행하는 컴포넌트 인터페이스이다. 번역할 때 로케일을 기반으로 메시지를 번역한다.
7 | *
8 | * @author springrunner.kr@gmail.com
9 | */
10 | public interface ExceptionMessageTranslator {
11 |
12 | /**
13 | * 입력된 예외에 대해 메시지를 작성해서 반환한다. 기본 메시지(defaultMessage)로 예외 객체 내부에 메시지를 사용한다.
14 | *
15 | * @param throwable 예외 객체
16 | * @param locale 언어/국가
17 | * @return 번역된 메시지
18 | */
19 | String getMessage(Throwable throwable, Locale locale);
20 |
21 | /**
22 | * 입력된 예외에 대해 메시지를 작성해서 반환한다. 적절한 메시지를 찾지 못하면 기본 메시지를 반환한다.
23 | *
24 | * @param throwable 예외 객체
25 | * @param defaultMessage 기본 메시지
26 | * @param locale 언어/국가
27 | * @return 번역된 메시지
28 | */
29 | String getMessage(Throwable throwable, String defaultMessage, Locale locale);
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/support/method/ProfilePictureReturnValueHandler.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.support.method;
2 |
3 | import jakarta.servlet.http.HttpServletResponse;
4 | import org.slf4j.Logger;
5 | import org.slf4j.LoggerFactory;
6 | import org.springframework.core.MethodParameter;
7 | import org.springframework.web.context.request.NativeWebRequest;
8 | import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
9 | import org.springframework.web.method.support.ModelAndViewContainer;
10 | import todoapp.core.user.domain.ProfilePicture;
11 | import todoapp.core.user.domain.ProfilePictureStorage;
12 |
13 | import java.util.Objects;
14 |
15 | /**
16 | * 스프링 MVC 핸들러 반환값으로 프로필 사진 객체를 처리하기 위해 작성된 컴포넌트입니다.
17 | *
18 | * @author springrunner.kr@gmail.com
19 | */
20 | public class ProfilePictureReturnValueHandler implements HandlerMethodReturnValueHandler {
21 |
22 | private final Logger log = LoggerFactory.getLogger(getClass());
23 |
24 | private final ProfilePictureStorage profilePictureStorage;
25 |
26 | public ProfilePictureReturnValueHandler(ProfilePictureStorage profilePictureStorage) {
27 | this.profilePictureStorage = Objects.requireNonNull(profilePictureStorage);
28 | }
29 |
30 | @Override
31 | public boolean supportsReturnType(MethodParameter returnType) {
32 | return ProfilePicture.class.isAssignableFrom(returnType.getParameterType());
33 | }
34 |
35 | @Override
36 | public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
37 | var response = webRequest.getNativeResponse(HttpServletResponse.class);
38 | var profilePicture = profilePictureStorage.load(((ProfilePicture) returnValue).getUri());
39 | profilePicture.getInputStream().transferTo(response.getOutputStream());
40 |
41 | mavContainer.setRequestHandled(true);
42 |
43 | log.debug("Response written for profile picture with URI {}", profilePicture.getURI());
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/support/method/UserSessionHandlerMethodArgumentResolver.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.support.method;
2 |
3 | import org.springframework.core.MethodParameter;
4 | import org.springframework.web.bind.support.WebDataBinderFactory;
5 | import org.springframework.web.context.request.NativeWebRequest;
6 | import org.springframework.web.method.support.HandlerMethodArgumentResolver;
7 | import org.springframework.web.method.support.ModelAndViewContainer;
8 | import todoapp.security.UserSession;
9 | import todoapp.security.UserSessionHolder;
10 |
11 | import java.util.Objects;
12 |
13 | /**
14 | * 스프링 MVC 핸들러 인수로 인증된 사용자 세션 객체를 제공하기 위해 작성된 컴포넌트입니다.
15 | *
16 | * @author springrunner.kr@gmail.com
17 | */
18 | public class UserSessionHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
19 |
20 | private final UserSessionHolder userSessionHolder;
21 |
22 | public UserSessionHandlerMethodArgumentResolver(UserSessionHolder userSessionHolder) {
23 | this.userSessionHolder = Objects.requireNonNull(userSessionHolder);
24 | }
25 |
26 | @Override
27 | public boolean supportsParameter(MethodParameter parameter) {
28 | return UserSession.class.isAssignableFrom(parameter.getParameterType());
29 | }
30 |
31 | @Override
32 | public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
33 | return userSessionHolder.get();
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/support/servlet/error/ReadableErrorAttributes.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.support.servlet.error;
2 |
3 | import jakarta.servlet.http.HttpServletRequest;
4 | import jakarta.servlet.http.HttpServletResponse;
5 | import org.slf4j.Logger;
6 | import org.slf4j.LoggerFactory;
7 | import org.springframework.boot.web.error.ErrorAttributeOptions;
8 | import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
9 | import org.springframework.boot.web.servlet.error.ErrorAttributes;
10 | import org.springframework.context.MessageSource;
11 | import org.springframework.context.MessageSourceResolvable;
12 | import org.springframework.core.Ordered;
13 | import org.springframework.validation.BindingResult;
14 | import org.springframework.web.context.request.WebRequest;
15 | import org.springframework.web.servlet.HandlerExceptionResolver;
16 | import org.springframework.web.servlet.ModelAndView;
17 |
18 | import java.util.Map;
19 | import java.util.Objects;
20 | import java.util.stream.Collectors;
21 |
22 | /**
23 | * 스프링부트에 기본 구현체인 {@link DefaultErrorAttributes}에 message 속성을 덮어쓰기 할 목적으로 작성한 컴포넌트이다.
24 | *
25 | * DefaultErrorAttributes는 message 속성을 예외 객체의 값을 사용하기 때문에 사용자가 읽기에 좋은 문구가 아니다. 해당 메시지를 보다 읽기 좋은 문구로
26 | * 가공해서 제공하는 것을 목적으로 만들어졌다.
27 | *
28 | * @author springrunner.kr@gmail.com
29 | */
30 | public class ReadableErrorAttributes implements ErrorAttributes, HandlerExceptionResolver, Ordered {
31 |
32 | private final MessageSource messageSource;
33 |
34 | private final DefaultErrorAttributes delegate = new DefaultErrorAttributes();
35 | private final Logger log = LoggerFactory.getLogger(getClass());
36 |
37 | public ReadableErrorAttributes(MessageSource messageSource) {
38 | this.messageSource = Objects.requireNonNull(messageSource);
39 | }
40 |
41 | @Override
42 | public Map getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
43 | var attributes = delegate.getErrorAttributes(webRequest, options);
44 | var error = getError(webRequest);
45 |
46 | log.debug("obtain error-attributes: {}", attributes, error);
47 |
48 | if (Objects.nonNull(error)) {
49 | var errorMessage = error.getMessage();
50 | if (error instanceof MessageSourceResolvable it) {
51 | errorMessage = messageSource.getMessage(it, webRequest.getLocale());
52 | } else {
53 | var errorCode = "Exception.%s".formatted(error.getClass().getSimpleName());
54 | errorMessage = messageSource.getMessage(errorCode, new Object[0], errorMessage, webRequest.getLocale());
55 | }
56 | attributes.put("message", errorMessage);
57 |
58 | var bindingResult = extractBindingResult(error);
59 | if (Objects.nonNull(bindingResult)) {
60 | var errors = bindingResult
61 | .getAllErrors()
62 | .stream()
63 | .map(it -> messageSource.getMessage(it, webRequest.getLocale()))
64 | .collect(Collectors.toList());
65 | attributes.put("errors", errors);
66 | }
67 | }
68 |
69 | return attributes;
70 | }
71 |
72 | @Override
73 | public Throwable getError(WebRequest webRequest) {
74 | return delegate.getError(webRequest);
75 | }
76 |
77 | @Override
78 | public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception error) {
79 | return delegate.resolveException(request, response, handler, error);
80 | }
81 |
82 | @Override
83 | public int getOrder() {
84 | return delegate.getOrder();
85 | }
86 |
87 | /**
88 | * 예외 객체에서 {@link org.springframework.boot.context.properties.bind.BindResult}를 추출한다.
89 | * 없으면 {@literal null}을 반환한다.
90 | */
91 | static BindingResult extractBindingResult(Throwable error) {
92 | if (error instanceof BindingResult bindingResult) {
93 | return bindingResult;
94 | }
95 | return null;
96 | }
97 |
98 | }
99 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/support/servlet/handler/ExecutionTimeHandlerInterceptor.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.support.servlet.handler;
2 |
3 | import jakarta.servlet.http.HttpServletRequest;
4 | import jakarta.servlet.http.HttpServletResponse;
5 | import org.slf4j.Logger;
6 | import org.slf4j.LoggerFactory;
7 | import org.springframework.core.Ordered;
8 | import org.springframework.util.StopWatch;
9 | import org.springframework.web.method.HandlerMethod;
10 | import org.springframework.web.servlet.HandlerInterceptor;
11 | import org.springframework.web.servlet.ModelAndView;
12 |
13 | /**
14 | * 핸들러 실행 시간을 측정하는 인터셉터 구현체이다.
15 | *
16 | * @author springrunner.kr@gmail.com
17 | */
18 | public class ExecutionTimeHandlerInterceptor implements HandlerInterceptor, Ordered {
19 |
20 | private static final String STOP_WATCH_ATTR_NAME = "ExecutionTimeHandlerInterceptor.StopWatch";
21 |
22 | private final Logger log = LoggerFactory.getLogger(ExecutionTimeHandlerInterceptor.class);
23 |
24 | @Override
25 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
26 | var stopWatch = new StopWatch(getHandlerName(handler));
27 | stopWatch.start();
28 | request.setAttribute(STOP_WATCH_ATTR_NAME, stopWatch);
29 | return true;
30 | }
31 |
32 | @Override
33 | public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
34 | var stopWatch = (StopWatch) request.getAttribute(STOP_WATCH_ATTR_NAME);
35 | stopWatch.stop();
36 |
37 | log.debug("[" + getHandlerName(handler) + "] executeTime : " + stopWatch.getTotalTimeMillis() + "ms");
38 | }
39 |
40 | private String getHandlerName(Object handler) {
41 | if (handler instanceof HandlerMethod handlerMethod) {
42 | return handlerMethod.getShortLogMessage();
43 | }
44 | return handler.toString();
45 | }
46 |
47 | @Override
48 | public int getOrder() {
49 | return Integer.MIN_VALUE;
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/support/servlet/handler/LoggingHandlerInterceptor.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.support.servlet.handler;
2 |
3 | import jakarta.servlet.http.HttpServletRequest;
4 | import jakarta.servlet.http.HttpServletResponse;
5 | import org.slf4j.Logger;
6 | import org.slf4j.LoggerFactory;
7 | import org.springframework.web.servlet.HandlerInterceptor;
8 | import org.springframework.web.servlet.ModelAndView;
9 |
10 | /**
11 | * 핸들러 실행 전, 후, 완료시 로그를 남기는 인터셉터 구현체이다.
12 | *
13 | * @author springrunner.kr@gmail.com
14 | */
15 | public class LoggingHandlerInterceptor implements HandlerInterceptor {
16 |
17 | private final Logger log = LoggerFactory.getLogger(getClass());
18 |
19 | @Override
20 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
21 | log.debug("preHandle method called (handler: {})", handler);
22 | return true;
23 | }
24 |
25 | @Override
26 | public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
27 | log.debug("postHandle method called (handler: {})", handler);
28 | }
29 |
30 | @Override
31 | public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
32 | log.debug("afterCompletion method called (handler: {})", handler);
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/support/servlet/view/CommaSeparatedValuesView.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.support.servlet.view;
2 |
3 | import jakarta.servlet.http.HttpServletRequest;
4 | import jakarta.servlet.http.HttpServletResponse;
5 | import org.slf4j.Logger;
6 | import org.slf4j.LoggerFactory;
7 | import org.springframework.http.HttpHeaders;
8 | import org.springframework.web.servlet.view.AbstractView;
9 | import todoapp.core.shared.util.Spreadsheet;
10 |
11 | import java.net.URLEncoder;
12 | import java.nio.charset.StandardCharsets;
13 | import java.util.Map;
14 |
15 | /**
16 | * {@link Spreadsheet} 모델을 CSV(comma-separated values) 파일 형식으로 출력하는 뷰 구현체이다.
17 | *
18 | * @author springrunner.kr@gmail.com
19 | */
20 | public class CommaSeparatedValuesView extends AbstractView {
21 |
22 | private static final String CONTENT_TYPE = "text/csv";
23 | private static final String FILE_EXTENSION = "csv";
24 |
25 | private final Logger log = LoggerFactory.getLogger(getClass());
26 |
27 | public CommaSeparatedValuesView() {
28 | setContentType(CONTENT_TYPE);
29 | }
30 |
31 | @Override
32 | protected boolean generatesDownloadContent() {
33 | return true;
34 | }
35 |
36 | @Override
37 | protected void renderMergedOutputModel(Map model, HttpServletRequest request, HttpServletResponse response) throws Exception {
38 | var spreadsheet = Spreadsheet.obtainSpreadsheet(model);
39 | log.info("write spreadsheet content to csv file: {}", spreadsheet);
40 |
41 | var encodedName = URLEncoder.encode(spreadsheet.getName(), StandardCharsets.UTF_8);
42 | var contentDisposition = "attachment; filename=\"%s.%s\"".formatted(encodedName, FILE_EXTENSION);
43 | response.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentDisposition);
44 |
45 | if (spreadsheet.hasHeader()) {
46 | var header = spreadsheet.getHeader().map(row -> row.joining(",")).orElse("");
47 | response.getWriter().println(header);
48 | }
49 |
50 | if (spreadsheet.hasRows()) {
51 | for (var row : spreadsheet.getRows()) {
52 | response.getWriter().println(row.joining(","));
53 | }
54 | }
55 |
56 | response.flushBuffer();
57 | }
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/server/src/main/java/todoapp/web/support/servlet/view/SimpleMappingViewResolver.java:
--------------------------------------------------------------------------------
1 | package todoapp.web.support.servlet.view;
2 |
3 | import org.springframework.web.servlet.View;
4 | import org.springframework.web.servlet.ViewResolver;
5 |
6 | import java.util.Locale;
7 | import java.util.Map;
8 |
9 | /**
10 | * 뷰 이름(ViewName)에 연결된 뷰 객체를 반환하는 뷰 리졸버 구현체이다.
11 | *
12 | * @author springrunner.kr@gmail.com
13 | */
14 | public class SimpleMappingViewResolver implements ViewResolver {
15 |
16 | private final Map viewMappings;
17 |
18 | public SimpleMappingViewResolver(Map viewMappings) {
19 | this.viewMappings = viewMappings;
20 | }
21 |
22 | public SimpleMappingViewResolver add(String viewName, View view) {
23 | viewMappings.remove(viewName);
24 | viewMappings.put(viewName, view);
25 | return this;
26 | }
27 |
28 | @Override
29 | public View resolveViewName(String viewName, Locale locale) throws Exception {
30 | if (viewMappings.containsKey(viewName)) {
31 | return viewMappings.get(viewName);
32 | }
33 | return null;
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/server/src/main/resources/application-default.yaml:
--------------------------------------------------------------------------------
1 | spring:
2 | web:
3 | resources:
4 | static-locations:
5 | - classpath:/static/
6 | - file:./../client/dist/
7 | thymeleaf:
8 | prefix: file:./../client/dist/pages/
--------------------------------------------------------------------------------
/server/src/main/resources/application.yaml:
--------------------------------------------------------------------------------
1 | todoapp:
2 | feature-toggles:
3 | auth: true
4 | online-users-counter: true
5 | site:
6 | author: SpringRunner
7 | description: What're your plans today?
8 | data:
9 | initialize: true
10 |
11 | spring:
12 | application:
13 | name: todos
14 |
15 | server:
16 | servlet:
17 | encoding:
18 | charset: utf-8
19 | force: true
20 |
21 | logging:
22 | level:
23 | web: debug
24 | sql: debug
25 | '[todoapp]': debug
--------------------------------------------------------------------------------
/server/src/main/resources/messages.properties:
--------------------------------------------------------------------------------
1 | Size.writeTodoCommand.text=\uD560\uC77C\uC740 {2}-{1}\uC790 \uC0AC\uC774\uB85C \uC791\uC131\uD574\uC8FC\uC138\uC694.
2 | Size.loginCommand.username=\uC0AC\uC6A9\uC790 \uC774\uB984\uC740 {2}-{1}\uC790 \uC0AC\uC774\uB85C \uC785\uB825\uD574\uC8FC\uC138\uC694.
3 | Exception.TodoNotFoundException=\uC694\uCCAD\uD55C \uD560\uC77C\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC5B4\uC694. (\uC77C\uB828\uBC88\uD638: {0})
4 | Exception.MethodArgumentNotValidException=\uC785\uB825 \uAC12\uC774 \uC5C6\uAC70\uB098 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC544\uC694.
5 | Exception.UnauthorizedAccessException=\uC11C\uBE44\uC2A4\uB97C \uC774\uC6A9\uD558\uB824\uBA74 \uC0AC\uC6A9\uC790 \uB85C\uADF8\uC778\uC774 \uD544\uC694\uD569\uB2C8\uB2E4.
6 | Exception.AccessDeniedException=\uC694\uCCAD\uD55C \uD398\uC774\uC9C0(\uB610\uB294 \uB370\uC774\uD130)\uC5D0 \uC811\uADFC \uAD8C\uD55C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.
--------------------------------------------------------------------------------
/server/src/main/resources/messages_en.properties:
--------------------------------------------------------------------------------
1 | Size.writeTodoCommand.text=Please write todo in {2}-{1} characters.
2 | Size.loginCommand.username=Please write username in {2}-{1} characters.
3 | Exception.TodoNotFoundException=Todo not found. (id: {0})
4 | Exception.MethodArgumentNotValidException=Request data is not valid.
5 | Exception.UnauthorizedAccessException=Please log in to access the service.
6 | Exception.AccessDeniedException=Sorry, you do not have permission to access the requested page or data.
--------------------------------------------------------------------------------
/server/src/main/resources/messages_ko.properties:
--------------------------------------------------------------------------------
1 | Size.writeTodoCommand.text=\uD560\uC77C\uC740 {2}-{1}\uC790 \uC0AC\uC774\uB85C \uC791\uC131\uD574\uC8FC\uC138\uC694.
2 | Size.loginCommand.username=\uC0AC\uC6A9\uC790 \uC774\uB984\uC740 {2}-{1}\uC790 \uC0AC\uC774\uB85C \uC785\uB825\uD574\uC8FC\uC138\uC694.
3 | Exception.TodoNotFoundException=\uC694\uCCAD\uD55C \uD560\uC77C\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC5B4\uC694. (\uC77C\uB828\uBC88\uD638: {0})
4 | Exception.MethodArgumentNotValidException=\uC785\uB825 \uAC12\uC774 \uC5C6\uAC70\uB098 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC544\uC694.
5 | Exception.UnauthorizedAccessException=\uC11C\uBE44\uC2A4\uB97C \uC774\uC6A9\uD558\uB824\uBA74 \uC0AC\uC6A9\uC790 \uB85C\uADF8\uC778\uC774 \uD544\uC694\uD569\uB2C8\uB2E4.
6 | Exception.AccessDeniedException=\uC694\uCCAD\uD55C \uD398\uC774\uC9C0(\uB610\uB294 \uB370\uC774\uD130)\uC5D0 \uC811\uADFC \uAD8C\uD55C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.
--------------------------------------------------------------------------------
/server/src/test/java/todoapp/TodoApplicationTests.java:
--------------------------------------------------------------------------------
1 | package todoapp;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import org.springframework.boot.test.context.SpringBootTest;
5 |
6 | @SpringBootTest
7 | class TodoApplicationTests {
8 |
9 | @Test
10 | void contextLoads() {
11 | }
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/test/java/todoapp/core/todo/TodoFixture.java:
--------------------------------------------------------------------------------
1 | package todoapp.core.todo;
2 |
3 | import todoapp.core.shared.identifier.TodoId;
4 | import todoapp.core.todo.domain.Todo;
5 | import todoapp.core.todo.domain.TodoIdGenerator;
6 |
7 | import java.util.UUID;
8 |
9 | /**
10 | * @author springrunner.kr@gmail.com
11 | */
12 | public class TodoFixture {
13 |
14 | private static final TodoIdGenerator idGenerator = () -> TodoId.of(UUID.randomUUID().toString());
15 |
16 | public static Todo random() {
17 | return Todo.create("Task#" + System.nanoTime(), idGenerator);
18 | }
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/server/src/test/java/todoapp/data/InMemoryTodoRepositoryTest.java:
--------------------------------------------------------------------------------
1 | package todoapp.data;
2 |
3 | import org.junit.jupiter.api.BeforeEach;
4 | import org.junit.jupiter.api.DisplayName;
5 | import org.junit.jupiter.api.Test;
6 | import todoapp.core.shared.identifier.TodoId;
7 | import todoapp.core.shared.identifier.UserId;
8 | import todoapp.core.todo.domain.Todo;
9 |
10 | import java.time.LocalDateTime;
11 | import java.util.UUID;
12 |
13 | import static org.assertj.core.api.Assertions.assertThat;
14 |
15 | /**
16 | * @author springrunner.kr@gmail.com
17 | */
18 | class InMemoryTodoRepositoryTest {
19 |
20 | private InMemoryTodoRepository repository;
21 |
22 | @BeforeEach
23 | void setUp() {
24 | repository = new InMemoryTodoRepository();
25 | }
26 |
27 | @Test
28 | @DisplayName("저장된 할일(Todo)이 없을 때 findAll() 호출 시 빈 목록을 반환한다")
29 | void when_NoTodosSaved_Expect_findAllReturnsEmptyList() {
30 | var todos = repository.findAll();
31 |
32 | assertThat(todos).isNotNull();
33 | assertThat(todos).isEmpty();
34 | }
35 |
36 | @Test
37 | @DisplayName("할일(Todo) 하나를 저장 후 findAll() 호출 시 해당 할일이 목록에 존재한다")
38 | void when_OneTodoSaved_Expect_findAllReturnsListWithSavedTodo() {
39 | var todo = createTodo("tester");
40 | repository.save(todo);
41 |
42 | var todos = repository.findAll();
43 |
44 | assertThat(todos).hasSize(1);
45 | assertThat(todos).containsExactly(todo);
46 | }
47 |
48 | @Test
49 | @DisplayName("findByOwner() 호출 시 해당 소유자(UserId)의 할일(Todo)만 반환한다")
50 | void when_TodosWithDifferentOwnersSaved_Expect_findByOwnerReturnsOnlyMatchingTodos() {
51 | var todo1 = createTodo("tester");
52 | var todo2 = createTodo("springrunner");
53 | var todo3 = createTodo("tester");
54 |
55 | repository.save(todo1);
56 | repository.save(todo2);
57 | repository.save(todo3);
58 |
59 | var foundForTester = repository.findByOwner(UserId.of("tester"));
60 | var foundForSpringRunner = repository.findByOwner(UserId.of("springrunner"));
61 |
62 | assertThat(foundForTester).containsExactlyInAnyOrder(todo1, todo3);
63 | assertThat(foundForSpringRunner).containsExactly(todo2);
64 | }
65 |
66 | @Test
67 | @DisplayName("저장되지 않는 TodoId로 findById()를 호출하면 빈 `Optional`을 반환한다")
68 | void when_FindByIdWithNonExistentId_Expect_EmptyOptional() {
69 | var result = repository.findById(TodoId.of("non-existent-id"));
70 |
71 | assertThat(result).isEmpty();
72 | }
73 |
74 | @Test
75 | @DisplayName("저장된 TodoId로 findById()를 호출하면 해당 할일(Todo)을 `Optional`로 감싸 반환한다")
76 | void when_FindByIdWithExistingId_Expect_ReturnOptionalWithTodo() {
77 | var todo = createTodo("tester");
78 | repository.save(todo);
79 |
80 | var result = repository.findById(todo.getId());
81 |
82 | assertThat(result).isPresent();
83 | assertThat(result.get()).isEqualTo(todo);
84 | }
85 |
86 | @Test
87 | @DisplayName("새로운 할일(Todo)을 save() 하면 목록에 추가된다")
88 | void when_SaveNewTodo_Expect_AddedToList() {
89 | var todo = createTodo("tester");
90 |
91 | var returnedTodo = repository.save(todo);
92 |
93 | assertThat(returnedTodo).isSameAs(todo);
94 | assertThat(repository.findAll()).contains(todo);
95 | }
96 |
97 | @Test
98 | @DisplayName("이미 존재하는 할일(Todo)을 save() 하면 중복 추가되지 않는다 (동일 객체 여부 확인)")
99 | void when_SaveExistingTodo_Expect_NoDuplicateInList() {
100 | var todo = createTodo("tester");
101 | repository.save(todo);
102 |
103 | repository.save(todo);
104 | var todos = repository.findAll();
105 |
106 | assertThat(todos).hasSize(1);
107 | assertThat(todos.getFirst()).isEqualTo(todo);
108 | }
109 |
110 | @Test
111 | @DisplayName("delete()로 삭제 요청 시 목록에서 해당 할일(Todo)이 제거된다")
112 | void when_DeleteExistingTodo_Expect_RemovedFromList() {
113 | var todo = createTodo("tester");
114 | repository.save(todo);
115 |
116 | repository.delete(todo);
117 |
118 | assertThat(repository.findAll()).doesNotContain(todo);
119 | }
120 |
121 | Todo createTodo(String owner) {
122 | return new Todo(
123 | TodoId.of(UUID.randomUUID().toString()),
124 | "Task One",
125 | UserId.of(owner),
126 | LocalDateTime.now()
127 | );
128 | }
129 |
130 | }
131 |
--------------------------------------------------------------------------------
/server/src/test/java/todoapp/data/InMemoryUserRepositoryTest.java:
--------------------------------------------------------------------------------
1 | package todoapp.data;
2 |
3 | import org.junit.jupiter.api.BeforeEach;
4 | import org.junit.jupiter.api.DisplayName;
5 | import org.junit.jupiter.api.Test;
6 | import todoapp.core.foundation.crypto.PasswordEncoder;
7 | import todoapp.core.shared.identifier.UserId;
8 | import todoapp.core.user.domain.User;
9 | import todoapp.core.user.domain.UserIdGenerator;
10 |
11 | import java.util.UUID;
12 |
13 | import static org.assertj.core.api.Assertions.assertThat;
14 | import static org.assertj.core.api.Assertions.assertThatThrownBy;
15 |
16 | /**
17 | * @author springrunner.kr@gmail.com
18 | */
19 | class InMemoryUserRepositoryTest {
20 |
21 | private final UserIdGenerator userIdGenerator = () -> UserId.of(UUID.randomUUID().toString());
22 | private final PasswordEncoder passwordEncoder = (password) -> password;
23 |
24 | private InMemoryUserRepository repository;
25 |
26 | @BeforeEach
27 | void setUp() {
28 | repository = new InMemoryUserRepository(userIdGenerator, passwordEncoder);
29 | }
30 |
31 | @Test
32 | @DisplayName("생성시 필요한 의존성 중 하나라도 `null`이면 NPE가 발생한다")
33 | void when_ConstructorHasNullParams_Expect_NullPointerException() {
34 | assertThatThrownBy(() -> new InMemoryUserRepository(null, passwordEncoder))
35 | .isInstanceOf(NullPointerException.class);
36 | assertThatThrownBy(() -> new InMemoryUserRepository(userIdGenerator, null))
37 | .isInstanceOf(NullPointerException.class);
38 | }
39 |
40 | @Test
41 | @DisplayName("저장되지 않은 사용자 이름으로 findByUsername()을 호출하면 빈 `Optional`을 반환한다")
42 | void when_FindByUsernameWithUnknownUser_Expect_EmptyOptional() {
43 | var result = repository.findByUsername("unknown-user");
44 |
45 | assertThat(result).isEmpty();
46 | }
47 |
48 | @Test
49 | @DisplayName("저장된 사용자 이름으로 findByUsername()을 호출하면 해당 사용자(User)를 `Optional`로 감싸 반환한다")
50 | void when_FindByUsernameWithKnownUser_Expect_ReturnOptionalWithUser() {
51 | var user = createUser("tester");
52 | repository.save(user);
53 |
54 | var result = repository.findByUsername(user.getUsername());
55 |
56 | assertThat(result).isPresent();
57 | assertThat(result.get()).isSameAs(user);
58 | }
59 |
60 | @Test
61 | @DisplayName("새로운 사용자(User)를 save() 하면 목록에 추가된다")
62 | void when_SaveNewUser_Expect_AddedToRepository() {
63 | var user = createUser("tester");
64 |
65 | var returnedUser = repository.save(user);
66 | assertThat(returnedUser).isSameAs(user);
67 |
68 | var found = repository.findByUsername(user.getUsername());
69 | assertThat(found).isPresent();
70 | assertThat(found.get()).isSameAs(user);
71 | }
72 |
73 | User createUser(String username) {
74 | return new User(UserId.of(UUID.randomUUID().toString()), username, "password");
75 | }
76 |
77 | }
--------------------------------------------------------------------------------
/server/src/test/java/todoapp/security/UserSessionTest.java:
--------------------------------------------------------------------------------
1 | package todoapp.security;
2 |
3 | import org.junit.jupiter.api.DisplayName;
4 | import org.junit.jupiter.api.Test;
5 | import todoapp.core.shared.identifier.UserId;
6 | import todoapp.core.user.domain.User;
7 |
8 | import java.util.UUID;
9 |
10 | import static org.assertj.core.api.Assertions.assertThat;
11 | import static org.assertj.core.api.Assertions.assertThatThrownBy;
12 |
13 | /**
14 | * @author springrunner.kr@gmail.com
15 | */
16 | class UserSessionTest {
17 |
18 | @Test
19 | @DisplayName("생성 시 전달되는 사용자 객체가 `null`이면 NPE가 발생한다")
20 | void when_NullUser_Expect_NullPointerException() {
21 | assertThatThrownBy(() -> new UserSession(null))
22 | .isInstanceOf(NullPointerException.class)
23 | .hasMessage("user object must be not null");
24 | }
25 |
26 | @Test
27 | @DisplayName("생성 시 ROLE_USER 역할은 기본으로 추가된다")
28 | void when_Created_Expect_DefaultRoleUser() {
29 | var userSession = new UserSession(createUser("tester"));
30 |
31 | var roles = userSession.getRoles();
32 |
33 | assertThat(roles).isNotNull();
34 | assertThat(roles).contains(UserSession.ROLE_USER);
35 | }
36 |
37 | @Test
38 | @DisplayName("getUser() 호출 시 전달된 사용자 객체가 그대로 반환된다")
39 | void when_GetUserCalled_Expect_ReturnSameUser() {
40 | var user = createUser("tester");
41 | var userSession = new UserSession(user);
42 |
43 | var retrievedUser = userSession.getUser();
44 |
45 | assertThat(retrievedUser).isSameAs(user);
46 | assertThat(retrievedUser.getUsername()).isEqualTo(user.getUsername());
47 | }
48 |
49 | @Test
50 | @DisplayName("역할 확인(hasRole)시 세션에 존재하는 역할은 참(true), 존재하지 않는 역할은 거짓(false)을 반환한다")
51 | void when_HasRoleCalled_Expect_TrueForExistingRoleAndFalseForNotAddedRole() {
52 | var userSession = new UserSession(createUser("tester"));
53 |
54 | assertThat(userSession.hasRole(UserSession.ROLE_USER)).isTrue();
55 | assertThat(userSession.hasRole("ROLE_ADMIN")).isFalse();
56 | }
57 |
58 | User createUser(String username) {
59 | return new User(UserId.of(UUID.randomUUID().toString()), username, "password");
60 | }
61 |
62 | }
63 |
--------------------------------------------------------------------------------
/server/src/test/java/todoapp/security/web/servlet/HttpUserSessionHolderTest.java:
--------------------------------------------------------------------------------
1 | package todoapp.security.web.servlet;
2 |
3 | import org.junit.jupiter.api.AfterEach;
4 | import org.junit.jupiter.api.BeforeEach;
5 | import org.junit.jupiter.api.DisplayName;
6 | import org.junit.jupiter.api.Test;
7 | import org.springframework.mock.web.MockHttpServletRequest;
8 | import org.springframework.mock.web.MockHttpSession;
9 | import org.springframework.web.context.request.RequestContextHolder;
10 | import org.springframework.web.context.request.ServletRequestAttributes;
11 | import todoapp.core.shared.identifier.UserId;
12 | import todoapp.core.user.domain.User;
13 | import todoapp.security.UserSession;
14 |
15 | import java.util.UUID;
16 |
17 | import static org.assertj.core.api.Assertions.assertThat;
18 | import static org.assertj.core.api.Assertions.assertThatThrownBy;
19 |
20 | /**
21 | * @author springrunner.kr@gmail.com
22 | */
23 | class HttpUserSessionHolderTest {
24 |
25 | private HttpUserSessionHolder userSessionHolder;
26 | private MockHttpSession mockHttpSession;
27 |
28 | @BeforeEach
29 | void setUp() {
30 | userSessionHolder = new HttpUserSessionHolder();
31 | mockHttpSession = new MockHttpSession();
32 |
33 | var request = new MockHttpServletRequest();
34 | request.setSession(mockHttpSession);
35 |
36 | RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
37 | }
38 |
39 | @AfterEach
40 | void tearDown() {
41 | RequestContextHolder.resetRequestAttributes();
42 | }
43 |
44 | @Test
45 | @DisplayName("`RequestContextHolder`가 비어있을 때 사용자 세션을 취득(get)하면 NPE가 발생한다")
46 | void when_RequestContextHolderIsEmpty_Expect_NullPointerExceptionOnGet() {
47 | RequestContextHolder.resetRequestAttributes();
48 |
49 | assertThatThrownBy(() -> userSessionHolder.get())
50 | .isInstanceOf(NullPointerException.class);
51 | }
52 |
53 | @Test
54 | @DisplayName("HTTP 세션에 사용자 세션이 존재할 때 취득(get)하면 해당 세션을 반환한다")
55 | void when_HttpSessionHasUserSession_Expect_UserSessionRetrievedByGet() {
56 | var userSession = newTestUserSession();
57 | mockHttpSession.setAttribute(HttpUserSessionHolder.USER_SESSION_KEY, userSession);
58 |
59 | var retrieved = userSessionHolder.get();
60 |
61 | assertThat(retrieved).isNotNull();
62 | assertThat(retrieved).isSameAs(userSession);
63 | }
64 |
65 | @Test
66 | @DisplayName("`null`을 설정(set)하면 NPE가 발생한다")
67 | void when_NullSet_Expect_NullPointerException() {
68 | assertThatThrownBy(() -> userSessionHolder.set(null))
69 | .isInstanceOf(NullPointerException.class)
70 | .hasMessage("session object must be not null");
71 | }
72 |
73 | @Test
74 | @DisplayName("사용자 세션을 설정(set)하면 HTTP 세션에 저장한다")
75 | void when_UserSessionSet_Expect_HttpSessionStored() {
76 | var userSession = newTestUserSession();
77 |
78 | userSessionHolder.set(userSession);
79 |
80 | var retrieved = mockHttpSession.getAttribute(HttpUserSessionHolder.USER_SESSION_KEY);
81 | assertThat(retrieved).isSameAs(userSession);
82 | }
83 |
84 | @Test
85 | @DisplayName("사용자 세션을 초기화(reset)하면 HTTP 세션이 비워진다")
86 | void when_ResetUserSession_Expect_HttpSessionCleared() {
87 | mockHttpSession.setAttribute(HttpUserSessionHolder.USER_SESSION_KEY, newTestUserSession());
88 |
89 | userSessionHolder.reset();
90 |
91 | var retrieved = mockHttpSession.getAttribute(HttpUserSessionHolder.USER_SESSION_KEY);
92 | assertThat(retrieved).isNull();
93 | }
94 |
95 | UserSession newTestUserSession() {
96 | return new UserSession(new User(UserId.of(UUID.randomUUID().toString()), "tester", "password"));
97 | }
98 |
99 | }
100 |
--------------------------------------------------------------------------------
/server/src/test/java/todoapp/web/FeatureTogglesRestControllerTest.java:
--------------------------------------------------------------------------------
1 | package todoapp.web;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import org.springframework.beans.factory.annotation.Autowired;
5 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
6 | import org.springframework.boot.test.mock.mockito.MockBean;
7 | import org.springframework.test.web.servlet.MockMvc;
8 | import todoapp.security.UserSessionHolder;
9 | import todoapp.web.model.FeatureTogglesProperties;
10 | import todoapp.web.model.SiteProperties;
11 |
12 | import static org.mockito.Mockito.when;
13 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
14 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
15 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
16 |
17 | /**
18 | * @author springrunner.kr@gmail.com
19 | */
20 | @WebMvcTest(FeatureTogglesRestController.class)
21 | class FeatureTogglesRestControllerTest {
22 |
23 | @Autowired
24 | private MockMvc mockMvc;
25 |
26 | @MockBean
27 | private FeatureTogglesProperties featureTogglesProperties;
28 |
29 | @MockBean
30 | private SiteProperties siteProperties;
31 |
32 | @MockBean
33 | private UserSessionHolder userSessionHolder;
34 |
35 | @Test
36 | void featureToggles() throws Exception {
37 | when(featureTogglesProperties.auth()).thenReturn(true);
38 | when(featureTogglesProperties.onlineUsersCounter()).thenReturn(false);
39 |
40 | mockMvc.perform(get("/api/feature-toggles"))
41 | .andExpect(status().isOk())
42 | .andExpect(content().json("{\"auth\":true,\"onlineUsersCounter\":false}"));
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/server/src/test/java/todoapp/web/TodoControllerTest.java:
--------------------------------------------------------------------------------
1 | package todoapp.web;
2 |
3 | import org.junit.jupiter.api.BeforeEach;
4 | import org.junit.jupiter.api.Test;
5 | import org.junit.jupiter.api.extension.ExtendWith;
6 | import org.mockito.Mock;
7 | import org.mockito.junit.jupiter.MockitoExtension;
8 | import org.springframework.test.web.servlet.MockMvc;
9 | import org.springframework.test.web.servlet.setup.MockMvcBuilders;
10 | import org.springframework.web.accept.ContentNegotiationManager;
11 | import org.springframework.web.accept.HeaderContentNegotiationStrategy;
12 | import org.springframework.web.filter.CharacterEncodingFilter;
13 | import org.springframework.web.servlet.view.ContentNegotiatingViewResolver;
14 | import todoapp.core.shared.util.Spreadsheet;
15 | import todoapp.core.todo.TodoFixture;
16 | import todoapp.core.todo.application.FindTodos;
17 | import todoapp.core.todo.domain.support.SpreadsheetConverter;
18 | import todoapp.web.support.servlet.view.CommaSeparatedValuesView;
19 |
20 | import java.util.List;
21 | import java.util.stream.Collectors;
22 |
23 | import static org.mockito.Mockito.when;
24 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
25 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
26 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
27 |
28 | /**
29 | * @author springrunner.kr@gmail.com
30 | */
31 | @ExtendWith(MockitoExtension.class)
32 | class TodoControllerTest {
33 |
34 | @Mock
35 | private FindTodos findTodos;
36 |
37 | private MockMvc mockMvc;
38 |
39 | @BeforeEach
40 | void setUp() {
41 | var contentNegotiationManager = new ContentNegotiationManager(new HeaderContentNegotiationStrategy());
42 | var contentNegotiatingViewResolver = new ContentNegotiatingViewResolver();
43 | contentNegotiatingViewResolver.setContentNegotiationManager(contentNegotiationManager);
44 | contentNegotiatingViewResolver.setDefaultViews(List.of(new CommaSeparatedValuesView()));
45 |
46 | var characterEncodingFilter = new CharacterEncodingFilter();
47 | characterEncodingFilter.setEncoding("UTF-8");
48 | characterEncodingFilter.setForceEncoding(true);
49 |
50 | mockMvc = MockMvcBuilders
51 | .standaloneSetup(new TodoController(findTodos))
52 | .setViewResolvers(contentNegotiatingViewResolver)
53 | .addFilter(characterEncodingFilter)
54 | .build();
55 | }
56 |
57 | @Test
58 | void downloadTodos_ShouldReturnCsv() throws Exception {
59 | var todos = List.of(
60 | TodoFixture.random(),
61 | TodoFixture.random()
62 | );
63 |
64 | when(findTodos.all()).thenReturn(todos);
65 |
66 | mockMvc.perform(get("/todos").accept("text/csv"))
67 | .andExpect(status().isOk())
68 | .andExpect(content().string(toCSV(SpreadsheetConverter.convert(todos))));
69 | }
70 |
71 | private String toCSV(Spreadsheet spreadsheet) {
72 | var header = spreadsheet.getHeader()
73 | .map(row -> row.joining(","))
74 | .orElse("");
75 |
76 | var rows = spreadsheet.getRows().stream()
77 | .map(row -> row.joining(","))
78 | .collect(Collectors.joining("\n"));
79 |
80 | return header + "\n" + rows + "\n";
81 | }
82 |
83 | }
84 |
--------------------------------------------------------------------------------
/server/src/test/java/todoapp/web/TodoRestControllerTest.java:
--------------------------------------------------------------------------------
1 | package todoapp.web;
2 |
3 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
4 | import org.junit.jupiter.api.BeforeEach;
5 | import org.junit.jupiter.api.Test;
6 | import org.junit.jupiter.api.extension.ExtendWith;
7 | import org.mockito.Mock;
8 | import org.mockito.junit.jupiter.MockitoExtension;
9 | import org.springframework.http.MediaType;
10 | import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
11 | import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
12 | import org.springframework.test.web.servlet.MockMvc;
13 | import org.springframework.test.web.servlet.setup.MockMvcBuilders;
14 | import todoapp.core.shared.identifier.TodoId;
15 | import todoapp.core.todo.TodoFixture;
16 | import todoapp.core.todo.application.AddTodo;
17 | import todoapp.core.todo.application.FindTodos;
18 | import todoapp.core.todo.application.ModifyTodo;
19 | import todoapp.core.todo.application.RemoveTodo;
20 | import todoapp.web.config.json.TodoModule;
21 |
22 | import java.util.Arrays;
23 | import java.util.UUID;
24 |
25 | import static org.mockito.BDDMockito.given;
26 | import static org.mockito.Mockito.verify;
27 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
28 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
29 |
30 | /**
31 | * @author springrunner.kr@gmail.com
32 | */
33 | @ExtendWith(MockitoExtension.class)
34 | class TodoRestControllerTest {
35 |
36 | @Mock
37 | private FindTodos findTodos;
38 |
39 | @Mock
40 | private AddTodo addTodo;
41 |
42 | @Mock
43 | private ModifyTodo modifyTodo;
44 |
45 | @Mock
46 | private RemoveTodo removeTodo;
47 |
48 | private MockMvc mockMvc;
49 |
50 | @BeforeEach
51 | void setUp() {
52 | var objectMapper = Jackson2ObjectMapperBuilder.json()
53 | .modules(new TodoModule(), new JavaTimeModule())
54 | .build();
55 |
56 | mockMvc = MockMvcBuilders.standaloneSetup(new TodoRestController(findTodos, addTodo, modifyTodo, removeTodo))
57 | .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
58 | .build();
59 | }
60 |
61 | @Test
62 | void readAll_ShouldReturnAllTodos() throws Exception {
63 | var first = TodoFixture.random();
64 | var second = TodoFixture.random();
65 |
66 | given(findTodos.all()).willReturn(Arrays.asList(first, second));
67 |
68 | mockMvc.perform(get("/api/todos"))
69 | .andExpect(status().isOk())
70 | .andExpect(content().contentType("application/json"))
71 | .andExpect(jsonPath("$.length()").value(2))
72 | .andExpect(jsonPath("$[0].id").value(first.getId().toString()))
73 | .andExpect(jsonPath("$[0].text").value(first.getText()))
74 | .andExpect(jsonPath("$[1].id").value(second.getId().toString()))
75 | .andExpect(jsonPath("$[1].text").value(second.getText()));
76 | }
77 |
78 | @Test
79 | void create_ShouldRegisterTodo() throws Exception {
80 | var todoText = "New Task";
81 | var todoJson = "{\"text\":\"" + todoText + "\"}";
82 |
83 | mockMvc.perform(
84 | post("/api/todos")
85 | .contentType(MediaType.APPLICATION_JSON)
86 | .content(todoJson)
87 | ).andExpect(status().isCreated());
88 |
89 | verify(addTodo).add(todoText);
90 | }
91 |
92 | @Test
93 | void create_ShouldReturnBadRequest_WhenTitleIsInvalid() throws Exception {
94 | var invalidTodoJson = "{\"text\":\"abc\"}";
95 |
96 | mockMvc.perform(
97 | post("/api/todos")
98 | .contentType(MediaType.APPLICATION_JSON)
99 | .content(invalidTodoJson)
100 | ).andExpect(status().isBadRequest());
101 | }
102 |
103 | @Test
104 | void update_ShouldModifyTodo() throws Exception {
105 | var todoId = TodoId.of(UUID.randomUUID().toString());
106 | var todoText = "Updated Task";
107 | var todoJson = "{\"text\":\"" + todoText + "\", \"completed\":true}";
108 |
109 | mockMvc.perform(
110 | put("/api/todos/" + todoId)
111 | .contentType(MediaType.APPLICATION_JSON)
112 | .content(todoJson)
113 | ).andExpect(status().isOk());
114 |
115 | verify(modifyTodo).modify(todoId, todoText, true);
116 | }
117 |
118 | @Test
119 | void update_ShouldReturnBadRequest_WhenTitleIsInvalid() throws Exception {
120 | var todoId = TodoId.of(UUID.randomUUID().toString());
121 | var invalidTodoJson = "{\"text\":\"abc\", \"completed\":true}";
122 |
123 | mockMvc.perform(
124 | put("/api/todos/" + todoId)
125 | .contentType(MediaType.APPLICATION_JSON)
126 | .content(invalidTodoJson)
127 | ).andExpect(status().isBadRequest());
128 | }
129 |
130 | @Test
131 | void delete_ShouldClearTodo() throws Exception {
132 | var todoId = TodoId.of(UUID.randomUUID().toString());
133 |
134 | mockMvc.perform(
135 | delete("/api/todos/" + todoId)
136 | ).andExpect(status().isOk());
137 |
138 | verify(removeTodo).remove(todoId);
139 | }
140 |
141 | }
142 |
--------------------------------------------------------------------------------