Skip to content

Commit

Permalink
Setup server (#10)
Browse files Browse the repository at this point in the history
* Add server instructions in README

* Fix java on Heroku

* WIP: Setup Exposed

* Implement `DbConfigProvider`

* WIP: Setup the DB

* WIP: Setup BE architecture

* Add workflow to run BE tests

* Add workflow to run BE tests

* Rename workflow
  • Loading branch information
ILIYANGERMANOV authored Apr 27, 2024
1 parent 1f1deb3 commit 14c1fbf
Show file tree
Hide file tree
Showing 14 changed files with 374 additions and 17 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/unit_test_server.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Unit tests Server

on:
push:
branches:
- main
pull_request:

jobs:
unit_test_server:
runs-on: ubuntu-latest
steps:
- name: Checkout GIT
uses: actions/checkout@v4

- name: Setup Java SDK
uses: actions/setup-java@v4
with:
distribution: 'adopt'
java-version: '18'

- name: Enable Gradle Wrapper caching (optimization)
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Run unit tests
run: ./gradlew server:test
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
Learn programming by thinking.

To run the web app:

```
./scripts/runWebApp.sh
```

To run the desktop app:

```
./scripts/runDesktopApp.sh
```

To run the sever:

```
./scripts/runServer.sh
```
19 changes: 19 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ ktor-client = "2.3.10"
logback = "1.5.6"
kotest = "5.8.1"
kotlin-coroutines = "1.8.0"
jebrains-exposed = "0.49.0"

[libraries]
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "jebrains-exposed" }
exposed-crypt = { group = "org.jetbrains.exposed", name = "exposed-crypt", version.ref = "jebrains-exposed" }
exposed-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "jebrains-exposed" }
exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "jebrains-exposed" }
exposed-kotlin-datetime = { group = "org.jetbrains.exposed", name = "exposed-kotlin-datetime", version.ref = "jebrains-exposed" }
exposed-json = { group = "org.jetbrains.exposed", name = "exposed-json", version.ref = "jebrains-exposed" }
arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
Expand All @@ -36,6 +43,8 @@ google-testparameterinjector = { module = "com.google.testparameterinjector:test
junit = { group = "junit", name = "junit", version.ref = "junit" }
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" }
kotest-property-arrow = { module = "io.kotest.extensions:kotest-property-arrow", version.ref = "arrow" }
kotest-assertions-arrow = { module = "io.kotest.extensions:kotest-assertions-arrow", version.ref = "arrow" }
kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" }
kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" }
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.6.3" }
Expand Down Expand Up @@ -72,6 +81,8 @@ test = [
"mockk",
"kotest-assertions",
"kotest-property",
"kotest-property-arrow",
"kotest-assertions-arrow",
"kotlin-coroutines-test"
]
ktor-client-common = [
Expand All @@ -80,4 +91,12 @@ ktor-client-common = [
"ktor-client-core",
"ktor-client-serialization",
"ktor-client-logging"
]
jetbrains-exposed = [
"exposed-core",
"exposed-crypt",
"exposed-dao",
"exposed-jdbc",
"exposed-kotlin-datetime",
"exposed-json"
]
2 changes: 2 additions & 0 deletions server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ dependencies {
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.arrow.core)
implementation(libs.bundles.jetbrains.exposed)
testImplementation(libs.ktor.server.tests)
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.bundles.test)
}
23 changes: 8 additions & 15 deletions server/src/main/kotlin/ivy/learn/Application.kt
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
package ivy.learn

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import ivy.di.Di
import ivy.di.SharedModule
import ivy.learn.data.di.DataModule
import ivy.learn.di.AppModule

fun main() {
Di.init(modules = setOf(SharedModule, DataModule, AppModule))
val app = Di.get<LearnApp>()

embeddedServer(
Netty,
port = System.getenv("PORT")?.toInt() ?: 8080,
host = "0.0.0.0",
module = Application::module
module = { app.init(this) },
).start(wait = true)
}

fun Application.module() {
routing {
get("/") {
call.respondText("Ktor: Hello from BE!")
}
get("/version") {
call.respondText("1.0.0")
}
}
}
38 changes: 38 additions & 0 deletions server/src/main/kotlin/ivy/learn/LearnApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ivy.learn

import io.ktor.server.application.*
import io.ktor.server.routing.*
import ivy.di.Di
import ivy.di.Di.register
import ivy.learn.api.AnalyticsApi
import ivy.learn.api.Api
import ivy.learn.data.database.ExposedDatabase

class LearnApp(
private val database: ExposedDatabase
) {
private val apis by lazy {
setOf<Api>(
Di.get<AnalyticsApi>()
)
}

private fun onDi() = Di.appScope {
register { AnalyticsApi() }
}

fun init(ktorApp: Application) {
database.init().onLeft {
throw InitializationError("Failed to initialize database: $it")
}
onDi()

ktorApp.routing {
apis.forEach { api ->
with(api) { endpoints() }
}
}
}
}

class InitializationError(message: String) : Exception(message)
13 changes: 13 additions & 0 deletions server/src/main/kotlin/ivy/learn/api/AnalyticsApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ivy.learn.api

import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

