Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[POC] Context Receivers #35

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
14 changes: 6 additions & 8 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
alias(libs.plugins.kotlinx.serialization)
alias(libs.plugins.sqldelight)
alias(libs.plugins.ktor)
alias(libs.plugins.spotless)
}

application {
Expand All @@ -35,9 +34,15 @@ repositories {
mavenCentral()
}

java {
sourceCompatibility = JavaVersion.VERSION_19
targetCompatibility = JavaVersion.VERSION_11
}

tasks {
withType<KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
}
}
Expand All @@ -55,13 +60,6 @@ ktor {
}
}

spotless {
kotlin {
targetExclude("**/build/**")
ktfmt().googleStyle()
}
}

dependencies {
implementation(libs.bundles.arrow)
implementation(libs.bundles.ktor.server)
Expand Down
51 changes: 51 additions & 0 deletions src/main/kotlin/io/github/nomisrev/auth/JwtToken.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.github.nomisrev.auth

import arrow.core.raise.effect
import arrow.core.raise.fold
import io.github.nomisrev.KtorCtx
import io.github.nomisrev.env.Env
import io.github.nomisrev.repo.UserId
import io.github.nomisrev.repo.UserPersistence
import io.github.nomisrev.routes.respond
import io.github.nomisrev.service.verifyJwtToken
import io.ktor.http.HttpStatusCode
import io.ktor.http.auth.HttpAuthHeader
import io.ktor.server.application.call
import io.ktor.server.auth.parseAuthorizationHeader
import io.ktor.server.response.respond

@JvmInline
value class JwtToken(val value: String)

// Small middleware to validate JWT token without using Ktor Auth / Nullable principle
context(KtorCtx, UserPersistence, Env.Auth)
suspend inline fun jwtAuth( // BUG: inline + same context as lambda as function
crossinline body: suspend /*context(KtorCtx)*/ (token: JwtToken, userId: UserId) -> Unit
) {
optionalJwtAuth { token, userId ->
token?.let {
userId?.let {
body(token, userId)
}
} ?: call.respond(HttpStatusCode.Unauthorized)
}
}

// TODO Report YT: BUG: inline + same context as lambda as function
context(KtorCtx, UserPersistence, Env.Auth)
suspend inline fun optionalJwtAuth( // BUG: inline + same context as lambda as function
crossinline body: suspend /*context(KtorCtx)*/ (token: JwtToken?, userId: UserId?) -> Unit
) = effect {
jwtTokenStringOrNul()?.let { token ->
val userId = verifyJwtToken(JwtToken(token))
Pair(JwtToken(token), userId)
}
}.fold(
{ error -> respond(error) },
{ pair -> body(pair?.first, pair?.second) }
)

context(KtorCtx)
fun jwtTokenStringOrNul(): String? =
(call.request.parseAuthorizationHeader() as? HttpAuthHeader.Single)
?.blob
49 changes: 0 additions & 49 deletions src/main/kotlin/io/github/nomisrev/auth/jwt.kt

This file was deleted.

37 changes: 13 additions & 24 deletions src/main/kotlin/io/github/nomisrev/env/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,43 @@ package io.github.nomisrev.env
import arrow.fx.coroutines.continuations.ResourceScope
import com.sksamuel.cohort.HealthCheckRegistry
import com.sksamuel.cohort.hikari.HikariConnectionsHealthCheck
import io.github.nomisrev.repo.ArticlePersistence
import io.github.nomisrev.repo.FavouritePersistence
import io.github.nomisrev.repo.TagPersistence
import io.github.nomisrev.repo.UserPersistence
import io.github.nomisrev.repo.articleRepo
import io.github.nomisrev.repo.articlePersistence
import io.github.nomisrev.repo.favouritePersistence
import io.github.nomisrev.repo.tagPersistence
import io.github.nomisrev.repo.userPersistence
import io.github.nomisrev.service.ArticleService
import io.github.nomisrev.service.JwtService
import io.github.nomisrev.service.UserService
import io.github.nomisrev.service.articleService
import io.github.nomisrev.service.jwtService
import io.github.nomisrev.service.slugifyGenerator
import io.github.nomisrev.service.userService
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Dispatchers

class Dependencies(
val userService: UserService,
val jwtService: JwtService,
val articleService: ArticleService,
val userPersistence: UserPersistence,
val articlePersistence: ArticlePersistence,
val healthCheck: HealthCheckRegistry,
val tagPersistence: TagPersistence,
val userPersistence: UserPersistence
val favouritePersistence: FavouritePersistence
)

suspend fun ResourceScope.dependencies(env: Env): Dependencies {
val hikari = hikari(env.dataSource)
val sqlDelight = sqlDelight(hikari)
val userRepo = userPersistence(sqlDelight.usersQueries, sqlDelight.followingQueries)
val articleRepo = articleRepo(sqlDelight.articlesQueries, sqlDelight.tagsQueries)
val userPersistence = userPersistence(sqlDelight.usersQueries, sqlDelight.followingQueries)
val articlePersistence = articlePersistence(sqlDelight.articlesQueries, sqlDelight.tagsQueries)
val tagPersistence = tagPersistence(sqlDelight.tagsQueries)
val favouritePersistence = favouritePersistence(sqlDelight.favoritesQueries)
val jwtService = jwtService(env.auth, userRepo)
val slugGenerator = slugifyGenerator()
val userService = userService(userRepo, jwtService)

val checks =
HealthCheckRegistry(Dispatchers.Default) {
register(HikariConnectionsHealthCheck(hikari, 1), 5.seconds)
}

return Dependencies(
userService = userService,
jwtService = jwtService,
articleService =
articleService(slugGenerator, articleRepo, userRepo, tagPersistence, favouritePersistence),
healthCheck = checks,
tagPersistence = tagPersistence,
userPersistence = userRepo,
userPersistence,
articlePersistence,
checks,
tagPersistence,
favouritePersistence
)
}
4 changes: 3 additions & 1 deletion src/main/kotlin/io/github/nomisrev/env/ktor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import kotlinx.serialization.modules.polymorphic

val kotlinXSerializersModule = SerializersModule {
contextual(UserWrapper::class) { UserWrapper.serializer(LoginUser.serializer()) }
polymorphic(Any::class) { subclass(LoginUser::class, LoginUser.serializer()) }
polymorphic(Any::class) {
subclass(LoginUser::class, LoginUser.serializer())
}
}

fun Application.configure() {
Expand Down
18 changes: 15 additions & 3 deletions src/main/kotlin/io/github/nomisrev/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import io.github.nomisrev.env.configure
import io.github.nomisrev.env.dependencies
import io.github.nomisrev.routes.health
import io.github.nomisrev.routes.routes
import io.github.nomisrev.service.slugifyGenerator
import io.ktor.server.application.Application
import io.ktor.server.netty.Netty
import kotlinx.coroutines.awaitCancellation
Expand All @@ -17,13 +18,24 @@ fun main(): Unit = SuspendApp {
val env = Env()
resourceScope {
val dependencies = dependencies(env)
server(Netty, host = env.http.host, port = env.http.port) { app(dependencies) }
server(Netty, host = env.http.host, port = env.http.port) {
app(env, dependencies)
}
awaitCancellation()
}
}

fun Application.app(module: Dependencies) {
fun Application.app(env: Env, module: Dependencies) {
configure()
routes(module)
with(
env.auth,
module.userPersistence,
module.articlePersistence,
module.tagPersistence,
module.favouritePersistence,
slugifyGenerator()
) {
routes()
}
health(module.healthCheck)
}
30 changes: 30 additions & 0 deletions src/main/kotlin/io/github/nomisrev/predef.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.github.nomisrev

import io.ktor.server.application.ApplicationCall
import io.ktor.util.pipeline.PipelineContext
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind.EXACTLY_ONCE
import kotlin.contracts.contract

typealias KtorCtx = PipelineContext<Unit, ApplicationCall>

// Work-around for bug with context receiver lambda
// https://youtrack.jetbrains.com/issue/KT-51243
@OptIn(ExperimentalContracts::class)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

@Suppress("SUBTYPING_BETWEEN_CONTEXT_RECEIVERS", "LongParameterList")
inline fun <A, B, C, D, E, F, R> with(
a: A,
b: B,
c: C,
d: D,
e: E,
f: F,
block: context(A, B, C, D, E, F) (TypePlacedHolder<F>) -> R
): R {
contract { callsInPlace(block, EXACTLY_ONCE) }
return block(a, b, c, d, e, f, TypePlacedHolder)
}

sealed interface TypePlacedHolder<out A> {
companion object : TypePlacedHolder<Nothing>
}
14 changes: 7 additions & 7 deletions src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ interface ArticlePersistence {
suspend fun exists(slug: Slug): Boolean

/** Get recent articles from users you follow * */
suspend fun getFeed(userId: UserId, limit: FeedLimit, offset: FeedOffset): List<Article>
suspend fun selectFeed(userId: UserId, limit: FeedLimit, offset: FeedOffset): List<Article>

suspend fun getArticleBySlug(slug: Slug): Either<ArticleBySlugNotFound, Articles>
suspend fun selectArticleBySlug(slug: Slug): Either<ArticleBySlugNotFound, Articles>
}

fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) =
fun articlePersistence(articles: ArticlesQueries, tagsQueries: TagsQueries) =
object : ArticlePersistence {
override suspend fun create(
authorId: UserId,
Expand All @@ -65,7 +65,7 @@ fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) =
override suspend fun exists(slug: Slug): Boolean =
articles.slugExists(slug.value).executeAsOne()

override suspend fun getFeed(
override suspend fun selectFeed(
userId: UserId,
limit: FeedLimit,
offset: FeedOffset,
Expand All @@ -81,10 +81,10 @@ fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) =
articleTitle,
articleDescription,
articleBody,
articleAuthorId,
_,
articleCreatedAt,
articleUpdatedAt,
usersId,
_,
usersUsername,
usersImage ->
Article(
Expand All @@ -103,7 +103,7 @@ fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) =
}
.executeAsList()

override suspend fun getArticleBySlug(slug: Slug): Either<ArticleBySlugNotFound, Articles> =
override suspend fun selectArticleBySlug(slug: Slug): Either<ArticleBySlugNotFound, Articles> =
either {
val article = articles.selectBySlug(slug.value).executeAsOneOrNull()
ensureNotNull(article) { ArticleBySlugNotFound(slug.value) }
Expand Down
Loading