diff --git a/.github/workflows/unit_test_server.yml b/.github/workflows/unit_test_server.yml new file mode 100644 index 00000000..a34ddcf6 --- /dev/null +++ b/.github/workflows/unit_test_server.yml @@ -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 diff --git a/README.md b/README.md index b1771eec..7a9d52a0 100644 --- a/README.md +++ b/README.md @@ -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 ``` \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea28e0dc..892cedb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } @@ -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" } @@ -72,6 +81,8 @@ test = [ "mockk", "kotest-assertions", "kotest-property", + "kotest-property-arrow", + "kotest-assertions-arrow", "kotlin-coroutines-test" ] ktor-client-common = [ @@ -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" ] \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts index d92724df..e3ac7b8f 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/Application.kt b/server/src/main/kotlin/ivy/learn/Application.kt index 47b79ee0..b6d3a890 100644 --- a/server/src/main/kotlin/ivy/learn/Application.kt +++ b/server/src/main/kotlin/ivy/learn/Application.kt @@ -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() + 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") - } - } } \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/LearnApp.kt b/server/src/main/kotlin/ivy/learn/LearnApp.kt new file mode 100644 index 00000000..7cf139c9 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/LearnApp.kt @@ -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( + Di.get() + ) + } + + 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) \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/api/AnalyticsApi.kt b/server/src/main/kotlin/ivy/learn/api/AnalyticsApi.kt new file mode 100644 index 00000000..7126ab86 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/api/AnalyticsApi.kt @@ -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!") + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/api/Api.kt b/server/src/main/kotlin/ivy/learn/api/Api.kt new file mode 100644 index 00000000..1592cc1e --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/api/Api.kt @@ -0,0 +1,7 @@ +package ivy.learn.api + +import io.ktor.server.routing.* + +interface Api { + fun Routing.endpoints() +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/data/database/ExposedDatabase.kt b/server/src/main/kotlin/ivy/learn/data/database/ExposedDatabase.kt new file mode 100644 index 00000000..80b9faef --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/data/database/ExposedDatabase.kt @@ -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 = 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 + ) : 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 = 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 = 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 + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/data/database/tables/AnalyticsEvents.kt b/server/src/main/kotlin/ivy/learn/data/database/tables/AnalyticsEvents.kt new file mode 100644 index 00000000..e26f8fcd --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/data/database/tables/AnalyticsEvents.kt @@ -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) +} diff --git a/server/src/main/kotlin/ivy/learn/data/di/DataModule.kt b/server/src/main/kotlin/ivy/learn/data/di/DataModule.kt new file mode 100644 index 00000000..b76853ad --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/data/di/DataModule.kt @@ -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()) } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/di/AppModule.kt b/server/src/main/kotlin/ivy/learn/di/AppModule.kt new file mode 100644 index 00000000..5029bccd --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/di/AppModule.kt @@ -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()) } + } +} \ No newline at end of file diff --git a/server/src/test/kotlin/ivy/learn/database/DbConfigProviderTest.kt b/server/src/test/kotlin/ivy/learn/database/DbConfigProviderTest.kt new file mode 100644 index 00000000..9ba812b4 --- /dev/null +++ b/server/src/test/kotlin/ivy/learn/database/DbConfigProviderTest.kt @@ -0,0 +1,98 @@ +package ivy.learn.database + +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector +import io.kotest.assertions.arrow.core.shouldBeLeft +import io.kotest.assertions.arrow.core.shouldBeRight +import io.kotest.matchers.shouldBe +import ivy.learn.data.database.DbConfigProvider +import ivy.learn.data.database.DbConfigProvider.HerokuConfigError +import ivy.learn.data.database.ExposedDatabaseConfig +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(TestParameterInjector::class) +class DbConfigProviderTest { + + private lateinit var configProvider: DbConfigProvider + + @Before + fun setup() { + configProvider = DbConfigProvider() + } + + @Test + fun `valid Heroku DB URL should return ExposedDatabaseConfig`() { + // given + val validUrl = "postgres://user:password@ec1-2-3.eu-west-1.compute.amazonaws.com:1234/db" + + // when + val res = configProvider.fromHerokuDbUrl(validUrl) + + // then + res.shouldBeRight() shouldBe ExposedDatabaseConfig( + url = "jdbc:postgresql://ec1-2-3.eu-west-1.compute.amazonaws.com:1234/db", + driver = "org.postgresql.Driver", + user = "user", + password = "password" + ) + } + + enum class InvalidDbUrlTestCase( + val dbUrl: String?, + val expectedError: HerokuConfigError + ) { + NullUrl(dbUrl = null, expectedError = HerokuConfigError.NullUrl), + InvalidUrl( + dbUrl = "postgres://user:password", + expectedError = HerokuConfigError.InvalidUrl( + "postgres://user:password", listOf("user", "password") + ) + ), + InvalidUrl2( + dbUrl = "jdbc://user:password@ec1-2-3.eu-west-1.compute.amazonaws.com:1234/db", + expectedError = HerokuConfigError.InvalidUrl( + "jdbc://user:password@ec1-2-3.eu-west-1.compute.amazonaws.com:1234/db", emptyList() + ) + ), + MissingUser( + dbUrl = "postgres://:password@ec1-2-3.eu-west-1.compute.amazonaws.com:1234/db", + expectedError = HerokuConfigError.BlankUser + ), + MissingPassword( + dbUrl = "postgres://user:@ec1-2-3.eu-west-1.compute.amazonaws.com:1234/db", + expectedError = HerokuConfigError.BlankPassword + ), + MissingHost( + dbUrl = "postgres://user:pass@:1234/db", + expectedError = HerokuConfigError.BlankHost + ), + EmptyPort( + dbUrl = "postgres://user:pass@host:/db", + expectedError = HerokuConfigError.InvalidPort("") + ), + InvalidPort( + dbUrl = "postgres://user:pass@host:abc/db", + expectedError = HerokuConfigError.InvalidPort("abc") + ), + MissingDatabase( + dbUrl = "postgres://user:pass@host:123/", + expectedError = HerokuConfigError.BlankDatabase + ), + } + + @Test + fun `invalid Heroku DB URL should return error`( + @TestParameter testCase: InvalidDbUrlTestCase + ) { + // given + val dbUrl = testCase.dbUrl + + // when + val res = configProvider.fromHerokuDbUrl(dbUrl) + + // then + res.shouldBeLeft() shouldBe testCase.expectedError + } +} \ No newline at end of file diff --git a/system.properties b/system.properties new file mode 100644 index 00000000..180a2734 --- /dev/null +++ b/system.properties @@ -0,0 +1 @@ +java.runtime.version=11 \ No newline at end of file