Skip to content

Commit

Permalink
Server lessons api (#14)
Browse files Browse the repository at this point in the history
* Setup server JSON serialization

* WIP: Fetch lessons from private repo

* Introduce dev mode for the server

* WIP: Lessons API

* Lessons API almost working!

* Configure polymorphic serialization for the LessonModel

* Make `:shared` a library
  • Loading branch information
ILIYANGERMANOV authored Apr 30, 2024
1 parent e763950 commit 9a4fec5
Show file tree
Hide file tree
Showing 18 changed files with 262 additions and 16 deletions.
5 changes: 3 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor-cl
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor-client" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor-client" }
ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor-client" }
ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-client" }
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-client" }
ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
ktor-server-contentNegotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }
ktor-server-tests = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
Expand Down Expand Up @@ -89,7 +90,7 @@ test = [
]
ktor-client-common = [
"ktor-client-content-negotiation",
"ktor-serialization",
"ktor-serialization-json",
"ktor-client-core",
"ktor-client-serialization",
"ktor-client-logging"
Expand Down
31 changes: 30 additions & 1 deletion scripts/runServer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,33 @@ if [ ! -f "settings.gradle.kts" ]; then
exit 1
fi

./gradlew :server:run
# -------------------
# 1. CHECK AND FREE UP PORT
# -------------------

# Define the port number
PORT=8081

# Use lsof to check if the port is in use
PID=$(sudo lsof -ti:$PORT)

# If a PID exists, kill the process
if [ ! -z "$PID" ]; then
echo "Port $PORT is in use by PID $PID. Attempting to free up..."
sudo kill -9 $PID
if [ $? -eq 0 ]; then
echo "Successfully freed up port $PORT."
else
echo "Failed to kill process $PID. Check permissions or process state."
exit 1
fi
else
echo "Port $PORT is free."
fi

# -------------------
# 2. RUN THE SERVER
# -------------------

echo "Starting the server on port $PORT..."
./gradlew :server:run --args='dev'
6 changes: 6 additions & 0 deletions server/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
id("org.jetbrains.kotlin.jvm")
id("io.ktor.plugin")
id("org.jetbrains.kotlin.plugin.serialization")
application
}

Expand All @@ -23,6 +24,11 @@ dependencies {
implementation(libs.arrow.core)
implementation(libs.bundles.jetbrains.exposed)
implementation(libs.postgresql.driver)
implementation(libs.kotlin.serialization)
implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.serialization.json)
implementation(libs.bundles.ktor.client.common)
implementation(libs.ktor.client.java)
testImplementation(libs.ktor.server.tests)
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.bundles.test)
Expand Down
24 changes: 20 additions & 4 deletions server/src/main/kotlin/ivy/learn/Application.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
package ivy.learn

import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import ivy.di.Di
import ivy.di.SharedModule
import ivy.learn.data.di.DataModule
import ivy.learn.di.AppModule
import kotlinx.serialization.json.Json

