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
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
application
alias(libs.plugins.kotest.multiplatform)
id(libs.plugins.kotlin.jvm.pluginId)
alias(libs.plugins.arrowGradleConfig.formatter)
// alias(libs.plugins.arrowGradleConfig.formatter)
alias(libs.plugins.dokka)
id(libs.plugins.detekt.pluginId)
alias(libs.plugins.kover)
Expand Down Expand Up @@ -36,7 +36,7 @@ tasks {
withType<KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" + "-opt-in=kotlin.RequiresOptIn"
}
sourceCompatibility = "1.8"
targetCompatibility = "1.8"
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/io/github/nomisrev/ApiError.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import io.github.nomisrev.routes.Profile
sealed interface ApiError {
object PasswordNotMatched : ApiError
data class IncorrectInput(val errors: NonEmptyList<InvalidField>) : ApiError {
constructor(field: InvalidField) : this(nonEmptyListOf(field))
constructor(head: InvalidField): this(nonEmptyListOf(head))
}
data class EmptyUpdate(val description: String) : ApiError
data class UserNotFound(val property: String) : ApiError
Expand Down
49 changes: 49 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,49 @@
package io.github.nomisrev.auth

import arrow.core.continuations.effect
import io.github.nomisrev.ApiError
import io.github.nomisrev.KtorCtx
import io.github.nomisrev.config.Config
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)

data class JwtContext(val token: JwtToken, val userId: UserId)

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

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

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/kjwt-auth.kt

This file was deleted.

1 change: 1 addition & 0 deletions src/main/kotlin/io/github/nomisrev/config/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ private const val AUTH_SECRET: String = "MySuperStrongSecret"
private const val AUTH_ISSUER: String = "KtorArrowExampleIssuer"
private const val AUTH_DURATION: Int = 30

