├── .github └── workflows │ └── main.yml ├── .gitignore ├── .travis.yml ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── http-client.env.json ├── http-requests └── auth.http ├── logo.png ├── postman_tests ├── Conduit.postman_collection.json ├── run-api-tests.sh └── swagger.json ├── resources ├── application.conf └── logback.xml ├── settings.gradle └── src ├── main ├── kotlin │ ├── Application.kt │ ├── api │ │ ├── ArticleResource.kt │ │ ├── AuthResource.kt │ │ ├── CommentResource.kt │ │ └── ProfileResource.kt │ ├── config │ │ ├── Api.kt │ │ ├── Auth.kt │ │ ├── Cors.kt │ │ └── StatusPages.kt │ ├── koin.kt │ ├── models │ │ ├── Article.kt │ │ ├── Comments.kt │ │ ├── Profile.kt │ │ └── User.kt │ ├── service │ │ ├── ArticleService.kt │ │ ├── AuthService.kt │ │ ├── CommentService.kt │ │ ├── DatabaseFactory.kt │ │ └── ProfileService.kt │ └── util │ │ ├── auth.kt │ │ ├── errors.kt │ │ └── web.kt └── resources │ ├── application.conf │ └── logback.xml └── test └── kotlin └── ApplicationTest.kt /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up JDK 21 17 | uses: actions/setup-java@v3 18 | with: 19 | distribution: temurin 20 | java-version: 21 21 | - name: Grant execute permission for gradlew 22 | run: chmod +x gradlew 23 | - name: Build with Gradle 24 | run: ./gradlew build test 25 | - name: Integration tests 26 | env: 27 | APIURL: http://localhost:8080/api 28 | run: | 29 | chmod +x postman_tests/run-api-tests.sh 30 | ./gradlew run & 31 | ./postman_tests/run-api-tests.sh 32 | ./gradlew --stop 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.gradle 2 | /.idea 3 | /out 4 | /build 5 | *.iml 6 | *.ipr 7 | *.iws 8 | buildSrc/.gradle 9 | buildSrc/build 10 | h2db 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - openjdk11 4 | before_install: 5 | - chmod +x gradlew 6 | - chmod +x gradle/wrapper/gradle-wrapper.jar 7 | - chmod +x postman_tests/run-api-tests.sh 8 | script: 9 | - ./gradlew test build 10 | - ./gradlew run & 11 | - ./postman_tests/run-api-tests.sh 12 | - ./gradlew --stop 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 2 | 3 | > ### [Kotlin-Ktor](https://github.com/kotlin/ktor) codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 4 | 5 | 6 | ### [Demo](https://github.com/gothinkster/realworld)    [RealWorld](https://github.com/gothinkster/realworld) 7 | 8 | 9 | This codebase was created to demonstrate a fully fledged fullstack application built with **[Kotlin-Ktor](https://github.com/kotlin/ktor)** including CRUD operations, authentication, routing, pagination, and more. 10 | 11 | We've gone to great lengths to adhere to the **[Kotlin-Ktor](https://github.com/kotlin/ktor)** community styleguides & best practices. 12 | 13 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 14 | 15 | # Build Status 16 | ![Build](https://github.com/dragneelfps/realworld-kotlin-ktor/workflows/Build/badge.svg?branch=master) 17 | 18 | # How it works 19 | 20 | - [h2](https://h2database.com/html/main.html) database 21 | - [hikari](https://github.com/brettwooldridge/HikariCP) as JDBC connection pool 22 | - [Exposed](https://github.com/JetBrains/Exposed/) as Kotlin SQL Framework 23 | - [Jackson](https://github.com/FasterXML/jackson) for handling JSON 24 | - [Koin](https://insert-koin.io/) for dependency injection 25 | 26 | # Getting started 27 | 28 | > Installation 29 | 30 | 1. Install h2 database. Default configuration uses server mode. 31 | 2. Run the gradle. :) 32 | 33 | > Running 34 | 35 | 1. Start the h2 database 36 | 2. Run the gradle. :)) 37 | 3. Check on [http://localhost:8080/api](http:localhost:8080/api), if using default configuration. 38 | 4. Yay. 39 | 40 | > Testing 41 | 1. ./gradlew build test 42 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.jvm) 3 | alias(libs.plugins.ktor) 4 | } 5 | 6 | 7 | group = "realworld" 8 | version = "0.0.1" 9 | 10 | application { 11 | mainClass.set("io.ktor.server.netty.EngineMain") 12 | } 13 | 14 | repositories { 15 | mavenCentral() 16 | } 17 | 18 | dependencies { 19 | implementation(libs.ktor.server.core) 20 | implementation(libs.ktor.server.netty) 21 | implementation(libs.ktor.auth) 22 | implementation(libs.ktor.auth.jwt) 23 | implementation(libs.ktor.content.negotiation) 24 | implementation(libs.ktor.jackson) 25 | implementation(libs.ktor.default.headers) 26 | implementation(libs.ktor.cors) 27 | implementation(libs.ktor.call.logging) 28 | implementation(libs.ktor.status.pages) 29 | implementation(libs.ktor.client.content.negotiation) 30 | 31 | // Logging 32 | implementation(libs.logback) 33 | 34 | // Database (Exposed & H2) 35 | implementation(libs.h2.database) 36 | implementation(libs.exposed.core) 37 | implementation(libs.exposed.dao) 38 | implementation(libs.exposed.jdbc) 39 | implementation(libs.exposed.java.time) 40 | implementation(libs.hikari) 41 | 42 | // Dependency Injection 43 | implementation(libs.koin.ktor) 44 | 45 | // Testing dependencies 46 | testImplementation(libs.kotlin.tests) 47 | testImplementation(libs.ktor.tests) // Ktor test dependency 48 | } 49 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kotlin = "2.1.10" 3 | ktor = "3.1.2" 4 | logback = "1.4.14" 5 | h2 = "2.3.232" 6 | exposed = "0.60.0" 7 | hikari = "6.3.0" 8 | koin = "4.1.0-Beta7" 9 | 10 | [libraries] 11 | # Ktor Dependencies 12 | ktor-server-core = { group = "io.ktor", name = "ktor-server-core" } 13 | ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty" } 14 | ktor-auth = { group = "io.ktor", name = "ktor-server-auth" } 15 | ktor-auth-jwt = { group = "io.ktor", name = "ktor-server-auth-jwt" } 16 | ktor-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation" } 17 | ktor-jackson = { group = "io.ktor", name = "ktor-serialization-jackson" } 18 | ktor-default-headers = { group = "io.ktor", name = "ktor-server-default-headers" } 19 | ktor-cors = { group = "io.ktor", name = "ktor-server-cors" } 20 | ktor-call-logging = { group = "io.ktor", name = "ktor-server-call-logging" } 21 | ktor-status-pages = { group = "io.ktor", name = "ktor-server-status-pages" } 22 | ktor-tests = { group = "io.ktor", name = "ktor-server-test-host" } 23 | ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation" } 24 | 25 | # Logging Dependencies 26 | logback = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } 27 | 28 | # Database Dependencies 29 | h2-database = { group = "com.h2database", name = "h2", version.ref = "h2" } 30 | exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" } 31 | exposed-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "exposed" } 32 | exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" } 33 | exposed-java-time = { group = "org.jetbrains.exposed", name = "exposed-java-time", version.ref = "exposed" } 34 | hikari = { group = "com.zaxxer", name = "HikariCP", version.ref = "hikari" } 35 | 36 | # Dependency Injection Dependencies 37 | koin-ktor = { group = "io.insert-koin", name = "koin-ktor3", version.ref = "koin" } 38 | 39 | # Testing Dependencies 40 | kotlin-tests = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } 41 | 42 | [plugins] 43 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 44 | ktor = { id = "io.ktor.plugin", version.ref = "ktor" } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragneelfps/realworld-kotlin-ktor/b74f499134fdad3bc0363d7743a0aa595dd96bc2/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-8.12-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MSYS* | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /http-client.env.json: -------------------------------------------------------------------------------- 1 | { 2 | "development": { 3 | "base-url": "http://localhost:8080/api" 4 | } 5 | } -------------------------------------------------------------------------------- /http-requests/auth.http: -------------------------------------------------------------------------------- 1 | # For a quick start check out our HTTP Requests collection (Tools|HTTP Client|Open HTTP Requests Collection) or 2 | # paste cURL into the file and request will be converted to HTTP Request format. 3 | # 4 | # Following HTTP Request Live Templates are available: 5 | # * 'gtrp' and 'gtr' create a GET request with or without query parameters; 6 | # * 'ptr' and 'ptrp' create a POST request with a simple or parameter-like body; 7 | # * 'mptr' and 'fptr' create a POST request to submit a form with a text or file field (multipart/form-data); 8 | 9 | # REGISTER 10 | POST {{base-url}}/users 11 | Content-Type: application/json 12 | 13 | { 14 | "user": { 15 | "email": "drag1@gmail.com", 16 | "username": "drag1", 17 | "password": "1234" 18 | } 19 | } 20 | 21 | > {% 22 | client.assert(typeof response.body.user.token !== "undefined", "No token returned"); 23 | client.global.set("auth_token", response.body.user.token); 24 | %} 25 | 26 | ### 27 | 28 | # CURRENT USER 29 | GET {{base-url}}/user 30 | Accept: application/json 31 | Authorization: Token {{auth_token}} 32 | 33 | ### 34 | 35 | # LOGIN 36 | POST {{base-url}}/users/login 37 | Content-Type: application/json 38 | 39 | { 40 | "user": { 41 | "email": "drag2@gmail.com", 42 | "password": "1234" 43 | } 44 | } 45 | 46 | > {% 47 | client.assert(typeof response.body.user.token !== "undefined", "No token returned"); 48 | client.global.set("auth_token", response.body.user.token); 49 | %} 50 | 51 | ### 52 | 53 | # UPDATE USER 54 | PUT {{base-url}}/user 55 | Content-Type: application/json 56 | Authorization: Token {{auth_token}} 57 | 58 | { 59 | "user": { 60 | "bio": "I work here too", 61 | "image": "https://image2.com" 62 | } 63 | } 64 | 65 | ### 66 | 67 | # GET ALL USERS 68 | GET {{base-url}}/users 69 | Accept: application/json 70 | 71 | ### 72 | 73 | # GET PROFILE - NOT AUTHENTICATED 74 | GET {{base-url}}/profiles/drag2 75 | Accept: application/json 76 | 77 | ### 78 | 79 | # GET PROFILE - AUTHENTICATED 80 | GET {{base-url}}/profiles/drag2 81 | Accept: application/json 82 | Authorization: Token {{auth_token}} 83 | 84 | ### 85 | 86 | # FOLLOW USER 87 | POST {{base-url}}/profiles/drag1/follow 88 | Accept: application/json 89 | Authorization: Token {{auth_token}} 90 | 91 | ### 92 | 93 | # UNFOLLOW USER 94 | DELETE {{base-url}}/profiles/drag1/follow 95 | Accept: application/json 96 | Authorization: Token {{auth_token}} 97 | 98 | ### 99 | 100 | # CREATE ARTICLE 101 | POST {{base-url}}/articles 102 | Content-Type: application/json 103 | Accept: application/json 104 | Authorization: Token {{auth_token}} 105 | 106 | { 107 | "article": { 108 | "title": "My puupy 2", 109 | "description": "is awesome", 110 | "body": "I love him 2", 111 | "tagList": [ 112 | "home", 113 | "dragons" 114 | ] 115 | } 116 | } 117 | 118 | > {% 119 | if(response.body.article !== undefined && response.body.article.slug !== undefined) 120 | client.global.set("slug", response.body.article.slug); 121 | %} 122 | 123 | ### 124 | 125 | # DROP DB 126 | 127 | GET {{base-url}}/drop 128 | 129 | ### 130 | 131 | # UPDATE ARTICLE 132 | PUT {{base-url}}/articles/my-puupy-2 133 | Accept: application/json 134 | Content-Type: application/json 135 | Authorization: Token {{auth_token}} 136 | 137 | { 138 | "article": { 139 | "title": "How to train your dragon 4", 140 | "description": "Ever wonder how it happend?", 141 | "body": "You have to believe. Please" 142 | } 143 | } 144 | 145 | ### 146 | 147 | # GET ARTICLE 148 | GET {{base-url}}/articles/my-puupy-2 149 | Accept: application/json 150 | 151 | ### 152 | 153 | # FAVORITE ARTICLE 154 | POST {{base-url}}/articles/my-puupy-2/favorite 155 | Accept: application/json 156 | Authorization: Token {{auth_token}} 157 | 158 | ### 159 | 160 | # UN-FAVORITE ARTICLE 161 | DELETE {{base-url}}/articles/my-puupy-2/favorite 162 | Accept: application/json 163 | Authorization: Token {{auth_token}} 164 | 165 | ### 166 | 167 | # DELETE ARTICLE 168 | DELETE {{base-url}}/articles/how-to-train-your-dragon-23 169 | Authorization: Token {{auth_token}} 170 | 171 | ### 172 | 173 | # GET ALL TAGS 174 | GET {{base-url}}/tags 175 | Accept: application/json 176 | 177 | ### 178 | 179 | # GET ARTICLES 180 | GET {{base-url}}/articles 181 | Authorization: Token {{auth_token}} 182 | 183 | ### 184 | 185 | 186 | # GET FEED 187 | GET {{base-url}}/articles/feed 188 | Authorization: Token {{auth_token}} 189 | 190 | ### 191 | 192 | # ADD COMMENT 193 | POST {{base-url}}/articles/my-puupy-2/comments 194 | Authorization: Token {{auth_token}} 195 | Accept: application/json 196 | Content-Type: application/json 197 | 198 | { 199 | "comment": { 200 | "body": "sooo cute" 201 | } 202 | } 203 | 204 | ### 205 | 206 | # GET COMMENTS - AUTHORIZED 207 | GET {{base-url}}/articles/my-puupy-2/comments 208 | Authorization: Token {{auth_token}} 209 | Accept: application/json 210 | 211 | ### 212 | 213 | # GET COMMENTS - UN-AUTHORIZED 214 | GET {{base-url}}/articles/my-puupy-2/comments 215 | Accept: application/json 216 | 217 | ### 218 | 219 | # DELETE COMMENT 220 | DELETE {{base-url}}/articles/my-puupy-2/comments/3 221 | Authorization: Token {{auth_token}} 222 | 223 | ### 224 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragneelfps/realworld-kotlin-ktor/b74f499134fdad3bc0363d7743a0aa595dd96bc2/logo.png -------------------------------------------------------------------------------- /postman_tests/Conduit.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "0574ad8a-a525-43ae-8e1e-5fd9756037f4", 4 | "name": "Conduit", 5 | "description": "Collection for testing the Conduit API\n\nhttps://github.com/gothinkster/realworld", 6 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 7 | }, 8 | "item": [ 9 | { 10 | "name": "Auth", 11 | "item": [ 12 | { 13 | "name": "Register", 14 | "event": [ 15 | { 16 | "listen": "test", 17 | "script": { 18 | "type": "text/javascript", 19 | "exec": [ 20 | "if (!(environment.isIntegrationTest)) {", 21 | "var responseJSON = JSON.parse(responseBody);", 22 | "", 23 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 24 | "", 25 | "var user = responseJSON.user || {};", 26 | "", 27 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 28 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 29 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 30 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 31 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 32 | "}", 33 | "" 34 | ] 35 | } 36 | } 37 | ], 38 | "request": { 39 | "method": "POST", 40 | "header": [ 41 | { 42 | "key": "Content-Type", 43 | "value": "application/json" 44 | }, 45 | { 46 | "key": "X-Requested-With", 47 | "value": "XMLHttpRequest" 48 | } 49 | ], 50 | "body": { 51 | "mode": "raw", 52 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"{{USERNAME}}\"}}" 53 | }, 54 | "url": { 55 | "raw": "{{APIURL}}/users", 56 | "host": [ 57 | "{{APIURL}}" 58 | ], 59 | "path": [ 60 | "users" 61 | ] 62 | } 63 | }, 64 | "response": [] 65 | }, 66 | { 67 | "name": "Login", 68 | "event": [ 69 | { 70 | "listen": "test", 71 | "script": { 72 | "type": "text/javascript", 73 | "exec": [ 74 | "var responseJSON = JSON.parse(responseBody);", 75 | "", 76 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 77 | "", 78 | "var user = responseJSON.user || {};", 79 | "", 80 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 81 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 82 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 83 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 84 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 85 | "" 86 | ] 87 | } 88 | } 89 | ], 90 | "request": { 91 | "method": "POST", 92 | "header": [ 93 | { 94 | "key": "Content-Type", 95 | "value": "application/json" 96 | }, 97 | { 98 | "key": "X-Requested-With", 99 | "value": "XMLHttpRequest" 100 | } 101 | ], 102 | "body": { 103 | "mode": "raw", 104 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}" 105 | }, 106 | "url": { 107 | "raw": "{{APIURL}}/users/login", 108 | "host": [ 109 | "{{APIURL}}" 110 | ], 111 | "path": [ 112 | "users", 113 | "login" 114 | ] 115 | } 116 | }, 117 | "response": [] 118 | }, 119 | { 120 | "name": "Login and Remember Token", 121 | "event": [ 122 | { 123 | "listen": "test", 124 | "script": { 125 | "id": "a7674032-bf09-4ae7-8224-4afa2fb1a9f9", 126 | "type": "text/javascript", 127 | "exec": [ 128 | "var responseJSON = JSON.parse(responseBody);", 129 | "", 130 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 131 | "", 132 | "var user = responseJSON.user || {};", 133 | "", 134 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 135 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 136 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 137 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 138 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 139 | "", 140 | "if(tests['User has \"token\" property']){", 141 | " pm.globals.set('token', user.token);", 142 | "}", 143 | "", 144 | "tests['Global variable \"token\" has been set'] = pm.globals.get('token') === user.token;", 145 | "" 146 | ] 147 | } 148 | } 149 | ], 150 | "request": { 151 | "method": "POST", 152 | "header": [ 153 | { 154 | "key": "Content-Type", 155 | "value": "application/json" 156 | }, 157 | { 158 | "key": "X-Requested-With", 159 | "value": "XMLHttpRequest" 160 | } 161 | ], 162 | "body": { 163 | "mode": "raw", 164 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}" 165 | }, 166 | "url": { 167 | "raw": "{{APIURL}}/users/login", 168 | "host": [ 169 | "{{APIURL}}" 170 | ], 171 | "path": [ 172 | "users", 173 | "login" 174 | ] 175 | } 176 | }, 177 | "response": [] 178 | }, 179 | { 180 | "name": "Current User", 181 | "event": [ 182 | { 183 | "listen": "test", 184 | "script": { 185 | "type": "text/javascript", 186 | "exec": [ 187 | "var responseJSON = JSON.parse(responseBody);", 188 | "", 189 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 190 | "", 191 | "var user = responseJSON.user || {};", 192 | "", 193 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 194 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 195 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 196 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 197 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 198 | "" 199 | ] 200 | } 201 | } 202 | ], 203 | "request": { 204 | "method": "GET", 205 | "header": [ 206 | { 207 | "key": "Content-Type", 208 | "value": "application/json" 209 | }, 210 | { 211 | "key": "X-Requested-With", 212 | "value": "XMLHttpRequest" 213 | }, 214 | { 215 | "key": "Authorization", 216 | "value": "Token {{token}}" 217 | } 218 | ], 219 | "body": { 220 | "mode": "raw", 221 | "raw": "" 222 | }, 223 | "url": { 224 | "raw": "{{APIURL}}/user", 225 | "host": [ 226 | "{{APIURL}}" 227 | ], 228 | "path": [ 229 | "user" 230 | ] 231 | } 232 | }, 233 | "response": [] 234 | }, 235 | { 236 | "name": "Update User", 237 | "event": [ 238 | { 239 | "listen": "test", 240 | "script": { 241 | "type": "text/javascript", 242 | "exec": [ 243 | "var responseJSON = JSON.parse(responseBody);", 244 | "", 245 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 246 | "", 247 | "var user = responseJSON.user || {};", 248 | "", 249 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 250 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 251 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 252 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 253 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 254 | "" 255 | ] 256 | } 257 | } 258 | ], 259 | "request": { 260 | "method": "PUT", 261 | "header": [ 262 | { 263 | "key": "Content-Type", 264 | "value": "application/json" 265 | }, 266 | { 267 | "key": "X-Requested-With", 268 | "value": "XMLHttpRequest" 269 | }, 270 | { 271 | "key": "Authorization", 272 | "value": "Token {{token}}" 273 | } 274 | ], 275 | "body": { 276 | "mode": "raw", 277 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\"}}" 278 | }, 279 | "url": { 280 | "raw": "{{APIURL}}/user", 281 | "host": [ 282 | "{{APIURL}}" 283 | ], 284 | "path": [ 285 | "user" 286 | ] 287 | } 288 | }, 289 | "response": [] 290 | } 291 | ] 292 | }, 293 | { 294 | "name": "Articles", 295 | "item": [ 296 | { 297 | "name": "All Articles", 298 | "event": [ 299 | { 300 | "listen": "test", 301 | "script": { 302 | "type": "text/javascript", 303 | "exec": [ 304 | "var is200Response = responseCode.code === 200;", 305 | "", 306 | "tests['Response code is 200 OK'] = is200Response;", 307 | "", 308 | "if(is200Response){", 309 | " var responseJSON = JSON.parse(responseBody);", 310 | "", 311 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 312 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 313 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 314 | "", 315 | " if(responseJSON.articles.length){", 316 | " var article = responseJSON.articles[0];", 317 | "", 318 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 319 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 320 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 321 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 322 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 323 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 324 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 325 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 326 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 327 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 328 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 329 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 330 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 331 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 332 | " } else {", 333 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 334 | " }", 335 | "}", 336 | "" 337 | ] 338 | } 339 | } 340 | ], 341 | "request": { 342 | "method": "GET", 343 | "header": [ 344 | { 345 | "key": "Content-Type", 346 | "value": "application/json" 347 | }, 348 | { 349 | "key": "X-Requested-With", 350 | "value": "XMLHttpRequest" 351 | } 352 | ], 353 | "body": { 354 | "mode": "raw", 355 | "raw": "" 356 | }, 357 | "url": { 358 | "raw": "{{APIURL}}/articles", 359 | "host": [ 360 | "{{APIURL}}" 361 | ], 362 | "path": [ 363 | "articles" 364 | ] 365 | } 366 | }, 367 | "response": [] 368 | }, 369 | { 370 | "name": "Articles by Author", 371 | "event": [ 372 | { 373 | "listen": "test", 374 | "script": { 375 | "type": "text/javascript", 376 | "exec": [ 377 | "var is200Response = responseCode.code === 200;", 378 | "", 379 | "tests['Response code is 200 OK'] = is200Response;", 380 | "", 381 | "if(is200Response){", 382 | " var responseJSON = JSON.parse(responseBody);", 383 | "", 384 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 385 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 386 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 387 | "", 388 | " if(responseJSON.articles.length){", 389 | " var article = responseJSON.articles[0];", 390 | "", 391 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 392 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 393 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 394 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 395 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 396 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 397 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 398 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 399 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 400 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 401 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 402 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 403 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 404 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 405 | " } else {", 406 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 407 | " }", 408 | "}", 409 | "" 410 | ] 411 | } 412 | } 413 | ], 414 | "request": { 415 | "method": "GET", 416 | "header": [ 417 | { 418 | "key": "Content-Type", 419 | "value": "application/json" 420 | }, 421 | { 422 | "key": "X-Requested-With", 423 | "value": "XMLHttpRequest" 424 | } 425 | ], 426 | "body": { 427 | "mode": "raw", 428 | "raw": "" 429 | }, 430 | "url": { 431 | "raw": "{{APIURL}}/articles?author=johnjacob", 432 | "host": [ 433 | "{{APIURL}}" 434 | ], 435 | "path": [ 436 | "articles" 437 | ], 438 | "query": [ 439 | { 440 | "key": "author", 441 | "value": "johnjacob" 442 | } 443 | ] 444 | } 445 | }, 446 | "response": [] 447 | }, 448 | { 449 | "name": "Articles Favorited by Username", 450 | "event": [ 451 | { 452 | "listen": "test", 453 | "script": { 454 | "type": "text/javascript", 455 | "exec": [ 456 | "var is200Response = responseCode.code === 200;", 457 | "", 458 | "tests['Response code is 200 OK'] = is200Response;", 459 | "", 460 | "if(is200Response){", 461 | " var responseJSON = JSON.parse(responseBody);", 462 | " ", 463 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 464 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 465 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 466 | "", 467 | " if(responseJSON.articles.length){", 468 | " var article = responseJSON.articles[0];", 469 | "", 470 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 471 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 472 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 473 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 474 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 475 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 476 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 477 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 478 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 479 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 480 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 481 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 482 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 483 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 484 | " } else {", 485 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 486 | " }", 487 | "}", 488 | "" 489 | ] 490 | } 491 | } 492 | ], 493 | "request": { 494 | "method": "GET", 495 | "header": [ 496 | { 497 | "key": "Content-Type", 498 | "value": "application/json" 499 | }, 500 | { 501 | "key": "X-Requested-With", 502 | "value": "XMLHttpRequest" 503 | } 504 | ], 505 | "body": { 506 | "mode": "raw", 507 | "raw": "" 508 | }, 509 | "url": { 510 | "raw": "{{APIURL}}/articles?favorited=jane", 511 | "host": [ 512 | "{{APIURL}}" 513 | ], 514 | "path": [ 515 | "articles" 516 | ], 517 | "query": [ 518 | { 519 | "key": "favorited", 520 | "value": "jane" 521 | } 522 | ] 523 | } 524 | }, 525 | "response": [] 526 | }, 527 | { 528 | "name": "Articles by Tag", 529 | "event": [ 530 | { 531 | "listen": "test", 532 | "script": { 533 | "type": "text/javascript", 534 | "exec": [ 535 | "var is200Response = responseCode.code === 200;", 536 | "", 537 | "tests['Response code is 200 OK'] = is200Response;", 538 | "", 539 | "if(is200Response){", 540 | " var responseJSON = JSON.parse(responseBody);", 541 | "", 542 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 543 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 544 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 545 | "", 546 | " if(responseJSON.articles.length){", 547 | " var article = responseJSON.articles[0];", 548 | "", 549 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 550 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 551 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 552 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 553 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 554 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 555 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 556 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 557 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 558 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 559 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 560 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 561 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 562 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 563 | " } else {", 564 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 565 | " }", 566 | "}", 567 | "" 568 | ] 569 | } 570 | } 571 | ], 572 | "request": { 573 | "method": "GET", 574 | "header": [ 575 | { 576 | "key": "Content-Type", 577 | "value": "application/json" 578 | }, 579 | { 580 | "key": "X-Requested-With", 581 | "value": "XMLHttpRequest" 582 | } 583 | ], 584 | "body": { 585 | "mode": "raw", 586 | "raw": "" 587 | }, 588 | "url": { 589 | "raw": "{{APIURL}}/articles?tag=dragons", 590 | "host": [ 591 | "{{APIURL}}" 592 | ], 593 | "path": [ 594 | "articles" 595 | ], 596 | "query": [ 597 | { 598 | "key": "tag", 599 | "value": "dragons" 600 | } 601 | ] 602 | } 603 | }, 604 | "response": [] 605 | } 606 | ] 607 | }, 608 | { 609 | "name": "Articles, Favorite, Comments", 610 | "item": [ 611 | { 612 | "name": "Create Article", 613 | "event": [ 614 | { 615 | "listen": "test", 616 | "script": { 617 | "id": "e711dbf8-8065-4ba8-8b74-f1639a7d8208", 618 | "type": "text/javascript", 619 | "exec": [ 620 | "var responseJSON = JSON.parse(responseBody);", 621 | "", 622 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", 623 | "", 624 | "var article = responseJSON.article || {};", 625 | "", 626 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 627 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 628 | "pm.globals.set('slug', article.slug);", 629 | "", 630 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 631 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 632 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 633 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 634 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 635 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 636 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 637 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 638 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 639 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 640 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 641 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 642 | "" 643 | ] 644 | } 645 | } 646 | ], 647 | "request": { 648 | "method": "POST", 649 | "header": [ 650 | { 651 | "key": "Content-Type", 652 | "value": "application/json" 653 | }, 654 | { 655 | "key": "X-Requested-With", 656 | "value": "XMLHttpRequest" 657 | }, 658 | { 659 | "key": "Authorization", 660 | "value": "Token {{token}}" 661 | } 662 | ], 663 | "body": { 664 | "mode": "raw", 665 | "raw": "{\"article\":{\"title\":\"How to train your dragon\", \"description\":\"Ever wonder how?\", \"body\":\"Very carefully.\", \"tagList\":[\"dragons\",\"training\"]}}" 666 | }, 667 | "url": { 668 | "raw": "{{APIURL}}/articles", 669 | "host": [ 670 | "{{APIURL}}" 671 | ], 672 | "path": [ 673 | "articles" 674 | ] 675 | } 676 | }, 677 | "response": [] 678 | }, 679 | { 680 | "name": "Feed", 681 | "event": [ 682 | { 683 | "listen": "test", 684 | "script": { 685 | "type": "text/javascript", 686 | "exec": [ 687 | "var is200Response = responseCode.code === 200;", 688 | "", 689 | "tests['Response code is 200 OK'] = is200Response;", 690 | "", 691 | "if(is200Response){", 692 | " var responseJSON = JSON.parse(responseBody);", 693 | "", 694 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 695 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 696 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 697 | "", 698 | " if(responseJSON.articles.length){", 699 | " var article = responseJSON.articles[0];", 700 | "", 701 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 702 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 703 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 704 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 705 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 706 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 707 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 708 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 709 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 710 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 711 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 712 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 713 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 714 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 715 | " } else {", 716 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 717 | " }", 718 | "}", 719 | "" 720 | ] 721 | } 722 | } 723 | ], 724 | "request": { 725 | "method": "GET", 726 | "header": [ 727 | { 728 | "key": "Content-Type", 729 | "value": "application/json" 730 | }, 731 | { 732 | "key": "X-Requested-With", 733 | "value": "XMLHttpRequest" 734 | }, 735 | { 736 | "key": "Authorization", 737 | "value": "Token {{token}}" 738 | } 739 | ], 740 | "body": { 741 | "mode": "raw", 742 | "raw": "" 743 | }, 744 | "url": { 745 | "raw": "{{APIURL}}/articles/feed", 746 | "host": [ 747 | "{{APIURL}}" 748 | ], 749 | "path": [ 750 | "articles", 751 | "feed" 752 | ] 753 | } 754 | }, 755 | "response": [] 756 | }, 757 | { 758 | "name": "All Articles", 759 | "event": [ 760 | { 761 | "listen": "test", 762 | "script": { 763 | "type": "text/javascript", 764 | "exec": [ 765 | "var is200Response = responseCode.code === 200;", 766 | "", 767 | "tests['Response code is 200 OK'] = is200Response;", 768 | "", 769 | "if(is200Response){", 770 | " var responseJSON = JSON.parse(responseBody);", 771 | "", 772 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 773 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 774 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 775 | "", 776 | " if(responseJSON.articles.length){", 777 | " var article = responseJSON.articles[0];", 778 | "", 779 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 780 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 781 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 782 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 783 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 784 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 785 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 786 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 787 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 788 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 789 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 790 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 791 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 792 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 793 | " } else {", 794 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 795 | " }", 796 | "}", 797 | "" 798 | ] 799 | } 800 | } 801 | ], 802 | "request": { 803 | "method": "GET", 804 | "header": [ 805 | { 806 | "key": "Content-Type", 807 | "value": "application/json" 808 | }, 809 | { 810 | "key": "X-Requested-With", 811 | "value": "XMLHttpRequest" 812 | }, 813 | { 814 | "key": "Authorization", 815 | "value": "Token {{token}}" 816 | } 817 | ], 818 | "body": { 819 | "mode": "raw", 820 | "raw": "" 821 | }, 822 | "url": { 823 | "raw": "{{APIURL}}/articles", 824 | "host": [ 825 | "{{APIURL}}" 826 | ], 827 | "path": [ 828 | "articles" 829 | ] 830 | } 831 | }, 832 | "response": [] 833 | }, 834 | { 835 | "name": "All Articles with auth", 836 | "event": [ 837 | { 838 | "listen": "test", 839 | "script": { 840 | "type": "text/javascript", 841 | "exec": [ 842 | "var is200Response = responseCode.code === 200;", 843 | "", 844 | "tests['Response code is 200 OK'] = is200Response;", 845 | "", 846 | "if(is200Response){", 847 | " var responseJSON = JSON.parse(responseBody);", 848 | "", 849 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 850 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 851 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 852 | "", 853 | " if(responseJSON.articles.length){", 854 | " var article = responseJSON.articles[0];", 855 | "", 856 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 857 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 858 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 859 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 860 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 861 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 862 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 863 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 864 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 865 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 866 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 867 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 868 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 869 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 870 | " } else {", 871 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 872 | " }", 873 | "}", 874 | "" 875 | ] 876 | } 877 | } 878 | ], 879 | "request": { 880 | "method": "GET", 881 | "header": [ 882 | { 883 | "key": "Content-Type", 884 | "value": "application/json" 885 | }, 886 | { 887 | "key": "X-Requested-With", 888 | "value": "XMLHttpRequest" 889 | }, 890 | { 891 | "key": "Authorization", 892 | "value": "Token {{token}}" 893 | } 894 | ], 895 | "body": { 896 | "mode": "raw", 897 | "raw": "" 898 | }, 899 | "url": { 900 | "raw": "{{APIURL}}/articles", 901 | "host": [ 902 | "{{APIURL}}" 903 | ], 904 | "path": [ 905 | "articles" 906 | ] 907 | } 908 | }, 909 | "response": [] 910 | }, 911 | { 912 | "name": "Articles by Author", 913 | "event": [ 914 | { 915 | "listen": "test", 916 | "script": { 917 | "type": "text/javascript", 918 | "exec": [ 919 | "var is200Response = responseCode.code === 200;", 920 | "", 921 | "tests['Response code is 200 OK'] = is200Response;", 922 | "", 923 | "if(is200Response){", 924 | " var responseJSON = JSON.parse(responseBody);", 925 | "", 926 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 927 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 928 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 929 | "", 930 | " if(responseJSON.articles.length){", 931 | " var article = responseJSON.articles[0];", 932 | "", 933 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 934 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 935 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 936 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 937 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 938 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 939 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 940 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 941 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 942 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 943 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 944 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 945 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 946 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 947 | " } else {", 948 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 949 | " }", 950 | "}", 951 | "" 952 | ] 953 | } 954 | } 955 | ], 956 | "request": { 957 | "method": "GET", 958 | "header": [ 959 | { 960 | "key": "Content-Type", 961 | "value": "application/json" 962 | }, 963 | { 964 | "key": "X-Requested-With", 965 | "value": "XMLHttpRequest" 966 | }, 967 | { 968 | "key": "Authorization", 969 | "value": "Token {{token}}" 970 | } 971 | ], 972 | "body": { 973 | "mode": "raw", 974 | "raw": "" 975 | }, 976 | "url": { 977 | "raw": "{{APIURL}}/articles?author={{USERNAME}}", 978 | "host": [ 979 | "{{APIURL}}" 980 | ], 981 | "path": [ 982 | "articles" 983 | ], 984 | "query": [ 985 | { 986 | "key": "author", 987 | "value": "{{USERNAME}}" 988 | } 989 | ] 990 | } 991 | }, 992 | "response": [] 993 | }, 994 | { 995 | "name": "Articles by Author with auth", 996 | "event": [ 997 | { 998 | "listen": "test", 999 | "script": { 1000 | "type": "text/javascript", 1001 | "exec": [ 1002 | "var is200Response = responseCode.code === 200;", 1003 | "", 1004 | "tests['Response code is 200 OK'] = is200Response;", 1005 | "", 1006 | "if(is200Response){", 1007 | " var responseJSON = JSON.parse(responseBody);", 1008 | "", 1009 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 1010 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 1011 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 1012 | "", 1013 | " if(responseJSON.articles.length){", 1014 | " var article = responseJSON.articles[0];", 1015 | "", 1016 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1017 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1018 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1019 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1020 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 1021 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1022 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 1023 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1024 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1025 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1026 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1027 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1028 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1029 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1030 | " } else {", 1031 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 1032 | " }", 1033 | "}", 1034 | "" 1035 | ] 1036 | } 1037 | } 1038 | ], 1039 | "request": { 1040 | "method": "GET", 1041 | "header": [ 1042 | { 1043 | "key": "Content-Type", 1044 | "value": "application/json" 1045 | }, 1046 | { 1047 | "key": "X-Requested-With", 1048 | "value": "XMLHttpRequest" 1049 | }, 1050 | { 1051 | "key": "Authorization", 1052 | "value": "Token {{token}}" 1053 | } 1054 | ], 1055 | "body": { 1056 | "mode": "raw", 1057 | "raw": "" 1058 | }, 1059 | "url": { 1060 | "raw": "{{APIURL}}/articles?author={{USERNAME}}", 1061 | "host": [ 1062 | "{{APIURL}}" 1063 | ], 1064 | "path": [ 1065 | "articles" 1066 | ], 1067 | "query": [ 1068 | { 1069 | "key": "author", 1070 | "value": "{{USERNAME}}" 1071 | } 1072 | ] 1073 | } 1074 | }, 1075 | "response": [] 1076 | }, 1077 | { 1078 | "name": "Articles Favorited by Username", 1079 | "event": [ 1080 | { 1081 | "listen": "test", 1082 | "script": { 1083 | "type": "text/javascript", 1084 | "exec": [ 1085 | "var is200Response = responseCode.code === 200;", 1086 | "", 1087 | "tests['Response code is 200 OK'] = is200Response;", 1088 | "", 1089 | "if(is200Response){", 1090 | " var responseJSON = JSON.parse(responseBody);", 1091 | " ", 1092 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 1093 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 1094 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 1095 | "", 1096 | " if(responseJSON.articles.length){", 1097 | " var article = responseJSON.articles[0];", 1098 | "", 1099 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1100 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1101 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1102 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1103 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 1104 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1105 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 1106 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1107 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1108 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1109 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1110 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1111 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1112 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1113 | " } else {", 1114 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 1115 | " }", 1116 | "}", 1117 | "" 1118 | ] 1119 | } 1120 | } 1121 | ], 1122 | "request": { 1123 | "method": "GET", 1124 | "header": [ 1125 | { 1126 | "key": "Content-Type", 1127 | "value": "application/json" 1128 | }, 1129 | { 1130 | "key": "X-Requested-With", 1131 | "value": "XMLHttpRequest" 1132 | }, 1133 | { 1134 | "key": "Authorization", 1135 | "value": "Token {{token}}" 1136 | } 1137 | ], 1138 | "body": { 1139 | "mode": "raw", 1140 | "raw": "" 1141 | }, 1142 | "url": { 1143 | "raw": "{{APIURL}}/articles?favorited=jane", 1144 | "host": [ 1145 | "{{APIURL}}" 1146 | ], 1147 | "path": [ 1148 | "articles" 1149 | ], 1150 | "query": [ 1151 | { 1152 | "key": "favorited", 1153 | "value": "jane" 1154 | } 1155 | ] 1156 | } 1157 | }, 1158 | "response": [] 1159 | }, 1160 | { 1161 | "name": "Articles Favorited by Username with auth", 1162 | "event": [ 1163 | { 1164 | "listen": "test", 1165 | "script": { 1166 | "type": "text/javascript", 1167 | "exec": [ 1168 | "var is200Response = responseCode.code === 200;", 1169 | "", 1170 | "tests['Response code is 200 OK'] = is200Response;", 1171 | "", 1172 | "if(is200Response){", 1173 | " var responseJSON = JSON.parse(responseBody);", 1174 | " ", 1175 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 1176 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 1177 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 1178 | "", 1179 | " if(responseJSON.articles.length){", 1180 | " var article = responseJSON.articles[0];", 1181 | "", 1182 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1183 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1184 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1185 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1186 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 1187 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1188 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 1189 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1190 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1191 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1192 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1193 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1194 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1195 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1196 | " } else {", 1197 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 1198 | " }", 1199 | "}", 1200 | "" 1201 | ] 1202 | } 1203 | } 1204 | ], 1205 | "request": { 1206 | "method": "GET", 1207 | "header": [ 1208 | { 1209 | "key": "Content-Type", 1210 | "value": "application/json" 1211 | }, 1212 | { 1213 | "key": "X-Requested-With", 1214 | "value": "XMLHttpRequest" 1215 | }, 1216 | { 1217 | "key": "Authorization", 1218 | "value": "Token {{token}}" 1219 | } 1220 | ], 1221 | "body": { 1222 | "mode": "raw", 1223 | "raw": "" 1224 | }, 1225 | "url": { 1226 | "raw": "{{APIURL}}/articles?favorited=jane", 1227 | "host": [ 1228 | "{{APIURL}}" 1229 | ], 1230 | "path": [ 1231 | "articles" 1232 | ], 1233 | "query": [ 1234 | { 1235 | "key": "favorited", 1236 | "value": "jane" 1237 | } 1238 | ] 1239 | } 1240 | }, 1241 | "response": [] 1242 | }, 1243 | { 1244 | "name": "Single Article by slug", 1245 | "event": [ 1246 | { 1247 | "listen": "test", 1248 | "script": { 1249 | "type": "text/javascript", 1250 | "exec": [ 1251 | "var responseJSON = JSON.parse(responseBody);", 1252 | "", 1253 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", 1254 | "", 1255 | "var article = responseJSON.article || {};", 1256 | "", 1257 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1258 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1259 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1260 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1261 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 1262 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1263 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 1264 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1265 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1266 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1267 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1268 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1269 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1270 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1271 | "" 1272 | ] 1273 | } 1274 | } 1275 | ], 1276 | "request": { 1277 | "method": "GET", 1278 | "header": [ 1279 | { 1280 | "key": "Content-Type", 1281 | "value": "application/json" 1282 | }, 1283 | { 1284 | "key": "X-Requested-With", 1285 | "value": "XMLHttpRequest" 1286 | }, 1287 | { 1288 | "key": "Authorization", 1289 | "value": "Token {{token}}" 1290 | } 1291 | ], 1292 | "body": { 1293 | "mode": "raw", 1294 | "raw": "" 1295 | }, 1296 | "url": { 1297 | "raw": "{{APIURL}}/articles/{{slug}}", 1298 | "host": [ 1299 | "{{APIURL}}" 1300 | ], 1301 | "path": [ 1302 | "articles", 1303 | "{{slug}}" 1304 | ] 1305 | } 1306 | }, 1307 | "response": [] 1308 | }, 1309 | { 1310 | "name": "Articles by Tag", 1311 | "event": [ 1312 | { 1313 | "listen": "test", 1314 | "script": { 1315 | "type": "text/javascript", 1316 | "exec": [ 1317 | "var is200Response = responseCode.code === 200;", 1318 | "", 1319 | "tests['Response code is 200 OK'] = is200Response;", 1320 | "", 1321 | "if(is200Response){", 1322 | " var responseJSON = JSON.parse(responseBody);", 1323 | "", 1324 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 1325 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 1326 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 1327 | "", 1328 | " if(responseJSON.articles.length){", 1329 | " var article = responseJSON.articles[0];", 1330 | "", 1331 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1332 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1333 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1334 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1335 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 1336 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1337 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 1338 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1339 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1340 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1341 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1342 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1343 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1344 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1345 | " } else {", 1346 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 1347 | " }", 1348 | "}", 1349 | "" 1350 | ] 1351 | } 1352 | } 1353 | ], 1354 | "request": { 1355 | "method": "GET", 1356 | "header": [ 1357 | { 1358 | "key": "Content-Type", 1359 | "value": "application/json" 1360 | }, 1361 | { 1362 | "key": "X-Requested-With", 1363 | "value": "XMLHttpRequest" 1364 | }, 1365 | { 1366 | "key": "Authorization", 1367 | "value": "Token {{token}}" 1368 | } 1369 | ], 1370 | "body": { 1371 | "mode": "raw", 1372 | "raw": "" 1373 | }, 1374 | "url": { 1375 | "raw": "{{APIURL}}/articles?tag=dragons", 1376 | "host": [ 1377 | "{{APIURL}}" 1378 | ], 1379 | "path": [ 1380 | "articles" 1381 | ], 1382 | "query": [ 1383 | { 1384 | "key": "tag", 1385 | "value": "dragons" 1386 | } 1387 | ] 1388 | } 1389 | }, 1390 | "response": [] 1391 | }, 1392 | { 1393 | "name": "Update Article", 1394 | "event": [ 1395 | { 1396 | "listen": "test", 1397 | "script": { 1398 | "type": "text/javascript", 1399 | "exec": [ 1400 | "if (!(environment.isIntegrationTest)) {", 1401 | "var responseJSON = JSON.parse(responseBody);", 1402 | "", 1403 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", 1404 | "", 1405 | "var article = responseJSON.article || {};", 1406 | "", 1407 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1408 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1409 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1410 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1411 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 1412 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1413 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 1414 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1415 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1416 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1417 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1418 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1419 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1420 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1421 | "}", 1422 | "" 1423 | ] 1424 | } 1425 | } 1426 | ], 1427 | "request": { 1428 | "method": "PUT", 1429 | "header": [ 1430 | { 1431 | "key": "Content-Type", 1432 | "value": "application/json" 1433 | }, 1434 | { 1435 | "key": "X-Requested-With", 1436 | "value": "XMLHttpRequest" 1437 | }, 1438 | { 1439 | "key": "Authorization", 1440 | "value": "Token {{token}}" 1441 | } 1442 | ], 1443 | "body": { 1444 | "mode": "raw", 1445 | "raw": "{\"article\":{\"body\":\"With two hands\"}}" 1446 | }, 1447 | "url": { 1448 | "raw": "{{APIURL}}/articles/{{slug}}", 1449 | "host": [ 1450 | "{{APIURL}}" 1451 | ], 1452 | "path": [ 1453 | "articles", 1454 | "{{slug}}" 1455 | ] 1456 | } 1457 | }, 1458 | "response": [] 1459 | }, 1460 | { 1461 | "name": "Favorite Article", 1462 | "event": [ 1463 | { 1464 | "listen": "test", 1465 | "script": { 1466 | "type": "text/javascript", 1467 | "exec": [ 1468 | "var responseJSON = JSON.parse(responseBody);", 1469 | "", 1470 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", 1471 | "", 1472 | "var article = responseJSON.article || {};", 1473 | "", 1474 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1475 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1476 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1477 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1478 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 1479 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1480 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 1481 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1482 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1483 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1484 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1485 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1486 | "tests[\"Article's 'favorited' property is true\"] = article.favorited === true;", 1487 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1488 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1489 | "tests[\"Article's 'favoritesCount' property is greater than 0\"] = article.favoritesCount > 0;", 1490 | "" 1491 | ] 1492 | } 1493 | } 1494 | ], 1495 | "request": { 1496 | "method": "POST", 1497 | "header": [ 1498 | { 1499 | "key": "Content-Type", 1500 | "value": "application/json" 1501 | }, 1502 | { 1503 | "key": "X-Requested-With", 1504 | "value": "XMLHttpRequest" 1505 | }, 1506 | { 1507 | "key": "Authorization", 1508 | "value": "Token {{token}}" 1509 | } 1510 | ], 1511 | "body": { 1512 | "mode": "raw", 1513 | "raw": "" 1514 | }, 1515 | "url": { 1516 | "raw": "{{APIURL}}/articles/{{slug}}/favorite", 1517 | "host": [ 1518 | "{{APIURL}}" 1519 | ], 1520 | "path": [ 1521 | "articles", 1522 | "{{slug}}", 1523 | "favorite" 1524 | ] 1525 | } 1526 | }, 1527 | "response": [] 1528 | }, 1529 | { 1530 | "name": "Unfavorite Article", 1531 | "event": [ 1532 | { 1533 | "listen": "test", 1534 | "script": { 1535 | "type": "text/javascript", 1536 | "exec": [ 1537 | "var responseJSON = JSON.parse(responseBody);", 1538 | "", 1539 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", 1540 | "", 1541 | "var article = responseJSON.article || {};", 1542 | "", 1543 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1544 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1545 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1546 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1547 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", 1548 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1549 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", 1550 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1551 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1552 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1553 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1554 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1555 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1556 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1557 | "tests[\"Article's \\\"favorited\\\" property is false\"] = article.favorited === false;", 1558 | "" 1559 | ] 1560 | } 1561 | } 1562 | ], 1563 | "request": { 1564 | "method": "DELETE", 1565 | "header": [ 1566 | { 1567 | "key": "Content-Type", 1568 | "value": "application/json" 1569 | }, 1570 | { 1571 | "key": "X-Requested-With", 1572 | "value": "XMLHttpRequest" 1573 | }, 1574 | { 1575 | "key": "Authorization", 1576 | "value": "Token {{token}}" 1577 | } 1578 | ], 1579 | "body": { 1580 | "mode": "raw", 1581 | "raw": "" 1582 | }, 1583 | "url": { 1584 | "raw": "{{APIURL}}/articles/{{slug}}/favorite", 1585 | "host": [ 1586 | "{{APIURL}}" 1587 | ], 1588 | "path": [ 1589 | "articles", 1590 | "{{slug}}", 1591 | "favorite" 1592 | ] 1593 | } 1594 | }, 1595 | "response": [] 1596 | }, 1597 | { 1598 | "name": "Create Comment for Article", 1599 | "event": [ 1600 | { 1601 | "listen": "test", 1602 | "script": { 1603 | "id": "9f90c364-cc68-4728-961a-85eb00197d7b", 1604 | "type": "text/javascript", 1605 | "exec": [ 1606 | "var responseJSON = JSON.parse(responseBody);", 1607 | "", 1608 | "tests['Response contains \"comment\" property'] = responseJSON.hasOwnProperty('comment');", 1609 | "", 1610 | "var comment = responseJSON.comment || {};", 1611 | "", 1612 | "tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');", 1613 | "pm.globals.set('commentId', comment.id);", 1614 | "", 1615 | "tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');", 1616 | "tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');", 1617 | "tests['\"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.createdAt);", 1618 | "tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');", 1619 | "tests['\"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.updatedAt);", 1620 | "tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');", 1621 | "" 1622 | ] 1623 | } 1624 | } 1625 | ], 1626 | "request": { 1627 | "method": "POST", 1628 | "header": [ 1629 | { 1630 | "key": "Content-Type", 1631 | "value": "application/json" 1632 | }, 1633 | { 1634 | "key": "X-Requested-With", 1635 | "value": "XMLHttpRequest" 1636 | }, 1637 | { 1638 | "key": "Authorization", 1639 | "value": "Token {{token}}" 1640 | } 1641 | ], 1642 | "body": { 1643 | "mode": "raw", 1644 | "raw": "{\"comment\":{\"body\":\"Thank you so much!\"}}" 1645 | }, 1646 | "url": { 1647 | "raw": "{{APIURL}}/articles/{{slug}}/comments", 1648 | "host": [ 1649 | "{{APIURL}}" 1650 | ], 1651 | "path": [ 1652 | "articles", 1653 | "{{slug}}", 1654 | "comments" 1655 | ] 1656 | } 1657 | }, 1658 | "response": [] 1659 | }, 1660 | { 1661 | "name": "All Comments for Article", 1662 | "event": [ 1663 | { 1664 | "listen": "test", 1665 | "script": { 1666 | "type": "text/javascript", 1667 | "exec": [ 1668 | "var is200Response = responseCode.code === 200", 1669 | "", 1670 | "tests['Response code is 200 OK'] = is200Response;", 1671 | "", 1672 | "if(is200Response){", 1673 | " var responseJSON = JSON.parse(responseBody);", 1674 | "", 1675 | " tests['Response contains \"comments\" property'] = responseJSON.hasOwnProperty('comments');", 1676 | "", 1677 | " if(responseJSON.comments.length){", 1678 | " var comment = responseJSON.comments[0];", 1679 | "", 1680 | " tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');", 1681 | " tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');", 1682 | " tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');", 1683 | " tests['\"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.createdAt);", 1684 | " tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');", 1685 | " tests['\"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.updatedAt);", 1686 | " tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');", 1687 | " }", 1688 | "}", 1689 | "" 1690 | ] 1691 | } 1692 | } 1693 | ], 1694 | "request": { 1695 | "method": "GET", 1696 | "header": [ 1697 | { 1698 | "key": "Content-Type", 1699 | "value": "application/json" 1700 | }, 1701 | { 1702 | "key": "X-Requested-With", 1703 | "value": "XMLHttpRequest" 1704 | }, 1705 | { 1706 | "key": "Authorization", 1707 | "value": "Token {{token}}" 1708 | } 1709 | ], 1710 | "body": { 1711 | "mode": "raw", 1712 | "raw": "" 1713 | }, 1714 | "url": { 1715 | "raw": "{{APIURL}}/articles/{{slug}}/comments", 1716 | "host": [ 1717 | "{{APIURL}}" 1718 | ], 1719 | "path": [ 1720 | "articles", 1721 | "{{slug}}", 1722 | "comments" 1723 | ] 1724 | } 1725 | }, 1726 | "response": [] 1727 | }, 1728 | { 1729 | "name": "Delete Comment for Article", 1730 | "request": { 1731 | "method": "DELETE", 1732 | "header": [ 1733 | { 1734 | "key": "Content-Type", 1735 | "value": "application/json" 1736 | }, 1737 | { 1738 | "key": "X-Requested-With", 1739 | "value": "XMLHttpRequest" 1740 | }, 1741 | { 1742 | "key": "Authorization", 1743 | "value": "Token {{token}}" 1744 | } 1745 | ], 1746 | "body": { 1747 | "mode": "raw", 1748 | "raw": "" 1749 | }, 1750 | "url": { 1751 | "raw": "{{APIURL}}/articles/{{slug}}/comments/{{commentId}}", 1752 | "host": [ 1753 | "{{APIURL}}" 1754 | ], 1755 | "path": [ 1756 | "articles", 1757 | "{{slug}}", 1758 | "comments", 1759 | "{{commentId}}" 1760 | ] 1761 | } 1762 | }, 1763 | "response": [] 1764 | }, 1765 | { 1766 | "name": "Delete Article", 1767 | "request": { 1768 | "method": "DELETE", 1769 | "header": [ 1770 | { 1771 | "key": "Content-Type", 1772 | "value": "application/json" 1773 | }, 1774 | { 1775 | "key": "X-Requested-With", 1776 | "value": "XMLHttpRequest" 1777 | }, 1778 | { 1779 | "key": "Authorization", 1780 | "value": "Token {{token}}" 1781 | } 1782 | ], 1783 | "body": { 1784 | "mode": "raw", 1785 | "raw": "" 1786 | }, 1787 | "url": { 1788 | "raw": "{{APIURL}}/articles/{{slug}}", 1789 | "host": [ 1790 | "{{APIURL}}" 1791 | ], 1792 | "path": [ 1793 | "articles", 1794 | "{{slug}}" 1795 | ] 1796 | } 1797 | }, 1798 | "response": [] 1799 | } 1800 | ], 1801 | "event": [ 1802 | { 1803 | "listen": "prerequest", 1804 | "script": { 1805 | "id": "67853a4a-e972-4573-a295-dad12a46a9d7", 1806 | "type": "text/javascript", 1807 | "exec": [ 1808 | "" 1809 | ] 1810 | } 1811 | }, 1812 | { 1813 | "listen": "test", 1814 | "script": { 1815 | "id": "3057f989-15e4-484e-b8fa-a041043d0ac0", 1816 | "type": "text/javascript", 1817 | "exec": [ 1818 | "" 1819 | ] 1820 | } 1821 | } 1822 | ] 1823 | }, 1824 | { 1825 | "name": "Profiles", 1826 | "item": [ 1827 | { 1828 | "name": "Register Celeb", 1829 | "event": [ 1830 | { 1831 | "listen": "test", 1832 | "script": { 1833 | "type": "text/javascript", 1834 | "exec": [ 1835 | "if (!(environment.isIntegrationTest)) {", 1836 | "var responseJSON = JSON.parse(responseBody);", 1837 | "", 1838 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 1839 | "", 1840 | "var user = responseJSON.user || {};", 1841 | "", 1842 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 1843 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 1844 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 1845 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 1846 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 1847 | "}", 1848 | "" 1849 | ] 1850 | } 1851 | } 1852 | ], 1853 | "request": { 1854 | "method": "POST", 1855 | "header": [ 1856 | { 1857 | "key": "Content-Type", 1858 | "value": "application/json" 1859 | }, 1860 | { 1861 | "key": "X-Requested-With", 1862 | "value": "XMLHttpRequest" 1863 | } 1864 | ], 1865 | "body": { 1866 | "mode": "raw", 1867 | "raw": "{\"user\":{\"email\":\"celeb_{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"celeb_{{USERNAME}}\"}}" 1868 | }, 1869 | "url": { 1870 | "raw": "{{APIURL}}/users", 1871 | "host": [ 1872 | "{{APIURL}}" 1873 | ], 1874 | "path": [ 1875 | "users" 1876 | ] 1877 | } 1878 | }, 1879 | "response": [] 1880 | }, 1881 | { 1882 | "name": "Profile", 1883 | "event": [ 1884 | { 1885 | "listen": "test", 1886 | "script": { 1887 | "type": "text/javascript", 1888 | "exec": [ 1889 | "if (!(environment.isIntegrationTest)) {", 1890 | "var is200Response = responseCode.code === 200;", 1891 | "", 1892 | "tests['Response code is 200 OK'] = is200Response;", 1893 | "", 1894 | "if(is200Response){", 1895 | " var responseJSON = JSON.parse(responseBody);", 1896 | "", 1897 | " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", 1898 | " ", 1899 | " var profile = responseJSON.profile || {};", 1900 | " ", 1901 | " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", 1902 | " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');", 1903 | " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", 1904 | " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", 1905 | "}", 1906 | "}", 1907 | "" 1908 | ] 1909 | } 1910 | } 1911 | ], 1912 | "request": { 1913 | "method": "GET", 1914 | "header": [ 1915 | { 1916 | "key": "Content-Type", 1917 | "value": "application/json" 1918 | }, 1919 | { 1920 | "key": "X-Requested-With", 1921 | "value": "XMLHttpRequest" 1922 | }, 1923 | { 1924 | "key": "Authorization", 1925 | "value": "Token {{token}}" 1926 | } 1927 | ], 1928 | "body": { 1929 | "mode": "raw", 1930 | "raw": "" 1931 | }, 1932 | "url": { 1933 | "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}", 1934 | "host": [ 1935 | "{{APIURL}}" 1936 | ], 1937 | "path": [ 1938 | "profiles", 1939 | "celeb_{{USERNAME}}" 1940 | ] 1941 | } 1942 | }, 1943 | "response": [] 1944 | }, 1945 | { 1946 | "name": "Follow Profile", 1947 | "event": [ 1948 | { 1949 | "listen": "test", 1950 | "script": { 1951 | "type": "text/javascript", 1952 | "exec": [ 1953 | "if (!(environment.isIntegrationTest)) {", 1954 | "var is200Response = responseCode.code === 200;", 1955 | "", 1956 | "tests['Response code is 200 OK'] = is200Response;", 1957 | "", 1958 | "if(is200Response){", 1959 | " var responseJSON = JSON.parse(responseBody);", 1960 | "", 1961 | " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", 1962 | " ", 1963 | " var profile = responseJSON.profile || {};", 1964 | " ", 1965 | " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", 1966 | " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');", 1967 | " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", 1968 | " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", 1969 | " tests['Profile\\'s \"following\" property is true'] = profile.following === true;", 1970 | "}", 1971 | "}", 1972 | "" 1973 | ] 1974 | } 1975 | } 1976 | ], 1977 | "request": { 1978 | "method": "POST", 1979 | "header": [ 1980 | { 1981 | "key": "Content-Type", 1982 | "value": "application/json" 1983 | }, 1984 | { 1985 | "key": "X-Requested-With", 1986 | "value": "XMLHttpRequest" 1987 | }, 1988 | { 1989 | "key": "Authorization", 1990 | "value": "Token {{token}}" 1991 | } 1992 | ], 1993 | "body": { 1994 | "mode": "raw", 1995 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\"}}" 1996 | }, 1997 | "url": { 1998 | "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}/follow", 1999 | "host": [ 2000 | "{{APIURL}}" 2001 | ], 2002 | "path": [ 2003 | "profiles", 2004 | "celeb_{{USERNAME}}", 2005 | "follow" 2006 | ] 2007 | } 2008 | }, 2009 | "response": [] 2010 | }, 2011 | { 2012 | "name": "Unfollow Profile", 2013 | "event": [ 2014 | { 2015 | "listen": "test", 2016 | "script": { 2017 | "type": "text/javascript", 2018 | "exec": [ 2019 | "if (!(environment.isIntegrationTest)) {", 2020 | "var is200Response = responseCode.code === 200;", 2021 | "", 2022 | "tests['Response code is 200 OK'] = is200Response;", 2023 | "", 2024 | "if(is200Response){", 2025 | " var responseJSON = JSON.parse(responseBody);", 2026 | "", 2027 | " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", 2028 | " ", 2029 | " var profile = responseJSON.profile || {};", 2030 | " ", 2031 | " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", 2032 | " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');", 2033 | " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", 2034 | " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", 2035 | " tests['Profile\\'s \"following\" property is false'] = profile.following === false;", 2036 | "}", 2037 | "}", 2038 | "" 2039 | ] 2040 | } 2041 | } 2042 | ], 2043 | "request": { 2044 | "method": "DELETE", 2045 | "header": [ 2046 | { 2047 | "key": "Content-Type", 2048 | "value": "application/json" 2049 | }, 2050 | { 2051 | "key": "X-Requested-With", 2052 | "value": "XMLHttpRequest" 2053 | }, 2054 | { 2055 | "key": "Authorization", 2056 | "value": "Token {{token}}" 2057 | } 2058 | ], 2059 | "body": { 2060 | "mode": "raw", 2061 | "raw": "" 2062 | }, 2063 | "url": { 2064 | "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}/follow", 2065 | "host": [ 2066 | "{{APIURL}}" 2067 | ], 2068 | "path": [ 2069 | "profiles", 2070 | "celeb_{{USERNAME}}", 2071 | "follow" 2072 | ] 2073 | } 2074 | }, 2075 | "response": [] 2076 | } 2077 | ] 2078 | }, 2079 | { 2080 | "name": "Tags", 2081 | "item": [ 2082 | { 2083 | "name": "All Tags", 2084 | "event": [ 2085 | { 2086 | "listen": "test", 2087 | "script": { 2088 | "type": "text/javascript", 2089 | "exec": [ 2090 | "var is200Response = responseCode.code === 200;", 2091 | "", 2092 | "tests['Response code is 200 OK'] = is200Response;", 2093 | "", 2094 | "if(is200Response){", 2095 | " var responseJSON = JSON.parse(responseBody);", 2096 | " ", 2097 | " tests['Response contains \"tags\" property'] = responseJSON.hasOwnProperty('tags');", 2098 | " tests['\"tags\" property returned as array'] = Array.isArray(responseJSON.tags);", 2099 | "}", 2100 | "" 2101 | ] 2102 | } 2103 | } 2104 | ], 2105 | "request": { 2106 | "method": "GET", 2107 | "header": [ 2108 | { 2109 | "key": "Content-Type", 2110 | "value": "application/json" 2111 | }, 2112 | { 2113 | "key": "X-Requested-With", 2114 | "value": "XMLHttpRequest" 2115 | } 2116 | ], 2117 | "body": { 2118 | "mode": "raw", 2119 | "raw": "" 2120 | }, 2121 | "url": { 2122 | "raw": "{{APIURL}}/tags", 2123 | "host": [ 2124 | "{{APIURL}}" 2125 | ], 2126 | "path": [ 2127 | "tags" 2128 | ] 2129 | } 2130 | }, 2131 | "response": [] 2132 | } 2133 | ] 2134 | } 2135 | ] 2136 | } 2137 | -------------------------------------------------------------------------------- /postman_tests/run-api-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | 4 | SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 5 | 6 | APIURL=${APIURL:-https://conduit.productionready.io/api} 7 | USERNAME=${USERNAME:-u`date +%s`} 8 | EMAIL=${EMAIL:-$USERNAME@mail.com} 9 | PASSWORD=${PASSWORD:-password} 10 | 11 | npx newman run $SCRIPTDIR/Conduit.postman_collection.json \ 12 | --delay-request 500 \ 13 | --global-var "APIURL=$APIURL" \ 14 | --global-var "USERNAME=$USERNAME" \ 15 | --global-var "EMAIL=$EMAIL" \ 16 | --global-var "PASSWORD=$PASSWORD" 17 | -------------------------------------------------------------------------------- /postman_tests/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "Conduit API", 5 | "version": "1.0.0", 6 | "title": "Conduit API", 7 | "contact": { 8 | "name": "RealWorld", 9 | "url": "https://realworld.io" 10 | }, 11 | "license": { 12 | "name": "MIT License", 13 | "url": "https://opensource.org/licenses/MIT" 14 | } 15 | }, 16 | "basePath": "/api", 17 | "schemes": [ 18 | "https", 19 | "http" 20 | ], 21 | "produces": [ 22 | "application/json" 23 | ], 24 | "consumes": [ 25 | "application/json" 26 | ], 27 | "securityDefinitions": { 28 | "Token": { 29 | "description": "For accessing the protected API resources, you must have received a a valid JWT token after registering or logging in. This JWT token must then be used for all protected resources by passing it in via the 'Authorization' header.\n\nA JWT token is generated by the API by either registering via /users or logging in via /users/login.\n\nThe following format must be in the 'Authorization' header :\n\n Token: xxxxxx.yyyyyyy.zzzzzz\n \n", 30 | "type": "apiKey", 31 | "name": "Authorization", 32 | "in": "header" 33 | } 34 | }, 35 | "paths": { 36 | "/users/login": { 37 | "post": { 38 | "summary": "Existing user login", 39 | "description": "Login for existing user", 40 | "tags": [ 41 | "User and Authentication" 42 | ], 43 | "operationId": "Login", 44 | "parameters": [ 45 | { 46 | "name": "body", 47 | "in": "body", 48 | "required": true, 49 | "description": "Credentials to use", 50 | "schema": { 51 | "$ref": "#/definitions/LoginUserRequest" 52 | } 53 | } 54 | ], 55 | "responses": { 56 | "200": { 57 | "description": "OK", 58 | "schema": { 59 | "$ref": "#/definitions/UserResponse" 60 | } 61 | }, 62 | "401": { 63 | "description": "Unauthorized" 64 | }, 65 | "422": { 66 | "description": "Unexpected error", 67 | "schema": { 68 | "$ref": "#/definitions/GenericErrorModel" 69 | } 70 | } 71 | } 72 | } 73 | }, 74 | "/users": { 75 | "post": { 76 | "summary": "Register a new user", 77 | "description": "Register a new user", 78 | "tags": [ 79 | "User and Authentication" 80 | ], 81 | "operationId": "CreateUser", 82 | "parameters": [ 83 | { 84 | "name": "body", 85 | "in": "body", 86 | "required": true, 87 | "description": "Details of the new user to register", 88 | "schema": { 89 | "$ref": "#/definitions/NewUserRequest" 90 | } 91 | } 92 | ], 93 | "responses": { 94 | "201": { 95 | "description": "OK", 96 | "schema": { 97 | "$ref": "#/definitions/UserResponse" 98 | } 99 | }, 100 | "422": { 101 | "description": "Unexpected error", 102 | "schema": { 103 | "$ref": "#/definitions/GenericErrorModel" 104 | } 105 | } 106 | } 107 | }, 108 | "get": { 109 | "summary": "Get current user", 110 | "description": "Gets the currently logged-in user", 111 | "tags": [ 112 | "User and Authentication" 113 | ], 114 | "security": [ 115 | { 116 | "Token": [] 117 | } 118 | ], 119 | "operationId": "GetCurrentUser", 120 | "responses": { 121 | "200": { 122 | "description": "OK", 123 | "schema": { 124 | "$ref": "#/definitions/UserResponse" 125 | } 126 | }, 127 | "401": { 128 | "description": "Unauthorized" 129 | }, 130 | "422": { 131 | "description": "Unexpected error", 132 | "schema": { 133 | "$ref": "#/definitions/GenericErrorModel" 134 | } 135 | } 136 | } 137 | }, 138 | "put": { 139 | "summary": "Update current user", 140 | "description": "Updated user information for current user", 141 | "tags": [ 142 | "User and Authentication" 143 | ], 144 | "security": [ 145 | { 146 | "Token": [] 147 | } 148 | ], 149 | "operationId": "UpdateCurrentUser", 150 | "parameters": [ 151 | { 152 | "name": "body", 153 | "in": "body", 154 | "required": true, 155 | "description": "User details to update. At least **one** field is required.", 156 | "schema": { 157 | "$ref": "#/definitions/UpdateUserRequest" 158 | } 159 | } 160 | ], 161 | "responses": { 162 | "200": { 163 | "description": "OK", 164 | "schema": { 165 | "$ref": "#/definitions/UserResponse" 166 | } 167 | }, 168 | "401": { 169 | "description": "Unauthorized" 170 | }, 171 | "422": { 172 | "description": "Unexpected error", 173 | "schema": { 174 | "$ref": "#/definitions/GenericErrorModel" 175 | } 176 | } 177 | } 178 | } 179 | }, 180 | "/profiles/{username}": { 181 | "get": { 182 | "summary": "Get a profile", 183 | "description": "Get a profile of a user of the system. Auth is optional", 184 | "tags": [ 185 | "Profile" 186 | ], 187 | "operationId": "GetProfileByUsername", 188 | "parameters": [ 189 | { 190 | "name": "username", 191 | "in": "path", 192 | "description": "Username of the profile to get", 193 | "required": true, 194 | "type": "string" 195 | } 196 | ], 197 | "responses": { 198 | "200": { 199 | "description": "OK", 200 | "schema": { 201 | "$ref": "#/definitions/ProfileResponse" 202 | } 203 | }, 204 | "401": { 205 | "description": "Unauthorized" 206 | }, 207 | "422": { 208 | "description": "Unexpected error", 209 | "schema": { 210 | "$ref": "#/definitions/GenericErrorModel" 211 | } 212 | } 213 | } 214 | } 215 | }, 216 | "/profiles/{username}/follow": { 217 | "post": { 218 | "summary": "Follow a user", 219 | "description": "Follow a user by username", 220 | "tags": [ 221 | "Profile" 222 | ], 223 | "security": [ 224 | { 225 | "Token": [] 226 | } 227 | ], 228 | "operationId": "FollowUserByUsername", 229 | "parameters": [ 230 | { 231 | "name": "username", 232 | "in": "path", 233 | "description": "Username of the profile you want to follow", 234 | "required": true, 235 | "type": "string" 236 | } 237 | ], 238 | "responses": { 239 | "200": { 240 | "description": "OK", 241 | "schema": { 242 | "$ref": "#/definitions/ProfileResponse" 243 | } 244 | }, 245 | "401": { 246 | "description": "Unauthorized" 247 | }, 248 | "422": { 249 | "description": "Unexpected error", 250 | "schema": { 251 | "$ref": "#/definitions/GenericErrorModel" 252 | } 253 | } 254 | } 255 | }, 256 | "delete": { 257 | "summary": "Unfollow a user", 258 | "description": "Unfollow a user by username", 259 | "tags": [ 260 | "Profile" 261 | ], 262 | "security": [ 263 | { 264 | "Token": [] 265 | } 266 | ], 267 | "operationId": "UnfollowUserByUsername", 268 | "parameters": [ 269 | { 270 | "name": "username", 271 | "in": "path", 272 | "description": "Username of the profile you want to unfollow", 273 | "required": true, 274 | "type": "string" 275 | } 276 | ], 277 | "responses": { 278 | "200": { 279 | "description": "OK", 280 | "schema": { 281 | "$ref": "#/definitions/ProfileResponse" 282 | } 283 | }, 284 | "401": { 285 | "description": "Unauthorized" 286 | }, 287 | "422": { 288 | "description": "Unexpected error", 289 | "schema": { 290 | "$ref": "#/definitions/GenericErrorModel" 291 | } 292 | } 293 | } 294 | } 295 | }, 296 | "/articles/feed": { 297 | "get": { 298 | "summary": "Get recent articles from users you follow", 299 | "description": "Get most recent articles from users you follow. Use query parameters to limit. Auth is required", 300 | "tags": [ 301 | "Articles" 302 | ], 303 | "security": [ 304 | { 305 | "Token": [] 306 | } 307 | ], 308 | "operationId": "GetArticlesFeed", 309 | "parameters": [ 310 | { 311 | "name": "limit", 312 | "in": "query", 313 | "description": "Limit number of articles returned (default is 20)", 314 | "required": false, 315 | "default": 20, 316 | "type": "integer" 317 | }, 318 | { 319 | "name": "offset", 320 | "in": "query", 321 | "description": "Offset/skip number of articles (default is 0)", 322 | "required": false, 323 | "default": 0, 324 | "type": "integer" 325 | } 326 | ], 327 | "responses": { 328 | "200": { 329 | "description": "OK", 330 | "schema": { 331 | "$ref": "#/definitions/MultipleArticlesResponse" 332 | } 333 | }, 334 | "401": { 335 | "description": "Unauthorized" 336 | }, 337 | "422": { 338 | "description": "Unexpected error", 339 | "schema": { 340 | "$ref": "#/definitions/GenericErrorModel" 341 | } 342 | } 343 | } 344 | } 345 | }, 346 | "/articles": { 347 | "get": { 348 | "summary": "Get recent articles globally", 349 | "description": "Get most recent articles globally. Use query parameters to filter results. Auth is optional", 350 | "tags": [ 351 | "Articles" 352 | ], 353 | "operationId": "GetArticles", 354 | "parameters": [ 355 | { 356 | "name": "tag", 357 | "in": "query", 358 | "description": "Filter by tag", 359 | "required": false, 360 | "type": "string" 361 | }, 362 | { 363 | "name": "author", 364 | "in": "query", 365 | "description": "Filter by author (username)", 366 | "required": false, 367 | "type": "string" 368 | }, 369 | { 370 | "name": "favorited", 371 | "in": "query", 372 | "description": "Filter by favorites of a user (username)", 373 | "required": false, 374 | "type": "string" 375 | }, 376 | { 377 | "name": "limit", 378 | "in": "query", 379 | "description": "Limit number of articles returned (default is 20)", 380 | "required": false, 381 | "default": 20, 382 | "type": "integer" 383 | }, 384 | { 385 | "name": "offset", 386 | "in": "query", 387 | "description": "Offset/skip number of articles (default is 0)", 388 | "required": false, 389 | "default": 0, 390 | "type": "integer" 391 | } 392 | ], 393 | "responses": { 394 | "200": { 395 | "description": "OK", 396 | "schema": { 397 | "$ref": "#/definitions/MultipleArticlesResponse" 398 | } 399 | }, 400 | "401": { 401 | "description": "Unauthorized" 402 | }, 403 | "422": { 404 | "description": "Unexpected error", 405 | "schema": { 406 | "$ref": "#/definitions/GenericErrorModel" 407 | } 408 | } 409 | } 410 | }, 411 | "post": { 412 | "summary": "Create an article", 413 | "description": "Create an article. Auth is required", 414 | "tags": [ 415 | "Articles" 416 | ], 417 | "security": [ 418 | { 419 | "Token": [] 420 | } 421 | ], 422 | "operationId": "CreateArticle", 423 | "parameters": [ 424 | { 425 | "name": "article", 426 | "in": "body", 427 | "required": true, 428 | "description": "Article to create", 429 | "schema": { 430 | "$ref": "#/definitions/NewArticleRequest" 431 | } 432 | } 433 | ], 434 | "responses": { 435 | "201": { 436 | "description": "OK", 437 | "schema": { 438 | "$ref": "#/definitions/SingleArticleResponse" 439 | } 440 | }, 441 | "401": { 442 | "description": "Unauthorized" 443 | }, 444 | "422": { 445 | "description": "Unexpected error", 446 | "schema": { 447 | "$ref": "#/definitions/GenericErrorModel" 448 | } 449 | } 450 | } 451 | } 452 | }, 453 | "/articles/{slug}": { 454 | "get": { 455 | "summary": "Get an article", 456 | "description": "Get an article. Auth not required", 457 | "tags": [ 458 | "Articles" 459 | ], 460 | "operationId": "GetArticle", 461 | "parameters": [ 462 | { 463 | "name": "slug", 464 | "in": "path", 465 | "required": true, 466 | "description": "Slug of the article to get", 467 | "type": "string" 468 | } 469 | ], 470 | "responses": { 471 | "200": { 472 | "description": "OK", 473 | "schema": { 474 | "$ref": "#/definitions/SingleArticleResponse" 475 | } 476 | }, 477 | "422": { 478 | "description": "Unexpected error", 479 | "schema": { 480 | "$ref": "#/definitions/GenericErrorModel" 481 | } 482 | } 483 | } 484 | }, 485 | "put": { 486 | "summary": "Update an article", 487 | "description": "Update an article. Auth is required", 488 | "tags": [ 489 | "Articles" 490 | ], 491 | "security": [ 492 | { 493 | "Token": [] 494 | } 495 | ], 496 | "operationId": "UpdateArticle", 497 | "parameters": [ 498 | { 499 | "name": "slug", 500 | "in": "path", 501 | "required": true, 502 | "description": "Slug of the article to update", 503 | "type": "string" 504 | }, 505 | { 506 | "name": "article", 507 | "in": "body", 508 | "required": true, 509 | "description": "Article to update", 510 | "schema": { 511 | "$ref": "#/definitions/UpdateArticleRequest" 512 | } 513 | } 514 | ], 515 | "responses": { 516 | "200": { 517 | "description": "OK", 518 | "schema": { 519 | "$ref": "#/definitions/SingleArticleResponse" 520 | } 521 | }, 522 | "401": { 523 | "description": "Unauthorized" 524 | }, 525 | "422": { 526 | "description": "Unexpected error", 527 | "schema": { 528 | "$ref": "#/definitions/GenericErrorModel" 529 | } 530 | } 531 | } 532 | }, 533 | "delete": { 534 | "summary": "Delete an article", 535 | "description": "Delete an article. Auth is required", 536 | "tags": [ 537 | "Articles" 538 | ], 539 | "security": [ 540 | { 541 | "Token": [] 542 | } 543 | ], 544 | "operationId": "DeleteArticle", 545 | "parameters": [ 546 | { 547 | "name": "slug", 548 | "in": "path", 549 | "required": true, 550 | "description": "Slug of the article to delete", 551 | "type": "string" 552 | } 553 | ], 554 | "responses": { 555 | "200": { 556 | "description": "OK" 557 | }, 558 | "401": { 559 | "description": "Unauthorized" 560 | }, 561 | "422": { 562 | "description": "Unexpected error", 563 | "schema": { 564 | "$ref": "#/definitions/GenericErrorModel" 565 | } 566 | } 567 | } 568 | } 569 | }, 570 | "/articles/{slug}/comments": { 571 | "get": { 572 | "summary": "Get comments for an article", 573 | "description": "Get the comments for an article. Auth is optional", 574 | "tags": [ 575 | "Comments" 576 | ], 577 | "operationId": "GetArticleComments", 578 | "parameters": [ 579 | { 580 | "name": "slug", 581 | "in": "path", 582 | "required": true, 583 | "description": "Slug of the article that you want to get comments for", 584 | "type": "string" 585 | } 586 | ], 587 | "responses": { 588 | "200": { 589 | "description": "OK", 590 | "schema": { 591 | "$ref": "#/definitions/MultipleCommentsResponse" 592 | } 593 | }, 594 | "401": { 595 | "description": "Unauthorized" 596 | }, 597 | "422": { 598 | "description": "Unexpected error", 599 | "schema": { 600 | "$ref": "#/definitions/GenericErrorModel" 601 | } 602 | } 603 | } 604 | }, 605 | "post": { 606 | "summary": "Create a comment for an article", 607 | "description": "Create a comment for an article. Auth is required", 608 | "tags": [ 609 | "Comments" 610 | ], 611 | "security": [ 612 | { 613 | "Token": [] 614 | } 615 | ], 616 | "operationId": "CreateArticleComment", 617 | "parameters": [ 618 | { 619 | "name": "slug", 620 | "in": "path", 621 | "required": true, 622 | "description": "Slug of the article that you want to create a comment for", 623 | "type": "string" 624 | }, 625 | { 626 | "name": "comment", 627 | "in": "body", 628 | "required": true, 629 | "description": "Comment you want to create", 630 | "schema": { 631 | "$ref": "#/definitions/NewCommentRequest" 632 | } 633 | } 634 | ], 635 | "responses": { 636 | "200": { 637 | "description": "OK", 638 | "schema": { 639 | "$ref": "#/definitions/SingleCommentResponse" 640 | } 641 | }, 642 | "401": { 643 | "description": "Unauthorized" 644 | }, 645 | "422": { 646 | "description": "Unexpected error", 647 | "schema": { 648 | "$ref": "#/definitions/GenericErrorModel" 649 | } 650 | } 651 | } 652 | } 653 | }, 654 | "/articles/{slug}/comments/{id}": { 655 | "delete": { 656 | "summary": "Delete a comment for an article", 657 | "description": "Delete a comment for an article. Auth is required", 658 | "tags": [ 659 | "Comments" 660 | ], 661 | "security": [ 662 | { 663 | "Token": [] 664 | } 665 | ], 666 | "operationId": "DeleteArticleComment", 667 | "parameters": [ 668 | { 669 | "name": "slug", 670 | "in": "path", 671 | "required": true, 672 | "description": "Slug of the article that you want to delete a comment for", 673 | "type": "string" 674 | }, 675 | { 676 | "name": "id", 677 | "in": "path", 678 | "required": true, 679 | "description": "ID of the comment you want to delete", 680 | "type": "integer" 681 | } 682 | ], 683 | "responses": { 684 | "200": { 685 | "description": "OK" 686 | }, 687 | "401": { 688 | "description": "Unauthorized" 689 | }, 690 | "422": { 691 | "description": "Unexpected error", 692 | "schema": { 693 | "$ref": "#/definitions/GenericErrorModel" 694 | } 695 | } 696 | } 697 | } 698 | }, 699 | "/articles/{slug}/favorite": { 700 | "post": { 701 | "summary": "Favorite an article", 702 | "description": "Favorite an article. Auth is required", 703 | "tags": [ 704 | "Favorites" 705 | ], 706 | "security": [ 707 | { 708 | "Token": [] 709 | } 710 | ], 711 | "operationId": "CreateArticleFavorite", 712 | "parameters": [ 713 | { 714 | "name": "slug", 715 | "in": "path", 716 | "required": true, 717 | "description": "Slug of the article that you want to favorite", 718 | "type": "string" 719 | } 720 | ], 721 | "responses": { 722 | "200": { 723 | "description": "OK", 724 | "schema": { 725 | "$ref": "#/definitions/SingleArticleResponse" 726 | } 727 | }, 728 | "401": { 729 | "description": "Unauthorized" 730 | }, 731 | "422": { 732 | "description": "Unexpected error", 733 | "schema": { 734 | "$ref": "#/definitions/GenericErrorModel" 735 | } 736 | } 737 | } 738 | }, 739 | "delete": { 740 | "summary": "Unfavorite an article", 741 | "description": "Unfavorite an article. Auth is required", 742 | "tags": [ 743 | "Favorites" 744 | ], 745 | "security": [ 746 | { 747 | "Token": [] 748 | } 749 | ], 750 | "operationId": "DeleteArticleFavorite", 751 | "parameters": [ 752 | { 753 | "name": "slug", 754 | "in": "path", 755 | "required": true, 756 | "description": "Slug of the article that you want to unfavorite", 757 | "type": "string" 758 | } 759 | ], 760 | "responses": { 761 | "200": { 762 | "description": "OK", 763 | "schema": { 764 | "$ref": "#/definitions/SingleArticleResponse" 765 | } 766 | }, 767 | "401": { 768 | "description": "Unauthorized" 769 | }, 770 | "422": { 771 | "description": "Unexpected error", 772 | "schema": { 773 | "$ref": "#/definitions/GenericErrorModel" 774 | } 775 | } 776 | } 777 | } 778 | }, 779 | "/tags": { 780 | "get": { 781 | "summary": "Get tags", 782 | "description": "Get tags. Auth not required", 783 | "responses": { 784 | "200": { 785 | "description": "OK", 786 | "schema": { 787 | "$ref": "#/definitions/TagsResponse" 788 | } 789 | }, 790 | "422": { 791 | "description": "Unexpected error", 792 | "schema": { 793 | "$ref": "#/definitions/GenericErrorModel" 794 | } 795 | } 796 | } 797 | } 798 | } 799 | }, 800 | "definitions": { 801 | "LoginUser": { 802 | "type": "object", 803 | "properties": { 804 | "email": { 805 | "type": "string" 806 | }, 807 | "password": { 808 | "type": "string", 809 | "format": "password" 810 | } 811 | }, 812 | "required": [ 813 | "email", 814 | "password" 815 | ] 816 | }, 817 | "LoginUserRequest": { 818 | "type": "object", 819 | "properties": { 820 | "user": { 821 | "$ref": "#/definitions/LoginUser" 822 | } 823 | }, 824 | "required": [ 825 | "user" 826 | ] 827 | }, 828 | "NewUser": { 829 | "type": "object", 830 | "properties": { 831 | "username": { 832 | "type": "string" 833 | }, 834 | "email": { 835 | "type": "string" 836 | }, 837 | "password": { 838 | "type": "string", 839 | "format": "password" 840 | } 841 | }, 842 | "required": [ 843 | "username", 844 | "email", 845 | "password" 846 | ] 847 | }, 848 | "NewUserRequest": { 849 | "type": "object", 850 | "properties": { 851 | "user": { 852 | "$ref": "#/definitions/NewUser" 853 | } 854 | }, 855 | "required": [ 856 | "user" 857 | ] 858 | }, 859 | "User": { 860 | "type": "object", 861 | "properties": { 862 | "email": { 863 | "type": "string" 864 | }, 865 | "token": { 866 | "type": "string" 867 | }, 868 | "username": { 869 | "type": "string" 870 | }, 871 | "bio": { 872 | "type": "string" 873 | }, 874 | "image": { 875 | "type": "string" 876 | } 877 | }, 878 | "required": [ 879 | "email", 880 | "token", 881 | "username", 882 | "bio", 883 | "image" 884 | ] 885 | }, 886 | "UserResponse": { 887 | "type": "object", 888 | "properties": { 889 | "user": { 890 | "$ref": "#/definitions/User" 891 | } 892 | }, 893 | "required": [ 894 | "user" 895 | ] 896 | }, 897 | "UpdateUser": { 898 | "type": "object", 899 | "properties": { 900 | "email": { 901 | "type": "string" 902 | }, 903 | "token": { 904 | "type": "string" 905 | }, 906 | "username": { 907 | "type": "string" 908 | }, 909 | "bio": { 910 | "type": "string" 911 | }, 912 | "image": { 913 | "type": "string" 914 | } 915 | } 916 | }, 917 | "UpdateUserRequest": { 918 | "type": "object", 919 | "properties": { 920 | "user": { 921 | "$ref": "#/definitions/UpdateUser" 922 | } 923 | }, 924 | "required": [ 925 | "user" 926 | ] 927 | }, 928 | "ProfileResponse": { 929 | "type": "object", 930 | "properties": { 931 | "profile": { 932 | "$ref": "#/definitions/Profile" 933 | } 934 | }, 935 | "required": [ 936 | "profile" 937 | ] 938 | }, 939 | "Profile": { 940 | "type": "object", 941 | "properties": { 942 | "username": { 943 | "type": "string" 944 | }, 945 | "bio": { 946 | "type": "string" 947 | }, 948 | "image": { 949 | "type": "string" 950 | }, 951 | "following": { 952 | "type": "boolean" 953 | } 954 | }, 955 | "required": [ 956 | "username", 957 | "bio", 958 | "image", 959 | "following" 960 | ] 961 | }, 962 | "Article": { 963 | "type": "object", 964 | "properties": { 965 | "slug": { 966 | "type": "string" 967 | }, 968 | "title": { 969 | "type": "string" 970 | }, 971 | "description": { 972 | "type": "string" 973 | }, 974 | "body": { 975 | "type": "string" 976 | }, 977 | "tagList": { 978 | "type": "array", 979 | "items": { 980 | "type": "string" 981 | } 982 | }, 983 | "createdAt": { 984 | "type": "string", 985 | "format": "date-time" 986 | }, 987 | "updatedAt": { 988 | "type": "string", 989 | "format": "date-time" 990 | }, 991 | "favorited": { 992 | "type": "boolean" 993 | }, 994 | "favoritesCount": { 995 | "type": "integer" 996 | }, 997 | "author": { 998 | "$ref": "#/definitions/Profile" 999 | } 1000 | }, 1001 | "required": [ 1002 | "slug", 1003 | "title", 1004 | "description", 1005 | "body", 1006 | "tagList", 1007 | "createdAt", 1008 | "updatedAt", 1009 | "favorited", 1010 | "favoritesCount", 1011 | "author" 1012 | ] 1013 | }, 1014 | "SingleArticleResponse": { 1015 | "type": "object", 1016 | "properties": { 1017 | "article": { 1018 | "$ref": "#/definitions/Article" 1019 | } 1020 | }, 1021 | "required": [ 1022 | "article" 1023 | ] 1024 | }, 1025 | "MultipleArticlesResponse": { 1026 | "type": "object", 1027 | "properties": { 1028 | "articles": { 1029 | "type": "array", 1030 | "items": { 1031 | "$ref": "#/definitions/Article" 1032 | } 1033 | }, 1034 | "articlesCount": { 1035 | "type": "integer" 1036 | } 1037 | }, 1038 | "required": [ 1039 | "articles", 1040 | "articlesCount" 1041 | ] 1042 | }, 1043 | "NewArticle": { 1044 | "type": "object", 1045 | "properties": { 1046 | "title": { 1047 | "type": "string" 1048 | }, 1049 | "description": { 1050 | "type": "string" 1051 | }, 1052 | "body": { 1053 | "type": "string" 1054 | }, 1055 | "tagList": { 1056 | "type": "array", 1057 | "items": { 1058 | "type": "string" 1059 | } 1060 | } 1061 | }, 1062 | "required": [ 1063 | "title", 1064 | "description", 1065 | "body" 1066 | ] 1067 | }, 1068 | "NewArticleRequest": { 1069 | "type": "object", 1070 | "properties": { 1071 | "article": { 1072 | "$ref": "#/definitions/NewArticle" 1073 | } 1074 | }, 1075 | "required": [ 1076 | "article" 1077 | ] 1078 | }, 1079 | "UpdateArticle": { 1080 | "type": "object", 1081 | "properties": { 1082 | "title": { 1083 | "type": "string" 1084 | }, 1085 | "description": { 1086 | "type": "string" 1087 | }, 1088 | "body": { 1089 | "type": "string" 1090 | } 1091 | } 1092 | }, 1093 | "UpdateArticleRequest": { 1094 | "type": "object", 1095 | "properties": { 1096 | "article": { 1097 | "$ref": "#/definitions/UpdateArticle" 1098 | } 1099 | }, 1100 | "required": [ 1101 | "article" 1102 | ] 1103 | }, 1104 | "Comment": { 1105 | "type": "object", 1106 | "properties": { 1107 | "id": { 1108 | "type": "integer" 1109 | }, 1110 | "createdAt": { 1111 | "type": "string", 1112 | "format": "date-time" 1113 | }, 1114 | "updatedAt": { 1115 | "type": "string", 1116 | "format": "date-time" 1117 | }, 1118 | "body": { 1119 | "type": "string" 1120 | }, 1121 | "author": { 1122 | "$ref": "#/definitions/Profile" 1123 | } 1124 | }, 1125 | "required": [ 1126 | "id", 1127 | "createdAt", 1128 | "updatedAt", 1129 | "body", 1130 | "author" 1131 | ] 1132 | }, 1133 | "SingleCommentResponse": { 1134 | "type": "object", 1135 | "properties": { 1136 | "comment": { 1137 | "$ref": "#/definitions/Comment" 1138 | } 1139 | }, 1140 | "required": [ 1141 | "comment" 1142 | ] 1143 | }, 1144 | "MultipleCommentsResponse": { 1145 | "type": "object", 1146 | "properties": { 1147 | "comments": { 1148 | "type": "array", 1149 | "items": { 1150 | "$ref": "#/definitions/Comment" 1151 | } 1152 | } 1153 | }, 1154 | "required": [ 1155 | "comments" 1156 | ] 1157 | }, 1158 | "NewComment": { 1159 | "type": "object", 1160 | "properties": { 1161 | "body": { 1162 | "type": "string" 1163 | } 1164 | }, 1165 | "required": [ 1166 | "body" 1167 | ] 1168 | }, 1169 | "NewCommentRequest": { 1170 | "type": "object", 1171 | "properties": { 1172 | "comment": { 1173 | "$ref": "#/definitions/NewComment" 1174 | } 1175 | }, 1176 | "required": [ 1177 | "comment" 1178 | ] 1179 | }, 1180 | "TagsResponse": { 1181 | "type": "object", 1182 | "properties": { 1183 | "tags": { 1184 | "type": "array", 1185 | "items": { 1186 | "type": "string" 1187 | } 1188 | } 1189 | }, 1190 | "required": [ 1191 | "tags" 1192 | ] 1193 | }, 1194 | "GenericErrorModel": { 1195 | "type": "object", 1196 | "properties": { 1197 | "errors": { 1198 | "type": "object", 1199 | "properties": { 1200 | "body": { 1201 | "type": "array", 1202 | "items": { 1203 | "type": "string" 1204 | } 1205 | } 1206 | }, 1207 | "required": [ 1208 | "body" 1209 | ] 1210 | } 1211 | }, 1212 | "required": [ 1213 | "errors" 1214 | ] 1215 | } 1216 | } 1217 | } -------------------------------------------------------------------------------- /resources/application.conf: -------------------------------------------------------------------------------- 1 | ktor { 2 | deployment { 3 | port = 8080 4 | port = ${?PORT} 5 | } 6 | application { 7 | modules = [com.nooblabs.ApplicationKt.module] 8 | } 9 | } 10 | 11 | jwt { 12 | secret = "my-secret" 13 | } -------------------------------------------------------------------------------- /resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = "realworld" 2 | -------------------------------------------------------------------------------- /src/main/kotlin/Application.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs 2 | 3 | import com.fasterxml.jackson.databind.SerializationFeature 4 | import com.nooblabs.service.IDatabaseFactory 5 | import com.nooblabs.util.SimpleJWT 6 | import config.api 7 | import config.cors 8 | import config.jwtConfig 9 | import config.statusPages 10 | import io.ktor.serialization.jackson.* 11 | import io.ktor.server.application.* 12 | import io.ktor.server.auth.* 13 | import io.ktor.server.plugins.calllogging.* 14 | import io.ktor.server.plugins.contentnegotiation.* 15 | import io.ktor.server.plugins.cors.routing.* 16 | import io.ktor.server.plugins.defaultheaders.* 17 | import io.ktor.server.plugins.statuspages.* 18 | import io.ktor.server.routing.* 19 | import org.koin.ktor.ext.inject 20 | import org.koin.ktor.plugin.Koin 21 | import org.slf4j.event.Level 22 | 23 | fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) 24 | 25 | fun Application.module() { 26 | 27 | install(DefaultHeaders) 28 | install(CORS) { 29 | cors() 30 | } 31 | 32 | install(CallLogging) { 33 | level = Level.INFO 34 | } 35 | 36 | val simpleJWT = SimpleJWT(secret = environment.config.property("jwt.secret").getString()) 37 | 38 | install(Authentication) { 39 | jwtConfig(simpleJWT) 40 | } 41 | 42 | install(ContentNegotiation) { 43 | jackson { 44 | enable(SerializationFeature.INDENT_OUTPUT) 45 | } 46 | } 47 | 48 | install(Koin) { 49 | modules(serviceKoinModule) 50 | modules(databaseKoinModule) 51 | } 52 | 53 | val factory: IDatabaseFactory by inject() 54 | factory.init() 55 | 56 | install(StatusPages) { 57 | statusPages() 58 | } 59 | 60 | routing { 61 | 62 | api(simpleJWT) 63 | } 64 | } 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/main/kotlin/api/ArticleResource.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.api 2 | 3 | import com.nooblabs.models.MultipleArticlesResponse 4 | import com.nooblabs.models.NewArticle 5 | import com.nooblabs.models.UpdateArticle 6 | import com.nooblabs.service.IArticleService 7 | import com.nooblabs.util.param 8 | import com.nooblabs.util.userId 9 | import io.ktor.http.* 10 | import io.ktor.server.application.* 11 | import io.ktor.server.auth.* 12 | import io.ktor.server.request.* 13 | import io.ktor.server.response.* 14 | import io.ktor.server.routing.* 15 | 16 | fun Route.article(articleService: IArticleService) { 17 | 18 | authenticate { 19 | 20 | /* 21 | Feed Articles 22 | GET /api/articles/feed 23 | */ 24 | get("/articles/feed") { 25 | val params = call.parameters 26 | val filter = mapOf( 27 | "limit" to params["limit"], 28 | "offset" to params["offset"] 29 | ) 30 | val articles = articleService.getFeedArticles(call.userId(), filter) 31 | call.respond(MultipleArticlesResponse(articles, articles.size)) 32 | } 33 | 34 | /* 35 | Create Article 36 | POST /api/articles 37 | */ 38 | post("/articles") { 39 | val newArticle = call.receive() 40 | val article = articleService.createArticle(call.userId(), newArticle) 41 | call.respond(article) 42 | } 43 | 44 | /* 45 | Update Article 46 | PUT /api/articles/:slug 47 | */ 48 | put("/articles/{slug}") { 49 | val slug = call.param("slug") 50 | val updateArticle = call.receive() 51 | val article = articleService.updateArticle(call.userId(), slug, updateArticle) 52 | call.respond(article) 53 | } 54 | 55 | /* 56 | Favorite Article 57 | POST /api/articles/:slug/favorite 58 | */ 59 | post("/articles/{slug}/favorite") { 60 | val slug = call.param("slug") 61 | val article = articleService.changeFavorite(call.userId(), slug, favorite = true) 62 | call.respond(article) 63 | } 64 | 65 | /* 66 | Unfavorite Article 67 | DELETE /api/articles/:slug/favorite 68 | */ 69 | delete("/articles/{slug}/favorite") { 70 | val slug = call.param("slug") 71 | val article = articleService.changeFavorite(call.userId(), slug, favorite = false) 72 | call.respond(article) 73 | } 74 | 75 | /* 76 | Delete Article 77 | DELETE /api/articles/:slug 78 | */ 79 | delete("/articles/{slug}") { 80 | val slug = call.param("slug") 81 | articleService.deleteArticle(call.userId(), slug) 82 | call.respond(HttpStatusCode.OK) 83 | } 84 | } 85 | 86 | authenticate(optional = true) { 87 | 88 | /* 89 | List Articles 90 | GET /api/articles 91 | */ 92 | get("/articles") { 93 | val userId = call.principal()?.name 94 | val params = call.parameters 95 | val filter = mapOf( 96 | "tag" to params["tag"], 97 | "author" to params["author"], 98 | "favorited" to params["favorited"], 99 | "limit" to params["limit"], 100 | "offset" to params["offset"] 101 | ) 102 | val articles = articleService.getArticles(userId, filter) 103 | call.respond(MultipleArticlesResponse(articles, articles.size)) 104 | } 105 | 106 | } 107 | 108 | /* 109 | Get Article 110 | GET /api/articles/:slug 111 | */ 112 | get("/articles/{slug}") { 113 | val slug = call.param("slug") 114 | val article = articleService.getArticle(slug) 115 | call.respond(article) 116 | } 117 | 118 | /* 119 | Get Tags 120 | GET /api/tags 121 | */ 122 | get("/tags") { 123 | call.respond(articleService.getAllTags()) 124 | } 125 | 126 | } -------------------------------------------------------------------------------- /src/main/kotlin/api/AuthResource.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.api 2 | 3 | import com.nooblabs.models.LoginUser 4 | import com.nooblabs.models.RegisterUser 5 | import com.nooblabs.models.UpdateUser 6 | import com.nooblabs.models.UserResponse 7 | import com.nooblabs.service.IAuthService 8 | import com.nooblabs.util.SimpleJWT 9 | import com.nooblabs.util.userId 10 | import io.ktor.server.application.* 11 | import io.ktor.server.auth.* 12 | import io.ktor.server.request.* 13 | import io.ktor.server.response.* 14 | import io.ktor.server.routing.* 15 | 16 | fun Route.auth(authService: IAuthService, simpleJWT: SimpleJWT) { 17 | 18 | /* 19 | Registration: 20 | POST /api/users 21 | */ 22 | post("/users") { 23 | val registerUser = call.receive() 24 | val newUser = authService.register(registerUser) 25 | call.respond(UserResponse.fromUser(newUser, token = simpleJWT.sign(newUser.id))) 26 | } 27 | 28 | /* 29 | Authentication: 30 | POST /api/users/login 31 | */ 32 | post("/users/login") { 33 | val loginUser = call.receive() 34 | val user = authService.loginAndGetUser(loginUser.user.email, loginUser.user.password) 35 | call.respond(UserResponse.fromUser(user, token = simpleJWT.sign(user.id))) 36 | } 37 | 38 | authenticate { 39 | 40 | /* 41 | Get Current User 42 | GET /api/user 43 | */ 44 | get("/user") { 45 | val user = authService.getUserById(call.userId()) 46 | call.respond(UserResponse.fromUser(user)) 47 | } 48 | 49 | /* 50 | Update User 51 | PUT /api/user 52 | */ 53 | put("/user") { 54 | val updateUser = call.receive() 55 | val user = authService.updateUser(call.userId(), updateUser) 56 | call.respond(UserResponse.fromUser(user, token = simpleJWT.sign(user.id))) 57 | } 58 | 59 | } 60 | 61 | //For development purposes 62 | //Returns all users 63 | get("/users") { 64 | val users = authService.getAllUsers() 65 | call.respond(users.map { UserResponse.fromUser(it) }) 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /src/main/kotlin/api/CommentResource.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.api 2 | 3 | import com.nooblabs.models.PostComment 4 | import com.nooblabs.service.ICommentService 5 | import com.nooblabs.util.param 6 | import com.nooblabs.util.userId 7 | import io.ktor.http.* 8 | import io.ktor.server.application.* 9 | import io.ktor.server.auth.* 10 | import io.ktor.server.request.* 11 | import io.ktor.server.response.* 12 | import io.ktor.server.routing.* 13 | 14 | fun Route.comment(commentService: ICommentService) { 15 | 16 | authenticate { 17 | 18 | /* 19 | Add Comments to an Article 20 | POST /api/articles/:slug/comments 21 | */ 22 | post("/articles/{slug}/comments") { 23 | val slug = call.param("slug") 24 | val postComment = call.receive() 25 | val comment = commentService.addComment(call.userId(), slug, postComment) 26 | call.respond(comment) 27 | } 28 | 29 | /* 30 | Delete Comment 31 | DELETE /api/articles/:slug/comments/:id 32 | */ 33 | delete("/articles/{slug}/comments/{id}") { 34 | val slug = call.param("slug") 35 | val id = call.param("id").toInt() 36 | commentService.deleteComment(call.userId(), slug, id) 37 | call.respond(HttpStatusCode.OK) 38 | } 39 | } 40 | 41 | authenticate(optional = true) { 42 | 43 | /* 44 | Get Comments from an Article 45 | GET /api/articles/:slug/comments 46 | */ 47 | get("/articles/{slug}/comments") { 48 | val slug = call.param("slug") 49 | val userId = call.principal()?.name 50 | val comments = commentService.getComments(userId, slug) 51 | call.respond(mapOf("comments" to comments)) 52 | } 53 | 54 | } 55 | 56 | } -------------------------------------------------------------------------------- /src/main/kotlin/api/ProfileResource.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.api 2 | 3 | import com.nooblabs.service.IProfileService 4 | import com.nooblabs.util.param 5 | import com.nooblabs.util.userId 6 | import io.ktor.server.application.* 7 | import io.ktor.server.auth.* 8 | import io.ktor.server.response.* 9 | import io.ktor.server.routing.* 10 | 11 | fun Route.profile(profileService: IProfileService) { 12 | 13 | authenticate(optional = true) { 14 | 15 | /* 16 | Get Profile 17 | GET /api/profiles/:username 18 | */ 19 | get("/profiles/{username}") { 20 | val username = call.param("username") 21 | val currentUserId = call.principal()?.name 22 | val profile = profileService.getProfile(username, currentUserId) 23 | call.respond(profile) 24 | } 25 | 26 | } 27 | 28 | authenticate { 29 | 30 | /* 31 | Follow user 32 | POST /api/profiles/:username/follow 33 | */ 34 | post("/profiles/{username}/follow") { 35 | val username = call.param("username") 36 | val currentUserId = call.userId() 37 | val profile = profileService.changeFollowStatus(username, currentUserId, true) 38 | call.respond(profile) 39 | } 40 | 41 | /* 42 | Unfollow user 43 | DELETE /api/profiles/:username/follow 44 | */ 45 | delete("/profiles/{username}/follow") { 46 | val username = call.param("username") 47 | val currentUserId = call.userId() 48 | val profile = profileService.changeFollowStatus(username, currentUserId, false) 49 | call.respond(profile) 50 | } 51 | 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /src/main/kotlin/config/Api.kt: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import com.nooblabs.api.article 4 | import com.nooblabs.api.auth 5 | import com.nooblabs.api.comment 6 | import com.nooblabs.api.profile 7 | import com.nooblabs.service.* 8 | import com.nooblabs.util.SimpleJWT 9 | import io.ktor.server.application.* 10 | import io.ktor.server.response.* 11 | import io.ktor.server.routing.* 12 | import org.koin.ktor.ext.inject 13 | 14 | fun Routing.api(simpleJWT: SimpleJWT) { 15 | 16 | val authService: IAuthService by inject() 17 | val profileService: IProfileService by inject() 18 | val articleService: IArticleService by inject() 19 | val commentService: ICommentService by inject() 20 | val databaseFactory: IDatabaseFactory by inject() 21 | 22 | route("/api") { 23 | 24 | get { 25 | call.respond("Welcome to Realworld") 26 | } 27 | 28 | auth(authService, simpleJWT) 29 | profile(profileService) 30 | article(articleService) 31 | comment(commentService) 32 | 33 | get("/drop") { 34 | databaseFactory.drop() 35 | call.respond("OK") 36 | } 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/config/Auth.kt: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import com.nooblabs.util.SimpleJWT 4 | import io.ktor.server.auth.* 5 | import io.ktor.server.auth.jwt.* 6 | 7 | fun AuthenticationConfig.jwtConfig(simpleJWT: SimpleJWT) { 8 | 9 | jwt { 10 | authSchemes("Token") 11 | verifier(simpleJWT.verifier) 12 | validate { 13 | println(it.payload.getClaim("id").asString()) 14 | UserIdPrincipal(it.payload.getClaim("id").asString()) 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/config/Cors.kt: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import io.ktor.http.* 4 | import io.ktor.server.plugins.cors.* 5 | import kotlin.time.Duration.Companion.days 6 | 7 | fun CORSConfig.cors() { 8 | allowMethod(HttpMethod.Options) 9 | allowMethod(HttpMethod.Get) 10 | allowMethod(HttpMethod.Post) 11 | allowMethod(HttpMethod.Put) 12 | allowMethod(HttpMethod.Delete) 13 | allowHeader(HttpHeaders.AccessControlAllowHeaders) 14 | allowHeader(HttpHeaders.AccessControlAllowOrigin) 15 | allowHeader(HttpHeaders.Authorization) 16 | allowCredentials = true 17 | allowSameOrigin = true 18 | anyHost() 19 | maxAgeDuration = 1.days 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/config/StatusPages.kt: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import com.nooblabs.util.* 4 | import io.ktor.http.* 5 | import io.ktor.server.plugins.statuspages.* 6 | import io.ktor.server.response.* 7 | 8 | fun StatusPagesConfig.statusPages() { 9 | exception { call, cause -> 10 | when (cause) { 11 | is AuthenticationException -> call.respond(HttpStatusCode.Unauthorized) 12 | is AuthorizationException -> call.respond(HttpStatusCode.Forbidden) 13 | is ValidationException -> call.respond(HttpStatusCode.UnprocessableEntity, mapOf("errors" to cause.params)) 14 | is UserExists -> call.respond( 15 | HttpStatusCode.UnprocessableEntity, 16 | mapOf("errors" to mapOf("user" to listOf("exists"))) 17 | ) 18 | is UserDoesNotExists, is ArticleDoesNotExist, is CommentNotFound -> call.respond(HttpStatusCode.NotFound) 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/koin.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs 2 | 3 | import com.nooblabs.service.* 4 | import org.koin.dsl.module 5 | 6 | val serviceKoinModule = module { 7 | single { ArticleService(get()) } 8 | single { AuthService(get()) } 9 | single { CommentService(get()) } 10 | single { ProfileService(get()) } 11 | } 12 | 13 | val databaseKoinModule = module { 14 | single { DatabaseFactory() } 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/models/Article.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.models 2 | 3 | import org.jetbrains.exposed.dao.UUIDEntity 4 | import org.jetbrains.exposed.dao.UUIDEntityClass 5 | import org.jetbrains.exposed.dao.id.EntityID 6 | import org.jetbrains.exposed.dao.id.UUIDTable 7 | import org.jetbrains.exposed.sql.ReferenceOption 8 | import org.jetbrains.exposed.sql.Table 9 | import org.jetbrains.exposed.sql.javatime.timestamp 10 | import java.time.Instant 11 | import java.util.* 12 | 13 | object Articles : UUIDTable() { 14 | val slug = varchar("slug", 255) 15 | var title = varchar("title", 255) 16 | val description = varchar("description", 255) 17 | val body = varchar("body", 255) 18 | val author = reference("author", Users) 19 | val createdAt = timestamp("createdAt").default(Instant.now()) 20 | val updatedAt = timestamp("updatedAt").default(Instant.now()) 21 | } 22 | 23 | object Tags : UUIDTable() { 24 | val tagName = varchar("tagName", 255).uniqueIndex() 25 | } 26 | 27 | object ArticleTags : Table() { 28 | val article = reference( 29 | "article", 30 | Articles, 31 | onDelete = ReferenceOption.CASCADE, 32 | onUpdate = ReferenceOption.CASCADE 33 | ) 34 | val tag = reference( 35 | "tag", 36 | Tags, 37 | onDelete = ReferenceOption.CASCADE, 38 | onUpdate = ReferenceOption.CASCADE 39 | ) 40 | 41 | override val primaryKey = PrimaryKey(article, tag) 42 | } 43 | 44 | object FavoriteArticle : Table() { 45 | val article = reference("article", Articles) 46 | val user = reference("user", Users) 47 | 48 | override val primaryKey = PrimaryKey(article, user) 49 | } 50 | 51 | class Tag(id: EntityID) : UUIDEntity(id) { 52 | companion object : UUIDEntityClass(Tags) 53 | 54 | var tag by Tags.tagName 55 | } 56 | 57 | class Article(id: EntityID) : UUIDEntity(id) { 58 | companion object : UUIDEntityClass
(Articles) { 59 | fun generateSlug(title: String) = title.lowercase(Locale.US).replace(" ", "-") 60 | } 61 | 62 | var slug by Articles.slug 63 | var title by Articles.title 64 | var description by Articles.description 65 | var body by Articles.body 66 | var tags by Tag via ArticleTags 67 | var author by Articles.author 68 | var favoritedBy by User via FavoriteArticle 69 | var createdAt by Articles.createdAt 70 | var updatedAt by Articles.updatedAt 71 | var comments by Comment via ArticleComment 72 | } 73 | 74 | data class NewArticle(val article: Article) { 75 | data class Article( 76 | val title: String, 77 | val description: String, 78 | val body: String, 79 | val tagList: List = emptyList() 80 | ) 81 | } 82 | 83 | data class UpdateArticle(val article: Article) { 84 | data class Article( 85 | val title: String? = null, 86 | val description: String? = null, 87 | val body: String? = null 88 | ) 89 | } 90 | 91 | data class ArticleResponse(val article: Article) { 92 | data class Article( 93 | val slug: String, 94 | val title: String, 95 | val description: String, 96 | val body: String, 97 | val tagList: List, 98 | val createdAt: String, 99 | val updatedAt: String, 100 | val favorited: Boolean = false, 101 | val favoritesCount: Int = 0, 102 | val author: ProfileResponse.Profile 103 | ) 104 | } 105 | 106 | data class MultipleArticlesResponse(val articles: List, val articlesCount: Int) 107 | 108 | data class TagResponse(val tags: List) -------------------------------------------------------------------------------- /src/main/kotlin/models/Comments.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.models 2 | 3 | import org.jetbrains.exposed.dao.IntEntity 4 | import org.jetbrains.exposed.dao.IntEntityClass 5 | import org.jetbrains.exposed.dao.id.EntityID 6 | import org.jetbrains.exposed.dao.id.IntIdTable 7 | import org.jetbrains.exposed.sql.ReferenceOption 8 | import org.jetbrains.exposed.sql.Table 9 | import org.jetbrains.exposed.sql.javatime.timestamp 10 | import java.time.Instant 11 | 12 | object Comments : IntIdTable() { 13 | val createdAt = timestamp("createdAt").default(Instant.now()) 14 | val updatedAt = timestamp("updatedAt").default(Instant.now()) 15 | val body = text("body") 16 | val author = reference("author", Users, onUpdate = ReferenceOption.CASCADE, onDelete = ReferenceOption.CASCADE) 17 | } 18 | 19 | object ArticleComment : Table() { 20 | val article = reference( 21 | "article", Articles, 22 | onDelete = ReferenceOption.CASCADE, 23 | onUpdate = ReferenceOption.CASCADE 24 | ) 25 | val comment = reference( 26 | "comment", Comments, 27 | onDelete = ReferenceOption.CASCADE, 28 | onUpdate = ReferenceOption.CASCADE 29 | ) 30 | override val primaryKey = PrimaryKey(article, comment) 31 | } 32 | 33 | class Comment(id: EntityID) : IntEntity(id) { 34 | companion object : IntEntityClass(Comments) 35 | 36 | var createdAt by Comments.createdAt 37 | var updatedAt by Comments.updatedAt 38 | var body by Comments.body 39 | var author by Comments.author 40 | } 41 | 42 | data class CommentResponse(val comment: Comment) { 43 | data class Comment( 44 | val id: Int, 45 | val createdAt: String, 46 | val updatedAt: String, 47 | val body: String, 48 | val author: ProfileResponse.Profile 49 | ) 50 | } 51 | 52 | data class PostComment(val comment: Comment) { 53 | data class Comment(val body: String) 54 | } -------------------------------------------------------------------------------- /src/main/kotlin/models/Profile.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.models 2 | 3 | data class ProfileResponse(val profile: Profile? = null) { 4 | data class Profile(val username: String, val bio: String, val image: String?, val following: Boolean = false) 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/models/User.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.models 2 | 3 | import org.jetbrains.exposed.dao.UUIDEntity 4 | import org.jetbrains.exposed.dao.UUIDEntityClass 5 | import org.jetbrains.exposed.dao.id.EntityID 6 | import org.jetbrains.exposed.dao.id.UUIDTable 7 | import java.util.UUID 8 | 9 | object Users : UUIDTable() { 10 | val email = varchar("email", 255).uniqueIndex() 11 | val username = varchar("username", 255).uniqueIndex() 12 | val bio = text("bio").default("") 13 | val image = varchar("image", 255).nullable() 14 | val password = varchar("password", 255) 15 | } 16 | 17 | object Followings : UUIDTable() { 18 | val userId = reference("userId", Users) 19 | val followerId = reference("followerId", Users) 20 | } 21 | 22 | class User(id: EntityID) : UUIDEntity(id) { 23 | companion object : UUIDEntityClass(Users) 24 | 25 | var email by Users.email 26 | var username by Users.username 27 | var bio by Users.bio 28 | var image by Users.image 29 | var password by Users.password 30 | var followers by User.via(Followings.userId, Followings.followerId) 31 | } 32 | 33 | data class RegisterUser(val user: User) { 34 | data class User(val email: String, val username: String, val password: String) 35 | } 36 | 37 | data class LoginUser(val user: User) { 38 | data class User(val email: String, val password: String) 39 | } 40 | 41 | data class UpdateUser(val user: User) { 42 | data class User( 43 | val email: String? = null, 44 | val username: String? = null, 45 | val password: String? = null, 46 | val image: String? = null, 47 | val bio: String? = null 48 | ) 49 | } 50 | 51 | data class UserResponse(val user: User) { 52 | data class User( 53 | val email: String, 54 | val token: String = "", 55 | val username: String, 56 | val bio: String, 57 | val image: String? 58 | ) 59 | 60 | companion object { 61 | fun fromUser(user: com.nooblabs.models.User, token: String = ""): UserResponse = UserResponse( 62 | user = User( 63 | email = user.email, 64 | token = token, 65 | username = user.username, 66 | bio = user.bio, 67 | image = user.image 68 | ) 69 | ) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/kotlin/service/ArticleService.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.service 2 | 3 | import com.nooblabs.models.Article 4 | import com.nooblabs.models.ArticleResponse 5 | import com.nooblabs.models.Articles 6 | import com.nooblabs.models.NewArticle 7 | import com.nooblabs.models.Tag 8 | import com.nooblabs.models.TagResponse 9 | import com.nooblabs.models.Tags 10 | import com.nooblabs.models.UpdateArticle 11 | import com.nooblabs.models.User 12 | import com.nooblabs.util.ArticleDoesNotExist 13 | import com.nooblabs.util.AuthorizationException 14 | import org.jetbrains.exposed.sql.Op 15 | import org.jetbrains.exposed.sql.SizedCollection 16 | import org.jetbrains.exposed.sql.SortOrder 17 | import java.time.Instant 18 | 19 | interface IArticleService { 20 | suspend fun createArticle(userId: String, newArticle: NewArticle): ArticleResponse 21 | 22 | suspend fun updateArticle(userId: String, slug: String, updateArticle: UpdateArticle): ArticleResponse 23 | 24 | suspend fun getArticle(slug: String): ArticleResponse 25 | 26 | suspend fun getArticles(userId: String? = null, filter: Map): List 27 | 28 | suspend fun getFeedArticles(userId: String, filter: Map): List 29 | 30 | suspend fun changeFavorite(userId: String, slug: String, favorite: Boolean): ArticleResponse 31 | 32 | suspend fun deleteArticle(userId: String, slug: String) 33 | 34 | suspend fun getAllTags(): TagResponse 35 | } 36 | 37 | class ArticleService(private val databaseFactory: IDatabaseFactory) : IArticleService { 38 | 39 | override suspend fun createArticle(userId: String, newArticle: NewArticle): ArticleResponse { 40 | return databaseFactory.dbQuery { 41 | val user = getUser(userId) 42 | val article = Article.new { 43 | title = newArticle.article.title 44 | slug = Article.generateSlug(newArticle.article.title) 45 | description = newArticle.article.description 46 | body = newArticle.article.body 47 | author = user.id 48 | } 49 | val tags = newArticle.article.tagList.map { tag -> getOrCreateTag(tag) } 50 | article.tags = SizedCollection(tags) 51 | getArticleResponse(article, user) 52 | } 53 | } 54 | 55 | override suspend fun updateArticle(userId: String, slug: String, updateArticle: UpdateArticle): ArticleResponse { 56 | return databaseFactory.dbQuery { 57 | val user = getUser(userId) 58 | val article = getArticleBySlug(slug) 59 | if (!isArticleAuthor(article, user)) throw AuthorizationException() 60 | if (updateArticle.article.title != null) { 61 | article.slug = Article.generateSlug(updateArticle.article.title) 62 | article.title = updateArticle.article.title 63 | article.updatedAt = Instant.now() 64 | } 65 | getArticleResponse(article, user) 66 | } 67 | } 68 | 69 | override suspend fun getArticle(slug: String): ArticleResponse { 70 | return databaseFactory.dbQuery { 71 | val article = getArticleBySlug(slug) 72 | getArticleResponse(article) 73 | } 74 | } 75 | 76 | override suspend fun getArticles(userId: String?, filter: Map): List { 77 | return databaseFactory.dbQuery { 78 | val user = if (userId != null) getUser(userId) else null 79 | getAllArticles( 80 | currentUser = user, 81 | tag = filter["tag"], 82 | authorUserName = filter["author"], 83 | favoritedByUserName = filter["favorited"], 84 | limit = filter["limit"]?.toInt() ?: 20, 85 | offset = filter["offset"]?.toInt() ?: 0 86 | ) 87 | } 88 | } 89 | 90 | override suspend fun getFeedArticles(userId: String, filter: Map): List { 91 | return databaseFactory.dbQuery { 92 | val user = getUser(userId) 93 | getAllArticles( 94 | currentUser = user, 95 | limit = filter["limit"]?.toInt() ?: 20, 96 | offset = filter["offset"]?.toInt() ?: 0, 97 | follows = true 98 | ) 99 | } 100 | } 101 | 102 | override suspend fun changeFavorite(userId: String, slug: String, favorite: Boolean): ArticleResponse { 103 | return databaseFactory.dbQuery { 104 | val user = getUser(userId) 105 | val article = getArticleBySlug(slug) 106 | if (favorite) { 107 | favoriteArticle(article, user) 108 | } else { 109 | unfavoriteArticle(article, user) 110 | } 111 | getArticleResponse(article, user) 112 | } 113 | } 114 | 115 | override suspend fun deleteArticle(userId: String, slug: String) { 116 | databaseFactory.dbQuery { 117 | val user = getUser(userId) 118 | val article = getArticleBySlug(slug) 119 | if (!isArticleAuthor(article, user)) throw AuthorizationException() 120 | article.delete() 121 | } 122 | } 123 | 124 | override suspend fun getAllTags(): TagResponse { 125 | return databaseFactory.dbQuery { 126 | val tags = Tag.all().map { it.tag } 127 | TagResponse(tags) 128 | } 129 | } 130 | 131 | private fun getAllArticles( 132 | currentUser: User? = null, 133 | tag: String? = null, 134 | authorUserName: String? = null, 135 | favoritedByUserName: String? = null, 136 | limit: Int = 20, 137 | offset: Int = 0, 138 | follows: Boolean = false 139 | ): List { 140 | val author = if (authorUserName != null) getUserByUsername(authorUserName) else null 141 | val articles = Article.find { 142 | if (author != null) (Articles.author eq author.id) else Op.TRUE 143 | }.limit(limit).offset(offset.toLong()).orderBy(Articles.createdAt to SortOrder.DESC) 144 | val filteredArticles = articles.filter { article -> 145 | if (favoritedByUserName != null) { 146 | val favoritedByUser = getUserByUsername(favoritedByUserName) 147 | isFavoritedArticle(article, favoritedByUser) 148 | } else { 149 | true 150 | } 151 | && 152 | if (tag != null) { 153 | article.tags.any { it.tag == tag } 154 | } else { 155 | true 156 | } 157 | && 158 | if (follows) { 159 | val articleAuthor = getUser(article.author.toString()) 160 | isFollower(articleAuthor, currentUser) 161 | } else { 162 | true 163 | } 164 | } 165 | return filteredArticles.map { 166 | getArticleResponse(it, currentUser).article 167 | } 168 | } 169 | 170 | private fun favoriteArticle(article: Article, user: User) { 171 | if (!isFavoritedArticle(article, user)) { 172 | article.favoritedBy = SizedCollection(article.favoritedBy.plus(user)) 173 | } 174 | } 175 | 176 | private fun unfavoriteArticle(article: Article, user: User) { 177 | if (isFavoritedArticle(article, user)) { 178 | article.favoritedBy = SizedCollection(article.favoritedBy.minus(user)) 179 | } 180 | } 181 | } 182 | 183 | fun isFavoritedArticle(article: Article, user: User?) = 184 | if (user != null) article.favoritedBy.any { it == user } else false 185 | 186 | fun getArticleBySlug(slug: String) = 187 | Article.find { Articles.slug eq slug }.firstOrNull() ?: throw ArticleDoesNotExist(slug) 188 | 189 | fun getOrCreateTag(tagName: String) = 190 | Tag.find { Tags.tagName eq tagName }.firstOrNull() ?: Tag.new { this.tag = tagName } 191 | 192 | fun getArticleResponse(article: Article, currentUser: User? = null): ArticleResponse { 193 | val author = getUser(article.author.toString()) 194 | val tagList = article.tags.map { it.tag } 195 | val favoriteCount = article.favoritedBy.count() 196 | val favorited = isFavoritedArticle(article, currentUser) 197 | val following = isFollower(author, currentUser) 198 | val authorProfile = getProfileByUser(getUser(article.author.toString()), following).profile!! 199 | return ArticleResponse( 200 | article = ArticleResponse.Article( 201 | slug = article.slug, 202 | title = article.title, 203 | description = article.description, 204 | body = article.body, 205 | tagList = tagList, 206 | createdAt = article.createdAt.toString(), 207 | updatedAt = article.updatedAt.toString(), 208 | favorited = favorited, 209 | favoritesCount = favoriteCount.toInt(), 210 | author = authorProfile 211 | ) 212 | ) 213 | } 214 | 215 | fun isArticleAuthor(article: Article, user: User) = article.author == user.id -------------------------------------------------------------------------------- /src/main/kotlin/service/AuthService.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.service 2 | 3 | import com.nooblabs.models.RegisterUser 4 | import com.nooblabs.models.UpdateUser 5 | import com.nooblabs.models.User 6 | import com.nooblabs.models.Users 7 | import com.nooblabs.util.UserDoesNotExists 8 | import com.nooblabs.util.UserExists 9 | import org.jetbrains.exposed.sql.and 10 | import org.jetbrains.exposed.sql.or 11 | import java.util.UUID 12 | 13 | interface IAuthService { 14 | suspend fun register(registerUser: RegisterUser): User 15 | 16 | suspend fun getAllUsers(): List 17 | 18 | suspend fun getUserByEmail(email: String): User? 19 | 20 | suspend fun getUserById(id: String): User 21 | 22 | suspend fun loginAndGetUser(email: String, password: String): User 23 | 24 | suspend fun updateUser(userId: String, updateUser: UpdateUser): User 25 | } 26 | 27 | class AuthService(private val databaseFactory: IDatabaseFactory) : IAuthService { 28 | 29 | override suspend fun register(registerUser: RegisterUser): User { 30 | return databaseFactory.dbQuery { 31 | val userInDatabase = 32 | User.find { (Users.username eq registerUser.user.username) or (Users.email eq registerUser.user.email) } 33 | .firstOrNull() 34 | if (userInDatabase != null) throw UserExists() 35 | User.new { 36 | username = registerUser.user.username 37 | email = registerUser.user.email 38 | password = registerUser.user.password 39 | } 40 | } 41 | } 42 | 43 | override suspend fun getAllUsers(): List { 44 | return databaseFactory.dbQuery { 45 | User.all().toList() 46 | } 47 | } 48 | 49 | override suspend fun getUserByEmail(email: String): User? { 50 | return databaseFactory.dbQuery { 51 | User.find { Users.email eq email }.firstOrNull() 52 | } 53 | } 54 | 55 | override suspend fun getUserById(id: String): User { 56 | return databaseFactory.dbQuery { 57 | getUser(id) 58 | } 59 | } 60 | 61 | override suspend fun loginAndGetUser(email: String, password: String): User { 62 | return databaseFactory.dbQuery { 63 | User.find { (Users.email eq email) and (Users.password eq password) }.firstOrNull() 64 | ?: throw UserDoesNotExists() 65 | } 66 | } 67 | 68 | override suspend fun updateUser(userId: String, updateUser: UpdateUser): User { 69 | return databaseFactory.dbQuery { 70 | val user = getUser(userId) 71 | user.apply { 72 | email = updateUser.user.email ?: email 73 | password = updateUser.user.password ?: password 74 | username = updateUser.user.username ?: username 75 | image = updateUser.user.image ?: image 76 | bio = updateUser.user.bio ?: bio 77 | } 78 | } 79 | } 80 | 81 | 82 | } 83 | 84 | fun getUser(id: String) = User.findById(UUID.fromString(id)) ?: throw UserDoesNotExists() 85 | 86 | fun getUserByUsername(username: String) = User.find { Users.username eq username }.firstOrNull() -------------------------------------------------------------------------------- /src/main/kotlin/service/CommentService.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.service 2 | 3 | import com.nooblabs.models.Comment 4 | import com.nooblabs.models.CommentResponse 5 | import com.nooblabs.models.PostComment 6 | import com.nooblabs.util.AuthorizationException 7 | import com.nooblabs.util.CommentNotFound 8 | import org.jetbrains.exposed.sql.SizedCollection 9 | 10 | interface ICommentService { 11 | suspend fun addComment(userId: String, slug: String, postComment: PostComment): CommentResponse 12 | 13 | suspend fun getComments(userId: String?, slug: String): List 14 | 15 | suspend fun deleteComment(userId: String, slug: String, commentId: Int) 16 | } 17 | 18 | class CommentService(private val databaseFactory: IDatabaseFactory) : ICommentService { 19 | 20 | override suspend fun addComment(userId: String, slug: String, postComment: PostComment): CommentResponse { 21 | return databaseFactory.dbQuery { 22 | val user = getUser(userId) 23 | val article = getArticleBySlug(slug) 24 | val comment = Comment.new { 25 | body = postComment.comment.body 26 | author = user.id 27 | } 28 | article.comments = SizedCollection(article.comments.plus(comment)) 29 | getCommentResponse(comment, userId) 30 | } 31 | } 32 | 33 | override suspend fun getComments(userId: String?, slug: String): List { 34 | return databaseFactory.dbQuery { 35 | val article = getArticleBySlug(slug) 36 | article.comments.map { comment -> getCommentResponse(comment, userId).comment } 37 | } 38 | } 39 | 40 | override suspend fun deleteComment(userId: String, slug: String, commentId: Int) { 41 | databaseFactory.dbQuery { 42 | val user = getUser(userId) 43 | val article = getArticleBySlug(slug) 44 | val comment = getCommentById(commentId) 45 | if (comment.author != user.id || article.comments.none { it == comment }) throw AuthorizationException() 46 | comment.delete() 47 | } 48 | } 49 | 50 | } 51 | 52 | fun getCommentResponse(comment: Comment, userId: String?): CommentResponse { 53 | val author = getUser(comment.author.toString()) 54 | val currentUser = if (userId != null) getUser(userId) else null 55 | val following = isFollower(author, currentUser) 56 | val authorProfile = getProfileByUser(author, following) 57 | return CommentResponse( 58 | comment = CommentResponse.Comment( 59 | id = comment.id.value, 60 | createdAt = comment.createdAt.toString(), 61 | updatedAt = comment.updatedAt.toString(), 62 | body = comment.body, 63 | author = authorProfile.profile!! 64 | ) 65 | ) 66 | } 67 | 68 | fun getCommentById(id: Int) = Comment.findById(id) ?: throw CommentNotFound() -------------------------------------------------------------------------------- /src/main/kotlin/service/DatabaseFactory.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.service 2 | 3 | import com.nooblabs.models.ArticleComment 4 | import com.nooblabs.models.ArticleTags 5 | import com.nooblabs.models.Articles 6 | import com.nooblabs.models.Comments 7 | import com.nooblabs.models.FavoriteArticle 8 | import com.nooblabs.models.Followings 9 | import com.nooblabs.models.Tags 10 | import com.nooblabs.models.Users 11 | import com.zaxxer.hikari.HikariConfig 12 | import com.zaxxer.hikari.HikariDataSource 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.withContext 15 | import org.jetbrains.exposed.sql.Database 16 | import org.jetbrains.exposed.sql.SchemaUtils.create 17 | import org.jetbrains.exposed.sql.SchemaUtils.drop 18 | import org.jetbrains.exposed.sql.transactions.transaction 19 | 20 | interface IDatabaseFactory { 21 | fun init() 22 | 23 | suspend fun dbQuery(block: () -> T): T 24 | 25 | suspend fun drop() 26 | } 27 | 28 | class DatabaseFactory : IDatabaseFactory { 29 | 30 | override fun init() { 31 | Database.connect(hikari()) 32 | transaction { 33 | create(Users, Followings, Articles, Tags, ArticleTags, FavoriteArticle, Comments, ArticleComment) 34 | 35 | //NOTE: Insert initial rows if any here 36 | } 37 | } 38 | 39 | private fun hikari(): HikariDataSource { 40 | val config = HikariConfig().apply { 41 | driverClassName = "org.h2.Driver" 42 | // jdbcUrl = "jdbc:h2:tcp://localhost/~/realworldtest" 43 | jdbcUrl = "jdbc:h2:mem:~realworldtest" 44 | maximumPoolSize = 3 45 | isAutoCommit = false 46 | transactionIsolation = "TRANSACTION_REPEATABLE_READ" 47 | } 48 | return HikariDataSource(config) 49 | } 50 | 51 | override suspend fun dbQuery(block: () -> T): T = withContext(Dispatchers.IO) { 52 | transaction { block() } 53 | } 54 | 55 | override suspend fun drop() { 56 | dbQuery { drop(Users, Followings, Articles, Tags, ArticleTags, FavoriteArticle, Comments, ArticleComment) } 57 | } 58 | } -------------------------------------------------------------------------------- /src/main/kotlin/service/ProfileService.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.service 2 | 3 | import com.nooblabs.models.ProfileResponse 4 | import com.nooblabs.models.User 5 | import com.nooblabs.util.UserDoesNotExists 6 | import org.jetbrains.exposed.sql.SizedCollection 7 | 8 | interface IProfileService { 9 | suspend fun getProfile(username: String, currentUserId: String? = null): ProfileResponse 10 | 11 | suspend fun changeFollowStatus(toUserName: String, fromUserId: String, follow: Boolean): ProfileResponse 12 | } 13 | 14 | class ProfileService(private val databaseFactory: IDatabaseFactory) : IProfileService { 15 | override suspend fun getProfile(username: String, currentUserId: String?): ProfileResponse { 16 | return databaseFactory.dbQuery { 17 | val toUser = getUserByUsername(username) ?: return@dbQuery getProfileByUser(null, false) 18 | currentUserId ?: return@dbQuery getProfileByUser(toUser) 19 | val fromUser = getUser(currentUserId) 20 | val follows = isFollower(toUser, fromUser) 21 | getProfileByUser(toUser, follows) 22 | } 23 | } 24 | 25 | override suspend fun changeFollowStatus(toUserName: String, fromUserId: String, follow: Boolean): ProfileResponse { 26 | databaseFactory.dbQuery { 27 | val toUser = getUserByUsername(toUserName) ?: throw UserDoesNotExists() 28 | val fromUser = getUser(fromUserId) 29 | if (follow) { 30 | addFollower(toUser, fromUser) 31 | } else { 32 | removeFollower(toUser, fromUser) 33 | } 34 | } 35 | return getProfile(toUserName, fromUserId) 36 | } 37 | 38 | private fun addFollower(user: User, newFollower: User) { 39 | if (!isFollower(user, newFollower)) { 40 | user.followers = SizedCollection(user.followers.plus(newFollower)) 41 | } 42 | } 43 | 44 | private fun removeFollower(user: User, newFollower: User) { 45 | if (isFollower(user, newFollower)) { 46 | user.followers = SizedCollection(user.followers.minus(newFollower)) 47 | } 48 | } 49 | 50 | } 51 | 52 | fun isFollower(user: User, follower: User?) = if (follower != null) user.followers.any { it == follower } else false 53 | 54 | fun getProfileByUser(user: User?, following: Boolean = false) = 55 | ProfileResponse(profile = user?.run { ProfileResponse.Profile(username, bio, image, following) }) -------------------------------------------------------------------------------- /src/main/kotlin/util/auth.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.util 2 | 3 | import com.auth0.jwt.JWT 4 | import com.auth0.jwt.algorithms.Algorithm 5 | 6 | open class SimpleJWT(secret: String) { 7 | private val algorithm = Algorithm.HMAC256(secret) 8 | val verifier = JWT.require(algorithm).build() 9 | fun sign(id: T): String = JWT.create().withClaim("id", id.toString()).sign(algorithm) 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/util/errors.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.util 2 | 3 | class AuthenticationException : RuntimeException() 4 | class AuthorizationException : RuntimeException() 5 | 6 | open class ValidationException(val params: Map>) : RuntimeException() 7 | 8 | class UserExists : RuntimeException() 9 | 10 | class UserDoesNotExists : RuntimeException() 11 | 12 | class ArticleDoesNotExist(val slug: String) : RuntimeException() 13 | 14 | class CommentNotFound() : RuntimeException() 15 | -------------------------------------------------------------------------------- /src/main/kotlin/util/web.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs.util 2 | 3 | import io.ktor.server.application.* 4 | import io.ktor.server.auth.* 5 | 6 | 7 | fun ApplicationCall.userId() = principal()?.name ?: throw AuthenticationException() 8 | 9 | fun ApplicationCall.param(param: String) = 10 | parameters[param] ?: throw ValidationException(mapOf("param" to listOf("can't be empty"))) -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | ktor { 2 | deployment { 3 | // Define the port the server will listen on 4 | port = 8080 5 | // You could also configure an SSL port if needed 6 | // sslPort = 8443 7 | } 8 | 9 | application { 10 | // Your application modules, etc. 11 | modules = [ com.nooblabs.ApplicationKt.module ] // Adjust to your main module function 12 | } 13 | } 14 | jwt { 15 | secret = foobar 16 | } -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/test/kotlin/ApplicationTest.kt: -------------------------------------------------------------------------------- 1 | package com.nooblabs 2 | 3 | import io.ktor.client.plugins.contentnegotiation.* 4 | import io.ktor.client.request.* 5 | import io.ktor.client.statement.* 6 | import io.ktor.http.* 7 | import io.ktor.serialization.jackson.* 8 | import io.ktor.server.config.* 9 | import io.ktor.server.testing.* 10 | import java.time.LocalDateTime 11 | import kotlin.test.Test 12 | import kotlin.test.assertEquals 13 | import kotlin.test.assertNotNull 14 | import kotlin.test.assertTrue 15 | 16 | class ApplicationTest { 17 | 18 | companion object { 19 | private val username = "u${LocalDateTime.now().nano}" 20 | private val email = "$username@mail.com" 21 | private val password = "pass1234" 22 | } 23 | 24 | @Test 25 | fun `Register User`() = testApplication { 26 | environment { 27 | config = ApplicationConfig("application.conf") 28 | } 29 | val client = createClient { 30 | install(ContentNegotiation) { 31 | jackson() 32 | } 33 | } 34 | 35 | val response = client.post("/api/users") { 36 | contentType(ContentType.Application.Json) 37 | setBody( 38 | mapOf( 39 | "user" to mapOf( 40 | "email" to email, 41 | "password" to password, 42 | "username" to username 43 | ) 44 | ) 45 | ) 46 | } 47 | 48 | assertEquals(HttpStatusCode.OK, response.status) 49 | response.bodyAsText().also { content -> 50 | assertNotNull(content) 51 | assertTrue(content.contains("email")) 52 | assertTrue(content.contains("username")) 53 | assertTrue(content.contains("bio")) 54 | assertTrue(content.contains("image")) 55 | assertTrue(content.contains("token")) 56 | } 57 | } 58 | 59 | 60 | @Test 61 | fun `Login User`() = testApplication { 62 | environment { 63 | config = ApplicationConfig("application.conf") 64 | } 65 | val client = createClient { 66 | install(ContentNegotiation) { 67 | jackson() 68 | } 69 | } 70 | 71 | val response = client.post("/api/users/login") { 72 | contentType(ContentType.Application.Json) 73 | setBody( 74 | mapOf( 75 | "user" to mapOf( 76 | "email" to email, 77 | "password" to password 78 | ) 79 | ) 80 | ) 81 | } 82 | 83 | assertEquals(HttpStatusCode.OK, response.status) 84 | response.bodyAsText().also { content -> 85 | assertNotNull(content) 86 | assertTrue(content.contains("email")) 87 | assertTrue(content.contains("username")) 88 | assertTrue(content.contains("bio")) 89 | assertTrue(content.contains("image")) 90 | assertTrue(content.contains("token")) 91 | } 92 | } 93 | } 94 | --------------------------------------------------------------------------------