fun main() {
Di.init(modules = setOf(SharedModule, DataModule, AppModule))
fun main(args: Array<String>) {
val devMode = "dev" in args
Di.init(modules = setOf(SharedModule, DataModule, AppModule(devMode = devMode)))
val app = Di.get<LearnServer>()

val port = System.getenv("PORT")?.toInt() ?: 8081
println("Starting server on port $port...")
embeddedServer(
Netty,
port = System.getenv("PORT")?.toInt() ?: 8080,
port = port,
host = "0.0.0.0",
module = { app.init(this) },
module = {
configureSever()
app.init(this)
},
).start(wait = true)
}

private fun Application.configureSever() {
install(ContentNegotiation) {
json(json = Di.get<Json>())
}
}
18 changes: 13 additions & 5 deletions server/src/main/kotlin/ivy/learn/LearnServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,33 @@ 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.api.LessonsApi
import ivy.learn.api.StatusApi
import ivy.learn.data.database.ExposedDatabase

class LearnServer(
private val database: ExposedDatabase
private val database: ExposedDatabase,
private val devMode: Boolean,
) {
private val apis by lazy {
setOf<Api>(
Di.get<AnalyticsApi>()
setOf(
Di.get<AnalyticsApi>(),
Di.get<LessonsApi>(),
Di.get<StatusApi>()
)
}

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

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

Expand Down
1 change: 1 addition & 0 deletions server/src/main/kotlin/ivy/learn/api/AnalyticsApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ivy.learn.api
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import ivy.learn.api.common.Api

class AnalyticsApi : Api {
override fun Routing.endpoints() {
Expand Down
24 changes: 24 additions & 0 deletions server/src/main/kotlin/ivy/learn/api/LessonsApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package ivy.learn.api

import arrow.core.raise.ensureNotNull
import io.ktor.server.routing.*
import ivy.learn.api.common.Api
import ivy.learn.api.common.endpoint
import ivy.learn.api.common.model.ServerError
import ivy.learn.data.repository.LessonsRepository
import ivy.model.Lesson
import ivy.model.LessonId

class LessonsApi(
private val repository: LessonsRepository
) : Api {
override fun Routing.endpoints() {
// Endpoint that gets a lesson by ID
get("/lesson/{id}", endpoint<Lesson> { params ->
val lessonId = params["id"]?.let(::LessonId)
ensureNotNull(lessonId) { ServerError.BadRequest("Lesson ID is missing!") }
repository.fetchLessonById(lessonId)
.mapLeft(ServerError::Unknown).bind()
})
}
}
23 changes: 23 additions & 0 deletions server/src/main/kotlin/ivy/learn/api/StatusApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package ivy.learn.api

import io.ktor.server.routing.*
import ivy.learn.api.common.Api
import ivy.learn.api.common.endpoint
import kotlinx.serialization.Serializable

class StatusApi : Api {
override fun Routing.endpoints() {
get("/hello", endpoint {
HelloResponse(
message = "Hello, world!",
time = System.currentTimeMillis(),
)
})
}
}

@Serializable
data class HelloResponse(
val message: String,
val time: Long,
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package ivy.learn.api
package ivy.learn.api.common

import io.ktor.server.routing.*

Expand Down
28 changes: 28 additions & 0 deletions server/src/main/kotlin/ivy/learn/api/common/ApiUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package ivy.learn.api.common

import arrow.core.raise.Raise
import arrow.core.raise.either
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.util.pipeline.*
import ivy.learn.api.common.model.ServerError
import ivy.learn.api.common.model.ServerErrorResponse

inline fun <reified T : Any> endpoint(
crossinline handler: suspend Raise<ServerError>.(Parameters) -> T
): suspend PipelineContext<Unit, ApplicationCall>.(Unit) -> Unit = {
either {
handler(call.parameters)
}.onLeft { error ->
call.respond(
status = when (error) {
is ServerError.BadRequest -> HttpStatusCode.BadRequest
is ServerError.Unknown -> HttpStatusCode.InternalServerError
},
message = ServerErrorResponse(error.msg)
)
}.onRight { response ->
call.respond(HttpStatusCode.OK, response)
}
}
15 changes: 15 additions & 0 deletions server/src/main/kotlin/ivy/learn/api/common/model/ServerError.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package ivy.learn.api.common.model

import kotlinx.serialization.Serializable

sealed interface ServerError {
val msg: String

data class BadRequest(override val msg: String) : ServerError
data class Unknown(override val msg: String) : ServerError
}

@Serializable
data class ServerErrorResponse(
val message: String,
)
4 changes: 4 additions & 0 deletions server/src/main/kotlin/ivy/learn/data/di/DataModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import ivy.di.Di.register
import ivy.di.DiModule
import ivy.learn.data.database.DbConfigProvider
import ivy.learn.data.database.ExposedDatabase
import ivy.learn.data.repository.LessonsRepository
import ivy.learn.data.source.LessonDataSource

object DataModule : DiModule {
override fun init() = Di.appScope {
register { DbConfigProvider() }
register { ExposedDatabase(Di.get()) }
register { LessonDataSource(Di.get()) }
register { LessonsRepository(Di.get()) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package ivy.learn.data.repository

import arrow.core.Either
import arrow.core.raise.catch
import ivy.learn.data.source.LessonDataSource
import ivy.model.Lesson
import ivy.model.LessonId

class LessonsRepository(
private val lessonDataSource: LessonDataSource
) {
suspend fun fetchLessonById(id: LessonId): Either<String, Lesson> = catch({
Either.Right(lessonDataSource.fetchLessonById(id))
}, {
Either.Left("Failed to fetch '${id.value}' lesson: $it}")
})
}
22 changes: 22 additions & 0 deletions server/src/main/kotlin/ivy/learn/data/source/LessonDataSource.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package ivy.learn.data.source

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import ivy.model.Lesson
import ivy.model.LessonId

class LessonDataSource(
private val httpClient: HttpClient
) {
suspend fun fetchLessonById(id: LessonId): Lesson = httpClient.get(
urlString = "https://raw.githubusercontent.com/Ivy-Apps/learn-content" +
"/main/content/lessons" +
"/${id.value}.json"
) {
headers {
append("Authorization", "token ${System.getenv("IVY_LEARN_GITHUB_PAT")}")
append("Accept", "application/vnd.github.v3+json")
}
}.body<Lesson>()
}
9 changes: 7 additions & 2 deletions server/src/main/kotlin/ivy/learn/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ import ivy.di.Di.singleton
import ivy.di.DiModule
import ivy.learn.LearnServer

object AppModule : DiModule {
class AppModule(private val devMode: Boolean) : DiModule {
override fun init() = Di.appScope {
singleton { LearnServer(Di.get()) }
singleton {
LearnServer(
database = Di.get(),
devMode = devMode,
)
}
}
}
13 changes: 13 additions & 0 deletions shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ plugins {
id("ivy.shared-module")
id("ivy.serialization")
id("ivy.ktor-client")
`maven-publish`
}

group = "ivy.learn.shared"
version = "1.0.0"

publishing {
publications {
create<MavenPublication>("maven") {
// This line specifies that the output of the 'kotlin' component should be published
from(components["kotlin"])
}
}
}

android {
Expand Down
Loading

0 comments on commit 9a4fec5

Please sign in to comment.