Skip to content

Commit

Permalink
Signup plus improve (#52)
Browse files Browse the repository at this point in the history
* Add BE dev mode + refactor

* Simplify BE API

* Improve API base

* Install logging

* Add logger

* Refactor

* Add GoogleOAuth config

* WIP: GoogleOAuthUseCase

* Impl GoogleOAuthUseCase

* WIP: Auth service

* Define DB schema

* WIP: Auth

* Add crypto test

* Add UserRepository

* Add selects to UserRepository

* Add find session by token

* Implement AuthenticationService

* Implement Google auth API

* Add logging

* Fix DI issue
  • Loading branch information
ILIYANGERMANOV authored Nov 28, 2024
1 parent 09193d6 commit 7b22c8c
Show file tree
Hide file tree
Showing 33 changed files with 658 additions and 75 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci_server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:
IVY_LEARN_DB_PORT: 5432
IVY_LEARN_DB_PASSWORD: password
IVY_LEARN_GITHUB_PAT: ${{ secrets.IVY_LEARN_GITHUB_PAT }}
IVY_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.IVY_GOOGLE_OAUTH_CLIENT_ID }}
IVY_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.IVY_GOOGLE_OAUTH_CLIENT_SECRET }}
steps:
- name: Checkout GIT
uses: actions/checkout@v4
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export IVY_LEARN_DB_USER="postgres"
export IVY_LEARN_DB_PORT="5432"
export IVY_LEARN_DB_PASSWORD="password"
export IVY_LEARN_GITHUB_PAT="your_github_personal_access_token"
export IVY_GOOGLE_OAUTH_CLIENT_ID="your-client-id"
export IVY_GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"
```

**To run the sever:**
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "k
ktor-server-tests = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor" }
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
mockk = { module = "io.mockk:mockk", version = "1.13.13" }
postgresql-driver = { module = "org.postgresql:postgresql", version = "42.7.4" }
Expand Down
1 change: 1 addition & 0 deletions server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies {
implementation(libs.kotlin.serialization)
implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.logging)
implementation(libs.ktor.serialization.json)
implementation(libs.bundles.ktor.client.common)
implementation(libs.ktor.client.java)
Expand Down
10 changes: 9 additions & 1 deletion server/src/main/kotlin/ivy/learn/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ivy.di.Di
import ivy.di.SharedModule
import ivy.learn.data.di.DataModule
import ivy.learn.di.AppModule
import ivy.learn.domain.di.DomainModule

fun main(args: Array<String>) {
val devMode = "dev" in args
Expand All @@ -14,6 +15,9 @@ fun main(args: Array<String>) {

val port = System.getenv("PORT")?.toInt() ?: 8081
println("Starting server on port $port...")
if (devMode) {
println("[WARNING][DEV MODE] Server running in dev mode!")
}
embeddedServer(
Netty,
port = port,
Expand All @@ -31,9 +35,13 @@ fun initDi(devMode: Boolean) {
modules = setOf(
SharedModule,
DataModule,
AppModule(devMode = devMode)
AppModule(devMode = devMode),
DomainModule,
)
)
}

@JvmInline
value class ServerMode(val devMode: Boolean)

class ServerInitializationException(reason: String) : Exception("Server initialization failed: $reason")
21 changes: 21 additions & 0 deletions server/src/main/kotlin/ivy/learn/LearnServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@ import arrow.core.raise.either
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.calllogging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.request.*
import io.ktor.server.routing.*
import ivy.di.Di
import ivy.di.Di.register
import ivy.di.Di.singleton
import ivy.di.autowire.autoWire
import ivy.learn.api.*
import ivy.learn.api.common.Api
import ivy.learn.config.ServerConfigurationProvider
import ivy.learn.data.database.Database
import kotlinx.serialization.json.Json
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.slf4j.event.Level

class LearnServer(
private val database: Database,
Expand All @@ -32,6 +39,7 @@ class LearnServer(
}

private fun injectDependencies() = Di.appScope {
singleton<Logger> { LoggerFactory.getLogger("Application") }
autoWire(::AnalyticsApi)
autoWire(::LessonsApi)
autoWire(::StatusApi)
Expand All @@ -44,6 +52,7 @@ class LearnServer(
with(ktorApp) {
configureCORS()
configureContentNegotiation()
configureLogging()
}
val config = configurationProvider.fromEnvironment().bind()
Di.appScope { register { config } }
Expand Down Expand Up @@ -77,4 +86,16 @@ class LearnServer(
anyHost()
}
}

private fun Application.configureLogging() {
install(CallLogging) {
level = Level.INFO // Log INFO-level messages
format { call ->
val status = call.response.status()?.value ?: "Unknown"
val method = call.request.httpMethod.value
val uri = call.request.uri
"HTTP $method $uri -> $status"
}
}
}
}
31 changes: 20 additions & 11 deletions server/src/main/kotlin/ivy/learn/api/GoogleAuthenticationApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,42 @@ import arrow.core.raise.ensureNotNull
import io.ktor.server.response.*
import io.ktor.server.routing.*
import ivy.IvyUrls
import ivy.learn.ServerMode
import ivy.learn.api.common.Api
import ivy.learn.api.common.getEndpointBase
import ivy.learn.api.common.model.ServerError
import ivy.learn.api.common.model.ServerError.BadRequest
import java.util.*
import ivy.learn.domain.auth.AuthenticationService
import ivy.learn.domain.auth.GoogleAuthorizationCode
import org.slf4j.Logger

class GoogleAuthenticationApi : Api {
class GoogleAuthenticationApi(
private val serverMode: ServerMode,
private val authService: AuthenticationService,
private val logger: Logger,
) : Api {
override fun Routing.endpoints() {
googleAuthCallback()
}

private fun Routing.googleAuthCallback() {
get(IvyConstants.GoogleAuthCallbackEndpoint) {
call.parameters[""]
}
getEndpointBase(IvyConstants.GoogleAuthCallbackEndpoint) { call ->
val googleAuthCode = call.parameters["code"]?.let(::GoogleAuthorizationCode)
ensureNotNull(googleAuthCode) {
BadRequest("Google authorization code is required as 'code' parameter.")
}
// TODO: 1. Validate authorization code
// TODO: 2. Created session token
val sessionToken = UUID.randomUUID().toString()
val frontEndUrl = IvyUrls.debugFrontEnd
val auth = authService.authenticate(googleAuthCode)
.mapLeft(ServerError::Unknown)
.bind()
val sessionToken = auth.session.token
val frontEndUrl = if (serverMode.devMode) {
IvyUrls.devFrontEnd
} else {
IvyUrls.frontEnd
}
logger.info("User '${auth.user.email}' logged on $frontEndUrl.")
call.respondRedirect("${frontEndUrl}?${IvyConstants.SessionTokenParam}=$sessionToken")
}
}

@JvmInline
value class GoogleAuthorizationCode(val value: String)
}
15 changes: 11 additions & 4 deletions server/src/main/kotlin/ivy/learn/api/StatusApi.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
package ivy.learn.api

import io.ktor.server.routing.*
import ivy.learn.ServerMode
import ivy.learn.api.common.Api
import ivy.learn.api.common.getEndpoint
import kotlinx.serialization.Serializable
import org.slf4j.Logger

class StatusApi : Api {
class StatusApi(
private val serverMode: ServerMode,
private val logger: Logger,
) : Api {
override fun Routing.endpoints() {
getEndpoint("/hello") {
getEndpoint("/status") {
val time = System.currentTimeMillis()
logger.debug("Requesting status at $time")
HelloResponse(
message = "Hello, world!",
time = System.currentTimeMillis(),
message = "Hello, world! (devMode = ${serverMode.devMode})",
time = time,
)
}
}
Expand Down
82 changes: 58 additions & 24 deletions server/src/main/kotlin/ivy/learn/api/common/ApiUtils.kt
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
package ivy.learn.api.common

import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import ivy.learn.api.common.model.ServerError
import ivy.learn.api.common.model.ServerErrorResponse


@IvyServerDsl
inline fun <reified T : Any> Routing.getEndpoint(
inline fun <reified Body : Any, reified Response : Any> Routing.postEndpoint(
path: String,
crossinline handler: suspend Raise<ServerError>.(Parameters) -> T
crossinline handler: suspend Raise<ServerError>.(Body, Parameters) -> Response
) {
get(path) {
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)
postEndpointBase(path) { call ->
val body = try {
call.receive<Body>()
} catch (e: Exception) {
raise(ServerError.BadRequest("Malformed request body: $e"))
}
val response = handler(body, call.parameters)
call.respond(HttpStatusCode.OK, response)
}
}

@IvyServerDsl
inline fun <reified Response : Any> Routing.getEndpoint(
path: String,
crossinline handler: suspend Raise<ServerError>.(Parameters) -> Response
) {
getEndpointBase(path) { call ->
val response = handler(call.parameters)
call.respond(HttpStatusCode.OK, response)
}
}

Expand All @@ -36,19 +44,45 @@ inline fun Routing.getEndpointBase(
crossinline handler: suspend Raise<ServerError>.(RoutingCall) -> Unit
) {
get(path) {
either {
handleRequest(handler)
}
}

@IvyServerDsl
inline fun Routing.postEndpointBase(
path: String,
crossinline handler: suspend Raise<ServerError>.(RoutingCall) -> Unit
) {
post(path) {
handleRequest(handler)
}
}

suspend inline fun RoutingContext.handleRequest(
crossinline handler: suspend Raise<ServerError>.(RoutingCall) -> Unit
) {
val result = try {
either<ServerError, Unit> {
handler(call)
}.onLeft { error ->
call.respond(
status = when (error) {
is ServerError.BadRequest -> HttpStatusCode.BadRequest
is ServerError.Unknown -> HttpStatusCode.InternalServerError
},
message = ServerErrorResponse(error.msg)
)
}
} catch (e: Throwable) {
Either.Left(ServerError.Unknown("Unexpected error occurred."))
}
result.onLeft { error ->
respondError(error)
}
}

@IvyServerDsl
suspend fun RoutingContext.respondError(error: ServerError) {
call.respond(
status = when (error) {
is ServerError.BadRequest -> HttpStatusCode.BadRequest
is ServerError.Unknown -> HttpStatusCode.InternalServerError
},
message = ServerErrorResponse(error.msg)
)
}

@DslMarker
annotation class IvyServerDsl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package ivy.learn
package ivy.learn.config

import arrow.core.Either
import arrow.core.right
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package ivy.learn
package ivy.learn.config

import arrow.core.Either
import arrow.core.raise.either
Expand All @@ -11,9 +11,15 @@ data class DatabaseConfig(
val password: String
)

data class GoogleOAuthConfig(
val clientId: String,
val clientSecret: String,
)

data class ServerConfiguration(
val database: DatabaseConfig,
val privateContentGitHubPat: String
val privateContentGitHubPat: String,
val googleOAuth: GoogleOAuthConfig,
)

class ServerConfigurationProvider(
Expand All @@ -28,7 +34,11 @@ class ServerConfigurationProvider(
port = environment.getVariable("IVY_LEARN_DB_PORT").bind(),
password = environment.getVariable("IVY_LEARN_DB_PASSWORD").bind()
),
privateContentGitHubPat = environment.getVariable("IVY_LEARN_GITHUB_PAT").bind()
privateContentGitHubPat = environment.getVariable("IVY_LEARN_GITHUB_PAT").bind(),
googleOAuth = GoogleOAuthConfig(
clientId = environment.getVariable("IVY_GOOGLE_OAUTH_CLIENT_ID").bind(),
clientSecret = environment.getVariable("IVY_GOOGLE_OAUTH_CLIENT_SECRET").bind(),
)
)
}
}
6 changes: 3 additions & 3 deletions server/src/main/kotlin/ivy/learn/data/database/Database.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package ivy.learn.data.database
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import ivy.learn.DatabaseConfig
import ivy.learn.data.database.tables.AnalyticsEvents
import ivy.learn.config.DatabaseConfig
import ivy.learn.data.database.tables.Analytics
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
Expand All @@ -30,7 +30,7 @@ class Database {

private fun createDbSchema(database: Database): Either<Throwable, Database> = catch({
transaction {
SchemaUtils.create(AnalyticsEvents)
SchemaUtils.create(Analytics)
}
Either.Right(database)
}) {
Expand Down
Loading

0 comments on commit 7b22c8c

Please sign in to comment.