├── .gitignore ├── kotlin-spring.png ├── mvnw ├── mvnw.cmd ├── pom.xml ├── readme.md └── src ├── main ├── kotlin │ └── io │ │ └── realworld │ │ ├── ApiApplication.kt │ │ ├── client │ │ ├── ProfileClient.kt │ │ ├── TagClient.kt │ │ ├── UserClient.kt │ │ └── response │ │ │ ├── InLogin.kt │ │ │ ├── InRegister.kt │ │ │ ├── OutProfile.kt │ │ │ ├── OutTag.kt │ │ │ └── OutUser.kt │ │ ├── exception │ │ ├── ForbiddenRequestException.kt │ │ ├── InvalidException.kt │ │ ├── InvalidLoginException.kt │ │ ├── NotFoundException.kt │ │ ├── UnauthorizedException.kt │ │ └── UserExistException.kt │ │ ├── jwt │ │ ├── ApiKeySecured.kt │ │ ├── ApiKeySecuredAspect.kt │ │ └── ExposeResponseInterceptor.kt │ │ ├── model │ │ ├── Article.kt │ │ ├── Comment.kt │ │ ├── Tag.kt │ │ ├── User.kt │ │ └── inout │ │ │ ├── Article.kt │ │ │ ├── Comment.kt │ │ │ ├── Login.kt │ │ │ ├── NewArticle.kt │ │ │ ├── NewComment.kt │ │ │ ├── Profile.kt │ │ │ ├── Register.kt │ │ │ ├── UpdateArticle.kt │ │ │ └── UpdateUser.kt │ │ ├── repository │ │ ├── ArticleRepository.kt │ │ ├── CommentRepository.kt │ │ ├── TagRepository.kt │ │ ├── UserRepository.kt │ │ └── specification │ │ │ └── ArticlesSpecifications.kt │ │ ├── service │ │ └── UserService.kt │ │ └── web │ │ ├── ArticleHandler.kt │ │ ├── InvalidRequestHandler.kt │ │ ├── ProfileHandler.kt │ │ ├── TagHandler.kt │ │ └── UserHandler.kt └── resources │ └── application.properties └── test └── kotlin └── io └── realworld └── ApiApplicationTests.kt /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /bower_components 6 | 7 | # IDEs and editors 8 | /.idea 9 | .project 10 | .classpath 11 | *.launch 12 | .settings/ 13 | 14 | target/ 15 | *.class 16 | *.jar 17 | *.iml 18 | 19 | #System Files 20 | .DS_Store 21 | Thumbs.db 22 | -------------------------------------------------------------------------------- /kotlin-spring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/kotlin-spring-realworld-example-app/848c29e3abfe1bd72001cc7d7a3227aebec96e9b/kotlin-spring.png -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Maven2 Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /etc/mavenrc ] ; then 40 | . /etc/mavenrc 41 | fi 42 | 43 | if [ -f "$HOME/.mavenrc" ] ; then 44 | . "$HOME/.mavenrc" 45 | fi 46 | 47 | fi 48 | 49 | # OS specific support. $var _must_ be set to either true or false. 50 | cygwin=false; 51 | darwin=false; 52 | mingw=false 53 | case "`uname`" in 54 | CYGWIN*) cygwin=true ;; 55 | MINGW*) mingw=true;; 56 | Darwin*) darwin=true 57 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 58 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 59 | if [ -z "$JAVA_HOME" ]; then 60 | if [ -x "/usr/libexec/java_home" ]; then 61 | export JAVA_HOME="`/usr/libexec/java_home`" 62 | else 63 | export JAVA_HOME="/Library/Java/Home" 64 | fi 65 | fi 66 | ;; 67 | esac 68 | 69 | if [ -z "$JAVA_HOME" ] ; then 70 | if [ -r /etc/gentoo-release ] ; then 71 | JAVA_HOME=`java-config --jre-home` 72 | fi 73 | fi 74 | 75 | if [ -z "$M2_HOME" ] ; then 76 | ## resolve links - $0 may be a link to maven's home 77 | PRG="$0" 78 | 79 | # need this for relative symlinks 80 | while [ -h "$PRG" ] ; do 81 | ls=`ls -ld "$PRG"` 82 | link=`expr "$ls" : '.*-> \(.*\)$'` 83 | if expr "$link" : '/.*' > /dev/null; then 84 | PRG="$link" 85 | else 86 | PRG="`dirname "$PRG"`/$link" 87 | fi 88 | done 89 | 90 | saveddir=`pwd` 91 | 92 | M2_HOME=`dirname "$PRG"`/.. 93 | 94 | # make it fully qualified 95 | M2_HOME=`cd "$M2_HOME" && pwd` 96 | 97 | cd "$saveddir" 98 | # echo Using m2 at $M2_HOME 99 | fi 100 | 101 | # For Cygwin, ensure paths are in UNIX format before anything is touched 102 | if $cygwin ; then 103 | [ -n "$M2_HOME" ] && 104 | M2_HOME=`cygpath --unix "$M2_HOME"` 105 | [ -n "$JAVA_HOME" ] && 106 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 107 | [ -n "$CLASSPATH" ] && 108 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 109 | fi 110 | 111 | # For Migwn, ensure paths are in UNIX format before anything is touched 112 | if $mingw ; then 113 | [ -n "$M2_HOME" ] && 114 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 115 | [ -n "$JAVA_HOME" ] && 116 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 117 | # TODO classpath? 118 | fi 119 | 120 | if [ -z "$JAVA_HOME" ]; then 121 | javaExecutable="`which javac`" 122 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 123 | # readlink(1) is not available as standard on Solaris 10. 124 | readLink=`which readlink` 125 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 126 | if $darwin ; then 127 | javaHome="`dirname \"$javaExecutable\"`" 128 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 129 | else 130 | javaExecutable="`readlink -f \"$javaExecutable\"`" 131 | fi 132 | javaHome="`dirname \"$javaExecutable\"`" 133 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 134 | JAVA_HOME="$javaHome" 135 | export JAVA_HOME 136 | fi 137 | fi 138 | fi 139 | 140 | if [ -z "$JAVACMD" ] ; then 141 | if [ -n "$JAVA_HOME" ] ; then 142 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 143 | # IBM's JDK on AIX uses strange locations for the executables 144 | JAVACMD="$JAVA_HOME/jre/sh/java" 145 | else 146 | JAVACMD="$JAVA_HOME/bin/java" 147 | fi 148 | else 149 | JAVACMD="`which java`" 150 | fi 151 | fi 152 | 153 | if [ ! -x "$JAVACMD" ] ; then 154 | echo "Error: JAVA_HOME is not defined correctly." >&2 155 | echo " We cannot execute $JAVACMD" >&2 156 | exit 1 157 | fi 158 | 159 | if [ -z "$JAVA_HOME" ] ; then 160 | echo "Warning: JAVA_HOME environment variable is not set." 161 | fi 162 | 163 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 164 | 165 | # traverses directory structure from process work directory to filesystem root 166 | # first directory with .mvn subdirectory is considered project base directory 167 | find_maven_basedir() { 168 | 169 | if [ -z "$1" ] 170 | then 171 | echo "Path not specified to find_maven_basedir" 172 | return 1 173 | fi 174 | 175 | basedir="$1" 176 | wdir="$1" 177 | while [ "$wdir" != '/' ] ; do 178 | if [ -d "$wdir"/.mvn ] ; then 179 | basedir=$wdir 180 | break 181 | fi 182 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 183 | if [ -d "${wdir}" ]; then 184 | wdir=`cd "$wdir/.."; pwd` 185 | fi 186 | # end of workaround 187 | done 188 | echo "${basedir}" 189 | } 190 | 191 | # concatenates all lines of a file 192 | concat_lines() { 193 | if [ -f "$1" ]; then 194 | echo "$(tr -s '\n' ' ' < "$1")" 195 | fi 196 | } 197 | 198 | BASE_DIR=`find_maven_basedir "$(pwd)"` 199 | if [ -z "$BASE_DIR" ]; then 200 | exit 1; 201 | fi 202 | 203 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 204 | echo $MAVEN_PROJECTBASEDIR 205 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 206 | 207 | # For Cygwin, switch paths to Windows format before running java 208 | if $cygwin; then 209 | [ -n "$M2_HOME" ] && 210 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 211 | [ -n "$JAVA_HOME" ] && 212 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 213 | [ -n "$CLASSPATH" ] && 214 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 215 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 216 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 217 | fi 218 | 219 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 220 | 221 | exec "$JAVACMD" \ 222 | $MAVEN_OPTS \ 223 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 224 | "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 225 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 226 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM http://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven2 Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' 39 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 40 | 41 | @REM set %HOME% to equivalent of $HOME 42 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 43 | 44 | @REM Execute a user defined script before this one 45 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 46 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 47 | if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" 48 | if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" 49 | :skipRcPre 50 | 51 | @setlocal 52 | 53 | set ERROR_CODE=0 54 | 55 | @REM To isolate internal variables from possible post scripts, we use another setlocal 56 | @setlocal 57 | 58 | @REM ==== START VALIDATION ==== 59 | if not "%JAVA_HOME%" == "" goto OkJHome 60 | 61 | echo. 62 | echo Error: JAVA_HOME not found in your environment. >&2 63 | echo Please set the JAVA_HOME variable in your environment to match the >&2 64 | echo location of your Java installation. >&2 65 | echo. 66 | goto error 67 | 68 | :OkJHome 69 | if exist "%JAVA_HOME%\bin\java.exe" goto init 70 | 71 | echo. 72 | echo Error: JAVA_HOME is set to an invalid directory. >&2 73 | echo JAVA_HOME = "%JAVA_HOME%" >&2 74 | echo Please set the JAVA_HOME variable in your environment to match the >&2 75 | echo location of your Java installation. >&2 76 | echo. 77 | goto error 78 | 79 | @REM ==== END VALIDATION ==== 80 | 81 | :init 82 | 83 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 84 | @REM Fallback to current working directory if not found. 85 | 86 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 87 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 88 | 89 | set EXEC_DIR=%CD% 90 | set WDIR=%EXEC_DIR% 91 | :findBaseDir 92 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 93 | cd .. 94 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 95 | set WDIR=%CD% 96 | goto findBaseDir 97 | 98 | :baseDirFound 99 | set MAVEN_PROJECTBASEDIR=%WDIR% 100 | cd "%EXEC_DIR%" 101 | goto endDetectBaseDir 102 | 103 | :baseDirNotFound 104 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 105 | cd "%EXEC_DIR%" 106 | 107 | :endDetectBaseDir 108 | 109 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 110 | 111 | @setlocal EnableExtensions EnableDelayedExpansion 112 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 113 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 114 | 115 | :endReadAdditionalConfig 116 | 117 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 118 | 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | %MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 123 | if ERRORLEVEL 1 goto error 124 | goto end 125 | 126 | :error 127 | set ERROR_CODE=1 128 | 129 | :end 130 | @endlocal & set ERROR_CODE=%ERROR_CODE% 131 | 132 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost 133 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 134 | if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" 135 | if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" 136 | :skipRcPost 137 | 138 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 139 | if "%MAVEN_BATCH_PAUSE%" == "on" pause 140 | 141 | if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% 142 | 143 | exit /B %ERROR_CODE% 144 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | io.realworld 7 | api 8 | 0.0.1-SNAPSHOT 9 | jar 10 | 11 | api 12 | Demo for realworld.io with Kotlin and Spring Boot Reactive 13 | 14 | 15 | org.springframework.boot 16 | spring-boot-starter-parent 17 | 3.0.6 18 | 19 | 20 | 21 | 22 | true 23 | UTF-8 24 | UTF-8 25 | 17 26 | 1.8.21 27 | 12.3 28 | 0.11.5 29 | 0.4 30 | 2.15.0 31 | 32 | 33 | 34 | 35 | org.springframework.boot 36 | spring-boot-starter-data-jpa 37 | 38 | 39 | org.mindrot 40 | jbcrypt 41 | ${jbcrypt.version} 42 | 43 | 44 | io.jsonwebtoken 45 | jjwt-api 46 | ${jjwt.version} 47 | 48 | 49 | io.jsonwebtoken 50 | jjwt-impl 51 | ${jjwt.version} 52 | 53 | 54 | io.jsonwebtoken 55 | jjwt-jackson 56 | ${jjwt.version} 57 | 58 | 59 | org.springframework.boot 60 | spring-boot-starter-web 61 | 62 | 63 | org.springframework.boot 64 | spring-boot-starter-validation 65 | 66 | 67 | org.jetbrains.kotlin 68 | kotlin-stdlib-jdk8 69 | ${kotlin.version} 70 | 71 | 72 | org.jetbrains.kotlin 73 | kotlin-reflect 74 | ${kotlin.version} 75 | 76 | 77 | com.fasterxml.jackson.module 78 | jackson-module-kotlin 79 | ${jackson-kotlin.version} 80 | 81 | 82 | org.springframework.boot 83 | spring-boot-starter-test 84 | test 85 | 86 | 87 | com.h2database 88 | h2 89 | 90 | 91 | io.github.openfeign 92 | feign-core 93 | ${feign.version} 94 | 95 | 96 | io.github.openfeign 97 | feign-gson 98 | ${feign.version} 99 | 100 | 101 | com.github.slugify 102 | slugify 103 | 2.5 104 | 105 | 106 | junit 107 | junit 108 | 4.13.1 109 | test 110 | 111 | 112 | 113 | 114 | ${project.basedir}/src/main/kotlin 115 | ${project.basedir}/src/test/kotlin 116 | 117 | 118 | org.springframework.boot 119 | spring-boot-maven-plugin 120 | 121 | 122 | kotlin-maven-plugin 123 | org.jetbrains.kotlin 124 | ${kotlin.version} 125 | 126 | 127 | spring 128 | 129 | 11 130 | 131 | 132 | 133 | compile 134 | compile 135 | 136 | compile 137 | 138 | 139 | 140 | test-compile 141 | test-compile 142 | 143 | test-compile 144 | 145 | 146 | 147 | 148 | 149 | org.jetbrains.kotlin 150 | kotlin-maven-allopen 151 | ${kotlin.version} 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | spring-snapshots 161 | Spring Snapshots 162 | https://repo.spring.io/snapshot 163 | 164 | true 165 | 166 | 167 | 168 | spring-milestones 169 | Spring Milestones 170 | https://repo.spring.io/milestone 171 | 172 | false 173 | 174 | 175 | 176 | jcenter 177 | https://jcenter.bintray.com/ 178 | 179 | 180 | 181 | 182 | 183 | 184 | spring-snapshots 185 | Spring Snapshots 186 | https://repo.spring.io/snapshot 187 | 188 | true 189 | 190 | 191 | 192 | spring-milestones 193 | Spring Milestones 194 | https://repo.spring.io/milestone 195 | 196 | false 197 | 198 | 199 | 200 | 201 | 202 | 203 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App using Kotlin and Spring](kotlin-spring.png) 2 | 3 | > ### Kotlin + Spring 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. 4 | 5 | This codebase was created to demonstrate a fully fledged fullstack application built with Kotlin + Spring including CRUD operations, authentication, routing, pagination, and more. 6 | 7 | We've gone to great lengths to adhere to the Kotlin + Spring community styleguides & best practices. 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 | # How it works 12 | 13 | The application uses Spring (Web, Data, AOP, Cache) and the Kotlin language. 14 | 15 | + client/ 16 | Some feign clients for testing 17 | + exception/ 18 | Exceptions by the application 19 | + jwt/ 20 | AOP advice that check for authentication using a defined @ApiKeySecured operation 21 | + model/ 22 | + inout/ 23 | Object for REST in/out operations 24 | JPA models 25 | + repository/ 26 | + specification/ 27 | Some specifications for JPA 28 | Spring repositories 29 | + service/ 30 | Spring services 31 | + web/ 32 | Spring controllers 33 | - ApiApplication.kt <- The main class 34 | 35 | # Security 36 | 37 | Instead of using Spring Security to implement an authenticator using JWT, I created a simple AOP advice that checks 38 | the `Authorization` header and put the user to be found in a `ThreadLocal` (see `UserService`). 39 | 40 | The secret key and jwt issuer are stored in `application.properties`. 41 | 42 | # Database 43 | 44 | It uses a H2 in memory database (for now), can be changed easily in the `application.properties` for any other database. 45 | You'll need to add the correct maven dependency for the needed `Driver` in `pom.xml`. 46 | 47 | # Getting started 48 | 49 | You need Java and maven installed. 50 | 51 | mvn spring-boot:run 52 | open http://localhost:8080 53 | 54 | # Help 55 | 56 | Please fork and PR to improve the code. 57 | 58 | # Kotlin 59 | 60 | I've been using Kotlin for some time, but I'm no expert, so feel free to contribute and modify the code to make it more idiomatic! 61 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/ApiApplication.kt: -------------------------------------------------------------------------------- 1 | package io.realworld 2 | 3 | import io.realworld.jwt.ExposeResponseInterceptor 4 | import org.springframework.boot.SpringApplication 5 | import org.springframework.boot.autoconfigure.SpringBootApplication 6 | import org.springframework.cache.annotation.EnableCaching 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean 10 | import org.springframework.validation.beanvalidation.MethodValidationPostProcessor 11 | import org.springframework.web.servlet.config.annotation.CorsRegistry 12 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry 13 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 14 | 15 | 16 | @Configuration 17 | @EnableCaching 18 | @SpringBootApplication 19 | class ApiApplication : WebMvcConfigurer { 20 | 21 | override fun addInterceptors(registry: InterceptorRegistry) { 22 | registry.addInterceptor(exposeResponseInterceptor()) 23 | } 24 | 25 | override fun addCorsMappings(registry: CorsRegistry) { 26 | registry.addMapping("/api/**") 27 | .allowedOrigins("*") 28 | .allowedMethods("*") 29 | .allowedHeaders("*") 30 | .allowCredentials(false) 31 | .maxAge(3600) 32 | super.addCorsMappings(registry) 33 | } 34 | 35 | @Bean 36 | fun exposeResponseInterceptor() = ExposeResponseInterceptor() 37 | 38 | @Bean 39 | fun methodValidationPostProcessor(): MethodValidationPostProcessor { 40 | val mv = MethodValidationPostProcessor() 41 | mv.setValidator(validator()) 42 | return mv 43 | } 44 | 45 | @Bean 46 | fun validator() = LocalValidatorFactoryBean() 47 | } 48 | 49 | fun main(args: Array) { 50 | SpringApplication.run(ApiApplication::class.java, *args) 51 | } 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/client/ProfileClient.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.client 2 | 3 | import feign.Headers 4 | import feign.Param 5 | import feign.RequestLine 6 | import io.realworld.client.response.OutProfile 7 | 8 | @Headers("Content-Type: application/json", 9 | "Authorization: Token {token}") 10 | interface ProfileClient { 11 | @RequestLine("GET /api/profiles/{username}") 12 | fun profile(@Param("token") token: String, @Param("username") username: String): OutProfile 13 | 14 | @RequestLine("POST /api/profiles/{username}/follow") 15 | fun follow(@Param("token") token: String, @Param("username") username: String): OutProfile 16 | 17 | @RequestLine("DELETE /api/profiles/{username}/follow") 18 | fun unfollow(@Param("token") token: String, @Param("username") username: String): OutProfile 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/client/TagClient.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.client 2 | 3 | import feign.Headers 4 | import feign.RequestLine 5 | import io.realworld.client.response.OutTag 6 | 7 | interface TagClient { 8 | @RequestLine("GET /api/tags") 9 | @Headers("Content-Type: application/json") 10 | fun tags(): OutTag 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/client/UserClient.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.client 2 | 3 | import feign.Headers 4 | import feign.RequestLine 5 | import io.realworld.client.response.InLogin 6 | import io.realworld.client.response.InRegister 7 | import io.realworld.client.response.OutUser 8 | 9 | @Headers("Content-Type: application/json") 10 | interface UserClient { 11 | @RequestLine("POST /api/users/login") 12 | fun login(login: InLogin): OutUser 13 | 14 | @RequestLine("POST /api/users") 15 | fun register(register: InRegister): OutUser 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/client/response/InLogin.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.client.response 2 | 3 | import io.realworld.model.inout.Login 4 | 5 | data class InLogin(val user: Login) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/client/response/InRegister.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.client.response 2 | 3 | import io.realworld.model.inout.Register 4 | 5 | data class InRegister(val user: Register) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/client/response/OutProfile.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.client.response 2 | 3 | import io.realworld.model.inout.Profile 4 | 5 | data class OutProfile(var profile: Profile? = null) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/client/response/OutTag.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.client.response 2 | 3 | data class OutTag(var tags: List = listOf()) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/client/response/OutUser.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.client.response 2 | 3 | import io.realworld.model.User 4 | 5 | data class OutUser(var user: User = User()) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/exception/ForbiddenRequestException.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.exception 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(HttpStatus.FORBIDDEN) 7 | class ForbiddenRequestException : RuntimeException() -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/exception/InvalidException.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.exception 2 | 3 | import org.springframework.validation.Errors 4 | 5 | data class InvalidException(val errors: Errors?) : RuntimeException() 6 | 7 | object InvalidRequest { 8 | fun check(errors: Errors) { 9 | if (errors.hasFieldErrors()) 10 | throw InvalidException(errors) 11 | } 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/exception/InvalidLoginException.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.exception 2 | 3 | class InvalidLoginException(val field: String, val error: String) : RuntimeException() 4 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/exception/NotFoundException.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.exception 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(HttpStatus.NOT_FOUND) 7 | class NotFoundException : RuntimeException() -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/exception/UnauthorizedException.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.exception 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(HttpStatus.UNAUTHORIZED) 7 | class UnauthorizedException : RuntimeException() -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/exception/UserExistException.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.exception 2 | 3 | /** 4 | * Created by alex on 30/04/2017. 5 | */ 6 | class UserExistException : RuntimeException() -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/jwt/ApiKeySecured.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.jwt 2 | 3 | import java.lang.annotation.Inherited 4 | 5 | @MustBeDocumented 6 | @Inherited 7 | @Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) 8 | @Retention(AnnotationRetention.RUNTIME) 9 | annotation class ApiKeySecured(val mandatory: Boolean = true) 10 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/jwt/ApiKeySecuredAspect.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.jwt 2 | 3 | import io.realworld.model.User 4 | import io.realworld.service.UserService 5 | import org.aspectj.lang.ProceedingJoinPoint 6 | import org.aspectj.lang.annotation.Around 7 | import org.aspectj.lang.annotation.Aspect 8 | import org.aspectj.lang.annotation.Pointcut 9 | import org.aspectj.lang.reflect.MethodSignature 10 | import org.slf4j.LoggerFactory 11 | import org.springframework.beans.factory.annotation.Autowired 12 | import org.springframework.stereotype.Component 13 | import org.springframework.util.StringUtils 14 | import org.springframework.web.bind.annotation.ResponseStatus 15 | import jakarta.servlet.http.HttpServletRequest 16 | import jakarta.servlet.http.HttpServletResponse 17 | 18 | /** 19 | * Aspect whose goal is to check automatically that methods 20 | * having a @ApiKeySecured annotation are correctly accessed 21 | * by users having a valid API Key (JWT). 22 | 23 | * A check against the user service is done to find the user 24 | * having the api key passed as request header/parameter. 25 | 26 | * If the API Key is valid the annotated method is executed, 27 | * otherwise the response is set with an UNAUTHORIZED status and the annotated 28 | * method is not executed. 29 | */ 30 | @Aspect 31 | @Component 32 | class ApiKeySecuredAspect(@Autowired val userService: UserService) { 33 | 34 | @Autowired 35 | lateinit var request: HttpServletRequest 36 | 37 | @Pointcut(value = "execution(@io.realworld.jwt.ApiKeySecured * *.*(..))") 38 | fun securedApiPointcut() { 39 | } 40 | 41 | @Around("securedApiPointcut()") 42 | @Throws(Throwable::class) 43 | fun aroundSecuredApiPointcut(joinPoint: ProceedingJoinPoint): Any? { 44 | if (request.method == "OPTIONS") 45 | return joinPoint.proceed() 46 | 47 | // see the ExposeResponseInterceptor class. 48 | val response = request.getAttribute(ExposeResponseInterceptor.KEY) as HttpServletResponse 49 | 50 | // check for needed roles 51 | val signature = joinPoint.signature as MethodSignature 52 | val method = signature.method 53 | val anno = method.getAnnotation(ApiKeySecured::class.java) 54 | 55 | val apiKey = request.getHeader("Authorization")?.replace("Token ", "") 56 | 57 | if (!StringUtils.hasText(apiKey) && anno.mandatory) { 58 | LOG.info("No Authorization part of the request header/parameters, returning {}.", HttpServletResponse.SC_UNAUTHORIZED) 59 | 60 | issueError(response) 61 | return null 62 | } 63 | 64 | // find the user associated to the given api key. 65 | var user = userService.findByToken(apiKey ?: "") 66 | LOG.info("user by token: ${user?.email}") 67 | if (user == null && anno.mandatory) { 68 | LOG.info("No user with Authorization: {}, returning {}.", apiKey, HttpServletResponse.SC_UNAUTHORIZED) 69 | 70 | issueError(response) 71 | return null 72 | } else { 73 | // validate JWT 74 | try { 75 | LOG.info("Validating JWT") 76 | if (!userService.validToken(apiKey ?: "", user ?: User())) { 77 | LOG.info("JWT invalid") 78 | if (!anno.mandatory && user == null) { 79 | LOG.info("No problem because not mandatory") 80 | user = User() 81 | } else { // error 82 | LOG.info("Authorization: {} is an invalid JWT.", apiKey) 83 | 84 | issueError(response) 85 | return null 86 | } 87 | } 88 | } catch (e: Exception) { 89 | if (anno.mandatory) { 90 | issueError(response) 91 | return null 92 | } else 93 | user = User() 94 | } 95 | } 96 | 97 | LOG.info("User is: ${user?.email}") 98 | userService.setCurrentUser(user ?: User()) 99 | 100 | LOG.info("OK accessing resource, proceeding.") 101 | 102 | // execute 103 | try { 104 | val result = joinPoint.proceed() 105 | // remove user from thread local 106 | userService.clearCurrentUser() 107 | 108 | LOG.info("DONE accessing resource.") 109 | 110 | return result 111 | } catch (e: Throwable) { 112 | // check for custom exception 113 | val rs = e.javaClass.getAnnotation(ResponseStatus::class.java) 114 | if (rs != null) { 115 | LOG.error("ERROR accessing resource, reason: '{}', status: {}.", 116 | if (!StringUtils.hasText(e.message)) rs.reason else e.message, 117 | rs.value) 118 | } else { 119 | LOG.error("ERROR accessing resource") 120 | } 121 | throw e 122 | } 123 | 124 | } 125 | 126 | private fun issueError(response: HttpServletResponse) { 127 | setStatus(response, HttpServletResponse.SC_UNAUTHORIZED) 128 | response.setHeader("Authorization", "You shall not pass without providing a valid API Key") 129 | response.writer.write("{\"errors\": {\"Authorization\": [\"You must provide a valid Authorization header.\"]}}") 130 | response.writer.flush() 131 | } 132 | 133 | fun setStatus(response: HttpServletResponse?, sc: Int) { 134 | if (response != null) 135 | response.status = sc 136 | } 137 | 138 | companion object { 139 | private val LOG = LoggerFactory.getLogger(ApiKeySecuredAspect::class.java) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/jwt/ExposeResponseInterceptor.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.jwt 2 | 3 | import jakarta.servlet.ServletException 4 | import jakarta.servlet.http.HttpServletRequest 5 | import jakarta.servlet.http.HttpServletResponse 6 | import org.springframework.web.servlet.HandlerInterceptor 7 | 8 | /** 9 | * This is an interceptor that saves a reference from the response into the request. 10 | * Spring can autowire HttpServletRequest but not HttpServletResponses. 11 | 12 | * We've got 3 solutions there: 13 | * 1) We inject HttpServletResponse as a parameter of every @Controller methods. 14 | * 2) We create a scoped-proxy factory bean that instantiate a filter which holds a threadlocal containing the response. 15 | * 3) We create an interceptor and save a reference to the response, inside the request which can be autowired by spring. 16 | 17 | * This is the solution number 3. It's kind of hacky, but it's being accessed only from the ApiKeySecuredAspect class. 18 | 19 | * Trust me, I'm an engineer. 20 | */ 21 | class ExposeResponseInterceptor : HandlerInterceptor { 22 | @Throws(ServletException::class) 23 | override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { 24 | request.setAttribute(KEY, response) 25 | return true 26 | } 27 | 28 | companion object { 29 | const val KEY = "spring.internal.httpServletResponse" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/Article.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model 2 | 3 | import java.time.OffsetDateTime 4 | import jakarta.persistence.* 5 | 6 | @Entity 7 | data class Article(var slug: String = "", 8 | var title: String = "", 9 | var description: String = "", 10 | var body: String = "", 11 | @ManyToMany 12 | val tagList: MutableList = mutableListOf(), 13 | var createdAt: OffsetDateTime = OffsetDateTime.now(), 14 | var updatedAt: OffsetDateTime = OffsetDateTime.now(), 15 | @ManyToMany 16 | var favorited: MutableList = mutableListOf(), 17 | @ManyToOne 18 | var author: User = User(), 19 | @Id @GeneratedValue(strategy = GenerationType.AUTO) 20 | var id: Long = 0) { 21 | fun favoritesCount() = favorited.size 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/Comment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model 2 | 3 | import java.time.OffsetDateTime 4 | import jakarta.persistence.* 5 | 6 | @Entity 7 | data class Comment(var createdAt: OffsetDateTime = OffsetDateTime.now(), 8 | var updatedAt: OffsetDateTime = OffsetDateTime.now(), 9 | var body: String = "", 10 | @ManyToOne 11 | var article: Article = Article(), 12 | @ManyToOne 13 | var author: User = User(), 14 | @Id @GeneratedValue(strategy = GenerationType.AUTO) 15 | var id: Long = 0) -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/Tag.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore 4 | import jakarta.persistence.Entity 5 | import jakarta.persistence.GeneratedValue 6 | import jakarta.persistence.GenerationType 7 | import jakarta.persistence.Id 8 | 9 | @Entity 10 | data class Tag(val name: String = "", 11 | @Id @GeneratedValue(strategy = GenerationType.AUTO) 12 | @JsonIgnore 13 | var id: Long = 0) -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/User.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore 4 | import com.fasterxml.jackson.annotation.JsonRootName 5 | import jakarta.persistence.* 6 | 7 | @Entity 8 | @JsonRootName("user") 9 | @Table(name = "users") 10 | data class User(var email: String = "", 11 | @JsonIgnore 12 | var password: String = "", 13 | var token: String = "", 14 | var username: String = "", 15 | var bio: String = "", 16 | var image: String = "", 17 | @ManyToMany 18 | @JsonIgnore 19 | var follows: MutableList = mutableListOf(), 20 | @Id @GeneratedValue(strategy = GenerationType.AUTO) 21 | var id: Long = 0) { 22 | override fun toString(): String = "User($email, $username)" 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/inout/Article.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model.inout 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName 4 | import io.realworld.model.User 5 | import java.time.OffsetDateTime 6 | import java.time.ZoneId 7 | import java.time.format.DateTimeFormatter 8 | 9 | @JsonRootName("article") 10 | data class Article(var title: String? = null, 11 | var description: String? = null, 12 | var body: String? = null, 13 | var tagList: List = listOf(), 14 | var slug: String = "", 15 | var createdAt: String = "", 16 | var updatedAt: String = "", 17 | var author: Profile = Profile(username = "", bio = "", image = "", following = false), 18 | var favorited: Boolean = false, 19 | var favoritesCount: Int = 0) { 20 | companion object { 21 | private fun dateFormat(date: OffsetDateTime): String { 22 | return date.toZonedDateTime().withZoneSameInstant(ZoneId.of("Z")).format(DateTimeFormatter.ISO_ZONED_DATE_TIME) 23 | } 24 | 25 | fun fromModel(model: io.realworld.model.Article, currentUser: User): Article { 26 | return Article( 27 | slug = model.slug, 28 | title = model.title, 29 | description = model.description, 30 | body = model.body, 31 | tagList = model.tagList.map { it.name }, 32 | createdAt = dateFormat(model.createdAt), 33 | updatedAt = dateFormat(model.updatedAt), 34 | author = Profile.fromUser(model.author, currentUser), 35 | favorited = model.favorited.contains(currentUser), 36 | favoritesCount = model.favorited.size) 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/inout/Comment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model.inout 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName 4 | import io.realworld.model.User 5 | import java.time.OffsetDateTime 6 | import java.time.ZoneId 7 | import java.time.format.DateTimeFormatter 8 | 9 | @JsonRootName("comment") 10 | data class Comment(val createdAt: String, 11 | val updatedAt: String, 12 | val body: String, 13 | val author: Profile, 14 | val id: Long) { 15 | companion object { 16 | private fun dateFormat(date: OffsetDateTime): String { 17 | return date.toZonedDateTime().withZoneSameInstant(ZoneId.of("Z")).format(DateTimeFormatter.ISO_ZONED_DATE_TIME) 18 | } 19 | 20 | fun fromModel(model: io.realworld.model.Comment, currentUser: User): Comment { 21 | return Comment( 22 | id = model.id, 23 | body = model.body, 24 | createdAt = dateFormat(model.createdAt), 25 | updatedAt = dateFormat(model.updatedAt), 26 | author = Profile.fromUser(model.author, currentUser) 27 | ) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/inout/Login.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model.inout 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName 4 | import jakarta.validation.constraints.NotNull 5 | import jakarta.validation.constraints.Pattern 6 | import jakarta.validation.constraints.Size 7 | 8 | @JsonRootName("user") 9 | class Login( 10 | @NotNull(message = "can't be missing") 11 | @Size(min = 1, message = "can't be empty") 12 | @Pattern( 13 | regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$", 14 | message = "must be a valid email" 15 | ) var email: String?, @NotNull(message = "can't be missing") 16 | @Size(min = 1, message = "can't be empty") var password: String? 17 | ) 18 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/inout/NewArticle.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model.inout 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName 4 | import jakarta.validation.constraints.NotNull 5 | import jakarta.validation.constraints.Size 6 | 7 | @JsonRootName("article") 8 | class NewArticle { 9 | @NotNull(message = "can't be missing") 10 | @Size(min = 1, message = "can't be empty") 11 | var title: String? = "" 12 | 13 | @NotNull(message = "can't be missing") 14 | @Size(min = 1, message = "can't be empty") 15 | var description: String? = "" 16 | 17 | @NotNull(message = "can't be missing") 18 | @Size(min = 1, message = "can't be empty") 19 | var body: String? = "" 20 | 21 | var tagList: List = listOf() 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/inout/NewComment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model.inout 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName 4 | import jakarta.validation.constraints.NotNull 5 | import jakarta.validation.constraints.Size 6 | 7 | @JsonRootName("comment") 8 | class NewComment { 9 | @NotNull(message = "can't be missing") 10 | @Size(min = 1, message = "can't be empty") 11 | var body: String? = "" 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/inout/Profile.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model.inout 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName 4 | import io.realworld.model.User 5 | 6 | @JsonRootName("profile") 7 | data class Profile(var username: String, 8 | var bio: String?, 9 | var image: String?, 10 | var following: Boolean) { 11 | companion object { 12 | fun fromUser(user: User, currentUser: User): Profile { 13 | return Profile(username = user.username, bio = user.bio, image = user.image, 14 | following = currentUser.follows.contains(user)) 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/inout/Register.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model.inout 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName 4 | import jakarta.validation.constraints.NotNull 5 | import jakarta.validation.constraints.Pattern 6 | import jakarta.validation.constraints.Size 7 | 8 | @JsonRootName("user") 9 | class Register( 10 | @NotNull(message = "can't be missing") 11 | @Size(min = 1, message = "can't be empty") 12 | @Pattern(regexp = "^\\w+$", message = "must be alphanumeric") var username: String?, 13 | @NotNull(message = "can't be missing") 14 | @Size(min = 1, message = "can't be empty") 15 | @Pattern( 16 | regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$", 17 | message = "must be a valid email" 18 | ) var email: String?, 19 | @NotNull(message = "can't be missing") 20 | @Size(min = 1, message = "can't be empty") var password: String? 21 | ) -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/inout/UpdateArticle.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model.inout 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName 4 | 5 | @JsonRootName("article") 6 | data class UpdateArticle(var title: String? = null, 7 | var description: String? = null, 8 | var body: String? = null, 9 | var tagList: List? = null) -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/model/inout/UpdateUser.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.model.inout 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName 4 | import jakarta.validation.constraints.Pattern 5 | import jakarta.validation.constraints.Size 6 | 7 | @JsonRootName("user") 8 | class UpdateUser { 9 | @Size(min = 1, message = "can't be empty") 10 | @Pattern(regexp="^\\w+$", message = "must be alphanumeric") 11 | var username: String? = null 12 | 13 | @Size(min = 1, message = "can't be empty") 14 | @Pattern(regexp="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$", message="must be a valid email") 15 | var email: String? = null 16 | 17 | var password: String? = null 18 | var image: String? = "" 19 | var bio: String? = "" 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/repository/ArticleRepository.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.repository 2 | 3 | import io.realworld.model.Article 4 | import org.springframework.data.domain.Pageable 5 | import org.springframework.data.jpa.repository.JpaSpecificationExecutor 6 | import org.springframework.data.repository.CrudRepository 7 | import org.springframework.data.repository.PagingAndSortingRepository 8 | import org.springframework.stereotype.Repository 9 | 10 | @Repository 11 | interface ArticleRepository : CrudRepository, PagingAndSortingRepository, JpaSpecificationExecutor
{ 12 | fun existsBySlug(slug: String): Boolean 13 | fun findBySlug(slug: String): Article? 14 | fun findByAuthorIdInOrderByCreatedAtDesc(ids: List, pageable: Pageable): List
15 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/repository/CommentRepository.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.repository 2 | 3 | import io.realworld.model.Article 4 | import io.realworld.model.Comment 5 | import org.springframework.data.repository.CrudRepository 6 | import org.springframework.stereotype.Repository 7 | 8 | @Repository 9 | interface CommentRepository : CrudRepository { 10 | fun findByArticle(article: Article): List 11 | fun findByArticleOrderByCreatedAtDesc(article: Article): List 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/repository/TagRepository.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.repository 2 | 3 | import io.realworld.model.Tag 4 | import org.springframework.data.repository.CrudRepository 5 | import org.springframework.stereotype.Repository 6 | 7 | @Repository 8 | interface TagRepository : CrudRepository { 9 | fun findByName(name: String): Tag? 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/repository/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.repository 2 | 3 | import io.realworld.model.User 4 | import org.springframework.data.repository.CrudRepository 5 | import org.springframework.stereotype.Repository 6 | 7 | @Repository 8 | interface UserRepository : CrudRepository { 9 | fun existsByEmail(email: String): Boolean 10 | fun existsByUsername(username: String): Boolean 11 | fun findByEmail(email: String): User? 12 | fun findByToken(token: String): User? 13 | fun findByEmailAndPassword(email: String, password: String): User? 14 | fun findByUsername(username: String): User? 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/repository/specification/ArticlesSpecifications.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.repository.specification 2 | 3 | import io.realworld.model.Article 4 | import io.realworld.model.Tag 5 | import io.realworld.model.User 6 | import org.springframework.data.jpa.domain.Specification 7 | import jakarta.persistence.criteria.Predicate 8 | 9 | object ArticlesSpecifications { 10 | fun lastArticles(tag: Tag?, author: User?, fav: User?): Specification
{ 11 | return Specification { root, _, cb -> 12 | val predicates = mutableListOf() 13 | 14 | tag?.let { 15 | val tagList = root.get>("tagList") 16 | predicates.add(cb.isMember(tag, tagList)) 17 | } 18 | 19 | author?.let { 20 | val user = root.get("author") 21 | predicates.add(cb.equal(user, author)) 22 | } 23 | 24 | fav?.let { 25 | val favorited = root.get>("favorited") 26 | predicates.add(cb.isMember(fav, favorited)) 27 | } 28 | 29 | cb.and(*predicates.toTypedArray()) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/service/UserService.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.service 2 | 3 | import io.jsonwebtoken.Jwts 4 | import io.jsonwebtoken.SignatureAlgorithm 5 | import io.realworld.exception.InvalidLoginException 6 | import io.realworld.model.User 7 | import io.realworld.model.inout.Login 8 | import io.realworld.repository.UserRepository 9 | import org.mindrot.jbcrypt.BCrypt 10 | import org.springframework.beans.factory.annotation.Value 11 | import org.springframework.stereotype.Service 12 | import java.util.* 13 | 14 | @Service 15 | class UserService(val userRepository: UserRepository, 16 | @Value("\${jwt.secret}") val jwtSecret: String, 17 | @Value("\${jwt.issuer}") val jwtIssuer: String) { 18 | 19 | val currentUser = ThreadLocal() 20 | 21 | fun findByToken(token: String) = userRepository.findByToken(token) 22 | 23 | fun clearCurrentUser() = currentUser.remove() 24 | 25 | fun setCurrentUser(user: User): User { 26 | currentUser.set(user) 27 | return user 28 | } 29 | 30 | fun currentUser(): User = currentUser.get() 31 | 32 | fun newToken(user: User): String { 33 | return Jwts.builder() 34 | .setIssuedAt(Date()) 35 | .setSubject(user.email) 36 | .setIssuer(jwtIssuer) 37 | .setExpiration(Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000)) // 10 days 38 | .signWith(SignatureAlgorithm.HS256, jwtSecret).compact() 39 | } 40 | 41 | fun validToken(token: String, user: User): Boolean { 42 | val claims = Jwts.parser().setSigningKey(jwtSecret) 43 | .parseClaimsJws(token).body 44 | return claims.subject == user.email && claims.issuer == jwtIssuer 45 | && Date().before(claims.expiration) 46 | } 47 | 48 | fun updateToken(user: User): User { 49 | user.token = newToken(user) 50 | return userRepository.save(user) 51 | } 52 | 53 | fun login(login: Login): User? { 54 | userRepository.findByEmail(login.email!!)?.let { 55 | if (BCrypt.checkpw(login.password!!, it.password)) { 56 | return updateToken(it) 57 | } 58 | throw InvalidLoginException("password", "invalid password") 59 | } 60 | throw InvalidLoginException("email", "unknown email") 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/web/ArticleHandler.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.web 2 | 3 | import com.github.slugify.Slugify 4 | import io.realworld.exception.ForbiddenRequestException 5 | import io.realworld.exception.InvalidRequest 6 | import io.realworld.exception.NotFoundException 7 | import io.realworld.jwt.ApiKeySecured 8 | import io.realworld.model.Article 9 | import io.realworld.model.Comment 10 | import io.realworld.model.Tag 11 | import io.realworld.model.User 12 | import io.realworld.model.inout.NewArticle 13 | import io.realworld.model.inout.NewComment 14 | import io.realworld.model.inout.UpdateArticle 15 | import io.realworld.repository.ArticleRepository 16 | import io.realworld.repository.CommentRepository 17 | import io.realworld.repository.TagRepository 18 | import io.realworld.repository.UserRepository 19 | import io.realworld.repository.specification.ArticlesSpecifications 20 | import io.realworld.service.UserService 21 | import org.springframework.data.domain.PageRequest 22 | import org.springframework.data.domain.Sort 23 | import org.springframework.http.HttpStatus 24 | import org.springframework.validation.Errors 25 | import org.springframework.validation.FieldError 26 | import org.springframework.web.bind.annotation.* 27 | import java.time.OffsetDateTime 28 | import java.util.* 29 | import jakarta.validation.Valid 30 | import io.realworld.model.inout.Article as ArticleIO 31 | import io.realworld.model.inout.Comment as CommentOut 32 | 33 | @RestController 34 | class ArticleHandler(val repository: ArticleRepository, 35 | val userService: UserService, 36 | val userRepository: UserRepository, 37 | val commentRepository: CommentRepository, 38 | val tagRepository: TagRepository) { 39 | 40 | @ApiKeySecured(mandatory = false) 41 | @GetMapping("/api/articles") 42 | fun articles(@RequestParam(defaultValue = "20") limit: Int, 43 | @RequestParam(defaultValue = "0") offset: Int, 44 | @RequestParam(defaultValue = "") tag: String, 45 | @RequestParam(defaultValue = "") author: String, 46 | @RequestParam(defaultValue = "") favorited: String): Any { 47 | val p = PageRequest.of(offset, limit, Sort.Direction.DESC, "createdAt") 48 | 49 | val articles = repository.findAll(ArticlesSpecifications.lastArticles( 50 | if (tag != "") tagRepository.findByName(tag) else null, 51 | if (author != "") userRepository.findByUsername(author) else null, 52 | if (favorited != "") userRepository.findByUsername(favorited) else null 53 | ), p).toList() 54 | 55 | return articlesView(articles, userService.currentUser()) 56 | } 57 | 58 | @ApiKeySecured 59 | @GetMapping("/api/articles/feed") 60 | fun feed(@RequestParam(defaultValue = "20") limit: Int, 61 | @RequestParam(defaultValue = "0") offset: Int): Any { 62 | val currentUser = userService.currentUser() 63 | val articles = repository.findByAuthorIdInOrderByCreatedAtDesc(currentUser.follows.map { it.id }, 64 | PageRequest.of(offset, limit)) 65 | return articlesView(articles, currentUser) 66 | } 67 | 68 | @ApiKeySecured(mandatory = false) 69 | @GetMapping("/api/articles/{slug}") 70 | fun article(@PathVariable slug: String): Any { 71 | repository.findBySlug(slug)?.let { 72 | return articleView(it, userService.currentUser()) 73 | } 74 | throw NotFoundException() 75 | } 76 | 77 | @ApiKeySecured 78 | @PostMapping("/api/articles") 79 | fun newArticle(@Valid @RequestBody newArticle: NewArticle, errors: Errors): Any { 80 | InvalidRequest.check(errors) 81 | 82 | var slug = Slugify().slugify(newArticle.title!!) 83 | 84 | if (repository.existsBySlug(slug)) { 85 | slug += "-" + UUID.randomUUID().toString().substring(0, 8) 86 | } 87 | 88 | val currentUser = userService.currentUser() 89 | 90 | // search for tags 91 | val tagList = newArticle.tagList.map { 92 | tagRepository.findByName(it) ?: tagRepository.save(Tag(name = it)) 93 | } 94 | 95 | val article = Article(slug = slug, 96 | author = currentUser, title = newArticle.title!!, description = newArticle.description!!, 97 | body = newArticle.body!!, tagList = tagList.toMutableList()) 98 | 99 | return articleView(repository.save(article), currentUser) 100 | } 101 | 102 | @ApiKeySecured 103 | @PutMapping("/api/articles/{slug}") 104 | fun updateArticle(@PathVariable slug: String, @RequestBody article: UpdateArticle): Any { 105 | repository.findBySlug(slug)?.let { it -> 106 | val currentUser = userService.currentUser() 107 | if (it.author.id != currentUser.id) 108 | throw ForbiddenRequestException() 109 | 110 | // check for errors 111 | val errors = org.springframework.validation.BindException(this, "") 112 | if (article.title == "") 113 | errors.addError(FieldError("", "title", "can't be empty")) 114 | if (article.description == "") 115 | errors.addError(FieldError("", "description", "can't be empty")) 116 | if (article.body == "") 117 | errors.addError(FieldError("", "body", "can't be empty")) 118 | InvalidRequest.check(errors) 119 | 120 | var articleSlug: String = it.slug 121 | article.title?.let { newTitle -> 122 | if (newTitle != it.title) { 123 | // we don't want conflicting slugs 124 | articleSlug = Slugify().slugify(article.title!!) 125 | if (repository.existsBySlug(articleSlug)) { 126 | articleSlug += "-" + UUID.randomUUID().toString().substring(0, 8) 127 | } 128 | } 129 | } 130 | 131 | // search for tags 132 | val tagList = article.tagList?.map { 133 | tagRepository.findByName(it) ?: tagRepository.save(Tag(name = it)) 134 | } 135 | 136 | val updated = it.copy(title = article.title ?: it.title, 137 | description = article.description ?: it.description, 138 | body = article.body ?: it.body, 139 | slug = articleSlug, 140 | updatedAt = OffsetDateTime.now(), 141 | tagList = if (tagList == null || tagList.isEmpty()) it.tagList 142 | else tagList.toMutableList()) 143 | 144 | return articleView(repository.save(updated), currentUser) 145 | } 146 | 147 | throw NotFoundException() 148 | } 149 | 150 | @ApiKeySecured 151 | @ResponseStatus(HttpStatus.OK) 152 | @DeleteMapping("/api/articles/{slug}") 153 | fun deleteArticle(@PathVariable slug: String) { 154 | repository.findBySlug(slug)?.let { 155 | if (it.author.id != userService.currentUser().id) 156 | throw ForbiddenRequestException() 157 | 158 | commentRepository.deleteAll(commentRepository.findByArticle(it)) 159 | return repository.delete(it) 160 | } 161 | throw NotFoundException() 162 | } 163 | 164 | @ApiKeySecured(mandatory = false) 165 | @GetMapping("/api/articles/{slug}/comments") 166 | fun articleComments(@PathVariable slug: String): Any { 167 | repository.findBySlug(slug)?.let { 168 | val currentUser = userService.currentUser() 169 | return commentsView(commentRepository.findByArticleOrderByCreatedAtDesc(it), currentUser) 170 | } 171 | throw NotFoundException() 172 | } 173 | 174 | @ApiKeySecured 175 | @PostMapping("/api/articles/{slug}/comments") 176 | fun addComment(@PathVariable slug: String, @Valid @RequestBody comment: NewComment, errors: Errors): Any { 177 | InvalidRequest.check(errors) 178 | 179 | repository.findBySlug(slug)?.let { 180 | val currentUser = userService.currentUser() 181 | val newComment = Comment(body = comment.body!!, article = it, author = currentUser) 182 | return commentView(commentRepository.save(newComment), currentUser) 183 | } 184 | throw NotFoundException() 185 | } 186 | 187 | @ApiKeySecured 188 | @ResponseStatus(HttpStatus.OK) 189 | @DeleteMapping("/api/articles/{slug}/comments/{id}") 190 | fun deleteComment(@PathVariable slug: String, @PathVariable id: Long) { 191 | repository.findBySlug(slug)?.let { 192 | val currentUser = userService.currentUser() 193 | val comment = commentRepository.findById(id).orElseThrow { NotFoundException() } 194 | if (comment.article.id != it.id) 195 | throw ForbiddenRequestException() 196 | if (comment.author.id != currentUser.id) 197 | throw ForbiddenRequestException() 198 | 199 | return commentRepository.delete(comment) 200 | } 201 | throw NotFoundException() 202 | } 203 | 204 | @ApiKeySecured 205 | @PostMapping("/api/articles/{slug}/favorite") 206 | fun favoriteArticle(@PathVariable slug: String): Any { 207 | repository.findBySlug(slug)?.let { 208 | val currentUser = userService.currentUser() 209 | if (!it.favorited.contains(currentUser)) { 210 | it.favorited.add(currentUser) 211 | return articleView(repository.save(it), currentUser) 212 | } 213 | return articleView(it, currentUser) 214 | } 215 | throw NotFoundException() 216 | } 217 | 218 | @ApiKeySecured 219 | @DeleteMapping("/api/articles/{slug}/favorite") 220 | fun unfavoriteArticle(@PathVariable slug: String): Any { 221 | repository.findBySlug(slug)?.let { 222 | val currentUser = userService.currentUser() 223 | if (it.favorited.contains(currentUser)) { 224 | it.favorited.remove(currentUser) 225 | return articleView(repository.save(it), currentUser) 226 | } 227 | return articleView(it, currentUser) 228 | } 229 | throw NotFoundException() 230 | } 231 | 232 | // helpers 233 | 234 | fun articleView(article: Article, currentUser: User) 235 | = mapOf("article" to ArticleIO.fromModel(article, currentUser)) 236 | 237 | fun articlesView(articles: List
, currentUser: User) 238 | = mapOf("articles" to articles.map { ArticleIO.fromModel(it, userService.currentUser()) }, 239 | "articlesCount" to articles.size) 240 | 241 | fun commentView(comment: Comment, currentUser: User) 242 | = mapOf("comment" to CommentOut.fromModel(comment, currentUser)) 243 | 244 | fun commentsView(comments: List, currentUser: User) 245 | = mapOf("comments" to comments.map { CommentOut.fromModel(it, currentUser) }) 246 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/web/InvalidRequestHandler.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.web 2 | 3 | import io.realworld.exception.InvalidException 4 | import org.springframework.http.HttpStatus 5 | import org.springframework.stereotype.Component 6 | import org.springframework.web.bind.annotation.ExceptionHandler 7 | import org.springframework.web.bind.annotation.ResponseBody 8 | import org.springframework.web.bind.annotation.ResponseStatus 9 | import org.springframework.web.bind.annotation.RestControllerAdvice 10 | 11 | /** 12 | * Generates an error with the following format: 13 | * 14 |
15 | {
16 | "errors":{
17 | "body": [
18 | "can't be empty"
19 | ]
20 | }
21 | }
22 | 
23 | */ 24 | @Component 25 | @RestControllerAdvice 26 | class InvalidRequestHandler { 27 | @ResponseBody 28 | @ExceptionHandler 29 | @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) 30 | fun processValidationError(ex: InvalidException): Any { 31 | val errors = mutableMapOf>() 32 | ex.errors?.fieldErrors?.forEach { 33 | if (errors.containsKey(it.field)) 34 | errors[it.field]!!.add(it.defaultMessage) 35 | else 36 | errors[it.field] = mutableListOf(it.defaultMessage) 37 | } 38 | return mapOf("errors" to errors) 39 | } 40 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/web/ProfileHandler.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.web 2 | 3 | import io.realworld.exception.NotFoundException 4 | import io.realworld.jwt.ApiKeySecured 5 | import io.realworld.model.User 6 | import io.realworld.model.inout.Profile 7 | import io.realworld.repository.UserRepository 8 | import io.realworld.service.UserService 9 | import org.springframework.web.bind.annotation.* 10 | 11 | @RestController 12 | class ProfileHandler(val userRepository: UserRepository, 13 | val userService: UserService) { 14 | 15 | @ApiKeySecured(mandatory = false) 16 | @GetMapping("/api/profiles/{username}") 17 | fun profile(@PathVariable username: String): Any { 18 | userRepository.findByUsername(username)?.let { 19 | return view(it, userService.currentUser()) 20 | } 21 | throw NotFoundException() 22 | } 23 | 24 | @ApiKeySecured 25 | @PostMapping("/api/profiles/{username}/follow") 26 | fun follow(@PathVariable username: String): Any { 27 | userRepository.findByUsername(username)?.let { 28 | var currentUser = userService.currentUser() 29 | if (!currentUser.follows.contains(it)) { 30 | currentUser.follows.add(it) 31 | currentUser = userService.setCurrentUser(userRepository.save(currentUser)) 32 | } 33 | return view(it, currentUser) 34 | } 35 | throw NotFoundException() 36 | } 37 | 38 | @ApiKeySecured 39 | @DeleteMapping("/api/profiles/{username}/follow") 40 | fun unfollow(@PathVariable username: String): Any { 41 | userRepository.findByUsername(username)?.let { 42 | var currentUser = userService.currentUser() 43 | if (currentUser.follows.contains(it)) { 44 | currentUser.follows.remove(it) 45 | currentUser = userService.setCurrentUser(userRepository.save(currentUser)) 46 | } 47 | return view(it, currentUser) 48 | } 49 | throw NotFoundException() 50 | } 51 | 52 | fun view(user: User, currentUser: User) = mapOf("profile" to Profile.fromUser(user, currentUser)) 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/web/TagHandler.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.web 2 | 3 | import io.realworld.model.Tag 4 | import io.realworld.repository.TagRepository 5 | import org.springframework.web.bind.annotation.GetMapping 6 | import org.springframework.web.bind.annotation.RestController 7 | 8 | @RestController 9 | class TagHandler(val repository: TagRepository) { 10 | @GetMapping("/api/tags") 11 | fun allTags() = mapOf("tags" to repository.findAll().map(Tag::name)) 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/realworld/web/UserHandler.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.web 2 | 3 | import io.realworld.exception.ForbiddenRequestException 4 | import io.realworld.exception.InvalidException 5 | import io.realworld.exception.InvalidLoginException 6 | import io.realworld.exception.InvalidRequest 7 | import io.realworld.jwt.ApiKeySecured 8 | import io.realworld.model.User 9 | import io.realworld.model.inout.Login 10 | import io.realworld.model.inout.Register 11 | import io.realworld.model.inout.UpdateUser 12 | import io.realworld.repository.UserRepository 13 | import io.realworld.service.UserService 14 | import org.mindrot.jbcrypt.BCrypt 15 | import org.springframework.validation.BindException 16 | import org.springframework.validation.Errors 17 | import org.springframework.validation.FieldError 18 | import org.springframework.web.bind.annotation.* 19 | import jakarta.validation.Valid 20 | 21 | @RestController 22 | class UserHandler(val repository: UserRepository, 23 | val service: UserService) { 24 | 25 | @PostMapping("/api/users/login") 26 | fun login(@Valid @RequestBody login: Login, errors: Errors): Any { 27 | InvalidRequest.check(errors) 28 | 29 | try { 30 | service.login(login)?.let { 31 | return view(service.updateToken(it)) 32 | } 33 | return ForbiddenRequestException() 34 | } catch (e: InvalidLoginException) { 35 | val loginErrors = BindException(this, "") 36 | loginErrors.addError(FieldError("", e.field, e.error)) 37 | throw InvalidException(loginErrors) 38 | } 39 | } 40 | 41 | @PostMapping("/api/users") 42 | fun register(@Valid @RequestBody register: Register, errors: Errors): Any { 43 | InvalidRequest.check(errors) 44 | 45 | // check for duplicate user 46 | val registerErrors = BindException(this, "") 47 | checkUserAvailability(registerErrors, register.email, register.username) 48 | InvalidRequest.check(registerErrors) 49 | 50 | val user = User(username = register.username!!, 51 | email = register.email!!, password = BCrypt.hashpw(register.password, BCrypt.gensalt())) 52 | user.token = service.newToken(user) 53 | 54 | return view(repository.save(user)) 55 | } 56 | 57 | @ApiKeySecured 58 | @GetMapping("/api/user") 59 | fun currentUser() = view(service.currentUser()) 60 | 61 | @ApiKeySecured 62 | @PutMapping("/api/user") 63 | fun updateUser(@Valid @RequestBody user: UpdateUser, errors: Errors): Any { 64 | InvalidRequest.check(errors) 65 | 66 | val currentUser = service.currentUser() 67 | 68 | // check for errors 69 | val updateErrors = BindException(this, "") 70 | if (currentUser.email != user.email && user.email != null) { 71 | if (repository.existsByEmail(user.email!!)) { 72 | updateErrors.addError(FieldError("", "email", "already taken")) 73 | } 74 | } 75 | if (currentUser.username != user.username && user.username != null) { 76 | if (repository.existsByUsername(user.username!!)) { 77 | updateErrors.addError(FieldError("", "username", "already taken")) 78 | } 79 | } 80 | if (user.password == "") { 81 | updateErrors.addError(FieldError("", "password", "can't be empty")) 82 | } 83 | InvalidRequest.check(updateErrors) 84 | 85 | // update the user 86 | val u = currentUser.copy(email = user.email ?: currentUser.email, username = user.username ?: currentUser.username, 87 | password = BCrypt.hashpw(user.password, BCrypt.gensalt()), image = user.image ?: currentUser.image, 88 | bio = user.bio ?: currentUser.bio) 89 | // update token only if email changed 90 | if (currentUser.email != u.email) { 91 | u.token = service.newToken(u) 92 | } 93 | 94 | return view(repository.save(u)) 95 | } 96 | 97 | private fun checkUserAvailability(errors: BindException, email: String?, username: String?) { 98 | email?.let { 99 | if (repository.existsByEmail(it)) { 100 | errors.addError(FieldError("", "email", "already taken")) 101 | } 102 | } 103 | username?.let { 104 | if (repository.existsByUsername(it)) { 105 | errors.addError(FieldError("", "username", "already taken")) 106 | } 107 | } 108 | } 109 | 110 | fun view(user: User) = mapOf("user" to user) 111 | } -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.datasource.url=jdbc:h2:mem:AZ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;Mode=Oracle 2 | spring.datasource.driverClassName=org.h2.Driver 3 | spring.datasource.username=sa 4 | spring.datasource.password= 5 | spring.jpa.database-platform=org.hibernate.dialect.H2Dialect 6 | 7 | spring.jackson.deserialization.UNWRAP_ROOT_VALUE=true 8 | 9 | jwt.secret=Em3u7dCZ2QSvSGSGSRFUTfrwgu3WjfU2rHZxSjNSqU5x89C3jXPL6WLMW7dTE6rd9NRgWAwUWHkj8ZLfbCNU8uVfv9kuBmWCYPkk776A5jQ2LeJ76bZbdhXN 10 | jwt.issuer=Kotlin&Spring 11 | 12 | #logging.level.org.springframework.web=DEBUG -------------------------------------------------------------------------------- /src/test/kotlin/io/realworld/ApiApplicationTests.kt: -------------------------------------------------------------------------------- 1 | package io.realworld 2 | 3 | import feign.Feign 4 | import feign.gson.GsonDecoder 5 | import feign.gson.GsonEncoder 6 | import io.realworld.client.ProfileClient 7 | import io.realworld.client.TagClient 8 | import io.realworld.client.UserClient 9 | import io.realworld.client.response.InLogin 10 | import io.realworld.client.response.InRegister 11 | import io.realworld.model.inout.Login 12 | import io.realworld.model.inout.Register 13 | import org.hamcrest.Matchers 14 | import org.junit.Assert 15 | import org.junit.Before 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | import org.springframework.beans.factory.annotation.Autowired 19 | import org.springframework.boot.test.context.SpringBootTest 20 | import org.springframework.core.env.Environment 21 | import org.springframework.test.context.junit4.SpringRunner 22 | 23 | 24 | @RunWith(SpringRunner::class) 25 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 26 | class ApiApplicationTests { 27 | 28 | var randomServerPort: Int = 0 29 | @Autowired 30 | var environment: Environment? = null 31 | var tagClient: TagClient? = null 32 | var userClient: UserClient? = null 33 | var profileClient: ProfileClient? = null 34 | 35 | fun buildClient(t: Class): T { 36 | environment.let { 37 | randomServerPort = Integer.valueOf(it!!.getProperty("local.server.port")) 38 | return Feign.builder() 39 | .encoder(GsonEncoder()) 40 | .decoder(GsonDecoder()) 41 | .target(t, "http://localhost:${randomServerPort}") 42 | } 43 | } 44 | 45 | @Before 46 | fun before() { 47 | tagClient = buildClient(TagClient::class.java) 48 | userClient = buildClient(UserClient::class.java) 49 | profileClient = buildClient(ProfileClient::class.java) 50 | } 51 | 52 | @Test 53 | fun retrieveTags() { 54 | println("> tags: " + tagClient?.tags()?.tags) 55 | } 56 | 57 | @Test 58 | fun userAndProfileTest() { 59 | val fooRegister = userClient?.register( 60 | InRegister(Register(username = "foo", email = "foo@foo.com", password = "foo"))) 61 | Assert.assertEquals("foo", fooRegister?.user?.username) 62 | Assert.assertEquals("foo@foo.com", fooRegister?.user?.email) 63 | Assert.assertThat(fooRegister?.user?.token, Matchers.notNullValue()) 64 | println("Register foo OK") 65 | 66 | val fooLogin = userClient?.login(InLogin(Login(email = "foo@foo.com", password = "foo"))) 67 | Assert.assertEquals("foo", fooLogin?.user?.username) 68 | Assert.assertEquals("foo@foo.com", fooLogin?.user?.email) 69 | Assert.assertThat(fooLogin?.user?.token, Matchers.notNullValue()) 70 | println("Login foo OK") 71 | 72 | val barRegister = userClient?.register( 73 | InRegister(Register(username = "bar", email = "bar@bar.com", password = "bar"))) 74 | Assert.assertEquals("bar", barRegister?.user?.username) 75 | Assert.assertEquals("bar@bar.com", barRegister?.user?.email) 76 | Assert.assertThat(barRegister?.user?.token, Matchers.notNullValue()) 77 | println("Register bar OK") 78 | 79 | val barLogin = userClient?.login(InLogin(Login(email = "bar@bar.com", password = "bar"))) 80 | Assert.assertEquals("bar", barLogin?.user?.username) 81 | Assert.assertEquals("bar@bar.com", barLogin?.user?.email) 82 | Assert.assertThat(barLogin?.user?.token, Matchers.notNullValue()) 83 | println("Login bar OK") 84 | 85 | var profile = profileClient?.profile(barLogin?.user?.token!!, "foo")?.profile 86 | Assert.assertEquals("foo", profile?.username) 87 | Assert.assertFalse(profile?.following!!) 88 | println("Profile foo requested by bar OK") 89 | 90 | profile = profileClient?.follow(barLogin?.user?.token!!, "foo")?.profile 91 | Assert.assertEquals("foo", profile?.username) 92 | Assert.assertTrue(profile?.following!!) 93 | println("Foo is followed by bar OK") 94 | 95 | profile = profileClient?.unfollow(barLogin?.user?.token!!, "foo")?.profile 96 | Assert.assertEquals("foo", profile?.username) 97 | Assert.assertFalse(profile?.following!!) 98 | println("Foo is unfollowed by bar OK") 99 | } 100 | } 101 | --------------------------------------------------------------------------------