├── .gitignore
├── .idea
├── .gitignore
├── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
├── compiler.xml
├── jarRepositories.xml
├── kotlinScripting.xml
├── ktlint.xml
├── misc.xml
├── modules
│ └── subprojects
│ │ ├── boot
│ │ └── kotlin-server-template.subprojects.boot.test.iml
│ │ ├── domain
│ │ └── kotlin-server-template.subprojects.domain.test.iml
│ │ ├── infrastructure
│ │ └── kotlin-server-template.subprojects.infrastructure.test.iml
│ │ ├── kotlin-server-template.subprojects.test.iml
│ │ └── presentation
│ │ └── kotlin-server-template.subprojects.presentation.test.iml
├── sqldialects.xml
└── vcs.xml
├── Dockerfile
├── README.md
├── build.gradle.kts
├── buildSrc
├── build.gradle.kts
└── src
│ └── main
│ └── kotlin
│ ├── Dependencies.kt
│ ├── Plugins.kt
│ └── Version.kt
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── subproject
├── boot
├── build.gradle.kts
└── src
│ ├── main
│ ├── kotlin
│ │ └── io
│ │ │ └── github
│ │ │ └── doohochang
│ │ │ └── ktserver
│ │ │ ├── Application.kt
│ │ │ └── Main.kt
│ └── resources
│ │ └── application.conf
│ └── test
│ └── kotlin
│ └── io
│ └── github
│ └── doohochang
│ └── ktserver
│ └── EndToEndTestSpec.kt
├── domain
├── build.gradle.kts
└── src
│ ├── main
│ └── kotlin
│ │ └── io
│ │ └── github
│ │ └── doohochang
│ │ └── ktserver
│ │ ├── entity
│ │ └── User.kt
│ │ ├── repository
│ │ └── UserRepository.kt
│ │ ├── service
│ │ └── UserService.kt
│ │ └── util
│ │ └── Random.kt
│ └── test
│ └── kotlin
│ └── io
│ └── github
│ └── doohochang
│ └── ktserver
│ ├── entity
│ └── UserSpec.kt
│ └── service
│ └── UserServiceSpec.kt
├── infrastructure
├── build.gradle.kts
└── src
│ ├── main
│ ├── kotlin
│ │ └── io
│ │ │ └── github
│ │ │ └── doohochang
│ │ │ └── ktserver
│ │ │ ├── configuration
│ │ │ └── PostgresqlConfiguration.kt
│ │ │ └── repository
│ │ │ ├── PostgresqlConnectionPool.kt
│ │ │ ├── SpringDataExtension.kt
│ │ │ └── UserRepositoryImpl.kt
│ └── resources
│ │ ├── infrastructure.conf
│ │ └── sql
│ │ └── 0.1.0.sql
│ ├── test
│ └── kotlin
│ │ └── io
│ │ └── github
│ │ └── doohochang
│ │ └── ktserver
│ │ └── repository
│ │ └── RepositorySpec.kt
│ └── testFixtures
│ ├── kotlin
│ └── io
│ │ └── github
│ │ └── doohochang
│ │ └── ktserver
│ │ └── repository
│ │ └── Postgresql.kt
│ └── resources
│ └── init-test.sql
├── logging
├── build.gradle.kts
└── src
│ ├── main
│ └── resources
│ │ └── logback.xml
│ └── testFixtures
│ └── resources
│ └── logback-test.xml
└── presentation
├── build.gradle.kts
└── src
└── main
├── kotlin
└── io
│ └── github
│ └── doohochang
│ └── ktserver
│ ├── http
│ ├── GreetingApi.kt
│ ├── HttpConfiguration.kt
│ ├── HttpServer.kt
│ ├── Logging.kt
│ └── UserApi.kt
│ └── json
│ ├── PatchUserRequest.kt
│ ├── PostUserRequest.kt
│ └── User.kt
└── resources
└── presentation.conf
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/macos,intellij,gradle,kotlin
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,intellij,gradle,kotlin
3 |
4 | ### Intellij ###
5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
7 |
8 | # User-specific stuff
9 | .idea/**/workspace.xml
10 | .idea/**/tasks.xml
11 | .idea/**/usage.statistics.xml
12 | .idea/**/dictionaries
13 | .idea/**/shelf
14 | .idea/**/dataSources.xml
15 |
16 | # AWS User-specific
17 | .idea/**/aws.xml
18 |
19 | # Generated files
20 | .idea/**/contentModel.xml
21 |
22 | # Sensitive or high-churn files
23 | .idea/**/dataSources/
24 | .idea/**/dataSources.ids
25 | .idea/**/dataSources.local.xml
26 | .idea/**/sqlDataSources.xml
27 | .idea/**/dynamic.xml
28 | .idea/**/uiDesigner.xml
29 | .idea/**/dbnavigator.xml
30 |
31 | # Gradle
32 | .idea/**/gradle.xml
33 | .idea/**/libraries
34 |
35 | # Gradle and Maven with auto-import
36 | # When using Gradle or Maven with auto-import, you should exclude module files,
37 | # since they will be recreated, and may cause churn. Uncomment if using
38 | # auto-import.
39 | # .idea/artifacts
40 | # .idea/compiler.xml
41 | # .idea/jarRepositories.xml
42 | # .idea/modules.xml
43 | # .idea/*.iml
44 | # .idea/modules
45 | # *.iml
46 | # *.ipr
47 |
48 | # CMake
49 | cmake-build-*/
50 |
51 | # Mongo Explorer plugin
52 | .idea/**/mongoSettings.xml
53 |
54 | # File-based project format
55 | *.iws
56 |
57 | # IntelliJ
58 | out/
59 |
60 | # mpeltonen/sbt-idea plugin
61 | .idea_modules/
62 |
63 | # JIRA plugin
64 | atlassian-ide-plugin.xml
65 |
66 | # Cursive Clojure plugin
67 | .idea/replstate.xml
68 |
69 | # SonarLint plugin
70 | .idea/sonarlint/
71 |
72 | # Crashlytics plugin (for Android Studio and IntelliJ)
73 | com_crashlytics_export_strings.xml
74 | crashlytics.properties
75 | crashlytics-build.properties
76 | fabric.properties
77 |
78 | # Editor-based Rest Client
79 | .idea/httpRequests
80 |
81 | # Android studio 3.1+ serialized cache file
82 | .idea/caches/build_file_checksums.ser
83 |
84 | ### Intellij Patch ###
85 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
86 |
87 | # *.iml
88 | # modules.xml
89 | # .idea/misc.xml
90 | # *.ipr
91 |
92 | # Sonarlint plugin
93 | # https://plugins.jetbrains.com/plugin/7973-sonarlint
94 | .idea/**/sonarlint/
95 |
96 | # SonarQube Plugin
97 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
98 | .idea/**/sonarIssues.xml
99 |
100 | # Markdown Navigator plugin
101 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
102 | .idea/**/markdown-navigator.xml
103 | .idea/**/markdown-navigator-enh.xml
104 | .idea/**/markdown-navigator/
105 |
106 | # Cache file creation bug
107 | # See https://youtrack.jetbrains.com/issue/JBR-2257
108 | .idea/$CACHE_FILE$
109 |
110 | # CodeStream plugin
111 | # https://plugins.jetbrains.com/plugin/12206-codestream
112 | .idea/codestream.xml
113 |
114 | ### Kotlin ###
115 | # Compiled class file
116 | *.class
117 |
118 | # Log file
119 | *.log
120 |
121 | # BlueJ files
122 | *.ctxt
123 |
124 | # Mobile Tools for Java (J2ME)
125 | .mtj.tmp/
126 |
127 | # Package Files #
128 | *.jar
129 | *.war
130 | *.nar
131 | *.ear
132 | *.zip
133 | *.tar.gz
134 | *.rar
135 |
136 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
137 | hs_err_pid*
138 | replay_pid*
139 |
140 | ### macOS ###
141 | # General
142 | .DS_Store
143 | .AppleDouble
144 | .LSOverride
145 |
146 | # Icon must end with two \r
147 | Icon
148 |
149 |
150 | # Thumbnails
151 | ._*
152 |
153 | # Files that might appear in the root of a volume
154 | .DocumentRevisions-V100
155 | .fseventsd
156 | .Spotlight-V100
157 | .TemporaryItems
158 | .Trashes
159 | .VolumeIcon.icns
160 | .com.apple.timemachine.donotpresent
161 |
162 | # Directories potentially created on remote AFP share
163 | .AppleDB
164 | .AppleDesktop
165 | Network Trash Folder
166 | Temporary Items
167 | .apdisk
168 |
169 | ### Gradle ###
170 | .gradle
171 | **/build/
172 | !src/**/build/
173 |
174 | # Ignore Gradle GUI config
175 | gradle-app.setting
176 |
177 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
178 | !gradle-wrapper.jar
179 |
180 | # Avoid ignore Gradle wrappper properties
181 | !gradle-wrapper.properties
182 |
183 | # Cache of project
184 | .gradletasknamecache
185 |
186 | # Eclipse Gradle plugin generated files
187 | # Eclipse Core
188 | .project
189 | # JDT-specific (Eclipse Java Development Tools)
190 | .classpath
191 |
192 | # End of https://www.toptal.com/developers/gitignore/api/macos,intellij,gradle,kotlin
193 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/jarRepositories.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/.idea/kotlinScripting.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/ktlint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/modules/subprojects/boot/kotlin-server-template.subprojects.boot.test.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/modules/subprojects/domain/kotlin-server-template.subprojects.domain.test.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/modules/subprojects/infrastructure/kotlin-server-template.subprojects.infrastructure.test.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/modules/subprojects/kotlin-server-template.subprojects.test.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/modules/subprojects/presentation/kotlin-server-template.subprojects.presentation.test.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/sqldialects.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM openjdk:17-jdk-alpine
2 | EXPOSE ${KTSERVER_HTTP_PORT}
3 | RUN mkdir /app
4 | COPY build/install/kotlin-server-template /app
5 | WORKDIR /app/bin
6 | CMD ["./kotlin-server-template"]
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Kotlin Server Template
2 | This project is a server code template using Kotlin, Gradle, and Ktor.
3 | It aims to help you build a service by providing reusable code examples that is likely essential for a server.
4 |
5 | ## Tech stack
6 | * [Kotlin](https://kotlinlang.org) as a main language.
7 | * [kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) for asynchronous, non-blocking programming.
8 | * [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization) for serialization.
9 | * [Gradle](https://gradle.org) as a build tool.
10 | * Kotlin DSL as well.
11 | * [Ktor](https://ktor.io) as a server framework.
12 | * [Logback](https://logback.qos.ch) for logging.
13 | * [Typesafe Config](https://github.com/lightbend/config) for configuration using HOCON files.
14 | * [Arrow Core](https://arrow-kt.io/docs/core/) for type-safe error handling.
15 | * Uses only basic features such as `Either`.
16 | * [Spring Data R2DBC](https://spring.io/projects/spring-data-r2dbc) and [PostgreSQL](http://postgresql.org) for persistence.
17 | * Uses Spring Data R2DBC as a client of R2DBC, but does not depend on Spring IoC or Spring Boot.
18 | * Uses `r2dbc-pool` to manage database connections, `r2dbc-postgresql` as a R2DBC driver.
19 | * [Testcontainers](https://www.testcontainers.org) for integration testing.
20 |
21 | ## Architecture
22 | This project consists of several Gradle subprojects separated based on Domain-driven design (DDD) as below.
23 | ```mermaid
24 | graph TD
25 | Infrastructure --> Domain
26 | Presentation --> Domain
27 | Boot --> Domain
28 | Boot --> Infrastructure
29 | Boot --> Presentation
30 | ```
31 | ### [domain](subproject/domain)
32 | Domain contains pure conceptual business logic implemented in Kotlin code which uses limited and minimal external dependencies.
33 | ### [infrastructure](subproject/infrastructure)
34 | Infrastructure contains actual implementations of domain interfaces which use external dependencies such as database, network, and libraries that possibly be replaced.
35 | ### [presentation](subproject/presentation)
36 | Presentation provides domain functions as actual network APIs.
37 | It contains logic to support network protocols such as HTTP, gRPC and also API serialization / deserialization for domain.
38 | ### [boot](subproject/boot)
39 | Boot depends on all other subprojects and connects implementations of them to run a server application.
40 | In other words, it is responsible for dependency injection (DI).
41 | It also contains several resource files to run a server.
42 | ### Other subprojects
43 | * [logging](subproject/logging) provides logback library dependency and configuration of it for global use in the project.
44 |
45 | ## Gradle Setting
46 | [Root build.gradle.kts](build.gradle.kts) contains settings commonly shared to all subprojects, and also several task settings for the entire application.
47 | * Common external library dependencies
48 | * Main class definition
49 | * Tasks that build a fat jar and containerize it.
50 |
51 | Each subproject's build.gradle.kts contains settings only for its own subproject.
52 | * Dependencies for the subproject.
53 | * Several plugin settings for the subproject.
54 |
55 | ## Testing
56 | Examples of unit test, integration test, and end-to-end test are all included in this project.
57 | * Unit tests are usually written for entities, services in domain layer.
58 | * [UserSpec](subproject/domain/src/test/kotlin/io/github/doohochang/ktserver/entity/UserSpec.kt)
59 | * [UserServiceSpec](subproject/domain/src/test/kotlin/io/github/doohochang/ktserver/service/UserServiceSpec.kt)
60 | * Integration tests are usually written for repositories in infrastructure layer by using Testcontainers.
61 | * [RepositorySpec](subproject/infrastructure/src/test/kotlin/io/github/doohochang/ktserver/repository/RepositorySpec.kt)
62 | * End-to-end tests are written in boot layer usually for testing the server APIs.
63 | * [EndToEndSpec](subproject/boot/src/test/kotlin/io/github/doohochang/ktserver/EndToEndTestSpec.kt)
64 |
65 | ## Server API
66 | The server provides simple API examples as follows.
67 | * `GET /greeting/{name}`
68 | ```
69 | > curl -X GET http://localhost:8080/greeting/alice
70 | HTTP/1.1 200 OK
71 | Content-Type: text/plain; charset=UTF-8
72 | Hello, alice.
73 | ```
74 | * `GET /users/{id}`
75 | ```
76 | > curl -X GET http://localhost:8080/users/existing-user
77 | HTTP/1.1 200 OK
78 | Content-Type: application/json; charset=UTF-8
79 | {"id":"existing-user","name":"..."}
80 |
81 | > curl -X GET http://localhost:8080/users/non-existing-user
82 | HTTP/1.1 404 Not Found
83 | ```
84 | * `POST /users/{id}`: Creates a user.
85 | ```
86 | > curl -i -X POST http://localhost:8080/users -H "Content-Type: application/json" -d '{ "name": "bob" }'
87 | HTTP/1.1 200 OK
88 | Content-Type: application/json; charset=UTF-8
89 | {"id":"some-random-id","name":"bob"}
90 |
91 | > curl -i -X POST http://localhost:8080/users -H "Content-Type: application/json" -d '{ "name": "!@" }'
92 | HTTP/1.1 400 Bad Request
93 | Content-Type: text/plain; charset=UTF-8
94 | User name must be alphanumeric.
95 | ```
96 | * `PATCH /users/{id}`: Updates a user.
97 | ```
98 | > curl -i -X PATCH http://localhost:8080/users/existing-user -H "Content-Type: application/json" -d '{ "name": "charlie" }'
99 | HTTP/1.1 200 OK
100 | Content-Type: application/json; charset=UTF-8
101 | {"id":"existing-user","name":"charlie"}
102 |
103 | > curl -i -X POST http://localhost:8080/users/existing-user -H "Content-Type: application/json" -d '{ "name": "!@" }'
104 | HTTP/1.1 400 Bad Request
105 | Content-Type: text/plain; charset=UTF-8
106 | User name must be alphanumeric.
107 |
108 | > curl -i -X POST http://localhost:8080/users/non-existing-user -H "Content-Type: application/json" -d '{ "name": "damien" }'
109 | HTTP/1.1 404 Not Found
110 | ```
111 | * `DELETE /users/{id}`
112 | ```
113 | > curl -i -X DELETE http://localhost:8080/users/existing-user
114 | HTTP/1.1 200 OK
115 |
116 | > curl -i -X POST http://localhost:8080/users/non-existing-user
117 | HTTP/1.1 404 Not Found
118 | ```
119 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | java
3 | kotlinJvm
4 | application
5 |
6 | ktlint
7 | shadow
8 | `java-test-fixtures`
9 | }
10 |
11 | /** Settings for all projects from here. */
12 | allprojects {
13 | group = "io.github.doohochang"
14 | version = Version.KOTLIN_SERVER_TEMPLATE
15 |
16 | applyJavaPlugin()
17 | applyJavaTestFixturesPlugin()
18 | applyKotlinJvmPlugin()
19 | applyGradleKtlintPlugin()
20 |
21 | repositories {
22 | mavenCentral()
23 | }
24 |
25 | dependencies {
26 | implementation(kotlin("stdlib"))
27 | implementation(kotlin("reflect"))
28 |
29 | implementation(KOTLINX_COROUTINES_CORE)
30 | implementation(ARROW_CORE)
31 |
32 | testImplementation(KOTEST)
33 | testImplementation(KOTEST_ASSERTIONS_ARROW)
34 | testImplementation(MOCK_K)
35 |
36 | testFixturesImplementation(KOTEST)
37 | testFixturesImplementation(KOTEST_ASSERTIONS_ARROW)
38 | testFixturesImplementation(MOCK_K)
39 | }
40 |
41 | /** Lint settings. */
42 | configure {
43 | disabledRules.set(setOf("no-wildcard-imports"))
44 | }
45 |
46 | /** Test settings. */
47 | tasks.withType().configureEach {
48 | useJUnitPlatform() // Platform setting for Kotest.
49 | testLogging.showStandardStreams = true // Prints log while test.
50 | }
51 | }
52 |
53 | /** Settings for only the root project from here. */
54 | dependencies {
55 | implementation(BOOT)
56 | }
57 |
58 | application {
59 | mainClass.set("io.github.doohochang.ktserver.MainKt")
60 | }
61 |
62 | tasks {
63 | shadowJar {
64 | manifest {
65 | attributes(Pair("Main-Class", "io.github.doohochang.ktserver.MainKt"))
66 | }
67 | }
68 |
69 | /** Builds a Docker image with the current project, and then publish it to local Docker registry. */
70 | register("publishDocker") {
71 | dependsOn("installDist")
72 | doLast {
73 | exec {
74 | commandLine(
75 | "docker",
76 | "build",
77 | "-t",
78 | "kotlin-server-template:latest",
79 | "-t",
80 | "kotlin-server-template:$version",
81 | "."
82 | )
83 | }
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/buildSrc/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | `kotlin-dsl`
3 | }
4 |
5 | repositories {
6 | mavenCentral()
7 | }
8 |
--------------------------------------------------------------------------------
/buildSrc/src/main/kotlin/Dependencies.kt:
--------------------------------------------------------------------------------
1 | import org.gradle.kotlin.dsl.DependencyHandlerScope
2 | import org.gradle.kotlin.dsl.project
3 |
4 | /** Subprojects from here. */
5 | val DependencyHandlerScope.DOMAIN get() = project(":subproject:domain")
6 | val DependencyHandlerScope.INFRASTRUCTURE get() = project(":subproject:infrastructure")
7 | val DependencyHandlerScope.PRESENTATION get() = project(":subproject:presentation")
8 | val DependencyHandlerScope.BOOT get() = project(":subproject:boot")
9 | val DependencyHandlerScope.LOGGING get() = project(":subproject:logging")
10 |
11 | /** External libraries from here. */
12 | val DependencyHandlerScope.KOTEST get() = "io.kotest:kotest-runner-junit5:${Version.KOTEST}"
13 | val DependencyHandlerScope.KOTEST_ASSERTIONS_ARROW
14 | get() = "io.kotest.extensions:kotest-assertions-arrow:${Version.KOTEST_ASSERTIONS_ARROW}"
15 |
16 | val DependencyHandlerScope.TESTCONTAINERS_BOM
17 | get() = platform("org.testcontainers:testcontainers-bom:${Version.TESTCONTAINERS_BOM}")
18 | val DependencyHandlerScope.TESTCONTAINERS get() = "org.testcontainers:testcontainers"
19 | val DependencyHandlerScope.TESTCONTAINERS_POSTGRESQL get() = "org.testcontainers:postgresql"
20 |
21 | val DependencyHandlerScope.MOCK_K get() = "io.mockk:mockk:${Version.MOCK_K}"
22 |
23 | val DependencyHandlerScope.KOTLINX_COROUTINES_CORE
24 | get() = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Version.KOTLINX_COROUTINES}"
25 | val DependencyHandlerScope.KOTLINX_COROUTINES_REACTIVE get() = "org.jetbrains.kotlinx:kotlinx-coroutines-reactive"
26 | val DependencyHandlerScope.KOTLINX_COROUTINES_REACTOR get() = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor"
27 |
28 | val DependencyHandlerScope.KOTLINX_SERIALIZATION_JSON
29 | get() = "org.jetbrains.kotlinx:kotlinx-serialization-json:${Version.KOTLINX_SERIALIZATION_JSON}"
30 |
31 | val DependencyHandlerScope.ARROW_CORE get() = "io.arrow-kt:arrow-core:${Version.ARROW}"
32 |
33 | val DependencyHandlerScope.LOGBACK get() = "ch.qos.logback:logback-classic:${Version.LOGBACK}"
34 |
35 | val DependencyHandlerScope.KTOR_SERVER_CORE get() = "io.ktor:ktor-server-core:${Version.KTOR}"
36 | val DependencyHandlerScope.KTOR_SERVER_NETTY get() = "io.ktor:ktor-server-netty:${Version.KTOR}"
37 | val DependencyHandlerScope.KTOR_SERIALIZATION get() = "io.ktor:ktor-serialization:${Version.KTOR}"
38 | val DependencyHandlerScope.KTOR_CLIENT_CORE get() = "io.ktor:ktor-client-core:${Version.KTOR}"
39 | val DependencyHandlerScope.KTOR_CLIENT_CIO get() = "io.ktor:ktor-client-cio:${Version.KTOR}"
40 | val DependencyHandlerScope.KTOR_CLIENT_SERIALIZATION get() = "io.ktor:ktor-client-serialization:${Version.KTOR}"
41 |
42 | val DependencyHandlerScope.TYPESAFE_CONFIG get() = "com.typesafe:config:${Version.TYPESAFE_CONFIG}"
43 |
44 | val DependencyHandlerScope.SPRING_DATA_R2DBC get() = "org.springframework.data:spring-data-r2dbc:${Version.SPRING_DATA_R2DBC}"
45 | val DependencyHandlerScope.R2DBC_BOM get() = platform("io.r2dbc:r2dbc-bom:${Version.R2DBC_BOM}")
46 | val DependencyHandlerScope.R2DBC_POOL get() = "io.r2dbc:r2dbc-pool"
47 | val DependencyHandlerScope.R2DBC_POSTGRESQL get() = "io.r2dbc:r2dbc-postgresql"
48 |
--------------------------------------------------------------------------------
/buildSrc/src/main/kotlin/Plugins.kt:
--------------------------------------------------------------------------------
1 | import org.gradle.api.plugins.PluginAware
2 | import org.gradle.kotlin.dsl.apply
3 | import org.gradle.kotlin.dsl.kotlin
4 | import org.gradle.kotlin.dsl.version
5 | import org.gradle.plugin.use.PluginDependenciesSpec
6 |
7 | fun PluginAware.applyJavaPlugin() {
8 | apply(plugin = "java")
9 | }
10 |
11 | fun PluginAware.applyJavaTestFixturesPlugin() {
12 | apply(plugin = "org.gradle.java-test-fixtures")
13 | }
14 |
15 | val PluginDependenciesSpec.kotlinJvm get() = kotlin("jvm") version Version.KOTLIN
16 | fun PluginAware.applyKotlinJvmPlugin() {
17 | apply(plugin = "org.jetbrains.kotlin.jvm")
18 | }
19 |
20 | val PluginDependenciesSpec.ktlint get() = id("org.jlleitschuh.gradle.ktlint") version Version.GRADLE_KTLINT
21 | fun PluginAware.applyGradleKtlintPlugin() {
22 | apply(plugin = "org.jlleitschuh.gradle.ktlint")
23 | }
24 |
25 | val PluginDependenciesSpec.shadow get() = id("com.github.johnrengelman.shadow") version Version.GRADLE_SHADOW
26 |
--------------------------------------------------------------------------------
/buildSrc/src/main/kotlin/Version.kt:
--------------------------------------------------------------------------------
1 | object Version {
2 | const val KOTLIN_SERVER_TEMPLATE = "0.1.0"
3 |
4 | const val KOTLIN = "1.6.10"
5 |
6 | const val KOTEST = "5.1.0"
7 | const val KOTEST_ASSERTIONS_ARROW = "1.2.3"
8 |
9 | const val TESTCONTAINERS_BOM = "1.16.3"
10 |
11 | const val MOCK_K = "1.12.3"
12 |
13 | const val GRADLE_KTLINT = "10.2.1"
14 | const val GRADLE_SHADOW = "7.0.0"
15 |
16 | const val KOTLINX_COROUTINES = "1.6.0"
17 | const val KOTLINX_SERIALIZATION_JSON = "1.3.2"
18 |
19 | const val ARROW = "1.0.1"
20 |
21 | const val LOGBACK = "1.2.10"
22 |
23 | const val KTOR = "1.6.7"
24 |
25 | const val TYPESAFE_CONFIG = "1.4.1"
26 |
27 | const val SPRING_DATA_R2DBC = "1.4.2"
28 | const val R2DBC_BOM = "Arabba-SR12"
29 | }
30 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | kotlin.code.style=official
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/doohochang/kotlin-server-template/6553f8648eff6049d955bb6163e28a0e7af46cca/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MSYS* | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "kotlin-server-template"
2 |
3 | include("subproject:domain")
4 | include("subproject:infrastructure")
5 | include("subproject:presentation")
6 | include("subproject:boot")
7 |
8 | include("subproject:logging")
9 |
--------------------------------------------------------------------------------
/subproject/boot/build.gradle.kts:
--------------------------------------------------------------------------------
1 | repositories {
2 | mavenCentral()
3 | }
4 |
5 | dependencies {
6 | implementation(DOMAIN)
7 | implementation(INFRASTRUCTURE)
8 | implementation(PRESENTATION)
9 | implementation(LOGGING)
10 |
11 | implementation(TYPESAFE_CONFIG)
12 |
13 | testImplementation(testFixtures(PRESENTATION))
14 | testImplementation(testFixtures(INFRASTRUCTURE))
15 |
16 | testImplementation(KTOR_CLIENT_CORE)
17 | testImplementation(KTOR_CLIENT_CIO)
18 | testImplementation(KTOR_CLIENT_SERIALIZATION)
19 | }
20 |
--------------------------------------------------------------------------------
/subproject/boot/src/main/kotlin/io/github/doohochang/ktserver/Application.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver
2 |
3 | import com.typesafe.config.ConfigFactory
4 | import io.github.doohochang.ktserver.configuration.PostgresqlConfiguration
5 | import io.github.doohochang.ktserver.http.HttpConfiguration
6 | import io.github.doohochang.ktserver.http.HttpServer
7 | import io.github.doohochang.ktserver.repository.PostgresqlConnectionPool
8 | import io.github.doohochang.ktserver.repository.UserRepositoryImpl
9 | import io.github.doohochang.ktserver.service.UserService
10 |
11 | data class Application(
12 | val configuration: Configuration,
13 | val infrastructure: Infrastructure,
14 | val domain: Domain,
15 | val presentation: Presentation
16 | ) {
17 | companion object {
18 | fun load(): Application {
19 | val configuration = Configuration.load()
20 | val infrastructure = Infrastructure.load(configuration)
21 | val domain = Domain.load(infrastructure)
22 | val presentation = Presentation.load(configuration, domain)
23 |
24 | return Application(
25 | configuration,
26 | infrastructure,
27 | domain,
28 | presentation
29 | )
30 | }
31 | }
32 | }
33 |
34 | data class Configuration(
35 | val postgresql: PostgresqlConfiguration,
36 | val http: HttpConfiguration
37 | ) {
38 | companion object {
39 | fun load(): Configuration {
40 | val rootConfiguration = ConfigFactory.load()
41 | val httpConfiguration = HttpConfiguration.from(rootConfiguration)
42 | val postgresqlConfiguration = PostgresqlConfiguration.from(rootConfiguration)
43 |
44 | return Configuration(
45 | postgresqlConfiguration,
46 | httpConfiguration
47 | )
48 | }
49 | }
50 | }
51 |
52 | data class Infrastructure(
53 | val userRepository: UserRepositoryImpl
54 | ) {
55 | companion object {
56 | fun load(configuration: Configuration): Infrastructure {
57 | val postgresqlConnectionPool = PostgresqlConnectionPool(configuration.postgresql)
58 | val userRepository = UserRepositoryImpl(postgresqlConnectionPool)
59 |
60 | return Infrastructure(userRepository)
61 | }
62 | }
63 | }
64 |
65 | data class Domain(
66 | val userService: UserService
67 | ) {
68 | companion object {
69 | fun load(infrastructure: Infrastructure): Domain {
70 | val userService = UserService(infrastructure.userRepository)
71 |
72 | return Domain(userService)
73 | }
74 | }
75 | }
76 |
77 | data class Presentation(
78 | val httpServer: HttpServer
79 | ) {
80 | companion object {
81 | fun load(configuration: Configuration, domain: Domain): Presentation {
82 | val httpServer = HttpServer(configuration.http, domain.userService)
83 |
84 | return Presentation(httpServer)
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/subproject/boot/src/main/kotlin/io/github/doohochang/ktserver/Main.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver
2 |
3 | import org.slf4j.LoggerFactory
4 |
5 | suspend fun main() {
6 | val log = LoggerFactory.getLogger("io.github.doohochang.ktserver.MainKt")
7 |
8 | try {
9 | val application = Application.load()
10 |
11 | // Starts the server.
12 | application.presentation.httpServer.start()
13 | log.info("Server has been started successfully.")
14 | } catch (e: Throwable) {
15 | log.error(e.stackTraceToString())
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/subproject/boot/src/main/resources/application.conf:
--------------------------------------------------------------------------------
1 | include "presentation.conf"
2 | include "infrastructure.conf"
3 |
--------------------------------------------------------------------------------
/subproject/boot/src/test/kotlin/io/github/doohochang/ktserver/EndToEndTestSpec.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver
2 |
3 | import io.github.doohochang.ktserver.json.PatchUserRequest
4 | import io.github.doohochang.ktserver.json.PostUserRequest
5 | import io.github.doohochang.ktserver.json.User
6 | import io.github.doohochang.ktserver.repository.Postgresql
7 | import io.kotest.core.spec.style.FreeSpec
8 | import io.kotest.matchers.shouldBe
9 | import io.ktor.client.*
10 | import io.ktor.client.engine.cio.*
11 | import io.ktor.client.features.*
12 | import io.ktor.client.features.json.*
13 | import io.ktor.client.request.*
14 | import io.ktor.client.statement.*
15 | import io.ktor.http.*
16 |
17 | /**
18 | * Contains end-to-end tests for the application, especially for the server APIs.
19 | * It actually boots the entire application including necessary external dependencies such as a database, and then tests it.
20 | */
21 | class EndToEndTestSpec : FreeSpec({
22 | "End-To-End Test" - {
23 | val originalConfiguration = Configuration.load()
24 |
25 | val postgresql = Postgresql(
26 | database = originalConfiguration.postgresql.database,
27 | username = originalConfiguration.postgresql.username,
28 | password = originalConfiguration.postgresql.password
29 | )
30 |
31 | val postgresqlPort = postgresql.startAndInitialize()
32 |
33 | val configuration = originalConfiguration.copy(
34 | postgresql = originalConfiguration.postgresql.copy(port = postgresqlPort)
35 | )
36 |
37 | val infrastructure = Infrastructure.load(configuration)
38 | val domain = Domain.load(infrastructure)
39 | val presentation = Presentation.load(configuration, domain)
40 |
41 | "Test HttpServer" - {
42 | val client = HttpClient(CIO) {
43 | install(JsonFeature)
44 | defaultRequest {
45 | host = "localhost"
46 | port = configuration.http.port
47 | }
48 | expectSuccess = false
49 | }
50 |
51 | presentation.httpServer.start()
52 |
53 | "Greeting APIs" {
54 | client.get(path = "/greeting/alice") shouldBe "Hello, alice.\n"
55 | }
56 |
57 | "User APIs" {
58 | // Tests POST /users from here.
59 | val user1 = client.post(path = "/users") {
60 | contentType(ContentType.Application.Json)
61 | body = PostUserRequest("alice")
62 | }
63 | user1.name shouldBe "alice"
64 |
65 | val user2 = client.post(path = "/users") {
66 | contentType(ContentType.Application.Json)
67 | body = PostUserRequest("bob")
68 | }
69 | user2.name shouldBe "bob"
70 |
71 | client.post(path = "/users") {
72 | contentType(ContentType.Application.Json)
73 | body = PostUserRequest("!@#")
74 | }.execute().status shouldBe HttpStatusCode.BadRequest
75 |
76 | // Tests GET /users/{id} from here.
77 | client.get(path = "/users/${user1.id}") shouldBe user1
78 | client.get(path = "/users/${user2.id}") shouldBe user2
79 |
80 | client.get(path = "/users/non-existing-user-id")
81 | .execute()
82 | .status shouldBe HttpStatusCode.NotFound
83 |
84 | // Tests PATCH /users/{id} from here.
85 | val updatedUser1 = client.patch(path = "/users/${user1.id}") {
86 | contentType(ContentType.Application.Json)
87 | body = PatchUserRequest("charlie")
88 | }
89 | updatedUser1 shouldBe User(user1.id, "charlie")
90 | client.get(path = "/users/${user1.id}") shouldBe updatedUser1
91 |
92 | val updatedUser2 = client.patch(path = "/users/${user2.id}") {
93 | contentType(ContentType.Application.Json)
94 | body = PatchUserRequest("damien")
95 | }
96 | updatedUser2 shouldBe User(user2.id, "damien")
97 | client.get(path = "/users/${user2.id}") shouldBe updatedUser2
98 |
99 | client.patch(path = "/users/${user1.id}") {
100 | contentType(ContentType.Application.Json)
101 | body = PatchUserRequest("!@#$%")
102 | }.execute().status shouldBe HttpStatusCode.BadRequest
103 |
104 | client.patch(path = "/users/non-existing-user-id") {
105 | contentType(ContentType.Application.Json)
106 | body = PatchUserRequest("eve")
107 | }.execute().status shouldBe HttpStatusCode.NotFound
108 |
109 | // Tests DELETE /users/{id} from here.
110 | client.delete(path = "/users/${user1.id}")
111 | .execute().status shouldBe HttpStatusCode.OK
112 | client.get(path = "/users/${user1.id}")
113 | .execute().status shouldBe HttpStatusCode.NotFound
114 |
115 | client.delete(path = "/users/${user2.id}")
116 | .execute().status shouldBe HttpStatusCode.OK
117 | client.get(path = "/users/${user2.id}")
118 | .execute().status shouldBe HttpStatusCode.NotFound
119 |
120 | client.delete(path = "/users/${user1.id}")
121 | .execute().status shouldBe HttpStatusCode.NotFound
122 | client.delete(path = "/users/${user2.id}")
123 | .execute().status shouldBe HttpStatusCode.NotFound
124 | }
125 |
126 | client.close()
127 | presentation.httpServer.stop()
128 | }
129 |
130 | postgresql.stop()
131 | }
132 | })
133 |
--------------------------------------------------------------------------------
/subproject/domain/build.gradle.kts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/doohochang/kotlin-server-template/6553f8648eff6049d955bb6163e28a0e7af46cca/subproject/domain/build.gradle.kts
--------------------------------------------------------------------------------
/subproject/domain/src/main/kotlin/io/github/doohochang/ktserver/entity/User.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.entity
2 |
3 | import arrow.core.Either
4 |
5 | /**
6 | * Represents a user whose [name] consists only of alphanumeric characters.
7 | *
8 | * @property id the string identifier of the user.
9 | */
10 | data class User(
11 | val id: String,
12 | val name: String
13 | ) {
14 | companion object {
15 | fun validateName(name: String): Either =
16 | if (name.filterNot { it.isDigit() || it in 'a'..'z' || it in 'A'..'Z' }.isNotEmpty())
17 | Either.Left("A user name should consists of alphanumeric characters.")
18 | else
19 | Either.Right(Unit)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/subproject/domain/src/main/kotlin/io/github/doohochang/ktserver/repository/UserRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.repository
2 |
3 | import arrow.core.Either
4 | import io.github.doohochang.ktserver.entity.User
5 |
6 | interface UserRepository {
7 | suspend fun find(id: String): Either
8 | suspend fun create(name: String): Either
9 | suspend fun update(id: String, name: String): Either
10 | suspend fun delete(id: String): Either
11 |
12 | companion object Dto {
13 | data class FindFailure(val transactionFailure: String)
14 | data class CreateFailure(val transactionFailure: String)
15 |
16 | sealed interface UpdateFailure {
17 | data class UserDoesNotExist(val id: String) : UpdateFailure
18 | data class TransactionFailed(val cause: String) : UpdateFailure
19 | }
20 |
21 | sealed interface DeleteFailure {
22 | data class UserDoesNotExist(val id: String) : DeleteFailure
23 | data class TransactionFailed(val cause: String) : DeleteFailure
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/subproject/domain/src/main/kotlin/io/github/doohochang/ktserver/service/UserService.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.service
2 |
3 | import arrow.core.Either
4 | import arrow.core.computations.either
5 | import io.github.doohochang.ktserver.entity.User
6 | import io.github.doohochang.ktserver.repository.UserRepository
7 |
8 | class UserService(private val userRepository: UserRepository) {
9 | suspend fun get(id: String): Either =
10 | userRepository.find(id).mapLeft { GetFailure(it.transactionFailure) }
11 |
12 | suspend fun create(name: String): Either = either {
13 | User.validateName(name).mapLeft { CreateFailure.InvalidName(name, it) }.bind()
14 | userRepository.create(name).mapLeft { CreateFailure.TransactionFailed(it.transactionFailure) }.bind()
15 | }
16 |
17 | suspend fun update(id: String, name: String): Either = either {
18 | User.validateName(name).mapLeft { UpdateFailure.InvalidName(name, it) }.bind()
19 |
20 | userRepository.update(id, name)
21 | .mapLeft {
22 | when (it) {
23 | is UserRepository.Dto.UpdateFailure.UserDoesNotExist -> UpdateFailure.UserDoesNotExist(it.id)
24 | is UserRepository.Dto.UpdateFailure.TransactionFailed -> UpdateFailure.TransactionFailed(it.cause)
25 | }
26 | }
27 | .bind()
28 | }
29 |
30 | suspend fun delete(id: String): Either =
31 | userRepository.delete(id)
32 | .mapLeft {
33 | when (it) {
34 | is UserRepository.Dto.DeleteFailure.UserDoesNotExist -> DeleteFailure.UserDoesNotExist(it.id)
35 | is UserRepository.Dto.DeleteFailure.TransactionFailed -> DeleteFailure.TransactionFailed(it.cause)
36 | }
37 | }
38 |
39 | companion object Dto {
40 | data class GetFailure(val transactionFailure: String)
41 |
42 | sealed interface CreateFailure {
43 | data class TransactionFailed(val failure: String) : CreateFailure
44 | data class InvalidName(val name: String, val reason: String) : CreateFailure
45 | }
46 |
47 | sealed interface UpdateFailure {
48 | data class TransactionFailed(val failure: String) : UpdateFailure
49 | data class UserDoesNotExist(val id: String) : UpdateFailure
50 | data class InvalidName(val name: String, val reason: String) : UpdateFailure
51 | }
52 |
53 | sealed interface DeleteFailure {
54 | data class TransactionFailed(val failure: String) : DeleteFailure
55 | data class UserDoesNotExist(val id: String) : DeleteFailure
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/subproject/domain/src/main/kotlin/io/github/doohochang/ktserver/util/Random.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.util
2 |
3 | fun randomAlphanumericString(length: Int): String {
4 | val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
5 |
6 | return (1..length)
7 | .map { allowedChars.random() }
8 | .joinToString("")
9 | }
10 |
--------------------------------------------------------------------------------
/subproject/domain/src/test/kotlin/io/github/doohochang/ktserver/entity/UserSpec.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.entity
2 |
3 | import io.kotest.assertions.arrow.core.shouldBeLeft
4 | import io.kotest.assertions.arrow.core.shouldBeRight
5 | import io.kotest.core.spec.style.FreeSpec
6 |
7 | class UserSpec : FreeSpec({
8 | "User.validateName should return" - {
9 | "Either.Left when the name has non-alphanumeric characters." {
10 | User.validateName("!@ab39").shouldBeLeft()
11 | User.validateName("한글").shouldBeLeft()
12 | User.validateName("-_<>").shouldBeLeft()
13 | User.validateName(" \n").shouldBeLeft()
14 | }
15 |
16 | "Either.Right when the name consists only of alphanumeric characters." {
17 | User.validateName("12345").shouldBeRight()
18 | User.validateName("abcde").shouldBeRight()
19 | User.validateName("XYZ").shouldBeRight()
20 | User.validateName("abc123").shouldBeRight()
21 | User.validateName("1q2w3e4r5t").shouldBeRight()
22 | }
23 | }
24 | })
25 |
--------------------------------------------------------------------------------
/subproject/domain/src/test/kotlin/io/github/doohochang/ktserver/service/UserServiceSpec.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.service
2 |
3 | import arrow.core.Either
4 | import io.github.doohochang.ktserver.entity.User
5 | import io.github.doohochang.ktserver.repository.UserRepository
6 | import io.github.doohochang.ktserver.util.randomAlphanumericString
7 | import io.kotest.assertions.arrow.core.shouldBeLeft
8 | import io.kotest.core.spec.style.FreeSpec
9 | import io.kotest.matchers.shouldBe
10 | import io.kotest.matchers.types.shouldBeTypeOf
11 | import io.mockk.clearMocks
12 | import io.mockk.coEvery
13 | import io.mockk.coVerify
14 | import io.mockk.mockk
15 |
16 | class UserServiceSpec : FreeSpec({
17 | val userRepository = mockk()
18 | val userService = UserService(userRepository)
19 |
20 | fun randomUser(): User =
21 | User(
22 | id = randomAlphanumericString(32),
23 | name = randomAlphanumericString(32)
24 | )
25 |
26 | afterTest {
27 | clearMocks(userRepository)
28 | }
29 |
30 | "get" - {
31 | val user = randomUser()
32 |
33 | "should return a user when the repository returns it." {
34 | coEvery { userRepository.find(user.id) } returns Either.Right(user)
35 | userService.get(user.id) shouldBe Either.Right(user)
36 | coVerify(exactly = 1) { userRepository.find(user.id) }
37 | }
38 |
39 | "should return null when the repository returns null." {
40 | coEvery { userRepository.find(user.id) } returns Either.Right(null)
41 | userService.get(user.id) shouldBe Either.Right(null)
42 | coVerify(exactly = 1) { userRepository.find(user.id) }
43 | }
44 |
45 | "should return Either.Left when the repository fails." {
46 | coEvery { userRepository.find(user.id) } returns Either.Left(UserRepository.Dto.FindFailure("cause"))
47 | userService.get(user.id) shouldBe Either.Left(UserService.Dto.GetFailure("cause"))
48 | coVerify(exactly = 1) { userRepository.find(user.id) }
49 | }
50 | }
51 |
52 | "create" - {
53 | val user = randomUser()
54 |
55 | "should return a user when the repository succeeds and the name is valid." {
56 | coEvery { userRepository.create(user.name) } returns Either.Right(user)
57 | userService.create(user.name) shouldBe Either.Right(user)
58 | coVerify(exactly = 1) { userRepository.create(user.name) }
59 | }
60 |
61 | "should return a user when the name is invalid." {
62 | userService.create("!@#$%")
63 | .shouldBeLeft()
64 | .shouldBeTypeOf()
65 | coVerify(exactly = 0) { userRepository.create(any()) }
66 | }
67 |
68 | "should return Either.Left when the repository fails." {
69 | coEvery { userRepository.create(user.name) } returns Either.Left(UserRepository.Dto.CreateFailure("cause"))
70 | userService.create(user.name) shouldBe Either.Left(UserService.Dto.CreateFailure.TransactionFailed("cause"))
71 | coVerify(exactly = 1) { userRepository.create(user.name) }
72 | }
73 | }
74 |
75 | "update" - {
76 | val user = randomUser()
77 | val newUserName = randomAlphanumericString(32)
78 | val updatedUser = User(user.id, newUserName)
79 |
80 | "should return the updated user when the repository succeeds." {
81 | coEvery { userRepository.update(user.id, newUserName) } returns Either.Right(updatedUser)
82 | userService.update(user.id, newUserName) shouldBe Either.Right(updatedUser)
83 | coVerify(exactly = 1) { userRepository.update(user.id, newUserName) }
84 | }
85 |
86 | "should fail when the given user name is invalid." {
87 | coEvery { userRepository.update(user.id, "!@#$%") } returns Either.Right(User(user.id, "!@#$%"))
88 | userService.update(user.id, "!@#$%")
89 | .shouldBeLeft()
90 | .shouldBeTypeOf()
91 | coVerify(exactly = 0) { userRepository.update(user.id, "!@#$%") }
92 | }
93 |
94 | "should fail when no user with the given id exists." {
95 | coEvery { userRepository.update(user.id, newUserName) } returns Either.Left(
96 | UserRepository.Dto.UpdateFailure.UserDoesNotExist(user.id)
97 | )
98 | userService.update(user.id, newUserName) shouldBe Either.Left(
99 | UserService.Dto.UpdateFailure.UserDoesNotExist(user.id)
100 | )
101 | coVerify(exactly = 1) { userRepository.update(user.id, newUserName) }
102 | }
103 |
104 | "should fail when the update transaction fails." {
105 | coEvery { userRepository.update(user.id, newUserName) } returns Either.Left(
106 | UserRepository.Dto.UpdateFailure.TransactionFailed("cause")
107 | )
108 | userService.update(user.id, newUserName) shouldBe Either.Left(
109 | UserService.Dto.UpdateFailure.TransactionFailed("cause")
110 | )
111 | coVerify(exactly = 1) { userRepository.update(user.id, newUserName) }
112 | }
113 | }
114 |
115 | "delete" - {
116 | val user = randomUser()
117 |
118 | "should succeed when the repository succeeds." {
119 | coEvery { userRepository.delete(user.id) } returns Either.Right(Unit)
120 | userService.delete(user.id) shouldBe Either.Right(Unit)
121 | coVerify(exactly = 1) { userRepository.delete(user.id) }
122 | }
123 |
124 | "should fail when no user with the given id exists." {
125 | coEvery { userRepository.delete(user.id) } returns Either.Left(
126 | UserRepository.Dto.DeleteFailure.UserDoesNotExist(user.id)
127 | )
128 | userService.delete(user.id) shouldBe Either.Left(UserService.Dto.DeleteFailure.UserDoesNotExist(user.id))
129 | coVerify(exactly = 1) { userRepository.delete(user.id) }
130 | }
131 |
132 | "should fail when the delete transaction fails." {
133 | coEvery { userRepository.delete(user.id) } returns Either.Left(UserRepository.Dto.DeleteFailure.TransactionFailed("cause"))
134 | userService.delete(user.id) shouldBe Either.Left(UserService.Dto.DeleteFailure.TransactionFailed("cause"))
135 | coVerify(exactly = 1) { userRepository.delete(user.id) }
136 | }
137 | }
138 | })
139 |
--------------------------------------------------------------------------------
/subproject/infrastructure/build.gradle.kts:
--------------------------------------------------------------------------------
1 | dependencies {
2 | implementation(DOMAIN)
3 | implementation(LOGGING)
4 |
5 | implementation(TYPESAFE_CONFIG)
6 |
7 | implementation(SPRING_DATA_R2DBC)
8 | implementation(R2DBC_BOM)
9 | implementation(R2DBC_POOL)
10 | implementation(R2DBC_POSTGRESQL)
11 |
12 | implementation(KOTLINX_COROUTINES_REACTIVE)
13 | implementation(KOTLINX_COROUTINES_REACTOR)
14 |
15 | testImplementation(testFixtures(LOGGING))
16 |
17 | testFixturesImplementation(SPRING_DATA_R2DBC)
18 | testFixturesImplementation(R2DBC_BOM)
19 | testFixturesImplementation(R2DBC_POOL)
20 | testFixturesImplementation(R2DBC_POSTGRESQL)
21 |
22 | testFixturesImplementation(TESTCONTAINERS_BOM)
23 | testFixturesImplementation(TESTCONTAINERS)
24 | testFixturesImplementation(TESTCONTAINERS_POSTGRESQL)
25 | }
26 |
--------------------------------------------------------------------------------
/subproject/infrastructure/src/main/kotlin/io/github/doohochang/ktserver/configuration/PostgresqlConfiguration.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.configuration
2 |
3 | import com.typesafe.config.Config
4 |
5 | data class PostgresqlConfiguration(
6 | val host: String,
7 | val port: Int,
8 | val database: String,
9 | val username: String,
10 | val password: String,
11 | val connectionInitialCount: Int,
12 | val connectionMaxCount: Int
13 | ) {
14 | companion object {
15 | fun from(config: Config): PostgresqlConfiguration {
16 | val postgresqlConfig = config.getConfig("postgresql")
17 |
18 | return PostgresqlConfiguration(
19 | host = postgresqlConfig.getString("host"),
20 | port = postgresqlConfig.getInt("port"),
21 | database = postgresqlConfig.getString("database"),
22 | username = postgresqlConfig.getString("username"),
23 | password = postgresqlConfig.getString("password"),
24 | connectionInitialCount = postgresqlConfig.getInt("connection-initial-count"),
25 | connectionMaxCount = postgresqlConfig.getInt("connection-max-count")
26 | )
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/subproject/infrastructure/src/main/kotlin/io/github/doohochang/ktserver/repository/PostgresqlConnectionPool.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.repository
2 |
3 | import io.github.doohochang.ktserver.configuration.PostgresqlConfiguration
4 | import io.r2dbc.pool.PoolingConnectionFactoryProvider
5 | import io.r2dbc.spi.ConnectionFactories
6 | import io.r2dbc.spi.ConnectionFactoryOptions
7 |
8 | class PostgresqlConnectionPool(
9 | configuration: PostgresqlConfiguration
10 | ) {
11 | internal val instance = ConnectionFactories.get(
12 | ConnectionFactoryOptions.builder()
13 | .option(ConnectionFactoryOptions.DRIVER, "pool")
14 | .option(ConnectionFactoryOptions.PROTOCOL, "postgresql")
15 | .option(ConnectionFactoryOptions.HOST, configuration.host)
16 | .option(ConnectionFactoryOptions.PORT, configuration.port)
17 | .option(ConnectionFactoryOptions.USER, configuration.username)
18 | .option(ConnectionFactoryOptions.PASSWORD, configuration.password)
19 | .option(ConnectionFactoryOptions.DATABASE, configuration.database)
20 | .option(PoolingConnectionFactoryProvider.INITIAL_SIZE, configuration.connectionInitialCount)
21 | .option(PoolingConnectionFactoryProvider.MAX_SIZE, configuration.connectionMaxCount)
22 | .build()
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/subproject/infrastructure/src/main/kotlin/io/github/doohochang/ktserver/repository/SpringDataExtension.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.repository
2 |
3 | import org.springframework.data.r2dbc.convert.R2dbcConverter
4 | import org.springframework.r2dbc.core.DatabaseClient
5 | import org.springframework.r2dbc.core.RowsFetchSpec
6 |
7 | inline fun DatabaseClient.GenericExecuteSpec.convert(converter: R2dbcConverter): RowsFetchSpec =
8 | map { row, rowMetadata -> converter.read(T::class.java, row, rowMetadata) }
9 |
--------------------------------------------------------------------------------
/subproject/infrastructure/src/main/kotlin/io/github/doohochang/ktserver/repository/UserRepositoryImpl.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.repository
2 |
3 | import arrow.core.Either
4 | import io.github.doohochang.ktserver.entity.User
5 | import io.github.doohochang.ktserver.util.randomAlphanumericString
6 | import kotlinx.coroutines.reactor.awaitSingle
7 | import org.springframework.data.r2dbc.core.*
8 | import org.springframework.data.relational.core.mapping.Column
9 | import org.springframework.data.relational.core.mapping.Table
10 | import org.springframework.data.relational.core.query.Criteria.where
11 | import org.springframework.data.relational.core.query.Query.query
12 | import org.springframework.r2dbc.core.awaitSingleOrNull
13 | import org.springframework.transaction.annotation.Isolation
14 | import org.springframework.transaction.annotation.Transactional
15 |
16 | open class UserRepositoryImpl(connectionPool: PostgresqlConnectionPool) : UserRepository {
17 | private val template = R2dbcEntityTemplate(connectionPool.instance)
18 | private val databaseClient = template.databaseClient
19 | private val converter = template.converter
20 |
21 | override suspend fun find(id: String): Either = try {
22 | /*
23 | An example using Spring Data Fluent API provided by [R2dbcEntityTemplate].
24 | It provides type-safe DSL to query data, but it still does not support relational queries such as join.
25 | */
26 | val row = template
27 | .select()
28 | .from(USER_TABLE)
29 | .matching(
30 | query(where(USER_ID).`is`(id))
31 | )
32 | .awaitOneOrNull()
33 |
34 | Either.Right(row?.toDomain())
35 | } catch (e: Throwable) {
36 | Either.Left(UserRepository.Dto.FindFailure(e.stackTraceToString()))
37 | }
38 |
39 | override suspend fun create(name: String): Either = try {
40 | val row = template
41 | .insert(
42 | UserRow(
43 | id = randomAlphanumericString(32),
44 | name = name
45 | )
46 | )
47 | .awaitSingle()
48 |
49 | Either.Right(row.toDomain())
50 | } catch (e: Throwable) {
51 | Either.Left(UserRepository.Dto.CreateFailure(e.stackTraceToString()))
52 | }
53 |
54 | /*
55 | An example using [@Transactional] annotation which seals all queries executed in the function into an atomic transaction.
56 | */
57 | @Transactional(isolation = Isolation.READ_COMMITTED)
58 | override suspend fun update(id: String, name: String): Either = try {
59 | /*
60 | Another querying style using a [DatabaseClient] instead of [R2dbcEntityTemplate].
61 | [DatabaseClient] does not support Spring Data Fluent API, but it provides more flexibility to use a database with
62 | string queries and parameter binding.
63 | */
64 | val updatedUser = databaseClient
65 | .sql(
66 | """
67 | update $USER_TABLE
68 | set name = :name
69 | where id = :id
70 | returning *
71 | """.trimIndent()
72 | )
73 | .bind("name", name)
74 | .bind("id", id)
75 | .convert(converter) // Maps the given query result to [UserRow] by using Spring's [R2dbcConverter].
76 | .awaitSingleOrNull()
77 |
78 | if (updatedUser == null) Either.Left(UserRepository.Dto.UpdateFailure.UserDoesNotExist(id))
79 | else Either.Right(updatedUser.toDomain())
80 | } catch (e: Throwable) {
81 | Either.Left(UserRepository.Dto.UpdateFailure.TransactionFailed(e.stackTraceToString()))
82 | }
83 |
84 | override suspend fun delete(id: String): Either = try {
85 | val deletedCount = template
86 | .delete()
87 | .matching(
88 | query(where(USER_ID).`is`(id))
89 | )
90 | .all()
91 | .awaitSingle()
92 |
93 | if (deletedCount == 0) Either.Left(UserRepository.Dto.DeleteFailure.UserDoesNotExist(id))
94 | else Either.Right(Unit)
95 | } catch (e: Throwable) {
96 | Either.Left(UserRepository.Dto.DeleteFailure.TransactionFailed(e.stackTraceToString()))
97 | }
98 |
99 | companion object {
100 | const val USER_TABLE = "\"user\""
101 | const val USER_ID = "id"
102 | const val USER_NAME = "name"
103 |
104 | @Table(USER_TABLE)
105 | data class UserRow(
106 | @Column(USER_ID) val id: String,
107 | @Column(USER_NAME) val name: String
108 | )
109 |
110 | private fun UserRow.toDomain(): User = User(id, name)
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/subproject/infrastructure/src/main/resources/infrastructure.conf:
--------------------------------------------------------------------------------
1 | postgresql {
2 | host = 0.0.0.0
3 | host = ${?KTSERVER_POSTGRESQL_HOST}
4 | port = 5432
5 | port = ${?KTSERVER_POSTGRESQL_PORT}
6 | database = testdb
7 | database = ${?KTSERVER_POSTGRESQL_DATABASE}
8 | username = tester
9 | username = ${?KTSERVER_POSTGRESQL_USERNAME}
10 | password = tester
11 | password = ${?KTSERVER_POSTGRESQL_PASSWORD}
12 | connection-initial-count = 10
13 | connection-initial-count = ${?KTSERVER_POSTGRESQL_CONNECTION_INITIAL_COUNT}
14 | connection-max-count = 20
15 | connection-max-count = ${?KTSERVER_POSTGRESQL_CONNECTION_MAX_COUNT}
16 | }
17 |
--------------------------------------------------------------------------------
/subproject/infrastructure/src/main/resources/sql/0.1.0.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "user" (
2 | id varchar(255) PRIMARY KEY,
3 | name varchar(255)
4 | );
5 |
--------------------------------------------------------------------------------
/subproject/infrastructure/src/test/kotlin/io/github/doohochang/ktserver/repository/RepositorySpec.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.repository
2 |
3 | import io.github.doohochang.ktserver.configuration.PostgresqlConfiguration
4 | import io.kotest.assertions.arrow.core.shouldBeLeft
5 | import io.kotest.assertions.arrow.core.shouldBeRight
6 | import io.kotest.core.spec.style.FreeSpec
7 | import io.kotest.matchers.shouldBe
8 |
9 | /**
10 | * Contains integration tests for repositories in infrastructure layer.
11 | * It tests the repositories with database containers instantiated by Testcontainers.
12 | */
13 | class RepositorySpec : FreeSpec({
14 | "Integration test for repositories" - {
15 | val postgresql = Postgresql(POSTGRESQL_DATABASE, POSTGRESQL_USERNAME, POSTGRESQL_PASSWORD)
16 |
17 | val postgresqlPort = postgresql.startAndInitialize()
18 |
19 | val connectionPool = PostgresqlConnectionPool(
20 | PostgresqlConfiguration(
21 | host = POSTGRESQL_HOST,
22 | port = postgresqlPort,
23 | database = POSTGRESQL_DATABASE,
24 | username = POSTGRESQL_USERNAME,
25 | password = POSTGRESQL_PASSWORD,
26 | connectionInitialCount = CONNECTION_POOL_INITIAL_COUNT,
27 | connectionMaxCount = CONNECTION_POOL_MAX_COUNT
28 | )
29 | )
30 |
31 | "UserRepositoryImpl" {
32 | val repository = UserRepositoryImpl(connectionPool)
33 |
34 | // Tests create from here.
35 | val user1 = repository.create("TestUserName1").shouldBeRight()
36 | val user2 = repository.create("TestUserName2").shouldBeRight()
37 | repository.find(user1.id) shouldBeRight user1
38 |
39 | // Tests find from here.
40 | val nonExistingId = "NonExistingId"
41 | repository.find(user2.id) shouldBeRight user2
42 | repository.find(nonExistingId) shouldBeRight null
43 |
44 | // Tests update from here.
45 | val nameToUpdate = "UpdatedUserName"
46 | val updatedUser = repository.update(user2.id, nameToUpdate).shouldBeRight()
47 | updatedUser.id shouldBe user2.id
48 | updatedUser.name shouldBe nameToUpdate
49 | repository.find(user2.id).shouldBeRight(updatedUser)
50 |
51 | repository.update(nonExistingId, "NewName")
52 | .shouldBeLeft(UserRepository.Dto.UpdateFailure.UserDoesNotExist(nonExistingId))
53 | repository.find(nonExistingId) shouldBeRight null
54 |
55 | // Tests delete from here.
56 | repository.delete(user1.id).shouldBeRight(Unit)
57 | repository.delete(nonExistingId)
58 | .shouldBeLeft(UserRepository.Dto.DeleteFailure.UserDoesNotExist(nonExistingId))
59 | repository.delete(user2.id).shouldBeRight(Unit)
60 | }
61 |
62 | postgresql.stop()
63 | }
64 | }) {
65 | companion object {
66 | const val POSTGRESQL_DATABASE = "testdb"
67 | const val POSTGRESQL_USERNAME = "tester"
68 | const val POSTGRESQL_PASSWORD = "tester"
69 | const val POSTGRESQL_HOST = "localhost"
70 | const val CONNECTION_POOL_INITIAL_COUNT = 10
71 | const val CONNECTION_POOL_MAX_COUNT = 20
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/subproject/infrastructure/src/testFixtures/kotlin/io/github/doohochang/ktserver/repository/Postgresql.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.repository
2 |
3 | import io.r2dbc.spi.ConnectionFactories
4 | import io.r2dbc.spi.ConnectionFactoryOptions
5 | import org.slf4j.LoggerFactory
6 | import org.springframework.r2dbc.core.DatabaseClient
7 | import org.springframework.r2dbc.core.await
8 | import org.testcontainers.containers.PostgreSQLContainer
9 | import org.testcontainers.containers.output.Slf4jLogConsumer
10 | import java.lang.RuntimeException
11 |
12 | class Postgresql(
13 | private val database: String,
14 | private val username: String,
15 | private val password: String
16 | ) {
17 | private val log = LoggerFactory.getLogger(this::class.java)
18 |
19 | private val container = PostgreSQLContainer(POSTGRESQL_IMAGE_TAG)
20 | .withExposedPorts(POSTGRESQL_PORT)
21 | .withDatabaseName(database)
22 | .withUsername(username)
23 | .withPassword(password)
24 | .withLogConsumer(Slf4jLogConsumer(log))
25 |
26 | val port: Int
27 | get() =
28 | if (container.isRunning) container.getMappedPort(POSTGRESQL_PORT)
29 | else throw RuntimeException("The container is not running")
30 |
31 | /**
32 | * Starts the PostgreSQL container and initialize it with SQL in resource file whose path is [POSTGRESQL_INIT_SCRIPT_PATH].
33 | * This function also returns the mapped port of the container.
34 | */
35 | suspend fun startAndInitialize(): Int {
36 | container.start()
37 |
38 | val port = container.getMappedPort(POSTGRESQL_PORT)
39 |
40 | val connectionPool = ConnectionFactories.get(
41 | ConnectionFactoryOptions.builder()
42 | .option(ConnectionFactoryOptions.DRIVER, "pool")
43 | .option(ConnectionFactoryOptions.PROTOCOL, "postgresql")
44 | .option(ConnectionFactoryOptions.HOST, "localhost")
45 | .option(ConnectionFactoryOptions.PORT, port)
46 | .option(ConnectionFactoryOptions.USER, username)
47 | .option(ConnectionFactoryOptions.PASSWORD, password)
48 | .option(ConnectionFactoryOptions.DATABASE, database)
49 | .build()
50 | )
51 |
52 | val databaseClient = DatabaseClient.create(connectionPool)
53 | val initScript = this::class.java.classLoader.getResource(POSTGRESQL_INIT_SCRIPT_PATH)!!.readText()
54 | databaseClient.sql(initScript).await()
55 |
56 | return port
57 | }
58 |
59 | fun stop() {
60 | container.stop()
61 | }
62 |
63 | companion object {
64 | const val POSTGRESQL_IMAGE_TAG = "postgres:14.2"
65 | const val POSTGRESQL_INIT_SCRIPT_PATH = "init-test.sql"
66 | const val POSTGRESQL_PORT = 5432
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/subproject/infrastructure/src/testFixtures/resources/init-test.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "user" (
2 | id varchar(255) PRIMARY KEY,
3 | name varchar(255)
4 | );
5 |
--------------------------------------------------------------------------------
/subproject/logging/build.gradle.kts:
--------------------------------------------------------------------------------
1 | repositories {
2 | mavenCentral()
3 | }
4 |
5 | dependencies {
6 | api(LOGBACK)
7 | }
8 |
--------------------------------------------------------------------------------
/subproject/logging/src/main/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/subproject/logging/src/testFixtures/resources/logback-test.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/subproject/presentation/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("plugin.serialization") version Version.KOTLIN
3 | }
4 |
5 | dependencies {
6 | implementation(DOMAIN)
7 | implementation(LOGGING)
8 |
9 | implementation(KOTLINX_SERIALIZATION_JSON)
10 |
11 | implementation(KTOR_SERVER_CORE)
12 | implementation(KTOR_SERVER_NETTY)
13 | implementation(KTOR_SERIALIZATION)
14 |
15 | implementation(TYPESAFE_CONFIG)
16 | }
17 |
--------------------------------------------------------------------------------
/subproject/presentation/src/main/kotlin/io/github/doohochang/ktserver/http/GreetingApi.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.http
2 |
3 | import io.ktor.application.*
4 | import io.ktor.http.*
5 | import io.ktor.response.*
6 | import io.ktor.routing.*
7 |
8 | fun Application.installGreetingApi() {
9 | routing {
10 | get("/greeting/{name}") {
11 | val name = call.parameters["name"]
12 |
13 | if (name == null) call.respond(HttpStatusCode.InternalServerError)
14 | else call.respondText("Hello, $name.\n")
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/subproject/presentation/src/main/kotlin/io/github/doohochang/ktserver/http/HttpConfiguration.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.http
2 |
3 | import com.typesafe.config.Config
4 |
5 | data class HttpConfiguration(
6 | val port: Int
7 | ) {
8 | companion object {
9 | fun from(config: Config): HttpConfiguration =
10 | HttpConfiguration(
11 | port = config.getInt("http.port")
12 | )
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/subproject/presentation/src/main/kotlin/io/github/doohochang/ktserver/http/HttpServer.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.http
2 |
3 | import io.github.doohochang.ktserver.service.UserService
4 | import io.ktor.application.*
5 | import io.ktor.features.*
6 | import io.ktor.serialization.*
7 | import io.ktor.server.engine.*
8 | import io.ktor.server.netty.*
9 | import java.util.concurrent.TimeUnit
10 |
11 | class HttpServer(
12 | private val httpConfiguration: HttpConfiguration,
13 | private val userService: UserService
14 | ) {
15 | private val embeddedServer by lazy {
16 | embeddedServer(Netty, port = httpConfiguration.port) {
17 | install(ContentNegotiation) { json() }
18 | installCallLogging()
19 |
20 | installGreetingApi()
21 | installUserApi(userService)
22 | }
23 | }
24 |
25 | fun start() {
26 | embeddedServer.start(wait = false)
27 | }
28 |
29 | fun stop() {
30 | embeddedServer.stop(1, 3, TimeUnit.SECONDS)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/subproject/presentation/src/main/kotlin/io/github/doohochang/ktserver/http/Logging.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.http
2 |
3 | import io.ktor.application.*
4 | import io.ktor.features.*
5 | import org.slf4j.event.Level
6 | import java.util.UUID
7 |
8 | fun Application.installCallLogging() {
9 | install(CallLogging) {
10 | level = Level.INFO
11 | mdc("traceId") { it.request.headers["X-B3-TraceId"] }
12 | mdc("requestId") { UUID.randomUUID().toString() }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/subproject/presentation/src/main/kotlin/io/github/doohochang/ktserver/http/UserApi.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.http
2 |
3 | import io.github.doohochang.ktserver.json.PatchUserRequest
4 | import io.github.doohochang.ktserver.json.PostUserRequest
5 | import io.github.doohochang.ktserver.json.toJson
6 | import io.github.doohochang.ktserver.service.UserService
7 | import io.ktor.application.*
8 | import io.ktor.http.*
9 | import io.ktor.request.*
10 | import io.ktor.response.*
11 | import io.ktor.routing.*
12 |
13 | fun Application.installUserApi(userService: UserService) {
14 | routing {
15 | get("/users/{id}") {
16 | val id = call.parameters["id"]!!
17 | userService.get(id).fold(
18 | ifRight = {
19 | if (it != null) call.respond(it.toJson())
20 | else call.respond(HttpStatusCode.NotFound)
21 | },
22 | ifLeft = {
23 | call.respond(HttpStatusCode.InternalServerError)
24 | log.error(it.transactionFailure)
25 | }
26 | )
27 | }
28 |
29 | post("/users") {
30 | val request = call.receive()
31 | userService.create(request.name).fold(
32 | ifRight = {
33 | call.respond(it.toJson())
34 | },
35 | ifLeft = {
36 | when (it) {
37 | is UserService.Dto.CreateFailure.InvalidName ->
38 | call.respond(HttpStatusCode.BadRequest, "User name must be alphanumeric.")
39 | is UserService.Dto.CreateFailure.TransactionFailed -> {
40 | call.respond(HttpStatusCode.InternalServerError)
41 | log.error(it.failure)
42 | }
43 | }
44 | }
45 | )
46 | }
47 |
48 | patch("/users/{id}") {
49 | val id = call.parameters["id"]!!
50 | val request = call.receive()
51 | userService.update(id, request.name).fold(
52 | ifRight = {
53 | call.respond(it.toJson())
54 | },
55 | ifLeft = {
56 | when (it) {
57 | is UserService.Dto.UpdateFailure.UserDoesNotExist ->
58 | call.respond(HttpStatusCode.NotFound)
59 | is UserService.Dto.UpdateFailure.InvalidName ->
60 | call.respond(HttpStatusCode.BadRequest, "User name must be alphanumeric.")
61 | is UserService.Dto.UpdateFailure.TransactionFailed -> {
62 | call.respond(HttpStatusCode.InternalServerError)
63 | log.error(it.failure)
64 | }
65 | }
66 | }
67 | )
68 | }
69 |
70 | delete("/users/{id}") {
71 | val id = call.parameters["id"]!!
72 | userService.delete(id).fold(
73 | ifRight = {
74 | call.respond(HttpStatusCode.OK)
75 | },
76 | ifLeft = {
77 | when (it) {
78 | is UserService.Dto.DeleteFailure.UserDoesNotExist ->
79 | call.respond(HttpStatusCode.NotFound)
80 | is UserService.Dto.DeleteFailure.TransactionFailed -> {
81 | call.respond(HttpStatusCode.InternalServerError)
82 | log.error(it.failure)
83 | }
84 | }
85 | }
86 | )
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/subproject/presentation/src/main/kotlin/io/github/doohochang/ktserver/json/PatchUserRequest.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.json
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class PatchUserRequest(val name: String)
7 |
--------------------------------------------------------------------------------
/subproject/presentation/src/main/kotlin/io/github/doohochang/ktserver/json/PostUserRequest.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.json
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class PostUserRequest(val name: String)
7 |
--------------------------------------------------------------------------------
/subproject/presentation/src/main/kotlin/io/github/doohochang/ktserver/json/User.kt:
--------------------------------------------------------------------------------
1 | package io.github.doohochang.ktserver.json
2 |
3 | import kotlinx.serialization.Serializable
4 | import io.github.doohochang.ktserver.entity.User as DomainUser
5 |
6 | @Serializable
7 | data class User(
8 | val id: String,
9 | val name: String
10 | )
11 |
12 | fun User.toDomain(): DomainUser = DomainUser(id, name)
13 | fun DomainUser.toJson(): User = User(id, name)
14 |
--------------------------------------------------------------------------------
/subproject/presentation/src/main/resources/presentation.conf:
--------------------------------------------------------------------------------
1 | http {
2 | port = 8080
3 | port = ${?KTSERVER_HTTP_PORT}
4 | }
5 |
--------------------------------------------------------------------------------