class AnalyticsApi : Api {
override fun Routing.endpoints() {
get("/analytics/hello") {
call.respondText("Hello, Analytics!")
}
}
}
7 changes: 7 additions & 0 deletions server/src/main/kotlin/ivy/learn/api/Api.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ivy.learn.api

import io.ktor.server.routing.*

interface Api {
fun Routing.endpoints()
}
116 changes: 116 additions & 0 deletions server/src/main/kotlin/ivy/learn/data/database/ExposedDatabase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package ivy.learn.data.database

import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull
import ivy.learn.data.database.tables.AnalyticsEvents
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction

data class ExposedDatabaseConfig(
val url: String,
val driver: String,
val user: String,
val password: String
)

class DbConfigProvider {
/**
* @param herokuDbUrl The Heroku "DATABASE_URL" environment variable.
* In the format "postgres://$user:$password@$host:$port/$database".
*/
fun fromHerokuDbUrl(
herokuDbUrl: String? = System.getenv("DATABASE_URL")
): Either<HerokuConfigError, ExposedDatabaseConfig> = either {
ensureNotNull(herokuDbUrl) { HerokuConfigError.NullUrl }
ensure(herokuDbUrl.startsWith("postgres://")) {
HerokuConfigError.InvalidUrl(herokuDbUrl, emptyList())
}

val parts = herokuDbUrl.replace("postgres://", "").split(":")
// $user:$password@$host:$port/$database
ensure(parts.size == 3) { HerokuConfigError.InvalidUrl(herokuDbUrl, parts) }
val (user, passwordAndHost, portAndDatabase) = parts
val (password, host) = passwordAndHost.split("@").also {
ensure(it.size == 2) { HerokuConfigError.InvalidPasswordAndHost(passwordAndHost) }
}
val (port, database) = portAndDatabase.split("/").also {
ensure(it.size == 2) { HerokuConfigError.InvalidPortAndDatabase(portAndDatabase) }
}
ensure(user.isNotBlank()) { HerokuConfigError.BlankUser }
ensure(password.isNotBlank()) { HerokuConfigError.BlankPassword }
ensure(host.isNotBlank()) { HerokuConfigError.BlankHost }
ensure(port.isNotBlank() && port.all(Char::isDigit)) {
HerokuConfigError.InvalidPort(port)
}
ensure(database.isNotBlank()) { HerokuConfigError.BlankDatabase }

ExposedDatabaseConfig(
url = "jdbc:postgresql://$host:$port/$database",
driver = "org.postgresql.Driver",
user = user,
password = password
)
}

sealed interface HerokuConfigError {
data object NullUrl : HerokuConfigError
data class InvalidUrl(
val url: String,
val parts: List<String>
) : HerokuConfigError

data class InvalidPasswordAndHost(
val passwordAndHost: String
) : HerokuConfigError

data class InvalidPortAndDatabase(
val portAndDatabase: String
) : HerokuConfigError

data object BlankUser : HerokuConfigError
data object BlankPassword : HerokuConfigError
data object BlankHost : HerokuConfigError
data class InvalidPort(val port: String) : HerokuConfigError
data object BlankDatabase : HerokuConfigError
}
}

class ExposedDatabase(
private val dbConfigProvider: DbConfigProvider,
) {
fun init(): Either<InitializationError, Database> = catch({
either {
val config = dbConfigProvider.fromHerokuDbUrl()
.mapLeft(InitializationError::InvalidConfig).bind()

Database.connect(
url = config.url,
driver = config.driver,
user = config.user,
password = config.password
).let(::createDbSchema)
.mapLeft(InitializationError::DbSchemaError).bind()
}
}) { e ->
Either.Left(InitializationError.Unknown(e))
}

private fun createDbSchema(database: Database): Either<Throwable, Database> = catch({
transaction {
SchemaUtils.create(AnalyticsEvents)
}
Either.Right(database)
}) {
Either.Left(it)
}

sealed interface InitializationError {
data class InvalidConfig(val error: DbConfigProvider.HerokuConfigError) : InitializationError
data class DbSchemaError(val error: Throwable) : InitializationError
data class Unknown(val e: Throwable) : InitializationError
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ivy.learn.data.database.tables

import org.jetbrains.exposed.dao.id.UUIDTable

object AnalyticsEvents : UUIDTable() {
val name = varchar("name", 50)
}
14 changes: 14 additions & 0 deletions server/src/main/kotlin/ivy/learn/data/di/DataModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package ivy.learn.data.di

import ivy.di.Di
import ivy.di.Di.register
import ivy.di.DiModule
import ivy.learn.data.database.DbConfigProvider
import ivy.learn.data.database.ExposedDatabase

object DataModule : DiModule {
override fun init() = Di.appScope {
register { DbConfigProvider() }
register { ExposedDatabase(Di.get()) }
}
}
12 changes: 12 additions & 0 deletions server/src/main/kotlin/ivy/learn/di/AppModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package ivy.learn.di

import ivy.di.Di
import ivy.di.Di.singleton
import ivy.di.DiModule
import ivy.learn.LearnApp

object AppModule : DiModule {
override fun init() = Di.appScope {
singleton { LearnApp(Di.get()) }
}
}
Loading

0 comments on commit 14c1fbf

Please sign in to comment.