├── .github └── workflows │ └── gradle.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.gradle ├── example-logo.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── graphql-schema.png └── src ├── main ├── java │ └── io │ │ └── spring │ │ ├── JacksonCustomizations.java │ │ ├── MyBatisConfig.java │ │ ├── RealWorldApplication.java │ │ ├── Util.java │ │ ├── api │ │ ├── ArticleApi.java │ │ ├── ArticleFavoriteApi.java │ │ ├── ArticlesApi.java │ │ ├── CommentsApi.java │ │ ├── CurrentUserApi.java │ │ ├── ProfileApi.java │ │ ├── TagsApi.java │ │ ├── UsersApi.java │ │ ├── exception │ │ │ ├── CustomizeExceptionHandler.java │ │ │ ├── ErrorResource.java │ │ │ ├── ErrorResourceSerializer.java │ │ │ ├── FieldErrorResource.java │ │ │ ├── InvalidAuthenticationException.java │ │ │ ├── InvalidRequestException.java │ │ │ ├── NoAuthorizationException.java │ │ │ └── ResourceNotFoundException.java │ │ └── security │ │ │ ├── JwtTokenFilter.java │ │ │ └── WebSecurityConfig.java │ │ ├── application │ │ ├── ArticleQueryService.java │ │ ├── CommentQueryService.java │ │ ├── CursorPageParameter.java │ │ ├── CursorPager.java │ │ ├── DateTimeCursor.java │ │ ├── Node.java │ │ ├── Page.java │ │ ├── PageCursor.java │ │ ├── ProfileQueryService.java │ │ ├── TagsQueryService.java │ │ ├── UserQueryService.java │ │ ├── article │ │ │ ├── ArticleCommandService.java │ │ │ ├── DuplicatedArticleConstraint.java │ │ │ ├── DuplicatedArticleValidator.java │ │ │ ├── NewArticleParam.java │ │ │ └── UpdateArticleParam.java │ │ ├── data │ │ │ ├── ArticleData.java │ │ │ ├── ArticleDataList.java │ │ │ ├── ArticleFavoriteCount.java │ │ │ ├── CommentData.java │ │ │ ├── ProfileData.java │ │ │ ├── UserData.java │ │ │ └── UserWithToken.java │ │ └── user │ │ │ ├── DuplicatedEmailConstraint.java │ │ │ ├── DuplicatedEmailValidator.java │ │ │ ├── DuplicatedUsernameConstraint.java │ │ │ ├── DuplicatedUsernameValidator.java │ │ │ ├── RegisterParam.java │ │ │ ├── UpdateUserCommand.java │ │ │ ├── UpdateUserParam.java │ │ │ └── UserService.java │ │ ├── core │ │ ├── article │ │ │ ├── Article.java │ │ │ ├── ArticleRepository.java │ │ │ └── Tag.java │ │ ├── comment │ │ │ ├── Comment.java │ │ │ └── CommentRepository.java │ │ ├── favorite │ │ │ ├── ArticleFavorite.java │ │ │ └── ArticleFavoriteRepository.java │ │ ├── service │ │ │ ├── AuthorizationService.java │ │ │ └── JwtService.java │ │ └── user │ │ │ ├── FollowRelation.java │ │ │ ├── User.java │ │ │ └── UserRepository.java │ │ ├── graphql │ │ ├── ArticleDatafetcher.java │ │ ├── ArticleMutation.java │ │ ├── CommentDatafetcher.java │ │ ├── CommentMutation.java │ │ ├── MeDatafetcher.java │ │ ├── ProfileDatafetcher.java │ │ ├── RelationMutation.java │ │ ├── SecurityUtil.java │ │ ├── TagDatafetcher.java │ │ ├── UserMutation.java │ │ └── exception │ │ │ ├── AuthenticationException.java │ │ │ └── GraphQLCustomizeExceptionHandler.java │ │ └── infrastructure │ │ ├── mybatis │ │ ├── DateTimeHandler.java │ │ ├── mapper │ │ │ ├── ArticleFavoriteMapper.java │ │ │ ├── ArticleMapper.java │ │ │ ├── CommentMapper.java │ │ │ └── UserMapper.java │ │ └── readservice │ │ │ ├── ArticleFavoritesReadService.java │ │ │ ├── ArticleReadService.java │ │ │ ├── CommentReadService.java │ │ │ ├── TagReadService.java │ │ │ ├── UserReadService.java │ │ │ └── UserRelationshipQueryService.java │ │ ├── repository │ │ ├── MyBatisArticleFavoriteRepository.java │ │ ├── MyBatisArticleRepository.java │ │ ├── MyBatisCommentRepository.java │ │ └── MyBatisUserRepository.java │ │ └── service │ │ └── DefaultJwtService.java └── resources │ ├── application-test.properties │ ├── application.properties │ ├── db │ └── migration │ │ └── V1__create_tables.sql │ ├── mapper │ ├── ArticleFavoriteMapper.xml │ ├── ArticleFavoritesReadService.xml │ ├── ArticleMapper.xml │ ├── ArticleReadService.xml │ ├── CommentMapper.xml │ ├── CommentReadService.xml │ ├── TagReadService.xml │ ├── TransferData.xml │ ├── UserMapper.xml │ ├── UserReadService.xml │ └── UserRelationshipQueryService.xml │ └── schema │ └── schema.graphqls └── test └── java └── io └── spring ├── RealworldApplicationTests.java ├── TestHelper.java ├── api ├── ArticleApiTest.java ├── ArticleFavoriteApiTest.java ├── ArticlesApiTest.java ├── CommentsApiTest.java ├── CurrentUserApiTest.java ├── ListArticleApiTest.java ├── ProfileApiTest.java ├── TestWithCurrentUser.java └── UsersApiTest.java ├── application ├── article │ └── ArticleQueryServiceTest.java ├── comment │ └── CommentQueryServiceTest.java ├── profile │ └── ProfileQueryServiceTest.java └── tag │ └── TagsQueryServiceTest.java ├── core └── article │ └── ArticleTest.java └── infrastructure ├── DbTestBase.java ├── article ├── ArticleRepositoryTransactionTest.java └── MyBatisArticleRepositoryTest.java ├── comment └── MyBatisCommentRepositoryTest.java ├── favorite └── MyBatisArticleFavoriteRepositoryTest.java ├── service └── DefaultJwtServiceTest.java └── user └── MyBatisUserRepositoryTest.java /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | name: Java CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags: 8 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 9 | pull_request: 10 | branches: 11 | - '**' 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up JDK 11 21 | uses: actions/setup-java@v2 22 | with: 23 | distribution: zulu 24 | java-version: '11' 25 | - uses: actions/cache@v2 26 | with: 27 | path: | 28 | ~/.gradle/caches 29 | ~/.gradle/wrapper 30 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} 31 | restore-keys: | 32 | ${{ runner.os }}-gradle- 33 | - name: Test with Gradle 34 | run: ./gradlew clean test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | *.db 5 | 6 | ### STS ### 7 | .apt_generated 8 | .classpath 9 | .factorypath 10 | .project 11 | .settings 12 | .springBeans 13 | 14 | ### IntelliJ IDEA ### 15 | .idea 16 | *.iws 17 | *.iml 18 | *.ipr 19 | 20 | ### NetBeans ### 21 | nbproject/private/ 22 | build/ 23 | nbbuild/ 24 | dist/ 25 | nbdist/ 26 | .nb-gradle/ 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Aisensiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App using Kotlin and Spring](example-logo.png) 2 | 3 | [![Actions](https://github.com/gothinkster/spring-boot-realworld-example-app/workflows/Java%20CI/badge.svg)](https://github.com/gothinkster/spring-boot-realworld-example-app/actions) 4 | 5 | > ### Spring boot + MyBatis codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) spec and API. 6 | 7 | This codebase was created to demonstrate a fully fledged full-stack application built with Spring boot + Mybatis including CRUD operations, authentication, routing, pagination, and more. 8 | 9 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 10 | 11 | # *NEW* GraphQL Support 12 | 13 | Following some DDD principles. REST or GraphQL is just a kind of adapter. And the domain layer will be consistent all the time. So this repository implement GraphQL and REST at the same time. 14 | 15 | The GraphQL schema is https://github.com/gothinkster/spring-boot-realworld-example-app/blob/master/src/main/resources/schema/schema.graphqls and the visualization looks like below. 16 | 17 | ![](graphql-schema.png) 18 | 19 | And this implementation is using [dgs-framework](https://github.com/Netflix/dgs-framework) which is a quite new java graphql server framework. 20 | # How it works 21 | 22 | The application uses Spring Boot (Web, Mybatis). 23 | 24 | * Use the idea of Domain Driven Design to separate the business term and infrastructure term. 25 | * Use MyBatis to implement the [Data Mapper](https://martinfowler.com/eaaCatalog/dataMapper.html) pattern for persistence. 26 | * Use [CQRS](https://martinfowler.com/bliki/CQRS.html) pattern to separate the read model and write model. 27 | 28 | And the code is organized as this: 29 | 30 | 1. `api` is the web layer implemented by Spring MVC 31 | 2. `core` is the business model including entities and services 32 | 3. `application` is the high-level services for querying the data transfer objects 33 | 4. `infrastructure` contains all the implementation classes as the technique details 34 | 35 | # Security 36 | 37 | Integration with Spring Security and add other filter for jwt token process. 38 | 39 | The secret key is stored in `application.properties`. 40 | 41 | # Database 42 | 43 | It uses a ~~H2 in-memory database~~ sqlite database (for easy local test without losing test data after every restart), can be changed easily in the `application.properties` for any other database. 44 | 45 | # Getting started 46 | 47 | You'll need Java 11 installed. 48 | 49 | ./gradlew bootRun 50 | 51 | To test that it works, open a browser tab at http://localhost:8080/tags . 52 | Alternatively, you can run 53 | 54 | curl http://localhost:8080/tags 55 | 56 | # Try it out with [Docker](https://www.docker.com/) 57 | 58 | You'll need Docker installed. 59 | 60 | ./gradlew bootBuildImage --imageName spring-boot-realworld-example-app 61 | docker run -p 8081:8080 spring-boot-realworld-example-app 62 | 63 | # Try it out with a RealWorld frontend 64 | 65 | The entry point address of the backend API is at http://localhost:8080, **not** http://localhost:8080/api as some of the frontend documentation suggests. 66 | 67 | # Run test 68 | 69 | The repository contains a lot of test cases to cover both api test and repository test. 70 | 71 | ./gradlew test 72 | 73 | # Code format 74 | 75 | Use spotless for code format. 76 | 77 | ./gradlew spotlessJavaApply 78 | 79 | # Help 80 | 81 | Please fork and PR to improve the project. 82 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'org.springframework.boot' version '2.6.3' 3 | id 'io.spring.dependency-management' version '1.0.11.RELEASE' 4 | id 'java' 5 | id "com.netflix.dgs.codegen" version "5.0.6" 6 | id "com.diffplug.spotless" version "6.2.1" 7 | } 8 | 9 | version = '0.0.1-SNAPSHOT' 10 | sourceCompatibility = '11' 11 | targetCompatibility = '11' 12 | 13 | spotless { 14 | java { 15 | target project.fileTree(project.rootDir) { 16 | include '**/*.java' 17 | exclude 'build/generated/**/*.*', 'build/generated-examples/**/*.*' 18 | } 19 | googleJavaFormat() 20 | } 21 | } 22 | 23 | repositories { 24 | mavenCentral() 25 | } 26 | 27 | configurations { 28 | compileOnly { 29 | extendsFrom annotationProcessor 30 | } 31 | } 32 | 33 | dependencies { 34 | implementation 'org.springframework.boot:spring-boot-starter-web' 35 | implementation 'org.springframework.boot:spring-boot-starter-validation' 36 | implementation 'org.springframework.boot:spring-boot-starter-hateoas' 37 | implementation 'org.springframework.boot:spring-boot-starter-security' 38 | implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2' 39 | implementation 'com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter:4.9.21' 40 | implementation 'org.flywaydb:flyway-core' 41 | implementation 'io.jsonwebtoken:jjwt-api:0.11.2' 42 | runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2', 43 | 'io.jsonwebtoken:jjwt-jackson:0.11.2' 44 | implementation 'joda-time:joda-time:2.10.13' 45 | implementation 'org.xerial:sqlite-jdbc:3.36.0.3' 46 | 47 | compileOnly 'org.projectlombok:lombok' 48 | annotationProcessor 'org.projectlombok:lombok' 49 | 50 | testImplementation 'io.rest-assured:rest-assured:4.5.1' 51 | testImplementation 'io.rest-assured:json-path:4.5.1' 52 | testImplementation 'io.rest-assured:xml-path:4.5.1' 53 | testImplementation 'io.rest-assured:spring-mock-mvc:4.5.1' 54 | testImplementation 'org.springframework.security:spring-security-test' 55 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 56 | testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:2.2.2' 57 | } 58 | 59 | tasks.named('test') { 60 | useJUnitPlatform() 61 | } 62 | 63 | tasks.named('clean') { 64 | doFirst { 65 | delete './dev.db' 66 | } 67 | } 68 | 69 | tasks.named('generateJava') { 70 | schemaPaths = ["${projectDir}/src/main/resources/schema"] // List of directories containing schema files 71 | packageName = 'io.spring.graphql' // The package name to use to generate sources 72 | } 73 | -------------------------------------------------------------------------------- /example-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/spring-boot-realworld-example-app/ee17e31aafe733d98c4853c8b9a74d7f2f6c924a/example-logo.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/spring-boot-realworld-example-app/ee17e31aafe733d98c4853c8b9a74d7f2f6c924a/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /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 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /graphql-schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/spring-boot-realworld-example-app/ee17e31aafe733d98c4853c8b9a74d7f2f6c924a/graphql-schema.png -------------------------------------------------------------------------------- /src/main/java/io/spring/JacksonCustomizations.java: -------------------------------------------------------------------------------- 1 | package io.spring; 2 | 3 | import com.fasterxml.jackson.core.JsonGenerator; 4 | import com.fasterxml.jackson.databind.Module; 5 | import com.fasterxml.jackson.databind.SerializerProvider; 6 | import com.fasterxml.jackson.databind.module.SimpleModule; 7 | import com.fasterxml.jackson.databind.ser.std.StdSerializer; 8 | import java.io.IOException; 9 | import org.joda.time.DateTime; 10 | import org.joda.time.format.ISODateTimeFormat; 11 | import org.springframework.context.annotation.Bean; 12 | import org.springframework.context.annotation.Configuration; 13 | 14 | @Configuration 15 | public class JacksonCustomizations { 16 | 17 | @Bean 18 | public Module realWorldModules() { 19 | return new RealWorldModules(); 20 | } 21 | 22 | public static class RealWorldModules extends SimpleModule { 23 | public RealWorldModules() { 24 | addSerializer(DateTime.class, new DateTimeSerializer()); 25 | } 26 | } 27 | 28 | public static class DateTimeSerializer extends StdSerializer { 29 | 30 | protected DateTimeSerializer() { 31 | super(DateTime.class); 32 | } 33 | 34 | @Override 35 | public void serialize(DateTime value, JsonGenerator gen, SerializerProvider provider) 36 | throws IOException { 37 | if (value == null) { 38 | gen.writeNull(); 39 | } else { 40 | gen.writeString(ISODateTimeFormat.dateTime().withZoneUTC().print(value)); 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/io/spring/MyBatisConfig.java: -------------------------------------------------------------------------------- 1 | package io.spring; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.transaction.annotation.EnableTransactionManagement; 5 | 6 | @Configuration 7 | @EnableTransactionManagement 8 | public class MyBatisConfig {} 9 | -------------------------------------------------------------------------------- /src/main/java/io/spring/RealWorldApplication.java: -------------------------------------------------------------------------------- 1 | package io.spring; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class RealWorldApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(RealWorldApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/io/spring/Util.java: -------------------------------------------------------------------------------- 1 | package io.spring; 2 | 3 | public class Util { 4 | public static boolean isEmpty(String value) { 5 | return value == null || value.isEmpty(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/ArticleApi.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import io.spring.api.exception.NoAuthorizationException; 4 | import io.spring.api.exception.ResourceNotFoundException; 5 | import io.spring.application.ArticleQueryService; 6 | import io.spring.application.article.ArticleCommandService; 7 | import io.spring.application.article.UpdateArticleParam; 8 | import io.spring.application.data.ArticleData; 9 | import io.spring.core.article.Article; 10 | import io.spring.core.article.ArticleRepository; 11 | import io.spring.core.service.AuthorizationService; 12 | import io.spring.core.user.User; 13 | import java.util.HashMap; 14 | import java.util.Map; 15 | import javax.validation.Valid; 16 | import lombok.AllArgsConstructor; 17 | import org.springframework.http.ResponseEntity; 18 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 19 | import org.springframework.web.bind.annotation.DeleteMapping; 20 | import org.springframework.web.bind.annotation.GetMapping; 21 | import org.springframework.web.bind.annotation.PathVariable; 22 | import org.springframework.web.bind.annotation.PutMapping; 23 | import org.springframework.web.bind.annotation.RequestBody; 24 | import org.springframework.web.bind.annotation.RequestMapping; 25 | import org.springframework.web.bind.annotation.RestController; 26 | 27 | @RestController 28 | @RequestMapping(path = "/articles/{slug}") 29 | @AllArgsConstructor 30 | public class ArticleApi { 31 | private ArticleQueryService articleQueryService; 32 | private ArticleRepository articleRepository; 33 | private ArticleCommandService articleCommandService; 34 | 35 | @GetMapping 36 | public ResponseEntity article( 37 | @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { 38 | return articleQueryService 39 | .findBySlug(slug, user) 40 | .map(articleData -> ResponseEntity.ok(articleResponse(articleData))) 41 | .orElseThrow(ResourceNotFoundException::new); 42 | } 43 | 44 | @PutMapping 45 | public ResponseEntity updateArticle( 46 | @PathVariable("slug") String slug, 47 | @AuthenticationPrincipal User user, 48 | @Valid @RequestBody UpdateArticleParam updateArticleParam) { 49 | return articleRepository 50 | .findBySlug(slug) 51 | .map( 52 | article -> { 53 | if (!AuthorizationService.canWriteArticle(user, article)) { 54 | throw new NoAuthorizationException(); 55 | } 56 | Article updatedArticle = 57 | articleCommandService.updateArticle(article, updateArticleParam); 58 | return ResponseEntity.ok( 59 | articleResponse( 60 | articleQueryService.findBySlug(updatedArticle.getSlug(), user).get())); 61 | }) 62 | .orElseThrow(ResourceNotFoundException::new); 63 | } 64 | 65 | @DeleteMapping 66 | public ResponseEntity deleteArticle( 67 | @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { 68 | return articleRepository 69 | .findBySlug(slug) 70 | .map( 71 | article -> { 72 | if (!AuthorizationService.canWriteArticle(user, article)) { 73 | throw new NoAuthorizationException(); 74 | } 75 | articleRepository.remove(article); 76 | return ResponseEntity.noContent().build(); 77 | }) 78 | .orElseThrow(ResourceNotFoundException::new); 79 | } 80 | 81 | private Map articleResponse(ArticleData articleData) { 82 | return new HashMap() { 83 | { 84 | put("article", articleData); 85 | } 86 | }; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/ArticleFavoriteApi.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import io.spring.api.exception.ResourceNotFoundException; 4 | import io.spring.application.ArticleQueryService; 5 | import io.spring.application.data.ArticleData; 6 | import io.spring.core.article.Article; 7 | import io.spring.core.article.ArticleRepository; 8 | import io.spring.core.favorite.ArticleFavorite; 9 | import io.spring.core.favorite.ArticleFavoriteRepository; 10 | import io.spring.core.user.User; 11 | import java.util.HashMap; 12 | import lombok.AllArgsConstructor; 13 | import org.springframework.http.ResponseEntity; 14 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 15 | import org.springframework.web.bind.annotation.DeleteMapping; 16 | import org.springframework.web.bind.annotation.PathVariable; 17 | import org.springframework.web.bind.annotation.PostMapping; 18 | import org.springframework.web.bind.annotation.RequestMapping; 19 | import org.springframework.web.bind.annotation.RestController; 20 | 21 | @RestController 22 | @RequestMapping(path = "articles/{slug}/favorite") 23 | @AllArgsConstructor 24 | public class ArticleFavoriteApi { 25 | private ArticleFavoriteRepository articleFavoriteRepository; 26 | private ArticleRepository articleRepository; 27 | private ArticleQueryService articleQueryService; 28 | 29 | @PostMapping 30 | public ResponseEntity favoriteArticle( 31 | @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { 32 | Article article = 33 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 34 | ArticleFavorite articleFavorite = new ArticleFavorite(article.getId(), user.getId()); 35 | articleFavoriteRepository.save(articleFavorite); 36 | return responseArticleData(articleQueryService.findBySlug(slug, user).get()); 37 | } 38 | 39 | @DeleteMapping 40 | public ResponseEntity unfavoriteArticle( 41 | @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { 42 | Article article = 43 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 44 | articleFavoriteRepository 45 | .find(article.getId(), user.getId()) 46 | .ifPresent( 47 | favorite -> { 48 | articleFavoriteRepository.remove(favorite); 49 | }); 50 | return responseArticleData(articleQueryService.findBySlug(slug, user).get()); 51 | } 52 | 53 | private ResponseEntity> responseArticleData( 54 | final ArticleData articleData) { 55 | return ResponseEntity.ok( 56 | new HashMap() { 57 | { 58 | put("article", articleData); 59 | } 60 | }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/ArticlesApi.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import io.spring.application.ArticleQueryService; 4 | import io.spring.application.Page; 5 | import io.spring.application.article.ArticleCommandService; 6 | import io.spring.application.article.NewArticleParam; 7 | import io.spring.core.article.Article; 8 | import io.spring.core.user.User; 9 | import java.util.HashMap; 10 | import javax.validation.Valid; 11 | import lombok.AllArgsConstructor; 12 | import org.springframework.http.ResponseEntity; 13 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 14 | import org.springframework.web.bind.annotation.GetMapping; 15 | import org.springframework.web.bind.annotation.PostMapping; 16 | import org.springframework.web.bind.annotation.RequestBody; 17 | import org.springframework.web.bind.annotation.RequestMapping; 18 | import org.springframework.web.bind.annotation.RequestParam; 19 | import org.springframework.web.bind.annotation.RestController; 20 | 21 | @RestController 22 | @RequestMapping(path = "/articles") 23 | @AllArgsConstructor 24 | public class ArticlesApi { 25 | private ArticleCommandService articleCommandService; 26 | private ArticleQueryService articleQueryService; 27 | 28 | @PostMapping 29 | public ResponseEntity createArticle( 30 | @Valid @RequestBody NewArticleParam newArticleParam, @AuthenticationPrincipal User user) { 31 | Article article = articleCommandService.createArticle(newArticleParam, user); 32 | return ResponseEntity.ok( 33 | new HashMap() { 34 | { 35 | put("article", articleQueryService.findById(article.getId(), user).get()); 36 | } 37 | }); 38 | } 39 | 40 | @GetMapping(path = "feed") 41 | public ResponseEntity getFeed( 42 | @RequestParam(value = "offset", defaultValue = "0") int offset, 43 | @RequestParam(value = "limit", defaultValue = "20") int limit, 44 | @AuthenticationPrincipal User user) { 45 | return ResponseEntity.ok(articleQueryService.findUserFeed(user, new Page(offset, limit))); 46 | } 47 | 48 | @GetMapping 49 | public ResponseEntity getArticles( 50 | @RequestParam(value = "offset", defaultValue = "0") int offset, 51 | @RequestParam(value = "limit", defaultValue = "20") int limit, 52 | @RequestParam(value = "tag", required = false) String tag, 53 | @RequestParam(value = "favorited", required = false) String favoritedBy, 54 | @RequestParam(value = "author", required = false) String author, 55 | @AuthenticationPrincipal User user) { 56 | return ResponseEntity.ok( 57 | articleQueryService.findRecentArticles( 58 | tag, author, favoritedBy, new Page(offset, limit), user)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/CommentsApi.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName; 4 | import io.spring.api.exception.NoAuthorizationException; 5 | import io.spring.api.exception.ResourceNotFoundException; 6 | import io.spring.application.CommentQueryService; 7 | import io.spring.application.data.CommentData; 8 | import io.spring.core.article.Article; 9 | import io.spring.core.article.ArticleRepository; 10 | import io.spring.core.comment.Comment; 11 | import io.spring.core.comment.CommentRepository; 12 | import io.spring.core.service.AuthorizationService; 13 | import io.spring.core.user.User; 14 | import java.util.HashMap; 15 | import java.util.List; 16 | import java.util.Map; 17 | import javax.validation.Valid; 18 | import javax.validation.constraints.NotBlank; 19 | import lombok.AllArgsConstructor; 20 | import lombok.Getter; 21 | import lombok.NoArgsConstructor; 22 | import org.springframework.http.ResponseEntity; 23 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 24 | import org.springframework.web.bind.annotation.GetMapping; 25 | import org.springframework.web.bind.annotation.PathVariable; 26 | import org.springframework.web.bind.annotation.PostMapping; 27 | import org.springframework.web.bind.annotation.RequestBody; 28 | import org.springframework.web.bind.annotation.RequestMapping; 29 | import org.springframework.web.bind.annotation.RequestMethod; 30 | import org.springframework.web.bind.annotation.RestController; 31 | 32 | @RestController 33 | @RequestMapping(path = "/articles/{slug}/comments") 34 | @AllArgsConstructor 35 | public class CommentsApi { 36 | private ArticleRepository articleRepository; 37 | private CommentRepository commentRepository; 38 | private CommentQueryService commentQueryService; 39 | 40 | @PostMapping 41 | public ResponseEntity createComment( 42 | @PathVariable("slug") String slug, 43 | @AuthenticationPrincipal User user, 44 | @Valid @RequestBody NewCommentParam newCommentParam) { 45 | Article article = 46 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 47 | Comment comment = new Comment(newCommentParam.getBody(), user.getId(), article.getId()); 48 | commentRepository.save(comment); 49 | return ResponseEntity.status(201) 50 | .body(commentResponse(commentQueryService.findById(comment.getId(), user).get())); 51 | } 52 | 53 | @GetMapping 54 | public ResponseEntity getComments( 55 | @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { 56 | Article article = 57 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 58 | List comments = commentQueryService.findByArticleId(article.getId(), user); 59 | return ResponseEntity.ok( 60 | new HashMap() { 61 | { 62 | put("comments", comments); 63 | } 64 | }); 65 | } 66 | 67 | @RequestMapping(path = "{id}", method = RequestMethod.DELETE) 68 | public ResponseEntity deleteComment( 69 | @PathVariable("slug") String slug, 70 | @PathVariable("id") String commentId, 71 | @AuthenticationPrincipal User user) { 72 | Article article = 73 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 74 | return commentRepository 75 | .findById(article.getId(), commentId) 76 | .map( 77 | comment -> { 78 | if (!AuthorizationService.canWriteComment(user, article, comment)) { 79 | throw new NoAuthorizationException(); 80 | } 81 | commentRepository.remove(comment); 82 | return ResponseEntity.noContent().build(); 83 | }) 84 | .orElseThrow(ResourceNotFoundException::new); 85 | } 86 | 87 | private Map commentResponse(CommentData commentData) { 88 | return new HashMap() { 89 | { 90 | put("comment", commentData); 91 | } 92 | }; 93 | } 94 | } 95 | 96 | @Getter 97 | @NoArgsConstructor 98 | @JsonRootName("comment") 99 | class NewCommentParam { 100 | @NotBlank(message = "can't be empty") 101 | private String body; 102 | } 103 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/CurrentUserApi.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import io.spring.application.UserQueryService; 4 | import io.spring.application.data.UserData; 5 | import io.spring.application.data.UserWithToken; 6 | import io.spring.application.user.UpdateUserCommand; 7 | import io.spring.application.user.UpdateUserParam; 8 | import io.spring.application.user.UserService; 9 | import io.spring.core.user.User; 10 | import java.util.HashMap; 11 | import java.util.Map; 12 | import javax.validation.Valid; 13 | import lombok.AllArgsConstructor; 14 | import org.springframework.http.ResponseEntity; 15 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 16 | import org.springframework.web.bind.annotation.GetMapping; 17 | import org.springframework.web.bind.annotation.PutMapping; 18 | import org.springframework.web.bind.annotation.RequestBody; 19 | import org.springframework.web.bind.annotation.RequestHeader; 20 | import org.springframework.web.bind.annotation.RequestMapping; 21 | import org.springframework.web.bind.annotation.RestController; 22 | 23 | @RestController 24 | @RequestMapping(path = "/user") 25 | @AllArgsConstructor 26 | public class CurrentUserApi { 27 | 28 | private UserQueryService userQueryService; 29 | private UserService userService; 30 | 31 | @GetMapping 32 | public ResponseEntity currentUser( 33 | @AuthenticationPrincipal User currentUser, 34 | @RequestHeader(value = "Authorization") String authorization) { 35 | UserData userData = userQueryService.findById(currentUser.getId()).get(); 36 | return ResponseEntity.ok( 37 | userResponse(new UserWithToken(userData, authorization.split(" ")[1]))); 38 | } 39 | 40 | @PutMapping 41 | public ResponseEntity updateProfile( 42 | @AuthenticationPrincipal User currentUser, 43 | @RequestHeader("Authorization") String token, 44 | @Valid @RequestBody UpdateUserParam updateUserParam) { 45 | 46 | userService.updateUser(new UpdateUserCommand(currentUser, updateUserParam)); 47 | UserData userData = userQueryService.findById(currentUser.getId()).get(); 48 | return ResponseEntity.ok(userResponse(new UserWithToken(userData, token.split(" ")[1]))); 49 | } 50 | 51 | private Map userResponse(UserWithToken userWithToken) { 52 | return new HashMap() { 53 | { 54 | put("user", userWithToken); 55 | } 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/ProfileApi.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import io.spring.api.exception.ResourceNotFoundException; 4 | import io.spring.application.ProfileQueryService; 5 | import io.spring.application.data.ProfileData; 6 | import io.spring.core.user.FollowRelation; 7 | import io.spring.core.user.User; 8 | import io.spring.core.user.UserRepository; 9 | import java.util.HashMap; 10 | import java.util.Optional; 11 | import lombok.AllArgsConstructor; 12 | import org.springframework.http.ResponseEntity; 13 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 14 | import org.springframework.web.bind.annotation.DeleteMapping; 15 | import org.springframework.web.bind.annotation.GetMapping; 16 | import org.springframework.web.bind.annotation.PathVariable; 17 | import org.springframework.web.bind.annotation.PostMapping; 18 | import org.springframework.web.bind.annotation.RequestMapping; 19 | import org.springframework.web.bind.annotation.RestController; 20 | 21 | @RestController 22 | @RequestMapping(path = "profiles/{username}") 23 | @AllArgsConstructor 24 | public class ProfileApi { 25 | private ProfileQueryService profileQueryService; 26 | private UserRepository userRepository; 27 | 28 | @GetMapping 29 | public ResponseEntity getProfile( 30 | @PathVariable("username") String username, @AuthenticationPrincipal User user) { 31 | return profileQueryService 32 | .findByUsername(username, user) 33 | .map(this::profileResponse) 34 | .orElseThrow(ResourceNotFoundException::new); 35 | } 36 | 37 | @PostMapping(path = "follow") 38 | public ResponseEntity follow( 39 | @PathVariable("username") String username, @AuthenticationPrincipal User user) { 40 | return userRepository 41 | .findByUsername(username) 42 | .map( 43 | target -> { 44 | FollowRelation followRelation = new FollowRelation(user.getId(), target.getId()); 45 | userRepository.saveRelation(followRelation); 46 | return profileResponse(profileQueryService.findByUsername(username, user).get()); 47 | }) 48 | .orElseThrow(ResourceNotFoundException::new); 49 | } 50 | 51 | @DeleteMapping(path = "follow") 52 | public ResponseEntity unfollow( 53 | @PathVariable("username") String username, @AuthenticationPrincipal User user) { 54 | Optional userOptional = userRepository.findByUsername(username); 55 | if (userOptional.isPresent()) { 56 | User target = userOptional.get(); 57 | return userRepository 58 | .findRelation(user.getId(), target.getId()) 59 | .map( 60 | relation -> { 61 | userRepository.removeRelation(relation); 62 | return profileResponse(profileQueryService.findByUsername(username, user).get()); 63 | }) 64 | .orElseThrow(ResourceNotFoundException::new); 65 | } else { 66 | throw new ResourceNotFoundException(); 67 | } 68 | } 69 | 70 | private ResponseEntity profileResponse(ProfileData profile) { 71 | return ResponseEntity.ok( 72 | new HashMap() { 73 | { 74 | put("profile", profile); 75 | } 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/TagsApi.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import io.spring.application.TagsQueryService; 4 | import java.util.HashMap; 5 | import lombok.AllArgsConstructor; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | @RestController 12 | @RequestMapping(path = "tags") 13 | @AllArgsConstructor 14 | public class TagsApi { 15 | private TagsQueryService tagsQueryService; 16 | 17 | @GetMapping 18 | public ResponseEntity getTags() { 19 | return ResponseEntity.ok( 20 | new HashMap() { 21 | { 22 | put("tags", tagsQueryService.allTags()); 23 | } 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/UsersApi.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import static org.springframework.web.bind.annotation.RequestMethod.POST; 4 | 5 | import com.fasterxml.jackson.annotation.JsonRootName; 6 | import io.spring.api.exception.InvalidAuthenticationException; 7 | import io.spring.application.UserQueryService; 8 | import io.spring.application.data.UserData; 9 | import io.spring.application.data.UserWithToken; 10 | import io.spring.application.user.RegisterParam; 11 | import io.spring.application.user.UserService; 12 | import io.spring.core.service.JwtService; 13 | import io.spring.core.user.User; 14 | import io.spring.core.user.UserRepository; 15 | import java.util.HashMap; 16 | import java.util.Map; 17 | import java.util.Optional; 18 | import javax.validation.Valid; 19 | import javax.validation.constraints.Email; 20 | import javax.validation.constraints.NotBlank; 21 | import lombok.AllArgsConstructor; 22 | import lombok.Getter; 23 | import lombok.NoArgsConstructor; 24 | import org.springframework.http.ResponseEntity; 25 | import org.springframework.security.crypto.password.PasswordEncoder; 26 | import org.springframework.web.bind.annotation.RequestBody; 27 | import org.springframework.web.bind.annotation.RequestMapping; 28 | import org.springframework.web.bind.annotation.RestController; 29 | 30 | @RestController 31 | @AllArgsConstructor 32 | public class UsersApi { 33 | private UserRepository userRepository; 34 | private UserQueryService userQueryService; 35 | private PasswordEncoder passwordEncoder; 36 | private JwtService jwtService; 37 | private UserService userService; 38 | 39 | @RequestMapping(path = "/users", method = POST) 40 | public ResponseEntity createUser(@Valid @RequestBody RegisterParam registerParam) { 41 | User user = userService.createUser(registerParam); 42 | UserData userData = userQueryService.findById(user.getId()).get(); 43 | return ResponseEntity.status(201) 44 | .body(userResponse(new UserWithToken(userData, jwtService.toToken(user)))); 45 | } 46 | 47 | @RequestMapping(path = "/users/login", method = POST) 48 | public ResponseEntity userLogin(@Valid @RequestBody LoginParam loginParam) { 49 | Optional optional = userRepository.findByEmail(loginParam.getEmail()); 50 | if (optional.isPresent() 51 | && passwordEncoder.matches(loginParam.getPassword(), optional.get().getPassword())) { 52 | UserData userData = userQueryService.findById(optional.get().getId()).get(); 53 | return ResponseEntity.ok( 54 | userResponse(new UserWithToken(userData, jwtService.toToken(optional.get())))); 55 | } else { 56 | throw new InvalidAuthenticationException(); 57 | } 58 | } 59 | 60 | private Map userResponse(UserWithToken userWithToken) { 61 | return new HashMap() { 62 | { 63 | put("user", userWithToken); 64 | } 65 | }; 66 | } 67 | } 68 | 69 | @Getter 70 | @JsonRootName("user") 71 | @NoArgsConstructor 72 | class LoginParam { 73 | @NotBlank(message = "can't be empty") 74 | @Email(message = "should be an email") 75 | private String email; 76 | 77 | @NotBlank(message = "can't be empty") 78 | private String password; 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/exception/CustomizeExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.spring.api.exception; 2 | 3 | import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; 4 | 5 | import java.util.ArrayList; 6 | import java.util.Arrays; 7 | import java.util.HashMap; 8 | import java.util.List; 9 | import java.util.stream.Collectors; 10 | import javax.validation.ConstraintViolation; 11 | import javax.validation.ConstraintViolationException; 12 | import org.springframework.http.HttpHeaders; 13 | import org.springframework.http.HttpStatus; 14 | import org.springframework.http.MediaType; 15 | import org.springframework.http.ResponseEntity; 16 | import org.springframework.web.bind.MethodArgumentNotValidException; 17 | import org.springframework.web.bind.annotation.ExceptionHandler; 18 | import org.springframework.web.bind.annotation.ResponseBody; 19 | import org.springframework.web.bind.annotation.ResponseStatus; 20 | import org.springframework.web.bind.annotation.RestControllerAdvice; 21 | import org.springframework.web.context.request.WebRequest; 22 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 23 | 24 | @RestControllerAdvice 25 | public class CustomizeExceptionHandler extends ResponseEntityExceptionHandler { 26 | 27 | @ExceptionHandler({InvalidRequestException.class}) 28 | public ResponseEntity handleInvalidRequest(RuntimeException e, WebRequest request) { 29 | InvalidRequestException ire = (InvalidRequestException) e; 30 | 31 | List errorResources = 32 | ire.getErrors().getFieldErrors().stream() 33 | .map( 34 | fieldError -> 35 | new FieldErrorResource( 36 | fieldError.getObjectName(), 37 | fieldError.getField(), 38 | fieldError.getCode(), 39 | fieldError.getDefaultMessage())) 40 | .collect(Collectors.toList()); 41 | 42 | ErrorResource error = new ErrorResource(errorResources); 43 | 44 | HttpHeaders headers = new HttpHeaders(); 45 | headers.setContentType(MediaType.APPLICATION_JSON); 46 | 47 | return handleExceptionInternal(e, error, headers, UNPROCESSABLE_ENTITY, request); 48 | } 49 | 50 | @ExceptionHandler(InvalidAuthenticationException.class) 51 | public ResponseEntity handleInvalidAuthentication( 52 | InvalidAuthenticationException e, WebRequest request) { 53 | return ResponseEntity.status(UNPROCESSABLE_ENTITY) 54 | .body( 55 | new HashMap() { 56 | { 57 | put("message", e.getMessage()); 58 | } 59 | }); 60 | } 61 | 62 | @Override 63 | protected ResponseEntity handleMethodArgumentNotValid( 64 | MethodArgumentNotValidException e, 65 | HttpHeaders headers, 66 | HttpStatus status, 67 | WebRequest request) { 68 | List errorResources = 69 | e.getBindingResult().getFieldErrors().stream() 70 | .map( 71 | fieldError -> 72 | new FieldErrorResource( 73 | fieldError.getObjectName(), 74 | fieldError.getField(), 75 | fieldError.getCode(), 76 | fieldError.getDefaultMessage())) 77 | .collect(Collectors.toList()); 78 | 79 | return ResponseEntity.status(UNPROCESSABLE_ENTITY).body(new ErrorResource(errorResources)); 80 | } 81 | 82 | @ExceptionHandler({ConstraintViolationException.class}) 83 | @ResponseStatus(UNPROCESSABLE_ENTITY) 84 | @ResponseBody 85 | public ErrorResource handleConstraintViolation( 86 | ConstraintViolationException ex, WebRequest request) { 87 | List errors = new ArrayList<>(); 88 | for (ConstraintViolation violation : ex.getConstraintViolations()) { 89 | FieldErrorResource fieldErrorResource = 90 | new FieldErrorResource( 91 | violation.getRootBeanClass().getName(), 92 | getParam(violation.getPropertyPath().toString()), 93 | violation.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(), 94 | violation.getMessage()); 95 | errors.add(fieldErrorResource); 96 | } 97 | 98 | return new ErrorResource(errors); 99 | } 100 | 101 | private String getParam(String s) { 102 | String[] splits = s.split("\\."); 103 | if (splits.length == 1) { 104 | return s; 105 | } else { 106 | return String.join(".", Arrays.copyOfRange(splits, 2, splits.length)); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/exception/ErrorResource.java: -------------------------------------------------------------------------------- 1 | package io.spring.api.exception; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonRootName; 5 | import com.fasterxml.jackson.databind.annotation.JsonSerialize; 6 | import java.util.List; 7 | 8 | @JsonSerialize(using = ErrorResourceSerializer.class) 9 | @JsonIgnoreProperties(ignoreUnknown = true) 10 | @lombok.Getter 11 | @JsonRootName("errors") 12 | public class ErrorResource { 13 | private List fieldErrors; 14 | 15 | public ErrorResource(List fieldErrorResources) { 16 | this.fieldErrors = fieldErrorResources; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/exception/ErrorResourceSerializer.java: -------------------------------------------------------------------------------- 1 | package io.spring.api.exception; 2 | 3 | import com.fasterxml.jackson.core.JsonGenerator; 4 | import com.fasterxml.jackson.core.JsonProcessingException; 5 | import com.fasterxml.jackson.databind.JsonSerializer; 6 | import com.fasterxml.jackson.databind.SerializerProvider; 7 | import java.io.IOException; 8 | import java.util.ArrayList; 9 | import java.util.HashMap; 10 | import java.util.List; 11 | import java.util.Map; 12 | 13 | public class ErrorResourceSerializer extends JsonSerializer { 14 | @Override 15 | public void serialize(ErrorResource value, JsonGenerator gen, SerializerProvider serializers) 16 | throws IOException, JsonProcessingException { 17 | Map> json = new HashMap<>(); 18 | gen.writeStartObject(); 19 | gen.writeObjectFieldStart("errors"); 20 | for (FieldErrorResource fieldErrorResource : value.getFieldErrors()) { 21 | if (!json.containsKey(fieldErrorResource.getField())) { 22 | json.put(fieldErrorResource.getField(), new ArrayList()); 23 | } 24 | json.get(fieldErrorResource.getField()).add(fieldErrorResource.getMessage()); 25 | } 26 | for (Map.Entry> pair : json.entrySet()) { 27 | gen.writeArrayFieldStart(pair.getKey()); 28 | pair.getValue() 29 | .forEach( 30 | content -> { 31 | try { 32 | gen.writeString(content); 33 | } catch (IOException e) { 34 | e.printStackTrace(); 35 | } 36 | }); 37 | gen.writeEndArray(); 38 | } 39 | gen.writeEndObject(); 40 | gen.writeEndObject(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/exception/FieldErrorResource.java: -------------------------------------------------------------------------------- 1 | package io.spring.api.exception; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | 7 | @JsonIgnoreProperties(ignoreUnknown = true) 8 | @Getter 9 | @AllArgsConstructor 10 | public class FieldErrorResource { 11 | private String resource; 12 | private String field; 13 | private String code; 14 | private String message; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/exception/InvalidAuthenticationException.java: -------------------------------------------------------------------------------- 1 | package io.spring.api.exception; 2 | 3 | public class InvalidAuthenticationException extends RuntimeException { 4 | 5 | public InvalidAuthenticationException() { 6 | super("invalid email or password"); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/exception/InvalidRequestException.java: -------------------------------------------------------------------------------- 1 | package io.spring.api.exception; 2 | 3 | import org.springframework.validation.Errors; 4 | 5 | @SuppressWarnings("serial") 6 | public class InvalidRequestException extends RuntimeException { 7 | private final Errors errors; 8 | 9 | public InvalidRequestException(Errors errors) { 10 | super(""); 11 | this.errors = errors; 12 | } 13 | 14 | public Errors getErrors() { 15 | return errors; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/exception/NoAuthorizationException.java: -------------------------------------------------------------------------------- 1 | package io.spring.api.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.FORBIDDEN) 7 | public class NoAuthorizationException extends RuntimeException {} 8 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/exception/ResourceNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.spring.api.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(value = HttpStatus.NOT_FOUND) 7 | public class ResourceNotFoundException extends RuntimeException {} 8 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/security/JwtTokenFilter.java: -------------------------------------------------------------------------------- 1 | package io.spring.api.security; 2 | 3 | import io.spring.core.service.JwtService; 4 | import io.spring.core.user.UserRepository; 5 | import java.io.IOException; 6 | import java.util.Collections; 7 | import java.util.Optional; 8 | import javax.servlet.FilterChain; 9 | import javax.servlet.ServletException; 10 | import javax.servlet.http.HttpServletRequest; 11 | import javax.servlet.http.HttpServletResponse; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 14 | import org.springframework.security.core.context.SecurityContextHolder; 15 | import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; 16 | import org.springframework.web.filter.OncePerRequestFilter; 17 | 18 | @SuppressWarnings("SpringJavaAutowiringInspection") 19 | public class JwtTokenFilter extends OncePerRequestFilter { 20 | @Autowired private UserRepository userRepository; 21 | @Autowired private JwtService jwtService; 22 | private final String header = "Authorization"; 23 | 24 | @Override 25 | protected void doFilterInternal( 26 | HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 27 | throws ServletException, IOException { 28 | getTokenString(request.getHeader(header)) 29 | .flatMap(token -> jwtService.getSubFromToken(token)) 30 | .ifPresent( 31 | id -> { 32 | if (SecurityContextHolder.getContext().getAuthentication() == null) { 33 | userRepository 34 | .findById(id) 35 | .ifPresent( 36 | user -> { 37 | UsernamePasswordAuthenticationToken authenticationToken = 38 | new UsernamePasswordAuthenticationToken( 39 | user, null, Collections.emptyList()); 40 | authenticationToken.setDetails( 41 | new WebAuthenticationDetailsSource().buildDetails(request)); 42 | SecurityContextHolder.getContext().setAuthentication(authenticationToken); 43 | }); 44 | } 45 | }); 46 | 47 | filterChain.doFilter(request, response); 48 | } 49 | 50 | private Optional getTokenString(String header) { 51 | if (header == null) { 52 | return Optional.empty(); 53 | } else { 54 | String[] split = header.split(" "); 55 | if (split.length < 2) { 56 | return Optional.empty(); 57 | } else { 58 | return Optional.ofNullable(split[1]); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/io/spring/api/security/WebSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package io.spring.api.security; 2 | 3 | import static java.util.Arrays.asList; 4 | 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.http.HttpMethod; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 10 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 11 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 12 | import org.springframework.security.config.http.SessionCreationPolicy; 13 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 14 | import org.springframework.security.crypto.password.PasswordEncoder; 15 | import org.springframework.security.web.authentication.HttpStatusEntryPoint; 16 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 17 | import org.springframework.web.cors.CorsConfiguration; 18 | import org.springframework.web.cors.CorsConfigurationSource; 19 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 20 | 21 | @Configuration 22 | @EnableWebSecurity 23 | public class WebSecurityConfig extends WebSecurityConfigurerAdapter { 24 | 25 | @Bean 26 | public JwtTokenFilter jwtTokenFilter() { 27 | return new JwtTokenFilter(); 28 | } 29 | 30 | @Bean 31 | public PasswordEncoder passwordEncoder() { 32 | return new BCryptPasswordEncoder(); 33 | } 34 | 35 | @Override 36 | protected void configure(HttpSecurity http) throws Exception { 37 | 38 | http.csrf() 39 | .disable() 40 | .cors() 41 | .and() 42 | .exceptionHandling() 43 | .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) 44 | .and() 45 | .sessionManagement() 46 | .sessionCreationPolicy(SessionCreationPolicy.STATELESS) 47 | .and() 48 | .authorizeRequests() 49 | .antMatchers(HttpMethod.OPTIONS) 50 | .permitAll() 51 | .antMatchers("/graphiql") 52 | .permitAll() 53 | .antMatchers("/graphql") 54 | .permitAll() 55 | .antMatchers(HttpMethod.GET, "/articles/feed") 56 | .authenticated() 57 | .antMatchers(HttpMethod.POST, "/users", "/users/login") 58 | .permitAll() 59 | .antMatchers(HttpMethod.GET, "/articles/**", "/profiles/**", "/tags") 60 | .permitAll() 61 | .anyRequest() 62 | .authenticated(); 63 | 64 | http.addFilterBefore(jwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); 65 | } 66 | 67 | @Bean 68 | public CorsConfigurationSource corsConfigurationSource() { 69 | final CorsConfiguration configuration = new CorsConfiguration(); 70 | configuration.setAllowedOrigins(asList("*")); 71 | configuration.setAllowedMethods(asList("HEAD", "GET", "POST", "PUT", "DELETE", "PATCH")); 72 | // setAllowCredentials(true) is important, otherwise: 73 | // The value of the 'Access-Control-Allow-Origin' header in the response must not be the 74 | // wildcard '*' when the request's credentials mode is 'include'. 75 | configuration.setAllowCredentials(false); 76 | // setAllowedHeaders is important! Without it, OPTIONS preflight request 77 | // will fail with 403 Invalid CORS request 78 | configuration.setAllowedHeaders(asList("Authorization", "Cache-Control", "Content-Type")); 79 | final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 80 | source.registerCorsConfiguration("/**", configuration); 81 | return source; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/CommentQueryService.java: -------------------------------------------------------------------------------- 1 | package io.spring.application; 2 | 3 | import io.spring.application.data.CommentData; 4 | import io.spring.core.user.User; 5 | import io.spring.infrastructure.mybatis.readservice.CommentReadService; 6 | import io.spring.infrastructure.mybatis.readservice.UserRelationshipQueryService; 7 | import java.util.ArrayList; 8 | import java.util.Collections; 9 | import java.util.List; 10 | import java.util.Optional; 11 | import java.util.Set; 12 | import java.util.stream.Collectors; 13 | import lombok.AllArgsConstructor; 14 | import org.joda.time.DateTime; 15 | import org.springframework.stereotype.Service; 16 | 17 | @Service 18 | @AllArgsConstructor 19 | public class CommentQueryService { 20 | private CommentReadService commentReadService; 21 | private UserRelationshipQueryService userRelationshipQueryService; 22 | 23 | public Optional findById(String id, User user) { 24 | CommentData commentData = commentReadService.findById(id); 25 | if (commentData == null) { 26 | return Optional.empty(); 27 | } else { 28 | commentData 29 | .getProfileData() 30 | .setFollowing( 31 | userRelationshipQueryService.isUserFollowing( 32 | user.getId(), commentData.getProfileData().getId())); 33 | } 34 | return Optional.ofNullable(commentData); 35 | } 36 | 37 | public List findByArticleId(String articleId, User user) { 38 | List comments = commentReadService.findByArticleId(articleId); 39 | if (comments.size() > 0 && user != null) { 40 | Set followingAuthors = 41 | userRelationshipQueryService.followingAuthors( 42 | user.getId(), 43 | comments.stream() 44 | .map(commentData -> commentData.getProfileData().getId()) 45 | .collect(Collectors.toList())); 46 | comments.forEach( 47 | commentData -> { 48 | if (followingAuthors.contains(commentData.getProfileData().getId())) { 49 | commentData.getProfileData().setFollowing(true); 50 | } 51 | }); 52 | } 53 | return comments; 54 | } 55 | 56 | public CursorPager findByArticleIdWithCursor( 57 | String articleId, User user, CursorPageParameter page) { 58 | List comments = commentReadService.findByArticleIdWithCursor(articleId, page); 59 | if (comments.isEmpty()) { 60 | return new CursorPager<>(new ArrayList<>(), page.getDirection(), false); 61 | } 62 | if (user != null) { 63 | Set followingAuthors = 64 | userRelationshipQueryService.followingAuthors( 65 | user.getId(), 66 | comments.stream() 67 | .map(commentData -> commentData.getProfileData().getId()) 68 | .collect(Collectors.toList())); 69 | comments.forEach( 70 | commentData -> { 71 | if (followingAuthors.contains(commentData.getProfileData().getId())) { 72 | commentData.getProfileData().setFollowing(true); 73 | } 74 | }); 75 | } 76 | boolean hasExtra = comments.size() > page.getLimit(); 77 | if (hasExtra) { 78 | comments.remove(page.getLimit()); 79 | } 80 | if (!page.isNext()) { 81 | Collections.reverse(comments); 82 | } 83 | return new CursorPager<>(comments, page.getDirection(), hasExtra); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/CursorPageParameter.java: -------------------------------------------------------------------------------- 1 | package io.spring.application; 2 | 3 | import io.spring.application.CursorPager.Direction; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Data 8 | @NoArgsConstructor 9 | public class CursorPageParameter { 10 | private static final int MAX_LIMIT = 1000; 11 | private int limit = 20; 12 | private T cursor; 13 | private Direction direction; 14 | 15 | public CursorPageParameter(T cursor, int limit, Direction direction) { 16 | setLimit(limit); 17 | setCursor(cursor); 18 | setDirection(direction); 19 | } 20 | 21 | public boolean isNext() { 22 | return direction == Direction.NEXT; 23 | } 24 | 25 | public int getQueryLimit() { 26 | return limit + 1; 27 | } 28 | 29 | private void setCursor(T cursor) { 30 | this.cursor = cursor; 31 | } 32 | 33 | private void setLimit(int limit) { 34 | if (limit > MAX_LIMIT) { 35 | this.limit = MAX_LIMIT; 36 | } else if (limit > 0) { 37 | this.limit = limit; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/CursorPager.java: -------------------------------------------------------------------------------- 1 | package io.spring.application; 2 | 3 | import java.util.List; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | public class CursorPager { 8 | private List data; 9 | private boolean next; 10 | private boolean previous; 11 | 12 | public CursorPager(List data, Direction direction, boolean hasExtra) { 13 | this.data = data; 14 | 15 | if (direction == Direction.NEXT) { 16 | this.previous = false; 17 | this.next = hasExtra; 18 | } else { 19 | this.next = false; 20 | this.previous = hasExtra; 21 | } 22 | } 23 | 24 | public boolean hasNext() { 25 | return next; 26 | } 27 | 28 | public boolean hasPrevious() { 29 | return previous; 30 | } 31 | 32 | public PageCursor getStartCursor() { 33 | return data.isEmpty() ? null : data.get(0).getCursor(); 34 | } 35 | 36 | public PageCursor getEndCursor() { 37 | return data.isEmpty() ? null : data.get(data.size() - 1).getCursor(); 38 | } 39 | 40 | public enum Direction { 41 | PREV, 42 | NEXT 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/DateTimeCursor.java: -------------------------------------------------------------------------------- 1 | package io.spring.application; 2 | 3 | import org.joda.time.DateTime; 4 | import org.joda.time.DateTimeZone; 5 | 6 | public class DateTimeCursor extends PageCursor { 7 | 8 | public DateTimeCursor(DateTime data) { 9 | super(data); 10 | } 11 | 12 | @Override 13 | public String toString() { 14 | return String.valueOf(getData().getMillis()); 15 | } 16 | 17 | public static DateTime parse(String cursor) { 18 | if (cursor == null) { 19 | return null; 20 | } 21 | return new DateTime().withMillis(Long.parseLong(cursor)).withZone(DateTimeZone.UTC); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/Node.java: -------------------------------------------------------------------------------- 1 | package io.spring.application; 2 | 3 | public interface Node { 4 | PageCursor getCursor(); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/Page.java: -------------------------------------------------------------------------------- 1 | package io.spring.application; 2 | 3 | import lombok.Data; 4 | import lombok.NoArgsConstructor; 5 | 6 | @NoArgsConstructor 7 | @Data 8 | public class Page { 9 | private static final int MAX_LIMIT = 100; 10 | private int offset = 0; 11 | private int limit = 20; 12 | 13 | public Page(int offset, int limit) { 14 | setOffset(offset); 15 | setLimit(limit); 16 | } 17 | 18 | private void setOffset(int offset) { 19 | if (offset > 0) { 20 | this.offset = offset; 21 | } 22 | } 23 | 24 | private void setLimit(int limit) { 25 | if (limit > MAX_LIMIT) { 26 | this.limit = MAX_LIMIT; 27 | } else if (limit > 0) { 28 | this.limit = limit; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/PageCursor.java: -------------------------------------------------------------------------------- 1 | package io.spring.application; 2 | 3 | public abstract class PageCursor { 4 | private T data; 5 | 6 | public PageCursor(T data) { 7 | this.data = data; 8 | } 9 | 10 | public T getData() { 11 | return data; 12 | } 13 | 14 | @Override 15 | public String toString() { 16 | return data.toString(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/ProfileQueryService.java: -------------------------------------------------------------------------------- 1 | package io.spring.application; 2 | 3 | import io.spring.application.data.ProfileData; 4 | import io.spring.application.data.UserData; 5 | import io.spring.core.user.User; 6 | import io.spring.infrastructure.mybatis.readservice.UserReadService; 7 | import io.spring.infrastructure.mybatis.readservice.UserRelationshipQueryService; 8 | import java.util.Optional; 9 | import lombok.AllArgsConstructor; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Component 13 | @AllArgsConstructor 14 | public class ProfileQueryService { 15 | private UserReadService userReadService; 16 | private UserRelationshipQueryService userRelationshipQueryService; 17 | 18 | public Optional findByUsername(String username, User currentUser) { 19 | UserData userData = userReadService.findByUsername(username); 20 | if (userData == null) { 21 | return Optional.empty(); 22 | } else { 23 | ProfileData profileData = 24 | new ProfileData( 25 | userData.getId(), 26 | userData.getUsername(), 27 | userData.getBio(), 28 | userData.getImage(), 29 | currentUser != null 30 | && userRelationshipQueryService.isUserFollowing( 31 | currentUser.getId(), userData.getId())); 32 | return Optional.of(profileData); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/TagsQueryService.java: -------------------------------------------------------------------------------- 1 | package io.spring.application; 2 | 3 | import io.spring.infrastructure.mybatis.readservice.TagReadService; 4 | import java.util.List; 5 | import lombok.AllArgsConstructor; 6 | import org.springframework.stereotype.Service; 7 | 8 | @Service 9 | @AllArgsConstructor 10 | public class TagsQueryService { 11 | private TagReadService tagReadService; 12 | 13 | public List allTags() { 14 | return tagReadService.all(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/UserQueryService.java: -------------------------------------------------------------------------------- 1 | package io.spring.application; 2 | 3 | import io.spring.application.data.UserData; 4 | import io.spring.infrastructure.mybatis.readservice.UserReadService; 5 | import java.util.Optional; 6 | import lombok.AllArgsConstructor; 7 | import org.springframework.stereotype.Service; 8 | 9 | @Service 10 | @AllArgsConstructor 11 | public class UserQueryService { 12 | private UserReadService userReadService; 13 | 14 | public Optional findById(String id) { 15 | return Optional.ofNullable(userReadService.findById(id)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/article/ArticleCommandService.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.article; 2 | 3 | import io.spring.core.article.Article; 4 | import io.spring.core.article.ArticleRepository; 5 | import io.spring.core.user.User; 6 | import javax.validation.Valid; 7 | import lombok.AllArgsConstructor; 8 | import org.springframework.stereotype.Service; 9 | import org.springframework.validation.annotation.Validated; 10 | 11 | @Service 12 | @Validated 13 | @AllArgsConstructor 14 | public class ArticleCommandService { 15 | 16 | private ArticleRepository articleRepository; 17 | 18 | public Article createArticle(@Valid NewArticleParam newArticleParam, User creator) { 19 | Article article = 20 | new Article( 21 | newArticleParam.getTitle(), 22 | newArticleParam.getDescription(), 23 | newArticleParam.getBody(), 24 | newArticleParam.getTagList(), 25 | creator.getId()); 26 | articleRepository.save(article); 27 | return article; 28 | } 29 | 30 | public Article updateArticle(Article article, @Valid UpdateArticleParam updateArticleParam) { 31 | article.update( 32 | updateArticleParam.getTitle(), 33 | updateArticleParam.getDescription(), 34 | updateArticleParam.getBody()); 35 | articleRepository.save(article); 36 | return article; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/article/DuplicatedArticleConstraint.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.article; 2 | 3 | import java.lang.annotation.Documented; 4 | import java.lang.annotation.ElementType; 5 | import java.lang.annotation.Retention; 6 | import java.lang.annotation.RetentionPolicy; 7 | import java.lang.annotation.Target; 8 | import javax.validation.Constraint; 9 | import javax.validation.Payload; 10 | 11 | @Documented 12 | @Constraint(validatedBy = DuplicatedArticleValidator.class) 13 | @Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE_USE}) 14 | @Retention(RetentionPolicy.RUNTIME) 15 | public @interface DuplicatedArticleConstraint { 16 | String message() default "article name exists"; 17 | 18 | Class[] groups() default {}; 19 | 20 | Class[] payload() default {}; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/article/DuplicatedArticleValidator.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.article; 2 | 3 | import io.spring.application.ArticleQueryService; 4 | import io.spring.core.article.Article; 5 | import javax.validation.ConstraintValidator; 6 | import javax.validation.ConstraintValidatorContext; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | 9 | class DuplicatedArticleValidator 10 | implements ConstraintValidator { 11 | 12 | @Autowired private ArticleQueryService articleQueryService; 13 | 14 | @Override 15 | public boolean isValid(String value, ConstraintValidatorContext context) { 16 | return !articleQueryService.findBySlug(Article.toSlug(value), null).isPresent(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/article/NewArticleParam.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.article; 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName; 4 | import java.util.List; 5 | import javax.validation.constraints.NotBlank; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Getter 12 | @JsonRootName("article") 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | @Builder 16 | public class NewArticleParam { 17 | @NotBlank(message = "can't be empty") 18 | @DuplicatedArticleConstraint 19 | private String title; 20 | 21 | @NotBlank(message = "can't be empty") 22 | private String description; 23 | 24 | @NotBlank(message = "can't be empty") 25 | private String body; 26 | 27 | private List tagList; 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/article/UpdateArticleParam.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.article; 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Getter 9 | @NoArgsConstructor 10 | @AllArgsConstructor 11 | @JsonRootName("article") 12 | public class UpdateArticleParam { 13 | private String title = ""; 14 | private String body = ""; 15 | private String description = ""; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/data/ArticleData.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.data; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import io.spring.application.DateTimeCursor; 5 | import java.util.List; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | import org.joda.time.DateTime; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class ArticleData implements io.spring.application.Node { 15 | private String id; 16 | private String slug; 17 | private String title; 18 | private String description; 19 | private String body; 20 | private boolean favorited; 21 | private int favoritesCount; 22 | private DateTime createdAt; 23 | private DateTime updatedAt; 24 | private List tagList; 25 | 26 | @JsonProperty("author") 27 | private ProfileData profileData; 28 | 29 | @Override 30 | public DateTimeCursor getCursor() { 31 | return new DateTimeCursor(updatedAt); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/data/ArticleDataList.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.data; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import java.util.List; 5 | import lombok.Getter; 6 | 7 | @Getter 8 | public class ArticleDataList { 9 | @JsonProperty("articles") 10 | private final List articleDatas; 11 | 12 | @JsonProperty("articlesCount") 13 | private final int count; 14 | 15 | public ArticleDataList(List articleDatas, int count) { 16 | 17 | this.articleDatas = articleDatas; 18 | this.count = count; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/data/ArticleFavoriteCount.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.data; 2 | 3 | import lombok.Value; 4 | 5 | @Value 6 | public class ArticleFavoriteCount { 7 | private String id; 8 | private Integer count; 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/data/CommentData.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.data; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import io.spring.application.DateTimeCursor; 6 | import io.spring.application.Node; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | import org.joda.time.DateTime; 11 | 12 | @Data 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class CommentData implements Node { 16 | private String id; 17 | private String body; 18 | @JsonIgnore private String articleId; 19 | private DateTime createdAt; 20 | private DateTime updatedAt; 21 | 22 | @JsonProperty("author") 23 | private ProfileData profileData; 24 | 25 | @Override 26 | public DateTimeCursor getCursor() { 27 | return new DateTimeCursor(createdAt); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/data/ProfileData.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.data; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @NoArgsConstructor 10 | @AllArgsConstructor 11 | public class ProfileData { 12 | @JsonIgnore private String id; 13 | private String username; 14 | private String bio; 15 | private String image; 16 | private boolean following; 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/data/UserData.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.data; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Data 8 | @NoArgsConstructor 9 | @AllArgsConstructor 10 | public class UserData { 11 | private String id; 12 | private String email; 13 | private String username; 14 | private String bio; 15 | private String image; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/data/UserWithToken.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.data; 2 | 3 | import lombok.Getter; 4 | 5 | @Getter 6 | public class UserWithToken { 7 | private String email; 8 | private String username; 9 | private String bio; 10 | private String image; 11 | private String token; 12 | 13 | public UserWithToken(UserData userData, String token) { 14 | this.email = userData.getEmail(); 15 | this.username = userData.getUsername(); 16 | this.bio = userData.getBio(); 17 | this.image = userData.getImage(); 18 | this.token = token; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/user/DuplicatedEmailConstraint.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.user; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import javax.validation.Constraint; 6 | import javax.validation.Payload; 7 | 8 | @Constraint(validatedBy = DuplicatedEmailValidator.class) 9 | @Retention(RetentionPolicy.RUNTIME) 10 | public @interface DuplicatedEmailConstraint { 11 | String message() default "duplicated email"; 12 | 13 | Class[] groups() default {}; 14 | 15 | Class[] payload() default {}; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/user/DuplicatedEmailValidator.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.user; 2 | 3 | import io.spring.core.user.UserRepository; 4 | import javax.validation.ConstraintValidator; 5 | import javax.validation.ConstraintValidatorContext; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | 8 | public class DuplicatedEmailValidator 9 | implements ConstraintValidator { 10 | 11 | @Autowired private UserRepository userRepository; 12 | 13 | @Override 14 | public boolean isValid(String value, ConstraintValidatorContext context) { 15 | return (value == null || value.isEmpty()) || !userRepository.findByEmail(value).isPresent(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/user/DuplicatedUsernameConstraint.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.user; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import javax.validation.Constraint; 6 | import javax.validation.Payload; 7 | 8 | @Constraint(validatedBy = DuplicatedUsernameValidator.class) 9 | @Retention(RetentionPolicy.RUNTIME) 10 | @interface DuplicatedUsernameConstraint { 11 | String message() default "duplicated username"; 12 | 13 | Class[] groups() default {}; 14 | 15 | Class[] payload() default {}; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/user/DuplicatedUsernameValidator.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.user; 2 | 3 | import io.spring.core.user.UserRepository; 4 | import javax.validation.ConstraintValidator; 5 | import javax.validation.ConstraintValidatorContext; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | 8 | class DuplicatedUsernameValidator 9 | implements ConstraintValidator { 10 | 11 | @Autowired private UserRepository userRepository; 12 | 13 | @Override 14 | public boolean isValid(String value, ConstraintValidatorContext context) { 15 | return (value == null || value.isEmpty()) || !userRepository.findByUsername(value).isPresent(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/user/RegisterParam.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.user; 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName; 4 | import javax.validation.constraints.Email; 5 | import javax.validation.constraints.NotBlank; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Getter; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Getter 11 | @JsonRootName("user") 12 | @AllArgsConstructor 13 | @NoArgsConstructor 14 | public class RegisterParam { 15 | @NotBlank(message = "can't be empty") 16 | @Email(message = "should be an email") 17 | @DuplicatedEmailConstraint 18 | private String email; 19 | 20 | @NotBlank(message = "can't be empty") 21 | @DuplicatedUsernameConstraint 22 | private String username; 23 | 24 | @NotBlank(message = "can't be empty") 25 | private String password; 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/user/UpdateUserCommand.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.user; 2 | 3 | import io.spring.core.user.User; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | 7 | @Getter 8 | @AllArgsConstructor 9 | @UpdateUserConstraint 10 | public class UpdateUserCommand { 11 | 12 | private User targetUser; 13 | private UpdateUserParam param; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/user/UpdateUserParam.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.user; 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName; 4 | import javax.validation.constraints.Email; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Getter; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Getter 11 | @JsonRootName("user") 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | @Builder 15 | public class UpdateUserParam { 16 | 17 | @Builder.Default 18 | @Email(message = "should be an email") 19 | private String email = ""; 20 | 21 | @Builder.Default private String password = ""; 22 | @Builder.Default private String username = ""; 23 | @Builder.Default private String bio = ""; 24 | @Builder.Default private String image = ""; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/spring/application/user/UserService.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.user; 2 | 3 | import io.spring.core.user.User; 4 | import io.spring.core.user.UserRepository; 5 | import java.lang.annotation.Retention; 6 | import java.lang.annotation.RetentionPolicy; 7 | import javax.validation.Constraint; 8 | import javax.validation.ConstraintValidator; 9 | import javax.validation.ConstraintValidatorContext; 10 | import javax.validation.Valid; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.beans.factory.annotation.Value; 13 | import org.springframework.security.crypto.password.PasswordEncoder; 14 | import org.springframework.stereotype.Service; 15 | import org.springframework.validation.annotation.Validated; 16 | 17 | @Service 18 | @Validated 19 | public class UserService { 20 | private UserRepository userRepository; 21 | private String defaultImage; 22 | private PasswordEncoder passwordEncoder; 23 | 24 | @Autowired 25 | public UserService( 26 | UserRepository userRepository, 27 | @Value("${image.default}") String defaultImage, 28 | PasswordEncoder passwordEncoder) { 29 | this.userRepository = userRepository; 30 | this.defaultImage = defaultImage; 31 | this.passwordEncoder = passwordEncoder; 32 | } 33 | 34 | public User createUser(@Valid RegisterParam registerParam) { 35 | User user = 36 | new User( 37 | registerParam.getEmail(), 38 | registerParam.getUsername(), 39 | passwordEncoder.encode(registerParam.getPassword()), 40 | "", 41 | defaultImage); 42 | userRepository.save(user); 43 | return user; 44 | } 45 | 46 | public void updateUser(@Valid UpdateUserCommand command) { 47 | User user = command.getTargetUser(); 48 | UpdateUserParam updateUserParam = command.getParam(); 49 | user.update( 50 | updateUserParam.getEmail(), 51 | updateUserParam.getUsername(), 52 | updateUserParam.getPassword(), 53 | updateUserParam.getBio(), 54 | updateUserParam.getImage()); 55 | userRepository.save(user); 56 | } 57 | } 58 | 59 | @Constraint(validatedBy = UpdateUserValidator.class) 60 | @Retention(RetentionPolicy.RUNTIME) 61 | @interface UpdateUserConstraint { 62 | 63 | String message() default "invalid update param"; 64 | 65 | Class[] groups() default {}; 66 | 67 | Class[] payload() default {}; 68 | } 69 | 70 | class UpdateUserValidator implements ConstraintValidator { 71 | 72 | @Autowired private UserRepository userRepository; 73 | 74 | @Override 75 | public boolean isValid(UpdateUserCommand value, ConstraintValidatorContext context) { 76 | String inputEmail = value.getParam().getEmail(); 77 | String inputUsername = value.getParam().getUsername(); 78 | final User targetUser = value.getTargetUser(); 79 | 80 | boolean isEmailValid = 81 | userRepository.findByEmail(inputEmail).map(user -> user.equals(targetUser)).orElse(true); 82 | boolean isUsernameValid = 83 | userRepository 84 | .findByUsername(inputUsername) 85 | .map(user -> user.equals(targetUser)) 86 | .orElse(true); 87 | if (isEmailValid && isUsernameValid) { 88 | return true; 89 | } else { 90 | context.disableDefaultConstraintViolation(); 91 | if (!isEmailValid) { 92 | context 93 | .buildConstraintViolationWithTemplate("email already exist") 94 | .addPropertyNode("email") 95 | .addConstraintViolation(); 96 | } 97 | if (!isUsernameValid) { 98 | context 99 | .buildConstraintViolationWithTemplate("username already exist") 100 | .addPropertyNode("username") 101 | .addConstraintViolation(); 102 | } 103 | return false; 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/article/Article.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.article; 2 | 3 | import static java.util.stream.Collectors.toList; 4 | 5 | import io.spring.Util; 6 | import java.util.HashSet; 7 | import java.util.List; 8 | import java.util.UUID; 9 | import lombok.EqualsAndHashCode; 10 | import lombok.Getter; 11 | import lombok.NoArgsConstructor; 12 | import org.joda.time.DateTime; 13 | 14 | @Getter 15 | @NoArgsConstructor 16 | @EqualsAndHashCode(of = {"id"}) 17 | public class Article { 18 | private String userId; 19 | private String id; 20 | private String slug; 21 | private String title; 22 | private String description; 23 | private String body; 24 | private List tags; 25 | private DateTime createdAt; 26 | private DateTime updatedAt; 27 | 28 | public Article( 29 | String title, String description, String body, List tagList, String userId) { 30 | this(title, description, body, tagList, userId, new DateTime()); 31 | } 32 | 33 | public Article( 34 | String title, 35 | String description, 36 | String body, 37 | List tagList, 38 | String userId, 39 | DateTime createdAt) { 40 | this.id = UUID.randomUUID().toString(); 41 | this.slug = toSlug(title); 42 | this.title = title; 43 | this.description = description; 44 | this.body = body; 45 | this.tags = new HashSet<>(tagList).stream().map(Tag::new).collect(toList()); 46 | this.userId = userId; 47 | this.createdAt = createdAt; 48 | this.updatedAt = createdAt; 49 | } 50 | 51 | public void update(String title, String description, String body) { 52 | if (!Util.isEmpty(title)) { 53 | this.title = title; 54 | this.slug = toSlug(title); 55 | this.updatedAt = new DateTime(); 56 | } 57 | if (!Util.isEmpty(description)) { 58 | this.description = description; 59 | this.updatedAt = new DateTime(); 60 | } 61 | if (!Util.isEmpty(body)) { 62 | this.body = body; 63 | this.updatedAt = new DateTime(); 64 | } 65 | } 66 | 67 | public static String toSlug(String title) { 68 | return title.toLowerCase().replaceAll("[\\&|[\\uFE30-\\uFFA0]|\\’|\\”|\\s\\?\\,\\.]+", "-"); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/article/ArticleRepository.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.article; 2 | 3 | import java.util.Optional; 4 | 5 | public interface ArticleRepository { 6 | 7 | void save(Article article); 8 | 9 | Optional
findById(String id); 10 | 11 | Optional
findBySlug(String slug); 12 | 13 | void remove(Article article); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/article/Tag.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.article; 2 | 3 | import java.util.UUID; 4 | import lombok.Data; 5 | import lombok.EqualsAndHashCode; 6 | import lombok.NoArgsConstructor; 7 | 8 | @NoArgsConstructor 9 | @Data 10 | @EqualsAndHashCode(of = "name") 11 | public class Tag { 12 | private String id; 13 | private String name; 14 | 15 | public Tag(String name) { 16 | this.id = UUID.randomUUID().toString(); 17 | this.name = name; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/comment/Comment.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.comment; 2 | 3 | import java.util.UUID; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import org.joda.time.DateTime; 8 | 9 | @Getter 10 | @NoArgsConstructor 11 | @EqualsAndHashCode(of = "id") 12 | public class Comment { 13 | private String id; 14 | private String body; 15 | private String userId; 16 | private String articleId; 17 | private DateTime createdAt; 18 | 19 | public Comment(String body, String userId, String articleId) { 20 | this.id = UUID.randomUUID().toString(); 21 | this.body = body; 22 | this.userId = userId; 23 | this.articleId = articleId; 24 | this.createdAt = new DateTime(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/comment/CommentRepository.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.comment; 2 | 3 | import java.util.Optional; 4 | 5 | public interface CommentRepository { 6 | void save(Comment comment); 7 | 8 | Optional findById(String articleId, String id); 9 | 10 | void remove(Comment comment); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/favorite/ArticleFavorite.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.favorite; 2 | 3 | import lombok.EqualsAndHashCode; 4 | import lombok.Getter; 5 | import lombok.NoArgsConstructor; 6 | 7 | @NoArgsConstructor 8 | @Getter 9 | @EqualsAndHashCode 10 | public class ArticleFavorite { 11 | private String articleId; 12 | private String userId; 13 | 14 | public ArticleFavorite(String articleId, String userId) { 15 | this.articleId = articleId; 16 | this.userId = userId; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/favorite/ArticleFavoriteRepository.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.favorite; 2 | 3 | import java.util.Optional; 4 | 5 | public interface ArticleFavoriteRepository { 6 | void save(ArticleFavorite articleFavorite); 7 | 8 | Optional find(String articleId, String userId); 9 | 10 | void remove(ArticleFavorite favorite); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/service/AuthorizationService.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.service; 2 | 3 | import io.spring.core.article.Article; 4 | import io.spring.core.comment.Comment; 5 | import io.spring.core.user.User; 6 | 7 | public class AuthorizationService { 8 | public static boolean canWriteArticle(User user, Article article) { 9 | return user.getId().equals(article.getUserId()); 10 | } 11 | 12 | public static boolean canWriteComment(User user, Article article, Comment comment) { 13 | return user.getId().equals(article.getUserId()) || user.getId().equals(comment.getUserId()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/service/JwtService.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.service; 2 | 3 | import io.spring.core.user.User; 4 | import java.util.Optional; 5 | import org.springframework.stereotype.Service; 6 | 7 | @Service 8 | public interface JwtService { 9 | String toToken(User user); 10 | 11 | Optional getSubFromToken(String token); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/user/FollowRelation.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.user; 2 | 3 | import lombok.Data; 4 | import lombok.NoArgsConstructor; 5 | 6 | @NoArgsConstructor 7 | @Data 8 | public class FollowRelation { 9 | private String userId; 10 | private String targetId; 11 | 12 | public FollowRelation(String userId, String targetId) { 13 | 14 | this.userId = userId; 15 | this.targetId = targetId; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/user/User.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.user; 2 | 3 | import io.spring.Util; 4 | import java.util.UUID; 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | 9 | @Getter 10 | @NoArgsConstructor 11 | @EqualsAndHashCode(of = {"id"}) 12 | public class User { 13 | private String id; 14 | private String email; 15 | private String username; 16 | private String password; 17 | private String bio; 18 | private String image; 19 | 20 | public User(String email, String username, String password, String bio, String image) { 21 | this.id = UUID.randomUUID().toString(); 22 | this.email = email; 23 | this.username = username; 24 | this.password = password; 25 | this.bio = bio; 26 | this.image = image; 27 | } 28 | 29 | public void update(String email, String username, String password, String bio, String image) { 30 | if (!Util.isEmpty(email)) { 31 | this.email = email; 32 | } 33 | 34 | if (!Util.isEmpty(username)) { 35 | this.username = username; 36 | } 37 | 38 | if (!Util.isEmpty(password)) { 39 | this.password = password; 40 | } 41 | 42 | if (!Util.isEmpty(bio)) { 43 | this.bio = bio; 44 | } 45 | 46 | if (!Util.isEmpty(image)) { 47 | this.image = image; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/io/spring/core/user/UserRepository.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.user; 2 | 3 | import java.util.Optional; 4 | import org.springframework.stereotype.Repository; 5 | 6 | @Repository 7 | public interface UserRepository { 8 | void save(User user); 9 | 10 | Optional findById(String id); 11 | 12 | Optional findByUsername(String username); 13 | 14 | Optional findByEmail(String email); 15 | 16 | void saveRelation(FollowRelation followRelation); 17 | 18 | Optional findRelation(String userId, String targetId); 19 | 20 | void removeRelation(FollowRelation followRelation); 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/ArticleMutation.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql; 2 | 3 | import com.netflix.graphql.dgs.DgsComponent; 4 | import com.netflix.graphql.dgs.DgsMutation; 5 | import com.netflix.graphql.dgs.InputArgument; 6 | import graphql.execution.DataFetcherResult; 7 | import io.spring.api.exception.NoAuthorizationException; 8 | import io.spring.api.exception.ResourceNotFoundException; 9 | import io.spring.application.article.ArticleCommandService; 10 | import io.spring.application.article.NewArticleParam; 11 | import io.spring.application.article.UpdateArticleParam; 12 | import io.spring.core.article.Article; 13 | import io.spring.core.article.ArticleRepository; 14 | import io.spring.core.favorite.ArticleFavorite; 15 | import io.spring.core.favorite.ArticleFavoriteRepository; 16 | import io.spring.core.service.AuthorizationService; 17 | import io.spring.core.user.User; 18 | import io.spring.graphql.DgsConstants.MUTATION; 19 | import io.spring.graphql.exception.AuthenticationException; 20 | import io.spring.graphql.types.ArticlePayload; 21 | import io.spring.graphql.types.CreateArticleInput; 22 | import io.spring.graphql.types.DeletionStatus; 23 | import io.spring.graphql.types.UpdateArticleInput; 24 | import java.util.Collections; 25 | import lombok.AllArgsConstructor; 26 | 27 | @DgsComponent 28 | @AllArgsConstructor 29 | public class ArticleMutation { 30 | 31 | private ArticleCommandService articleCommandService; 32 | private ArticleFavoriteRepository articleFavoriteRepository; 33 | private ArticleRepository articleRepository; 34 | 35 | @DgsMutation(field = MUTATION.CreateArticle) 36 | public DataFetcherResult createArticle( 37 | @InputArgument("input") CreateArticleInput input) { 38 | User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); 39 | NewArticleParam newArticleParam = 40 | NewArticleParam.builder() 41 | .title(input.getTitle()) 42 | .description(input.getDescription()) 43 | .body(input.getBody()) 44 | .tagList(input.getTagList() == null ? Collections.emptyList() : input.getTagList()) 45 | .build(); 46 | Article article = articleCommandService.createArticle(newArticleParam, user); 47 | return DataFetcherResult.newResult() 48 | .data(ArticlePayload.newBuilder().build()) 49 | .localContext(article) 50 | .build(); 51 | } 52 | 53 | @DgsMutation(field = MUTATION.UpdateArticle) 54 | public DataFetcherResult updateArticle( 55 | @InputArgument("slug") String slug, @InputArgument("changes") UpdateArticleInput params) { 56 | Article article = 57 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 58 | User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); 59 | if (!AuthorizationService.canWriteArticle(user, article)) { 60 | throw new NoAuthorizationException(); 61 | } 62 | article = 63 | articleCommandService.updateArticle( 64 | article, 65 | new UpdateArticleParam(params.getTitle(), params.getBody(), params.getDescription())); 66 | return DataFetcherResult.newResult() 67 | .data(ArticlePayload.newBuilder().build()) 68 | .localContext(article) 69 | .build(); 70 | } 71 | 72 | @DgsMutation(field = MUTATION.FavoriteArticle) 73 | public DataFetcherResult favoriteArticle(@InputArgument("slug") String slug) { 74 | User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); 75 | Article article = 76 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 77 | ArticleFavorite articleFavorite = new ArticleFavorite(article.getId(), user.getId()); 78 | articleFavoriteRepository.save(articleFavorite); 79 | return DataFetcherResult.newResult() 80 | .data(ArticlePayload.newBuilder().build()) 81 | .localContext(article) 82 | .build(); 83 | } 84 | 85 | @DgsMutation(field = MUTATION.UnfavoriteArticle) 86 | public DataFetcherResult unfavoriteArticle(@InputArgument("slug") String slug) { 87 | User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); 88 | Article article = 89 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 90 | articleFavoriteRepository 91 | .find(article.getId(), user.getId()) 92 | .ifPresent( 93 | favorite -> { 94 | articleFavoriteRepository.remove(favorite); 95 | }); 96 | return DataFetcherResult.newResult() 97 | .data(ArticlePayload.newBuilder().build()) 98 | .localContext(article) 99 | .build(); 100 | } 101 | 102 | @DgsMutation(field = MUTATION.DeleteArticle) 103 | public DeletionStatus deleteArticle(@InputArgument("slug") String slug) { 104 | User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); 105 | Article article = 106 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 107 | 108 | if (!AuthorizationService.canWriteArticle(user, article)) { 109 | throw new NoAuthorizationException(); 110 | } 111 | 112 | articleRepository.remove(article); 113 | return DeletionStatus.newBuilder().success(true).build(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/CommentDatafetcher.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql; 2 | 3 | import com.netflix.graphql.dgs.DgsComponent; 4 | import com.netflix.graphql.dgs.DgsData; 5 | import com.netflix.graphql.dgs.DgsDataFetchingEnvironment; 6 | import com.netflix.graphql.dgs.InputArgument; 7 | import graphql.execution.DataFetcherResult; 8 | import graphql.relay.DefaultConnectionCursor; 9 | import graphql.relay.DefaultPageInfo; 10 | import io.spring.application.CommentQueryService; 11 | import io.spring.application.CursorPageParameter; 12 | import io.spring.application.CursorPager; 13 | import io.spring.application.CursorPager.Direction; 14 | import io.spring.application.DateTimeCursor; 15 | import io.spring.application.data.ArticleData; 16 | import io.spring.application.data.CommentData; 17 | import io.spring.core.user.User; 18 | import io.spring.graphql.DgsConstants.ARTICLE; 19 | import io.spring.graphql.DgsConstants.COMMENTPAYLOAD; 20 | import io.spring.graphql.types.Article; 21 | import io.spring.graphql.types.Comment; 22 | import io.spring.graphql.types.CommentEdge; 23 | import io.spring.graphql.types.CommentsConnection; 24 | import java.util.HashMap; 25 | import java.util.Map; 26 | import java.util.stream.Collectors; 27 | import lombok.AllArgsConstructor; 28 | import org.joda.time.format.ISODateTimeFormat; 29 | 30 | @DgsComponent 31 | @AllArgsConstructor 32 | public class CommentDatafetcher { 33 | private CommentQueryService commentQueryService; 34 | 35 | @DgsData(parentType = COMMENTPAYLOAD.TYPE_NAME, field = COMMENTPAYLOAD.Comment) 36 | public DataFetcherResult getComment(DgsDataFetchingEnvironment dfe) { 37 | CommentData comment = dfe.getLocalContext(); 38 | Comment commentResult = buildCommentResult(comment); 39 | return DataFetcherResult.newResult() 40 | .data(commentResult) 41 | .localContext( 42 | new HashMap() { 43 | { 44 | put(comment.getId(), comment); 45 | } 46 | }) 47 | .build(); 48 | } 49 | 50 | @DgsData(parentType = ARTICLE.TYPE_NAME, field = ARTICLE.Comments) 51 | public DataFetcherResult articleComments( 52 | @InputArgument("first") Integer first, 53 | @InputArgument("after") String after, 54 | @InputArgument("last") Integer last, 55 | @InputArgument("before") String before, 56 | DgsDataFetchingEnvironment dfe) { 57 | 58 | if (first == null && last == null) { 59 | throw new IllegalArgumentException("first 和 last 必须只存在一个"); 60 | } 61 | 62 | User current = SecurityUtil.getCurrentUser().orElse(null); 63 | Article article = dfe.getSource(); 64 | Map map = dfe.getLocalContext(); 65 | ArticleData articleData = map.get(article.getSlug()); 66 | 67 | CursorPager comments; 68 | if (first != null) { 69 | comments = 70 | commentQueryService.findByArticleIdWithCursor( 71 | articleData.getId(), 72 | current, 73 | new CursorPageParameter<>(DateTimeCursor.parse(after), first, Direction.NEXT)); 74 | } else { 75 | comments = 76 | commentQueryService.findByArticleIdWithCursor( 77 | articleData.getId(), 78 | current, 79 | new CursorPageParameter<>(DateTimeCursor.parse(before), last, Direction.PREV)); 80 | } 81 | graphql.relay.PageInfo pageInfo = buildCommentPageInfo(comments); 82 | CommentsConnection result = 83 | CommentsConnection.newBuilder() 84 | .pageInfo(pageInfo) 85 | .edges( 86 | comments.getData().stream() 87 | .map( 88 | a -> 89 | CommentEdge.newBuilder() 90 | .cursor(a.getCursor().toString()) 91 | .node(buildCommentResult(a)) 92 | .build()) 93 | .collect(Collectors.toList())) 94 | .build(); 95 | return DataFetcherResult.newResult() 96 | .data(result) 97 | .localContext( 98 | comments.getData().stream().collect(Collectors.toMap(CommentData::getId, c -> c))) 99 | .build(); 100 | } 101 | 102 | private DefaultPageInfo buildCommentPageInfo(CursorPager comments) { 103 | return new DefaultPageInfo( 104 | comments.getStartCursor() == null 105 | ? null 106 | : new DefaultConnectionCursor(comments.getStartCursor().toString()), 107 | comments.getEndCursor() == null 108 | ? null 109 | : new DefaultConnectionCursor(comments.getEndCursor().toString()), 110 | comments.hasPrevious(), 111 | comments.hasNext()); 112 | } 113 | 114 | private Comment buildCommentResult(CommentData comment) { 115 | return Comment.newBuilder() 116 | .id(comment.getId()) 117 | .body(comment.getBody()) 118 | .updatedAt(ISODateTimeFormat.dateTime().withZoneUTC().print(comment.getCreatedAt())) 119 | .createdAt(ISODateTimeFormat.dateTime().withZoneUTC().print(comment.getCreatedAt())) 120 | .build(); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/CommentMutation.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql; 2 | 3 | import com.netflix.graphql.dgs.DgsComponent; 4 | import com.netflix.graphql.dgs.DgsData; 5 | import com.netflix.graphql.dgs.InputArgument; 6 | import graphql.execution.DataFetcherResult; 7 | import io.spring.api.exception.NoAuthorizationException; 8 | import io.spring.api.exception.ResourceNotFoundException; 9 | import io.spring.application.CommentQueryService; 10 | import io.spring.application.data.CommentData; 11 | import io.spring.core.article.Article; 12 | import io.spring.core.article.ArticleRepository; 13 | import io.spring.core.comment.Comment; 14 | import io.spring.core.comment.CommentRepository; 15 | import io.spring.core.service.AuthorizationService; 16 | import io.spring.core.user.User; 17 | import io.spring.graphql.DgsConstants.MUTATION; 18 | import io.spring.graphql.exception.AuthenticationException; 19 | import io.spring.graphql.types.CommentPayload; 20 | import io.spring.graphql.types.DeletionStatus; 21 | import lombok.AllArgsConstructor; 22 | 23 | @DgsComponent 24 | @AllArgsConstructor 25 | public class CommentMutation { 26 | 27 | private ArticleRepository articleRepository; 28 | private CommentRepository commentRepository; 29 | private CommentQueryService commentQueryService; 30 | 31 | @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.AddComment) 32 | public DataFetcherResult createComment( 33 | @InputArgument("slug") String slug, @InputArgument("body") String body) { 34 | User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); 35 | Article article = 36 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 37 | Comment comment = new Comment(body, user.getId(), article.getId()); 38 | commentRepository.save(comment); 39 | CommentData commentData = 40 | commentQueryService 41 | .findById(comment.getId(), user) 42 | .orElseThrow(ResourceNotFoundException::new); 43 | return DataFetcherResult.newResult() 44 | .localContext(commentData) 45 | .data(CommentPayload.newBuilder().build()) 46 | .build(); 47 | } 48 | 49 | @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.DeleteComment) 50 | public DeletionStatus removeComment( 51 | @InputArgument("slug") String slug, @InputArgument("id") String commentId) { 52 | User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); 53 | 54 | Article article = 55 | articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); 56 | return commentRepository 57 | .findById(article.getId(), commentId) 58 | .map( 59 | comment -> { 60 | if (!AuthorizationService.canWriteComment(user, article, comment)) { 61 | throw new NoAuthorizationException(); 62 | } 63 | commentRepository.remove(comment); 64 | return DeletionStatus.newBuilder().success(true).build(); 65 | }) 66 | .orElseThrow(ResourceNotFoundException::new); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/MeDatafetcher.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql; 2 | 3 | import com.netflix.graphql.dgs.DgsComponent; 4 | import com.netflix.graphql.dgs.DgsData; 5 | import graphql.execution.DataFetcherResult; 6 | import graphql.schema.DataFetchingEnvironment; 7 | import io.spring.api.exception.ResourceNotFoundException; 8 | import io.spring.application.UserQueryService; 9 | import io.spring.application.data.UserData; 10 | import io.spring.application.data.UserWithToken; 11 | import io.spring.core.service.JwtService; 12 | import io.spring.graphql.DgsConstants.QUERY; 13 | import io.spring.graphql.DgsConstants.USERPAYLOAD; 14 | import io.spring.graphql.types.User; 15 | import lombok.AllArgsConstructor; 16 | import org.springframework.security.authentication.AnonymousAuthenticationToken; 17 | import org.springframework.security.core.Authentication; 18 | import org.springframework.security.core.context.SecurityContextHolder; 19 | import org.springframework.web.bind.annotation.RequestHeader; 20 | 21 | @DgsComponent 22 | @AllArgsConstructor 23 | public class MeDatafetcher { 24 | private UserQueryService userQueryService; 25 | private JwtService jwtService; 26 | 27 | @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Me) 28 | public DataFetcherResult getMe( 29 | @RequestHeader(value = "Authorization") String authorization, 30 | DataFetchingEnvironment dataFetchingEnvironment) { 31 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 32 | if (authentication instanceof AnonymousAuthenticationToken 33 | || authentication.getPrincipal() == null) { 34 | return null; 35 | } 36 | io.spring.core.user.User user = (io.spring.core.user.User) authentication.getPrincipal(); 37 | UserData userData = 38 | userQueryService.findById(user.getId()).orElseThrow(ResourceNotFoundException::new); 39 | UserWithToken userWithToken = new UserWithToken(userData, authorization.split(" ")[1]); 40 | User result = 41 | User.newBuilder() 42 | .email(userWithToken.getEmail()) 43 | .username(userWithToken.getUsername()) 44 | .token(userWithToken.getToken()) 45 | .build(); 46 | return DataFetcherResult.newResult().data(result).localContext(user).build(); 47 | } 48 | 49 | @DgsData(parentType = USERPAYLOAD.TYPE_NAME, field = USERPAYLOAD.User) 50 | public DataFetcherResult getUserPayloadUser( 51 | DataFetchingEnvironment dataFetchingEnvironment) { 52 | io.spring.core.user.User user = dataFetchingEnvironment.getLocalContext(); 53 | User result = 54 | User.newBuilder() 55 | .email(user.getEmail()) 56 | .username(user.getUsername()) 57 | .token(jwtService.toToken(user)) 58 | .build(); 59 | return DataFetcherResult.newResult().data(result).localContext(user).build(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/ProfileDatafetcher.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql; 2 | 3 | import com.netflix.graphql.dgs.DgsComponent; 4 | import com.netflix.graphql.dgs.DgsData; 5 | import com.netflix.graphql.dgs.InputArgument; 6 | import graphql.schema.DataFetchingEnvironment; 7 | import io.spring.api.exception.ResourceNotFoundException; 8 | import io.spring.application.ProfileQueryService; 9 | import io.spring.application.data.ArticleData; 10 | import io.spring.application.data.CommentData; 11 | import io.spring.application.data.ProfileData; 12 | import io.spring.core.user.User; 13 | import io.spring.graphql.DgsConstants.ARTICLE; 14 | import io.spring.graphql.DgsConstants.COMMENT; 15 | import io.spring.graphql.DgsConstants.QUERY; 16 | import io.spring.graphql.DgsConstants.USER; 17 | import io.spring.graphql.types.Article; 18 | import io.spring.graphql.types.Comment; 19 | import io.spring.graphql.types.Profile; 20 | import io.spring.graphql.types.ProfilePayload; 21 | import java.util.Map; 22 | import lombok.AllArgsConstructor; 23 | 24 | @DgsComponent 25 | @AllArgsConstructor 26 | public class ProfileDatafetcher { 27 | 28 | private ProfileQueryService profileQueryService; 29 | 30 | @DgsData(parentType = USER.TYPE_NAME, field = USER.Profile) 31 | public Profile getUserProfile(DataFetchingEnvironment dataFetchingEnvironment) { 32 | User user = dataFetchingEnvironment.getLocalContext(); 33 | String username = user.getUsername(); 34 | return queryProfile(username); 35 | } 36 | 37 | @DgsData(parentType = ARTICLE.TYPE_NAME, field = ARTICLE.Author) 38 | public Profile getAuthor(DataFetchingEnvironment dataFetchingEnvironment) { 39 | Map map = dataFetchingEnvironment.getLocalContext(); 40 | Article article = dataFetchingEnvironment.getSource(); 41 | return queryProfile(map.get(article.getSlug()).getProfileData().getUsername()); 42 | } 43 | 44 | @DgsData(parentType = COMMENT.TYPE_NAME, field = COMMENT.Author) 45 | public Profile getCommentAuthor(DataFetchingEnvironment dataFetchingEnvironment) { 46 | Comment comment = dataFetchingEnvironment.getSource(); 47 | Map map = dataFetchingEnvironment.getLocalContext(); 48 | return queryProfile(map.get(comment.getId()).getProfileData().getUsername()); 49 | } 50 | 51 | @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Profile) 52 | public ProfilePayload queryProfile( 53 | @InputArgument("username") String username, DataFetchingEnvironment dataFetchingEnvironment) { 54 | Profile profile = queryProfile(dataFetchingEnvironment.getArgument("username")); 55 | return ProfilePayload.newBuilder().profile(profile).build(); 56 | } 57 | 58 | private Profile queryProfile(String username) { 59 | User current = SecurityUtil.getCurrentUser().orElse(null); 60 | ProfileData profileData = 61 | profileQueryService 62 | .findByUsername(username, current) 63 | .orElseThrow(ResourceNotFoundException::new); 64 | return Profile.newBuilder() 65 | .username(profileData.getUsername()) 66 | .bio(profileData.getBio()) 67 | .image(profileData.getImage()) 68 | .following(profileData.isFollowing()) 69 | .build(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/RelationMutation.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql; 2 | 3 | import com.netflix.graphql.dgs.DgsComponent; 4 | import com.netflix.graphql.dgs.DgsData; 5 | import com.netflix.graphql.dgs.InputArgument; 6 | import io.spring.api.exception.ResourceNotFoundException; 7 | import io.spring.application.ProfileQueryService; 8 | import io.spring.application.data.ProfileData; 9 | import io.spring.core.user.FollowRelation; 10 | import io.spring.core.user.User; 11 | import io.spring.core.user.UserRepository; 12 | import io.spring.graphql.DgsConstants.MUTATION; 13 | import io.spring.graphql.exception.AuthenticationException; 14 | import io.spring.graphql.types.Profile; 15 | import io.spring.graphql.types.ProfilePayload; 16 | import lombok.AllArgsConstructor; 17 | 18 | @DgsComponent 19 | @AllArgsConstructor 20 | public class RelationMutation { 21 | 22 | private UserRepository userRepository; 23 | private ProfileQueryService profileQueryService; 24 | 25 | @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.FollowUser) 26 | public ProfilePayload follow(@InputArgument("username") String username) { 27 | User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); 28 | return userRepository 29 | .findByUsername(username) 30 | .map( 31 | target -> { 32 | FollowRelation followRelation = new FollowRelation(user.getId(), target.getId()); 33 | userRepository.saveRelation(followRelation); 34 | Profile profile = buildProfile(username, user); 35 | return ProfilePayload.newBuilder().profile(profile).build(); 36 | }) 37 | .orElseThrow(ResourceNotFoundException::new); 38 | } 39 | 40 | @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.UnfollowUser) 41 | public ProfilePayload unfollow(@InputArgument("username") String username) { 42 | User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); 43 | User target = 44 | userRepository.findByUsername(username).orElseThrow(ResourceNotFoundException::new); 45 | return userRepository 46 | .findRelation(user.getId(), target.getId()) 47 | .map( 48 | relation -> { 49 | userRepository.removeRelation(relation); 50 | Profile profile = buildProfile(username, user); 51 | return ProfilePayload.newBuilder().profile(profile).build(); 52 | }) 53 | .orElseThrow(ResourceNotFoundException::new); 54 | } 55 | 56 | private Profile buildProfile(@InputArgument("username") String username, User current) { 57 | ProfileData profileData = profileQueryService.findByUsername(username, current).get(); 58 | return Profile.newBuilder() 59 | .username(profileData.getUsername()) 60 | .bio(profileData.getBio()) 61 | .image(profileData.getImage()) 62 | .following(profileData.isFollowing()) 63 | .build(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/SecurityUtil.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql; 2 | 3 | import io.spring.core.user.User; 4 | import java.util.Optional; 5 | import org.springframework.security.authentication.AnonymousAuthenticationToken; 6 | import org.springframework.security.core.Authentication; 7 | import org.springframework.security.core.context.SecurityContextHolder; 8 | 9 | public class SecurityUtil { 10 | public static Optional getCurrentUser() { 11 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 12 | if (authentication instanceof AnonymousAuthenticationToken 13 | || authentication.getPrincipal() == null) { 14 | return Optional.empty(); 15 | } 16 | io.spring.core.user.User currentUser = (io.spring.core.user.User) authentication.getPrincipal(); 17 | return Optional.of(currentUser); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/TagDatafetcher.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql; 2 | 3 | import com.netflix.graphql.dgs.DgsComponent; 4 | import com.netflix.graphql.dgs.DgsData; 5 | import io.spring.application.TagsQueryService; 6 | import io.spring.graphql.DgsConstants.QUERY; 7 | import java.util.List; 8 | import lombok.AllArgsConstructor; 9 | 10 | @DgsComponent 11 | @AllArgsConstructor 12 | public class TagDatafetcher { 13 | private TagsQueryService tagsQueryService; 14 | 15 | @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Tags) 16 | public List getTags() { 17 | return tagsQueryService.allTags(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/UserMutation.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql; 2 | 3 | import com.netflix.graphql.dgs.DgsComponent; 4 | import com.netflix.graphql.dgs.DgsData; 5 | import com.netflix.graphql.dgs.InputArgument; 6 | import graphql.execution.DataFetcherResult; 7 | import io.spring.api.exception.InvalidAuthenticationException; 8 | import io.spring.application.user.RegisterParam; 9 | import io.spring.application.user.UpdateUserCommand; 10 | import io.spring.application.user.UpdateUserParam; 11 | import io.spring.application.user.UserService; 12 | import io.spring.core.user.User; 13 | import io.spring.core.user.UserRepository; 14 | import io.spring.graphql.DgsConstants.MUTATION; 15 | import io.spring.graphql.exception.GraphQLCustomizeExceptionHandler; 16 | import io.spring.graphql.types.CreateUserInput; 17 | import io.spring.graphql.types.UpdateUserInput; 18 | import io.spring.graphql.types.UserPayload; 19 | import io.spring.graphql.types.UserResult; 20 | import java.util.Optional; 21 | import javax.validation.ConstraintViolationException; 22 | import lombok.AllArgsConstructor; 23 | import org.springframework.security.authentication.AnonymousAuthenticationToken; 24 | import org.springframework.security.core.Authentication; 25 | import org.springframework.security.core.context.SecurityContextHolder; 26 | import org.springframework.security.crypto.password.PasswordEncoder; 27 | 28 | @DgsComponent 29 | @AllArgsConstructor 30 | public class UserMutation { 31 | 32 | private UserRepository userRepository; 33 | private PasswordEncoder encryptService; 34 | private UserService userService; 35 | 36 | @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.CreateUser) 37 | public DataFetcherResult createUser(@InputArgument("input") CreateUserInput input) { 38 | RegisterParam registerParam = 39 | new RegisterParam(input.getEmail(), input.getUsername(), input.getPassword()); 40 | User user; 41 | try { 42 | user = userService.createUser(registerParam); 43 | } catch (ConstraintViolationException cve) { 44 | return DataFetcherResult.newResult() 45 | .data(GraphQLCustomizeExceptionHandler.getErrorsAsData(cve)) 46 | .build(); 47 | } 48 | 49 | return DataFetcherResult.newResult() 50 | .data(UserPayload.newBuilder().build()) 51 | .localContext(user) 52 | .build(); 53 | } 54 | 55 | @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.Login) 56 | public DataFetcherResult login( 57 | @InputArgument("password") String password, @InputArgument("email") String email) { 58 | Optional optional = userRepository.findByEmail(email); 59 | if (optional.isPresent() && encryptService.matches(password, optional.get().getPassword())) { 60 | return DataFetcherResult.newResult() 61 | .data(UserPayload.newBuilder().build()) 62 | .localContext(optional.get()) 63 | .build(); 64 | } else { 65 | throw new InvalidAuthenticationException(); 66 | } 67 | } 68 | 69 | @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.UpdateUser) 70 | public DataFetcherResult updateUser( 71 | @InputArgument("changes") UpdateUserInput updateUserInput) { 72 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 73 | if (authentication instanceof AnonymousAuthenticationToken 74 | || authentication.getPrincipal() == null) { 75 | return null; 76 | } 77 | io.spring.core.user.User currentUser = (io.spring.core.user.User) authentication.getPrincipal(); 78 | UpdateUserParam param = 79 | UpdateUserParam.builder() 80 | .username(updateUserInput.getUsername()) 81 | .email(updateUserInput.getEmail()) 82 | .bio(updateUserInput.getBio()) 83 | .password(updateUserInput.getPassword()) 84 | .image(updateUserInput.getImage()) 85 | .build(); 86 | 87 | userService.updateUser(new UpdateUserCommand(currentUser, param)); 88 | return DataFetcherResult.newResult() 89 | .data(UserPayload.newBuilder().build()) 90 | .localContext(currentUser) 91 | .build(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/exception/AuthenticationException.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql.exception; 2 | 3 | public class AuthenticationException extends RuntimeException {} 4 | -------------------------------------------------------------------------------- /src/main/java/io/spring/graphql/exception/GraphQLCustomizeExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.spring.graphql.exception; 2 | 3 | import com.netflix.graphql.dgs.exceptions.DefaultDataFetcherExceptionHandler; 4 | import com.netflix.graphql.types.errors.ErrorType; 5 | import com.netflix.graphql.types.errors.TypedGraphQLError; 6 | import graphql.GraphQLError; 7 | import graphql.execution.DataFetcherExceptionHandler; 8 | import graphql.execution.DataFetcherExceptionHandlerParameters; 9 | import graphql.execution.DataFetcherExceptionHandlerResult; 10 | import io.spring.api.exception.FieldErrorResource; 11 | import io.spring.api.exception.InvalidAuthenticationException; 12 | import io.spring.graphql.types.Error; 13 | import io.spring.graphql.types.ErrorItem; 14 | import java.util.ArrayList; 15 | import java.util.Arrays; 16 | import java.util.HashMap; 17 | import java.util.List; 18 | import java.util.Map; 19 | import java.util.stream.Collectors; 20 | import javax.validation.ConstraintViolation; 21 | import javax.validation.ConstraintViolationException; 22 | import org.springframework.stereotype.Component; 23 | 24 | @Component 25 | public class GraphQLCustomizeExceptionHandler implements DataFetcherExceptionHandler { 26 | 27 | private final DefaultDataFetcherExceptionHandler defaultHandler = 28 | new DefaultDataFetcherExceptionHandler(); 29 | 30 | @Override 31 | public DataFetcherExceptionHandlerResult onException( 32 | DataFetcherExceptionHandlerParameters handlerParameters) { 33 | if (handlerParameters.getException() instanceof InvalidAuthenticationException) { 34 | GraphQLError graphqlError = 35 | TypedGraphQLError.newBuilder() 36 | .errorType(ErrorType.UNAUTHENTICATED) 37 | .message(handlerParameters.getException().getMessage()) 38 | .path(handlerParameters.getPath()) 39 | .build(); 40 | return DataFetcherExceptionHandlerResult.newResult().error(graphqlError).build(); 41 | } else if (handlerParameters.getException() instanceof ConstraintViolationException) { 42 | List errors = new ArrayList<>(); 43 | for (ConstraintViolation violation : 44 | ((ConstraintViolationException) handlerParameters.getException()) 45 | .getConstraintViolations()) { 46 | FieldErrorResource fieldErrorResource = 47 | new FieldErrorResource( 48 | violation.getRootBeanClass().getName(), 49 | getParam(violation.getPropertyPath().toString()), 50 | violation 51 | .getConstraintDescriptor() 52 | .getAnnotation() 53 | .annotationType() 54 | .getSimpleName(), 55 | violation.getMessage()); 56 | errors.add(fieldErrorResource); 57 | } 58 | GraphQLError graphqlError = 59 | TypedGraphQLError.newBadRequestBuilder() 60 | .message(handlerParameters.getException().getMessage()) 61 | .path(handlerParameters.getPath()) 62 | .extensions(errorsToMap(errors)) 63 | .build(); 64 | return DataFetcherExceptionHandlerResult.newResult().error(graphqlError).build(); 65 | } else { 66 | return defaultHandler.onException(handlerParameters); 67 | } 68 | } 69 | 70 | public static Error getErrorsAsData(ConstraintViolationException cve) { 71 | List errors = new ArrayList<>(); 72 | for (ConstraintViolation violation : cve.getConstraintViolations()) { 73 | FieldErrorResource fieldErrorResource = 74 | new FieldErrorResource( 75 | violation.getRootBeanClass().getName(), 76 | getParam(violation.getPropertyPath().toString()), 77 | violation.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(), 78 | violation.getMessage()); 79 | errors.add(fieldErrorResource); 80 | } 81 | Map> errorMap = new HashMap<>(); 82 | for (FieldErrorResource fieldErrorResource : errors) { 83 | if (!errorMap.containsKey(fieldErrorResource.getField())) { 84 | errorMap.put(fieldErrorResource.getField(), new ArrayList<>()); 85 | } 86 | errorMap.get(fieldErrorResource.getField()).add(fieldErrorResource.getMessage()); 87 | } 88 | List errorItems = 89 | errorMap.entrySet().stream() 90 | .map(kv -> ErrorItem.newBuilder().key(kv.getKey()).value(kv.getValue()).build()) 91 | .collect(Collectors.toList()); 92 | return Error.newBuilder().message("BAD_REQUEST").errors(errorItems).build(); 93 | } 94 | 95 | private static String getParam(String s) { 96 | String[] splits = s.split("\\."); 97 | if (splits.length == 1) { 98 | return s; 99 | } else { 100 | return String.join(".", Arrays.copyOfRange(splits, 2, splits.length)); 101 | } 102 | } 103 | 104 | private static Map errorsToMap(List errors) { 105 | Map json = new HashMap<>(); 106 | for (FieldErrorResource fieldErrorResource : errors) { 107 | if (!json.containsKey(fieldErrorResource.getField())) { 108 | json.put(fieldErrorResource.getField(), new ArrayList<>()); 109 | } 110 | ((List) json.get(fieldErrorResource.getField())).add(fieldErrorResource.getMessage()); 111 | } 112 | return json; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/DateTimeHandler.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis; 2 | 3 | import java.sql.CallableStatement; 4 | import java.sql.PreparedStatement; 5 | import java.sql.ResultSet; 6 | import java.sql.SQLException; 7 | import java.sql.Timestamp; 8 | import java.util.Calendar; 9 | import java.util.TimeZone; 10 | import org.apache.ibatis.type.JdbcType; 11 | import org.apache.ibatis.type.MappedTypes; 12 | import org.apache.ibatis.type.TypeHandler; 13 | import org.joda.time.DateTime; 14 | 15 | @MappedTypes(DateTime.class) 16 | public class DateTimeHandler implements TypeHandler { 17 | 18 | private static final Calendar UTC_CALENDAR = Calendar.getInstance(TimeZone.getTimeZone("UTC")); 19 | 20 | @Override 21 | public void setParameter(PreparedStatement ps, int i, DateTime parameter, JdbcType jdbcType) 22 | throws SQLException { 23 | ps.setTimestamp( 24 | i, parameter != null ? new Timestamp(parameter.getMillis()) : null, UTC_CALENDAR); 25 | } 26 | 27 | @Override 28 | public DateTime getResult(ResultSet rs, String columnName) throws SQLException { 29 | Timestamp timestamp = rs.getTimestamp(columnName, UTC_CALENDAR); 30 | return timestamp != null ? new DateTime(timestamp.getTime()) : null; 31 | } 32 | 33 | @Override 34 | public DateTime getResult(ResultSet rs, int columnIndex) throws SQLException { 35 | Timestamp timestamp = rs.getTimestamp(columnIndex, UTC_CALENDAR); 36 | return timestamp != null ? new DateTime(timestamp.getTime()) : null; 37 | } 38 | 39 | @Override 40 | public DateTime getResult(CallableStatement cs, int columnIndex) throws SQLException { 41 | Timestamp ts = cs.getTimestamp(columnIndex, UTC_CALENDAR); 42 | return ts != null ? new DateTime(ts.getTime()) : null; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleFavoriteMapper.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis.mapper; 2 | 3 | import io.spring.core.favorite.ArticleFavorite; 4 | import org.apache.ibatis.annotations.Mapper; 5 | import org.apache.ibatis.annotations.Param; 6 | 7 | @Mapper 8 | public interface ArticleFavoriteMapper { 9 | ArticleFavorite find(@Param("articleId") String articleId, @Param("userId") String userId); 10 | 11 | void insert(@Param("articleFavorite") ArticleFavorite articleFavorite); 12 | 13 | void delete(@Param("favorite") ArticleFavorite favorite); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleMapper.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis.mapper; 2 | 3 | import io.spring.core.article.Article; 4 | import io.spring.core.article.Tag; 5 | import org.apache.ibatis.annotations.Mapper; 6 | import org.apache.ibatis.annotations.Param; 7 | 8 | @Mapper 9 | public interface ArticleMapper { 10 | void insert(@Param("article") Article article); 11 | 12 | Article findById(@Param("id") String id); 13 | 14 | Tag findTag(@Param("tagName") String tagName); 15 | 16 | void insertTag(@Param("tag") Tag tag); 17 | 18 | void insertArticleTagRelation(@Param("articleId") String articleId, @Param("tagId") String tagId); 19 | 20 | Article findBySlug(@Param("slug") String slug); 21 | 22 | void update(@Param("article") Article article); 23 | 24 | void delete(@Param("id") String id); 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis.mapper; 2 | 3 | import io.spring.core.comment.Comment; 4 | import org.apache.ibatis.annotations.Mapper; 5 | import org.apache.ibatis.annotations.Param; 6 | 7 | @Mapper 8 | public interface CommentMapper { 9 | void insert(@Param("comment") Comment comment); 10 | 11 | Comment findById(@Param("articleId") String articleId, @Param("id") String id); 12 | 13 | void delete(@Param("id") String id); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/mapper/UserMapper.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis.mapper; 2 | 3 | import io.spring.core.user.FollowRelation; 4 | import io.spring.core.user.User; 5 | import org.apache.ibatis.annotations.Mapper; 6 | import org.apache.ibatis.annotations.Param; 7 | 8 | @Mapper 9 | public interface UserMapper { 10 | void insert(@Param("user") User user); 11 | 12 | User findByUsername(@Param("username") String username); 13 | 14 | User findByEmail(@Param("email") String email); 15 | 16 | User findById(@Param("id") String id); 17 | 18 | void update(@Param("user") User user); 19 | 20 | FollowRelation findRelation(@Param("userId") String userId, @Param("targetId") String targetId); 21 | 22 | void saveRelation(@Param("followRelation") FollowRelation followRelation); 23 | 24 | void deleteRelation(@Param("followRelation") FollowRelation followRelation); 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/readservice/ArticleFavoritesReadService.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis.readservice; 2 | 3 | import io.spring.application.data.ArticleFavoriteCount; 4 | import io.spring.core.user.User; 5 | import java.util.List; 6 | import java.util.Set; 7 | import org.apache.ibatis.annotations.Mapper; 8 | import org.apache.ibatis.annotations.Param; 9 | 10 | @Mapper 11 | public interface ArticleFavoritesReadService { 12 | boolean isUserFavorite(@Param("userId") String userId, @Param("articleId") String articleId); 13 | 14 | int articleFavoriteCount(@Param("articleId") String articleId); 15 | 16 | List articlesFavoriteCount(@Param("ids") List ids); 17 | 18 | Set userFavorites(@Param("ids") List ids, @Param("currentUser") User currentUser); 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/readservice/ArticleReadService.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis.readservice; 2 | 3 | import io.spring.application.CursorPageParameter; 4 | import io.spring.application.Page; 5 | import io.spring.application.data.ArticleData; 6 | import java.util.List; 7 | import org.apache.ibatis.annotations.Mapper; 8 | import org.apache.ibatis.annotations.Param; 9 | 10 | @Mapper 11 | public interface ArticleReadService { 12 | ArticleData findById(@Param("id") String id); 13 | 14 | ArticleData findBySlug(@Param("slug") String slug); 15 | 16 | List queryArticles( 17 | @Param("tag") String tag, 18 | @Param("author") String author, 19 | @Param("favoritedBy") String favoritedBy, 20 | @Param("page") Page page); 21 | 22 | int countArticle( 23 | @Param("tag") String tag, 24 | @Param("author") String author, 25 | @Param("favoritedBy") String favoritedBy); 26 | 27 | List findArticles(@Param("articleIds") List articleIds); 28 | 29 | List findArticlesOfAuthors( 30 | @Param("authors") List authors, @Param("page") Page page); 31 | 32 | List findArticlesOfAuthorsWithCursor( 33 | @Param("authors") List authors, @Param("page") CursorPageParameter page); 34 | 35 | int countFeedSize(@Param("authors") List authors); 36 | 37 | List findArticlesWithCursor( 38 | @Param("tag") String tag, 39 | @Param("author") String author, 40 | @Param("favoritedBy") String favoritedBy, 41 | @Param("page") CursorPageParameter page); 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/readservice/CommentReadService.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis.readservice; 2 | 3 | import io.spring.application.CursorPageParameter; 4 | import io.spring.application.data.CommentData; 5 | import java.util.List; 6 | import org.apache.ibatis.annotations.Mapper; 7 | import org.apache.ibatis.annotations.Param; 8 | import org.joda.time.DateTime; 9 | 10 | @Mapper 11 | public interface CommentReadService { 12 | CommentData findById(@Param("id") String id); 13 | 14 | List findByArticleId(@Param("articleId") String articleId); 15 | 16 | List findByArticleIdWithCursor( 17 | @Param("articleId") String articleId, @Param("page") CursorPageParameter page); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/readservice/TagReadService.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis.readservice; 2 | 3 | import java.util.List; 4 | import org.apache.ibatis.annotations.Mapper; 5 | 6 | @Mapper 7 | public interface TagReadService { 8 | List all(); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/readservice/UserReadService.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis.readservice; 2 | 3 | import io.spring.application.data.UserData; 4 | import org.apache.ibatis.annotations.Mapper; 5 | import org.apache.ibatis.annotations.Param; 6 | 7 | @Mapper 8 | public interface UserReadService { 9 | 10 | UserData findByUsername(@Param("username") String username); 11 | 12 | UserData findById(@Param("id") String id); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/mybatis/readservice/UserRelationshipQueryService.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.mybatis.readservice; 2 | 3 | import java.util.List; 4 | import java.util.Set; 5 | import org.apache.ibatis.annotations.Mapper; 6 | import org.apache.ibatis.annotations.Param; 7 | 8 | @Mapper 9 | public interface UserRelationshipQueryService { 10 | boolean isUserFollowing( 11 | @Param("userId") String userId, @Param("anotherUserId") String anotherUserId); 12 | 13 | Set followingAuthors(@Param("userId") String userId, @Param("ids") List ids); 14 | 15 | List followedUsers(@Param("userId") String userId); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/repository/MyBatisArticleFavoriteRepository.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.repository; 2 | 3 | import io.spring.core.favorite.ArticleFavorite; 4 | import io.spring.core.favorite.ArticleFavoriteRepository; 5 | import io.spring.infrastructure.mybatis.mapper.ArticleFavoriteMapper; 6 | import java.util.Optional; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.stereotype.Repository; 9 | 10 | @Repository 11 | public class MyBatisArticleFavoriteRepository implements ArticleFavoriteRepository { 12 | private ArticleFavoriteMapper mapper; 13 | 14 | @Autowired 15 | public MyBatisArticleFavoriteRepository(ArticleFavoriteMapper mapper) { 16 | this.mapper = mapper; 17 | } 18 | 19 | @Override 20 | public void save(ArticleFavorite articleFavorite) { 21 | if (mapper.find(articleFavorite.getArticleId(), articleFavorite.getUserId()) == null) { 22 | mapper.insert(articleFavorite); 23 | } 24 | } 25 | 26 | @Override 27 | public Optional find(String articleId, String userId) { 28 | return Optional.ofNullable(mapper.find(articleId, userId)); 29 | } 30 | 31 | @Override 32 | public void remove(ArticleFavorite favorite) { 33 | mapper.delete(favorite); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/repository/MyBatisArticleRepository.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.repository; 2 | 3 | import io.spring.core.article.Article; 4 | import io.spring.core.article.ArticleRepository; 5 | import io.spring.core.article.Tag; 6 | import io.spring.infrastructure.mybatis.mapper.ArticleMapper; 7 | import java.util.Optional; 8 | import org.springframework.stereotype.Repository; 9 | import org.springframework.transaction.annotation.Transactional; 10 | 11 | @Repository 12 | public class MyBatisArticleRepository implements ArticleRepository { 13 | private ArticleMapper articleMapper; 14 | 15 | public MyBatisArticleRepository(ArticleMapper articleMapper) { 16 | this.articleMapper = articleMapper; 17 | } 18 | 19 | @Override 20 | @Transactional 21 | public void save(Article article) { 22 | if (articleMapper.findById(article.getId()) == null) { 23 | createNew(article); 24 | } else { 25 | articleMapper.update(article); 26 | } 27 | } 28 | 29 | private void createNew(Article article) { 30 | for (Tag tag : article.getTags()) { 31 | Tag targetTag = 32 | Optional.ofNullable(articleMapper.findTag(tag.getName())) 33 | .orElseGet( 34 | () -> { 35 | articleMapper.insertTag(tag); 36 | return tag; 37 | }); 38 | articleMapper.insertArticleTagRelation(article.getId(), targetTag.getId()); 39 | } 40 | articleMapper.insert(article); 41 | } 42 | 43 | @Override 44 | public Optional
findById(String id) { 45 | return Optional.ofNullable(articleMapper.findById(id)); 46 | } 47 | 48 | @Override 49 | public Optional
findBySlug(String slug) { 50 | return Optional.ofNullable(articleMapper.findBySlug(slug)); 51 | } 52 | 53 | @Override 54 | public void remove(Article article) { 55 | articleMapper.delete(article.getId()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/repository/MyBatisCommentRepository.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.repository; 2 | 3 | import io.spring.core.comment.Comment; 4 | import io.spring.core.comment.CommentRepository; 5 | import io.spring.infrastructure.mybatis.mapper.CommentMapper; 6 | import java.util.Optional; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Component 11 | public class MyBatisCommentRepository implements CommentRepository { 12 | private CommentMapper commentMapper; 13 | 14 | @Autowired 15 | public MyBatisCommentRepository(CommentMapper commentMapper) { 16 | this.commentMapper = commentMapper; 17 | } 18 | 19 | @Override 20 | public void save(Comment comment) { 21 | commentMapper.insert(comment); 22 | } 23 | 24 | @Override 25 | public Optional findById(String articleId, String id) { 26 | return Optional.ofNullable(commentMapper.findById(articleId, id)); 27 | } 28 | 29 | @Override 30 | public void remove(Comment comment) { 31 | commentMapper.delete(comment.getId()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.repository; 2 | 3 | import io.spring.core.user.FollowRelation; 4 | import io.spring.core.user.User; 5 | import io.spring.core.user.UserRepository; 6 | import io.spring.infrastructure.mybatis.mapper.UserMapper; 7 | import java.util.Optional; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.stereotype.Repository; 10 | 11 | @Repository 12 | public class MyBatisUserRepository implements UserRepository { 13 | private final UserMapper userMapper; 14 | 15 | @Autowired 16 | public MyBatisUserRepository(UserMapper userMapper) { 17 | this.userMapper = userMapper; 18 | } 19 | 20 | @Override 21 | public void save(User user) { 22 | if (userMapper.findById(user.getId()) == null) { 23 | userMapper.insert(user); 24 | } else { 25 | userMapper.update(user); 26 | } 27 | } 28 | 29 | @Override 30 | public Optional findById(String id) { 31 | return Optional.ofNullable(userMapper.findById(id)); 32 | } 33 | 34 | @Override 35 | public Optional findByUsername(String username) { 36 | return Optional.ofNullable(userMapper.findByUsername(username)); 37 | } 38 | 39 | @Override 40 | public Optional findByEmail(String email) { 41 | return Optional.ofNullable(userMapper.findByEmail(email)); 42 | } 43 | 44 | @Override 45 | public void saveRelation(FollowRelation followRelation) { 46 | if (!findRelation(followRelation.getUserId(), followRelation.getTargetId()).isPresent()) { 47 | userMapper.saveRelation(followRelation); 48 | } 49 | } 50 | 51 | @Override 52 | public Optional findRelation(String userId, String targetId) { 53 | return Optional.ofNullable(userMapper.findRelation(userId, targetId)); 54 | } 55 | 56 | @Override 57 | public void removeRelation(FollowRelation followRelation) { 58 | userMapper.deleteRelation(followRelation); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/io/spring/infrastructure/service/DefaultJwtService.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.service; 2 | 3 | import io.jsonwebtoken.Claims; 4 | import io.jsonwebtoken.Jws; 5 | import io.jsonwebtoken.Jwts; 6 | import io.jsonwebtoken.SignatureAlgorithm; 7 | import io.spring.core.service.JwtService; 8 | import io.spring.core.user.User; 9 | import java.util.Date; 10 | import java.util.Optional; 11 | import javax.crypto.SecretKey; 12 | import javax.crypto.spec.SecretKeySpec; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.beans.factory.annotation.Value; 15 | import org.springframework.stereotype.Component; 16 | 17 | @Component 18 | public class DefaultJwtService implements JwtService { 19 | private final SecretKey signingKey; 20 | private final SignatureAlgorithm signatureAlgorithm; 21 | private int sessionTime; 22 | 23 | @Autowired 24 | public DefaultJwtService( 25 | @Value("${jwt.secret}") String secret, @Value("${jwt.sessionTime}") int sessionTime) { 26 | this.sessionTime = sessionTime; 27 | signatureAlgorithm = SignatureAlgorithm.HS512; 28 | this.signingKey = new SecretKeySpec(secret.getBytes(), signatureAlgorithm.getJcaName()); 29 | } 30 | 31 | @Override 32 | public String toToken(User user) { 33 | return Jwts.builder() 34 | .setSubject(user.getId()) 35 | .setExpiration(expireTimeFromNow()) 36 | .signWith(signingKey) 37 | .compact(); 38 | } 39 | 40 | @Override 41 | public Optional getSubFromToken(String token) { 42 | try { 43 | Jws claimsJws = 44 | Jwts.parserBuilder().setSigningKey(signingKey).build().parseClaimsJws(token); 45 | return Optional.ofNullable(claimsJws.getBody().getSubject()); 46 | } catch (Exception e) { 47 | return Optional.empty(); 48 | } 49 | } 50 | 51 | private Date expireTimeFromNow() { 52 | return new Date(System.currentTimeMillis() + sessionTime * 1000L); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/resources/application-test.properties: -------------------------------------------------------------------------------- 1 | spring.datasource.url=jdbc:sqlite::memory: -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.datasource.url=jdbc:sqlite:dev.db 2 | spring.datasource.driver-class-name=org.sqlite.JDBC 3 | spring.datasource.username= 4 | spring.datasource.password= 5 | spring.jackson.deserialization.UNWRAP_ROOT_VALUE=true 6 | 7 | image.default=https://static.productionready.io/images/smiley-cyrus.jpg 8 | 9 | jwt.secret=nRvyYC4soFxBdZ-F-5Nnzz5USXstR1YylsTd-mA0aKtI9HUlriGrtkf-TiuDapkLiUCogO3JOK7kwZisrHp6wA 10 | jwt.sessionTime=86400 11 | 12 | mybatis.configuration.cache-enabled=true 13 | mybatis.configuration.default-statement-timeout=3000 14 | mybatis.configuration.map-underscore-to-camel-case=true 15 | mybatis.configuration.use-generated-keys=true 16 | mybatis.type-handlers-package=io.spring.infrastructure.mybatis 17 | mybatis.mapper-locations=mapper/*.xml 18 | 19 | logging.level.io.spring.infrastructure.mybatis.readservice.ArticleReadService=DEBUG 20 | logging.level.io.spring.infrastructure.mybatis.mapper=DEBUG 21 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1__create_tables.sql: -------------------------------------------------------------------------------- 1 | create table users ( 2 | id varchar(255) primary key, 3 | username varchar(255) UNIQUE, 4 | password varchar(255), 5 | email varchar(255) UNIQUE, 6 | bio text, 7 | image varchar(511) 8 | ); 9 | 10 | create table articles ( 11 | id varchar(255) primary key, 12 | user_id varchar(255), 13 | slug varchar(255) UNIQUE, 14 | title varchar(255), 15 | description text, 16 | body text, 17 | created_at TIMESTAMP NOT NULL, 18 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 19 | ); 20 | 21 | create table article_favorites ( 22 | article_id varchar(255) not null, 23 | user_id varchar(255) not null, 24 | primary key(article_id, user_id) 25 | ); 26 | 27 | create table follows ( 28 | user_id varchar(255) not null, 29 | follow_id varchar(255) not null 30 | ); 31 | 32 | create table tags ( 33 | id varchar(255) primary key, 34 | name varchar(255) not null 35 | ); 36 | 37 | create table article_tags ( 38 | article_id varchar(255) not null, 39 | tag_id varchar(255) not null 40 | ); 41 | 42 | create table comments ( 43 | id varchar(255) primary key, 44 | body text, 45 | article_id varchar(255), 46 | user_id varchar(255), 47 | created_at TIMESTAMP NOT NULL, 48 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 49 | ); 50 | -------------------------------------------------------------------------------- /src/main/resources/mapper/ArticleFavoriteMapper.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | insert into article_favorites (article_id, user_id) values (#{articleFavorite.articleId}, #{articleFavorite.userId}) 6 | 7 | 8 | delete from article_favorites where article_id = #{favorite.articleId} and user_id = #{favorite.userId} 9 | 10 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/main/resources/mapper/ArticleFavoritesReadService.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 10 | 19 | 30 | -------------------------------------------------------------------------------- /src/main/resources/mapper/ArticleMapper.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | insert into articles(id, slug, title, description, body, user_id, created_at, updated_at) 6 | values( 7 | #{article.id}, 8 | #{article.slug}, 9 | #{article.title}, 10 | #{article.description}, 11 | #{article.body}, 12 | #{article.userId}, 13 | #{article.createdAt}, 14 | #{article.updatedAt}) 15 | 16 | 17 | insert into tags (id, name) values (#{tag.id}, #{tag.name}) 18 | 19 | 20 | insert into article_tags (article_id, tag_id) values(#{articleId}, #{tagId}) 21 | 22 | 23 | update articles 24 | 25 | title = #{article.title}, 26 | slug = #{article.slug}, 27 | description = #{article.description}, 28 | body = #{article.body} 29 | 30 | where id = #{article.id} 31 | 32 | 33 | delete from articles where id = #{id} 34 | 35 | 36 | select 37 | A.id articleId, 38 | A.slug articleSlug, 39 | A.title articleTitle, 40 | A.description articleDescription, 41 | A.body articleBody, 42 | A.user_id articleUserId, 43 | A.created_at articleCreatedAt, 44 | A.updated_at articleUpdatedAt, 45 | T.id tagId, 46 | T.name tagName 47 | from articles A 48 | left join article_tags AT on A.id = AT.article_id 49 | left join tags T on T.id = AT.tag_id 50 | 51 | 52 | 56 | 57 | 60 | 61 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /src/main/resources/mapper/ArticleReadService.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | U.id userId, 6 | U.username userUsername, 7 | U.bio userBio, 8 | U.image userImage 9 | 10 | 11 | select 12 | A.id articleId, 13 | A.slug articleSlug, 14 | A.title articleTitle, 15 | A.description articleDescription, 16 | A.body articleBody, 17 | A.created_at articleCreatedAt, 18 | A.updated_at articleUpdatedAt, 19 | T.name tagName, 20 | 21 | from 22 | articles A 23 | left join article_tags AT on A.id = AT.article_id 24 | left join tags T on T.id = AT.tag_id 25 | left join users U on U.id = A.user_id 26 | 27 | 28 | select 29 | DISTINCT(A.id) articleId, A.created_at 30 | from 31 | articles A 32 | left join article_tags AT on A.id = AT.article_id 33 | left join tags T on T.id = AT.tag_id 34 | left join article_favorites AF on AF.article_id = A.id 35 | left join users AU on AU.id = A.user_id 36 | left join users AFU on AFU.id = AF.user_id 37 | 38 | 39 | 43 | 47 | 63 | 85 | 93 | 101 | 107 | 134 | 156 | 157 | 158 | 159 | 160 | -------------------------------------------------------------------------------- /src/main/resources/mapper/CommentMapper.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | insert into comments(id, body, user_id, article_id, created_at, updated_at) 6 | values ( 7 | #{comment.id}, 8 | #{comment.body}, 9 | #{comment.userId}, 10 | #{comment.articleId}, 11 | #{comment.createdAt}, 12 | #{comment.createdAt} 13 | ) 14 | 15 | 16 | delete from comments where id = #{id} 17 | 18 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/main/resources/mapper/CommentReadService.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SELECT 6 | C.id commentId, 7 | C.body commentBody, 8 | C.created_at commentCreatedAt, 9 | C.article_id commentArticleId, 10 | 11 | from comments C 12 | left join users U 13 | on C.user_id = U.id 14 | 15 | 16 | 20 | 24 | 42 | -------------------------------------------------------------------------------- /src/main/resources/mapper/TagReadService.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /src/main/resources/mapper/TransferData.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/main/resources/mapper/UserMapper.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | insert into users (id, username, email, password, bio, image) values( 6 | #{user.id}, 7 | #{user.username}, 8 | #{user.email}, 9 | #{user.password}, 10 | #{user.bio}, 11 | #{user.image} 12 | ) 13 | 14 | 15 | insert into follows(user_id, follow_id) values (#{followRelation.userId}, #{followRelation.targetId}) 16 | 17 | 18 | update users 19 | 20 | username = #{user.username}, 21 | email = #{user.email}, 22 | password = #{user.password}, 23 | bio = #{user.bio}, 24 | image = #{user.image} 25 | 26 | where id = #{user.id} 27 | 28 | 29 | delete from follows where user_id = #{followRelation.userId} and follow_id = #{followRelation.targetId} 30 | 31 | 34 | 37 | 40 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/main/resources/mapper/UserReadService.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 10 | -------------------------------------------------------------------------------- /src/main/resources/mapper/UserRelationshipQueryService.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 15 | 18 | -------------------------------------------------------------------------------- /src/main/resources/schema/schema.graphqls: -------------------------------------------------------------------------------- 1 | # Build the schema. 2 | type Query { 3 | article(slug: String!): Article 4 | articles( 5 | first: Int, 6 | after: String, 7 | last: Int, 8 | before: String, 9 | authoredBy: String 10 | favoritedBy: String 11 | withTag: String 12 | ): ArticlesConnection 13 | me: User 14 | feed(first: Int, after: String, last: Int, before: String): ArticlesConnection 15 | profile(username: String!): ProfilePayload 16 | tags: [String] 17 | } 18 | 19 | union UserResult = UserPayload | Error 20 | 21 | type Mutation { 22 | ### User & Profile 23 | createUser(input: CreateUserInput): UserResult 24 | login(password: String!, email: String!): UserPayload 25 | updateUser(changes: UpdateUserInput!): UserPayload 26 | followUser(username: String!): ProfilePayload 27 | unfollowUser(username: String!): ProfilePayload 28 | 29 | ### Article 30 | createArticle(input: CreateArticleInput!): ArticlePayload 31 | updateArticle(slug: String!, changes: UpdateArticleInput!): ArticlePayload 32 | favoriteArticle(slug: String!): ArticlePayload 33 | unfavoriteArticle(slug: String!): ArticlePayload 34 | deleteArticle(slug: String!): DeletionStatus 35 | 36 | ### Comment 37 | addComment(slug: String!, body: String!): CommentPayload 38 | deleteComment(slug: String!, id: ID!): DeletionStatus 39 | } 40 | 41 | schema { 42 | query: Query 43 | mutation: Mutation 44 | } 45 | 46 | ### Articles 47 | type Article { 48 | author: Profile! 49 | body: String! 50 | comments(first: Int, after: String, last: Int, before: String): CommentsConnection 51 | createdAt: String! 52 | description: String! 53 | favorited: Boolean! 54 | favoritesCount: Int! 55 | slug: String! 56 | tagList: [String], 57 | title: String! 58 | updatedAt: String! 59 | } 60 | 61 | type ArticleEdge { 62 | cursor: String! 63 | node: Article 64 | } 65 | 66 | type ArticlesConnection { 67 | edges: [ArticleEdge] 68 | pageInfo: PageInfo! 69 | } 70 | 71 | ### Comments 72 | type Comment { 73 | id: ID! 74 | author: Profile! 75 | article: Article! 76 | body: String! 77 | createdAt: String! 78 | updatedAt: String! 79 | } 80 | 81 | type CommentEdge { 82 | cursor: String! 83 | node: Comment 84 | } 85 | 86 | type CommentsConnection { 87 | edges: [CommentEdge] 88 | pageInfo: PageInfo! 89 | } 90 | 91 | type DeletionStatus { 92 | success: Boolean! 93 | } 94 | 95 | type PageInfo { 96 | endCursor: String 97 | hasNextPage: Boolean! 98 | hasPreviousPage: Boolean! 99 | startCursor: String 100 | } 101 | 102 | ### Profile 103 | type Profile { 104 | username: String! 105 | bio: String 106 | following: Boolean! 107 | image: String 108 | articles(first: Int, after: String, last: Int, before: String): ArticlesConnection 109 | favorites(first: Int, after: String, last: Int, before: String): ArticlesConnection 110 | feed(first: Int, after: String, last: Int, before: String): ArticlesConnection 111 | } 112 | 113 | ### User 114 | type User { 115 | email: String! 116 | profile: Profile! 117 | token: String! 118 | username: String! 119 | } 120 | 121 | ### Error 122 | type Error { 123 | message: String 124 | errors: [ErrorItem!] 125 | } 126 | 127 | type ErrorItem { 128 | key: String! 129 | value: [String!]! 130 | } 131 | 132 | ## Mutations 133 | 134 | # Input types. 135 | input UpdateArticleInput { 136 | body: String 137 | description: String 138 | title: String 139 | } 140 | 141 | input CreateArticleInput { 142 | body: String! 143 | description: String! 144 | tagList: [String] 145 | title: String! 146 | } 147 | 148 | type ArticlePayload { 149 | article: Article 150 | } 151 | 152 | type CommentPayload { 153 | comment: Comment 154 | } 155 | 156 | input CreateUserInput { 157 | email: String! 158 | username: String! 159 | password: String! 160 | } 161 | 162 | input UpdateUserInput { 163 | email: String 164 | username: String 165 | password: String 166 | image: String 167 | bio: String 168 | } 169 | 170 | type UserPayload { 171 | user: User 172 | } 173 | 174 | type ProfilePayload { 175 | profile: Profile 176 | } 177 | 178 | -------------------------------------------------------------------------------- /src/test/java/io/spring/RealworldApplicationTests.java: -------------------------------------------------------------------------------- 1 | package io.spring; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | public class RealworldApplicationTests { 8 | 9 | @Test 10 | public void contextLoads() {} 11 | } 12 | -------------------------------------------------------------------------------- /src/test/java/io/spring/TestHelper.java: -------------------------------------------------------------------------------- 1 | package io.spring; 2 | 3 | import io.spring.application.data.ArticleData; 4 | import io.spring.application.data.ProfileData; 5 | import io.spring.core.article.Article; 6 | import io.spring.core.user.User; 7 | import java.util.ArrayList; 8 | import java.util.Arrays; 9 | import org.joda.time.DateTime; 10 | 11 | public class TestHelper { 12 | public static ArticleData articleDataFixture(String seed, User user) { 13 | DateTime now = new DateTime(); 14 | return new ArticleData( 15 | seed + "id", 16 | "title-" + seed, 17 | "title " + seed, 18 | "desc " + seed, 19 | "body " + seed, 20 | false, 21 | 0, 22 | now, 23 | now, 24 | new ArrayList<>(), 25 | new ProfileData(user.getId(), user.getUsername(), user.getBio(), user.getImage(), false)); 26 | } 27 | 28 | public static ArticleData getArticleDataFromArticleAndUser(Article article, User user) { 29 | return new ArticleData( 30 | article.getId(), 31 | article.getSlug(), 32 | article.getTitle(), 33 | article.getDescription(), 34 | article.getBody(), 35 | false, 36 | 0, 37 | article.getCreatedAt(), 38 | article.getUpdatedAt(), 39 | Arrays.asList("joda"), 40 | new ProfileData(user.getId(), user.getUsername(), user.getBio(), user.getImage(), false)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/test/java/io/spring/api/ArticleFavoriteApiTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; 4 | import static org.hamcrest.core.IsEqual.equalTo; 5 | import static org.mockito.ArgumentMatchers.any; 6 | import static org.mockito.ArgumentMatchers.eq; 7 | import static org.mockito.Mockito.verify; 8 | import static org.mockito.Mockito.when; 9 | 10 | import io.restassured.module.mockmvc.RestAssuredMockMvc; 11 | import io.spring.JacksonCustomizations; 12 | import io.spring.api.security.WebSecurityConfig; 13 | import io.spring.application.ArticleQueryService; 14 | import io.spring.application.data.ArticleData; 15 | import io.spring.application.data.ProfileData; 16 | import io.spring.core.article.Article; 17 | import io.spring.core.article.ArticleRepository; 18 | import io.spring.core.article.Tag; 19 | import io.spring.core.favorite.ArticleFavorite; 20 | import io.spring.core.favorite.ArticleFavoriteRepository; 21 | import io.spring.core.user.User; 22 | import java.util.Arrays; 23 | import java.util.Optional; 24 | import java.util.stream.Collectors; 25 | import org.junit.jupiter.api.BeforeEach; 26 | import org.junit.jupiter.api.Test; 27 | import org.springframework.beans.factory.annotation.Autowired; 28 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 29 | import org.springframework.boot.test.mock.mockito.MockBean; 30 | import org.springframework.context.annotation.Import; 31 | import org.springframework.test.web.servlet.MockMvc; 32 | 33 | @WebMvcTest(ArticleFavoriteApi.class) 34 | @Import({WebSecurityConfig.class, JacksonCustomizations.class}) 35 | public class ArticleFavoriteApiTest extends TestWithCurrentUser { 36 | @Autowired private MockMvc mvc; 37 | 38 | @MockBean private ArticleFavoriteRepository articleFavoriteRepository; 39 | 40 | @MockBean private ArticleRepository articleRepository; 41 | 42 | @MockBean private ArticleQueryService articleQueryService; 43 | 44 | private Article article; 45 | 46 | @BeforeEach 47 | public void setUp() throws Exception { 48 | super.setUp(); 49 | RestAssuredMockMvc.mockMvc(mvc); 50 | User anotherUser = new User("other@test.com", "other", "123", "", ""); 51 | article = new Article("title", "desc", "body", Arrays.asList("java"), anotherUser.getId()); 52 | when(articleRepository.findBySlug(eq(article.getSlug()))).thenReturn(Optional.of(article)); 53 | ArticleData articleData = 54 | new ArticleData( 55 | article.getId(), 56 | article.getSlug(), 57 | article.getTitle(), 58 | article.getDescription(), 59 | article.getBody(), 60 | true, 61 | 1, 62 | article.getCreatedAt(), 63 | article.getUpdatedAt(), 64 | article.getTags().stream().map(Tag::getName).collect(Collectors.toList()), 65 | new ProfileData( 66 | anotherUser.getId(), 67 | anotherUser.getUsername(), 68 | anotherUser.getBio(), 69 | anotherUser.getImage(), 70 | false)); 71 | when(articleQueryService.findBySlug(eq(articleData.getSlug()), eq(user))) 72 | .thenReturn(Optional.of(articleData)); 73 | } 74 | 75 | @Test 76 | public void should_favorite_an_article_success() throws Exception { 77 | given() 78 | .header("Authorization", "Token " + token) 79 | .when() 80 | .post("/articles/{slug}/favorite", article.getSlug()) 81 | .prettyPeek() 82 | .then() 83 | .statusCode(200) 84 | .body("article.id", equalTo(article.getId())); 85 | 86 | verify(articleFavoriteRepository).save(any()); 87 | } 88 | 89 | @Test 90 | public void should_unfavorite_an_article_success() throws Exception { 91 | when(articleFavoriteRepository.find(eq(article.getId()), eq(user.getId()))) 92 | .thenReturn(Optional.of(new ArticleFavorite(article.getId(), user.getId()))); 93 | given() 94 | .header("Authorization", "Token " + token) 95 | .when() 96 | .delete("/articles/{slug}/favorite", article.getSlug()) 97 | .prettyPeek() 98 | .then() 99 | .statusCode(200) 100 | .body("article.id", equalTo(article.getId())); 101 | verify(articleFavoriteRepository).remove(new ArticleFavorite(article.getId(), user.getId())); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/test/java/io/spring/api/CommentsApiTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; 4 | import static org.hamcrest.core.IsEqual.equalTo; 5 | import static org.mockito.ArgumentMatchers.any; 6 | import static org.mockito.ArgumentMatchers.anyString; 7 | import static org.mockito.ArgumentMatchers.eq; 8 | import static org.mockito.Mockito.when; 9 | 10 | import io.restassured.module.mockmvc.RestAssuredMockMvc; 11 | import io.spring.JacksonCustomizations; 12 | import io.spring.api.security.WebSecurityConfig; 13 | import io.spring.application.CommentQueryService; 14 | import io.spring.application.data.CommentData; 15 | import io.spring.application.data.ProfileData; 16 | import io.spring.core.article.Article; 17 | import io.spring.core.article.ArticleRepository; 18 | import io.spring.core.comment.Comment; 19 | import io.spring.core.comment.CommentRepository; 20 | import io.spring.core.user.User; 21 | import java.util.Arrays; 22 | import java.util.HashMap; 23 | import java.util.Map; 24 | import java.util.Optional; 25 | import org.junit.jupiter.api.BeforeEach; 26 | import org.junit.jupiter.api.Test; 27 | import org.springframework.beans.factory.annotation.Autowired; 28 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 29 | import org.springframework.boot.test.mock.mockito.MockBean; 30 | import org.springframework.context.annotation.Import; 31 | import org.springframework.test.web.servlet.MockMvc; 32 | 33 | @WebMvcTest(CommentsApi.class) 34 | @Import({WebSecurityConfig.class, JacksonCustomizations.class}) 35 | public class CommentsApiTest extends TestWithCurrentUser { 36 | 37 | @MockBean private ArticleRepository articleRepository; 38 | 39 | @MockBean private CommentRepository commentRepository; 40 | @MockBean private CommentQueryService commentQueryService; 41 | 42 | private Article article; 43 | private CommentData commentData; 44 | private Comment comment; 45 | @Autowired private MockMvc mvc; 46 | 47 | @BeforeEach 48 | public void setUp() throws Exception { 49 | RestAssuredMockMvc.mockMvc(mvc); 50 | super.setUp(); 51 | article = new Article("title", "desc", "body", Arrays.asList("test", "java"), user.getId()); 52 | when(articleRepository.findBySlug(eq(article.getSlug()))).thenReturn(Optional.of(article)); 53 | comment = new Comment("comment", user.getId(), article.getId()); 54 | commentData = 55 | new CommentData( 56 | comment.getId(), 57 | comment.getBody(), 58 | comment.getArticleId(), 59 | comment.getCreatedAt(), 60 | comment.getCreatedAt(), 61 | new ProfileData( 62 | user.getId(), user.getUsername(), user.getBio(), user.getImage(), false)); 63 | } 64 | 65 | @Test 66 | public void should_create_comment_success() throws Exception { 67 | Map param = 68 | new HashMap() { 69 | { 70 | put( 71 | "comment", 72 | new HashMap() { 73 | { 74 | put("body", "comment content"); 75 | } 76 | }); 77 | } 78 | }; 79 | 80 | when(commentQueryService.findById(anyString(), eq(user))).thenReturn(Optional.of(commentData)); 81 | 82 | given() 83 | .contentType("application/json") 84 | .header("Authorization", "Token " + token) 85 | .body(param) 86 | .when() 87 | .post("/articles/{slug}/comments", article.getSlug()) 88 | .then() 89 | .statusCode(201) 90 | .body("comment.body", equalTo(commentData.getBody())); 91 | } 92 | 93 | @Test 94 | public void should_get_422_with_empty_body() throws Exception { 95 | Map param = 96 | new HashMap() { 97 | { 98 | put( 99 | "comment", 100 | new HashMap() { 101 | { 102 | put("body", ""); 103 | } 104 | }); 105 | } 106 | }; 107 | 108 | given() 109 | .contentType("application/json") 110 | .header("Authorization", "Token " + token) 111 | .body(param) 112 | .when() 113 | .post("/articles/{slug}/comments", article.getSlug()) 114 | .then() 115 | .statusCode(422) 116 | .body("errors.body[0]", equalTo("can't be empty")); 117 | } 118 | 119 | @Test 120 | public void should_get_comments_of_article_success() throws Exception { 121 | when(commentQueryService.findByArticleId(anyString(), eq(null))) 122 | .thenReturn(Arrays.asList(commentData)); 123 | RestAssuredMockMvc.when() 124 | .get("/articles/{slug}/comments", article.getSlug()) 125 | .prettyPeek() 126 | .then() 127 | .statusCode(200) 128 | .body("comments[0].id", equalTo(commentData.getId())); 129 | } 130 | 131 | @Test 132 | public void should_delete_comment_success() throws Exception { 133 | when(commentRepository.findById(eq(article.getId()), eq(comment.getId()))) 134 | .thenReturn(Optional.of(comment)); 135 | 136 | given() 137 | .header("Authorization", "Token " + token) 138 | .when() 139 | .delete("/articles/{slug}/comments/{id}", article.getSlug(), comment.getId()) 140 | .then() 141 | .statusCode(204); 142 | } 143 | 144 | @Test 145 | public void should_get_403_if_not_author_of_article_or_author_of_comment_when_delete_comment() 146 | throws Exception { 147 | User anotherUser = new User("other@example.com", "other", "123", "", ""); 148 | when(userRepository.findByUsername(eq(anotherUser.getUsername()))) 149 | .thenReturn(Optional.of(anotherUser)); 150 | when(jwtService.getSubFromToken(any())).thenReturn(Optional.of(anotherUser.getId())); 151 | when(userRepository.findById(eq(anotherUser.getId()))) 152 | .thenReturn(Optional.ofNullable(anotherUser)); 153 | 154 | when(commentRepository.findById(eq(article.getId()), eq(comment.getId()))) 155 | .thenReturn(Optional.of(comment)); 156 | String token = jwtService.toToken(anotherUser); 157 | when(userRepository.findById(eq(anotherUser.getId()))).thenReturn(Optional.of(anotherUser)); 158 | given() 159 | .header("Authorization", "Token " + token) 160 | .when() 161 | .delete("/articles/{slug}/comments/{id}", article.getSlug(), comment.getId()) 162 | .then() 163 | .statusCode(403); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/test/java/io/spring/api/CurrentUserApiTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; 4 | import static org.hamcrest.core.IsEqual.equalTo; 5 | import static org.mockito.ArgumentMatchers.any; 6 | import static org.mockito.ArgumentMatchers.eq; 7 | import static org.mockito.Mockito.when; 8 | 9 | import io.restassured.module.mockmvc.RestAssuredMockMvc; 10 | import io.spring.JacksonCustomizations; 11 | import io.spring.api.security.WebSecurityConfig; 12 | import io.spring.application.UserQueryService; 13 | import io.spring.application.user.UserService; 14 | import io.spring.core.user.User; 15 | import java.util.HashMap; 16 | import java.util.Map; 17 | import java.util.Optional; 18 | import org.junit.jupiter.api.BeforeEach; 19 | import org.junit.jupiter.api.Test; 20 | import org.springframework.beans.factory.annotation.Autowired; 21 | import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; 22 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 23 | import org.springframework.boot.test.mock.mockito.MockBean; 24 | import org.springframework.context.annotation.Import; 25 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 26 | import org.springframework.test.web.servlet.MockMvc; 27 | 28 | @WebMvcTest(CurrentUserApi.class) 29 | @Import({ 30 | WebSecurityConfig.class, 31 | JacksonCustomizations.class, 32 | UserService.class, 33 | ValidationAutoConfiguration.class, 34 | BCryptPasswordEncoder.class 35 | }) 36 | public class CurrentUserApiTest extends TestWithCurrentUser { 37 | 38 | @Autowired private MockMvc mvc; 39 | 40 | @MockBean private UserQueryService userQueryService; 41 | 42 | @Override 43 | @BeforeEach 44 | public void setUp() throws Exception { 45 | super.setUp(); 46 | RestAssuredMockMvc.mockMvc(mvc); 47 | } 48 | 49 | @Test 50 | public void should_get_current_user_with_token() throws Exception { 51 | when(userQueryService.findById(any())).thenReturn(Optional.of(userData)); 52 | 53 | given() 54 | .header("Authorization", "Token " + token) 55 | .contentType("application/json") 56 | .when() 57 | .get("/user") 58 | .then() 59 | .statusCode(200) 60 | .body("user.email", equalTo(email)) 61 | .body("user.username", equalTo(username)) 62 | .body("user.bio", equalTo("")) 63 | .body("user.image", equalTo(defaultAvatar)) 64 | .body("user.token", equalTo(token)); 65 | } 66 | 67 | @Test 68 | public void should_get_401_without_token() throws Exception { 69 | given().contentType("application/json").when().get("/user").then().statusCode(401); 70 | } 71 | 72 | @Test 73 | public void should_get_401_with_invalid_token() throws Exception { 74 | String invalidToken = "asdfasd"; 75 | when(jwtService.getSubFromToken(eq(invalidToken))).thenReturn(Optional.empty()); 76 | given() 77 | .contentType("application/json") 78 | .header("Authorization", "Token " + invalidToken) 79 | .when() 80 | .get("/user") 81 | .then() 82 | .statusCode(401); 83 | } 84 | 85 | @Test 86 | public void should_update_current_user_profile() throws Exception { 87 | String newEmail = "newemail@example.com"; 88 | String newBio = "updated"; 89 | String newUsername = "newusernamee"; 90 | 91 | Map param = 92 | new HashMap() { 93 | { 94 | put( 95 | "user", 96 | new HashMap() { 97 | { 98 | put("email", newEmail); 99 | put("bio", newBio); 100 | put("username", newUsername); 101 | } 102 | }); 103 | } 104 | }; 105 | 106 | when(userRepository.findByUsername(eq(newUsername))).thenReturn(Optional.empty()); 107 | when(userRepository.findByEmail(eq(newEmail))).thenReturn(Optional.empty()); 108 | 109 | when(userQueryService.findById(eq(user.getId()))).thenReturn(Optional.of(userData)); 110 | 111 | given() 112 | .contentType("application/json") 113 | .header("Authorization", "Token " + token) 114 | .body(param) 115 | .when() 116 | .put("/user") 117 | .then() 118 | .statusCode(200); 119 | } 120 | 121 | @Test 122 | public void should_get_error_if_email_exists_when_update_user_profile() throws Exception { 123 | String newEmail = "newemail@example.com"; 124 | String newBio = "updated"; 125 | String newUsername = "newusernamee"; 126 | 127 | Map param = prepareUpdateParam(newEmail, newBio, newUsername); 128 | 129 | when(userRepository.findByEmail(eq(newEmail))) 130 | .thenReturn(Optional.of(new User(newEmail, "username", "123", "", ""))); 131 | when(userRepository.findByUsername(eq(newUsername))).thenReturn(Optional.empty()); 132 | 133 | when(userQueryService.findById(eq(user.getId()))).thenReturn(Optional.of(userData)); 134 | 135 | given() 136 | .contentType("application/json") 137 | .header("Authorization", "Token " + token) 138 | .body(param) 139 | .when() 140 | .put("/user") 141 | .prettyPeek() 142 | .then() 143 | .statusCode(422) 144 | .body("errors.email[0]", equalTo("email already exist")); 145 | } 146 | 147 | private HashMap prepareUpdateParam( 148 | final String newEmail, final String newBio, final String newUsername) { 149 | return new HashMap() { 150 | { 151 | put( 152 | "user", 153 | new HashMap() { 154 | { 155 | put("email", newEmail); 156 | put("bio", newBio); 157 | put("username", newUsername); 158 | } 159 | }); 160 | } 161 | }; 162 | } 163 | 164 | @Test 165 | public void should_get_401_if_not_login() throws Exception { 166 | given() 167 | .contentType("application/json") 168 | .body( 169 | new HashMap() { 170 | { 171 | put("user", new HashMap()); 172 | } 173 | }) 174 | .when() 175 | .put("/user") 176 | .then() 177 | .statusCode(401); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/test/java/io/spring/api/ListArticleApiTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; 4 | import static io.spring.TestHelper.articleDataFixture; 5 | import static java.util.Arrays.asList; 6 | import static org.mockito.ArgumentMatchers.eq; 7 | import static org.mockito.Mockito.when; 8 | 9 | import io.restassured.module.mockmvc.RestAssuredMockMvc; 10 | import io.spring.JacksonCustomizations; 11 | import io.spring.api.security.WebSecurityConfig; 12 | import io.spring.application.ArticleQueryService; 13 | import io.spring.application.Page; 14 | import io.spring.application.article.ArticleCommandService; 15 | import io.spring.application.data.ArticleDataList; 16 | import io.spring.core.article.ArticleRepository; 17 | import org.junit.jupiter.api.BeforeEach; 18 | import org.junit.jupiter.api.Test; 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 21 | import org.springframework.boot.test.mock.mockito.MockBean; 22 | import org.springframework.context.annotation.Import; 23 | import org.springframework.test.web.servlet.MockMvc; 24 | 25 | @WebMvcTest(ArticlesApi.class) 26 | @Import({WebSecurityConfig.class, JacksonCustomizations.class}) 27 | public class ListArticleApiTest extends TestWithCurrentUser { 28 | @MockBean private ArticleRepository articleRepository; 29 | 30 | @MockBean private ArticleQueryService articleQueryService; 31 | 32 | @MockBean private ArticleCommandService articleCommandService; 33 | 34 | @Autowired private MockMvc mvc; 35 | 36 | @Override 37 | @BeforeEach 38 | public void setUp() throws Exception { 39 | super.setUp(); 40 | RestAssuredMockMvc.mockMvc(mvc); 41 | } 42 | 43 | @Test 44 | public void should_get_default_article_list() throws Exception { 45 | ArticleDataList articleDataList = 46 | new ArticleDataList( 47 | asList(articleDataFixture("1", user), articleDataFixture("2", user)), 2); 48 | when(articleQueryService.findRecentArticles( 49 | eq(null), eq(null), eq(null), eq(new Page(0, 20)), eq(null))) 50 | .thenReturn(articleDataList); 51 | RestAssuredMockMvc.when().get("/articles").prettyPeek().then().statusCode(200); 52 | } 53 | 54 | @Test 55 | public void should_get_feeds_401_without_login() throws Exception { 56 | RestAssuredMockMvc.when().get("/articles/feed").prettyPeek().then().statusCode(401); 57 | } 58 | 59 | @Test 60 | public void should_get_feeds_success() throws Exception { 61 | ArticleDataList articleDataList = 62 | new ArticleDataList( 63 | asList(articleDataFixture("1", user), articleDataFixture("2", user)), 2); 64 | when(articleQueryService.findUserFeed(eq(user), eq(new Page(0, 20)))) 65 | .thenReturn(articleDataList); 66 | 67 | given() 68 | .header("Authorization", "Token " + token) 69 | .when() 70 | .get("/articles/feed") 71 | .prettyPeek() 72 | .then() 73 | .statusCode(200); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/test/java/io/spring/api/ProfileApiTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; 4 | import static org.hamcrest.core.IsEqual.equalTo; 5 | import static org.mockito.ArgumentMatchers.eq; 6 | import static org.mockito.Mockito.verify; 7 | import static org.mockito.Mockito.when; 8 | 9 | import io.restassured.module.mockmvc.RestAssuredMockMvc; 10 | import io.spring.JacksonCustomizations; 11 | import io.spring.api.security.WebSecurityConfig; 12 | import io.spring.application.ProfileQueryService; 13 | import io.spring.application.data.ProfileData; 14 | import io.spring.core.user.FollowRelation; 15 | import io.spring.core.user.User; 16 | import java.util.Optional; 17 | import org.junit.jupiter.api.BeforeEach; 18 | import org.junit.jupiter.api.Test; 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 21 | import org.springframework.boot.test.mock.mockito.MockBean; 22 | import org.springframework.context.annotation.Import; 23 | import org.springframework.test.web.servlet.MockMvc; 24 | 25 | @WebMvcTest(ProfileApi.class) 26 | @Import({WebSecurityConfig.class, JacksonCustomizations.class}) 27 | public class ProfileApiTest extends TestWithCurrentUser { 28 | private User anotherUser; 29 | 30 | @Autowired private MockMvc mvc; 31 | 32 | @MockBean private ProfileQueryService profileQueryService; 33 | 34 | private ProfileData profileData; 35 | 36 | @BeforeEach 37 | public void setUp() throws Exception { 38 | super.setUp(); 39 | RestAssuredMockMvc.mockMvc(mvc); 40 | anotherUser = new User("username@test.com", "username", "123", "", ""); 41 | profileData = 42 | new ProfileData( 43 | anotherUser.getId(), 44 | anotherUser.getUsername(), 45 | anotherUser.getBio(), 46 | anotherUser.getImage(), 47 | false); 48 | when(userRepository.findByUsername(eq(anotherUser.getUsername()))) 49 | .thenReturn(Optional.of(anotherUser)); 50 | } 51 | 52 | @Test 53 | public void should_get_user_profile_success() throws Exception { 54 | when(profileQueryService.findByUsername(eq(profileData.getUsername()), eq(null))) 55 | .thenReturn(Optional.of(profileData)); 56 | RestAssuredMockMvc.when() 57 | .get("/profiles/{username}", profileData.getUsername()) 58 | .prettyPeek() 59 | .then() 60 | .statusCode(200) 61 | .body("profile.username", equalTo(profileData.getUsername())); 62 | } 63 | 64 | @Test 65 | public void should_follow_user_success() throws Exception { 66 | when(profileQueryService.findByUsername(eq(profileData.getUsername()), eq(user))) 67 | .thenReturn(Optional.of(profileData)); 68 | given() 69 | .header("Authorization", "Token " + token) 70 | .when() 71 | .post("/profiles/{username}/follow", anotherUser.getUsername()) 72 | .prettyPeek() 73 | .then() 74 | .statusCode(200); 75 | verify(userRepository).saveRelation(new FollowRelation(user.getId(), anotherUser.getId())); 76 | } 77 | 78 | @Test 79 | public void should_unfollow_user_success() throws Exception { 80 | FollowRelation followRelation = new FollowRelation(user.getId(), anotherUser.getId()); 81 | when(userRepository.findRelation(eq(user.getId()), eq(anotherUser.getId()))) 82 | .thenReturn(Optional.of(followRelation)); 83 | when(profileQueryService.findByUsername(eq(profileData.getUsername()), eq(user))) 84 | .thenReturn(Optional.of(profileData)); 85 | 86 | given() 87 | .header("Authorization", "Token " + token) 88 | .when() 89 | .delete("/profiles/{username}/follow", anotherUser.getUsername()) 90 | .prettyPeek() 91 | .then() 92 | .statusCode(200); 93 | 94 | verify(userRepository).removeRelation(eq(followRelation)); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/test/java/io/spring/api/TestWithCurrentUser.java: -------------------------------------------------------------------------------- 1 | package io.spring.api; 2 | 3 | import static org.mockito.ArgumentMatchers.eq; 4 | import static org.mockito.Mockito.when; 5 | 6 | import io.spring.application.data.UserData; 7 | import io.spring.core.service.JwtService; 8 | import io.spring.core.user.User; 9 | import io.spring.core.user.UserRepository; 10 | import io.spring.infrastructure.mybatis.readservice.UserReadService; 11 | import java.util.Optional; 12 | import org.junit.jupiter.api.BeforeEach; 13 | import org.springframework.boot.test.mock.mockito.MockBean; 14 | 15 | abstract class TestWithCurrentUser { 16 | @MockBean protected UserRepository userRepository; 17 | 18 | @MockBean protected UserReadService userReadService; 19 | 20 | protected User user; 21 | protected UserData userData; 22 | protected String token; 23 | protected String email; 24 | protected String username; 25 | protected String defaultAvatar; 26 | 27 | @MockBean protected JwtService jwtService; 28 | 29 | protected void userFixture() { 30 | email = "john@jacob.com"; 31 | username = "johnjacob"; 32 | defaultAvatar = "https://static.productionready.io/images/smiley-cyrus.jpg"; 33 | 34 | user = new User(email, username, "123", "", defaultAvatar); 35 | when(userRepository.findByUsername(eq(username))).thenReturn(Optional.of(user)); 36 | when(userRepository.findById(eq(user.getId()))).thenReturn(Optional.of(user)); 37 | 38 | userData = new UserData(user.getId(), email, username, "", defaultAvatar); 39 | when(userReadService.findById(eq(user.getId()))).thenReturn(userData); 40 | 41 | token = "token"; 42 | when(jwtService.getSubFromToken(eq(token))).thenReturn(Optional.of(user.getId())); 43 | } 44 | 45 | @BeforeEach 46 | public void setUp() throws Exception { 47 | userFixture(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/test/java/io/spring/application/comment/CommentQueryServiceTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.comment; 2 | 3 | import io.spring.application.CommentQueryService; 4 | import io.spring.application.data.CommentData; 5 | import io.spring.core.article.Article; 6 | import io.spring.core.article.ArticleRepository; 7 | import io.spring.core.comment.Comment; 8 | import io.spring.core.comment.CommentRepository; 9 | import io.spring.core.user.FollowRelation; 10 | import io.spring.core.user.User; 11 | import io.spring.core.user.UserRepository; 12 | import io.spring.infrastructure.DbTestBase; 13 | import io.spring.infrastructure.repository.MyBatisArticleRepository; 14 | import io.spring.infrastructure.repository.MyBatisCommentRepository; 15 | import io.spring.infrastructure.repository.MyBatisUserRepository; 16 | import java.util.Arrays; 17 | import java.util.List; 18 | import java.util.Optional; 19 | import org.junit.jupiter.api.Assertions; 20 | import org.junit.jupiter.api.BeforeEach; 21 | import org.junit.jupiter.api.Test; 22 | import org.springframework.beans.factory.annotation.Autowired; 23 | import org.springframework.context.annotation.Import; 24 | 25 | @Import({ 26 | MyBatisCommentRepository.class, 27 | MyBatisUserRepository.class, 28 | CommentQueryService.class, 29 | MyBatisArticleRepository.class 30 | }) 31 | public class CommentQueryServiceTest extends DbTestBase { 32 | @Autowired private CommentRepository commentRepository; 33 | 34 | @Autowired private UserRepository userRepository; 35 | 36 | @Autowired private CommentQueryService commentQueryService; 37 | 38 | @Autowired private ArticleRepository articleRepository; 39 | 40 | private User user; 41 | 42 | @BeforeEach 43 | public void setUp() { 44 | user = new User("aisensiy@test.com", "aisensiy", "123", "", ""); 45 | userRepository.save(user); 46 | } 47 | 48 | @Test 49 | public void should_read_comment_success() { 50 | Comment comment = new Comment("content", user.getId(), "123"); 51 | commentRepository.save(comment); 52 | 53 | Optional optional = commentQueryService.findById(comment.getId(), user); 54 | Assertions.assertTrue(optional.isPresent()); 55 | CommentData commentData = optional.get(); 56 | Assertions.assertEquals(commentData.getProfileData().getUsername(), user.getUsername()); 57 | } 58 | 59 | @Test 60 | public void should_read_comments_of_article() { 61 | Article article = new Article("title", "desc", "body", Arrays.asList("java"), user.getId()); 62 | articleRepository.save(article); 63 | 64 | User user2 = new User("user2@email.com", "user2", "123", "", ""); 65 | userRepository.save(user2); 66 | userRepository.saveRelation(new FollowRelation(user.getId(), user2.getId())); 67 | 68 | Comment comment1 = new Comment("content1", user.getId(), article.getId()); 69 | commentRepository.save(comment1); 70 | Comment comment2 = new Comment("content2", user2.getId(), article.getId()); 71 | commentRepository.save(comment2); 72 | 73 | List comments = commentQueryService.findByArticleId(article.getId(), user); 74 | Assertions.assertEquals(comments.size(), 2); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/test/java/io/spring/application/profile/ProfileQueryServiceTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.profile; 2 | 3 | import io.spring.application.ProfileQueryService; 4 | import io.spring.application.data.ProfileData; 5 | import io.spring.core.user.User; 6 | import io.spring.core.user.UserRepository; 7 | import io.spring.infrastructure.DbTestBase; 8 | import io.spring.infrastructure.repository.MyBatisUserRepository; 9 | import java.util.Optional; 10 | import org.junit.jupiter.api.Assertions; 11 | import org.junit.jupiter.api.Test; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.context.annotation.Import; 14 | 15 | @Import({ProfileQueryService.class, MyBatisUserRepository.class}) 16 | public class ProfileQueryServiceTest extends DbTestBase { 17 | @Autowired private ProfileQueryService profileQueryService; 18 | @Autowired private UserRepository userRepository; 19 | 20 | @Test 21 | public void should_fetch_profile_success() { 22 | User currentUser = new User("a@test.com", "a", "123", "", ""); 23 | User profileUser = new User("p@test.com", "p", "123", "", ""); 24 | userRepository.save(profileUser); 25 | 26 | Optional optional = 27 | profileQueryService.findByUsername(profileUser.getUsername(), currentUser); 28 | Assertions.assertTrue(optional.isPresent()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/test/java/io/spring/application/tag/TagsQueryServiceTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.application.tag; 2 | 3 | import io.spring.application.TagsQueryService; 4 | import io.spring.core.article.Article; 5 | import io.spring.core.article.ArticleRepository; 6 | import io.spring.infrastructure.DbTestBase; 7 | import io.spring.infrastructure.repository.MyBatisArticleRepository; 8 | import java.util.Arrays; 9 | import org.junit.jupiter.api.Assertions; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.context.annotation.Import; 13 | 14 | @Import({TagsQueryService.class, MyBatisArticleRepository.class}) 15 | public class TagsQueryServiceTest extends DbTestBase { 16 | @Autowired private TagsQueryService tagsQueryService; 17 | 18 | @Autowired private ArticleRepository articleRepository; 19 | 20 | @Test 21 | public void should_get_all_tags() { 22 | articleRepository.save(new Article("test", "test", "test", Arrays.asList("java"), "123")); 23 | Assertions.assertTrue(tagsQueryService.allTags().contains("java")); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/java/io/spring/core/article/ArticleTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.core.article; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.hamcrest.MatcherAssert.assertThat; 5 | 6 | import java.util.Arrays; 7 | import org.junit.jupiter.api.Test; 8 | 9 | public class ArticleTest { 10 | 11 | @Test 12 | public void should_get_right_slug() { 13 | Article article = new Article("a new title", "desc", "body", Arrays.asList("java"), "123"); 14 | assertThat(article.getSlug(), is("a-new-title")); 15 | } 16 | 17 | @Test 18 | public void should_get_right_slug_with_number_in_title() { 19 | Article article = new Article("a new title 2", "desc", "body", Arrays.asList("java"), "123"); 20 | assertThat(article.getSlug(), is("a-new-title-2")); 21 | } 22 | 23 | @Test 24 | public void should_get_lower_case_slug() { 25 | Article article = new Article("A NEW TITLE", "desc", "body", Arrays.asList("java"), "123"); 26 | assertThat(article.getSlug(), is("a-new-title")); 27 | } 28 | 29 | @Test 30 | public void should_handle_other_language() { 31 | Article article = new Article("中文:标题", "desc", "body", Arrays.asList("java"), "123"); 32 | assertThat(article.getSlug(), is("中文-标题")); 33 | } 34 | 35 | @Test 36 | public void should_handle_commas() { 37 | Article article = new Article("what?the.hell,w", "desc", "body", Arrays.asList("java"), "123"); 38 | assertThat(article.getSlug(), is("what-the-hell-w")); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/test/java/io/spring/infrastructure/DbTestBase.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure; 2 | 3 | import org.mybatis.spring.boot.test.autoconfigure.MybatisTest; 4 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; 5 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; 6 | import org.springframework.test.context.ActiveProfiles; 7 | 8 | @ActiveProfiles("test") 9 | @AutoConfigureTestDatabase(replace = Replace.NONE) 10 | @MybatisTest 11 | public abstract class DbTestBase {} 12 | -------------------------------------------------------------------------------- /src/test/java/io/spring/infrastructure/article/ArticleRepositoryTransactionTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.article; 2 | 3 | import io.spring.core.article.Article; 4 | import io.spring.core.article.ArticleRepository; 5 | import io.spring.core.user.User; 6 | import io.spring.core.user.UserRepository; 7 | import io.spring.infrastructure.mybatis.mapper.ArticleMapper; 8 | import java.util.Arrays; 9 | import org.junit.jupiter.api.Assertions; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.test.context.ActiveProfiles; 15 | 16 | @ActiveProfiles("test") 17 | @SpringBootTest 18 | @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 19 | public class ArticleRepositoryTransactionTest { 20 | @Autowired private ArticleRepository articleRepository; 21 | 22 | @Autowired private UserRepository userRepository; 23 | 24 | @Autowired private ArticleMapper articleMapper; 25 | 26 | @Test 27 | public void transactional_test() { 28 | User user = new User("aisensiy@gmail.com", "aisensiy", "123", "bio", "default"); 29 | userRepository.save(user); 30 | Article article = 31 | new Article("test", "desc", "body", Arrays.asList("java", "spring"), user.getId()); 32 | articleRepository.save(article); 33 | Article anotherArticle = 34 | new Article("test", "desc", "body", Arrays.asList("java", "spring", "other"), user.getId()); 35 | try { 36 | articleRepository.save(anotherArticle); 37 | } catch (Exception e) { 38 | Assertions.assertNull(articleMapper.findTag("other")); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test/java/io/spring/infrastructure/article/MyBatisArticleRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.article; 2 | 3 | import io.spring.core.article.Article; 4 | import io.spring.core.article.ArticleRepository; 5 | import io.spring.core.article.Tag; 6 | import io.spring.core.user.User; 7 | import io.spring.core.user.UserRepository; 8 | import io.spring.infrastructure.DbTestBase; 9 | import io.spring.infrastructure.repository.MyBatisArticleRepository; 10 | import io.spring.infrastructure.repository.MyBatisUserRepository; 11 | import java.util.Arrays; 12 | import java.util.Optional; 13 | import org.junit.jupiter.api.Assertions; 14 | import org.junit.jupiter.api.BeforeEach; 15 | import org.junit.jupiter.api.Test; 16 | import org.springframework.beans.factory.annotation.Autowired; 17 | import org.springframework.context.annotation.Import; 18 | 19 | @Import({MyBatisArticleRepository.class, MyBatisUserRepository.class}) 20 | public class MyBatisArticleRepositoryTest extends DbTestBase { 21 | @Autowired private ArticleRepository articleRepository; 22 | 23 | @Autowired private UserRepository userRepository; 24 | 25 | private Article article; 26 | 27 | @BeforeEach 28 | public void setUp() { 29 | User user = new User("aisensiy@gmail.com", "aisensiy", "123", "bio", "default"); 30 | userRepository.save(user); 31 | article = new Article("test", "desc", "body", Arrays.asList("java", "spring"), user.getId()); 32 | } 33 | 34 | @Test 35 | public void should_create_and_fetch_article_success() { 36 | articleRepository.save(article); 37 | Optional
optional = articleRepository.findById(article.getId()); 38 | Assertions.assertTrue(optional.isPresent()); 39 | Assertions.assertEquals(optional.get(), article); 40 | Assertions.assertTrue(optional.get().getTags().contains(new Tag("java"))); 41 | Assertions.assertTrue(optional.get().getTags().contains(new Tag("spring"))); 42 | } 43 | 44 | @Test 45 | public void should_update_and_fetch_article_success() { 46 | articleRepository.save(article); 47 | 48 | String newTitle = "new test 2"; 49 | article.update(newTitle, "", ""); 50 | articleRepository.save(article); 51 | System.out.println(article.getSlug()); 52 | Optional
optional = articleRepository.findBySlug(article.getSlug()); 53 | Assertions.assertTrue(optional.isPresent()); 54 | Article fetched = optional.get(); 55 | Assertions.assertEquals(fetched.getTitle(), newTitle); 56 | Assertions.assertNotEquals(fetched.getBody(), ""); 57 | } 58 | 59 | @Test 60 | public void should_delete_article() { 61 | articleRepository.save(article); 62 | 63 | articleRepository.remove(article); 64 | Assertions.assertFalse(articleRepository.findById(article.getId()).isPresent()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/test/java/io/spring/infrastructure/comment/MyBatisCommentRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.comment; 2 | 3 | import io.spring.core.comment.Comment; 4 | import io.spring.core.comment.CommentRepository; 5 | import io.spring.infrastructure.DbTestBase; 6 | import io.spring.infrastructure.repository.MyBatisCommentRepository; 7 | import java.util.Optional; 8 | import org.junit.jupiter.api.Assertions; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.context.annotation.Import; 12 | 13 | @Import({MyBatisCommentRepository.class}) 14 | public class MyBatisCommentRepositoryTest extends DbTestBase { 15 | @Autowired private CommentRepository commentRepository; 16 | 17 | @Test 18 | public void should_create_and_fetch_comment_success() { 19 | Comment comment = new Comment("content", "123", "456"); 20 | commentRepository.save(comment); 21 | 22 | Optional optional = commentRepository.findById("456", comment.getId()); 23 | Assertions.assertTrue(optional.isPresent()); 24 | Assertions.assertEquals(optional.get(), comment); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/java/io/spring/infrastructure/favorite/MyBatisArticleFavoriteRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.favorite; 2 | 3 | import io.spring.core.favorite.ArticleFavorite; 4 | import io.spring.core.favorite.ArticleFavoriteRepository; 5 | import io.spring.infrastructure.DbTestBase; 6 | import io.spring.infrastructure.repository.MyBatisArticleFavoriteRepository; 7 | import org.junit.jupiter.api.Assertions; 8 | import org.junit.jupiter.api.Test; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.context.annotation.Import; 11 | 12 | @Import({MyBatisArticleFavoriteRepository.class}) 13 | public class MyBatisArticleFavoriteRepositoryTest extends DbTestBase { 14 | @Autowired private ArticleFavoriteRepository articleFavoriteRepository; 15 | 16 | @Autowired 17 | private io.spring.infrastructure.mybatis.mapper.ArticleFavoriteMapper articleFavoriteMapper; 18 | 19 | @Test 20 | public void should_save_and_fetch_articleFavorite_success() { 21 | ArticleFavorite articleFavorite = new ArticleFavorite("123", "456"); 22 | articleFavoriteRepository.save(articleFavorite); 23 | Assertions.assertNotNull( 24 | articleFavoriteMapper.find(articleFavorite.getArticleId(), articleFavorite.getUserId())); 25 | } 26 | 27 | @Test 28 | public void should_remove_favorite_success() { 29 | ArticleFavorite articleFavorite = new ArticleFavorite("123", "456"); 30 | articleFavoriteRepository.save(articleFavorite); 31 | articleFavoriteRepository.remove(articleFavorite); 32 | Assertions.assertFalse(articleFavoriteRepository.find("123", "456").isPresent()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/java/io/spring/infrastructure/service/DefaultJwtServiceTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.service; 2 | 3 | import io.spring.core.service.JwtService; 4 | import io.spring.core.user.User; 5 | import java.util.Optional; 6 | import org.junit.jupiter.api.Assertions; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Test; 9 | 10 | public class DefaultJwtServiceTest { 11 | 12 | private JwtService jwtService; 13 | 14 | @BeforeEach 15 | public void setUp() { 16 | jwtService = new DefaultJwtService("123123123123123123123123123123123123123123123123123123123123", 3600); 17 | } 18 | 19 | @Test 20 | public void should_generate_and_parse_token() { 21 | User user = new User("email@email.com", "username", "123", "", ""); 22 | String token = jwtService.toToken(user); 23 | Assertions.assertNotNull(token); 24 | Optional optional = jwtService.getSubFromToken(token); 25 | Assertions.assertTrue(optional.isPresent()); 26 | Assertions.assertEquals(optional.get(), user.getId()); 27 | } 28 | 29 | @Test 30 | public void should_get_null_with_wrong_jwt() { 31 | Optional optional = jwtService.getSubFromToken("123"); 32 | Assertions.assertFalse(optional.isPresent()); 33 | } 34 | 35 | @Test 36 | public void should_get_null_with_expired_jwt() { 37 | String token = 38 | "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhaXNlbnNpeSIsImV4cCI6MTUwMjE2MTIwNH0.SJB-U60WzxLYNomqLo4G3v3LzFxJKuVrIud8D8Lz3-mgpo9pN1i7C8ikU_jQPJGm8HsC1CquGMI-rSuM7j6LDA"; 39 | Assertions.assertFalse(jwtService.getSubFromToken(token).isPresent()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test/java/io/spring/infrastructure/user/MyBatisUserRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package io.spring.infrastructure.user; 2 | 3 | import io.spring.core.user.FollowRelation; 4 | import io.spring.core.user.User; 5 | import io.spring.core.user.UserRepository; 6 | import io.spring.infrastructure.DbTestBase; 7 | import io.spring.infrastructure.repository.MyBatisUserRepository; 8 | import java.util.Optional; 9 | import org.junit.jupiter.api.Assertions; 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.Test; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.context.annotation.Import; 14 | 15 | @Import(MyBatisUserRepository.class) 16 | public class MyBatisUserRepositoryTest extends DbTestBase { 17 | @Autowired private UserRepository userRepository; 18 | private User user; 19 | 20 | @BeforeEach 21 | public void setUp() { 22 | user = new User("aisensiy@163.com", "aisensiy", "123", "", "default"); 23 | } 24 | 25 | @Test 26 | public void should_save_and_fetch_user_success() { 27 | userRepository.save(user); 28 | Optional userOptional = userRepository.findByUsername("aisensiy"); 29 | Assertions.assertEquals(userOptional.get(), user); 30 | Optional userOptional2 = userRepository.findByEmail("aisensiy@163.com"); 31 | Assertions.assertEquals(userOptional2.get(), user); 32 | } 33 | 34 | @Test 35 | public void should_update_user_success() { 36 | String newEmail = "newemail@email.com"; 37 | user.update(newEmail, "", "", "", ""); 38 | userRepository.save(user); 39 | Optional optional = userRepository.findByUsername(user.getUsername()); 40 | Assertions.assertTrue(optional.isPresent()); 41 | Assertions.assertEquals(optional.get().getEmail(), newEmail); 42 | 43 | String newUsername = "newUsername"; 44 | user.update("", newUsername, "", "", ""); 45 | userRepository.save(user); 46 | optional = userRepository.findByEmail(user.getEmail()); 47 | Assertions.assertTrue(optional.isPresent()); 48 | Assertions.assertEquals(optional.get().getUsername(), newUsername); 49 | Assertions.assertEquals(optional.get().getImage(), user.getImage()); 50 | } 51 | 52 | @Test 53 | public void should_create_new_user_follow_success() { 54 | User other = new User("other@example.com", "other", "123", "", ""); 55 | userRepository.save(other); 56 | 57 | FollowRelation followRelation = new FollowRelation(user.getId(), other.getId()); 58 | userRepository.saveRelation(followRelation); 59 | Assertions.assertTrue(userRepository.findRelation(user.getId(), other.getId()).isPresent()); 60 | } 61 | 62 | @Test 63 | public void should_unfollow_user_success() { 64 | User other = new User("other@example.com", "other", "123", "", ""); 65 | userRepository.save(other); 66 | 67 | FollowRelation followRelation = new FollowRelation(user.getId(), other.getId()); 68 | userRepository.saveRelation(followRelation); 69 | 70 | userRepository.removeRelation(followRelation); 71 | Assertions.assertFalse(userRepository.findRelation(user.getId(), other.getId()).isPresent()); 72 | } 73 | } 74 | --------------------------------------------------------------------------------