├── .README └── todoapp.png ├── .env ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── client ├── .gitignore ├── README.md ├── index.html ├── main.js ├── package-lock.json ├── package.json ├── pages │ ├── error.html │ ├── login.html │ └── todos.html ├── public │ ├── favicon.ico │ └── profile-picture.png ├── src │ ├── application │ │ ├── feature-toggles-service.js │ │ ├── todo-service.js │ │ └── user-service.js │ └── ui │ │ ├── error │ │ ├── app.css │ │ └── app.js │ │ ├── feature-toggles.js │ │ ├── login │ │ ├── app.css │ │ └── app.js │ │ └── todos │ │ ├── app.css │ │ ├── app.js │ │ ├── component.js │ │ ├── controller.js │ │ ├── todos.js │ │ ├── user-count.js │ │ └── user-session.js ├── style.css └── vite.config.js ├── docker-compose.yml └── server ├── .README └── todoapp-architecture.png ├── .gitignore ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── java │ └── todoapp │ │ ├── TodoApplication.java │ │ ├── core │ │ ├── foundation │ │ │ ├── Constant.java │ │ │ ├── NotImplementedException.java │ │ │ ├── SystemException.java │ │ │ ├── crypto │ │ │ │ ├── PasswordEncoder.java │ │ │ │ └── support │ │ │ │ │ ├── NoOpPasswordEncoder.java │ │ │ │ │ └── SimplePasswordEncoder.java │ │ │ └── util │ │ │ │ ├── DigestUtils.java │ │ │ │ └── StreamUtils.java │ │ ├── shared │ │ │ ├── identifier │ │ │ │ ├── TodoId.java │ │ │ │ └── UserId.java │ │ │ └── util │ │ │ │ └── Spreadsheet.java │ │ ├── todo │ │ │ ├── application │ │ │ │ ├── AddPersonalTodo.java │ │ │ │ ├── AddTodo.java │ │ │ │ ├── DefaultTodoManager.java │ │ │ │ ├── FindPersonalTodos.java │ │ │ │ ├── FindTodos.java │ │ │ │ ├── ModifyPersonalTodo.java │ │ │ │ ├── ModifyTodo.java │ │ │ │ ├── RemovePersonalTodo.java │ │ │ │ └── RemoveTodo.java │ │ │ └── domain │ │ │ │ ├── Todo.java │ │ │ │ ├── TodoException.java │ │ │ │ ├── TodoIdGenerator.java │ │ │ │ ├── TodoNotFoundException.java │ │ │ │ ├── TodoOwnerMismatchException.java │ │ │ │ ├── TodoRegistrationRejectedException.java │ │ │ │ ├── TodoRepository.java │ │ │ │ ├── TodoState.java │ │ │ │ └── support │ │ │ │ ├── SpreadsheetConverter.java │ │ │ │ └── UUIDBasedTodoIdGenerator.java │ │ └── user │ │ │ ├── application │ │ │ ├── ChangeUserProfilePicture.java │ │ │ ├── DefaultUserService.java │ │ │ ├── RegisterUser.java │ │ │ └── VerifyUserPassword.java │ │ │ └── domain │ │ │ ├── ProfilePicture.java │ │ │ ├── ProfilePictureException.java │ │ │ ├── ProfilePictureStorage.java │ │ │ ├── User.java │ │ │ ├── UserException.java │ │ │ ├── UserIdGenerator.java │ │ │ ├── UserNotFoundException.java │ │ │ ├── UserPasswordNotMatchedException.java │ │ │ ├── UserRepository.java │ │ │ └── support │ │ │ └── UUIDBasedUserIdGenerator.java │ │ ├── data │ │ ├── InMemoryTodoRepository.java │ │ ├── InMemoryUserRepository.java │ │ ├── LocalProfilePictureStorage.java │ │ ├── TodosDataInitializer.java │ │ └── jpa │ │ │ ├── JpaTodoRepository.java │ │ │ └── JpaUserRepository.java │ │ ├── security │ │ ├── AccessDeniedException.java │ │ ├── UnauthorizedAccessException.java │ │ ├── UserSession.java │ │ ├── UserSessionHolder.java │ │ ├── support │ │ │ └── RolesAllowedSupport.java │ │ └── web │ │ │ └── servlet │ │ │ ├── HttpUserSessionHolder.java │ │ │ ├── RolesVerifyHandlerInterceptor.java │ │ │ └── UserSessionFilter.java │ │ └── web │ │ ├── FeatureTogglesRestController.java │ │ ├── LoginController.java │ │ ├── OnlineUsersCounterController.java │ │ ├── TodoController.java │ │ ├── TodoRestController.java │ │ ├── UserController.java │ │ ├── UserRestController.java │ │ ├── config │ │ ├── GlobalControllerAdvice.java │ │ ├── WebMvcConfiguration.java │ │ └── json │ │ │ ├── TodoModule.java │ │ │ └── UserModule.java │ │ ├── model │ │ ├── FeatureTogglesProperties.java │ │ ├── SiteProperties.java │ │ └── UserProfile.java │ │ └── support │ │ ├── ConnectedClientCountBroadcaster.java │ │ ├── context │ │ └── ExceptionMessageTranslator.java │ │ ├── method │ │ ├── ProfilePictureReturnValueHandler.java │ │ └── UserSessionHandlerMethodArgumentResolver.java │ │ └── servlet │ │ ├── error │ │ └── ReadableErrorAttributes.java │ │ ├── handler │ │ ├── ExecutionTimeHandlerInterceptor.java │ │ └── LoggingHandlerInterceptor.java │ │ └── view │ │ ├── CommaSeparatedValuesView.java │ │ └── SimpleMappingViewResolver.java └── resources │ ├── application-default.yaml │ ├── application.yaml │ ├── messages.properties │ ├── messages_en.properties │ └── messages_ko.properties └── test └── java └── todoapp ├── TodoApplicationTests.java ├── core └── todo │ └── TodoFixture.java ├── data ├── InMemoryTodoRepositoryTest.java └── InMemoryUserRepositoryTest.java ├── security ├── UserSessionTest.java └── web │ └── servlet │ └── HttpUserSessionHolderTest.java └── web ├── FeatureTogglesRestControllerTest.java ├── TodoControllerTest.java └── TodoRestControllerTest.java /.README/todoapp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springrunner/mastering-spring-web-101/6336c2d8398942ac55d9925126abc80d1cdcba18/.README/todoapp.png -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | SPRING_PROFILES_ACTIVE=prod 2 | SPRING_DATASOURCE_URL=jdbc:h2:tcp://h2database:1521/todoapp 3 | SPRING_DATASOURCE_USERNAME=sa 4 | SPRING_JPA_HIBERNATE_DDL_AUTO=update 5 | SPRING_THYMELEAF_PREFIX=file:./templates/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Project ### 2 | docs/ 3 | .docker/ 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Run this command in the project root to build the Docker image 2 | # docker rm -f todoapp-container && docker run --platform linux/amd64 -d -p 8080:8080 --name todoapp-container todoapp:latest 3 | 4 | FROM --platform=linux/amd64 node:20 AS client-builder 5 | WORKDIR /builder 6 | COPY client/package.json client/package-lock.json . 7 | RUN npm install 8 | COPY client/ . 9 | RUN npm run build 10 | 11 | FROM --platform=linux/amd64 bellsoft/liberica-runtime-container:jdk-21-crac-cds-slim-glibc AS server-builder 12 | WORKDIR /builder 13 | COPY server/gradle/ gradle 14 | COPY --chmod=+x server/gradlew.bat . 15 | COPY --chmod=+x server/gradlew . 16 | RUN ./gradlew --version --no-daemon 17 | COPY server/ . 18 | RUN ./gradlew clean build --no-daemon 19 | RUN java -Djarmode=tools -jar build/libs/todoapp.jar extract --layers --destination extracted 20 | 21 | FROM --platform=linux/amd64 bellsoft/liberica-runtime-container:jdk-21-crac-cds-slim-glibc 22 | WORKDIR /application 23 | COPY --from=client-builder /builder/dist/pages/ templates 24 | COPY --from=client-builder /builder/dist/favicon.ico static/ 25 | COPY --from=client-builder /builder/dist/profile-picture.png static/ 26 | COPY --from=client-builder /builder/dist/assets/ static/assets 27 | COPY --from=server-builder /builder/extracted/application/ . 28 | COPY --from=server-builder /builder/extracted/dependencies/ . 29 | COPY --from=server-builder /builder/extracted/snapshot-dependencies/ . 30 | COPY --from=server-builder /builder/extracted/spring-boot-loader/ . 31 | ENTRYPOINT ["java", "-jar", "todoapp.jar"] -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 SpringRunner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 코드로 익히는 스프링 웹 프로그래밍(부제: Mastering Spring Web 101 Workshop) 2 | 3 | > 이 프로젝트는 코드로 익히는 스프링 웹 프로그래밍 강좌를 위해 만들어진 Todoapp 웹 애플리케이션입니다. 4 | 5 | 코드로 익히는 스프링 웹 프로그래밍은 [Spring MVC](https://docs.spring.io/spring-framework/docs/current/reference/html/web.html)와 [Spring Boot](https://spring.io/projects/spring-boot)로 웹 애플리케이션 서버 사이드(Server-side)를 직접 개발·실습하며 학습하는 워크숍 형식의 강좌입니다. 참가자는 제공된 애플리케이션 정의서, Web API 명세서, 그리고 템플릿 프로젝트를 바탕으로 Todoapp 웹 애플리케이션의 서버 사이드를 구축하게 됩니다. 강사가 라이브 코딩과 함께 스프링의 기능을 설명하면, 참가자는 해당 코드를 직접 작성하고 실행·테스트하며 완전한 웹 애플리케이션을 완성하게 됩니다. 6 | 7 | 강좌에 대한 자세한 소개는 [여기](https://edu.nextstep.camp/c/ygVWPEgo)에서 볼 수 있습니다. 8 | 9 | ## I. Todoapp 10 | 11 |

12 | 13 |

14 | 15 | `Todoapp` 웹 애플리케이션은 할일 목록 기능을 제공하는 웹 애플리케이션입니다. 사용자는 할일을 추가, 완료, 삭제 및 수정할 수 있으며, 할일을 CSV 파일로 다운로드 받을 수 있습니다. 추가적으로 사용자 로그인 및 로그아웃, 프로필 이미지 변경 기능을 포함합니다. 16 | 17 | ### 요구사항 18 | 19 | #### 기능 20 | 21 | * 할일 관리 22 | - 사용자는 할일 목록을 조회할 수 있습니다. 23 | - 사용자는 완료 여부로 할일 목록을 필터링할 수 있습니다. 24 | - 사용자는 새로운 할일을 등록할 수 있습니다. 25 | - 사용자는 등록된 할일을 변경할 수 있습니다. 26 | - 사용자는 등록된 할일을 완료할 수 있습니다. 27 | - 사용자는 등록된 할일을 취소할 수 있습니다. 28 | - 사용자는 완료된 할일을 정리할 수 있습니다. (정리 시점의 모든 완료된 할일이 대상입니다) 29 | - 사용자는 할일을 CSV 파일로 다운로드 받을 수 있습니다. (다운로드 시점의 모든 할일이 포함됩니다) 30 | * 사용자 관리 31 | - 사용자는 자신의 계정으로 로그인할 수 있습니다. 32 | - 사용자는 로그아웃할 수 있습니다. 33 | - 사용자는 자신의 프로필 이미지를 업로드하고 변경할 수 있습니다. 34 | 35 | #### 비기능 36 | 37 | * 코드베이스는 모듈화되고 잘 주석 처리되어 있어야 합니다. 38 | * 사용자 비밀번호는 해시 및 암호화하여 저장해야 합니다. 39 | * 오류가 발생했을 때 사용자에게 친절하게 안내해야 합니다. 40 | * 보안이 필요한 API 엔드포인트는 인증 및 인가 절차를 거쳐야 합니다. 41 | * 애플리케이션은 사용자가 증가함에 따라 수평적으로 확장 가능해야 합니다. 42 | 43 | ### 클라이언트 사이드(Client-side) 44 | 45 | Todoapp 웹 애플리케이션의 클라이언트 사이드는 웹 기술(HTML, CSS, JavaScript)을 기반으로 만듭니다. 46 | 47 | ### 서버 사이드(Server-side) 48 | 49 | Todoapp 웹 애플리케이션의 서버 사이드는 자바(Java)와 스프링(Spring)을 기반으로 만듭니다. 50 | 51 | ## II. 빌드 및 실행 방법 52 | 53 | 저장소를 복제하거나 압축 파일로 다운로드한 후 터미널에서 도커 컴포즈로 실행할 수 있습니다. 54 | 55 | ``` 56 | ❯ docker compose up --build -d 57 | ``` 58 | 59 | 도커 컨테이너가 구성되면, http://localhost:50080/ 으로 접속해서 접속이 되는지 확인합니다. 60 | 61 | ## III. 라이선스 62 | 저장소 내 모든 내용은 [MIT 라이선스](LICENSE.md)로 제공됩니다. 63 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Todoapp Client-side 2 | 3 | > 이 프로젝트는 [TodoMVC App Template](https://github.com/tastejs/todomvc-app-template/)을 기반으로, 웹 기술(HTML, CSS, JavaScript)과 [Thymeleaf](https://www.thymeleaf.org/)로 개발된 웹 클라이언트입니다. 4 | 5 | Todoapp 웹 클라이언트는 3개 페이지로 구성되어있습니다. 6 | 7 | * **todos.html** : 할일 목록 페이지 - AJAX를 사용해 [Web API](https://en.wikipedia.org/wiki/Web_API)를 호출하고, 결과를 출력합니다. 8 | * **login.html** : 사용자 로그인 페이지 - HTML 폼(form) 전송으로 사용자 로그인을 시도합니다. 9 | * **error.html** : 오류 페이지 - 서버 발생오류에 담긴 모델을 출력합니다. 10 | 11 | ### 공통 12 | 13 | * 모든 페이지(html)는 Thymeleaf 형식으로 작성되었습니다. 14 | * 모든 페이지 하단에는 사이트 작성자와 설명을 노출하는 기능(푸터, Footer)이 포함되어 있습니다. 15 | - 서버에서 제공된 모델(Model)에 다음 키(Key)에 해당하는 값(Value)이 있으면 출력합니다. 16 | - `site.authour`: 사이트 작성자를 출력합니다. 17 | - `site.description`: 사이트 설명을 출력합니다. 18 | 19 | ### 할일 목록 페이지: todos.html 20 | 21 | 이 페이지는 할일 등록부터 완료, 목록 조회 등 다양한 기능이 순수한 자바스크립트로 동작하도록 작성되어 있습니다. 페이지의 기능이 동작하기 위해서는 다음과 같은 Web API가 필요합니다. 22 | 23 | * `GET /api/todos`: 할일 목록 조회 24 | * `POST /api/todos`: 새로운 할일 등록 25 | * `PUT /api/todos/{todo.id}`: 등록된 할일 수정 또는 완료 26 | * `DELETE /api/todos/{todo.id}`: 등록된 할일 삭제 27 | * `GET /api/feature-toggles`: 확장 기능 활성화 28 | * `GET /api/user/profile`: 로그인된 사용자 프로필 정보 조회 29 | * `POST /api/user/profile-picture`: 로그인된 사용자 프로필 이미지 변경 30 | * `GET /stream/online-users-counter`: 접속된 사용자 수 변경 이벤트 결과 출력 31 | - 이벤트 스트림은 [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)로 연결 32 | 33 | > Web API 응답 상태코드가 40X([클라이언트 오류, Client Error](https://developer.mozilla.org/ko/docs/Web/HTTP/Status#%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8_%EC%97%90%EB%9F%AC_%EC%9D%91%EB%8B%B5)), 50X([서버 오류, Server error](https://developer.mozilla.org/ko/docs/Web/HTTP/Status#%EC%84%9C%EB%B2%84_%EC%97%90%EB%9F%AC_%EC%9D%91%EB%8B%B5))라면, 응답 바디에 담긴 오류 모델을 출력합니다. 보다 자세한 내용은 [error.html](#오류-페이지:-error.html) 을 참조바랍니다. 34 | 35 | > [Todoapp Web APIs Document](https://app.swaggerhub.com/apis-docs/code-rain/todoapp/1.0.0-snapshot)에서 보다 상세한 WEB API 스펙을 확인할 수 있습니다. 36 | 37 | 할일 목록을 [CSV(Comma-separated values)](https://en.wikipedia.org/wiki/Comma-separated_values)형식으로 내려받을 목적으로 다음과 같이 서버를 호출합니다. 38 | 39 | ``` 40 | Http URL: /todos 41 | Http Method: GET 42 | Http Headers: 43 | Accept: text/csv 44 | ``` 45 | 46 | ### 사용자 로그인 페이지: login.html 47 | 48 | * HTML 폼으로 사용자이름(username)과 비밀번호(password)를 입력받도록 작성되었습니다. 49 | * 로그인 버튼을 클릭하면 `POST /login`로 사용자 입력값(username, password)을 전송합니다. 50 | * 서버에서 제공된 모델(Model)에 다음 키(Key)에 해당하는 값(Value)이 있으면 출력합니다. 51 | - `bindingResult`: [Spring BindingResult](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/validation/BindingResult.html) 객체에서 오류 내용을 출력합니다. 52 | - `message`: 서버에서 전달한 메시지가 있으면 출력합니다. 53 | 54 | ### 오류 페이지: error.html 55 | 56 | * 타임리프(Thymeleaf)를 통해 오류 정보를 출력하도록 작성되었습니다. 57 | * 서버에서 제공하는 모델(Model)에 다음 키(Key)에 해당하는 값(Value)이 있으면 출력합니다. 58 | - `path`: 오류가 발생한 URL 경로(path) 59 | - `status`: HTTP 상태코드 60 | - `error`: 오류 발생 이유 61 | - `errors`: 스프링 BindingResult 내부에 모든 ObjectErrors 객체 목록 62 | - `message`: 오류 내용 63 | - `timestamp`: 오류 발생시간 64 | 65 | ## I. 프로젝트 구성 66 | 67 | 이 프로젝트는 웹 기술(HTML, CSS, JavaScript)과 타임리프(Thymeleaf)를 사용해 바닐라 자바스크립트(Vanilla JS)로 개발하고, 비트(Vite)로 관리하고 있습니다. 타임리프를 통해 빠르게 초기 화면을 구성하고, 바닐라 자바스크립트를 통해 사용자 인터랙션 및 동적인 기능을 구현하여 성능과 사용자 경험을 모두 향상시킵니다. 특히, 비트의 개발 서버와 HMR(Hot Module Replacement)은 바닐라 자바스크립트의 장점을 극대화하여 개발 생산성을 높이고, 쾌적한 개발 환경을 제공합니다. 68 | 69 | > [Thymeleaf](https://www.thymeleaf.org/)는 강력한 자바 템플릿 엔진으로, 서버에서 동적으로 HTML을 생성합니다. 이를 통해 사용자는 첫 페이지 로딩 시 완전한 형태의 페이지를 빠르게 볼 수 있으며, 검색 엔진은 페이지 내용을 정확히 파악하여 검색 결과에 반영할 수 있습니다. 70 | > 71 | > 바닐라 자바스크립트는 외부 라이브러리나 프레임워크 없이 순수 자바스크립트만으로 개발하는 방식입니다. 이는 불필요한 코드를 줄여 애플리케이션의 크기를 최소화하고, 로딩 속도를 향상시키는 데 기여합니다. 또한, 앱의 핵심 로직을 명확하게 파악하고 유지보수하기 용이하다는 장점이 있습니다. 72 | > 73 | > [Vite](https://vitejs.dev)는 빠른 개발 서버 구축 및 빌드를 위한 도구입니다. 특히 Hot Module Replacement (HMR) 기능을 통해 코드 변경 사항을 실시간으로 반영하여 개발 과정을 훨씬 효율적으로 만들어 줍니다. 또한, 최신 웹 개발 트렌드를 반영하여 ES 모듈을 기본적으로 지원하고, 다양한 플러그인을 통해 확장성을 높일 수 있습니다. 74 | 75 | ### 디렉토리 구조 76 | 77 | ``` 78 | ├── pages # HTML 소스 코드 79 | ├── public # 리소스 파일 80 | ├── src # JavaScript, CSS 소스 코드 81 | ├── dist # 빌드(`npm run build`) 명령으로 생성된 배포본 82 | ├── index.html 83 | ├── style.css 84 | ├── vite.config.js # Vite 설정 85 | ├── package.json 86 | └── package-lock.json 87 | ``` 88 | 89 | ### 의존성 관리 90 | 91 | 패키지 의존성 관리를 위해 [NPM](https://nodejs.org/en/learn/getting-started/an-introduction-to-the-npm-package-manager/)을 사용하며, 클라이언트 개발에 사용된 의존성은 `package.json` 명세파일에 선언되어 있습니다. 92 | 93 | ### 프로젝트 설정 94 | > 프로젝트 설정을 위해서 `NPM`이 설치되어 있어야 합니다. 95 | 96 | ``` 97 | ❯ git clone https://github.com/springrunner/mastering-spring-web-101.git 98 | ❯ cd mastering-spring-web-101/client 99 | ❯ npm install 100 | ``` 101 | 102 | ## II. 빌드 및 실행 방법 103 | 104 | 저장소를 복제하거나 압축 파일로 다운로드한 후 터미널에서 다음과 같은 방법으로 실행할 수 있습니다. 105 | 106 | ``` 107 | ❯ git clone https://github.com/springrunner/mastering-spring-web-101.git 108 | ❯ cd mastering-spring-web-101/client 109 | ❯ npm run dev 110 | ``` 111 | 112 | 빌드는 다음 명령을 통해 실행할 수 있습니다. 113 | 114 | ``` 115 | ❯ npm run build 116 | ``` 117 | 118 | # III. 참고자료 119 | 120 | * [TodoMVC App Template](https://github.com/tastejs/todomvc-app-template/) 121 | * [Vite](https://vitejs.dev/) 122 | * [Thymeleaf](https://www.thymeleaf.org/) 123 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Todoapp 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | 3 | document.querySelector('#app').innerHTML = ` 4 |
5 |

