├── logo.png ├── favicon.ico ├── package.json ├── settings.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .dockerignore ├── .sdkmanrc ├── backend ├── src │ ├── main │ │ ├── resources │ │ │ ├── keystore.p12 │ │ │ └── META-INF │ │ │ │ └── native-image │ │ │ │ └── com.hexagonkt │ │ │ │ └── core │ │ │ │ └── native-image.properties │ │ └── kotlin │ │ │ ├── rest │ │ │ ├── messages │ │ │ │ ├── TagsMessages.kt │ │ │ │ ├── ProfileMessages.kt │ │ │ │ ├── UsersMessages.kt │ │ │ │ ├── UserMessages.kt │ │ │ │ ├── CommentsMessages.kt │ │ │ │ ├── RoutesMessages.kt │ │ │ │ └── ArticlesMessages.kt │ │ │ ├── TagsController.kt │ │ │ ├── UserController.kt │ │ │ ├── UsersController.kt │ │ │ ├── RestApi.kt │ │ │ ├── ProfilesController.kt │ │ │ ├── ApiController.kt │ │ │ ├── CommentsController.kt │ │ │ └── ArticlesController.kt │ │ │ ├── domain │ │ │ ├── ArticlesService.kt │ │ │ ├── model │ │ │ │ ├── User.kt │ │ │ │ ├── Comment.kt │ │ │ │ └── Article.kt │ │ │ └── UsersService.kt │ │ │ ├── Settings.kt │ │ │ ├── Jwt.kt │ │ │ └── Main.kt │ └── test │ │ ├── kotlin │ │ ├── JwtTest.kt │ │ ├── rest │ │ │ ├── it │ │ │ │ ├── ApiControllerIT.kt │ │ │ │ ├── ITBase.kt │ │ │ │ ├── CorsIT.kt │ │ │ │ ├── ProfilesControllerIT.kt │ │ │ │ ├── TagsIT.kt │ │ │ │ ├── UsersControllerIT.kt │ │ │ │ ├── UserControllerIT.kt │ │ │ │ ├── CommentsIT.kt │ │ │ │ └── ArticlesIT.kt │ │ │ ├── messages │ │ │ │ └── ArticlesMessagesTest.kt │ │ │ └── ArticlesRouterTest.kt │ │ ├── ArchTest.kt │ │ └── RealWorldClient.kt │ │ └── resources │ │ ├── postman │ │ ├── docker.json │ │ └── local.json │ │ └── swagger │ │ └── swagger.yml ├── native.dockerfile ├── distribution.dockerfile ├── jpackage.dockerfile ├── build.gradle.kts └── README.md ├── k8s ├── config.yaml ├── volumes.yaml ├── mongodb.yaml └── backend.yaml ├── .github ├── workflows │ └── main.yml └── pre-push.sh ├── gradle.properties ├── docker-compose.yml ├── .gitignore ├── .editorconfig ├── gradlew.bat ├── README.md ├── CODE_OF_CONDUCT.md └── gradlew /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexagontk/real_world/HEAD/logo.png -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexagontk/real_world/HEAD/favicon.ico -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "newman": "^6.2.1" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | rootProject.name = "real_world" 3 | 4 | include("backend") 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexagontk/real_world/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | .github/ 3 | .idea/ 4 | .gradle/ 5 | build/ 6 | node_modules/ 7 | 8 | package*.json 9 | logo.png 10 | -------------------------------------------------------------------------------- /.sdkmanrc: -------------------------------------------------------------------------------- 1 | # Enable auto-env through the sdkman_auto_env config 2 | # Add key=value pairs of SDKs to use below 3 | java=21.0.2-graalce 4 | -------------------------------------------------------------------------------- /backend/src/main/resources/keystore.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexagontk/real_world/HEAD/backend/src/main/resources/keystore.p12 -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/messages/TagsMessages.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.messages 2 | 3 | data class TagsResponseRoot(val tags: Collection) 4 | -------------------------------------------------------------------------------- /k8s/config.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: v1 3 | kind: ConfigMap 4 | 5 | metadata: 6 | name: backend-configmap 7 | 8 | data: 9 | bindAddress: "0.0.0.0" 10 | mongodbUrl: "mongodb://mongodb:3010/real_world" 11 | -------------------------------------------------------------------------------- /backend/src/main/resources/META-INF/native-image/com.hexagonkt/core/native-image.properties: -------------------------------------------------------------------------------- 1 | Args= \ 2 | -march=native \ 3 | -R:MaxHeapSize=64 \ 4 | -H:IncludeResources=keystore\\.p12 5 | 6 | # Oracle GraalVM 7 | #Args=--static --libc=musl --gc=G1 --enable-sbom 8 | -------------------------------------------------------------------------------- /k8s/volumes.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: v1 3 | kind: PersistentVolumeClaim 4 | 5 | metadata: 6 | labels: 7 | io.kompose.service: mongodb 8 | name: mongodb 9 | 10 | spec: 11 | accessModes: 12 | - ReadWriteOnce 13 | resources: 14 | requests: 15 | storage: 100Mi 16 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/domain/ArticlesService.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.domain 2 | 3 | import com.hexagonkt.realworld.domain.model.Article 4 | import com.hexagonkt.store.Store 5 | 6 | data class ArticlesService( 7 | internal val articles: Store, 8 | ) { 9 | } 10 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-all.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/domain/model/User.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.domain.model 2 | 3 | import java.net.URI 4 | 5 | data class User( 6 | val username: String, 7 | val email: String, 8 | val password: String, 9 | val bio: String? = null, 10 | val image: URI? = null, 11 | val following: Set = emptySet() 12 | ) 13 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/domain/model/Comment.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.domain.model 2 | 3 | import java.time.LocalDateTime 4 | 5 | data class Comment( 6 | val id: Int, 7 | val author: String, 8 | val body: String, 9 | val createdAt: LocalDateTime = LocalDateTime.now(), 10 | val updatedAt: LocalDateTime = LocalDateTime.now() 11 | ) 12 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/JwtTest.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld 2 | 3 | import com.hexagonkt.core.urlOf 4 | import org.junit.jupiter.api.Test 5 | import kotlin.test.assertEquals 6 | 7 | class JwtTest { 8 | 9 | @Test fun `JWT creation and parsing works properly`() { 10 | val jwt = Jwt(urlOf("classpath:keystore.p12"), "storepass", "realWorld") 11 | val token = jwt.sign("subject") 12 | 13 | assertEquals("subject", jwt.verify(token)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | 2 | on: 3 | - push 4 | - pull_request 5 | 6 | jobs: 7 | main: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: 20.x 14 | cache: npm 15 | - uses: graalvm/setup-graalvm@v1 16 | with: 17 | version: latest 18 | distribution: graalvm-community 19 | java-version: 21 20 | cache: gradle 21 | - run: .github/pre-push.sh 22 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/domain/model/Article.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.domain.model 2 | 3 | import java.time.LocalDateTime 4 | 5 | data class Article( 6 | val slug: String, 7 | val author: String, 8 | val title: String, 9 | val description: String, 10 | val body: String, 11 | val tagList: Set, 12 | val createdAt: LocalDateTime = LocalDateTime.now(), 13 | val updatedAt: LocalDateTime = LocalDateTime.now(), 14 | val favoritedBy: Set = emptySet(), 15 | val comments: List = emptyList() 16 | ) 17 | -------------------------------------------------------------------------------- /.github/pre-push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | docker compose --profile local up -d --build --force-recreate 6 | 7 | ./gradlew build 8 | 9 | npm install newman 10 | 11 | export TEST_RES="backend/src/test/resources/postman" 12 | node_modules/.bin/newman run --verbose $TEST_RES/postman.json -e $TEST_RES/docker.json 13 | 14 | export REPO="https://raw.githubusercontent.com/gothinkster/realworld" 15 | mkdir -p build 16 | curl $REPO/main/api/Conduit.postman_collection.json -o build/postman.json 17 | node_modules/.bin/newman run --verbose build/postman.json -e $TEST_RES/docker.json 18 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/messages/ProfileMessages.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.messages 2 | 3 | import com.hexagonkt.core.requirePath 4 | 5 | data class ProfileResponse( 6 | val username: String, 7 | val bio: String, 8 | val image: String, 9 | val following: Boolean 10 | ) { 11 | constructor(data: Map) : this( 12 | data.requirePath("profile", ProfileResponse::username), 13 | data.requirePath("profile", ProfileResponse::bio), 14 | data.requirePath("profile", ProfileResponse::image), 15 | data.requirePath("profile", ProfileResponse::following), 16 | ) 17 | } 18 | 19 | data class ProfileResponseRoot(val profile: ProfileResponse) 20 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # suppress inspection "UnusedProperty" for whole file 2 | 3 | org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 4 | org.gradle.logging.level=warn 5 | org.gradle.warning.mode=all 6 | org.gradle.console=plain 7 | org.gradle.parallel=true 8 | 9 | # Gradle 10 | version=1.0 11 | group=com.hexagonkt.realworld 12 | description=Real World Implementation 13 | 14 | #modules=java.logging,java.management 15 | modules=java.logging 16 | kotlin.code.style=official 17 | gradleScripts=https://raw.githubusercontent.com/hexagonkt/hexagon/3.6.6/gradle 18 | 19 | mockkVersion=1.13.11 20 | hexagonVersion=3.6.6 21 | hexagonExtraVersion=3.6.0 22 | javaJwtVersion=4.4.0 23 | archUnitVersion=1.3.0 24 | slf4jVersion=2.0.16 25 | 26 | testcontainersVersion=1.20.1 27 | -------------------------------------------------------------------------------- /backend/native.dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # BUILD 3 | # 4 | FROM ghcr.io/graalvm/native-image-community:21-muslib as build 5 | USER root 6 | ENV DOCKER_BUILD=true 7 | WORKDIR /build 8 | 9 | ADD . . 10 | RUN microdnf -y install findutils 11 | RUN ./gradlew nativeCompile 12 | 13 | # 14 | # RUNTIME 15 | # 16 | FROM scratch 17 | LABEL description="Realworld API" 18 | 19 | # Project setup 20 | ENV PROJECT backend 21 | ENV TZ UTC 22 | 23 | EXPOSE 2010 24 | 25 | # Machine setup 26 | VOLUME /tmp 27 | #RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 28 | 29 | # Project install 30 | USER 1000 31 | COPY --from=build --chown=1000:1000 /build/$PROJECT/build/$PROJECT/ /opt/$PROJECT 32 | 33 | # Process execution 34 | WORKDIR /opt/$PROJECT 35 | ENTRYPOINT /opt/backend/bin/backend 36 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/messages/UsersMessages.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.messages 2 | 3 | import com.hexagonkt.core.requirePath 4 | 5 | data class RegistrationRequest( 6 | val email: String, 7 | val username: String, 8 | val password: String 9 | ) { 10 | constructor(data: Map) : this( 11 | data.requirePath(RegistrationRequest::email), 12 | data.requirePath(RegistrationRequest::username), 13 | data.requirePath(RegistrationRequest::password), 14 | ) 15 | } 16 | 17 | data class LoginRequest( 18 | val email: String, 19 | val password: String 20 | ) { 21 | constructor(data: Map) : this( 22 | data.requirePath(LoginRequest::email), 23 | data.requirePath(LoginRequest::password), 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/Settings.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld 2 | 3 | import com.hexagonkt.core.Jvm.systemSettingOrNull 4 | import com.hexagonkt.core.LOOPBACK_INTERFACE 5 | import java.net.InetAddress 6 | 7 | data class Settings( 8 | val bindAddress: InetAddress = systemSettingOrNull("bindAddress") ?: LOOPBACK_INTERFACE, 9 | val bindPort: Int = systemSettingOrNull("bindPort") ?: 2010, 10 | val keyPairAlias: String = systemSettingOrNull("keyPairAlias") ?: "realWorld", 11 | val keyStorePassword: String = systemSettingOrNull("keyStorePassword") ?: "storepass", 12 | val keyStoreResource: String = 13 | systemSettingOrNull("keyStoreResource") ?: "classpath:keystore.p12", 14 | val mongodbUrl: String = 15 | systemSettingOrNull("mongodbUrl") ?: "mongodb://localhost:3010/real_world", 16 | ) 17 | -------------------------------------------------------------------------------- /backend/distribution.dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # BUILD 3 | # 4 | FROM container-registry.oracle.com/graalvm/native-image:21-muslib-ol9 as build 5 | USER root 6 | ENV DOCKER_BUILD=true 7 | WORKDIR /build 8 | 9 | ADD . . 10 | RUN microdnf -y install findutils 11 | RUN ./gradlew installDist 12 | 13 | # 14 | # RUNTIME 15 | # 16 | FROM docker.io/eclipse-temurin:21-jre-alpine 17 | LABEL description="Realworld API" 18 | 19 | # Project setup 20 | ENV PROJECT backend 21 | ENV TZ UTC 22 | 23 | EXPOSE 2010 24 | 25 | # Machine setup 26 | VOLUME /tmp 27 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 28 | 29 | # Project install 30 | USER 1000 31 | COPY --from=build --chown=1000:1000 /build/$PROJECT/build/install/$PROJECT/ /opt/$PROJECT 32 | 33 | # Process execution 34 | WORKDIR /opt/$PROJECT 35 | ENTRYPOINT /opt/$PROJECT/bin/$PROJECT 36 | -------------------------------------------------------------------------------- /backend/jpackage.dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # BUILD 3 | # 4 | FROM container-registry.oracle.com/graalvm/native-image:21-muslib-ol9 as build 5 | USER root 6 | ENV DOCKER_BUILD=true 7 | WORKDIR /build 8 | 9 | ADD . . 10 | RUN microdnf -y install findutils 11 | RUN ./gradlew jpackage 12 | 13 | # 14 | # RUNTIME 15 | # 16 | FROM docker.io/bellsoft/alpaquita-linux-base:stream-glibc 17 | LABEL description="Realworld API" 18 | 19 | # Project setup 20 | ENV PROJECT backend 21 | ENV TZ UTC 22 | 23 | EXPOSE 2010 24 | 25 | # Machine setup 26 | VOLUME /tmp 27 | #RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 28 | 29 | # Project install 30 | USER 1000 31 | COPY --from=build --chown=1000:1000 /build/$PROJECT/build/$PROJECT/ /opt/$PROJECT 32 | 33 | # Process execution 34 | WORKDIR /opt/$PROJECT 35 | ENTRYPOINT /opt/backend/bin/backend 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | name: real_world 3 | 4 | services: 5 | 6 | mongodb: 7 | image: docker.io/mongo:7-jammy 8 | volumes: 9 | - mongodb:/data/db 10 | environment: 11 | MONGO_INITDB_ROOT_USERNAME: root 12 | MONGO_INITDB_ROOT_PASSWORD: password 13 | MONGO_INITDB_DATABASE: real_world 14 | ports: 15 | - "3010:27017" 16 | 17 | backend: 18 | profiles: [ local ] 19 | labels: 20 | app: realworld 21 | type: backend 22 | depends_on: 23 | - mongodb 24 | environment: 25 | bindAddress: 0.0.0.0 26 | # TODO This should be a secret 27 | mongodbUrl: mongodb://root:password@mongodb/real_world?authSource=admin 28 | image: ${REGISTRY:-}backend 29 | build: 30 | dockerfile: backend/distribution.dockerfile 31 | ports: 32 | - "3090:2010" 33 | 34 | volumes: 35 | mongodb: 36 | driver: local 37 | -------------------------------------------------------------------------------- /backend/src/test/resources/postman/docker.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dcd7a780-80f8-4ccf-9635-1905bca8a2db", 3 | "name": "Docker", 4 | "values": [ 5 | { 6 | "key": "APIURL", 7 | "value": "http://localhost:3090/api", 8 | "enabled": true 9 | }, 10 | { 11 | "key": "EMAIL", 12 | "value": "jake@jake.jake", 13 | "enabled": true 14 | }, 15 | { 16 | "key": "USERNAME", 17 | "value": "jake", 18 | "enabled": true 19 | }, 20 | { 21 | "key": "PASSWORD", 22 | "value": "jakejake", 23 | "enabled": true 24 | }, 25 | { 26 | "key": "slug", 27 | "value": "how-to-train-your-dragon", 28 | "enabled": true 29 | }, 30 | { 31 | "key": "commentId", 32 | "value": "1", 33 | "enabled": true 34 | } 35 | ], 36 | "_postman_variable_scope": "environment", 37 | "_postman_exported_at": "2019-08-29T22:25:24.013Z", 38 | "_postman_exported_using": "Postman/7.5.0" 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/test/resources/postman/local.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dcd7a780-80f8-4ccf-9635-1905bca8a2db", 3 | "name": "Local", 4 | "values": [ 5 | { 6 | "key": "APIURL", 7 | "value": "http://localhost:2010/api", 8 | "enabled": true 9 | }, 10 | { 11 | "key": "EMAIL", 12 | "value": "jake@jake.jake", 13 | "enabled": true 14 | }, 15 | { 16 | "key": "USERNAME", 17 | "value": "jake", 18 | "enabled": true 19 | }, 20 | { 21 | "key": "PASSWORD", 22 | "value": "jakejake", 23 | "enabled": true 24 | }, 25 | { 26 | "key": "slug", 27 | "value": "how-to-train-your-dragon", 28 | "enabled": true 29 | }, 30 | { 31 | "key": "commentId", 32 | "value": "1", 33 | "enabled": true 34 | } 35 | ], 36 | "_postman_variable_scope": "environment", 37 | "_postman_exported_at": "2019-08-29T22:25:24.013Z", 38 | "_postman_exported_using": "Postman/7.5.0" 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/domain/UsersService.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.domain 2 | 3 | import com.hexagonkt.realworld.domain.model.User 4 | import com.hexagonkt.store.Store 5 | 6 | data class UsersService( 7 | val users: Store 8 | ) { 9 | fun register(user: User): String = 10 | users.insertOne(user) 11 | 12 | fun login(email: String, password: String): User? { 13 | val filter = mapOf(User::email.name to email) 14 | val user = users.findOne(filter) ?: error("Not found") 15 | return if (user.password == password) user else null 16 | } 17 | 18 | fun deleteUser(username: String): Boolean = 19 | users.deleteOne(username) 20 | 21 | fun replaceUser(subject: String, updates: Map): Boolean = 22 | users.updateOne(subject, updates) 23 | 24 | fun searchUser(subject: String): User? = 25 | users.findOne(subject) 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/messages/UserMessages.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.messages 2 | 3 | import com.hexagonkt.core.fieldsMapOf 4 | import com.hexagonkt.core.getString 5 | 6 | data class PutUserRequest( 7 | val email: String? = null, 8 | val password: String? = null, 9 | val bio: String? = null, 10 | val image: String? = null 11 | ) { 12 | constructor(data: Map) : this( 13 | data.getString(PutUserRequest::email), 14 | data.getString(PutUserRequest::password), 15 | data.getString(PutUserRequest::bio), 16 | data.getString(PutUserRequest::image), 17 | ) 18 | 19 | fun toFieldsMap(): Map = 20 | fieldsMapOf( 21 | PutUserRequest::email to email, 22 | PutUserRequest::password to password, 23 | PutUserRequest::bio to bio, 24 | PutUserRequest::image to image, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Private / Local 3 | private_* 4 | local_* 5 | 6 | # dependencies 7 | /node_modules 8 | /dist 9 | 10 | # Binaries 11 | *.out 12 | 13 | # Maven 14 | target/ 15 | pom.xml.* 16 | release.properties 17 | dependency-reduced-pom.xml 18 | buildNumber.properties 19 | *.versionsBackup 20 | 21 | # Gradle 22 | .gradle/ 23 | build/ 24 | 25 | # Eclipse 26 | bin/ 27 | .settings/ 28 | .project 29 | .classpath 30 | .c9/ 31 | *.launch 32 | *.sublime-workspace 33 | 34 | # VS Code 35 | .vscode/* 36 | !.vscode/settings.json 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | !.vscode/extensions.json 40 | .history/* 41 | 42 | # Idea 43 | .idea/ 44 | out/ 45 | *.iml 46 | 47 | # Runtime 48 | log/ 49 | *.log 50 | *.hprof 51 | *.build_artifacts.txt 52 | .attach_pid* 53 | hs_err_pid* 54 | 55 | # Other Tools 56 | kotlin-js-store/ 57 | node_modules/ 58 | .vagrant/ 59 | .env 60 | 61 | # System Files 62 | .DS_Store 63 | Thumbs.db 64 | 65 | # Log files 66 | npm-debug.log* 67 | yarn-debug.log* 68 | yarn-error.log* 69 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/Jwt.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld 2 | 3 | import com.auth0.jwt.JWT 4 | import com.auth0.jwt.algorithms.Algorithm 5 | import com.hexagonkt.core.security.getPrivateKey 6 | import com.hexagonkt.core.security.getPublicKey 7 | import com.hexagonkt.core.security.loadKeyStore 8 | import com.hexagonkt.http.handlers.HttpContext 9 | import java.net.URL 10 | 11 | class Jwt(keyStoreResource: URL, password: String, private val alias: String) { 12 | private val keyStore = loadKeyStore(keyStoreResource, password) 13 | private val privateKey = keyStore.getPrivateKey(alias, password) 14 | private val publicKey = keyStore.getPublicKey(alias) 15 | private val algorithm: Algorithm = Algorithm.RSA256(publicKey, privateKey) 16 | private val verifier = JWT.require(algorithm).withIssuer(alias).build() 17 | 18 | fun sign(subject: String): String = 19 | JWT.create().withIssuer(alias).withSubject(subject).sign(algorithm) 20 | 21 | fun verify(token: String): String = 22 | verifier.verify(token).subject 23 | 24 | fun parsePrincipal(context: HttpContext): String? = 25 | context.request.authorization?.let { verify(it.value) } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/it/ApiControllerIT.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.it 2 | 3 | import com.hexagonkt.http.model.NOT_FOUND_404 4 | import com.hexagonkt.realworld.RealWorldClient 5 | import com.hexagonkt.realworld.restApi 6 | import com.hexagonkt.realworld.domain.model.User 7 | import org.junit.jupiter.api.Test 8 | import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable 9 | import java.net.URI 10 | import kotlin.test.assertEquals 11 | 12 | /** 13 | * TODO Test bad requests (invalid JSON, bad field formats, etc.) 14 | */ 15 | @DisabledIfEnvironmentVariable(named = "DOCKER_BUILD", matches = "true") 16 | internal class ApiControllerIT : ITBase() { 17 | 18 | private val jake = User( 19 | username = "jake", 20 | email = "jake@jake.jake", 21 | password = "jakejake", 22 | bio = "I work at statefarm", 23 | image = URI("https://i.pravatar.cc/150?img=3") 24 | ) 25 | 26 | @Test fun `Non existing route returns a 404`() { 27 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 28 | val jakeClient = client.initializeUser(jake) 29 | 30 | jakeClient.client.get("/404").apply { assertEquals(NOT_FOUND_404, status) } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/TagsController.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest 2 | 3 | import com.hexagonkt.http.handlers.HttpController 4 | import com.hexagonkt.http.handlers.path 5 | import com.hexagonkt.http.model.ContentType 6 | import com.hexagonkt.realworld.rest.messages.TagsResponseRoot 7 | import com.hexagonkt.realworld.domain.model.Article 8 | import com.hexagonkt.store.Store 9 | 10 | internal data class TagsController( 11 | private val articles: Store, 12 | private val contentType: ContentType, 13 | ) : HttpController { 14 | 15 | override val handler by lazy { 16 | path { 17 | get { 18 | val field = Article::tagList.name 19 | val tags = articles.findAll(listOf(field)) 20 | .flatMap { 21 | it[field]?.let { tags -> 22 | if (tags is Collection<*>) 23 | tags.map { tag -> tag.toString() } 24 | else 25 | null 26 | } ?: emptyList() 27 | } 28 | .distinct() 29 | 30 | ok(TagsResponseRoot(tags), contentType = contentType) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/ArchTest.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld 2 | 3 | import com.tngtech.archunit.core.domain.JavaClasses 4 | import com.tngtech.archunit.core.importer.ClassFileImporter 5 | import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes 6 | import kotlin.test.Test 7 | 8 | internal class ArchTest { 9 | 10 | companion object { 11 | private val APPLICATION_PACKAGE: String = Settings::class.java.`package`.name 12 | private val DOMAIN_PACKAGE: String = "$APPLICATION_PACKAGE.domain" 13 | private val DOMAIN_MODEL_PACKAGE: String = "$DOMAIN_PACKAGE.model" 14 | private val ADAPTER_PACKAGE: String = "$APPLICATION_PACKAGE.adapter" 15 | 16 | private val classes: JavaClasses = ClassFileImporter().importPackages(APPLICATION_PACKAGE) 17 | } 18 | 19 | @Test fun `Domain can only access domain`() { 20 | classes() 21 | .that() 22 | .resideInAPackage("$DOMAIN_PACKAGE..") 23 | .should() 24 | .onlyAccessClassesThat() 25 | .resideInAnyPackage( 26 | "$DOMAIN_PACKAGE..", 27 | "java..", 28 | "kotlin..", 29 | "com.hexagonkt.store.." // TODO This must be deleted after refactor 30 | ) 31 | .check(classes) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/it/ITBase.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.it 2 | 3 | import com.hexagonkt.core.Jvm 4 | import com.hexagonkt.realworld.restApi 5 | import com.hexagonkt.realworld.main 6 | import com.hexagonkt.serialization.SerializationManager 7 | import com.hexagonkt.serialization.jackson.json.Json 8 | import org.junit.jupiter.api.AfterAll 9 | import org.junit.jupiter.api.BeforeAll 10 | import org.junit.jupiter.api.TestInstance 11 | import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS 12 | import org.testcontainers.containers.MongoDBContainer 13 | 14 | @TestInstance(PER_CLASS) 15 | internal open class ITBase { 16 | 17 | private val port = Jvm.systemSettingOrNull("realWorldPort") 18 | 19 | private val mongoDb: MongoDBContainer by lazy { 20 | MongoDBContainer("mongo:7-jammy") 21 | .withExposedPorts(27017) 22 | .apply { start() } 23 | } 24 | 25 | private val mongodbUrl by lazy { 26 | "mongodb://localhost:${mongoDb.getMappedPort(27017)}/real_world" 27 | } 28 | 29 | @BeforeAll fun startup() { 30 | SerializationManager.formats = setOf(Json) 31 | if (port != null) 32 | return 33 | System.setProperty("mongodbUrl", mongodbUrl) 34 | 35 | main() 36 | } 37 | 38 | @AfterAll fun shutdown() { 39 | restApi.server.stop() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/messages/CommentsMessages.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.messages 2 | 3 | import com.hexagonkt.core.requirePath 4 | import com.hexagonkt.realworld.domain.model.Comment 5 | import com.hexagonkt.realworld.domain.model.User 6 | 7 | data class CommentRequest(val body: String) { 8 | constructor(data: Map) : this( 9 | data.requirePath(CommentRequest::body), 10 | ) 11 | } 12 | 13 | data class CommentResponse( 14 | val id: Int, 15 | val createdAt: String, 16 | val updatedAt: String, 17 | val body: String, 18 | val author: AuthorResponse 19 | ) { 20 | constructor(data: Map) : this( 21 | data.requirePath(CommentResponse::id), 22 | data.requirePath(CommentResponse::createdAt), 23 | data.requirePath(CommentResponse::updatedAt), 24 | data.requirePath(CommentResponse::body), 25 | AuthorResponse(data.requirePath(CommentResponse::author)), 26 | ) 27 | 28 | constructor(comment: Comment, author: User, user: User?): this( 29 | id = comment.id, 30 | createdAt = comment.createdAt.toUtc(), 31 | updatedAt = comment.updatedAt.toUtc(), 32 | body = comment.body, 33 | author = AuthorResponse( 34 | username = author.username, 35 | bio = author.bio ?: "", 36 | image = author.image?.toString() ?: "", 37 | following = user?.following?.contains(author.username) ?: false 38 | ) 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/messages/RoutesMessages.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.messages 2 | 3 | import com.hexagonkt.core.requireString 4 | import com.hexagonkt.core.withZone 5 | import com.hexagonkt.realworld.domain.model.User 6 | import java.time.LocalDateTime 7 | import java.time.ZoneId 8 | import java.time.format.DateTimeFormatter.ISO_ZONED_DATE_TIME 9 | 10 | data class OkResponse(val message: String) 11 | 12 | data class ErrorResponse(val body: List = listOf("Unknown error")) 13 | 14 | data class ErrorResponseRoot(val errors: ErrorResponse) 15 | 16 | data class UserResponse( 17 | val email: String, 18 | val username: String, 19 | val bio: String, 20 | val image: String, 21 | val token: String 22 | ) { 23 | constructor(data: Map) : this( 24 | data.requireString(UserResponse::email), 25 | data.requireString(UserResponse::username), 26 | data.requireString(UserResponse::bio), 27 | data.requireString(UserResponse::image), 28 | data.requireString(UserResponse::token), 29 | ) 30 | } 31 | 32 | data class UserResponseRoot(val user: UserResponse) { 33 | constructor(user: User, token: String) : this( 34 | UserResponse( 35 | email = user.email, 36 | username = user.username, 37 | bio = user.bio ?: "", 38 | image = user.image?.toString() ?: "", 39 | token = token 40 | ) 41 | ) 42 | } 43 | 44 | fun LocalDateTime.toUtc(): String = 45 | withZone(ZoneId.of("Z")).format(ISO_ZONED_DATE_TIME) 46 | -------------------------------------------------------------------------------- /k8s/mongodb.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | 5 | metadata: 6 | annotations: 7 | kompose.cmd: kompose convert -f docker-compose.yml -o k8s 8 | kompose.version: 1.31.0 (HEAD) 9 | labels: 10 | io.kompose.service: mongodb 11 | name: mongodb 12 | 13 | spec: 14 | replicas: 1 15 | selector: 16 | matchLabels: 17 | io.kompose.service: mongodb 18 | strategy: 19 | type: Recreate 20 | template: 21 | metadata: 22 | annotations: 23 | kompose.cmd: kompose convert -f docker-compose.yml -o k8s 24 | kompose.version: 1.31.0 (HEAD) 25 | labels: 26 | io.kompose.network/real-world-default: "true" 27 | io.kompose.service: mongodb 28 | spec: 29 | containers: 30 | - image: docker.io/mongo:7-jammy 31 | name: mongodb 32 | ports: 33 | - containerPort: 27017 34 | hostPort: 3010 35 | protocol: TCP 36 | resources: {} 37 | volumeMounts: 38 | - mountPath: /data/db 39 | name: mongodb 40 | restartPolicy: Always 41 | volumes: 42 | - name: mongodb 43 | persistentVolumeClaim: 44 | claimName: mongodb 45 | 46 | --- 47 | 48 | apiVersion: v1 49 | kind: Service 50 | 51 | metadata: 52 | annotations: 53 | kompose.cmd: kompose convert -f docker-compose.yml -o k8s 54 | kompose.version: 1.31.0 (HEAD) 55 | labels: 56 | io.kompose.service: mongodb 57 | name: mongodb 58 | 59 | spec: 60 | ports: 61 | - name: "3010" 62 | port: 3010 63 | targetPort: 27017 64 | selector: 65 | io.kompose.service: mongodb 66 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/it/CorsIT.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.it 2 | 3 | import com.hexagonkt.core.media.APPLICATION_JSON 4 | import com.hexagonkt.core.urlOf 5 | import com.hexagonkt.http.client.HttpClient 6 | import com.hexagonkt.http.client.HttpClientSettings 7 | import com.hexagonkt.http.client.jetty.JettyClientAdapter 8 | import com.hexagonkt.http.model.ContentType 9 | import com.hexagonkt.http.model.Header 10 | import com.hexagonkt.http.model.Headers 11 | import com.hexagonkt.realworld.restApi 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable 14 | import kotlin.test.assertEquals 15 | 16 | @DisabledIfEnvironmentVariable(named = "DOCKER_BUILD", matches = "true") 17 | internal class CorsIT : ITBase() { 18 | 19 | @Test fun `OPTIONS returns correct CORS headers`() { 20 | val baseUrl = urlOf("http://localhost:${restApi.server.runtimePort}/api") 21 | val settings = HttpClientSettings(baseUrl = baseUrl, contentType = ContentType(APPLICATION_JSON)) 22 | val client = HttpClient(JettyClientAdapter(), settings) 23 | client.start() 24 | val corsHeaders = "accept,user-agent,host,content-type" 25 | val response = client.options("/tags", headers = Headers( 26 | Header("origin", "localhost"), 27 | Header("access-control-request-headers", corsHeaders), 28 | Header("access-control-request-method", "GET"), 29 | ) 30 | ) 31 | 32 | assertEquals(204, response.status.code) 33 | assertEquals(corsHeaders, response.headers["access-control-allow-headers"]?.value) 34 | assertEquals("localhost", response.headers["access-control-allow-origin"]?.value) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/it/ProfilesControllerIT.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.it 2 | 3 | import com.hexagonkt.realworld.RealWorldClient 4 | import com.hexagonkt.realworld.restApi 5 | import com.hexagonkt.realworld.domain.model.User 6 | import org.junit.jupiter.api.Test 7 | import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable 8 | import java.net.URI 9 | 10 | @DisabledIfEnvironmentVariable(named = "DOCKER_BUILD", matches = "true") 11 | internal class ProfilesControllerIT : ITBase() { 12 | 13 | private val jake = User( 14 | username = "jake", 15 | email = "jake@jake.jake", 16 | password = "jakejake", 17 | bio = "I work at statefarm", 18 | image = URI("https://i.pravatar.cc/150?img=3") 19 | ) 20 | 21 | private val jane = User( 22 | username = "jane", 23 | email = "jane@jane.jane", 24 | password = "janejane", 25 | bio = "I own MegaCloud", 26 | image = URI("https://i.pravatar.cc/150?img=1") 27 | ) 28 | 29 | @Test fun `Follow and unfollow a profile`() { 30 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 31 | val jakeClient = client.initializeUser(jake) 32 | val janeClient = client.initializeUser(jane) 33 | 34 | jakeClient.getProfile(jane, false) 35 | jakeClient.followProfile(jane, true) 36 | jakeClient.getProfile(jane, true) 37 | jakeClient.followProfile(jane, false) 38 | jakeClient.getProfile(jane, false) 39 | 40 | janeClient.getProfile(jake, false) 41 | janeClient.followProfile(jake, true) 42 | janeClient.getProfile(jake, true) 43 | janeClient.followProfile(jake, false) 44 | janeClient.getProfile(jake, false) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/messages/ArticlesMessagesTest.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.messages 2 | 3 | import com.hexagonkt.realworld.domain.model.Article 4 | import com.hexagonkt.realworld.domain.model.Comment 5 | import com.hexagonkt.realworld.domain.model.User 6 | import org.junit.jupiter.api.Test 7 | 8 | class ArticlesMessagesTest { 9 | 10 | @Test fun `ArticleResponse is created from an article, a user and an author`() { 11 | 12 | val jake = User( 13 | username = "jake", 14 | email = "jake@jake.jake", 15 | password = "jakejake" 16 | ) 17 | 18 | val trainDragon = Article( 19 | title = "How to train your dragon", 20 | slug = "how-to-train-your-dragon", 21 | description = "Ever wonder how?", 22 | body = "Very carefully.", 23 | tagList = linkedSetOf("dragons", "training"), 24 | author = jake.username 25 | ) 26 | 27 | val response = ArticleResponseRoot(trainDragon, jake, null) 28 | assert(!response.article.favorited) 29 | assert(response.article.author.bio.isEmpty()) 30 | assert(response.article.author.image.isEmpty()) 31 | assert(!response.article.author.following) 32 | } 33 | 34 | @Test fun `comments are created properly`() { 35 | 36 | val jake = User( 37 | username = "jake", 38 | email = "jake@jake.jake", 39 | password = "jakejake" 40 | ) 41 | 42 | val trainDragon = Comment(1, jake.username, "Body") 43 | 44 | val response = CommentResponse(trainDragon, jake, null) 45 | assert(response.author.bio.isEmpty()) 46 | assert(response.author.image.isEmpty()) 47 | assert(!response.author.following) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/UserController.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest 2 | 3 | import com.hexagonkt.core.requirePath 4 | import com.hexagonkt.http.handlers.HttpContext 5 | import com.hexagonkt.http.handlers.HttpController 6 | import com.hexagonkt.http.handlers.HttpHandler 7 | import com.hexagonkt.http.handlers.path 8 | import com.hexagonkt.http.model.ContentType 9 | import com.hexagonkt.realworld.rest.messages.PutUserRequest 10 | import com.hexagonkt.realworld.rest.messages.UserResponseRoot 11 | import com.hexagonkt.realworld.Jwt 12 | import com.hexagonkt.realworld.domain.UsersService 13 | import com.hexagonkt.rest.bodyMap 14 | 15 | internal data class UserController( 16 | private val jwt: Jwt, 17 | private val users: UsersService, 18 | private val contentType: ContentType, 19 | private val authenticator: HttpHandler, 20 | ) : HttpController { 21 | 22 | override val handler = path { 23 | use(authenticator) 24 | 25 | get { 26 | val subject = jwt.parsePrincipal(this) ?: return@get unauthorized("Unauthorized") 27 | findUser(subject) 28 | } 29 | 30 | put { 31 | val subject = jwt.parsePrincipal(this) ?: return@put unauthorized("Unauthorized") 32 | val body = PutUserRequest(request.bodyMap().requirePath>("user")) 33 | val updates = body.toFieldsMap() 34 | 35 | val updated = users.replaceUser(subject, updates) 36 | 37 | if (updated) findUser(subject) 38 | else internalServerError("Username $subject not updated") 39 | } 40 | } 41 | 42 | private fun HttpContext.findUser(subject: String): HttpContext { 43 | val user = users.searchUser(subject) ?: return notFound("User: $subject not found") 44 | val token = jwt.sign(user.username) 45 | 46 | return ok(UserResponseRoot(user, token), contentType = contentType) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /k8s/backend.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | 5 | metadata: 6 | annotations: 7 | app: realworld 8 | kompose.cmd: kompose convert -f docker-compose.yml -o k8s 9 | kompose.version: 1.31.0 (HEAD) 10 | type: backend 11 | labels: 12 | io.kompose.service: backend 13 | name: backend 14 | 15 | spec: 16 | replicas: 1 17 | selector: 18 | matchLabels: 19 | io.kompose.service: backend 20 | template: 21 | metadata: 22 | annotations: 23 | app: realworld 24 | kompose.cmd: kompose convert -f docker-compose.yml -o k8s 25 | kompose.version: 1.31.0 (HEAD) 26 | type: backend 27 | labels: 28 | io.kompose.network/real-world-default: "true" 29 | io.kompose.service: backend 30 | spec: 31 | containers: 32 | - env: 33 | - name: bindAddress 34 | valueFrom: 35 | configMapKeyRef: 36 | name: backend-configmap 37 | key: bindAddress 38 | - name: mongodbUrl 39 | valueFrom: 40 | configMapKeyRef: 41 | name: backend-configmap 42 | key: mongodbUrl 43 | image: 192.168.49.2:5000/backend 44 | name: backend 45 | ports: 46 | - containerPort: 2010 47 | hostPort: 3090 48 | protocol: TCP 49 | resources: {} 50 | restartPolicy: Always 51 | 52 | #--- 53 | # 54 | #apiVersion: v1 55 | #kind: Service 56 | # 57 | #metadata: 58 | # annotations: 59 | # app: realworld 60 | # kompose.cmd: kompose convert -f docker-compose.yml -o k8s 61 | # kompose.version: 1.31.0 (HEAD) 62 | # type: backend 63 | # labels: 64 | # io.kompose.service: backend 65 | # name: backend 66 | # 67 | #spec: 68 | # ports: 69 | # - name: "3090" 70 | # port: 3090 71 | # targetPort: 2010 72 | # selector: 73 | # io.kompose.service: backend 74 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | # EditorConfig is awesome: http://EditorConfig.org 3 | 4 | # top-most EditorConfig file 5 | root = true 6 | 7 | # Unix-style newlines with a newline ending every file with UTF 8 encoding 8 | [*] 9 | end_of_line = lf 10 | insert_final_newline = true 11 | charset = utf-8 12 | indent_style = space 13 | indent_size = 2 14 | trim_trailing_whitespace = true 15 | max_line_length = 100 16 | 17 | # IntelliJ specific rules for ANY source file 18 | ij_continuation_indent_size = 2 19 | ij_any_keep_blank_lines_in_code = 1 20 | ij_any_keep_line_breaks = true 21 | ij_any_catch_on_new_line = true 22 | ij_any_else_on_new_line = true 23 | ij_any_finally_on_new_line = true 24 | ij_any_while_on_new_line = true 25 | ij_any_class_annotation_wrap = off 26 | ij_any_field_annotation_wrap = off 27 | ij_any_method_annotation_wrap = off 28 | ij_any_parameter_annotation_wrap = off 29 | ij_any_variable_annotation_wrap = off 30 | 31 | # IntelliJ JSON specific rules 32 | [*.md] 33 | ij_markdown_min_lines_around_header = 0 34 | ij_markdown_min_lines_around_block_elements = 0 35 | 36 | [*.json] 37 | ij_json_space_after_colon = true 38 | ij_json_space_after_comma = true 39 | ij_json_space_before_colon = false 40 | ij_json_space_before_comma = false 41 | ij_json_spaces_within_braces = true 42 | ij_json_spaces_within_brackets = true 43 | ij_json_wrap_long_lines = true 44 | 45 | # IntelliJ YAML specific rules 46 | [*.{yaml,yml}] 47 | ij_yaml_spaces_within_brackets = true 48 | ij_yaml_spaces_within_braces = true 49 | ij_yaml_keep_line_breaks = true 50 | 51 | # IntelliJ HTML formatting 52 | [*.{html,htm}] 53 | ij_html_do_not_indent_children_of_tags = html 54 | 55 | # 4 space indentation 56 | [*.{java,js,ts,kt,kts,gradle,groovy,py}] 57 | indent_size = 4 58 | ij_continuation_indent_size = 4 59 | 60 | # Go language uses tabs by default 61 | [*.go] 62 | indent_style = tab 63 | 64 | # IntelliJ Kotlin specific rules 65 | [*.{kt,kts}] 66 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 67 | ij_kotlin_import_nested_classes = true 68 | 69 | # Log files exception 70 | [*.log] 71 | max_line_length = 200 72 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/it/TagsIT.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.it 2 | 3 | import com.hexagonkt.realworld.RealWorldClient 4 | import com.hexagonkt.realworld.restApi 5 | import com.hexagonkt.realworld.domain.model.Article 6 | import com.hexagonkt.realworld.domain.model.User 7 | import org.junit.jupiter.api.Test 8 | import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable 9 | import java.net.URI 10 | 11 | @DisabledIfEnvironmentVariable(named = "DOCKER_BUILD", matches = "true") 12 | internal class TagsIT : ITBase() { 13 | 14 | private val jake = User( 15 | username = "jake", 16 | email = "jake@jake.jake", 17 | password = "jakejake", 18 | bio = "I work at statefarm", 19 | image = URI("https://i.pravatar.cc/150?img=3") 20 | ) 21 | 22 | private val neverEndingStory = Article( 23 | title = "Never Ending Story", 24 | slug = "never-ending-story", 25 | description = "Fantasia is dying", 26 | body = "Fight for Fantasia!", 27 | tagList = linkedSetOf("dragons", "books"), 28 | author = jake.username 29 | ) 30 | 31 | private val trainDragon = Article( 32 | title = "How to train your dragon", 33 | slug = "how-to-train-your-dragon", 34 | description = "Ever wonder how?", 35 | body = "Very carefully.", 36 | tagList = linkedSetOf("dragons", "training"), 37 | author = jake.username 38 | ) 39 | 40 | @Test fun `Get all tags don't return duplicates`() { 41 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 42 | val jakeClient = client.initializeUser(jake) 43 | 44 | jakeClient.deleteArticle(trainDragon.slug) 45 | jakeClient.deleteArticle(neverEndingStory.slug) 46 | client.getTags() 47 | 48 | jakeClient.postArticle(trainDragon) 49 | client.getTags("dragons", "training") 50 | 51 | jakeClient.postArticle(neverEndingStory) 52 | client.getTags("dragons", "training", "books") 53 | 54 | jakeClient.deleteArticle(trainDragon.slug) 55 | client.getTags("dragons", "books") 56 | 57 | jakeClient.deleteArticle(neverEndingStory.slug) 58 | client.getTags() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/it/UsersControllerIT.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.it 2 | 3 | import com.hexagonkt.core.requirePath 4 | import com.hexagonkt.http.model.INTERNAL_SERVER_ERROR_500 5 | import com.hexagonkt.realworld.RealWorldClient 6 | import com.hexagonkt.realworld.restApi 7 | import com.hexagonkt.realworld.rest.messages.ErrorResponse 8 | import com.hexagonkt.realworld.domain.model.User 9 | import com.hexagonkt.rest.bodyMap 10 | import org.junit.jupiter.api.Test 11 | import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable 12 | import java.net.URI 13 | import kotlin.test.assertEquals 14 | 15 | /** 16 | * TODO 17 | * - Login without credentials 18 | * - Login with bad password 19 | */ 20 | @DisabledIfEnvironmentVariable(named = "DOCKER_BUILD", matches = "true") 21 | internal class UsersControllerIT : ITBase() { 22 | 23 | private val jake = User( 24 | username = "jake", 25 | email = "jake@jake.jake", 26 | password = "jakejake", 27 | bio = "I work at statefarm", 28 | image = URI("https://i.pravatar.cc/150?img=3") 29 | ) 30 | 31 | @Test fun `Delete, login and register users`() { 32 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 33 | 34 | client.deleteUser(jake) 35 | client.deleteUser(jake, setOf(404)) 36 | client.registerUser(jake) 37 | client.registerUser(jake) { 38 | assertEquals(INTERNAL_SERVER_ERROR_500, status) 39 | assertEquals(contentType, contentType) 40 | 41 | val errors = ErrorResponse(bodyMap().requirePath("errors", "body")) 42 | val exception = "MongoWriteException: Write operation error on server localhost" 43 | val message = "WriteError{code=11000, message='E11000 duplicate key error collection" 44 | val key = """real_world.User index: _id_ dup key: { _id: "${jake.username}" }', details={}}.""" 45 | val errorMessage = errors.body.first() 46 | assert(errorMessage.contains(exception)) 47 | assert(errorMessage.contains(message)) 48 | assert(errorMessage.contains(key)) 49 | } 50 | 51 | client.loginUser(jake) 52 | client.loginUser(jake) // Login ok two times in a row should work 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/UsersController.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest 2 | 3 | import com.hexagonkt.core.require 4 | import com.hexagonkt.core.requirePath 5 | import com.hexagonkt.http.handlers.HttpController 6 | import com.hexagonkt.http.handlers.path 7 | import com.hexagonkt.http.model.ContentType 8 | import com.hexagonkt.realworld.Jwt 9 | import com.hexagonkt.realworld.domain.model.User 10 | import com.hexagonkt.realworld.rest.messages.* 11 | import com.hexagonkt.rest.bodyMap 12 | import com.hexagonkt.store.Store 13 | 14 | internal data class UsersController( 15 | private val jwt: Jwt, 16 | private val users: Store, 17 | private val contentType: ContentType, 18 | ) : HttpController{ 19 | 20 | override val handler = path { 21 | // TODO Authenticate and require 'root' user or owner 22 | delete("/{username}") { 23 | val username = pathParameters.require("username") 24 | val deleteOne = users.deleteOne(username) 25 | if (deleteOne) 26 | ok(OkResponse("$username deleted"), contentType = contentType) 27 | else 28 | notFound("$username not found") 29 | } 30 | 31 | post("/login") { 32 | val bodyUser = LoginRequest(request.bodyMap().requirePath("user")) 33 | val filter = mapOf(User::email.name to bodyUser.email) 34 | val user = users.findOne(filter) ?: return@post notFound("Not Found") 35 | if (user.password == bodyUser.password) 36 | ok(UserResponseRoot(user, jwt.sign(user.username)), contentType = contentType) 37 | else 38 | unauthorized("Bad credentials") 39 | } 40 | 41 | post { 42 | val user = RegistrationRequest(request.bodyMap().requirePath("user")) 43 | 44 | val key = users.insertOne(User(user.username, user.email, user.password)) 45 | val content = UserResponseRoot( 46 | UserResponse( 47 | email = user.email, 48 | username = key, 49 | bio = "", 50 | image = "", 51 | token = jwt.sign(key) 52 | ) 53 | ) 54 | 55 | created(content, contentType = contentType) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/it/UserControllerIT.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.it 2 | 3 | import com.hexagonkt.core.media.APPLICATION_JSON 4 | import com.hexagonkt.core.requirePath 5 | import com.hexagonkt.http.model.ContentType 6 | import com.hexagonkt.http.model.UNAUTHORIZED_401 7 | import com.hexagonkt.realworld.RealWorldClient 8 | import com.hexagonkt.realworld.restApi 9 | import com.hexagonkt.realworld.rest.messages.ErrorResponse 10 | import com.hexagonkt.realworld.rest.messages.PutUserRequest 11 | import com.hexagonkt.realworld.domain.model.User 12 | import com.hexagonkt.rest.bodyMap 13 | import org.junit.jupiter.api.Test 14 | import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable 15 | import java.net.URI 16 | import kotlin.test.assertEquals 17 | 18 | @DisabledIfEnvironmentVariable(named = "DOCKER_BUILD", matches = "true") 19 | internal class UserControllerIT : ITBase() { 20 | 21 | private val jake = User( 22 | username = "jake", 23 | email = "jake@jake.jake", 24 | password = "jakejake", 25 | bio = "I work at statefarm", 26 | image = URI("https://i.pravatar.cc/150?img=3") 27 | ) 28 | 29 | @Test fun `Get and update current user`() { 30 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 31 | val jakeClient = client.initializeUser(jake) 32 | 33 | jakeClient.getUser(jake) 34 | jakeClient.updateUser(jake, PutUserRequest(email = jake.email)) 35 | jakeClient.updateUser(jake, PutUserRequest(email = "changed.${jake.email}")) 36 | 37 | client.getUser(jake) { 38 | val errors = ErrorResponse(bodyMap().requirePath("errors", "body")) 39 | assertEquals(UNAUTHORIZED_401, status) 40 | assertEquals(ContentType(APPLICATION_JSON, charset = Charsets.UTF_8), contentType) 41 | assert(errors.body.isNotEmpty()) 42 | assertEquals("Unauthorized", errors.body.first()) 43 | } 44 | 45 | client.updateUser(jake, PutUserRequest(email = jake.email)) { 46 | val errors = ErrorResponse(bodyMap().requirePath("errors", "body")) 47 | assertEquals(UNAUTHORIZED_401, status) 48 | assertEquals(ContentType(APPLICATION_JSON, charset = Charsets.UTF_8), contentType) 49 | assert(errors.body.isNotEmpty()) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/RestApi.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest 2 | 3 | import com.hexagonkt.core.media.APPLICATION_JSON 4 | import com.hexagonkt.http.handlers.FilterHandler 5 | import com.hexagonkt.http.handlers.HttpHandler 6 | import com.hexagonkt.http.model.ContentType 7 | import com.hexagonkt.http.server.* 8 | import com.hexagonkt.http.server.netty.NettyServerAdapter 9 | import com.hexagonkt.realworld.Jwt 10 | import com.hexagonkt.realworld.Settings 11 | import com.hexagonkt.realworld.domain.UsersService 12 | import com.hexagonkt.realworld.domain.model.Article 13 | import com.hexagonkt.realworld.domain.model.User 14 | import com.hexagonkt.store.Store 15 | import kotlin.text.Charsets.UTF_8 16 | 17 | data class RestApi( 18 | private val settings: Settings = Settings(), 19 | private val jwt: Jwt, 20 | private val users: Store, 21 | private val articles: Store, 22 | private val contentType: ContentType = ContentType(APPLICATION_JSON, charset = UTF_8) 23 | ) { 24 | private val authenticator = FilterHandler("*") { 25 | val principal = jwt.parsePrincipal(this) 26 | 27 | if (principal == null) next() 28 | else send(attributes = attributes + ("principal" to principal)).next() 29 | } 30 | 31 | private val serverSettings = HttpServerSettings(settings.bindAddress, settings.bindPort, "/api") 32 | private val serverAdapter = NettyServerAdapter() 33 | private val usersService = UsersService(users) 34 | private val userController = UserController(jwt, usersService, contentType, authenticator) 35 | private val usersController = UsersController(jwt, users, contentType) 36 | private val profilesController = ProfilesController(users, contentType, authenticator) 37 | private val commentsController = CommentsController(jwt, users, articles, contentType) 38 | private val articlesController = ArticlesController(jwt, users, articles, contentType, authenticator, commentsController) 39 | private val tagsController = TagsController(articles, contentType) 40 | private val router: HttpHandler by lazy { ApiController(userController, usersController, profilesController, articlesController, tagsController) } 41 | internal val server: HttpServer by lazy { HttpServer(serverAdapter, router, serverSettings) } 42 | 43 | fun start() { 44 | server.start() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/it/CommentsIT.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.it 2 | 3 | import com.hexagonkt.realworld.RealWorldClient 4 | import com.hexagonkt.realworld.restApi 5 | import com.hexagonkt.realworld.rest.messages.CommentRequest 6 | import com.hexagonkt.realworld.domain.model.Article 7 | import com.hexagonkt.realworld.domain.model.User 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable 10 | import java.net.URI 11 | 12 | @DisabledIfEnvironmentVariable(named = "DOCKER_BUILD", matches = "true") 13 | internal class CommentsIT : ITBase() { 14 | 15 | private val jake = User( 16 | username = "jake", 17 | email = "jake@jake.jake", 18 | password = "jakejake", 19 | bio = "I work at statefarm", 20 | image = URI("https://i.pravatar.cc/150?img=3") 21 | ) 22 | 23 | private val trainDragon = Article( 24 | title = "How to train your dragon", 25 | slug = "how-to-train-your-dragon", 26 | description = "Ever wonder how?", 27 | body = "Very carefully.", 28 | tagList = linkedSetOf("dragons", "training"), 29 | author = jake.username 30 | ) 31 | 32 | @Test fun `Delete, create and get article's comments`() { 33 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 34 | val jakeClient = client.initializeUser(jake) 35 | 36 | jakeClient.deleteArticle(trainDragon.slug) 37 | jakeClient.postArticle(trainDragon) 38 | 39 | jakeClient.createComment(trainDragon.slug, CommentRequest("Nice film")) 40 | jakeClient.getComments(trainDragon.slug, 1) 41 | jakeClient.deleteComment(trainDragon.slug, 1) 42 | } 43 | 44 | @Test fun `Get article's comments without login`() { 45 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 46 | val jakeClient = client.initializeUser(jake) 47 | 48 | jakeClient.deleteArticle(trainDragon.slug) 49 | jakeClient.postArticle(trainDragon) 50 | 51 | jakeClient.createComment(trainDragon.slug, CommentRequest("Nice film")) 52 | client.getComments(trainDragon.slug, 1) 53 | jakeClient.createComment(trainDragon.slug, CommentRequest("Not bad")) 54 | client.getComments(trainDragon.slug, 1, 2) 55 | } 56 | 57 | @Test fun `Post comment to a not created article`() { 58 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 59 | val jakeClient = client.initializeUser(jake) 60 | 61 | jakeClient.createComment("non_existing_article", CommentRequest("Nice film")) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /backend/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.graalvm.buildtools.gradle.dsl.GraalVMExtension 2 | 3 | plugins { 4 | application 5 | war 6 | } 7 | 8 | apply(from = "${properties["gradleScripts"]}/kotlin.gradle") 9 | apply(from = "${properties["gradleScripts"]}/application.gradle") 10 | apply(from = "${properties["gradleScripts"]}/native.gradle") 11 | 12 | extensions.configure { 13 | mainClass.set("com.hexagonkt.realworld.MainKt") 14 | } 15 | 16 | tasks.named("assemble") { 17 | dependsOn("installDist") 18 | } 19 | 20 | dependencies { 21 | val hexagonVersion = properties["hexagonVersion"] 22 | val hexagonExtraVersion = properties["hexagonExtraVersion"] 23 | val javaJwtVersion = properties["javaJwtVersion"] 24 | val testcontainersVersion = properties["testcontainersVersion"] 25 | val mockkVersion = properties["mockkVersion"] 26 | val archUnitVersion = properties["archUnitVersion"] 27 | val slf4jVersion = properties["slf4jVersion"] 28 | 29 | "implementation"("com.hexagonkt:serialization_jackson_json:$hexagonVersion") 30 | "implementation"("com.hexagonkt:http_server_netty:$hexagonVersion") 31 | "implementation"("com.hexagonkt:rest:$hexagonVersion") 32 | "implementation"("com.hexagonkt.extra:store_mongodb:$hexagonExtraVersion") 33 | 34 | "implementation"("com.auth0:java-jwt:$javaJwtVersion") 35 | "implementation"("org.slf4j:jcl-over-slf4j:$slf4jVersion") 36 | "implementation"("org.slf4j:log4j-over-slf4j:$slf4jVersion") 37 | "implementation"("org.slf4j:slf4j-jdk14:$slf4jVersion") 38 | 39 | "testImplementation"("com.tngtech.archunit:archunit-junit5:$archUnitVersion") 40 | "testImplementation"("com.hexagonkt:rest_tools:$hexagonVersion") 41 | "testImplementation"("com.hexagonkt:http_client_jetty:$hexagonVersion") 42 | "testImplementation"("io.mockk:mockk:$mockkVersion") 43 | "testImplementation"("org.testcontainers:mongodb:$testcontainersVersion") { 44 | exclude(module = "commons-compress") 45 | } 46 | } 47 | 48 | extensions.configure { 49 | fun option(name: String, value: (String) -> String): String? = 50 | System.getProperty(name)?.let(value) 51 | 52 | binaries { 53 | named("main") { 54 | listOfNotNull( 55 | option("static") { "--static" }, 56 | option("enableMonitoring") { "--enable-monitoring" }, 57 | option("pgoInstrument") { "--pgo-instrument" }, 58 | option("pgo") { "--pgo=../../../default.iprof" }, 59 | ) 60 | .forEach(buildArgs::add) 61 | } 62 | named("test") { 63 | listOfNotNull( 64 | option("pgoInstrument") { "--pgo-instrument" }, 65 | ) 66 | .forEach(buildArgs::add) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/ProfilesController.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest 2 | 3 | import com.hexagonkt.core.require 4 | import com.hexagonkt.http.handlers.HttpContext 5 | import com.hexagonkt.http.handlers.HttpController 6 | import com.hexagonkt.http.handlers.HttpHandler 7 | import com.hexagonkt.http.handlers.path 8 | import com.hexagonkt.http.model.ContentType 9 | import com.hexagonkt.realworld.rest.messages.ProfileResponse 10 | import com.hexagonkt.realworld.rest.messages.ProfileResponseRoot 11 | import com.hexagonkt.realworld.domain.model.User 12 | import com.hexagonkt.store.Store 13 | 14 | internal data class ProfilesController( 15 | private val users: Store, 16 | private val contentType: ContentType, 17 | private val authenticator: HttpHandler, 18 | ) : HttpController { 19 | 20 | override val handler by lazy { 21 | path { 22 | use(authenticator) 23 | post("/follow") { followProfile(users, true) } 24 | delete("/follow") { followProfile(users, false) } 25 | get { getProfile(users) } 26 | } 27 | } 28 | 29 | private fun HttpContext.getProfile(users: Store): HttpContext { 30 | val subject = attributes["principal"] as String 31 | val user = users.findOne(subject) ?: return notFound("Not Found") 32 | val profile = 33 | users.findOne(pathParameters.require("username")) ?: return notFound("Not Found") 34 | val content = ProfileResponseRoot( 35 | ProfileResponse( 36 | username = profile.username, 37 | bio = profile.bio ?: "", 38 | image = profile.image?.toString() ?: "", 39 | following = user.following.contains(profile.username) 40 | ) 41 | ) 42 | 43 | return ok(content, contentType = contentType) 44 | } 45 | 46 | private fun HttpContext.followProfile( 47 | users: Store, follow: Boolean 48 | ): HttpContext { 49 | 50 | val subject = attributes["principal"] as String 51 | val user = users.findOne(subject) ?: return notFound("Not Found") 52 | val followingList = 53 | if (follow) user.following + pathParameters["username"] 54 | else user.following - pathParameters["username"] 55 | val updated = users.updateOne(subject, mapOf("following" to followingList)) 56 | if (!updated) 57 | return internalServerError() 58 | val profile = 59 | users.findOne(pathParameters.require("username")) ?: return notFound("Not Found") 60 | val content = ProfileResponseRoot( 61 | ProfileResponse( 62 | username = profile.username, 63 | bio = profile.bio ?: "", 64 | image = profile.image?.toString() ?: "", 65 | following = follow 66 | ) 67 | ) 68 | 69 | return ok(content, contentType = contentType) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /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 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/ApiController.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest 2 | 3 | import com.hexagonkt.core.MultipleException 4 | import com.hexagonkt.core.fail 5 | import com.hexagonkt.core.media.APPLICATION_JSON 6 | import com.hexagonkt.http.handlers.HttpContext 7 | import com.hexagonkt.http.handlers.HttpController 8 | import com.hexagonkt.http.handlers.HttpHandler 9 | import com.hexagonkt.http.handlers.path 10 | import com.hexagonkt.http.model.ContentType 11 | import com.hexagonkt.http.model.HttpStatus 12 | import com.hexagonkt.http.server.callbacks.CorsCallback 13 | import com.hexagonkt.http.server.callbacks.LoggingCallback 14 | import com.hexagonkt.realworld.rest.messages.ErrorResponse 15 | import com.hexagonkt.realworld.rest.messages.ErrorResponseRoot 16 | import com.hexagonkt.rest.SerializeResponseCallback 17 | import kotlin.text.Charsets.UTF_8 18 | 19 | internal data class ApiController( 20 | private val userRouter: HttpHandler, 21 | private val usersRouter: HttpHandler, 22 | private val profilesRouter: HttpHandler, 23 | private val articlesRouter: HttpHandler, 24 | private val tagsRouter: HttpHandler, 25 | ) : HttpController { 26 | 27 | val contentType: ContentType = ContentType(APPLICATION_JSON, charset = UTF_8) 28 | val allowedHeaders: Set = setOf("accept", "user-agent", "host", "content-type") 29 | 30 | override val handler by lazy { 31 | path { 32 | filter("*", callback = LoggingCallback(includeBody = false, includeHeaders = false)) 33 | filter("*", callback = CorsCallback(allowedHeaders = allowedHeaders)) 34 | after("*", callback = SerializeResponseCallback()) 35 | 36 | exception { exceptionHandler(exception ?: fail) } 37 | exception { multipleExceptionHandler(exception ?: fail) } 38 | 39 | after("*") { 40 | if (status.code in setOf(401, 403, 404)) statusCodeHandler(status, response.body) 41 | else this 42 | } 43 | 44 | path("/users", usersRouter) 45 | path("/user", userRouter) 46 | path("/profiles/{username}", profilesRouter) 47 | path("/articles", articlesRouter) 48 | path("/tags", tagsRouter) 49 | } 50 | } 51 | 52 | internal fun HttpContext.statusCodeHandler(status: HttpStatus, body: Any): HttpContext { 53 | val messages = when (body) { 54 | is List<*> -> body.mapNotNull { it?.toString() } 55 | else -> listOf(body.toString()) 56 | } 57 | 58 | return send(status, ErrorResponseRoot(ErrorResponse(messages)), contentType = contentType) 59 | } 60 | 61 | internal fun HttpContext.multipleExceptionHandler(error: Exception): HttpContext { 62 | return if (error is MultipleException) { 63 | val messages = error.causes.map { it.message ?: "" } 64 | internalServerError( 65 | ErrorResponseRoot(ErrorResponse(messages)), 66 | contentType = contentType 67 | ) 68 | } 69 | else exceptionHandler(error) 70 | } 71 | 72 | internal fun HttpContext.exceptionHandler(error: Exception): HttpContext { 73 | val errorMessage = error.javaClass.simpleName + ": " + (error.message ?: "") 74 | val errorResponseRoot = ErrorResponseRoot(ErrorResponse(listOf(errorMessage))) 75 | return internalServerError(errorResponseRoot, contentType = contentType) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/CommentsController.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest 2 | 3 | import com.hexagonkt.core.require 4 | import com.hexagonkt.core.requirePath 5 | import com.hexagonkt.http.handlers.HttpController 6 | import com.hexagonkt.http.handlers.path 7 | import com.hexagonkt.http.model.ContentType 8 | import com.hexagonkt.realworld.* 9 | import com.hexagonkt.rest.bodyMap 10 | import com.hexagonkt.realworld.domain.model.Article 11 | import com.hexagonkt.realworld.domain.model.Comment 12 | import com.hexagonkt.realworld.domain.model.User 13 | import com.hexagonkt.realworld.rest.messages.CommentRequest 14 | import com.hexagonkt.realworld.rest.messages.CommentResponse 15 | import com.hexagonkt.realworld.rest.messages.OkResponse 16 | import com.hexagonkt.store.Store 17 | 18 | internal data class CommentsController( 19 | private val jwt: Jwt, 20 | private val users: Store, 21 | private val articles: Store, 22 | private val contentType: ContentType, 23 | ) : HttpController { 24 | 25 | override val handler = path { 26 | post { 27 | val subject = jwt.parsePrincipal(this) ?: return@post unauthorized("Unauthorized") 28 | val slug = pathParameters.require(Article::slug.name) 29 | val article = articles.findOne(slug) ?: return@post notFound("$slug article not found") 30 | val author = users.findOne(article.author) 31 | ?: return@post notFound("${article.author} user not found") 32 | val user = users.findOne(subject) ?: return@post notFound("$subject user not found") 33 | val commentRequest = 34 | CommentRequest(request.bodyMap().requirePath>("comment")) 35 | val comment = Comment( 36 | id = (article.comments.maxOfOrNull { it.id } ?: 0) + 1, 37 | author = subject, 38 | body = commentRequest.body 39 | ) 40 | 41 | val updated = articles.replaceOne(article.copy(comments = article.comments + comment)) 42 | 43 | if (!updated) 44 | return@post internalServerError("Not updated") 45 | 46 | val content = mapOf("comment" to CommentResponse(comment, author, user)) 47 | 48 | ok(content, contentType = contentType) 49 | } 50 | 51 | get { 52 | val subject = jwt.parsePrincipal(this) 53 | val slug = pathParameters.require(Article::slug.name) 54 | val article = articles.findOne(slug) ?: return@get notFound("$slug article not found") 55 | val author = users.findOne(article.author) 56 | ?: return@get notFound("${article.author} user not found") 57 | val user = 58 | if (subject != null) users.findOne(subject) 59 | ?: return@get notFound("$subject user not found") 60 | else null 61 | 62 | val content = article.comments.map { CommentResponse(it, author, user) } 63 | 64 | ok(mapOf("comments" to content), contentType = contentType) 65 | } 66 | 67 | delete("/{id}") { 68 | jwt.parsePrincipal(this) ?: return@delete unauthorized("Unauthorized") 69 | val slug = pathParameters.require(Article::slug.name) 70 | val article = 71 | articles.findOne(slug) ?: return@delete notFound("$slug article not found") 72 | val id = pathParameters.require(Comment::id.name).toInt() 73 | val newArticle = article.copy(comments = article.comments.filter { it.id != id }) 74 | val updated = articles.replaceOne(newArticle) 75 | 76 | if (!updated) 77 | return@delete internalServerError("Not updated") 78 | 79 | ok(OkResponse("$id deleted"), contentType = contentType) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/ArticlesRouterTest.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest 2 | 3 | import com.hexagonkt.realworld.Settings 4 | import com.hexagonkt.realworld.createJwt 5 | import com.hexagonkt.realworld.domain.model.Article 6 | import com.hexagonkt.realworld.domain.model.User 7 | import com.hexagonkt.store.Store 8 | import io.mockk.mockk 9 | import java.net.URI 10 | 11 | internal class ArticlesRouterTest { 12 | 13 | private val jake = User( 14 | username = "jake", 15 | email = "jake@jake.jake", 16 | password = "jakejake", 17 | bio = "I work at statefarm", 18 | image = URI("https://i.pravatar.cc/150?img=3") 19 | ) 20 | 21 | private val jane = User( 22 | username = "jane", 23 | email = "jane@jane.jane", 24 | password = "janejane", 25 | bio = "I own MegaCloud", 26 | image = URI("https://i.pravatar.cc/150?img=1") 27 | ) 28 | 29 | private val trainDragon = Article( 30 | title = "How to train your dragon", 31 | slug = "how-to-train-your-dragon", 32 | description = "Ever wonder how?", 33 | body = "Very carefully.", 34 | tagList = linkedSetOf("dragons", "training"), 35 | author = jane.username 36 | ) 37 | 38 | private val userStore = mockk>() 39 | private val articleStore = mockk>() 40 | private val jwt = createJwt(Settings()) 41 | private val token = jwt.sign(jake.username) 42 | private val principal = jwt.verify(token) 43 | 44 | // @Test fun `Favorite not found article returns 404`() { 45 | // every { articleStore.findOne("sample") } returns null 46 | // 47 | // val request = HttpRequest(GET, path = "sample") 48 | // 49 | // val call = ArticlesRouter().articlesRouter.process(request) 50 | // call.attributes["principal"] = principal 51 | // 52 | // try { 53 | // call.favoriteArticle(userStore, articleStore, true) 54 | // assert(false) 55 | // } 56 | // catch (e: CodedException) { 57 | // assertEquals(404, e.code) 58 | // } 59 | // } 60 | // 61 | // @Test fun `Favorite not found author throws exception`() { 62 | // every { articleStore.findOne("sample") } returns trainDragon 63 | // every { userStore.findOne(trainDragon.author) } returns null 64 | // 65 | // val request = TestRequest(pathParameters = mapOf("slug" to "sample")) 66 | // val call = testCall(request = request) 67 | // call.attributes["principal"] = principal 68 | // 69 | // assertFailsWith { 70 | // call.favoriteArticle(userStore, articleStore, true) 71 | // } 72 | // } 73 | // 74 | // @Test fun `Favorite not found user throws exception`() { 75 | // every { articleStore.findOne("sample") } returns trainDragon 76 | // every { userStore.findOne(trainDragon.author) } returns jane 77 | // every { userStore.findOne(jake.username) } returns null 78 | // 79 | // val request = TestRequest(pathParameters = mapOf("slug" to "sample")) 80 | // val call = testCall(request = request) 81 | // call.attributes["principal"] = principal 82 | // 83 | // assertFailsWith { 84 | // call.favoriteArticle(userStore, articleStore, true) 85 | // } 86 | // } 87 | // 88 | // @Test fun `Favorite failing to update article returns 500`() { 89 | // every { articleStore.findOne("sample") } returns trainDragon 90 | // every { userStore.findOne(trainDragon.author) } returns jane 91 | // every { userStore.findOne(jake.username) } returns jake 92 | // every { articleStore.updateOne("sample", any>()) } returns false 93 | // 94 | // val request = TestRequest(pathParameters = mapOf("slug" to "sample")) 95 | // val call = testCall(request = request) 96 | // call.attributes["principal"] = principal 97 | // 98 | // try { 99 | // call.favoriteArticle(userStore, articleStore, true) 100 | // assert(false) 101 | // } 102 | // catch (e: CodedException) { 103 | // assertEquals(500, e.code) 104 | // } 105 | // } 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # ![RealWorld Hexagon Implementation](logo.png) 3 | > Hexagon codebase containing real world examples (CRUD, auth, advanced patterns, etc.) that 4 | > adheres to the [RealWorld] spec and API. 5 | 6 | ![GitHub CI](https://github.com/hexagonkt/real_world/actions/workflows/main.yml/badge.svg) 7 | 8 | ### [Demo](https://github.com/gothinkster/realworld) 9 | TODO Set working demo URL 10 | 11 | TODO Document setup: refer to `.git/hooks/pre-push.sample` for information about this hook script. 12 | 13 | TODO Document clean up To clean Docker artifacts execute: `sudo docker system prune -af` 14 | 15 | ### [RealWorld] 16 | This codebase was created to demonstrate a fully fledged fullstack application built with 17 | **Hexagon** including CRUD operations, authentication, routing, pagination, and more. 18 | 19 | We've gone to great lengths to adhere to the **Hexagon** community styleguide & best practices. 20 | 21 | For more information on how to this works with other frontends/backends, head over to the 22 | [RealWorld]. 23 | 24 | [RealWorld]: https://github.com/gothinkster/realworld 25 | 26 | # How it works 27 | The project has a Gradle multi-module layout. The goal is to code a full stack application providing 28 | a module for the back-end, the front-end (TBD) and the Mobile application (TBD). 29 | 30 | Docker Compose is used to build images for each module (if applies) and push them to the Docker 31 | Registry. 32 | 33 | See: 34 | 35 | * [backend readme](backend/README.md) 36 | * [frontend readme](frontend/README.md) (TBD) 37 | * [mobile readme](mobile/README.md) (TBD) 38 | 39 | # Getting started 40 | The source code has the bare minimum formatting rules inside the `.editorconfig` file. 41 | 42 | Gradle is the tool used to automate build tasks locally. 43 | 44 | For image creation, `backend/Dockerfile` is used. 45 | 46 | Docker Compose is used to build all modules (from their `Dockerfile`) and run them inside 47 | containers. 48 | 49 | To be sure that everything works before pushing changes, you can link the `deploy/pre-push.sh` file 50 | to the `.git/hooks` directory: 51 | 52 | ln -s $(pwd)/deploy/pre-push.sh .git/hooks/pre-push 53 | 54 | However, you can use `git push --no-verify` to skip these checks. 55 | 56 | Useful build commands: 57 | 58 | * Build: `./gradlew installDist`. Generates: 59 | - Application directory: `backend/build/install/backend` 60 | - Packaged application: `backend/build/distributions` 61 | * Rebuild: `./gradlew clean installDist` 62 | * Run: `./gradlew run` 63 | * Build local container images: `docker compose build` 64 | * Start application inside containers: `docker compose up -d` 65 | 66 | # Testing 67 | Postman is used to perform requests interactively: `backend/src/test/resources/postman/*`. 68 | 69 | # Continuous Integration 70 | The build pipeline is implemented using GitHub Actions, it takes care of checking the tests 71 | (including Postman collection tests) and the following tasks: 72 | 73 | ## Release 74 | Tagging of source code and container images should be done upon Pull Request merge on live branches. 75 | This is still to be implemented by the CI/CD pipeline using GitHub Actions. 76 | 77 | # TODO 78 | * Publish in Docker Registry 79 | * Deploy on K8s 80 | * Add requests' bodies validation returning as many errors as wrong fields 81 | * Code stress tests using Gatling.io against local, container, or deployed service 82 | * Create native executable using GraalVM 83 | * Vue front end 84 | * Publish front end in GitHub pages 85 | * Migrate readme.md API documentation to Swagger 86 | * ArchUnit or Konsist tests 87 | * Cucumber or Kotest tests 88 | * Jlink 89 | * OpenJ9 90 | * Liberica NIK 91 | * Commit message 92 | * PR/MR template 93 | * Cucumber BDD (check transactions) 94 | * ArchUnit (preferred over modules: it allows naming checks, etc.) 95 | 96 | # Features 97 | * Kotlin 98 | * Testcontainers 99 | * Hexagonal Architecture 100 | * Docker compose 101 | * SDKMAN 102 | * Pre push script 103 | 104 | ```bash 105 | REGISTRY="$(minikube ip):5000/" docker compose build backend 106 | REGISTRY="$(minikube ip):5000/" docker compose push backend 107 | ``` 108 | 109 | > Try to add this line to Docker's daemon.json file and restart the Docker Daemon: 110 | > (C:\ProgramData\Docker\config\daemon.json on windows, /etc/docker/daemon.json on linux) 111 | > 112 | > "insecure-registries":["192.168.49.2:5000"] 113 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/Main.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld 2 | 3 | import com.hexagonkt.core.* 4 | import com.hexagonkt.realworld.domain.model.Article 5 | import com.hexagonkt.realworld.domain.model.Comment 6 | import com.hexagonkt.realworld.domain.model.User 7 | import com.hexagonkt.realworld.rest.RestApi 8 | import com.hexagonkt.serialization.SerializationManager 9 | import com.hexagonkt.serialization.jackson.json.Json 10 | import com.hexagonkt.store.Store 11 | import com.hexagonkt.store.mongodb.MongoDbStore 12 | import com.mongodb.client.model.IndexOptions 13 | import com.mongodb.client.model.Indexes 14 | import java.net.URI 15 | 16 | val restApi by lazy { 17 | val settings = Settings() 18 | val jwt = createJwt(settings) 19 | val userStore = createUserStore(settings) 20 | val articleStore = createArticleStore(settings) 21 | 22 | RestApi(settings, jwt, userStore, articleStore) 23 | } 24 | 25 | internal fun main() { 26 | SerializationManager.formats = setOf(Json) 27 | 28 | restApi.start() 29 | } 30 | 31 | internal fun createJwt(settings: Settings): Jwt = 32 | Jwt(urlOf(settings.keyStoreResource), settings.keyStorePassword, settings.keyPairAlias) 33 | 34 | private fun createUserStore(settings: Settings): Store { 35 | val userStore = MongoDbStore( 36 | type = User::class, 37 | key = User::username, 38 | database = MongoDbStore.database(settings.mongodbUrl), 39 | encoder = { 40 | fieldsMapOfNotNull( 41 | User::username to it.username, 42 | User::email to it.email, 43 | User::password to it.password, 44 | User::bio to it.bio, 45 | User::image to it.image, 46 | User::following to it.following, 47 | ) 48 | }, 49 | decoder = { 50 | User( 51 | username = it.requireString(User::username), 52 | email = it.requireString(User::email), 53 | password = it.requireString(User::password), 54 | bio = it.getString(User::bio), 55 | image = it.getString(User::image)?.let(::URI), 56 | following = it.getStringsOrEmpty(User::following).toSet(), 57 | ) 58 | }, 59 | ) 60 | 61 | val indexField = User::email.name 62 | val indexOptions = IndexOptions().unique(true).background(true).name(indexField) 63 | userStore.collection.createIndex(Indexes.ascending(indexField), indexOptions) 64 | 65 | return userStore 66 | } 67 | 68 | private fun createArticleStore(settings: Settings): Store { 69 | val articleStore = MongoDbStore( 70 | type = Article::class, 71 | key = Article::slug, 72 | database = MongoDbStore.database(settings.mongodbUrl), 73 | encoder = { 74 | fieldsMapOfNotNull( 75 | Article::slug to it.slug, 76 | Article::author to it.author, 77 | Article::title to it.title, 78 | Article::description to it.description, 79 | Article::body to it.body, 80 | Article::tagList to it.tagList, 81 | Article::createdAt to it.createdAt, 82 | Article::updatedAt to it.updatedAt, 83 | Article::favoritedBy to it.favoritedBy, 84 | Article::comments to it.comments.map { m -> 85 | fieldsMapOfNotNull( 86 | Comment::id to m.id, 87 | Comment::author to m.author, 88 | Comment::body to m.body, 89 | Comment::createdAt to m.createdAt, 90 | Comment::updatedAt to m.updatedAt, 91 | ) 92 | }, 93 | ) 94 | }, 95 | decoder = { 96 | Article( 97 | slug = it.requireString(Article::slug), 98 | author = it.requireString(Article::author), 99 | title = it.requireString(Article::title), 100 | description = it.requireString(Article::description), 101 | body = it.requireString(Article::body), 102 | tagList = it.getStringsOrEmpty(Article::tagList).let(::LinkedHashSet), 103 | createdAt = it.requireKey(Comment::createdAt), 104 | updatedAt = it.requireKey(Comment::updatedAt), 105 | favoritedBy = it.getStringsOrEmpty(Article::favoritedBy).toSet(), 106 | comments = it.getMapsOrEmpty(Article::comments).map { m -> 107 | Comment( 108 | id = m.requireInt(Comment::id), 109 | author = m.requireString(Comment::author), 110 | body = m.requireString(Comment::body), 111 | createdAt = m.requireKey(Comment::createdAt), 112 | updatedAt = m.requireKey(Comment::updatedAt), 113 | ) 114 | }, 115 | ) 116 | }, 117 | ) 118 | 119 | val indexField = Article::author.name 120 | val indexOptions = IndexOptions().unique(false).background(true).name(indexField) 121 | articleStore.collection.createIndex(Indexes.ascending(indexField), indexOptions) 122 | 123 | return articleStore 124 | } 125 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement by contacting the maintainer team 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/rest/it/ArticlesIT.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.it 2 | 3 | import com.hexagonkt.realworld.RealWorldClient 4 | import com.hexagonkt.realworld.restApi 5 | import com.hexagonkt.realworld.rest.messages.PutArticleRequest 6 | import com.hexagonkt.realworld.domain.model.Article 7 | import com.hexagonkt.realworld.domain.model.User 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable 10 | import java.net.URI 11 | 12 | // TODO Add test to check articles' tags order 13 | @DisabledIfEnvironmentVariable(named = "DOCKER_BUILD", matches = "true") 14 | internal class ArticlesIT : ITBase() { 15 | 16 | private val jake = User( 17 | username = "jake", 18 | email = "jake@jake.jake", 19 | password = "jakejake", 20 | bio = "I work at statefarm", 21 | image = URI("https://i.pravatar.cc/150?img=3") 22 | ) 23 | 24 | private val jane = User( 25 | username = "jane", 26 | email = "jane@jane.jane", 27 | password = "janejane", 28 | bio = "I own MegaCloud", 29 | image = URI("https://i.pravatar.cc/150?img=1") 30 | ) 31 | 32 | private val trainDragon = Article( 33 | title = "How to train your dragon", 34 | slug = "how-to-train-your-dragon", 35 | description = "Ever wonder how?", 36 | body = "Very carefully.", 37 | tagList = linkedSetOf("dragons", "training"), 38 | author = jake.username 39 | ) 40 | 41 | private val neverEndingStory = Article( 42 | title = "Never Ending Story", 43 | slug = "never-ending-story", 44 | description = "Fantasia is dying", 45 | body = "Fight for Fantasia!", 46 | tagList = linkedSetOf("dragons", "books"), 47 | author = jake.username 48 | ) 49 | 50 | @Test fun `Delete, create update and get an article`() { 51 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 52 | val jakeClient = client.initializeUser(jake) 53 | 54 | jakeClient.deleteArticle(trainDragon.slug) 55 | jakeClient.postArticle(trainDragon) 56 | jakeClient.updateArticle(trainDragon, PutArticleRequest()) 57 | jakeClient.updateArticle(trainDragon, PutArticleRequest(body = "With your bare hands")) 58 | jakeClient.getArticle(trainDragon.slug) 59 | } 60 | 61 | @Test fun `Favorite and un-favorite articles`() { 62 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 63 | val user = jake.username 64 | 65 | val jakeClient = client.initializeUser(jake) 66 | val janeClient = client.initializeUser(jane) 67 | 68 | janeClient.deleteArticle(trainDragon.slug) 69 | janeClient.deleteArticle(neverEndingStory.slug) 70 | janeClient.postArticle(trainDragon) 71 | janeClient.postArticle(neverEndingStory) 72 | 73 | jakeClient.findArticles(favorited = user, expected = emptySet()) 74 | 75 | jakeClient.favoriteArticle(trainDragon, true) 76 | jakeClient.findArticles(favorited = user, expected = setOf(trainDragon)) 77 | 78 | jakeClient.favoriteArticle(neverEndingStory, true) 79 | jakeClient.findArticles(favorited = user, expected = setOf(trainDragon, neverEndingStory)) 80 | 81 | jakeClient.favoriteArticle(trainDragon, false) 82 | jakeClient.findArticles(favorited = user, expected = setOf(neverEndingStory)) 83 | 84 | jakeClient.favoriteArticle(neverEndingStory, false) 85 | jakeClient.findArticles(favorited = user, expected = emptySet()) 86 | } 87 | 88 | @Test fun `Find articles filters correctly`() { 89 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 90 | val jakeClient = client.initializeUser(jake) 91 | val janeClient = client.initializeUser(jane) 92 | 93 | jakeClient.deleteArticle(trainDragon.slug) 94 | jakeClient.deleteArticle(neverEndingStory.slug) 95 | 96 | jakeClient.postArticle(trainDragon) 97 | janeClient.postArticle(neverEndingStory) 98 | jakeClient.favoriteArticle(neverEndingStory, true) 99 | janeClient.favoriteArticle(trainDragon, true) 100 | 101 | val clients = listOf(client, jakeClient, janeClient) 102 | 103 | clients.forEach { 104 | it.findArticles(author = "jake", expected = setOf(trainDragon)) 105 | it.findArticles(author = "jane", expected = setOf(neverEndingStory)) 106 | it.findArticles(author = "john", expected = emptySet()) 107 | } 108 | 109 | clients.forEach { 110 | it.findArticles(tag = "dragons", expected = setOf(trainDragon, neverEndingStory)) 111 | it.findArticles(tag = "training", expected = setOf(trainDragon)) 112 | it.findArticles(tag = "books", expected = setOf(neverEndingStory)) 113 | it.findArticles(tag = "other", expected = emptySet()) 114 | } 115 | 116 | clients.forEach { 117 | it.findArticles(favorited = jake.username, expected = setOf(neverEndingStory)) 118 | it.findArticles(favorited = jane.username, expected = setOf(trainDragon)) 119 | it.findArticles(favorited = "john", expected = emptySet()) 120 | } 121 | } 122 | 123 | @Test fun `Get user feed`() { 124 | val client = RealWorldClient("http://localhost:${restApi.server.runtimePort}/api") 125 | val jakeClient = client.initializeUser(jake) 126 | val janeClient = client.initializeUser(jane) 127 | 128 | janeClient.deleteArticle(trainDragon.slug) 129 | janeClient.deleteArticle(neverEndingStory.slug) 130 | 131 | jakeClient.getFeed() 132 | jakeClient.followProfile(jane, true) 133 | jakeClient.getFeed() 134 | janeClient.postArticle(trainDragon) 135 | jakeClient.getFeed(trainDragon) 136 | janeClient.postArticle(neverEndingStory) 137 | jakeClient.getFeed(trainDragon, neverEndingStory) 138 | janeClient.deleteArticle(trainDragon.slug) 139 | jakeClient.getFeed(neverEndingStory) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/messages/ArticlesMessages.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest.messages 2 | 3 | import com.hexagonkt.core.* 4 | import com.hexagonkt.realworld.domain.model.Article 5 | import com.hexagonkt.realworld.domain.model.User 6 | 7 | data class ArticleRequest( 8 | val title: String, 9 | val description: String, 10 | val body: String, 11 | val tagList: Set 12 | ) { 13 | constructor(data: Map) : this( 14 | data.requireString(ArticleRequest::title), 15 | data.requireString(ArticleRequest::description), 16 | data.requireString(ArticleRequest::body), 17 | data.getStringsOrEmpty(ArticleRequest::tagList).toSortedSet(), 18 | ) 19 | } 20 | 21 | data class AuthorResponse( 22 | val username: String, 23 | val bio: String, 24 | val image: String, 25 | val following: Boolean 26 | ) { 27 | constructor(data: Map) : this( 28 | username = data.requireString(AuthorResponse::username), 29 | bio = data.requireString(AuthorResponse::bio), 30 | image = data.requireString(AuthorResponse::image), 31 | following = data.requireBoolean(AuthorResponse::following), 32 | ) 33 | } 34 | 35 | data class ArticleCreationResponse( 36 | val slug: String, 37 | val title: String, 38 | val description: String, 39 | val body: String, 40 | val tagList: Set, 41 | val createdAt: String, 42 | val updatedAt: String, 43 | val favorited: Boolean, 44 | val favoritesCount: Int, 45 | val author: String 46 | ) { 47 | constructor(data: Map) : this( 48 | slug = data.requireString(ArticleCreationResponse::slug), 49 | title = data.requireString(ArticleCreationResponse::title), 50 | description = data.requireString(ArticleCreationResponse::description), 51 | body = data.requireString(ArticleCreationResponse::body), 52 | tagList = data.getStringsOrEmpty(ArticleCreationResponse::tagList).toSortedSet(), 53 | createdAt = data.requireString(ArticleCreationResponse::createdAt), 54 | updatedAt = data.requireString(ArticleCreationResponse::updatedAt), 55 | favorited = data.requireBoolean(ArticleCreationResponse::favorited), 56 | favoritesCount = data.requireInt(ArticleCreationResponse::favoritesCount), 57 | author = data.requireString(ArticleCreationResponse::author), 58 | ) 59 | } 60 | 61 | data class ArticleCreationResponseRoot(val article: ArticleCreationResponse) { 62 | constructor(article: Article, subject: String) : this( 63 | ArticleCreationResponse( 64 | slug = article.slug, 65 | title = article.title, 66 | description = article.description, 67 | body = article.body, 68 | tagList = article.tagList, 69 | createdAt = article.createdAt.toUtc(), 70 | updatedAt = article.updatedAt.toUtc(), 71 | favorited = false, 72 | favoritesCount = 0, 73 | author = subject 74 | ) 75 | ) 76 | } 77 | 78 | data class PutArticleRequest( 79 | val title: String? = null, 80 | val description: String? = null, 81 | val body: String? = null, 82 | val tagList: Set = emptySet() 83 | ) { 84 | constructor(data: Map) : this( 85 | data.getString(PutArticleRequest::title), 86 | data.getString(PutArticleRequest::description), 87 | data.getString(PutArticleRequest::body), 88 | data.getStringsOrEmpty(PutArticleRequest::tagList).toSortedSet(), 89 | ) 90 | 91 | fun toFieldsMap(): Map = 92 | fieldsMapOf( 93 | PutArticleRequest::title to title, 94 | PutArticleRequest::description to description, 95 | PutArticleRequest::body to body, 96 | PutArticleRequest::tagList to tagList, 97 | ) 98 | } 99 | 100 | data class ArticleResponse( 101 | val slug: String, 102 | val title: String, 103 | val description: String, 104 | val body: String, 105 | val tagList: Set, 106 | val createdAt: String, 107 | val updatedAt: String, 108 | val favorited: Boolean, 109 | val favoritesCount: Int, 110 | val author: AuthorResponse 111 | ) { 112 | constructor(data: Map) : this( 113 | slug = data.requireString(ArticleCreationResponse::slug), 114 | title = data.requireString(ArticleCreationResponse::title), 115 | description = data.requireString(ArticleCreationResponse::description), 116 | body = data.requireString(ArticleCreationResponse::body), 117 | tagList = data.getStringsOrEmpty(ArticleCreationResponse::tagList).toSortedSet(), 118 | createdAt = data.requireString(ArticleCreationResponse::createdAt), 119 | updatedAt = data.requireString(ArticleCreationResponse::updatedAt), 120 | favorited = data.requireBoolean(ArticleCreationResponse::favorited), 121 | favoritesCount = data.requireInt(ArticleCreationResponse::favoritesCount), 122 | author = data.requireMap(ArticleCreationResponse::author).let(::AuthorResponse), 123 | ) 124 | } 125 | 126 | data class ArticleResponseRoot(val article: ArticleResponse) { 127 | constructor(article: Article, author: User, user: User?) : this( 128 | ArticleResponse( 129 | slug = article.slug, 130 | title = article.title, 131 | description = article.description, 132 | body = article.body, 133 | tagList = article.tagList, 134 | createdAt = article.createdAt.toUtc(), 135 | updatedAt = article.updatedAt.toUtc(), 136 | favorited = article.favoritedBy.contains(user?.username), 137 | favoritesCount = article.favoritedBy.size, 138 | author = AuthorResponse( 139 | username = author.username, 140 | bio = author.bio ?: "", 141 | image = author.image?.toString() ?: "", 142 | following = user?.following?.contains(author.username) ?: false 143 | ) 144 | ) 145 | ) 146 | } 147 | 148 | data class ArticlesResponseRoot( 149 | val articles: List, 150 | val articlesCount: Long 151 | ) 152 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/rest/ArticlesController.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld.rest 2 | 3 | import com.hexagonkt.core.require 4 | import com.hexagonkt.core.requirePath 5 | import com.hexagonkt.core.withZone 6 | import com.hexagonkt.http.handlers.HttpContext 7 | import com.hexagonkt.http.handlers.HttpController 8 | import com.hexagonkt.http.handlers.HttpHandler 9 | import com.hexagonkt.http.handlers.path 10 | import com.hexagonkt.http.model.ContentType 11 | import com.hexagonkt.realworld.Jwt 12 | import com.hexagonkt.realworld.domain.model.Article 13 | import com.hexagonkt.realworld.domain.model.User 14 | import com.hexagonkt.realworld.rest.messages.* 15 | import com.hexagonkt.rest.bodyMap 16 | import com.hexagonkt.store.Store 17 | import java.time.LocalDateTime 18 | import java.time.ZoneId 19 | import java.time.format.DateTimeFormatter.ISO_ZONED_DATE_TIME 20 | 21 | internal data class ArticlesController( 22 | private val jwt: Jwt, 23 | private val users: Store, 24 | private val articles: Store, 25 | private val contentType: ContentType, 26 | private val authenticator: HttpHandler, 27 | private val commentsRouter: HttpHandler, 28 | ) : HttpController { 29 | 30 | override val handler by lazy { 31 | path { 32 | get("/feed") { getFeed() } 33 | 34 | path("/(?!feed)(?[^/]+?)") { 35 | 36 | path("/favorite") { 37 | use(authenticator) 38 | post { favoriteArticle(true) } 39 | delete { favoriteArticle(false) } 40 | } 41 | 42 | path("/comments", commentsRouter) 43 | 44 | delete { deleteArticle() } 45 | put { updateArticle() } 46 | get { getArticle() } 47 | } 48 | 49 | post { createArticle() } 50 | get { findArticles() } 51 | } 52 | } 53 | 54 | fun HttpContext.findArticles(): HttpContext { 55 | 56 | val subject = jwt.parsePrincipal(this) 57 | val filter = queryParameters 58 | .mapKeys { 59 | when (it.key) { 60 | "tag" -> Article::tagList.name 61 | "favorited" -> Article::favoritedBy.name 62 | else -> it.key 63 | } 64 | } 65 | .mapValues { it.value.value } 66 | 67 | val foundArticles = searchArticles(subject, filter) 68 | return ok(foundArticles, contentType = contentType) 69 | } 70 | 71 | private fun HttpContext.createArticle(): HttpContext { 72 | val subject = jwt.parsePrincipal(this) ?: return unauthorized("Unauthorized") 73 | val bodyArticle = ArticleRequest(request.bodyMap().requirePath("article")) 74 | val article = Article( 75 | slug = bodyArticle.title.toSlug(), 76 | author = subject, 77 | title = bodyArticle.title, 78 | description = bodyArticle.description, 79 | body = bodyArticle.body, 80 | tagList = bodyArticle.tagList 81 | ) 82 | 83 | articles.insertOne(article) 84 | 85 | val articleCreationResponseRoot = ArticleCreationResponseRoot(article, subject) 86 | return ok(articleCreationResponseRoot, contentType = contentType) 87 | } 88 | 89 | fun HttpContext.favoriteArticle(favorite: Boolean): HttpContext { 90 | val subject = attributes["principal"] as String 91 | val slug = pathParameters.require("slug") 92 | val article = articles.findOne(slug) ?: return notFound() 93 | val author = checkNotNull(users.findOne(article.author)) 94 | val user = checkNotNull(users.findOne(subject)) // Both can be fetched with one 'find' 95 | val updatedAt = LocalDateTime.now() 96 | val pair = Article::updatedAt.name to updatedAt 97 | val favoritedBy = 98 | if (favorite) article.favoritedBy + subject 99 | else article.favoritedBy - subject 100 | val updates = mapOf(Article::favoritedBy.name to favoritedBy) 101 | 102 | if (!articles.updateOne(slug, updates + pair)) 103 | return internalServerError() 104 | 105 | val favoritedArticle = article.copy(favoritedBy = favoritedBy) 106 | 107 | val articleResponseRoot = ArticleResponseRoot(favoritedArticle, author, user) 108 | return ok(articleResponseRoot, contentType = contentType) 109 | } 110 | 111 | fun HttpContext.getArticle(): HttpContext { 112 | 113 | val subject = jwt.parsePrincipal(this) 114 | val article = articles.findOne(pathParameters.require("slug")) ?: return notFound() 115 | val author = checkNotNull(users.findOne(article.author)) 116 | val user = users.findOne(subject ?: "") 117 | 118 | return ok(ArticleResponseRoot(article, author, user), contentType = contentType) 119 | } 120 | 121 | fun HttpContext.updateArticle(): HttpContext { 122 | val subject = jwt.parsePrincipal(this) ?: return unauthorized("Unauthorized") 123 | val body = request.bodyMap().requirePath>("article").let(::PutArticleRequest) 124 | val slug = pathParameters.require("slug") 125 | 126 | val updatedAt = LocalDateTime.now() 127 | val updatedAtPair = Article::updatedAt.name to updatedAt 128 | val requestUpdates = body.toFieldsMap().mapKeys { it.key } + updatedAtPair 129 | 130 | val updates = 131 | if (body.title != null) requestUpdates + (Article::slug.name to body.title.toSlug()) 132 | else requestUpdates 133 | 134 | val updated = articles.updateOne(slug, updates) 135 | 136 | return if (updated) { 137 | val article = checkNotNull(articles.findOne(slug)) 138 | val content = ArticleCreationResponseRoot(article, subject) 139 | ok(content, contentType = contentType) 140 | } 141 | else { 142 | internalServerError("Article $slug not updated") 143 | } 144 | } 145 | 146 | fun HttpContext.deleteArticle(): HttpContext { 147 | jwt.parsePrincipal(this) ?: return unauthorized("Unauthorized") 148 | val slug = pathParameters.require("slug") 149 | return if (!articles.deleteOne(slug)) 150 | notFound("Article $slug not found") 151 | else 152 | ok(OkResponse("Article $slug deleted"), contentType = contentType) 153 | } 154 | 155 | fun HttpContext.getFeed(): HttpContext { 156 | val subject = jwt.parsePrincipal(this) ?: return unauthorized("Unauthorized") 157 | val user = users.findOne(subject) ?: return notFound() 158 | 159 | val feedArticles = if(user.following.isEmpty()) { 160 | ArticlesResponseRoot(emptyList(), 0) 161 | } 162 | else { 163 | val filter = mapOf(Article::author.name to (user.following.toList())) 164 | searchArticles(subject, filter) 165 | } 166 | 167 | return ok(feedArticles, contentType = contentType) 168 | } 169 | 170 | fun String.toSlug() = 171 | this.lowercase().replace(' ', '-') 172 | 173 | fun HttpContext.searchArticles(subject: String?, filter: Map): ArticlesResponseRoot { 174 | 175 | val sort = mapOf(Article::createdAt.name to false) 176 | val queryParameters = request.queryParameters 177 | val limit = queryParameters["limit"]?.string()?.toInt() ?: 20 178 | val offset = queryParameters["offset"]?.string()?.toInt() ?: 0 179 | val allArticles = articles.findMany(filter, limit, offset, sort) 180 | val userNames = allArticles.map { it.author } + subject 181 | val authors = users.findMany(mapOf(User::username.name to userNames)) 182 | val authorsMap = authors.associateBy { it.username } 183 | val user = authorsMap[subject] 184 | val responses = allArticles.map { 185 | val authorUsername = it.author 186 | val author = authorsMap[authorUsername] 187 | ArticleResponse( 188 | slug = it.slug, 189 | title = it.title, 190 | description = it.description, 191 | body = it.body, 192 | tagList = it.tagList, 193 | createdAt = it.createdAt.withZone(ZoneId.of("Z")).format(ISO_ZONED_DATE_TIME), 194 | updatedAt = it.updatedAt.withZone(ZoneId.of("Z")).format(ISO_ZONED_DATE_TIME), 195 | favorited = it.favoritedBy.contains(subject), 196 | favoritesCount = it.favoritedBy.size, 197 | author = AuthorResponse( 198 | username = authorUsername, 199 | bio = author?.bio ?: "", 200 | image = author?.image?.toString() ?: "", 201 | following = user?.following?.contains(authorUsername) ?: false 202 | ) 203 | ) 204 | } 205 | 206 | return ArticlesResponseRoot(responses, articles.count(filter)) 207 | } 208 | 209 | fun getFeed1(subject: String, limit: Int, offset: Int): List
{ 210 | val user = users.findOne(subject) ?: return emptyList() 211 | 212 | val feedArticles = if(user.following.isEmpty()) { 213 | emptyList() 214 | } 215 | else { 216 | val filter = mapOf(Article::author.name to (user.following.toList())) 217 | searchArticles1(subject, filter, limit, offset) 218 | } 219 | 220 | return feedArticles 221 | } 222 | 223 | fun searchArticles1( 224 | subject: String?, 225 | filter: Map, 226 | limit: Int, 227 | offset: Int, 228 | ): List
{ 229 | 230 | val sort = mapOf(Article::createdAt.name to false) 231 | val allArticles = articles.findMany(filter, limit, offset, sort) 232 | val userNames = allArticles.map { it.author } + subject 233 | val authors = users.findMany(mapOf(User::username.name to userNames)) 234 | val authorsMap = authors.associateBy { it.username } 235 | val user = authorsMap[subject] 236 | 237 | return allArticles 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | 2 | # REAL WORLD BACKEND 3 | 4 | Real World demo is a "Conduit" like application. Conduit is a social blogging site (i.e. a 5 | Medium.com clone). It uses a custom API for all requests, including authentication. You can view a 6 | live demo over at https://demo.realworld.io 7 | 8 | General functionality: 9 | 10 | * Authenticate users via JWT (login/sign-up pages + logout button on settings page) 11 | * CRU* users (sign up & settings page - no deleting required) 12 | * CRUD Articles 13 | * CR*D Comments on articles (no updating required) 14 | * GET and display paginated lists of articles 15 | * Favorite articles 16 | * Follow other users 17 | 18 | ## Architecture 19 | 20 | The code has multiple classes per file, not like Java to take advantage of Kotlin language and keep 21 | things short. 22 | 23 | `routes` package holds the controllers, called that way because another kind of controllers 24 | (ie: commands for CLI) could exist (in this case a package called `commands` would be created). 25 | 26 | The `messages` package contains the request/responses sent to controllers. It is left aside as it 27 | would be eventually shared with the front-end. 28 | 29 | Common handling logic (as error processing) is held in `Routes.kt`. 30 | 31 | The service has three layers: 32 | 33 | * Routes: HTTP layer (controllers) and messages (requests/responses) 34 | 1. Gets data and parameters 35 | 2. Authorize 36 | 3. Authenticate 37 | 4. Validate data and state 38 | 5. Convert to services model 39 | 6. Uses services for validation and execution 40 | 7. Take resulting models and convert them to output messages 41 | * Services: application logic and data (independent of the others) 42 | - Called by routes 43 | - Calls stores (the dependency will be by interfaces) 44 | * Stores: storage port (with a MongoDB adapter) 45 | 46 | Rules of thumb: 47 | 48 | - "All you know binds you" the less each component knows about others, the better. 49 | - Each layer is in a package 50 | - A layer package can have subpackages 51 | - Services can not import anything outside services (except logging) 52 | - Other packages can only import from services not among them 53 | 54 | ## RealWorld API Spec 55 | 56 | * CORS should be working ok and content type must be json/utf8: `application/json;charset=utf8` 57 | * Authenticated endpoints must have the authentication header: `Authorization: Token jwt.token.here` 58 | 59 | ## JWT 60 | 61 | JWT token generation/parsing requires an RSA key pair. To generate a keypair keystore execute: 62 | 63 | ```bash 64 | keytool \ 65 | -genkeypair \ 66 | -keystore keystore.p12 \ 67 | -storetype pkcs12 \ 68 | -storepass storepass \ 69 | -keyalg RSA \ 70 | -validity 999 \ 71 | -alias realWorld \ 72 | -dname "CN=Real World, OU=Development, O=Hexagon, L=Madrid, S=Madrid, C=ES" 73 | ``` 74 | 75 | ## Build 76 | 77 | From now on assume `alias gw='./gradlew'`. 78 | 79 | * Build: `gw installDist` 80 | * Rebuild: `gw clean installDist` 81 | * Run: `gw run` 82 | * Watch: `gw --no-daemon --continuous runService` 83 | * Test: `gw test` 84 | 85 | ## API 86 | 87 | ### Users (for authentication) 88 | 89 | ```JSON 90 | { 91 | "user": { 92 | "email": "jake@jake.jake", 93 | "token": "jwt.token.here", 94 | "username": "jake", 95 | "bio": "I work at statefarm", 96 | "image": null 97 | } 98 | } 99 | ``` 100 | 101 | ### Profile 102 | 103 | ```JSON 104 | { 105 | "profile": { 106 | "username": "jake", 107 | "bio": "I work at statefarm", 108 | "image": "https://static.productionready.io/images/smiley-cyrus.jpg", 109 | "following": false 110 | } 111 | } 112 | ``` 113 | 114 | ### Single Article 115 | 116 | ```JSON 117 | { 118 | "article": { 119 | "slug": "how-to-train-your-dragon", 120 | "title": "How to train your dragon", 121 | "description": "Ever wonder how?", 122 | "body": "It takes a Jacobian", 123 | "tagList": ["dragons", "training"], 124 | "createdAt": "2016-02-18T03:22:56.637Z", 125 | "updatedAt": "2016-02-18T03:48:35.824Z", 126 | "favorited": false, 127 | "favoritesCount": 0, 128 | "author": { 129 | "username": "jake", 130 | "bio": "I work at statefarm", 131 | "image": "https://i.stack.imgur.com/xHWG8.jpg", 132 | "following": false 133 | } 134 | } 135 | } 136 | ``` 137 | 138 | ### Multiple Articles 139 | 140 | ```JSON 141 | { 142 | "articles":[{ 143 | "slug": "how-to-train-your-dragon", 144 | "title": "How to train your dragon", 145 | "description": "Ever wonder how?", 146 | "body": "It takes a Jacobian", 147 | "tagList": ["dragons", "training"], 148 | "createdAt": "2016-02-18T03:22:56.637Z", 149 | "updatedAt": "2016-02-18T03:48:35.824Z", 150 | "favorited": false, 151 | "favoritesCount": 0, 152 | "author": { 153 | "username": "jake", 154 | "bio": "I work at statefarm", 155 | "image": "https://i.stack.imgur.com/xHWG8.jpg", 156 | "following": false 157 | } 158 | }, { 159 | "slug": "how-to-train-your-dragon-2", 160 | "title": "How to train your dragon 2", 161 | "description": "So toothless", 162 | "body": "It a dragon", 163 | "tagList": ["dragons", "training"], 164 | "createdAt": "2016-02-18T03:22:56.637Z", 165 | "updatedAt": "2016-02-18T03:48:35.824Z", 166 | "favorited": false, 167 | "favoritesCount": 0, 168 | "author": { 169 | "username": "jake", 170 | "bio": "I work at statefarm", 171 | "image": "https://i.stack.imgur.com/xHWG8.jpg", 172 | "following": false 173 | } 174 | }], 175 | "articlesCount": 2 176 | } 177 | ``` 178 | 179 | ### Single Comment 180 | 181 | ```JSON 182 | { 183 | "comment": { 184 | "id": 1, 185 | "createdAt": "2016-02-18T03:22:56.637Z", 186 | "updatedAt": "2016-02-18T03:22:56.637Z", 187 | "body": "It takes a Jacobian", 188 | "author": { 189 | "username": "jake", 190 | "bio": "I work at statefarm", 191 | "image": "https://i.stack.imgur.com/xHWG8.jpg", 192 | "following": false 193 | } 194 | } 195 | } 196 | ``` 197 | 198 | ### Multiple Comments 199 | 200 | ```JSON 201 | { 202 | "comments": [{ 203 | "id": 1, 204 | "createdAt": "2016-02-18T03:22:56.637Z", 205 | "updatedAt": "2016-02-18T03:22:56.637Z", 206 | "body": "It takes a Jacobian", 207 | "author": { 208 | "username": "jake", 209 | "bio": "I work at statefarm", 210 | "image": "https://i.stack.imgur.com/xHWG8.jpg", 211 | "following": false 212 | } 213 | }] 214 | } 215 | ``` 216 | 217 | ### List of Tags 218 | 219 | ```JSON 220 | { 221 | "tags": [ 222 | "reactjs", 223 | "angularjs" 224 | ] 225 | } 226 | ``` 227 | 228 | ### Errors and Status Codes 229 | 230 | If a request fails any validations, expect a 422 and errors in the following format: 231 | 232 | ```JSON 233 | { 234 | "errors":{ 235 | "body": [ 236 | "can't be empty" 237 | ] 238 | } 239 | } 240 | ``` 241 | 242 | #### Other status codes: 243 | 244 | 401 for Unauthorized requests, when a request requires authentication, but it isn't provided 245 | 246 | 403 for Forbidden requests, when a request may be valid but the user doesn't have permissions to perform the action 247 | 248 | 404 for Not found requests, when a resource can't be found to fulfill the request 249 | 250 | ## Endpoints: 251 | 252 | ### Get Profile 253 | 254 | `GET /api/profiles/:username` 255 | 256 | Authentication optional, returns a [Profile](#profile) 257 | 258 | ### Follow user 259 | 260 | `POST /api/profiles/:username/follow` 261 | 262 | Authentication required, returns a [Profile](#profile) 263 | 264 | No additional parameters required 265 | 266 | ### Unfollow user 267 | 268 | `DELETE /api/profiles/:username/follow` 269 | 270 | Authentication required, returns a [Profile](#profile) 271 | 272 | No additional parameters required 273 | 274 | ### Create Article 275 | 276 | `POST /api/articles` 277 | 278 | Example request body: 279 | 280 | ```JSON 281 | { 282 | "article": { 283 | "title": "How to train your dragon", 284 | "description": "Ever wonder how?", 285 | "body": "You have to believe", 286 | "tagList": ["reactjs", "angularjs", "dragons"] 287 | } 288 | } 289 | ``` 290 | 291 | Authentication required, will return an [Article](#single-article) 292 | 293 | Required fields: `title`, `description`, `body` 294 | 295 | Optional fields: `tagList` as an array of Strings 296 | 297 | ### Update Article 298 | 299 | `PUT /api/articles/:slug` 300 | 301 | Example request body: 302 | 303 | ```JSON 304 | { 305 | "article": { 306 | "title": "Did you train your dragon?" 307 | } 308 | } 309 | ``` 310 | 311 | Authentication required, returns the updated [Article](#single-article) 312 | 313 | Optional fields: `title`, `description`, `body` 314 | 315 | The `slug` also gets updated when the `title` is changed 316 | 317 | ### Delete Article 318 | 319 | `DELETE /api/articles/:slug` 320 | 321 | Authentication required 322 | 323 | ### Favorite Article 324 | 325 | `POST /api/articles/:slug/favorite` 326 | 327 | Authentication required, returns the [Article](#single-article) 328 | 329 | No additional parameters required 330 | 331 | ### Unfavorite Article 332 | 333 | `DELETE /api/articles/:slug/favorite` 334 | 335 | Authentication required, returns the [Article](#single-article) 336 | 337 | No additional parameters required 338 | 339 | ### List Articles 340 | 341 | `GET /api/articles` 342 | 343 | Returns most recent articles globally by default, provide `tag`, `author` or `favorited` query parameter to filter results 344 | 345 | Query Parameters: 346 | 347 | Filter by tag: 348 | 349 | `?tag=AngularJS` 350 | 351 | Filter by author: 352 | 353 | `?author=jake` 354 | 355 | Favorited by user: 356 | 357 | `?favorited=jake` 358 | 359 | Limit number of articles (default is 20): 360 | 361 | `?limit=20` 362 | 363 | Offset/skip number of articles (default is 0): 364 | 365 | `?offset=0` 366 | 367 | Authentication optional, will return [multiple articles](#multiple-articles), ordered by most recent first 368 | 369 | ### Feed Articles 370 | 371 | `GET /api/articles/feed` 372 | 373 | Can also take `limit` and `offset` query parameters like [List Articles](#list-articles) 374 | 375 | Authentication required, will return [multiple articles](#multiple-articles) created by followed users, ordered by most recent first. 376 | 377 | ### Get Article 378 | 379 | `GET /api/articles/:slug` 380 | 381 | No authentication required, will return [single article](#single-article) 382 | 383 | ### Add Comments to an Article 384 | 385 | `POST /api/articles/:slug/comments` 386 | 387 | Example request body: 388 | 389 | ```JSON 390 | { 391 | "comment": { 392 | "body": "His name was my name too." 393 | } 394 | } 395 | ``` 396 | 397 | Authentication required, returns the created [Comment](#single-comment) 398 | 399 | Required field: `body` 400 | 401 | ### Get Comments from an Article 402 | 403 | `GET /api/articles/:slug/comments` 404 | 405 | Authentication optional, returns [multiple comments](#multiple-comments) 406 | 407 | ### Delete Comment 408 | 409 | `DELETE /api/articles/:slug/comments/:id` 410 | 411 | Authentication required 412 | 413 | ### Get Tags 414 | 415 | `GET /api/tags` 416 | 417 | No authentication required, returns a [List of Tags](#list-of-tags) 418 | -------------------------------------------------------------------------------- /backend/src/test/kotlin/RealWorldClient.kt: -------------------------------------------------------------------------------- 1 | package com.hexagonkt.realworld 2 | 3 | import com.hexagonkt.core.media.APPLICATION_JSON 4 | import com.hexagonkt.core.requirePath 5 | import com.hexagonkt.http.client.jetty.JettyClientAdapter 6 | import com.hexagonkt.http.model.* 7 | import com.hexagonkt.realworld.rest.messages.* 8 | import com.hexagonkt.realworld.domain.model.Article 9 | import com.hexagonkt.realworld.domain.model.User 10 | import com.hexagonkt.rest.bodyMap 11 | import com.hexagonkt.rest.tools.HttpClientTool 12 | import java.time.ZonedDateTime 13 | import kotlin.test.assertEquals 14 | 15 | internal class RealWorldClient(val client: HttpClientTool) { 16 | 17 | companion object { 18 | val json = ContentType(APPLICATION_JSON) 19 | } 20 | 21 | constructor(endpoint: String) : this(HttpClientTool(JettyClientAdapter(), endpoint, json)) 22 | 23 | init { 24 | client.start() 25 | } 26 | 27 | fun deleteUser(user: User, allowedCodes: Set = setOf(200, 404)) { 28 | client.delete("/users/${user.username}").apply { 29 | assert(status.code in allowedCodes) { "${status.code} not in $allowedCodes" } 30 | assertEquals(client.contentType, contentType) 31 | } 32 | } 33 | 34 | fun registerUser(user: User) { 35 | registerUser(user) { 36 | assertEquals(CREATED_201, status) 37 | 38 | val userResponse = UserResponse(bodyMap().requirePath("user")) 39 | assertEquals(user.username, userResponse.username) 40 | assertEquals(user.email, userResponse.email) 41 | assert(userResponse.token.isNotBlank()) 42 | } 43 | } 44 | 45 | fun registerUser(user: User, callback: HttpResponsePort.() -> Unit) { 46 | client.post("/users", mapOf("user" to user.toRegistrationRequest())).apply(callback) 47 | } 48 | 49 | fun loginUser(user: User): RealWorldClient { 50 | val header = client.post("/users/login", mapOf("user" to user.toLoginRequest())).let { 51 | assertEquals(OK_200, it.status) 52 | assertEquals(ContentType(APPLICATION_JSON, charset = Charsets.UTF_8), it.contentType) 53 | 54 | val userResponse = UserResponse(it.bodyMap().requirePath("user")) 55 | assertEquals(user.username, userResponse.username) 56 | assertEquals(user.email, userResponse.email) 57 | assert(userResponse.token.isNotBlank()) 58 | 59 | userResponse.token 60 | } 61 | 62 | return RealWorldClient( 63 | HttpClientTool( 64 | client.adapter, 65 | client.url, 66 | json, 67 | authorization = Authorization("token", header) 68 | ) 69 | ) 70 | } 71 | 72 | fun initializeUser(user: User): RealWorldClient { 73 | deleteUser(user) 74 | registerUser(user) 75 | return loginUser(user) 76 | } 77 | 78 | fun getUser(user: User) { 79 | getUser(user) { 80 | assertEquals(OK_200, status) 81 | assertEquals(contentType, contentType) 82 | 83 | val userResponse = UserResponse(bodyMap().requirePath("user")) 84 | assertEquals(user.username, userResponse.username) 85 | assertEquals(user.email, userResponse.email) 86 | assert(userResponse.token.isNotBlank()) 87 | } 88 | } 89 | 90 | fun getUser(user: User, callback: HttpResponsePort.(User) -> Unit) { 91 | client.get("/user").apply { callback(user) } 92 | } 93 | 94 | fun updateUser(user: User, updateRequest: PutUserRequest) { 95 | updateUser(user, updateRequest) { 96 | assertEquals(OK_200, status) 97 | assertEquals(contentType, contentType) 98 | 99 | val userResponse = UserResponse(bodyMap().requirePath("user")) 100 | assertEquals(user.username, userResponse.username) 101 | assertEquals((updateRequest.email ?: user.email), userResponse.email) 102 | assert(userResponse.token.isNotBlank()) 103 | } 104 | } 105 | 106 | fun updateUser( 107 | user: User, updateRequest: PutUserRequest, callback: HttpResponsePort.(User) -> Unit 108 | ) { 109 | client.put("/user", mapOf("user" to updateRequest)).apply { callback(user) } 110 | } 111 | 112 | fun getProfile(user: User, following: Boolean) { 113 | client.get("/profiles/${user.username}").apply { 114 | assertEquals(OK_200, status) 115 | assertEquals(contentType, contentType) 116 | 117 | val profileResponse = ProfileResponse(bodyMap()) 118 | assertEquals(user.username, profileResponse.username) 119 | assertEquals(following, profileResponse.following) 120 | } 121 | } 122 | 123 | fun followProfile(user: User, follow: Boolean) { 124 | val url = "/profiles/${user.username}/follow" 125 | val response = if (follow) client.post(url) else client.delete(url) 126 | 127 | response.apply { 128 | assertEquals(OK_200, status) 129 | assertEquals(contentType, contentType) 130 | 131 | val profileResponse = ProfileResponse(bodyMap()) 132 | assertEquals(user.username, profileResponse.username) 133 | assertEquals(follow, profileResponse.following) 134 | } 135 | } 136 | 137 | fun postArticle(article: Article) { 138 | client.post("/articles", mapOf("article" to article.toCreationRequest())).apply { 139 | assertEquals(OK_200, status) 140 | assertEquals(contentType, contentType) 141 | 142 | val postArticleResponse = ArticleCreationResponse(bodyMap().requirePath("article")) 143 | // TODO Check all timestamps' formats 144 | ZonedDateTime.parse(postArticleResponse.createdAt) 145 | assertEquals(article.body, postArticleResponse.body) 146 | assertEquals(article.slug, postArticleResponse.slug) 147 | assertEquals(article.description, postArticleResponse.description) 148 | assert(!postArticleResponse.favorited) 149 | assertEquals(0, postArticleResponse.favoritesCount) 150 | } 151 | } 152 | 153 | fun getArticle(slug: String) { 154 | client.get("/articles/$slug").apply { 155 | assertEquals(OK_200, status) 156 | assertEquals(contentType, contentType) 157 | 158 | val getArticleResponse = ArticleResponse(bodyMap().requirePath("article")) 159 | assertEquals(slug, getArticleResponse.slug) 160 | } 161 | } 162 | 163 | fun deleteArticle(slug: String) { 164 | client.delete("/articles/$slug").apply { 165 | assert(status in setOf(OK_200, NOT_FOUND_404)) 166 | assertEquals(contentType, contentType) 167 | 168 | if (status == OK_200) 169 | assertEquals("Article $slug deleted", OkResponse(bodyMap().requirePath("message")).message) 170 | else 171 | assertEquals("Article $slug not found", ErrorResponse(bodyMap().requirePath("errors", "body")).body.first()) 172 | } 173 | } 174 | 175 | fun updateArticle(article: Article, updateRequest: PutArticleRequest) { 176 | client.put("/articles/${article.slug}", mapOf("article" to updateRequest)).apply { 177 | assertEquals(OK_200, status) 178 | assertEquals(contentType, contentType) 179 | 180 | val responseArticle = ArticleCreationResponse(bodyMap().requirePath("article")) 181 | assertEquals(article.slug, responseArticle.slug) 182 | assertEquals(updateRequest.title ?: article.title, responseArticle.title) 183 | assertEquals(updateRequest.description ?: article.description, responseArticle.description) 184 | assertEquals(updateRequest.body ?: article.body, responseArticle.body) 185 | } 186 | } 187 | 188 | fun getFeed(vararg articles: Article) { 189 | client.get("/articles/feed").apply { 190 | assertEquals(OK_200, status) 191 | assertEquals(contentType, contentType) 192 | 193 | val feedArticles = bodyMap().requirePath>>("articles").map { ArticleResponse(it) } 194 | val feedResponse = ArticlesResponseRoot(feedArticles, articles.size.toLong()) 195 | assert(feedResponse.articlesCount >= feedResponse.articles.size) 196 | assertEquals(articles.size, feedResponse.articles.size) 197 | assert(feedResponse.articles.all { 198 | it.slug in articles.map { article -> article.slug } 199 | }) 200 | } 201 | } 202 | 203 | fun favoriteArticle(article: Article, favorite: Boolean) { 204 | val url = "/articles/${article.slug}/favorite" 205 | val response = if (favorite) client.post(url) else client.delete(url) 206 | 207 | response.apply { 208 | assertEquals(OK_200, status) 209 | assertEquals(contentType, contentType) 210 | 211 | val profileResponse = ArticleResponse(bodyMap().requirePath("article")) 212 | assertEquals(favorite, profileResponse.favorited) 213 | } 214 | } 215 | 216 | fun findArticles( 217 | author: String? = null, 218 | favorited: String? = null, 219 | tag: String? = null, 220 | expected: Set
= emptySet()) { 221 | 222 | val slugs = expected.map { it.slug } 223 | 224 | findArticles(author, favorited, tag).apply { 225 | assertEquals(slugs.size, size) 226 | assert(map { it.slug }.containsAll(slugs)) 227 | } 228 | } 229 | 230 | fun createComment(article: String, comment: CommentRequest) { 231 | client.post("/articles/$article/comments", mapOf("comment" to comment)).apply { 232 | assert(status in setOf(OK_200, NOT_FOUND_404)) 233 | assertEquals(contentType, contentType) 234 | 235 | if (status == OK_200) { 236 | val commentsResponse = CommentResponse(bodyMap().requirePath("comment")) 237 | assertEquals(comment.body, commentsResponse.body) 238 | } 239 | else if (status == NOT_FOUND_404) { 240 | val commentsResponse = ErrorResponse(bodyMap().requirePath("errors", "body")) 241 | assertEquals("$article article not found", commentsResponse.body.first()) 242 | } 243 | } 244 | } 245 | 246 | fun deleteComment(article: String, id: Int) { 247 | client.delete("/articles/$article/comments/$id").apply { 248 | assertEquals(OK_200, status) 249 | assertEquals(contentType, contentType) 250 | assertEquals("$id deleted", OkResponse(bodyMap().requirePath("message")).message) 251 | } 252 | } 253 | 254 | fun getComments(article: String, vararg ids: Int) { 255 | client.get("/articles/$article/comments").apply { 256 | assertEquals(OK_200, status) 257 | assertEquals(contentType, contentType) 258 | 259 | val commentsResponse = bodyMap().requirePath>>("comments").map { CommentResponse(it) } 260 | assertEquals(ids.size, commentsResponse.size) 261 | assert(commentsResponse.map { it.id }.containsAll(ids.toSet())) 262 | } 263 | } 264 | 265 | fun getTags(vararg expectedTags: String) { 266 | client.get("/tags").apply { 267 | assertEquals(OK_200, status) 268 | assertEquals(contentType, contentType) 269 | 270 | val tags = bodyMap().requirePath>("tags") 271 | assertEquals(expectedTags.size, tags.size) 272 | assert(tags.containsAll(expectedTags.toList().let(::LinkedHashSet))) 273 | } 274 | } 275 | 276 | private fun findArticles( 277 | author: String? = null, 278 | favorited: String? = null, 279 | tag: String? = null): List { 280 | 281 | val queryString = mapOf("author" to author, "favorited" to favorited, "tag" to tag) 282 | .filterValues { it?.isNotBlank() ?: false } 283 | .map { it.key + "=" + it.value } 284 | .joinToString("&", "?") 285 | 286 | client.get("/articles$queryString").apply { 287 | assertEquals(OK_200, status) 288 | assertEquals(contentType, contentType) 289 | 290 | val articles = bodyMap().requirePath>>("articles").map { ArticleResponse(it) } 291 | val articlesRoot = ArticlesResponseRoot(articles, articles.size.toLong()) 292 | assert(articlesRoot.articlesCount >= 0) 293 | return articles 294 | } 295 | } 296 | 297 | private fun User.toRegistrationRequest(): RegistrationRequest = 298 | RegistrationRequest(email, username, password) 299 | 300 | private fun User.toLoginRequest(): LoginRequest = 301 | LoginRequest(email, password) 302 | 303 | private fun Article.toCreationRequest(): ArticleRequest = 304 | ArticleRequest(title, description, body, tagList) 305 | } 306 | -------------------------------------------------------------------------------- /backend/src/test/resources/swagger/swagger.yml: -------------------------------------------------------------------------------- 1 | 2 | openapi: 3.0.2 3 | 4 | info: 5 | title: Conduit API 6 | description: Conduit API 7 | version: 1.0.0 8 | contact: 9 | name: RealWorld 10 | url: https://realworld.io 11 | license: 12 | name: MIT License 13 | url: https://opensource.org/licenses/MIT 14 | 15 | servers: 16 | - url: /api 17 | 18 | paths: 19 | 20 | /users: 21 | post: 22 | tags: [ User and Authentication ] 23 | summary: Register a new user 24 | requestBody: 25 | description: Details of the new user to register. 26 | required: true 27 | content: 28 | application/json: 29 | schema: 30 | required: [ user ] 31 | example: 32 | user: 33 | username: jake 34 | email: jake@jake.jake 35 | password: jakejake 36 | properties: 37 | user: 38 | required: [ email, password, username ] 39 | properties: 40 | username: { type: string } 41 | email: { type: string } 42 | password: { type: string, format: password } 43 | responses: 44 | 201: 45 | description: OK 46 | content: 47 | application/json: 48 | schema: { $ref: '#/components/schemas/UserResponse' } 49 | 422: { $ref: '#/components/responses/422' } 50 | 51 | /users/login: 52 | post: 53 | tags: [ User and Authentication ] 54 | summary: Existing user login 55 | requestBody: 56 | description: Credentials to use. 57 | required: true 58 | content: 59 | application/json: 60 | schema: 61 | example: 62 | user: 63 | email: jake@jake.jake 64 | password: jakejake 65 | required: [ user ] 66 | properties: 67 | user: 68 | required: [ email, password ] 69 | properties: 70 | email: { type: string } 71 | password: { type: string, format: password } 72 | responses: 73 | 200: 74 | description: OK 75 | content: 76 | application/json: 77 | schema: { $ref: '#/components/schemas/UserResponse' } 78 | 401: { $ref: '#/components/responses/401' } 79 | 422: { $ref: '#/components/responses/422' } 80 | 81 | /user: 82 | get: 83 | tags: [ User and Authentication ] 84 | summary: Get current user 85 | description: Gets the currently logged-in user. 86 | responses: 87 | 200: 88 | description: OK 89 | content: 90 | application/json: 91 | schema: { $ref: '#/components/schemas/UserResponse' } 92 | 401: { $ref: '#/components/responses/401' } 93 | 422: { $ref: '#/components/responses/422' } 94 | security: [ Token: [] ] 95 | 96 | put: 97 | tags: [ User and Authentication ] 98 | summary: Update current user 99 | description: Update user information for current user. 100 | requestBody: 101 | description: User details to update. At least **one** field is required. 102 | required: true 103 | content: 104 | application/json: 105 | schema: 106 | example: 107 | user: 108 | email: jake@jake.jake 109 | bio: I like to skateboard 110 | image: https://i.stack.imgur.com/xHWG8.jpg 111 | required: [ user ] 112 | properties: 113 | user: 114 | properties: 115 | email: { type: string } 116 | bio: { type: string } 117 | password: { type: string } 118 | image: { type: string } 119 | responses: 120 | 200: 121 | description: OK 122 | content: 123 | application/json: 124 | schema: { $ref: '#/components/schemas/UserResponse' } 125 | 401: { $ref: '#/components/responses/401' } 126 | 422: { $ref: '#/components/responses/422' } 127 | security: [ Token: [] ] 128 | 129 | /profiles/{username}: 130 | get: 131 | tags: [ Profile ] 132 | summary: Get a profile 133 | description: Get a profile of a user of the system. Auth is optional. 134 | parameters: 135 | - $ref: '#/components/parameters/username' 136 | description: Username of the profile to get. 137 | responses: 138 | 200: 139 | description: OK 140 | content: 141 | application/json: 142 | schema: 143 | $ref: '#/components/schemas/ProfileResponse' 144 | 401: { $ref: '#/components/responses/401' } 145 | 422: { $ref: '#/components/responses/422' } 146 | 147 | /profiles/{username}/follow: 148 | post: 149 | tags: [ Profile ] 150 | summary: Follow a user 151 | description: Follow a user by username. 152 | parameters: 153 | - $ref: '#/components/parameters/username' 154 | description: Username of the profile you want to follow. 155 | responses: 156 | 200: 157 | description: OK 158 | content: 159 | application/json: 160 | schema: 161 | $ref: '#/components/schemas/ProfileResponse' 162 | 401: { $ref: '#/components/responses/401' } 163 | 422: { $ref: '#/components/responses/422' } 164 | security: [ Token: [] ] 165 | delete: 166 | tags: [ Profile ] 167 | summary: Unfollow a user 168 | description: Unfollow a user by username. 169 | parameters: 170 | - $ref: '#/components/parameters/username' 171 | description: Username of the profile you want to unfollow. 172 | responses: 173 | 200: 174 | description: OK 175 | content: 176 | application/json: 177 | schema: 178 | $ref: '#/components/schemas/ProfileResponse' 179 | 401: { $ref: '#/components/responses/401' } 180 | 422: { $ref: '#/components/responses/422' } 181 | security: [ Token: [] ] 182 | 183 | /articles/feed: 184 | get: 185 | tags: 186 | - Articles 187 | summary: Get recent articles from users you follow 188 | description: Get most recent articles from users you follow. Use query parameters 189 | to limit. Auth is required 190 | parameters: 191 | - $ref: '#/components/parameters/limit' 192 | - $ref: '#/components/parameters/offset' 193 | responses: 194 | 200: 195 | description: OK 196 | content: 197 | application/json: 198 | schema: 199 | $ref: '#/components/schemas/MultipleArticlesResponse' 200 | 401: { $ref: '#/components/responses/401' } 201 | 422: { $ref: '#/components/responses/422' } 202 | security: [ Token: [] ] 203 | 204 | /articles: 205 | get: 206 | tags: 207 | - Articles 208 | summary: Get recent articles globally 209 | description: Get most recent articles globally. Use query parameters to filter 210 | results. Auth is optional 211 | parameters: 212 | - name: tag 213 | in: query 214 | description: Filter by tag 215 | schema: 216 | type: string 217 | - name: author 218 | in: query 219 | description: Filter by author (username) 220 | schema: 221 | type: string 222 | - name: favorited 223 | in: query 224 | description: Filter by favorites of a user (username) 225 | schema: 226 | type: string 227 | - $ref: '#/components/parameters/limit' 228 | - $ref: '#/components/parameters/offset' 229 | responses: 230 | 200: 231 | description: OK 232 | content: 233 | application/json: 234 | schema: 235 | $ref: '#/components/schemas/MultipleArticlesResponse' 236 | 401: { $ref: '#/components/responses/401' } 237 | 422: { $ref: '#/components/responses/422' } 238 | post: 239 | tags: 240 | - Articles 241 | summary: Create an article 242 | description: Create an article. Auth is required 243 | requestBody: 244 | description: Article to create 245 | content: 246 | application/json: 247 | schema: 248 | $ref: '#/components/schemas/NewArticleRequest' 249 | required: true 250 | responses: 251 | 201: 252 | description: OK 253 | content: 254 | application/json: 255 | schema: 256 | $ref: '#/components/schemas/SingleArticleResponse' 257 | 401: { $ref: '#/components/responses/401' } 258 | 422: { $ref: '#/components/responses/422' } 259 | security: [ Token: [] ] 260 | 261 | /articles/{slug}: 262 | get: 263 | tags: 264 | - Articles 265 | summary: Get an article 266 | description: Get an article. Auth not required 267 | parameters: 268 | - $ref: '#/components/parameters/slug' 269 | description: Slug of the article to get. 270 | responses: 271 | 200: 272 | description: OK 273 | content: 274 | application/json: 275 | schema: 276 | $ref: '#/components/schemas/SingleArticleResponse' 277 | 422: { $ref: '#/components/responses/422' } 278 | put: 279 | tags: 280 | - Articles 281 | summary: Update an article 282 | description: Update an article. Auth is required 283 | parameters: 284 | - $ref: '#/components/parameters/slug' 285 | description: Slug of the article to update. 286 | requestBody: 287 | description: Article to update 288 | content: 289 | application/json: 290 | schema: 291 | $ref: '#/components/schemas/UpdateArticleRequest' 292 | required: true 293 | responses: 294 | 200: 295 | description: OK 296 | content: 297 | application/json: 298 | schema: 299 | $ref: '#/components/schemas/SingleArticleResponse' 300 | 401: { $ref: '#/components/responses/401' } 301 | 422: { $ref: '#/components/responses/422' } 302 | security: [ Token: [] ] 303 | delete: 304 | tags: 305 | - Articles 306 | summary: Delete an article 307 | description: Delete an article. Auth is required 308 | parameters: 309 | - $ref: '#/components/parameters/slug' 310 | description: Slug of the article to delete. 311 | responses: 312 | 200: 313 | description: OK 314 | content: {} 315 | 401: { $ref: '#/components/responses/401' } 316 | 422: { $ref: '#/components/responses/422' } 317 | security: [ Token: [] ] 318 | 319 | /articles/{slug}/comments: 320 | get: 321 | tags: 322 | - Comments 323 | summary: Get comments for an article 324 | description: Get the comments for an article. Auth is optional 325 | parameters: 326 | - $ref: '#/components/parameters/slug' 327 | description: Slug of the article that you want to get comments for. 328 | responses: 329 | 200: 330 | description: OK 331 | content: 332 | application/json: 333 | schema: 334 | $ref: '#/components/schemas/MultipleCommentsResponse' 335 | 401: { $ref: '#/components/responses/401' } 336 | 422: { $ref: '#/components/responses/422' } 337 | post: 338 | tags: 339 | - Comments 340 | summary: Create a comment for an article 341 | description: Create a comment for an article. Auth is required 342 | parameters: 343 | - $ref: '#/components/parameters/slug' 344 | description: Slug of the article that you want to create a comment for. 345 | requestBody: 346 | description: Comment you want to create 347 | content: 348 | application/json: 349 | schema: 350 | $ref: '#/components/schemas/NewCommentRequest' 351 | required: true 352 | responses: 353 | 200: 354 | description: OK 355 | content: 356 | application/json: 357 | schema: 358 | $ref: '#/components/schemas/SingleCommentResponse' 359 | 401: { $ref: '#/components/responses/401' } 360 | 422: { $ref: '#/components/responses/422' } 361 | security: [ Token: [] ] 362 | 363 | /articles/{slug}/comments/{id}: 364 | delete: 365 | tags: 366 | - Comments 367 | summary: Delete a comment for an article 368 | description: Delete a comment for an article. Auth is required 369 | parameters: 370 | - $ref: '#/components/parameters/slug' 371 | description: Slug of the article that you want to delete a comment for. 372 | - name: id 373 | in: path 374 | description: ID of the comment you want to delete 375 | required: true 376 | schema: 377 | type: integer 378 | responses: 379 | 200: 380 | description: OK 381 | content: {} 382 | 401: { $ref: '#/components/responses/401' } 383 | 422: { $ref: '#/components/responses/422' } 384 | security: [ Token: [] ] 385 | 386 | /articles/{slug}/favorite: 387 | post: 388 | tags: 389 | - Favorites 390 | summary: Favorite an article 391 | description: Favorite an article. Auth is required 392 | parameters: 393 | - $ref: '#/components/parameters/slug' 394 | description: Slug of the article that you want to favorite. 395 | responses: 396 | 200: 397 | description: OK 398 | content: 399 | application/json: 400 | schema: 401 | $ref: '#/components/schemas/SingleArticleResponse' 402 | 401: { $ref: '#/components/responses/401' } 403 | 422: { $ref: '#/components/responses/422' } 404 | security: [ Token: [] ] 405 | delete: 406 | tags: 407 | - Favorites 408 | summary: Unfavorite an article 409 | description: Unfavorite an article. Auth is required 410 | parameters: 411 | - name: slug 412 | in: path 413 | description: Slug of the article that you want to unfavorite 414 | required: true 415 | schema: 416 | type: string 417 | responses: 418 | 200: 419 | description: OK 420 | content: 421 | application/json: 422 | schema: 423 | $ref: '#/components/schemas/SingleArticleResponse' 424 | 401: { $ref: '#/components/responses/401' } 425 | 422: { $ref: '#/components/responses/422' } 426 | security: [ Token: [] ] 427 | 428 | /tags: 429 | get: 430 | tags: [ Tags ] 431 | summary: Get tags 432 | responses: 433 | 200: 434 | description: OK 435 | content: 436 | application/json: 437 | schema: 438 | $ref: '#/components/schemas/TagsResponse' 439 | 422: { $ref: '#/components/responses/422' } 440 | 441 | components: 442 | 443 | parameters: 444 | 445 | username: 446 | name: username 447 | in: path 448 | required: true 449 | schema: 450 | type: string 451 | 452 | slug: 453 | name: slug 454 | in: path 455 | required: true 456 | schema: 457 | type: string 458 | 459 | limit: 460 | name: limit 461 | in: query 462 | description: Limit number of articles returned (default is 20) 463 | schema: 464 | type: integer 465 | default: 20 466 | 467 | offset: 468 | name: offset 469 | in: query 470 | description: Offset/skip number of articles (default is 0) 471 | schema: 472 | type: integer 473 | default: 0 474 | 475 | schemas: 476 | UpdateArticle: 477 | properties: 478 | title: 479 | type: string 480 | description: 481 | type: string 482 | body: 483 | type: string 484 | SingleCommentResponse: 485 | required: [ comment ] 486 | properties: 487 | comment: 488 | $ref: '#/components/schemas/Comment' 489 | UpdateArticleRequest: 490 | required: [ article ] 491 | properties: 492 | article: 493 | $ref: '#/components/schemas/UpdateArticle' 494 | Comment: 495 | required: [ author, body, createdAt, id, updatedAt ] 496 | properties: 497 | id: 498 | type: integer 499 | createdAt: 500 | type: string 501 | format: date-time 502 | updatedAt: 503 | type: string 504 | format: date-time 505 | body: 506 | type: string 507 | author: 508 | $ref: '#/components/schemas/Profile' 509 | User: 510 | example: 511 | email: "jake@jake.jake" 512 | token: "jwt.token.here" 513 | username: "jake" 514 | bio: "I work at statefarm" 515 | image: null 516 | required: [ bio, email, image, token, username ] 517 | properties: 518 | email: 519 | type: string 520 | token: 521 | type: string 522 | username: 523 | type: string 524 | bio: 525 | type: string 526 | image: 527 | type: string 528 | SingleArticleResponse: 529 | required: [ article ] 530 | properties: 531 | article: 532 | $ref: '#/components/schemas/Article' 533 | MultipleArticlesResponse: 534 | required: [ articles, articlesCount ] 535 | properties: 536 | articles: 537 | type: array 538 | items: 539 | $ref: '#/components/schemas/Article' 540 | articlesCount: 541 | type: integer 542 | Article: 543 | required: 544 | - author 545 | - body 546 | - createdAt 547 | - description 548 | - favorited 549 | - favoritesCount 550 | - slug 551 | - tagList 552 | - title 553 | - updatedAt 554 | properties: 555 | slug: 556 | type: string 557 | title: 558 | type: string 559 | description: 560 | type: string 561 | body: 562 | type: string 563 | tagList: 564 | type: array 565 | items: 566 | type: string 567 | createdAt: 568 | type: string 569 | format: date-time 570 | updatedAt: 571 | type: string 572 | format: date-time 573 | favorited: 574 | type: boolean 575 | favoritesCount: 576 | type: integer 577 | author: 578 | $ref: '#/components/schemas/Profile' 579 | NewArticle: 580 | required: [ body, description, title ] 581 | properties: 582 | title: 583 | type: string 584 | description: 585 | type: string 586 | body: 587 | type: string 588 | tagList: 589 | type: array 590 | items: 591 | type: string 592 | NewCommentRequest: 593 | required: [ comment ] 594 | properties: 595 | comment: 596 | $ref: '#/components/schemas/NewComment' 597 | ProfileResponse: 598 | required: [ profile ] 599 | properties: 600 | profile: 601 | $ref: '#/components/schemas/Profile' 602 | Profile: 603 | required: [ bio, following, image, username ] 604 | properties: 605 | username: 606 | type: string 607 | bio: 608 | type: string 609 | image: 610 | type: string 611 | following: 612 | type: boolean 613 | UserResponse: 614 | example: 615 | user: 616 | email: "jake@jake.jake" 617 | token: "jwt.token.here" 618 | username: "jake" 619 | bio: "I work at statefarm" 620 | image: null 621 | required: [ user ] 622 | properties: 623 | user: 624 | $ref: '#/components/schemas/User' 625 | NewComment: 626 | required: [ body ] 627 | properties: 628 | body: 629 | type: string 630 | MultipleCommentsResponse: 631 | required: [ comments ] 632 | properties: 633 | comments: 634 | type: array 635 | items: 636 | $ref: '#/components/schemas/Comment' 637 | TagsResponse: 638 | required: [ tags ] 639 | properties: 640 | tags: 641 | type: array 642 | items: 643 | type: string 644 | NewArticleRequest: 645 | required: [ article ] 646 | properties: 647 | article: 648 | $ref: '#/components/schemas/NewArticle' 649 | 650 | responses: 651 | 422: 652 | description: Unexpected error 653 | content: 654 | application/json: 655 | schema: 656 | required: [ errors ] 657 | properties: 658 | errors: 659 | required: [ body ] 660 | properties: 661 | body: 662 | type: array 663 | items: 664 | type: string 665 | 666 | 401: 667 | description: Unauthorized 668 | 669 | 403: 670 | description: Forbidden 671 | 672 | 404: 673 | description: Not Found 674 | 675 | securitySchemes: 676 | Token: 677 | type: apiKey 678 | name: Authorization 679 | in: header 680 | description: | 681 | For accessing the protected API resources, you must have received a a valid JWT token after 682 | registering or logging in. This JWT token must then be used for all protected resources by 683 | passing it in via the 'Authorization' header. 684 | 685 | A JWT token is generated by the API by either registering via /users or logging in via 686 | /users/login. 687 | 688 | The following format must be in the 'Authorization' header 689 | 690 | Token xxxxxx.yyyyyyy.zzzzzz 691 | --------------------------------------------------------------------------------