/** Config that is creating from System Env Variables, and default values */
data class Config(
val dataSource: DataSource = DataSource(),
val http: Http = Http(),
Expand Down
36 changes: 13 additions & 23 deletions src/main/kotlin/io/github/nomisrev/config/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,27 @@ package io.github.nomisrev.config

import arrow.fx.coroutines.Resource
import arrow.fx.coroutines.continuations.resource
import io.github.nomisrev.repo.articleRepo
import com.zaxxer.hikari.HikariDataSource
import io.github.nomisrev.repo.ArticlePersistence
import io.github.nomisrev.repo.UserPersistence
import io.github.nomisrev.repo.articlePersistence
import io.github.nomisrev.repo.userPersistence
import io.github.nomisrev.service.ArticleService
import io.github.nomisrev.service.DatabasePool
import io.github.nomisrev.service.JwtService
import io.github.nomisrev.service.UserService
import io.github.nomisrev.service.articleService
import io.github.nomisrev.service.databasePool
import io.github.nomisrev.service.jwtService
import io.github.nomisrev.service.SlugGenerator
import io.github.nomisrev.service.slugifyGenerator
import io.github.nomisrev.service.userService

class Dependencies(
val pool: DatabasePool,
val userService: UserService,
val jwtService: JwtService,
val articleService: ArticleService
val config: Config,
val hikariDataSource: HikariDataSource,
val userPersistence: UserPersistence,
val articlePersistence: ArticlePersistence,
val slugGenerator: SlugGenerator
)

fun dependencies(config: Config): Resource<Dependencies> = resource {
val hikari = hikari(config.dataSource).bind()
val sqlDelight = sqlDelight(hikari).bind()
val userRepo = userPersistence(sqlDelight.usersQueries)
val articleRepo = articleRepo(sqlDelight.articlesQueries, sqlDelight.tagsQueries)
val jwtService = jwtService(config.auth, userRepo)
val userPersistence = userPersistence(sqlDelight.usersQueries)
val articlePersistence = articlePersistence(sqlDelight.articlesQueries, sqlDelight.tagsQueries)
val slugGenerator = slugifyGenerator()
val userService = userService(userRepo, jwtService)
Dependencies(
databasePool(hikari),
userService,
jwtService,
articleService(slugGenerator, articleRepo, userRepo)
)
Dependencies(config, hikari, userPersistence, articlePersistence, slugGenerator)
}
12 changes: 2 additions & 10 deletions src/main/kotlin/io/github/nomisrev/config/ktor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,18 @@ import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.cors.CORS
import io.ktor.server.plugins.cors.maxAgeDuration
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
import kotlin.time.Duration.Companion.days
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic

val kotlinXSerializersModule = SerializersModule {
contextual(UserWrapper::class) { args -> UserWrapper.serializer(LoginUser.serializer()) }
contextual(UserWrapper::class) { UserWrapper.serializer(LoginUser.serializer()) }
polymorphic(Any::class) {
// subclass(UserWrapper::class,
// UserWrapper.serializer(PolymorphicSerializer(Any::class)).nullable as
// KSerializer<UserWrapper<*>>)
// subclass(UserWrapper::class,
// UserWrapper.serializer(PolymorphicSerializer(Any::class).nullable))
// subclass(NewUser::class, NewUser.serializer())
// subclass(User::class, User.serializer())
subclass(LoginUser::class, LoginUser.serializer())
// subclass(UpdateUser::class, UpdateUser.serializer())
}
}

Expand Down
6 changes: 4 additions & 2 deletions src/main/kotlin/io/github/nomisrev/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ fun main(): Unit =

fun Application.app(module: Dependencies) {
configure()
healthRoute(module.pool)
userRoutes(module.userService, module.jwtService)
with(module.userPersistence, module.config.auth, module.hikariDataSource) {
healthRoute()
userRoutes()
}
}
36 changes: 36 additions & 0 deletions src/main/kotlin/io/github/nomisrev/predef.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.github.nomisrev

import arrow.core.continuations.EffectScope
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.

❤️

inline fun <A, B, R> with(a: A, b: B, block: context(A, B) (TypePlacedHolder<B>) -> R): R {
contract { callsInPlace(block, EXACTLY_ONCE) }
return block(a, b, TypePlacedHolder)
}

@OptIn(ExperimentalContracts::class)
inline fun <A, B, C, R> with(a: A, b: B, c: C, block: context(A, B, C) (TypePlacedHolder<C>) -> R): R {
contract { callsInPlace(block, EXACTLY_ONCE) }
return block(a, b, c, TypePlacedHolder)
}

sealed interface TypePlacedHolder<out A> {
companion object : TypePlacedHolder<Nothing>
}

// TODO - temp fix for ambiguity bug in compiler
context(EffectScope<R>)
@OptIn(ExperimentalContracts::class)
suspend fun <R, B : Any> ensureNotNull(value: B?, shift: () -> R): B {
contract { returns() implies (value != null) }
return value ?: shift(shift())
}
21 changes: 14 additions & 7 deletions src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.github.nomisrev.repo

import arrow.core.Either
import arrow.core.continuations.EffectScope
import io.github.nomisrev.ApiError
import io.github.nomisrev.ApiError.Unexpected
import io.github.nomisrev.service.Slug
import io.github.nomisrev.sqldelight.ArticlesQueries
Expand All @@ -10,8 +12,9 @@ import java.time.OffsetDateTime
@JvmInline value class ArticleId(val serial: Long)

interface ArticlePersistence {
@Suppress("LongParameterList")
/** Creates a new Article with the specified tags */
context(EffectScope<ApiError>)
@Suppress("LongParameterList")
suspend fun create(
authorId: UserId,
slug: Slug,
Expand All @@ -21,14 +24,16 @@ interface ArticlePersistence {
createdAt: OffsetDateTime,
updatedAt: OffsetDateTime,
tags: Set<String>
): Either<Unexpected, ArticleId>
): ArticleId

/** Verifies if a certain slug already exists or not */
suspend fun exists(slug: Slug): Either<Unexpected, Boolean>
context(EffectScope<ApiError>)
suspend fun exists(slug: Slug): Boolean
}

fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) =
fun articlePersistence(articles: ArticlesQueries, tagsQueries: TagsQueries) =
object : ArticlePersistence {
context(EffectScope<Unexpected>)
override suspend fun create(
authorId: UserId,
slug: Slug,
Expand All @@ -38,7 +43,7 @@ fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) =
createdAt: OffsetDateTime,
updatedAt: OffsetDateTime,
tags: Set<String>
): Either<Unexpected, ArticleId> =
): ArticleId =
Either.catch {
articles.transactionWithResult<ArticleId> {
val articleId =
Expand All @@ -52,9 +57,11 @@ fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) =
}
}
.mapLeft { e -> Unexpected("Failed to create article: $authorId:$title:$tags", e) }
.bind()

override suspend fun exists(slug: Slug): Either<Unexpected, Boolean> =
context(EffectScope<Unexpected>)
override suspend fun exists(slug: Slug): Boolean =
Either.catch { articles.slugExists(slug.value).executeAsOne() }.mapLeft { e ->
Unexpected("Failed to check existence of $slug", e)
}
}.bind()
}
Loading