├── .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 | # 
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 | 
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 |
--------------------------------------------------------------------------------