Welcome. This is a Todoapp Client Project!

6 |
7 | ` 8 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "vite": "^5.3.4" 13 | }, 14 | "dependencies": { 15 | "todomvc-app-css": "^2.4.3", 16 | "todomvc-common": "^1.0.5" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/pages/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Todoapp 8 | 9 | 10 |
11 |
12 |

todos

13 |
14 |
15 |

Oops! Server Error... Σ(°ロ°)

16 |

[ 503 - Service Unavailable ]

17 |

Common causes are a server that is down for maintenance or that is overloaded. This response should be used for temporary conditions and the Retry-After HTTP header should, if possible, contain the estimated time for the recovery of the service.

18 |
19 | 21 |
22 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /client/pages/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Todoapp 8 | 9 | 10 |
11 |
12 |

todos

13 |
14 |
15 |
16 |
17 | 18 | 19 |
20 | Please provide a valid username 21 |
22 |
23 |
24 | 25 | 26 |
27 | Please provide a valid password 28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 | 39 |
40 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /client/pages/todos.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Todoapp 8 | 9 | 10 | 11 |
12 |
13 |
14 |

todos

15 |
16 | 17 | 28 |
29 | 30 |
31 |
32 |
33 | 34 | 35 |
36 | 52 |
53 | 70 |
71 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springrunner/mastering-spring-web-101/6336c2d8398942ac55d9925126abc80d1cdcba18/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/profile-picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springrunner/mastering-spring-web-101/6336c2d8398942ac55d9925126abc80d1cdcba18/client/public/profile-picture.png -------------------------------------------------------------------------------- /client/src/application/feature-toggles-service.js: -------------------------------------------------------------------------------- 1 | const QueryStringFeatureTogglesService = () => { 2 | return { 3 | fetchAll: async () => { 4 | const params = new URLSearchParams(window.location.search); 5 | return { 6 | auth: params.get('auth') === 'true', 7 | onlineUsersCounter: params.get('onlineUsersCounter') === 'true' 8 | }; 9 | } 10 | }; 11 | } 12 | 13 | const WebAPIFeatureTogglesService = (apiUrl = "/api/feature-toggles") => { 14 | const headers = { 'Content-Type': 'application/json' }; 15 | const handleResponse = async (response) => { 16 | if (!response.ok) { 17 | if (response.status === 404) { // 404 에러 처리 18 | console.warn('feature-toggles are not yet implemented on the server'); 19 | return { 20 | auth: false, 21 | onlineUsersCounter: false 22 | }; 23 | } 24 | 25 | const data = await response.json(); 26 | const error = new Error(data.message ?? "Failed to process in server"); 27 | error.name = data.error ?? "Unknown Error"; 28 | error.details = (data.errors ?? []).map(it => typeof it === 'string' ? it : it.defaultMessage) 29 | 30 | throw error; 31 | } 32 | return response.json(); 33 | } 34 | 35 | return { 36 | fetchAll: async () => { 37 | const response = await fetch(apiUrl); 38 | return await handleResponse(response); 39 | } 40 | }; 41 | }; 42 | 43 | export { QueryStringFeatureTogglesService, WebAPIFeatureTogglesService }; -------------------------------------------------------------------------------- /client/src/application/todo-service.js: -------------------------------------------------------------------------------- 1 | const LocalStorageTodosService = (localStorage) => { 2 | const key = 'todos'; 3 | 4 | return { 5 | all: async () => JSON.parse(localStorage.getItem(key)) || [], 6 | add: async (todo) => { 7 | const todos = JSON.parse(localStorage.getItem(key)) || []; 8 | todos.push(todo); 9 | localStorage.setItem(key, JSON.stringify(todos)); 10 | }, 11 | edit: async (updatedTodo) => { 12 | let todos = JSON.parse(localStorage.getItem(key)) || []; 13 | todos = todos.map(todo => todo.id === updatedTodo.id ? {...todo, ...updatedTodo} : todo); 14 | localStorage.setItem(key, JSON.stringify(todos)); 15 | }, 16 | remove: async (todoId) => { 17 | const todos = JSON.parse(localStorage.getItem(key)) || []; 18 | const filteredTodos = todos.filter(todo => todo.id !== todoId); 19 | localStorage.setItem(key, JSON.stringify(filteredTodos)); 20 | }, 21 | clearCompleted: async () => { 22 | const todos = JSON.parse(localStorage.getItem(key)) || []; 23 | const filteredTodos = todos.filter(todo => !todo.completed); 24 | localStorage.setItem(key, JSON.stringify(filteredTodos)); 25 | } 26 | }; 27 | } 28 | 29 | const WebAPITodosService = (apiUrl = "/api/todos") => { 30 | const headers = { 'Content-Type': 'application/json' }; 31 | const handleResponse = async (response) => { 32 | if (response.status >= 200 && response.status < 300) { 33 | return response.json().catch(parseError => null); 34 | } else { 35 | let data = {}; 36 | try { 37 | data = await response.json(); 38 | } catch (parseError) { 39 | console.warn('Failed to parse JSON response:', parseError); 40 | } 41 | 42 | const error = new Error(data.message ?? "Failed to process in server"); 43 | error.name = data.error ?? "Unknown Error"; 44 | error.details = (data.errors ?? []).map(it => typeof it === 'string' ? it : it.defaultMessage) 45 | 46 | throw error; 47 | } 48 | } 49 | 50 | return { 51 | all: async () => { 52 | const response = await fetch(apiUrl); 53 | return await handleResponse(response); 54 | }, 55 | add: async (todo) => { 56 | const response = await fetch(apiUrl, { 57 | method: 'POST', 58 | headers, 59 | body: JSON.stringify(todo) 60 | }); 61 | return await handleResponse(response); 62 | }, 63 | edit: async (updatedTodo) => { 64 | const response = await fetch(`${apiUrl}/${updatedTodo.id}`, { 65 | method: 'PUT', 66 | headers, 67 | body: JSON.stringify(updatedTodo) 68 | }); 69 | return await handleResponse(response); 70 | }, 71 | remove: async (todoId) => { 72 | const response = await fetch(`${apiUrl}/${todoId}`, { 73 | method: 'DELETE', 74 | headers 75 | }); 76 | return await handleResponse(response); 77 | }, 78 | clearCompleted: async () => { 79 | const todos = await this.all(); 80 | const completedTodos = todos.filter(todo => todo.completed); 81 | await Promise.all(completedTodos.map(todo => this.remove(todo.id))); 82 | } 83 | }; 84 | }; 85 | 86 | export { LocalStorageTodosService, WebAPITodosService }; -------------------------------------------------------------------------------- /client/src/application/user-service.js: -------------------------------------------------------------------------------- 1 | const LocalStorageUserProfileService = (localStorage) => { 2 | const key = 'user-profile'; 3 | 4 | return { 5 | set: async (userProfile) => localStorage.setItem(key, JSON.stringify(userProfile)), 6 | get: async () => JSON.parse(localStorage.getItem(key)) || null, 7 | clear: async () => localStorage.removeItem(key), 8 | updateProfilePicture: async(profilePicture) => { 9 | const userProfile = JSON.parse(localStorage.getItem(key)) || null; 10 | if (userProfile === null) { 11 | throw new Error('user-profile is null'); 12 | } 13 | 14 | userProfile.profilePictureUrl = await new Promise((resolve, reject) => { 15 | const reader = new FileReader(); 16 | reader.onload = event => resolve(event.target.result); 17 | reader.onerror = error => reject(error); 18 | reader.readAsDataURL(profilePicture); 19 | }); 20 | localStorage.setItem(key, JSON.stringify(userProfile)); 21 | 22 | return userProfile; 23 | } 24 | }; 25 | } 26 | 27 | const WebAPIUserProfileService = ( 28 | fetchProfileUrl = "/api/user/profile", 29 | clearProfileUrl = "/logout", 30 | updateProfilePictureUrl = "/api/user/profile-picture", 31 | ) => { 32 | const headers = { 'Content-Type': 'application/json' }; 33 | const handleResponse = async (response) => { 34 | if (!response.ok) { 35 | if (response.status === 401) { 36 | console.warn('Unauthorized access detected on the server'); 37 | return null; 38 | } else if (response.status === 404) { 39 | console.warn('user-profile are not yet implemented on the server'); 40 | return null; 41 | } 42 | 43 | const data = await response.json(); 44 | const error = new Error(data.message ?? "Failed to process in server"); 45 | error.name = data.error ?? "Unknown Error"; 46 | error.details = (data.errors ?? []).map(it => typeof it === 'string' ? it : it.defaultMessage) 47 | 48 | throw error; 49 | } 50 | return response.json(); 51 | } 52 | 53 | return { 54 | set: (userProfile) => { 55 | throw new Error("Unsupported set user-profile operation"); 56 | }, 57 | get: async () => { 58 | const response = await fetch(fetchProfileUrl, { 59 | headers 60 | }); 61 | return await handleResponse(response); 62 | }, 63 | clear: async () => { 64 | const response = await fetch(clearProfileUrl, { 65 | headers 66 | }); 67 | return await handleResponse(response); 68 | }, 69 | updateProfilePicture: async(profilePicture) => { 70 | const formData = new FormData(); 71 | formData.append('profilePicture', profilePicture); 72 | 73 | const response = await fetch(updateProfilePictureUrl, { 74 | method: 'POST', 75 | header: { 76 | 'Content-Type': 'multipart/form-data' 77 | }, 78 | body: formData 79 | }); 80 | return await handleResponse(response); 81 | } 82 | }; 83 | } 84 | 85 | const RandomUserCountService = (interval = 30000) => { 86 | let intervalId; 87 | 88 | return { 89 | connect(onUserCountChange) { 90 | intervalId = setInterval(() => { 91 | const number = Math.floor(Math.random() * 10) + 1; 92 | onUserCountChange(number); 93 | }, interval); 94 | }, 95 | disconnect() { 96 | clearInterval(intervalId); 97 | } 98 | } 99 | } 100 | 101 | const OnlineUserCountService = (streamUrl = '/stream/online-users-counter') => { 102 | let eventSource; 103 | let isConnected = false; 104 | 105 | return { 106 | connect(onUserCountChange) { 107 | if (isConnected) { 108 | console.warn('Already connected to the event source.'); 109 | return; 110 | } 111 | 112 | eventSource = new EventSource(streamUrl); 113 | eventSource.onerror = () => {}; 114 | eventSource.addEventListener('message', (event) => { 115 | onUserCountChange(parseInt(event.data, 10)); 116 | }); 117 | 118 | isConnected = true; 119 | }, 120 | disconnect() { 121 | if (eventSource) { 122 | eventSource.close(); 123 | eventSource = null; 124 | isConnected = false; 125 | } 126 | } 127 | } 128 | } 129 | 130 | export { LocalStorageUserProfileService, WebAPIUserProfileService, RandomUserCountService, OnlineUserCountService }; -------------------------------------------------------------------------------- /client/src/ui/error/app.css: -------------------------------------------------------------------------------- 1 | @charset 'utf-8'; 2 | 3 | .error-container { 4 | padding: 10px 10px; 5 | text-align: center; 6 | } 7 | 8 | .error-container h4 { 9 | margin-bottom: 0; 10 | } 11 | 12 | .error-container p { 13 | margin-bottom: 0; 14 | } 15 | 16 | .error-attributes { 17 | padding-top: 10px; 18 | padding-bottom: 10px; 19 | } -------------------------------------------------------------------------------- /client/src/ui/error/app.js: -------------------------------------------------------------------------------- 1 | import 'todomvc-app-css/index.css'; 2 | import 'todomvc-common/base.css'; 3 | import './app.css'; 4 | -------------------------------------------------------------------------------- /client/src/ui/feature-toggles.js: -------------------------------------------------------------------------------- 1 | class FeatureToggles { 2 | constructor(featureTogglesService) { 3 | this.featureTogglesService = featureTogglesService; 4 | this.subscribers = []; 5 | 6 | this.props = { 7 | auth: false, 8 | onlineUsersCounter: false, 9 | }; 10 | 11 | (async () => { 12 | try { 13 | this.props = await this.featureTogglesService.fetchAll(); 14 | this.notify(); 15 | } catch (error) { 16 | console.warn('Failed to fetch feature-toggles:', error); 17 | } 18 | })(); 19 | } 20 | 21 | subscribe(subscriber) { 22 | this.subscribers.push(subscriber); 23 | } 24 | 25 | async notify() { 26 | this.subscribers.forEach((subscriber) => subscriber.onChangedFeatureToggles(this.props)); 27 | } 28 | 29 | isAuth() { 30 | return this.props.auth; 31 | } 32 | 33 | isOnlineUsersCounter() { 34 | return this.props.onlineUsersCounter; 35 | } 36 | }; 37 | 38 | export { FeatureToggles }; -------------------------------------------------------------------------------- /client/src/ui/login/app.css: -------------------------------------------------------------------------------- 1 | @charset 'utf-8'; 2 | 3 | .login-form { 4 | padding: 30px 25px; 5 | } 6 | 7 | .login-form label { 8 | display: block; 9 | padding-left: 2px; 10 | margin-bottom: 7px; 11 | } 12 | 13 | .login-form .label-link { 14 | float: right; 15 | font-size: 12px; 16 | color: #0366d6; 17 | text-decoration: none; 18 | } 19 | 20 | .login-form input { 21 | display: block; 22 | width: 100%; 23 | margin-top: 5px; 24 | background-color: #fff; 25 | background-position: right 8px center; 26 | background-repeat: no-repeat; 27 | border: 1px solid #d1d5da; 28 | border-radius: 3px; 29 | color: #24292e; 30 | font-size: 14px; 31 | line-height: 20px; 32 | min-height: 24px; 33 | outline: none; 34 | padding: 6px 8px; 35 | } 36 | 37 | .login-form .form-group { 38 | margin-bottom: 15px; 39 | } 40 | 41 | .login-form .invalid-feedback { 42 | color: #dc3545; 43 | font-size: 0.8em; 44 | margin-top: .1rem; 45 | } 46 | 47 | .login-form .btn { 48 | margin-top: 20px; 49 | text-align: center; 50 | } 51 | 52 | .login-form .btn button { 53 | color: inherit; 54 | line-height: 20px; 55 | padding: 6px 8px; 56 | border: 1px solid transparent; 57 | border-radius: 3px; 58 | border-color: rgba(175, 47, 47, 0.2); 59 | width: 100%; 60 | } 61 | 62 | .login-form .btn button:hover, button:focus { 63 | cursor: pointer; 64 | border-color: rgba(175, 47, 47, 0.35); 65 | } 66 | 67 | .login-footer { 68 | padding-bottom: 10px; 69 | } 70 | 71 | .login-footer p { 72 | margin-top: 0px; 73 | text-align: center; 74 | font-size: 1.1em; 75 | } 76 | -------------------------------------------------------------------------------- /client/src/ui/login/app.js: -------------------------------------------------------------------------------- 1 | import { LocalStorageUserProfileService } from '../../application/user-service.js'; 2 | 3 | import 'todomvc-app-css/index.css'; 4 | import 'todomvc-common/base.css'; 5 | import './app.css'; 6 | 7 | /** 8 | * In the development environment, this simulate the login process. 9 | * In production environments, this should be handled on the server via a login form request. 10 | */ 11 | const mode = import.meta.env.MODE 12 | if (mode === 'development') { 13 | console.info('Running in development mode'); 14 | 15 | const userProfileService = LocalStorageUserProfileService(localStorage); 16 | const form = document.getElementsByClassName('login-form')[0]; 17 | const username = document.getElementById('username'); 18 | 19 | form.addEventListener('submit', function(event) { 20 | event.preventDefault(); 21 | 22 | const userProfile = { name: username.value, profilePictureUrl: '/profile-picture.png' }; 23 | userProfileService.set(userProfile); 24 | 25 | window.location.href = '/pages/todos.html'; 26 | }); 27 | } -------------------------------------------------------------------------------- /client/src/ui/todos/app.css: -------------------------------------------------------------------------------- 1 | @charset 'utf-8'; 2 | 3 | @import url('todomvc-app-css/index.css'); 4 | @import url('todomvc-common/base.css'); 5 | 6 | #snackbar { 7 | visibility: hidden; 8 | min-width: 480px; 9 | min-height: 30px; 10 | max-width: 90vw; 11 | background-color: #C44B4B; 12 | color: #fff; 13 | padding: 16px; 14 | position: fixed; 15 | z-index: 1; 16 | top: 35px; 17 | left: 50%; 18 | transform: translateX(-50%); 19 | border-radius: 2px; 20 | display: block; 21 | align-items: center; 22 | justify-content: center; 23 | overflow-wrap: break-word; 24 | white-space: pre-line; 25 | } 26 | 27 | #snackbar.show { 28 | visibility: visible; 29 | -webkit-animation: fadein 0.5s, fadeout 0.5s 2.5s; 30 | animation: fadein 0.5s, fadeout 0.5s 2.5s; 31 | } 32 | 33 | @-webkit-keyframes fadein { 34 | from { top: 0; opacity: 0; } 35 | to { top: 35px; opacity: 1; } 36 | } 37 | 38 | @keyframes fadein { 39 | from { top: 0; opacity: 0; } 40 | to { top: 35px; opacity: 1; } 41 | } 42 | 43 | @-webkit-keyframes fadeout { 44 | from { top: 35px; opacity: 1; } 45 | to { top: 0; opacity: 0; } 46 | } 47 | 48 | @keyframes fadeout { 49 | from { top: 35px; opacity: 1; } 50 | to { top: 0; opacity: 0; } 51 | } 52 | 53 | .user-session-container { 54 | display: none; 55 | padding: 6px 8px; 56 | background: rgba(0, 0, 0, 0.003); 57 | -webkit-box-shadow: 0 2px 1px 0 rgba(0, 0, 0, 0.03); 58 | box-shadow: 0 2px 1px 0 rgba(0, 0, 0, 0.03); 59 | border-bottom: 1px solid #e6e6e6; 60 | position: relative; 61 | } 62 | 63 | .login-guide { 64 | text-align: center; 65 | font-size: 1.1em; 66 | } 67 | 68 | .login-guide a { 69 | color: rgba(175, 47, 47, 0.58); 70 | } 71 | 72 | .user-profile p { 73 | font-size: 1.1em; 74 | margin-left: 55px; 75 | } 76 | 77 | .user-profile img { 78 | position: absolute; 79 | margin-top: -10px; 80 | margin-left: 5px; 81 | width: 40px; 82 | height: 40px; 83 | border-radius: 50%; 84 | object-fit: cover; 85 | object-position: center right; 86 | } 87 | 88 | .user-profile .settings { 89 | position: absolute; 90 | right: 0; 91 | top: 0; 92 | padding-top: 3px; 93 | padding-right: 8px; 94 | } 95 | 96 | .user-profile .settings button { 97 | border: none; 98 | cursor: pointer; 99 | } 100 | 101 | .user-profile .settings .buttons { 102 | display: none; 103 | position: absolute; 104 | right: 0; 105 | margin-right: 6px; 106 | padding: 4px 4px; 107 | min-width: 150px; 108 | background-color: #ffffff; 109 | border: 1px solid #e6e6e6; 110 | text-align: right; 111 | z-index: 1; 112 | } 113 | 114 | .user-profile .settings:hover .buttons { 115 | display: block; 116 | } 117 | 118 | .user-profile .settings .buttons a { 119 | color: #4d4d4d; 120 | font-size: 0.9em; 121 | line-height: 18px; 122 | text-decoration: none; 123 | display: block; 124 | padding: 2px 4px; 125 | } 126 | 127 | .user-profile .settings .buttons a:hover { 128 | font-weight: 400; 129 | } 130 | 131 | .footer { 132 | height: 52px; 133 | } 134 | 135 | .footer>div:last-child { 136 | margin-top: 26px; 137 | border-top: 1px dotted #f1f1f1; 138 | } 139 | 140 | .user-count { 141 | margin-top: 6px; 142 | float: left; 143 | text-align: left; 144 | } 145 | 146 | .user-count strong { 147 | font-weight: 300; 148 | } 149 | 150 | .download-todos:hover { 151 | text-decoration: underline; 152 | } 153 | 154 | .download-todos, html .download-todos:active { 155 | margin-top: 6px; 156 | float: right; 157 | position: relative; 158 | line-height: 19px; 159 | text-decoration: none; 160 | cursor: pointer; 161 | } 162 | 163 | @media (max-width: 430px) { 164 | .footer { 165 | height: 84px; 166 | } 167 | 168 | .footer>div:last-child { 169 | margin-top: 24px; 170 | } 171 | 172 | .filters { 173 | bottom: 10px; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /client/src/ui/todos/app.js: -------------------------------------------------------------------------------- 1 | import { LocalStorageUserProfileService, WebAPIUserProfileService, RandomUserCountService, OnlineUserCountService } from '../../application/user-service.js'; 2 | import { LocalStorageTodosService, WebAPITodosService } from '../../application/todo-service.js'; 3 | import { UserSession, UserSessionView } from './user-session.js'; 4 | import { UserCount, UserCountView } from './user-count.js'; 5 | import { Todos, TodosView } from './todos.js'; 6 | import TodosController from './controller.js'; 7 | 8 | import { FeatureToggles } from '../feature-toggles.js'; 9 | import { QueryStringFeatureTogglesService, WebAPIFeatureTogglesService } from '../../application/feature-toggles-service.js'; 10 | 11 | const isDevelopmentMode = import.meta.env.MODE === 'development' 12 | 13 | const featureToggles = new FeatureToggles(isDevelopmentMode ? QueryStringFeatureTogglesService() : WebAPIFeatureTogglesService()); 14 | 15 | const userSessionView = new UserSessionView(); 16 | const userCountView = new UserCountView(); 17 | const todosView = new TodosView(); 18 | 19 | const userSession = new UserSession(isDevelopmentMode ? LocalStorageUserProfileService(localStorage) : WebAPIUserProfileService()); 20 | userSession.subscribe(userSessionView); 21 | 22 | const userCount = new UserCount(isDevelopmentMode ? RandomUserCountService() : OnlineUserCountService()); 23 | userCount.subscribe(userCountView); 24 | 25 | const todos = new Todos(isDevelopmentMode ? LocalStorageTodosService(localStorage) : WebAPITodosService()); 26 | todos.subscribe(todosView); 27 | 28 | featureToggles.subscribe(userSessionView); 29 | featureToggles.subscribe(userCount); 30 | featureToggles.subscribe(userCountView); 31 | featureToggles.notify(); 32 | 33 | const todosControler = new TodosController( 34 | { 35 | downloadUrl: isDevelopmentMode ? null : '/todos', 36 | loginUrl: isDevelopmentMode ? '/pages/login.html' : '/login', 37 | logoutUrl: isDevelopmentMode ? null : '/logout', 38 | logoutSuccessUrl: isDevelopmentMode ? '/pages/login.html' : null, 39 | }, userSession, todos); 40 | todosControler.bindTodosViewCallbacks(todosView); 41 | todosControler.bindUserSessionViewCallbacks(userSessionView); -------------------------------------------------------------------------------- /client/src/ui/todos/component.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_ERROR_MESSAGE = 'An unexpected system error occurred. Please try again later.'; 2 | const DEFAULT_AUTO_HIDE_DURATION = 3000; 3 | 4 | const Snackbar = () => { 5 | const snackbar = document.getElementById('snackbar'); 6 | 7 | return { 8 | show: (error, autoHideDuration = DEFAULT_AUTO_HIDE_DURATION) => { 9 | if (error instanceof Error) { 10 | const message = error.message || DEFAULT_ERROR_MESSAGE; 11 | const details = error.details || []; 12 | 13 | if (details && details.length > 0) { 14 | const formattedDetails = details.map(it => `
${it}
`).join(''); 15 | snackbar.innerHTML = `${message}\n${formattedDetails}`; 16 | } else { 17 | snackbar.textContent = message; 18 | } 19 | } else if (typeof error === 'string') { 20 | snackbar.textContent = error; 21 | } else { 22 | snackbar.textContent = DEFAULT_ERROR_MESSAGE; 23 | console.warn(`Warning: The '${error}' is unprocessable`); 24 | } 25 | 26 | snackbar.classList.add('show'); 27 | setTimeout(() => snackbar.classList.remove('show'), autoHideDuration); 28 | } 29 | }; 30 | } 31 | 32 | export { Snackbar }; -------------------------------------------------------------------------------- /client/src/ui/todos/controller.js: -------------------------------------------------------------------------------- 1 | export default class TodosController { 2 | constructor(props, userSession, todos) { 3 | this.props = props || {}; 4 | this.userSession = userSession; 5 | this.todos = todos; 6 | } 7 | 8 | bindTodosViewCallbacks(todosView) { 9 | const onChangedTodos = todosView.onChangedTodos.bind(todosView); 10 | const onError = todosView.onError.bind(todosView); 11 | 12 | todosView.onToggleAll = (completed) => { 13 | this.todos.toggleAll(completed).catch(onError) 14 | }; 15 | 16 | todosView.onCreateTodo = (text) => { 17 | this.todos.add(text).catch(onError); 18 | }; 19 | 20 | todosView.onUpdateTodo = (todoId, text, completed) => { 21 | if (todoId == null || todoId.trim() === '') { 22 | console.error('Update todo: todoId cannot be null, undefined, or empty'); 23 | throw new Error('Please enter an ID to update a todo'); 24 | } 25 | 26 | this.todos.edit(todoId, text, completed).catch(onError); 27 | }; 28 | 29 | todosView.onDeleteTodo = (todoId) => { 30 | if (todoId == null || todoId.trim() === '') { 31 | console.error('Delete todo: todoId cannot be null, undefined, or empty'); 32 | throw new Error('Please enter an ID to delete a todo'); 33 | } 34 | 35 | this.todos.remove(todoId).catch(onError); 36 | }; 37 | 38 | todosView.onFilterTodos = (filter) => { 39 | this.todos.refresh(filter).catch(onError); 40 | } 41 | 42 | todosView.onClearCompletedTodos = () => { 43 | this.todos.clearCompleted().catch(onError); 44 | }; 45 | 46 | todosView.onDownloadTodos = (outputStream) => { 47 | if (this.props.downloadUrl) { 48 | outputStream(this.props.downloadUrl); 49 | return; 50 | } 51 | 52 | this.todos.all().then(todos => { 53 | const headers = 'id,text,completed,createdAt\n'; 54 | const rows = todos.map(todo => { 55 | const createdAt = new Date(todo.createdAt).toString(); 56 | return `${todo.id},"${todo.text}",${todo.completed},${createdAt}` 57 | }).join('\n'); 58 | 59 | outputStream(headers + rows, 'text/csv;charset=utf-8;', 'todos.csv'); 60 | }); 61 | }; 62 | 63 | this.todos.refresh().catch(error => { 64 | onChangedTodos([]); 65 | onError(error); 66 | }); 67 | } 68 | 69 | bindUserSessionViewCallbacks(userSessionView) { 70 | const onError = userSessionView.onError.bind(userSessionView); 71 | 72 | userSessionView.onLogin = () => { 73 | if (!this.props.loginUrl) { 74 | userSessionView.onError('Login is not supported'); 75 | console.warn('Warning: login-url is not defined'); 76 | return; 77 | } 78 | 79 | document.location.href = this.props.loginUrl; 80 | }; 81 | 82 | userSessionView.onUpdateProfilePicture = (profilePicture) => { 83 | this.userSession.updateProfilePicture(profilePicture).catch(onError); 84 | }; 85 | 86 | userSessionView.onLogout = () => { 87 | if (this.props.logoutUrl) { 88 | document.location.href = this.props.logoutUrl; 89 | } else { 90 | this.userSession.logout().then(() => { 91 | if (!this.props.logoutSuccessUrl) { 92 | console.warn('Warning: logout-success-url is not defined'); 93 | return; 94 | } 95 | 96 | document.location.href = this.props.logoutSuccessUrl; 97 | }); 98 | } 99 | }; 100 | } 101 | }; -------------------------------------------------------------------------------- /client/src/ui/todos/todos.js: -------------------------------------------------------------------------------- 1 | import { Snackbar } from './component.js' 2 | 3 | const ENTER_KEY = 'Enter'; 4 | const ESCAPE_KEY = 'Escape'; 5 | 6 | class Todos { 7 | constructor(todoService) { 8 | this.todoService = todoService; 9 | this.subscribers = []; 10 | 11 | this.data = []; 12 | this.filter = 'all'; 13 | } 14 | 15 | subscribe(subscriber) { 16 | this.subscribers.push(subscriber); 17 | } 18 | 19 | notify() { 20 | this.subscribers.forEach((subscriber) => subscriber.onChangedTodos(this.data)); 21 | } 22 | 23 | async refresh(filter = 'all') { 24 | const todos = await this.todoService.all(); 25 | 26 | this.data = todos.filter(todo => 27 | filter === 'all' || 28 | (filter === 'active' && !todo.completed) || 29 | (filter === 'completed' && todo.completed) 30 | ); 31 | this.filter = filter; 32 | 33 | this.notify(); 34 | } 35 | 36 | async toggleAll(completed) { 37 | const todos = await this.todoService.all(); 38 | const tasks = todos.map(todo => this.todoService.edit({ ...todo, completed })); 39 | 40 | return Promise.all(tasks).then(() => this.refresh(this.filter)); 41 | } 42 | 43 | add(text) { 44 | const todo = { id: Date.now().toString(), text, completed: false, createdAt: Date.now(), updatedAt: null }; 45 | 46 | return this.todoService.add(todo).then(() => this.refresh(this.filter)); 47 | } 48 | 49 | edit(todoId, text, completed) { 50 | const updatedTodo = { id: todoId, text, completed, updatedAt: Date.now() }; 51 | 52 | return this.todoService.edit(updatedTodo).then(() => this.refresh(this.filter)); 53 | } 54 | 55 | remove(todoId) { 56 | return this.todoService.remove(todoId).then(() => this.refresh(this.filter)); 57 | } 58 | 59 | clearCompleted() { 60 | return this.todoService.clearCompleted().then(() => this.refresh(this.filter)); 61 | } 62 | 63 | all() { 64 | return this.todoService.all(); 65 | } 66 | }; 67 | 68 | class TodosView { 69 | constructor() { 70 | this.snackbar = Snackbar(); 71 | 72 | this.newTodoInput = document.querySelector('.new-todo'); 73 | this.toggleAllCheckbox = document.querySelector('.toggle-all'); 74 | this.toggleAll = document.querySelector('.toggle-all-label'); 75 | this.todoList = document.querySelector('.todo-list'); 76 | this.todoCount = document.querySelector('.todo-count strong'); 77 | this.filters = document.querySelector('.filters'); 78 | this.clearCompletedButton = document.querySelector('.clear-completed'); 79 | this.downloadTodosButton = document.querySelector('.download-todos'); 80 | 81 | this.onToggleAll = null; 82 | this.onCreateTodo = null; 83 | this.onUpdateTodo = null; 84 | this.onDeleteTodo = null; 85 | this.onFilterTodos = null; 86 | this.onClearCompletedTodos = null; 87 | this.onDownloadTodos = null; 88 | 89 | this.attachEventListeners(); 90 | } 91 | 92 | attachEventListeners() { 93 | this.toggleAll.addEventListener('click', event => { 94 | if (!this.onToggleAll) { 95 | console.warn('Warning: onToggleAll handler is not defined'); 96 | return; 97 | } 98 | 99 | this.toggleAllCheckbox.checked = !this.toggleAllCheckbox.checked; 100 | this.onToggleAll(this.toggleAllCheckbox.checked); 101 | }); 102 | 103 | this.newTodoInput.addEventListener('keypress', event => { 104 | if (!this.onCreateTodo) { 105 | console.warn('Warning: onCreateTodo handler is not defined'); 106 | return; 107 | } 108 | 109 | if (event.key === ENTER_KEY && this.newTodoInput.value.trim() !== '') { 110 | this.onCreateTodo(this.newTodoInput.value.trim()); 111 | this.newTodoInput.value = ''; 112 | } 113 | }); 114 | 115 | this.todoList.addEventListener('click', event => { 116 | if (!this.onDeleteTodo) { 117 | console.warn('Warning: onDeleteTodo handler is not defined'); 118 | return; 119 | } 120 | if (!this.onUpdateTodo) { 121 | console.warn('Warning: onUpdateTodo handler is not defined'); 122 | return; 123 | } 124 | 125 | const target = event.target; 126 | const todoItem = target.closest('li'); 127 | if (!todoItem) return; 128 | 129 | const id = todoItem.dataset.id; 130 | if (target.classList.contains('destroy')) { 131 | this.onDeleteTodo(id); 132 | } else if (target.classList.contains('toggle')) { 133 | const label = todoItem.querySelector('label'); 134 | const checkbox = todoItem.querySelector('.toggle'); 135 | this.onUpdateTodo(id, label.textContent, checkbox.checked); 136 | } 137 | }); 138 | this.todoList.addEventListener('dblclick', event => { 139 | if (!this.onUpdateTodo) { 140 | console.warn('Warning: onUpdateTodo handler is not defined'); 141 | return; 142 | } 143 | 144 | const target = event.target; 145 | const todoItem = target.closest('li'); 146 | if (!todoItem) return; 147 | 148 | if (target.tagName === 'LABEL') { 149 | const id = todoItem.dataset.id; 150 | const label = todoItem.querySelector('label'); 151 | const checkbox = todoItem.querySelector('.toggle'); 152 | const className = todoItem.className; 153 | 154 | const input = document.createElement('input'); 155 | input.className = 'edit'; 156 | input.value = label.textContent; 157 | 158 | todoItem.className = `${className} editing`; 159 | todoItem.appendChild(input); 160 | input.focus(); 161 | 162 | input.addEventListener('blur', () => { 163 | todoItem.className = className; 164 | todoItem.removeChild(input); 165 | }); 166 | input.addEventListener('keyup', event => { 167 | if (event.key === ESCAPE_KEY) { 168 | todoItem.className = className; 169 | todoItem.removeChild(input); 170 | } 171 | }); 172 | input.addEventListener('keypress', event => { 173 | if (event.key === ENTER_KEY) { 174 | this.onUpdateTodo(id, input.value, checkbox.checked); 175 | } 176 | }); 177 | } 178 | }); 179 | 180 | this.filters.addEventListener('click', (event) => { 181 | if (event.target.tagName === 'A') { 182 | const selectedFilter = event.target.getAttribute('href').slice(2); 183 | const filters = document.querySelectorAll('.filters li a'); 184 | filters.forEach(filter => { 185 | if (filter.getAttribute('href') === `#/${selectedFilter}`) { 186 | filter.classList.add('selected'); 187 | this.filter = filter.textContent.toLowerCase(); 188 | } else { 189 | filter.classList.remove('selected'); 190 | } 191 | }); 192 | this.onFilterTodos(this.filter); 193 | } 194 | }); 195 | 196 | this.clearCompletedButton.addEventListener('click', (event) => { 197 | if (!this.onClearCompletedTodos) { 198 | console.warn('Warning: onClearCompletedTodos handler is not defined'); 199 | return; 200 | } 201 | 202 | this.onClearCompletedTodos(); 203 | }); 204 | 205 | this.downloadTodosButton.addEventListener('click', (event) => { 206 | if (!this.onDownloadTodos) { 207 | console.warn('Warning: onDownloadTodos handler is not defined'); 208 | return; 209 | } 210 | 211 | const save = (blob, fileName) => { 212 | const url = URL.createObjectURL(blob); 213 | const link = document.createElement('a'); 214 | link.setAttribute('href', url); 215 | link.setAttribute('download', fileName); 216 | link.style.visibility = 'hidden'; 217 | document.body.appendChild(link); 218 | link.click(); 219 | document.body.removeChild(link); 220 | } 221 | 222 | this.onDownloadTodos((...args) => { 223 | if (args.length === 3) { 224 | const [content, contentType, fileName] = args; 225 | save(new Blob([content], { type: contentType }), fileName); 226 | } else if (args.length === 1) { 227 | const [url] = args; 228 | if (typeof url === 'string' && url.startsWith('http')) { 229 | console.error('Warning: Invalid download url'); 230 | return; 231 | } 232 | 233 | fetch(url, { headers: { 'Accept': 'text/csv' }}).then(response => { 234 | let fileName = 'todos.csv'; 235 | 236 | const contentDisposition = response.headers.get('Content-Disposition'); 237 | if (contentDisposition) { 238 | const matches = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?/i); 239 | if (matches && matches[1]) { 240 | fileName = matches[1]; 241 | } 242 | } 243 | 244 | return response.blob().then(blob => ({ blob, fileName })); 245 | }).then(({ blob, fileName }) => save(blob, fileName)).catch(this.onError.bind(this)); 246 | } else { 247 | console.error('Warning: Unable to resolve download todos'); 248 | } 249 | }); 250 | }); 251 | } 252 | 253 | onChangedTodos(todos) { 254 | const completedCount = todos.filter(todo => todo.completed).length; 255 | 256 | this.todoList.innerHTML = todos.map(todo => 257 | `
  • 258 |
    259 | 260 | 261 | 262 |
    263 |
  • ` 264 | ).join(''); 265 | this.todoCount.textContent = todos.length; 266 | this.clearCompletedButton.style.display = completedCount > 0 ? 'block' : 'none'; 267 | } 268 | 269 | onError(error) { 270 | this.snackbar.show(error); 271 | } 272 | }; 273 | 274 | export { Todos, TodosView }; -------------------------------------------------------------------------------- /client/src/ui/todos/user-count.js: -------------------------------------------------------------------------------- 1 | class UserCount { 2 | constructor(userCountService) { 3 | this.userCountService = userCountService; 4 | this.subscribers = []; 5 | 6 | this.userCount = 1; 7 | } 8 | 9 | subscribe(subscriber) { 10 | this.subscribers.push(subscriber); 11 | } 12 | 13 | async notify() { 14 | this.subscribers.forEach((subscriber) => subscriber.onChangedUserCount(this.userCount)); 15 | } 16 | 17 | onChangedFeatureToggles(featureToggles) { 18 | if (featureToggles.onlineUsersCounter) { 19 | this.userCountService.connect(userCount => { 20 | this.userCount = userCount; 21 | this.notify(); 22 | }); 23 | } else { 24 | this.userCountService.disconnect(); 25 | } 26 | } 27 | }; 28 | 29 | class UserCountView { 30 | constructor() { 31 | this.userCountContainer = document.querySelector('.user-count'); 32 | this.userCount = document.querySelector('.user-count strong'); 33 | } 34 | 35 | onChangedFeatureToggles(featureToggles) { 36 | this.userCountContainer.style.display = featureToggles.onlineUsersCounter ? 'block' : 'none'; 37 | } 38 | 39 | onChangedUserCount(count) { 40 | this.userCount.textContent = count; 41 | } 42 | } 43 | 44 | export { UserCount, UserCountView }; -------------------------------------------------------------------------------- /client/src/ui/todos/user-session.js: -------------------------------------------------------------------------------- 1 | import { Snackbar } from './component.js' 2 | 3 | class UserSession { 4 | constructor(userProfileService) { 5 | this.userProfileService = userProfileService; 6 | this.subscribers = []; 7 | 8 | this.props = { 9 | userProfile: { name: 'Guest', profilePictureUrl: '/profile-picture.png' } 10 | }; 11 | 12 | this.refresh(); 13 | } 14 | 15 | subscribe(subscriber) { 16 | this.subscribers.push(subscriber); 17 | } 18 | 19 | async notify() { 20 | this.subscribers.forEach((subscriber) => subscriber.onChangedUserSession(this.props)); 21 | } 22 | 23 | async refresh() { 24 | this.props = { 25 | userProfile: await this.userProfileService.get() 26 | } 27 | this.notify(); 28 | } 29 | 30 | async updateProfilePicture(profilePicture) { 31 | this.props = { 32 | userProfile: await this.userProfileService.updateProfilePicture(profilePicture) 33 | } 34 | this.notify(); 35 | } 36 | 37 | async logout() { 38 | this.props = { 39 | userProfile: await this.userProfileService.clear() 40 | } 41 | this.notify(); 42 | } 43 | }; 44 | 45 | class UserSessionView { 46 | constructor() { 47 | this.snackbar = Snackbar(); 48 | 49 | this.userSessionContainer = document.querySelector('.user-session-container'); 50 | this.loginGuide = document.querySelector('.login-guide'); 51 | this.loginLink = document.querySelector('.login-guide a'); 52 | this.userProfile = document.querySelector('.user-profile'); 53 | this.username = document.querySelector('.user-profile strong'); 54 | this.userProfilePicture = document.querySelector('.user-profile img'); 55 | this.updateProfilePictureLink = document.querySelector('.update-profile-picture-link'); 56 | this.logoutLink = document.querySelector('.logout-link'); 57 | 58 | this.onLogin = null; 59 | this.onUpdateProfilePicture = null; 60 | this.onLogout = null; 61 | 62 | this.attachEventListeners(); 63 | } 64 | 65 | attachEventListeners() { 66 | this.loginLink.addEventListener('click', event => { 67 | if (!this.onLogin) { 68 | console.warn('Warning: onLogin handler is not defined'); 69 | return; 70 | } 71 | 72 | this.onLogin(); 73 | }); 74 | 75 | this.updateProfilePictureLink.addEventListener('click', event => { 76 | if (!this.onUpdateProfilePicture) { 77 | console.warn('Warning: onUpdateProfilePicture handler is not defined'); 78 | return; 79 | } 80 | 81 | const fileInput = document.createElement('input'); 82 | fileInput.type = 'file'; 83 | fileInput.accept = 'image/*'; 84 | fileInput.style.display = 'none'; 85 | 86 | document.body.appendChild(fileInput); 87 | fileInput.click(); 88 | 89 | fileInput.addEventListener('change', () => { 90 | if (fileInput.files.length > 0) { 91 | const file = fileInput.files[0]; 92 | this.onUpdateProfilePicture(file); 93 | } 94 | document.body.removeChild(fileInput); 95 | }); 96 | }); 97 | 98 | this.logoutLink.addEventListener('click', event => { 99 | if (!this.onLogout) { 100 | console.warn('Warning: onLogout handler is not defined'); 101 | return; 102 | } 103 | 104 | this.onLogout(); 105 | }); 106 | } 107 | 108 | onChangedFeatureToggles(featureToggles) { 109 | this.userSessionContainer.style.display = featureToggles.auth ? 'block' : 'none'; 110 | } 111 | 112 | onChangedUserSession(userSession) { 113 | const { userProfile } = userSession || {}; 114 | if (userProfile) { 115 | this.loginGuide.style.display = 'none'; 116 | this.userProfile.style.display = 'block'; 117 | this.username.textContent = userProfile.name ?? 'Guest'; 118 | this.userProfilePicture.src = userProfile.profilePictureUrl ?? ''; 119 | } else { 120 | this.loginGuide.style.display = 'block'; 121 | this.userProfile.style.display = 'none'; 122 | } 123 | } 124 | 125 | onError(error) { 126 | this.snackbar.show(error); 127 | } 128 | } 129 | 130 | export { UserSession, UserSessionView }; -------------------------------------------------------------------------------- /client/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | #app { 39 | max-width: 1024px; 40 | margin: 0 auto; 41 | padding: 2rem; 42 | text-align: center; 43 | } 44 | 45 | @media (prefers-color-scheme: light) { 46 | :root { 47 | color: #213547; 48 | background-color: #ffffff; 49 | } 50 | a:hover { 51 | color: #747bff; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { resolve } from 'path' 3 | 4 | export default defineConfig({ 5 | build: { 6 | target: 'modules', 7 | minify: false, 8 | rollupOptions: { 9 | input: { 10 | main: resolve(__dirname, 'index.html'), 11 | todos: resolve(__dirname, 'pages/todos.html'), 12 | login: resolve(__dirname, 'pages/login.html'), 13 | error: resolve(__dirname, 'pages/error.html'), 14 | }, 15 | } 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | todoapp: 3 | build: 4 | context: ./ 5 | dockerfile: Dockerfile 6 | depends_on: 7 | - h2database 8 | deploy: 9 | replicas: 1 10 | env_file: 11 | - .env 12 | networks: 13 | - springrunner 14 | ports: 15 | - "50080:8080" 16 | h2database: 17 | image: oscarfonts/h2:latest 18 | environment: 19 | H2_OPTIONS: -ifNotExists 20 | networks: 21 | - springrunner 22 | ports: 23 | - "50081:81" 24 | volumes: 25 | - ./.docker/h2database:/opt/h2-data 26 | networks: 27 | springrunner: -------------------------------------------------------------------------------- /server/.README/todoapp-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springrunner/mastering-spring-web-101/6336c2d8398942ac55d9925126abc80d1cdcba18/server/.README/todoapp-architecture.png -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | ### Profile ### 2 | files/ 3 | 4 | ### Gradle ### 5 | .gradle 6 | build/ 7 | 8 | ### IntelliJ IDEA ### 9 | .idea 10 | out/ 11 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Todoapp Server-side 2 | 3 | > 이 프로젝트는 [Spring MVC](https://docs.spring.io/spring-framework/docs/current/reference/html/web.html) 4 | > 와 [Spring Boot](https://spring.io/projects/spring-boot)로 개발된 웹 애플리케이션 서버입니다. 5 | 6 | Todoapp 웹 애플리케이션 서버는 다음과 같은 엔드포인트(endpoint)로 구성되어있습니다. 7 | 8 | ### 할일 관리 페이지 컨트롤러 9 | 10 | * `/todos`: 할일 목록 페이지를 반환합니다. 11 | * `/todos Accept: text/csv`: 할일 목록을 CSV 형식으로 반환합니다. 12 | 13 | ### 로그인 및 사용자 프로필 페이지 컨트롤러 14 | 15 | * `GET /login`: 로그인 페이지를 반환합니다. 현재 로그인된 사용자가 있다면 `/todos`로 리다이렉트합니다. 16 | * `POST /login`: 사용자 이름과 비밀번호를 입력받아 사용자 로그인을 처리합니다. 17 | * `/logout`: 현재 로그인된 사용자를 로그아웃을 처리하고 `/todos`로 리다이렉트합니다. 18 | * `/user/profile-picture`: 현재 로그인된 사용자 프로필 이미지 리소스를 반환합니다. 19 | 20 | ### 할일 Web API 21 | 22 | * `GET /api/todos`: 할일 목록을 반환합니다. 23 | * `POST /api/todos`: 새로운 할일 항목을 추가합니다. 24 | * `PUT /api/todos/{id}`: 기존 할일 항목을 수정합니다. 25 | * `DELETE /api/todos/{id}`: 할일 항목을 삭제합니다. 26 | 27 | ### 사용자 프로필 Web API 28 | 29 | * `GET /api/user/profile`: 현재 로그인된 사용자의 프로필 정보를 반환합니다. 로그인되지 않은 경우 401 Unauthorized 상태를 반환합니다. 30 | * `POST /api/user/profile-picture`: 현재 로그인된 사용자 프로필 이미지를 변경합니다. 31 | 32 | ### 기타 Web API 33 | 34 | * `GET /api/feature-toggles`: 현재 활성화된 기능 토글 정보를 반환합니다. 35 | * `GET /stream/online-users-counter`: 현재 접속된 사용자 수를 이벤트 스트림으로 전송합니다. 36 | - 이벤트 스트림은 [Server-sent events](https://en.wikipedia.org/wiki/Server-sent_events) 명세를 구현되었습니다. 37 | 38 | ## I. 프로젝트 구성 39 | 40 | 이 프로젝트는 자바와 스프링 MVC, 그리고 스프링 부트를 사용해 개발하고, 그레이들(Gradle)로 관리하고 있습니다. 스프링 MVC라는 견고한 웹 프레임워크 기반 위에, 스프링 부트의 자동 구성 및 내장 서버 41 | 기능을 통해 개발 생산성을 극대화했습니다. 특히, 그레이들의 유연한 빌드 시스템과 의존성 관리는 복잡한 설정을 최소화하고, 빠르고 효율적인 개발 환경을 제공합니다. 42 | 43 | > [Spring MVC](https://docs.spring.io/spring-framework/docs/current/reference/html/web.html)는 44 | > 오랜 기간 동안 수많은 프로젝트에서 검증된 웹 프레임워크로, 45 | > Model-View-Controller (MVC) 패턴을 기반으로 애플리케이션의 구조를 명확하게 분리하여 유지보수성과 확장성을 높입니다. 46 | > 또한, 다양한 기능을 제공하여 개발자가 비즈니스 로직에 집중할 수 있도록 돕습니다. 47 | > 48 | > [Spring Boot](https://spring.io/projects/spring-boot)는 스프링 기반 애플리케이션 개발을 위한 편리한 도구입니다. 49 | > 자동 구성, 내장 웹 서버, 간편한 의존성 관리 등 다양한 기능을 제공하여 개발자가 복잡한 설정에 시간을 낭비하지 않고 핵심 기능 구현에 집중할 수 있도록 돕습니다. 50 | > 51 | > [Gradle](https://gradle.org)은 빌드 도구로, 유연한 설정과 강력한 기능을 제공합니다. 52 | > 특히, 스프링 부트와의 뛰어난 호환성을 통해 빌드 및 배포 과정을 간소화하고 자동화할 수 있습니다. 53 | 54 | ### 애플리케이션 아키텍처 55 | 56 | 서버 사이드 애플리케이션은 클린 아키텍처(Clean Architecture)를 기반로 설계되기를 기대합니다. 57 | 클린 아키텍처가 정의하는 다양한 요소가 있지만, 본 프로젝트에서는 외부(adapters)에서 내부(core)로 들어가는 의존성 방향과 영역별 역할만 중요하게 보도록 하겠습니다. 58 | 59 |

    60 | 61 |

    62 | 63 | * 애플리케이션 핵심 로직이 담긴 내부(core)는 애플리케이션과 도메인 영역으로 구성되었습니다. 64 | - 도메인(domain) 영역은 모델과 비즈니스 논리가 위치하며, 가능한 한 프레임워크나 외부 기술에 의존하지 않도록 작성합니다. 65 | - 애플리케이션(application) 영역은 도메인 로직을 활용해 구체적인 업무 처리를 수행하며, 오직 도메인 영역에만 의존해야 합니다. 66 | * 나머지(웹, 데이터, 보안 등)는 언제든 갈아끼울 수 있는 외부(adapters)로 두고, 핵심 로직이 외부 기술 변경에 영향을 받지 않도록 구성합니다. 67 | - 인터페이스 어댑터(interface adapters)는 내부(core)에 의존하며 구체적인 기술(Servlet, File, Database, Spring 등)을 사용해 내부로 연결합니다. 68 | - UI(user interface)는 사용자와 상호작용하는 클라이언트-사이드 영역으로, 브라우저를 통해 서버와 통신을 통해 서비스를 제공합니다. 69 | - DB(database)는 JDBC 드라이버로, 스토리지(disk, cloud)는 SDK로 인터페이스 어댑터에 연결됩니다. 70 | 71 | ### 디렉토리 구조 72 | 73 | ``` 74 | ├── src 75 | │ ├── main # 애플리케이션에서 사용할 소스 코드와 리소스 파일 76 | │ │ ├── java 77 | │ │ │ └── todoapp 78 | │ │ │ ├── core # 도메인 모델과 비즈니스 논리, 사용 사례(use cases)가 배치된 애플리케이션의 핵심 모듈 79 | │ │ │ ├── security # 보안(인증/인가)에 관련된 로직을 처리하기 위한 어댑터 모듈 80 | │ │ │ ├── data # 데이터 접근(DB 접근, 외부 API 연동 등)에 관련된 로직을 처리하기 위한 어댑터 모듈 81 | │ │ │ └── web # 웹 요청과 응답에 관련된 로직을 처리하기 위한 어댑터 모듈 82 | │ │ └── resources 83 | │ └── test # main 디렉토리에 작성된 애플리케이션의 테스트 코드와 리소스 파일 84 | │ ├── java 85 | │ └── resources 86 | ├── build.gradle # 이하 그레이들 빌드 스크립트 87 | └── settings.gradle 88 | ``` 89 | 90 | ### 의존성 관리 91 | 92 | 의존성 관리를 위해 [Gradle](https://gradle.org/)을 사용하며, 서버 개발에 사용된 의존성은 빌드 스크립트(`build.gradle`)에 선언되어 있습니다. 93 | 94 | ### 프로젝트 설정 95 | 96 | > 프로젝트 설정을 위해서 `Java 21`과 `Gradle`이 설치되어 있어야 합니다. 97 | > 익숙하지 않다면 스프링러너 [그레이들 프로젝트 구성하기](https://www.youtube.com/watch?v=x5lWmaSzPVQ) 테크톡을 통해 배울 수 있습니다. 98 | 99 | ``` 100 | ❯ git clone https://github.com/springrunner/mastering-spring-web-101.git 101 | ❯ cd mastering-spring-web-101/server 102 | ❯ ./gradlew clean build 103 | ``` 104 | 105 | ## II. 빌드 및 실행 방법 106 | 107 | 저장소를 복제하거나 압축 파일로 다운로드한 후 터미널에서 다음과 같은 방법으로 실행할 수 있습니다. 108 | 109 | ``` 110 | ❯ git clone https://github.com/springrunner/mastering-spring-web-101.git 111 | ❯ cd mastering-spring-web-101/server 112 | ❯ ./gradlew bootRun 113 | ``` 114 | 115 | # III. 참고자료 116 | 117 | * [The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 118 | -------------------------------------------------------------------------------- /server/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java' 3 | id 'org.springframework.boot' version '3.3.2' 4 | id 'io.spring.dependency-management' version '1.1.6' 5 | } 6 | 7 | repositories { 8 | mavenCentral() 9 | } 10 | 11 | dependencies { 12 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 13 | implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' 14 | implementation 'org.springframework.boot:spring-boot-starter-validation' 15 | implementation 'org.springframework.boot:spring-boot-starter-web' 16 | developmentOnly 'org.springframework.boot:spring-boot-devtools' 17 | runtimeOnly 'com.h2database:h2' 18 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 19 | testRuntimeOnly 'org.junit.platform:junit-platform-launcher' 20 | } 21 | 22 | java { 23 | toolchain { 24 | languageVersion = JavaLanguageVersion.of(21) 25 | } 26 | } 27 | 28 | tasks.named('test') { 29 | useJUnitPlatform() 30 | } 31 | -------------------------------------------------------------------------------- /server/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springrunner/mastering-spring-web-101/6336c2d8398942ac55d9925126abc80d1cdcba18/server/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /server/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /server/gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | org.gradle.wrapper.GradleWrapperMain \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /server/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /server/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'todoapp' 2 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/TodoApplication.java: -------------------------------------------------------------------------------- 1 | package todoapp; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | 7 | @SpringBootApplication 8 | @ConfigurationPropertiesScan 9 | public class TodoApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(TodoApplication.class, args); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/foundation/Constant.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.foundation; 2 | 3 | /** 4 | * @author springrunner.kr@gmail.com 5 | */ 6 | public final class Constant { 7 | 8 | public static final String PROFILE_DEVELOPMENT = "default"; 9 | public static final String PROFILE_PRODUCTION = "prod"; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/foundation/NotImplementedException.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.foundation; 2 | 3 | /** 4 | * 메서드 내부가 구현되지 않은 경우 발생할 수 있는 예외 클래스이다. 5 | * 6 | * @author springrunner.kr@gmail.com 7 | */ 8 | public class NotImplementedException extends SystemException { 9 | 10 | public NotImplementedException() { 11 | super("method is not yet implemented"); 12 | } 13 | 14 | public NotImplementedException(String format, Object[] args) { 15 | super(format, args); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/foundation/SystemException.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.foundation; 2 | 3 | import org.springframework.context.MessageSourceResolvable; 4 | 5 | /** 6 | * 시스템 운용 중 발생 가능한 최상위 예외 클래스이다. 7 | * 8 | * @author springrunner.kr@gmail.com 9 | */ 10 | public class SystemException extends RuntimeException implements MessageSourceResolvable { 11 | 12 | public SystemException(String format, Object... args) { 13 | super(String.format(format, args)); 14 | } 15 | 16 | public SystemException(Throwable cause) { 17 | super(cause); 18 | } 19 | 20 | public SystemException(String message, Throwable cause) { 21 | super(message, cause); 22 | } 23 | 24 | @Override 25 | public String[] getCodes() { 26 | return new String[]{"Exception." + getClass().getSimpleName()}; 27 | } 28 | 29 | @Override 30 | public Object[] getArguments() { 31 | return new Object[0]; 32 | } 33 | 34 | @Override 35 | public String getDefaultMessage() { 36 | return getMessage(); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/foundation/crypto/PasswordEncoder.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.foundation.crypto; 2 | 3 | /** 4 | * 비밀번호 암호화 인터페이스이다. 5 | * 6 | * @author springrunner.kr@gmail.com 7 | */ 8 | public interface PasswordEncoder { 9 | 10 | /** 11 | * 입력된 비밀번호를 암호화한다. 복호화 불가능한 방식으로 암호화 처리가 되어야 한다. 12 | * 13 | * @param password 비밀번호 14 | * @return 암호화된 비밀번호 15 | */ 16 | String encode(String password); 17 | 18 | } 19 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/foundation/crypto/support/NoOpPasswordEncoder.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.foundation.crypto.support; 2 | 3 | import todoapp.core.foundation.crypto.PasswordEncoder; 4 | 5 | /** 6 | * 아무 처리를 하지 않는 {@link PasswordEncoder} 구현체이다. 7 | * 8 | * @author springrunner.kr@gmail.com 9 | */ 10 | public class NoOpPasswordEncoder implements PasswordEncoder { 11 | 12 | @Override 13 | public String encode(String password) { 14 | return password; 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/foundation/crypto/support/SimplePasswordEncoder.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.foundation.crypto.support; 2 | 3 | import org.springframework.stereotype.Component; 4 | import org.springframework.util.DigestUtils; 5 | import todoapp.core.foundation.crypto.PasswordEncoder; 6 | 7 | import java.nio.charset.Charset; 8 | 9 | /** 10 | * {@link PasswordEncoder} 기본 구현체이다. 11 | * 12 | * @author springrunner.kr@gmail.com 13 | */ 14 | @Component 15 | public class SimplePasswordEncoder implements PasswordEncoder { 16 | 17 | @Override 18 | public String encode(String password) { 19 | return DigestUtils.md5DigestAsHex(password.getBytes(Charset.defaultCharset())); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/foundation/util/DigestUtils.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.foundation.util; 2 | 3 | import todoapp.core.foundation.SystemException; 4 | 5 | import java.nio.charset.StandardCharsets; 6 | import java.security.MessageDigest; 7 | import java.security.NoSuchAlgorithmException; 8 | 9 | /** 10 | * 해시 함수(hash function) 유틸리티 클래스이다. 11 | * 12 | * @author springrunner.kr@gmail.com 13 | */ 14 | public interface DigestUtils { 15 | 16 | /** 17 | * SHA-256 알고리즘으로 입력된 문자열을 해시 값을 생성한다. 18 | * 19 | * @param value 대상 문자열 20 | * @return 해시된 문자열 21 | */ 22 | static String sha256(String value) { 23 | try { 24 | var digest = MessageDigest.getInstance("SHA-256"); 25 | return new String(digest.digest(value.getBytes(StandardCharsets.UTF_8))); 26 | } catch (NoSuchAlgorithmException error) { 27 | throw new SystemException("SHA-256 algorithm not available", error); 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/foundation/util/StreamUtils.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.foundation.util; 2 | 3 | import java.util.Iterator; 4 | import java.util.Spliterator; 5 | import java.util.Spliterators; 6 | import java.util.stream.Stream; 7 | import java.util.stream.StreamSupport; 8 | 9 | /** 10 | * {@link Stream} 지원용 유틸리티 클래스이다. 11 | * 12 | * @author springrunner.kr@gmail.com 13 | */ 14 | public interface StreamUtils { 15 | 16 | /** 17 | * {@link Iterator}로 {@link Stream}을 생성한다. 18 | * 19 | * @param iterator null 값이 아닌 이터레이터 객체 20 | * @return 스트림 객체 21 | */ 22 | static Stream createStreamFromIterator(Iterator iterator) { 23 | var spliterator = Spliterators.spliteratorUnknownSize(iterator, Spliterator.NONNULL); 24 | return StreamSupport.stream(spliterator, false); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/shared/identifier/TodoId.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.shared.identifier; 2 | 3 | import jakarta.persistence.Embeddable; 4 | 5 | import java.io.Serializable; 6 | import java.util.Objects; 7 | 8 | /** 9 | * 할일 식별자(identifier) 10 | * 11 | * @author springrunner.kr@gmail.com 12 | */ 13 | @Embeddable 14 | public class TodoId implements Serializable { 15 | 16 | private String value; 17 | 18 | TodoId(String value) { 19 | if (value == null || value.isBlank()) { 20 | throw new IllegalArgumentException("todo-id is must not be null or empty"); 21 | } 22 | this.value = value; 23 | } 24 | 25 | // for hibernate 26 | @SuppressWarnings("unused") 27 | private TodoId() { 28 | } 29 | 30 | public static TodoId of(String value) { 31 | return new TodoId(value); 32 | } 33 | 34 | @Override 35 | public int hashCode() { 36 | return Objects.hash(value); 37 | } 38 | 39 | @Override 40 | public boolean equals(Object obj) { 41 | if (this == obj) 42 | return true; 43 | if (obj == null || getClass() != obj.getClass()) 44 | return false; 45 | return Objects.equals(value, ((TodoId) obj).value); 46 | } 47 | 48 | @Override 49 | public String toString() { 50 | return value; 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/shared/identifier/UserId.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.shared.identifier; 2 | 3 | import jakarta.persistence.Embeddable; 4 | 5 | import java.io.Serializable; 6 | import java.util.Objects; 7 | 8 | /** 9 | * 사용자 식별자(identifier) 10 | * 11 | * @author springrunner.kr@gmail.com 12 | */ 13 | @Embeddable 14 | public class UserId implements Serializable { 15 | 16 | private String value; 17 | 18 | UserId(String value) { 19 | if (value == null || value.isBlank()) { 20 | throw new IllegalArgumentException("user-id is must not be null or empty"); 21 | } 22 | this.value = value; 23 | } 24 | 25 | // for hibernate 26 | @SuppressWarnings("unused") 27 | private UserId() { 28 | } 29 | 30 | public static UserId of(String value) { 31 | return new UserId(value); 32 | } 33 | 34 | @Override 35 | public int hashCode() { 36 | return Objects.hash(value); 37 | } 38 | 39 | @Override 40 | public boolean equals(Object obj) { 41 | if (this == obj) 42 | return true; 43 | if (obj == null || getClass() != obj.getClass()) 44 | return false; 45 | return Objects.equals(value, ((UserId) obj).value); 46 | } 47 | 48 | @Override 49 | public String toString() { 50 | return value; 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/shared/util/Spreadsheet.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.shared.util; 2 | 3 | import todoapp.core.foundation.util.StreamUtils; 4 | 5 | import java.util.*; 6 | import java.util.stream.Collectors; 7 | 8 | /** 9 | * 스프레드시트 데이터 모델: 행과 열로 구성된 데이터 구조이다. 10 | * 11 | * @author springrunner.kr@gmail.com 12 | */ 13 | public class Spreadsheet { 14 | 15 | private final String name; 16 | private final Row header; 17 | private final List rows; 18 | 19 | public Spreadsheet(String name, Row header, List rows) { 20 | if (name == null || name.isBlank()) { 21 | throw new IllegalArgumentException("name is must not be null or empty"); 22 | } 23 | 24 | this.name = name; 25 | this.header = header; 26 | this.rows = rows; 27 | } 28 | 29 | public String getName() { 30 | return name; 31 | } 32 | 33 | public Optional getHeader() { 34 | return Optional.ofNullable(header); 35 | } 36 | 37 | public boolean hasHeader() { 38 | return Objects.nonNull(header); 39 | } 40 | 41 | public List getRows() { 42 | return rows; 43 | } 44 | 45 | public boolean hasRows() { 46 | return Objects.nonNull(rows) && !rows.isEmpty(); 47 | } 48 | 49 | @Override 50 | public String toString() { 51 | return "Spreadsheet [name=%s]".formatted(name); 52 | } 53 | 54 | /** 55 | * 주어진 맵(map) 객체에서 {@link Spreadsheet} 객체를 찾아 반환한다. 56 | * 57 | * @param map 맵 객체 58 | * @return 스프레드시트 객체 59 | * @throws IllegalArgumentException 값이 없거나, 두개 이상 발견되면 발생한다 60 | */ 61 | public static Spreadsheet obtainSpreadsheet(Map map) { 62 | var spreadsheets = map.values().stream().filter(it -> it instanceof Spreadsheet).toList(); 63 | if (spreadsheets.isEmpty()) { 64 | throw new IllegalArgumentException("spreadsheet object inside the map is required"); 65 | } 66 | if (spreadsheets.size() > 1) { 67 | throw new IllegalArgumentException("multiple spreadsheet objects were found"); 68 | } 69 | return (Spreadsheet) spreadsheets.getFirst(); 70 | } 71 | 72 | public static class Row { 73 | 74 | private final List> cells = new ArrayList<>(); 75 | 76 | public static Row of(Object... values) { 77 | var row = new Row(); 78 | for (Object value : values) { 79 | row.addCell(value); 80 | } 81 | return row; 82 | } 83 | 84 | public Row addCell(Cell cell) { 85 | this.cells.add(cell); 86 | return this; 87 | } 88 | 89 | public Row addCell(Object cellValue) { 90 | return this.addCell(new Cell<>(cellValue)); 91 | } 92 | 93 | public List> getCells() { 94 | return cells; 95 | } 96 | 97 | public String joining(CharSequence delimiter) { 98 | return StreamUtils 99 | .createStreamFromIterator(cells.iterator()) 100 | .map(Cell::value) 101 | .map(String::valueOf) 102 | .collect(Collectors.joining(delimiter)); 103 | } 104 | 105 | } 106 | 107 | public record Cell(T value) { 108 | 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/application/AddPersonalTodo.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.application; 2 | 3 | import todoapp.core.shared.identifier.TodoId; 4 | import todoapp.core.shared.identifier.UserId; 5 | import todoapp.core.todo.domain.TodoRegistrationRejectedException; 6 | 7 | /** 8 | * 개인화된 할일 등록 유스케이스이다. 9 | * 10 | * @author springrunner.kr@gmail.com 11 | */ 12 | public interface AddPersonalTodo extends AddTodo { 13 | 14 | /** 15 | * 사용자의 새로운 할일을 등록한다. 16 | * 17 | * @param owner 소유자 식별자 18 | * @param text 할일 19 | * @return 등록된 할일 식별자 20 | */ 21 | TodoId register(UserId owner, String text) throws TodoRegistrationRejectedException; 22 | 23 | } 24 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/application/AddTodo.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.application; 2 | 3 | import todoapp.core.shared.identifier.TodoId; 4 | import todoapp.core.todo.domain.TodoRegistrationRejectedException; 5 | 6 | /** 7 | * 새로운 할일을 등록하기 위한 유스케이스이다. 8 | * 9 | * @author springrunner.kr@gmail.com 10 | */ 11 | public interface AddTodo { 12 | 13 | /** 14 | * 새로운 할일을 등록한다. 15 | * 16 | * @param text 할일 17 | * @return 등록된 할일 식별자 18 | */ 19 | TodoId add(String text) throws TodoRegistrationRejectedException; 20 | 21 | } 22 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/application/DefaultTodoManager.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.application; 2 | 3 | import org.springframework.stereotype.Service; 4 | import org.springframework.transaction.annotation.Transactional; 5 | import todoapp.core.shared.identifier.TodoId; 6 | import todoapp.core.todo.domain.Todo; 7 | import todoapp.core.todo.domain.TodoIdGenerator; 8 | import todoapp.core.todo.domain.TodoNotFoundException; 9 | import todoapp.core.todo.domain.TodoRepository; 10 | 11 | import java.util.List; 12 | import java.util.Objects; 13 | 14 | /** 15 | * 할일 관리를 위한 유스케이스 구현체이다. 16 | * 17 | * @author springrunner.kr@gmail.com 18 | */ 19 | @Service 20 | @Transactional 21 | class DefaultTodoManager implements FindTodos, AddTodo, ModifyTodo, RemoveTodo { 22 | 23 | private final TodoIdGenerator todoIdGenerator; 24 | private final TodoRepository todoRepository; 25 | 26 | DefaultTodoManager(TodoIdGenerator todoIdGenerator, TodoRepository todoRepository) { 27 | this.todoIdGenerator = Objects.requireNonNull(todoIdGenerator); 28 | this.todoRepository = Objects.requireNonNull(todoRepository); 29 | } 30 | 31 | @Override 32 | public List all() { 33 | return todoRepository.findAll(); 34 | } 35 | 36 | @Override 37 | public Todo byId(TodoId id) { 38 | return todoRepository.findById(id).orElseThrow(() -> new TodoNotFoundException(id)); 39 | } 40 | 41 | @Override 42 | public TodoId add(String text) { 43 | return todoRepository.save(Todo.create(text, todoIdGenerator)).getId(); 44 | } 45 | 46 | @Override 47 | public void modify(TodoId id, String text, boolean completed) { 48 | byId(id).edit(text, completed); 49 | } 50 | 51 | @Override 52 | public void remove(TodoId id) { 53 | todoRepository.delete(byId(id)); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/application/FindPersonalTodos.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.application; 2 | 3 | import todoapp.core.shared.identifier.UserId; 4 | import todoapp.core.todo.domain.Todo; 5 | 6 | import java.util.List; 7 | 8 | /** 9 | * 개인화된 할일 검색 유스케이스이다. 10 | * 11 | * @author springrunner.kr@gmail.com 12 | */ 13 | public interface FindPersonalTodos extends FindTodos { 14 | 15 | /** 16 | * 해당 소유자로 등록된 모든 할일 목록을 반환한다. 할일이 없으면 빈 목록을 반환한다. 17 | * 18 | * @param owner 소유자 식별자 19 | * @return List 할일 목록 객체 20 | */ 21 | List all(UserId owner); 22 | 23 | } 24 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/application/FindTodos.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.application; 2 | 3 | import todoapp.core.shared.identifier.TodoId; 4 | import todoapp.core.todo.domain.Todo; 5 | import todoapp.core.todo.domain.TodoNotFoundException; 6 | 7 | import java.util.List; 8 | 9 | /** 10 | * 할일 검색하기 위한 유스케이스이다. 11 | * 12 | * @author springrunner.kr@gmail.com 13 | */ 14 | public interface FindTodos { 15 | 16 | /** 17 | * 등록된 모든 할일을 반환한다. 할일이 없으면 빈 목록을 반환한다. 18 | * 19 | * @return List 객체 20 | */ 21 | List all(); 22 | 23 | /** 24 | * 할일 식별자로 할일을 찾아 반환한다. 25 | * 26 | * @return 할일 객체 27 | */ 28 | Todo byId(TodoId id) throws TodoNotFoundException; 29 | 30 | } 31 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/application/ModifyPersonalTodo.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.application; 2 | 3 | import todoapp.core.shared.identifier.TodoId; 4 | import todoapp.core.shared.identifier.UserId; 5 | import todoapp.core.todo.domain.TodoNotFoundException; 6 | 7 | /** 8 | * 개인화된 할일 수정(변경) 유스케이스이다. 9 | * 10 | * @author springrunner.kr@gmail.com 11 | */ 12 | public interface ModifyPersonalTodo extends ModifyTodo { 13 | 14 | /** 15 | * 사용자가 등록한 할일을 수정한다. 16 | * 17 | * @param owner 소유자 식별자 18 | * @param id 할일 번호 19 | * @param text 할일 20 | * @param completed 완료여부 21 | */ 22 | void modify(UserId owner, TodoId id, String text, boolean completed) throws TodoNotFoundException; 23 | 24 | } 25 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/application/ModifyTodo.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.application; 2 | 3 | import todoapp.core.shared.identifier.TodoId; 4 | import todoapp.core.todo.domain.TodoNotFoundException; 5 | 6 | /** 7 | * 할일 수정(변경)하기 위한 유스케이스이다. 8 | * 9 | * @author springrunner.kr@gmail.com 10 | */ 11 | public interface ModifyTodo { 12 | 13 | /** 14 | * 등록된 할일을 수정한다. 15 | * 16 | * @param id 할일 식별자 17 | * @param text 할일 내용 18 | * @param completed 완료여부 19 | */ 20 | void modify(TodoId id, String text, boolean completed) throws TodoNotFoundException; 21 | 22 | } 23 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/application/RemovePersonalTodo.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.application; 2 | 3 | import todoapp.core.shared.identifier.TodoId; 4 | import todoapp.core.shared.identifier.UserId; 5 | import todoapp.core.todo.domain.TodoNotFoundException; 6 | 7 | /** 8 | * 개인화된 할일 정리 유스케이스이다. 9 | * 10 | * @author springrunner.kr@gmail.com 11 | */ 12 | public interface RemovePersonalTodo extends RemoveTodo { 13 | 14 | /** 15 | * 사용자가 등록한 할일을 정리(삭제)한다. 16 | * 17 | * @param owner 소유자 식별자 18 | * @param id 할일 식별자 19 | */ 20 | void remove(UserId owner, TodoId id) throws TodoNotFoundException; 21 | 22 | } 23 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/application/RemoveTodo.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.application; 2 | 3 | import todoapp.core.shared.identifier.TodoId; 4 | import todoapp.core.todo.domain.TodoNotFoundException; 5 | 6 | /** 7 | * 등록된 할일을 삭제하기 위한 유스케이스이다. 8 | * 9 | * @author springrunner.kr@gmail.com 10 | */ 11 | public interface RemoveTodo { 12 | 13 | /** 14 | * 등록된 할일을 삭제한다. 15 | * 16 | * @param id 할일 Id 17 | */ 18 | void remove(TodoId id) throws TodoNotFoundException; 19 | 20 | } 21 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/domain/Todo.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.domain; 2 | 3 | import jakarta.persistence.*; 4 | import todoapp.core.shared.identifier.TodoId; 5 | import todoapp.core.shared.identifier.UserId; 6 | 7 | import java.io.Serializable; 8 | import java.time.LocalDateTime; 9 | import java.util.Objects; 10 | 11 | /** 12 | * 할일 도메인 모델 13 | * 14 | * @author springrunner.kr@gmail.com 15 | */ 16 | @Entity(name = "todos") 17 | public class Todo implements Serializable { 18 | 19 | @EmbeddedId 20 | @AttributeOverride(name = "value", column = @Column(name = "id")) 21 | private TodoId id; 22 | private String text; 23 | private TodoState state = TodoState.ACTIVE; 24 | 25 | @Embedded 26 | @AttributeOverride(name = "value", column = @Column(name = "owner_id")) 27 | private UserId owner; 28 | 29 | private LocalDateTime createdDate; 30 | private LocalDateTime lastModifiedDate; 31 | 32 | public Todo(TodoId id, String text, LocalDateTime createdDate) { 33 | this.id = Objects.requireNonNull(id, "id must be not null"); 34 | this.text = Objects.requireNonNull(text, "text must be not null"); 35 | this.createdDate = createdDate; 36 | this.lastModifiedDate = createdDate; 37 | } 38 | 39 | public Todo(TodoId id, String text, UserId owner, LocalDateTime createdDate) { 40 | this.id = Objects.requireNonNull(id, "id must be not null"); 41 | this.text = Objects.requireNonNull(text, "text must be not null"); 42 | this.owner = Objects.requireNonNull(owner, "owner must be not null"); 43 | this.createdDate = createdDate; 44 | this.lastModifiedDate = createdDate; 45 | } 46 | 47 | // for hibernate 48 | @SuppressWarnings("unused") 49 | private Todo() { 50 | } 51 | 52 | public static Todo create(String text, TodoIdGenerator idGenerator) { 53 | return new Todo(idGenerator.generateId(), text, LocalDateTime.now()); 54 | } 55 | 56 | public static Todo create(String text, UserId owner, TodoIdGenerator idGenerator) { 57 | return new Todo(idGenerator.generateId(), text, owner, LocalDateTime.now()); 58 | } 59 | 60 | public TodoId getId() { 61 | return id; 62 | } 63 | 64 | public String getText() { 65 | return text; 66 | } 67 | 68 | public TodoState getState() { 69 | return state; 70 | } 71 | 72 | public UserId getOwner() { 73 | return owner; 74 | } 75 | 76 | public LocalDateTime getCreatedDate() { 77 | return createdDate; 78 | } 79 | 80 | public LocalDateTime getLastModifiedDate() { 81 | return lastModifiedDate; 82 | } 83 | 84 | public boolean isCompleted() { 85 | return state == TodoState.COMPLETED; 86 | } 87 | 88 | public Todo edit(String text, boolean completed) { 89 | this.text = text; 90 | this.state = completed ? TodoState.COMPLETED : TodoState.ACTIVE; 91 | this.lastModifiedDate = LocalDateTime.now(); 92 | return this; 93 | } 94 | 95 | public Todo edit(String text, boolean completed, UserId owner) { 96 | if (!Objects.equals(owner, getOwner())) { 97 | throw new TodoOwnerMismatchException(); 98 | } 99 | return edit(text, completed); 100 | } 101 | 102 | @Override 103 | public int hashCode() { 104 | return Objects.hash(getId()); 105 | } 106 | 107 | @Override 108 | public boolean equals(Object todo) { 109 | if (this == todo) 110 | return true; 111 | if (todo == null || getClass() != todo.getClass()) 112 | return false; 113 | return Objects.equals(getId(), ((Todo) todo).getId()); 114 | } 115 | 116 | @Override 117 | public String toString() { 118 | return "Todo [id=%s, text=%s, state=%s, created-date=%s, last-modified-date=%s]".formatted(id, text, state, createdDate, lastModifiedDate); 119 | } 120 | 121 | static String checkText(String text) { 122 | if (Objects.isNull(text) || text.trim().length() < 4) { 123 | throw new TodoRegistrationRejectedException("text should be at least 4 characters long"); 124 | } 125 | return text; 126 | } 127 | 128 | static class Text { 129 | 130 | private final String value; 131 | 132 | public Text(String value) { 133 | this.value = checkText(value); 134 | } 135 | 136 | public String getValue() { 137 | return value; 138 | } 139 | 140 | @Override 141 | public String toString() { 142 | return value; 143 | } 144 | 145 | } 146 | 147 | } 148 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/domain/TodoException.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.domain; 2 | 3 | /** 4 | * 할일 도메인에서 발생 가능한 최상위 예외 클래스이다. 5 | * 6 | * @author springrunner.kr@gmail.com 7 | */ 8 | public class TodoException extends RuntimeException { 9 | 10 | public TodoException(String format, Object... args) { 11 | super(String.format(format, args)); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/domain/TodoIdGenerator.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.domain; 2 | 3 | import todoapp.core.shared.identifier.TodoId; 4 | 5 | /** 6 | * 할일 식별자 생성기 7 | * 8 | * @author springrunner.kr@gmail.com 9 | */ 10 | public interface TodoIdGenerator { 11 | 12 | TodoId generateId(); 13 | 14 | } 15 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/domain/TodoNotFoundException.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.domain; 2 | 3 | import todoapp.core.shared.identifier.TodoId; 4 | 5 | /** 6 | * 할일 저장소에서 할일 도메인 모델을 찾을 수 없을 때 발생 가능한 예외 클래스이다. 7 | * 8 | * @author springrunner.kr@gmail.com 9 | */ 10 | public class TodoNotFoundException extends TodoException { 11 | 12 | private final TodoId id; 13 | 14 | public TodoNotFoundException(TodoId id) { 15 | super("todo not found (id: %s)", id); 16 | this.id = id; 17 | } 18 | 19 | public TodoId getId() { 20 | return id; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/domain/TodoOwnerMismatchException.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.domain; 2 | 3 | /** 4 | * 할일을 편집할 때 작성자가 일치하지 않으면 발생하는 예외 클래스이다. 5 | * 6 | * @author springrunner.kr@gmail.com 7 | */ 8 | public class TodoOwnerMismatchException extends TodoException { 9 | 10 | public TodoOwnerMismatchException() { 11 | super("mismatched username"); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/domain/TodoRegistrationRejectedException.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.domain; 2 | 3 | /** 4 | * 할일을 등록하는 과정에서 발생가능한 예외 클래이다. 5 | * 6 | * @author springrunner.kr@gmail.com 7 | */ 8 | public class TodoRegistrationRejectedException extends TodoException { 9 | 10 | public TodoRegistrationRejectedException(String format, Object... args) { 11 | super(String.format(format, args)); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/domain/TodoRepository.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.domain; 2 | 3 | import todoapp.core.shared.identifier.TodoId; 4 | import todoapp.core.shared.identifier.UserId; 5 | 6 | import java.util.List; 7 | import java.util.Optional; 8 | 9 | /** 10 | * 할일 저장소 인터페이스이다. 11 | * 12 | * @author springrunner.kr@gmail.com 13 | */ 14 | public interface TodoRepository { 15 | 16 | /** 17 | * 모든 할일 목록을 반환한다. 등록된 할일이 없으면 빈 목록을 반환한다. 18 | * 19 | * @return List 객체 20 | */ 21 | List findAll(); 22 | 23 | /** 24 | * 해당 사용자로 등록된 모든 할일 목록을 반환한다. 등록된 할일이 없으면 빈 목록을 반환한다. 25 | * 26 | * @param owner 소유자(사용자) 27 | * @return List 객체 28 | */ 29 | List findByOwner(UserId owner); 30 | 31 | /** 32 | * 할일 식별자로 할일을 찾는다. 일치하는 할일이 없으면 Optional.empty()가 반환된다. 33 | * 34 | * @param id 할일 식별자 35 | * @return Optional 객체 36 | */ 37 | Optional findById(TodoId id); 38 | 39 | /** 40 | * 저장소에 할일 객체를 저장한다. 41 | * 42 | * @param todo 할일 객체 43 | * @return 저장된 할일 객체 44 | */ 45 | Todo save(Todo todo); 46 | 47 | /** 48 | * 저장소에 할일 객체를 제거한다. 일치하는 할일이 없으면 무시한다. 49 | * 50 | * @param todo 삭제할 할일 객체 51 | */ 52 | void delete(Todo todo); 53 | 54 | } 55 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/domain/TodoState.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.domain; 2 | 3 | /** 4 | * 할일 상태 값 5 | * 6 | * @author springrunner.kr@gmail.com 7 | */ 8 | public enum TodoState { 9 | 10 | ACTIVE("active", "Todo to be processed"), 11 | COMPLETED("completed", "Todo completed"); 12 | 13 | private final String literal; 14 | private final String description; 15 | 16 | TodoState(String literal, String description) { 17 | this.literal = literal; 18 | this.description = description; 19 | } 20 | 21 | public String getLiteral() { 22 | return literal; 23 | } 24 | 25 | public String getDescription() { 26 | return description; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/domain/support/SpreadsheetConverter.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.domain.support; 2 | 3 | import todoapp.core.foundation.util.StreamUtils; 4 | import todoapp.core.shared.util.Spreadsheet; 5 | import todoapp.core.todo.domain.Todo; 6 | 7 | /** 8 | * 할일 목록을 {@link Spreadsheet} 모델로 변환하는 변환기이다. 9 | * 10 | * @author springrunner.kr@gmail.com 11 | */ 12 | public class SpreadsheetConverter { 13 | 14 | public static Spreadsheet convert(Iterable todos) { 15 | var header = Spreadsheet.Row.of("id", "text", "completed"); 16 | 17 | var todoStream = StreamUtils.createStreamFromIterator(todos.iterator()); 18 | var rows = todoStream.map(SpreadsheetConverter::mapRow).toList(); 19 | 20 | return new Spreadsheet("todos", header, rows); 21 | } 22 | 23 | private static Spreadsheet.Row mapRow(Todo todo) { 24 | return Spreadsheet.Row.of( 25 | todo.getId(), 26 | todo.getText(), 27 | todo.isCompleted() ? "완료" : "미완료" 28 | ); 29 | } 30 | 31 | // 외부에서 생성을 막기 위해 비공개 기본 생성자를 선언했다 32 | private SpreadsheetConverter() { 33 | 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/todo/domain/support/UUIDBasedTodoIdGenerator.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.todo.domain.support; 2 | 3 | import org.springframework.stereotype.Component; 4 | import todoapp.core.shared.identifier.TodoId; 5 | import todoapp.core.todo.domain.TodoIdGenerator; 6 | 7 | import java.util.UUID; 8 | 9 | /** 10 | * UUID 기반 할일 식별자 생성기 11 | * 12 | * @author springrunner.kr@gmail.com 13 | */ 14 | @Component 15 | class UUIDBasedTodoIdGenerator implements TodoIdGenerator { 16 | 17 | @Override 18 | public TodoId generateId() { 19 | return TodoId.of(UUID.randomUUID().toString()); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/application/ChangeUserProfilePicture.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.application; 2 | 3 | import todoapp.core.user.domain.ProfilePicture; 4 | import todoapp.core.user.domain.ProfilePictureException; 5 | import todoapp.core.user.domain.User; 6 | 7 | /** 8 | * 사용자 프로필 이미지를 변경하기 위한 유스케이스이다. 9 | * 10 | * @author springrunner.kr@gmail.com 11 | */ 12 | public interface ChangeUserProfilePicture { 13 | 14 | /** 15 | * 지정된 사용자 이름에 해당하는 사용자의 프로필 이미지를 변경한다. 16 | * 17 | * @param username 사용자 이름 18 | * @param profilePicture 프로필 이미지 객체 19 | * @return 프로필 이미지가 변경된 사용자 객체 20 | */ 21 | User change(String username, ProfilePicture profilePicture) throws ProfilePictureException; 22 | 23 | } 24 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/application/DefaultUserService.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.application; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.stereotype.Service; 6 | import org.springframework.transaction.annotation.Transactional; 7 | import todoapp.core.foundation.crypto.PasswordEncoder; 8 | import todoapp.core.user.domain.*; 9 | 10 | import java.util.Objects; 11 | 12 | /** 13 | * 사용자 서비스를 위한 유스케이스 구현체이다. 14 | * 15 | * @author springrunner.kr@gmail.com 16 | */ 17 | @Service 18 | @Transactional 19 | class DefaultUserService implements VerifyUserPassword, RegisterUser, ChangeUserProfilePicture { 20 | 21 | private final Logger log = LoggerFactory.getLogger(getClass()); 22 | 23 | private final UserIdGenerator userIdGenerator; 24 | private final PasswordEncoder passwordEncoder; 25 | private final UserRepository userRepository; 26 | 27 | DefaultUserService(UserIdGenerator userIdGenerator, PasswordEncoder passwordEncoder, UserRepository userRepository) { 28 | this.userIdGenerator = Objects.requireNonNull(userIdGenerator); 29 | this.passwordEncoder = Objects.requireNonNull(passwordEncoder); 30 | this.userRepository = Objects.requireNonNull(userRepository); 31 | } 32 | 33 | public User verify(String username, String rawPassword) throws UserNotFoundException, UserPasswordNotMatchedException { 34 | return userRepository 35 | .findByUsername(username) 36 | .orElseThrow(() -> new UserNotFoundException(username)) 37 | .verifyPassword(passwordEncoder.encode(rawPassword)); 38 | } 39 | 40 | public User register(String username, String rawPassword) { 41 | return userRepository.findByUsername(username).orElseGet(() -> { 42 | var user = userRepository.save( 43 | new User(userIdGenerator.generateId(), username, passwordEncoder.encode(rawPassword)) 44 | ); 45 | log.info("new user has joined: {}", user); 46 | 47 | return user; 48 | }); 49 | } 50 | 51 | @Override 52 | public User change(String username, ProfilePicture profilePicture) { 53 | return userRepository 54 | .findByUsername(username) 55 | .orElseThrow(() -> new UserNotFoundException(username)) 56 | .changeProfilePicture(profilePicture); 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/application/RegisterUser.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.application; 2 | 3 | import todoapp.core.user.domain.User; 4 | 5 | /** 6 | * 사용자 가입을 처리하기 위한 유스케이스이다. 7 | * 8 | * @author springrunner.kr@gmail.com 9 | */ 10 | public interface RegisterUser { 11 | 12 | /** 13 | * 새로운 사용자를 등록한다. 14 | * 만약 동일한 이름에 사용자가 존재하면 등록하지 않는다. 15 | * 16 | * @param username 사용자 이름 17 | * @param rawPassword 원시 비밀번호 18 | * @return 등록된 사용자 객체 19 | */ 20 | User register(String username, String rawPassword); 21 | 22 | } 23 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/application/VerifyUserPassword.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.application; 2 | 3 | import todoapp.core.user.domain.User; 4 | import todoapp.core.user.domain.UserNotFoundException; 5 | import todoapp.core.user.domain.UserPasswordNotMatchedException; 6 | 7 | /** 8 | * 사용자 비밀번호 검증을 위한 유스케이스이다. 9 | * 10 | * @author springrunner.kr@gmail.com 11 | */ 12 | public interface VerifyUserPassword { 13 | 14 | /** 15 | * 사용자 이름과 일치하는 사용자의 비밀번호를 확인한다. 16 | * 비밀번호가 일치하지 않으면 예외가 발생한다. 17 | * 18 | * @param username 사용자 이름 19 | * @param rawPassword 비밀번호 20 | * @return 사용자 객체 21 | */ 22 | User verify(String username, String rawPassword) throws UserNotFoundException, UserPasswordNotMatchedException; 23 | 24 | } 25 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/domain/ProfilePicture.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.domain; 2 | 3 | import jakarta.persistence.Embeddable; 4 | 5 | import java.io.Serializable; 6 | import java.net.URI; 7 | import java.util.Objects; 8 | 9 | /** 10 | * 사용자 프로필 이미지 값 객체(Value Object)이다. 11 | * 12 | * @author springrunner.kr@gmail.com 13 | */ 14 | @Embeddable 15 | public class ProfilePicture implements Serializable { 16 | 17 | private URI uri; 18 | 19 | public ProfilePicture(URI uri) { 20 | setUri(Objects.requireNonNull(uri, "uri must be not null")); 21 | } 22 | 23 | // for hibernate 24 | @SuppressWarnings("unused") 25 | private ProfilePicture() { 26 | } 27 | 28 | public URI getUri() { 29 | return uri; 30 | } 31 | 32 | private void setUri(URI uri) { 33 | this.uri = uri; 34 | } 35 | 36 | @Override 37 | public int hashCode() { 38 | return Objects.hash(uri); 39 | } 40 | 41 | @Override 42 | public boolean equals(Object obj) { 43 | if (this == obj) 44 | return true; 45 | if (obj == null || getClass() != obj.getClass()) 46 | return false; 47 | return Objects.equals(uri, ((ProfilePicture) obj).uri); 48 | } 49 | 50 | @Override 51 | public String toString() { 52 | return uri.toString(); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/domain/ProfilePictureException.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.domain; 2 | 3 | /** 4 | * 사용자 프로필 이미지를 처리하는 과정에서 발생 가능한 예외 클래스이다. 5 | * 6 | * @author springrunner.kr@gmail.com 7 | */ 8 | public class ProfilePictureException extends UserException { 9 | 10 | public ProfilePictureException(String format, Object... args) { 11 | super(format, args); 12 | } 13 | 14 | public ProfilePictureException(String message, Throwable cause) { 15 | super(message, cause); 16 | } 17 | 18 | /** 19 | * 사용자 프로필 이미지 저장 실패시 발생 가능한 예외 클래스이다. 20 | * 21 | * @author springrunner.kr@gmail.com 22 | */ 23 | public static class ProfilePictureSaveException extends ProfilePictureException { 24 | 25 | public ProfilePictureSaveException(String message) { 26 | super(message); 27 | } 28 | 29 | public ProfilePictureSaveException(String message, Throwable cause) { 30 | super(message, cause); 31 | } 32 | } 33 | 34 | /** 35 | * 사용자 프로필 이미지 불러오기 실패시 발생 가능한 예외 클래스이다. 36 | * 37 | * @author springrunner.kr@gmail.com 38 | */ 39 | public static class ProfilePictureLoadFailedException extends ProfilePictureException { 40 | 41 | public ProfilePictureLoadFailedException(String message) { 42 | super(message); 43 | } 44 | 45 | public ProfilePictureLoadFailedException(String message, Throwable cause) { 46 | super(message, cause); 47 | } 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/domain/ProfilePictureStorage.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.domain; 2 | 3 | import org.springframework.core.io.Resource; 4 | 5 | import java.net.URI; 6 | 7 | /** 8 | * 프로필 이미지 보관소 인터페이스이다. 9 | * 10 | * @author springrunner.kr@gmail.com 11 | */ 12 | public interface ProfilePictureStorage { 13 | 14 | /** 15 | * 프로필 이미지를 저장 후 URI 생성해서 반환한다. 16 | * 17 | * @param resource 이미지 자원 18 | * @return 프로필 이미지에 접근 가능한 URI 객체 19 | */ 20 | URI save(Resource resource); 21 | 22 | /** 23 | * URI가 가르키는 자원을 찾아 반환한다. 24 | * 25 | * @param uri URI 객체 26 | * @return 자원 객체 27 | */ 28 | Resource load(URI uri); 29 | 30 | } 31 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/domain/User.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.domain; 2 | 3 | import jakarta.persistence.AttributeOverride; 4 | import jakarta.persistence.Column; 5 | import jakarta.persistence.EmbeddedId; 6 | import jakarta.persistence.Entity; 7 | import todoapp.core.shared.identifier.UserId; 8 | 9 | import java.io.Serializable; 10 | import java.util.Objects; 11 | 12 | /** 13 | * 사용자 엔티티(Entity) 14 | * 15 | * @author springrunner.kr@gmail.com 16 | */ 17 | @Entity(name = "users") 18 | public class User implements Serializable { 19 | 20 | @EmbeddedId 21 | @AttributeOverride(name = "value", column = @Column(name = "id")) 22 | private UserId id; 23 | private String username; 24 | private String password; 25 | private ProfilePicture profilePicture; 26 | 27 | public User(UserId id, String username, String password) { 28 | this.id = Objects.requireNonNull(id, "id must be not null"); 29 | this.username = Objects.requireNonNull(username, "username must be not null"); 30 | this.password = Objects.requireNonNull(password, "password must be not null"); 31 | } 32 | 33 | // for hibernate 34 | @SuppressWarnings("unused") 35 | private User() { 36 | } 37 | 38 | public UserId getId() { 39 | return id; 40 | } 41 | 42 | public String getUsername() { 43 | return username; 44 | } 45 | 46 | public String getPassword() { 47 | return password; 48 | } 49 | 50 | public ProfilePicture getProfilePicture() { 51 | return profilePicture; 52 | } 53 | 54 | public boolean hasProfilePicture() { 55 | return Objects.nonNull(profilePicture); 56 | } 57 | 58 | /** 59 | * 입력된 비밀번호가 일치하는지 검증한다. 60 | * 61 | * @param password 비교할 비밀번호 62 | * @return 비밀번호가 일치하면 현재 사용자 객체, 그렇지 않으면 예외 발생 63 | */ 64 | public User verifyPassword(String password) { 65 | if (Objects.equals(getPassword(), password)) { 66 | return this; 67 | } 68 | throw new UserPasswordNotMatchedException(); 69 | } 70 | 71 | /** 72 | * 사용자 프로필 이미지를 변경한다. 73 | * 74 | * @param profilePicture 변경할 프로필 이미지 75 | * @return 프로필 이미지가 변경된 현재 사용자 객체 76 | */ 77 | public User changeProfilePicture(ProfilePicture profilePicture) { 78 | this.profilePicture = profilePicture; 79 | return this; 80 | } 81 | 82 | @Override 83 | public int hashCode() { 84 | return Objects.hash(getId()); 85 | } 86 | 87 | @Override 88 | public boolean equals(Object user) { 89 | if (this == user) 90 | return true; 91 | if (user == null || getClass() != user.getClass()) 92 | return false; 93 | return Objects.equals(getId(), ((User) user).getId()); 94 | } 95 | 96 | @Override 97 | public String toString() { 98 | return "User [username=%s, profile-picture=%s]".formatted(username, profilePicture); 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/domain/UserException.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.domain; 2 | 3 | import todoapp.core.foundation.SystemException; 4 | 5 | /** 6 | * 사용자 엔티티에서 발생 가능한 최상위 예외 클래스이다. 7 | * 8 | * @author springrunner.kr@gmail.com 9 | */ 10 | public class UserException extends SystemException { 11 | 12 | public UserException(String format, Object... args) { 13 | super(format, args); 14 | } 15 | 16 | public UserException(String message, Throwable cause) { 17 | super(message, cause); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/domain/UserIdGenerator.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.domain; 2 | 3 | import todoapp.core.shared.identifier.UserId; 4 | 5 | /** 6 | * 사용자 식별자 생성기 7 | * 8 | * @author springrunner.kr@gmail.com 9 | */ 10 | public interface UserIdGenerator { 11 | 12 | UserId generateId(); 13 | 14 | } 15 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/domain/UserNotFoundException.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.domain; 2 | 3 | /** 4 | * 사용자 저장소에서 사용자 엔티티를 찾을 수 없을 때 발생 가능한 예외 클래스이다. 5 | * 6 | * @author springrunner.kr@gmail.com 7 | */ 8 | public class UserNotFoundException extends UserException { 9 | 10 | private final String username; 11 | 12 | public UserNotFoundException(String username) { 13 | super(String.format("user not found (username: %s)", username)); 14 | this.username = username; 15 | } 16 | 17 | @Override 18 | public Object[] getArguments() { 19 | return new Object[]{String.valueOf(username)}; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/domain/UserPasswordNotMatchedException.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.domain; 2 | 3 | /** 4 | * 사용자 비밀번호 검증시 일치하지 않으면 발생 가능한 예외 클래스이다. 5 | * 6 | * @author springrunner.kr@gmail.com 7 | */ 8 | public class UserPasswordNotMatchedException extends UserException { 9 | 10 | public UserPasswordNotMatchedException() { 11 | super("entered password does not match"); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/domain/UserRepository.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.domain; 2 | 3 | import java.util.Optional; 4 | 5 | /** 6 | * 사용자 저장소 인터페이스이다. 7 | * 8 | * @author springrunner.kr@gmail.com 9 | */ 10 | public interface UserRepository { 11 | 12 | /** 13 | * 사용자 이름으로 사용자를 찾는다. 일치하는 사용자가 없으면 Optional.empty()가 반환된다. 14 | * 15 | * @param username 사용자 이름 16 | * @return Optional 객체 17 | */ 18 | Optional findByUsername(String username); 19 | 20 | /** 21 | * 저장소에 사용자 객체를 저장한다. 22 | * 23 | * @param user 사용자 객체 24 | * @return 저장된 사용자 객체 25 | */ 26 | User save(User user); 27 | 28 | } 29 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/core/user/domain/support/UUIDBasedUserIdGenerator.java: -------------------------------------------------------------------------------- 1 | package todoapp.core.user.domain.support; 2 | 3 | import org.springframework.stereotype.Component; 4 | import todoapp.core.shared.identifier.UserId; 5 | import todoapp.core.user.domain.UserIdGenerator; 6 | 7 | import java.util.UUID; 8 | 9 | /** 10 | * UUID 사용자 식별자 생성기 11 | * 12 | * @author springrunner.kr@gmail.com 13 | */ 14 | @Component 15 | class UUIDBasedUserIdGenerator implements UserIdGenerator { 16 | 17 | @Override 18 | public UserId generateId() { 19 | return UserId.of(UUID.randomUUID().toString()); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/data/InMemoryTodoRepository.java: -------------------------------------------------------------------------------- 1 | package todoapp.data; 2 | 3 | import org.springframework.context.annotation.Profile; 4 | import org.springframework.stereotype.Repository; 5 | import todoapp.core.foundation.Constant; 6 | import todoapp.core.shared.identifier.TodoId; 7 | import todoapp.core.shared.identifier.UserId; 8 | import todoapp.core.todo.domain.Todo; 9 | import todoapp.core.todo.domain.TodoRepository; 10 | 11 | import java.util.Collections; 12 | import java.util.List; 13 | import java.util.Objects; 14 | import java.util.Optional; 15 | import java.util.concurrent.CopyOnWriteArrayList; 16 | 17 | /** 18 | * 메모리 기반 할일 저장소 구현체이다. 19 | * 20 | * @author springrunner.kr@gmail.com 21 | */ 22 | @Profile(Constant.PROFILE_DEVELOPMENT) 23 | @Repository 24 | class InMemoryTodoRepository implements TodoRepository { 25 | 26 | private final List todos = new CopyOnWriteArrayList<>(); 27 | 28 | @Override 29 | public List findAll() { 30 | return Collections.unmodifiableList(todos); 31 | } 32 | 33 | @Override 34 | public List findByOwner(UserId owner) { 35 | var result = todos.stream().filter(todo -> Objects.equals(owner, todo.getOwner())).toList(); 36 | return Collections.unmodifiableList(result); 37 | } 38 | 39 | public Optional findById(TodoId id) { 40 | return todos.stream().filter(todo -> Objects.equals(id, todo.getId())).findFirst(); 41 | } 42 | 43 | @Override 44 | public Todo save(Todo todo) { 45 | if (!todos.contains(todo)) { 46 | todos.add(todo); 47 | } 48 | return todo; 49 | } 50 | 51 | @Override 52 | public void delete(Todo todo) { 53 | todos.remove(todo); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/data/InMemoryUserRepository.java: -------------------------------------------------------------------------------- 1 | package todoapp.data; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.boot.ApplicationArguments; 6 | import org.springframework.boot.ApplicationRunner; 7 | import org.springframework.context.annotation.Profile; 8 | import org.springframework.stereotype.Repository; 9 | import todoapp.core.foundation.Constant; 10 | import todoapp.core.foundation.crypto.PasswordEncoder; 11 | import todoapp.core.user.domain.User; 12 | import todoapp.core.user.domain.UserIdGenerator; 13 | import todoapp.core.user.domain.UserRepository; 14 | 15 | import java.util.List; 16 | import java.util.Objects; 17 | import java.util.Optional; 18 | import java.util.concurrent.CopyOnWriteArrayList; 19 | 20 | /** 21 | * 메모리 기반 사용자 저장소 구현체이다. 22 | * 23 | * @author springrunner.kr@gmail.com 24 | */ 25 | @Profile(Constant.PROFILE_DEVELOPMENT) 26 | @Repository 27 | class InMemoryUserRepository implements UserRepository, ApplicationRunner { 28 | 29 | private final UserIdGenerator userIdGenerator; 30 | private final PasswordEncoder passwordEncoder; 31 | 32 | private final List users = new CopyOnWriteArrayList<>(); 33 | private final Logger log = LoggerFactory.getLogger(getClass()); 34 | 35 | InMemoryUserRepository(UserIdGenerator userIdGenerator, PasswordEncoder passwordEncoder) { 36 | this.userIdGenerator = Objects.requireNonNull(userIdGenerator); 37 | this.passwordEncoder = Objects.requireNonNull(passwordEncoder); 38 | } 39 | 40 | @Override 41 | public Optional findByUsername(String username) { 42 | return users.stream().filter(user -> Objects.equals(user.getUsername(), username)).findAny(); 43 | } 44 | 45 | @Override 46 | public User save(User user) { 47 | if (!users.contains(user)) { 48 | users.add(user); 49 | } 50 | return user; 51 | } 52 | 53 | @Override 54 | public void run(ApplicationArguments args) throws Exception { 55 | save(new User(userIdGenerator.generateId(), "user", passwordEncoder.encode("password"))); 56 | log.info("enroll new user (username: user, password: password)"); 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/data/LocalProfilePictureStorage.java: -------------------------------------------------------------------------------- 1 | package todoapp.data; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.beans.factory.InitializingBean; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.context.ResourceLoaderAware; 8 | import org.springframework.core.io.Resource; 9 | import org.springframework.core.io.ResourceLoader; 10 | import org.springframework.stereotype.Component; 11 | import org.springframework.util.FileCopyUtils; 12 | import todoapp.core.user.domain.ProfilePictureException.ProfilePictureLoadFailedException; 13 | import todoapp.core.user.domain.ProfilePictureException.ProfilePictureSaveException; 14 | import todoapp.core.user.domain.ProfilePictureStorage; 15 | 16 | import java.io.FileNotFoundException; 17 | import java.net.URI; 18 | import java.nio.file.Files; 19 | import java.nio.file.Path; 20 | import java.util.Objects; 21 | import java.util.UUID; 22 | 23 | /** 24 | * 로컬 디스크에 사용자 프로필 이미지를 저장하고, 불러오는 {@link ProfilePictureStorage} 구현체이다. 25 | * 26 | * @author springrunner.kr@gmail.com 27 | */ 28 | @Component 29 | class LocalProfilePictureStorage implements ProfilePictureStorage, ResourceLoaderAware, InitializingBean { 30 | 31 | private final Logger log = LoggerFactory.getLogger(getClass()); 32 | 33 | private ResourceLoader resourceLoader; 34 | private Path basePath; 35 | 36 | @Override 37 | public void setResourceLoader(ResourceLoader resourceLoader) { 38 | this.resourceLoader = resourceLoader; 39 | } 40 | 41 | @Value("${site.user.profilePicture.basePath:./files/user-profile-picture}") 42 | public void setBasePath(Path basePath) { 43 | this.basePath = basePath; 44 | } 45 | 46 | @Override 47 | public void afterPropertiesSet() throws Exception { 48 | Objects.requireNonNull(resourceLoader, "resourceLoader is required"); 49 | if (!Objects.requireNonNull(basePath, "basePath is required").toFile().exists()) { 50 | basePath.toFile().mkdirs(); 51 | log.debug("create a directory: {}", basePath.toAbsolutePath().toUri()); 52 | } 53 | } 54 | 55 | @Override 56 | public URI save(Resource resource) { 57 | try { 58 | var profilePicture = basePath.resolve(UUID.randomUUID().toString()); 59 | FileCopyUtils.copy(resource.getInputStream(), Files.newOutputStream(profilePicture)); 60 | return profilePicture.toUri(); 61 | } catch (Exception error) { 62 | throw new ProfilePictureSaveException("failed to save profile picture to local disk", error); 63 | } 64 | } 65 | 66 | @Override 67 | public Resource load(URI uri) { 68 | try { 69 | var resource = resourceLoader.getResource(uri.toString()); 70 | if (!resource.exists()) { 71 | throw new FileNotFoundException(String.format("not found file for uri: %s", uri)); 72 | } 73 | return resource; 74 | } catch (Exception error) { 75 | throw new ProfilePictureLoadFailedException("failed to load profile picture to local disk", error); 76 | } 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/data/TodosDataInitializer.java: -------------------------------------------------------------------------------- 1 | package todoapp.data; 2 | 3 | import org.springframework.beans.factory.InitializingBean; 4 | import org.springframework.boot.ApplicationArguments; 5 | import org.springframework.boot.ApplicationRunner; 6 | import org.springframework.boot.CommandLineRunner; 7 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 8 | import org.springframework.stereotype.Component; 9 | import todoapp.core.todo.domain.Todo; 10 | import todoapp.core.todo.domain.TodoIdGenerator; 11 | import todoapp.core.todo.domain.TodoRepository; 12 | 13 | import java.util.Objects; 14 | 15 | /** 16 | * @author springrunner.kr@gmail.com 17 | */ 18 | @Component 19 | @ConditionalOnProperty(name = "todoapp.data.initialize", havingValue = "true") 20 | class TodosDataInitializer implements InitializingBean, ApplicationRunner, CommandLineRunner { 21 | 22 | private final TodoIdGenerator todoIdGenerator; 23 | private final TodoRepository todoRepository; 24 | 25 | public TodosDataInitializer(TodoIdGenerator todoIdGenerator, TodoRepository todoRepository) { 26 | this.todoIdGenerator = Objects.requireNonNull(todoIdGenerator); 27 | this.todoRepository = Objects.requireNonNull(todoRepository); 28 | } 29 | 30 | @Override 31 | public void afterPropertiesSet() throws Exception { 32 | // 1. InitializingBean 33 | todoRepository.save(Todo.create("Task one", todoIdGenerator)); 34 | } 35 | 36 | @Override 37 | public void run(ApplicationArguments args) throws Exception { 38 | // 2. ApplicationRunner 39 | todoRepository.save(Todo.create("Task two", todoIdGenerator)); 40 | } 41 | 42 | @Override 43 | public void run(String... args) throws Exception { 44 | // 3. CommandLineRunner 45 | todoRepository.save(Todo.create("Task three", todoIdGenerator)); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/data/jpa/JpaTodoRepository.java: -------------------------------------------------------------------------------- 1 | package todoapp.data.jpa; 2 | 3 | import org.springframework.context.annotation.Profile; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import todoapp.core.foundation.Constant; 6 | import todoapp.core.shared.identifier.TodoId; 7 | import todoapp.core.todo.domain.Todo; 8 | import todoapp.core.todo.domain.TodoRepository; 9 | 10 | /** 11 | * Spring Data JPA 기반 할일 저장소 구현체이다. 12 | * 13 | * @author springrunner.kr@gmail.com 14 | */ 15 | @Profile(Constant.PROFILE_PRODUCTION) 16 | interface JpaTodoRepository extends TodoRepository, JpaRepository { 17 | 18 | } 19 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/data/jpa/JpaUserRepository.java: -------------------------------------------------------------------------------- 1 | package todoapp.data.jpa; 2 | 3 | import org.springframework.context.annotation.Profile; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import todoapp.core.foundation.Constant; 6 | import todoapp.core.shared.identifier.UserId; 7 | import todoapp.core.user.domain.User; 8 | import todoapp.core.user.domain.UserRepository; 9 | 10 | /** 11 | * Spring Data JPA 기반 사용자 저장소 구현체이다. 12 | * 13 | * @author springrunner.kr@gmail.com 14 | */ 15 | @Profile(Constant.PROFILE_PRODUCTION) 16 | interface JpaUserRepository extends UserRepository, JpaRepository { 17 | 18 | } 19 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/security/AccessDeniedException.java: -------------------------------------------------------------------------------- 1 | package todoapp.security; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | import todoapp.core.foundation.SystemException; 6 | 7 | /** 8 | * 권한이 없어 접근 불가 상황시 발생 가능한 예외 클래스이다. 9 | * 10 | * @author springrunner.kr@gmail.com 11 | */ 12 | @ResponseStatus(HttpStatus.FORBIDDEN) 13 | public class AccessDeniedException extends SystemException { 14 | 15 | public AccessDeniedException() { 16 | super("Access denied: insufficient permissions to access the resource"); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/security/UnauthorizedAccessException.java: -------------------------------------------------------------------------------- 1 | package todoapp.security; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | import todoapp.core.foundation.SystemException; 6 | 7 | /** 8 | * 인증되지 않은 사용자 접근시 발생 가능한 예외 클래스이다. 9 | * 10 | * @author springrunner.kr@gmail.com 11 | */ 12 | @ResponseStatus(HttpStatus.UNAUTHORIZED) 13 | public class UnauthorizedAccessException extends SystemException { 14 | 15 | public UnauthorizedAccessException() { 16 | super("Unauthorized access: You must be authenticated to access this resource"); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/security/UserSession.java: -------------------------------------------------------------------------------- 1 | package todoapp.security; 2 | 3 | import todoapp.core.user.domain.User; 4 | 5 | import java.security.Principal; 6 | import java.util.Collections; 7 | import java.util.HashSet; 8 | import java.util.Objects; 9 | import java.util.Set; 10 | 11 | /** 12 | * 사용자 세션 모델이다. 13 | * 14 | * @author springrunner.kr@gmail.com 15 | */ 16 | public class UserSession implements Principal { 17 | 18 | public static final String ROLE_USER = "ROLE_USER"; 19 | 20 | private final User user; 21 | private final Set roles = new HashSet<>(); 22 | 23 | public UserSession(User user) { 24 | this.user = Objects.requireNonNull(user, "user object must be not null"); 25 | this.roles.add(ROLE_USER); 26 | } 27 | 28 | public User getUser() { 29 | return user; 30 | } 31 | 32 | public String getName() { 33 | return user.getUsername(); 34 | } 35 | 36 | public Set getRoles() { 37 | return Collections.unmodifiableSet(roles); 38 | } 39 | 40 | public boolean hasRole(String role) { 41 | return roles.contains(role); 42 | } 43 | 44 | @Override 45 | public int hashCode() { 46 | return Objects.hash(user); 47 | } 48 | 49 | @Override 50 | public boolean equals(Object obj) { 51 | if (this == obj) 52 | return true; 53 | if (obj == null || getClass() != obj.getClass()) 54 | return false; 55 | return Objects.equals(user, ((UserSession) obj).user); 56 | } 57 | 58 | @Override 59 | public String toString() { 60 | return "UserSession [username=%s, roles=%s]".formatted(user.getUsername(), roles); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/security/UserSessionHolder.java: -------------------------------------------------------------------------------- 1 | package todoapp.security; 2 | 3 | /** 4 | * 사용자 세션을 관리하기 위한 인터페이스이다. 5 | * 6 | * @author springrunner.kr@gmail.com 7 | */ 8 | public interface UserSessionHolder { 9 | 10 | /** 11 | * 저장된 사용자 세션을 불러옵니다. 12 | * 저장된 사용자 세션이 없으면 {@literal null} 을 반환합니다. 13 | * 14 | * @return 사용자 세션 15 | */ 16 | UserSession get(); 17 | 18 | /** 19 | * 사용자 세션을 저장합니다. 20 | * 21 | * @param session 사용자 세션 22 | */ 23 | void set(UserSession session); 24 | 25 | /** 26 | * 사용자 세션을 초기화합니다 . 27 | */ 28 | void reset(); 29 | 30 | } 31 | -------------------------------------------------------------------------------- /server/src/main/java/todoapp/security/support/RolesAllowedSupport.java: -------------------------------------------------------------------------------- 1 | package todoapp.security.support; 2 | 3 | import jakarta.annotation.security.RolesAllowed; 4 | import org.springframework.core.annotation.AnnotatedElementUtils; 5 | import org.springframework.web.method.HandlerMethod; 6 | 7 | import java.util.Objects; 8 | import java.util.Optional; 9 | 10 | /** 11 | * {@link RolesAllowed} 애노테이션을 편리하게 다루기 위한 유틸리티 클래스이다. 12 | * 13 | * @author springrunner.kr@gmail.com 14 | */ 15 | public interface RolesAllowedSupport { 16 | 17 | /** 18 | * {@link HandlerMethod} 타입에 핸들러에서 {@link RolesAllowed} 애노테이션을 추출한다. 해당 핸들러에 없으면, 핸들러가 19 | * 등록된 @Controller 또는 @RestController 에서도 찾아본다. 20 | * 21 | * @param handler 핸들러({@link HandlerMethod}) 객체 22 | * @return {@link RolesAllowed} 객체 23 | */ 24 | default Optional 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 | --------------------------------------------------------------------------------