-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
1f1deb3
commit 14c1fbf
Showing
14 changed files
with
374 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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!") | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
116
server/src/main/kotlin/ivy/learn/data/database/ExposedDatabase.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
7 changes: 7 additions & 0 deletions
7
server/src/main/kotlin/ivy/learn/data/database/tables/AnalyticsEvents.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) } | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) } | ||
} | ||
} |
Oops, something went wrong.