From 0cd6d62ed4a7c831866e4673ed79ed0a3d95bb9d Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Thu, 21 Apr 2022 18:11:43 +0200 Subject: [PATCH 1/5] Introduce context receivers --- build.gradle.kts | 2 +- .../io/github/nomisrev/auth/kjwt-auth.kt | 25 ++++---- .../nomisrev/repo/ArticlePersistence.kt | 19 ++++-- .../github/nomisrev/repo/UserPersistence.kt | 41 ++++++++----- .../kotlin/io/github/nomisrev/routes/error.kt | 22 +++---- .../kotlin/io/github/nomisrev/routes/users.kt | 55 +++++++++-------- .../github/nomisrev/service/ArticleService.kt | 21 +++---- .../io/github/nomisrev/service/JwtService.kt | 40 ++++++------ .../github/nomisrev/service/SlugGenerator.kt | 17 +++--- .../io/github/nomisrev/service/UserService.kt | 46 ++++++++------ .../github/nomisrev/routes/UserRouteSpec.kt | 18 +++--- .../nomisrev/service/UserServiceSpec.kt | 61 ++++++++++++------- 12 files changed, 210 insertions(+), 157 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index cf96b47f..6e34fb5b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) diff --git a/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt b/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt index 6c98f4e2..f5dfd308 100644 --- a/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt +++ b/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt @@ -2,7 +2,8 @@ package io.github.nomisrev.auth -import arrow.core.Either +import arrow.core.continuations.effect +import io.github.nomisrev.ApiError import io.github.nomisrev.repo.UserId import io.github.nomisrev.routes.respond import io.github.nomisrev.service.JwtService @@ -14,7 +15,8 @@ import io.ktor.server.auth.parseAuthorizationHeader import io.ktor.server.response.respond import io.ktor.util.pipeline.PipelineContext -@JvmInline value class JwtToken(val value: String) +@JvmInline +value class JwtToken(val value: String) data class JwtContext(val token: JwtToken, val userId: UserId) @@ -31,19 +33,16 @@ suspend inline fun PipelineContext.jwtAuth( suspend inline fun PipelineContext.optionalJwtAuth( jwtService: JwtService, crossinline body: suspend PipelineContext.(JwtContext?) -> Unit -) { +) = effect { jwtToken()?.let { token -> - jwtService - .verifyJwtToken(JwtToken(token)) - .fold( - { error -> respond(error) }, - { userId -> body(this, JwtContext(JwtToken(token), userId)) } - ) + val userId = jwtService.verifyJwtToken(JwtToken(token)) + JwtContext(JwtToken(token), userId) } - ?: body(this, null) -} +}.fold( + { error -> respond(error) }, + { context -> body(this, context) } +) fun PipelineContext.jwtToken(): String? = - Either.catch { (call.request.parseAuthorizationHeader() as? HttpAuthHeader.Single) } - .orNull() + (call.request.parseAuthorizationHeader() as? HttpAuthHeader.Single) ?.blob diff --git a/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt b/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt index 15f17852..29a6dd4f 100644 --- a/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt +++ b/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt @@ -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 @@ -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) + @Suppress("LongParameterList") suspend fun create( authorId: UserId, slug: Slug, @@ -21,14 +24,16 @@ interface ArticlePersistence { createdAt: OffsetDateTime, updatedAt: OffsetDateTime, tags: Set - ): Either + ): ArticleId /** Verifies if a certain slug already exists or not */ - suspend fun exists(slug: Slug): Either + context(EffectScope) + suspend fun exists(slug: Slug): Boolean } fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) = object : ArticlePersistence { + context(EffectScope) override suspend fun create( authorId: UserId, slug: Slug, @@ -38,7 +43,7 @@ fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) = createdAt: OffsetDateTime, updatedAt: OffsetDateTime, tags: Set - ): Either = + ): ArticleId = Either.catch { articles.transactionWithResult { val articleId = @@ -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 = + context(EffectScope) + 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() } diff --git a/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt b/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt index d01e6b4a..c37ff8e5 100644 --- a/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt +++ b/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt @@ -2,6 +2,7 @@ package io.github.nomisrev.repo import arrow.core.Either import arrow.core.computations.ensureNotNull +import arrow.core.continuations.EffectScope import arrow.core.continuations.either import arrow.core.continuations.ensureNotNull import io.github.nomisrev.ApiError @@ -19,20 +20,25 @@ import org.postgresql.util.PSQLState interface UserPersistence { /** Creates a new user in the database, and returns the [UserId] of the newly created user */ - suspend fun insert(username: String, email: String, password: String): Either + context(EffectScope) + suspend fun insert(username: String, email: String, password: String): UserId /** Verifies is a password is correct for a given email */ + context(EffectScope) suspend fun verifyPassword( email: String, password: String - ): Either> + ): Pair /** Select a User by its [UserId] */ - suspend fun select(userId: UserId): Either + context(EffectScope) + suspend fun select(userId: UserId): UserInfo /** Select a User by its username */ - suspend fun select(username: String): Either + context(EffectScope) + suspend fun select(username: String): UserInfo + context(EffectScope) @Suppress("LongParameterList") suspend fun update( userId: UserId, @@ -41,7 +47,7 @@ interface UserPersistence { password: String?, bio: String?, image: String? - ): Either + ): UserInfo } /** UserPersistence implementation based on SqlDelight and JavaX Crypto */ @@ -53,11 +59,12 @@ fun userPersistence( ) = object : UserPersistence { + context(EffectScope) override suspend fun insert( username: String, email: String, password: String - ): Either { + ): UserId { val salt = generateSalt() val key = generateKey(password, salt) return Either.catch { usersQueries.create(salt, key, username, email) }.mapLeft { error -> @@ -66,13 +73,14 @@ fun userPersistence( } else { Unexpected("Failed to persist user: $username:$email", error) } - } + }.bind() } + context(EffectScope) override suspend fun verifyPassword( email: String, password: String - ): Either> = either { + ): Pair { val (userId, username, salt, key, bio, image) = ensureNotNull(usersQueries.selectSecurityByEmail(email).executeAsOneOrNull()) { UserNotFound("email=$email") @@ -80,10 +88,11 @@ fun userPersistence( val hash = generateKey(password, salt) ensure(hash contentEquals key) { ApiError.PasswordNotMatched } - Pair(userId, UserInfo(email, username, bio, image)) + return Pair(userId, UserInfo(email, username, bio, image)) } - override suspend fun select(userId: UserId): Either = either { + context(EffectScope) + override suspend fun select(userId: UserId): UserInfo { val userInfo = Either.catch { usersQueries @@ -94,17 +103,19 @@ fun userPersistence( } .mapLeft { e -> Unexpected("Failed to select user with userId: $userId", e) } .bind() - ensureNotNull(userInfo) { UserNotFound("userId=$userId") } + return ensureNotNull(userInfo) { UserNotFound("userId=$userId") } } - override suspend fun select(username: String): Either = either { + context(EffectScope) + override suspend fun select(username: String): UserInfo { val userInfo = Either.catch { usersQueries.selectByUsername(username, ::UserInfo).executeAsOneOrNull() } .mapLeft { e -> Unexpected("Failed to select user with username: $username", e) } .bind() - ensureNotNull(userInfo) { UserNotFound("username=$username") } + return ensureNotNull(userInfo) { UserNotFound("username=$username") } } + context(EffectScope) override suspend fun update( userId: UserId, email: String?, @@ -112,7 +123,7 @@ fun userPersistence( password: String?, bio: String?, image: String? - ): Either = either { + ): UserInfo { val info = usersQueries.transactionWithResult { usersQueries.selectById(userId).executeAsOneOrNull()?.let { @@ -126,7 +137,7 @@ fun userPersistence( UserInfo(newEmail, newUsername, newBio, newImage) } } - ensureNotNull(info) { UserNotFound("userId=$userId") } + return ensureNotNull(info) { UserNotFound("userId=$userId") } } private fun generateSalt(): ByteArray = UUID.randomUUID().toString().toByteArray() diff --git a/src/main/kotlin/io/github/nomisrev/routes/error.kt b/src/main/kotlin/io/github/nomisrev/routes/error.kt index 992cddf8..6a79b3ff 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/error.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/error.kt @@ -9,6 +9,7 @@ import io.github.nomisrev.ApiError.EmailAlreadyExists import io.github.nomisrev.ApiError.EmptyUpdate import io.github.nomisrev.ApiError.IncorrectInput import io.github.nomisrev.ApiError.JwtGeneration +import io.github.nomisrev.ApiError.JwtInvalid import io.github.nomisrev.ApiError.PasswordNotMatched import io.github.nomisrev.ApiError.ProfileNotFound import io.github.nomisrev.ApiError.Unexpected @@ -23,16 +24,17 @@ import io.ktor.server.response.respond import io.ktor.util.pipeline.PipelineContext import kotlinx.serialization.Serializable -@Serializable data class GenericErrorModel(val errors: GenericErrorModelErrors) +@Serializable +data class GenericErrorModel(val errors: GenericErrorModelErrors) -@Serializable data class GenericErrorModelErrors(val body: List) +@Serializable +data class GenericErrorModelErrors(val body: List) fun GenericErrorModel(vararg msg: String): GenericErrorModel = GenericErrorModel(GenericErrorModelErrors(msg.toList())) context(PipelineContext) - -suspend inline fun Either.respond(status: HttpStatusCode): Unit = + suspend inline fun Either.respond(status: HttpStatusCode): Unit = when (this) { is Either.Left -> respond(value) is Either.Right -> call.respond(status, value) @@ -43,9 +45,7 @@ suspend fun PipelineContext.respond(error: ApiError): Uni when (error) { PasswordNotMatched -> call.respond(HttpStatusCode.Unauthorized) is IncorrectInput -> - unprocessable( - error.errors.joinToString { field -> "${field.field}: ${field.errors.joinToString()}" } - ) + unprocessable(error.errors.joinToString { field -> "${field.field}: ${field.errors.joinToString()}" }) is Unexpected -> internal( """ @@ -61,13 +61,11 @@ suspend fun PipelineContext.respond(error: ApiError): Uni is EmailAlreadyExists -> unprocessable("${error.email} is already registered") is JwtGeneration -> unprocessable(error.description) is ProfileNotFound -> unprocessable("Profile for ${error.profile.username} not found") - is UserFollowingHimself -> - unprocessable("${error.profile.username} cannot follow ${error.profile.username}") + is UserFollowingHimself -> unprocessable("${error.profile.username} cannot follow ${error.profile.username}") is UserNotFound -> unprocessable("User with ${error.property} not found") - is UserUnfollowingHimself -> - unprocessable("${error.profile.username} cannot unfollow ${error.profile.username}") + is UserUnfollowingHimself -> unprocessable("${error.profile.username} cannot unfollow ${error.profile.username}") is UsernameAlreadyExists -> unprocessable("Username ${error.username} already exists") - is ApiError.JwtInvalid -> unprocessable(error.description) + is JwtInvalid -> unprocessable(error.description) } private suspend inline fun PipelineContext.unprocessable( diff --git a/src/main/kotlin/io/github/nomisrev/routes/users.kt b/src/main/kotlin/io/github/nomisrev/routes/users.kt index 786739e1..35897e40 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/users.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/users.kt @@ -1,6 +1,7 @@ package io.github.nomisrev.routes import arrow.core.Either +import arrow.core.continuations.EffectScope import arrow.core.continuations.either import io.github.nomisrev.ApiError import io.github.nomisrev.ApiError.Unexpected @@ -23,9 +24,11 @@ import io.ktor.server.routing.routing import io.ktor.util.pipeline.PipelineContext import kotlinx.serialization.Serializable -@Serializable data class UserWrapper(val user: T) +@Serializable +data class UserWrapper(val user: T) -@Serializable data class NewUser(val username: String, val email: String, val password: String) +@Serializable +data class NewUser(val username: String, val email: String, val password: String) @Serializable data class UpdateUser( @@ -45,7 +48,8 @@ data class User( val image: String ) -@Serializable data class LoginUser(val email: String, val password: String) +@Serializable +data class LoginUser(val email: String, val password: String) fun Application.userRoutes( userService: UserService, @@ -55,19 +59,17 @@ fun Application.userRoutes( /* Registration: POST /api/users */ post { either> { - val (username, email, password) = receiveCatching>().bind().user - val token = userService.register(RegisterUser(username, email, password)).bind().value - UserWrapper(User(email, token, username, "", "")) - } - .respond(HttpStatusCode.Created) + val (username, email, password) = receiveCatching>().user + val token = userService.register(RegisterUser(username, email, password)).value + UserWrapper(User(email, token, username, "", "")) + }.respond(HttpStatusCode.Created) } post("/login") { either> { - val (email, password) = receiveCatching>().bind().user - val (token, info) = userService.login(Login(email, password)).bind() - UserWrapper(User(email, token.value, info.username, info.bio, info.image)) - } - .respond(HttpStatusCode.OK) + val (email, password) = receiveCatching>().user + val (token, info) = userService.login(Login(email, password)) + UserWrapper(User(email, token.value, info.username, info.bio, info.image)) + }.respond(HttpStatusCode.OK) } } @@ -75,10 +77,9 @@ fun Application.userRoutes( get("/user") { jwtAuth(jwtService) { (token, userId) -> either> { - val info = userService.getUser(userId).bind() - UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) - } - .respond(HttpStatusCode.OK) + val info = userService.getUser(userId) + UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) + }.respond(HttpStatusCode.OK) } } @@ -86,20 +87,20 @@ fun Application.userRoutes( put("/user") { jwtAuth(jwtService) { (token, userId) -> either> { - val (email, username, password, bio, image) = - receiveCatching>().bind().user - val info = - userService.update(Update(userId, username, email, password, bio, image)).bind() - UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) - } - .respond(HttpStatusCode.OK) + val (email, username, password, bio, image) = + receiveCatching>().user + val info = + userService.update(Update(userId, username, email, password, bio, image)) + UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) + }.respond(HttpStatusCode.OK) } } } // TODO improve how we receive models with validation -private suspend inline fun PipelineContext< - Unit, ApplicationCall>.receiveCatching(): Either = +context(EffectScope) + private suspend inline fun PipelineContext< + Unit, ApplicationCall>.receiveCatching(): A = Either.catch { call.receive() }.mapLeft { e -> Unexpected(e.message ?: "Received malformed JSON for ${A::class.simpleName}", e) - } + }.bind() diff --git a/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt b/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt index c55c07a9..e3fd1e38 100644 --- a/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt +++ b/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt @@ -1,7 +1,6 @@ package io.github.nomisrev.service -import arrow.core.Either -import arrow.core.continuations.either +import arrow.core.continuations.EffectScope import io.github.nomisrev.ApiError import io.github.nomisrev.repo.ArticlePersistence import io.github.nomisrev.repo.UserId @@ -20,7 +19,8 @@ data class CreateArticle( interface ArticleService { /** Creates a new article and returns the resulting Article */ - suspend fun createArticle(input: CreateArticle): Either + context(EffectScope) + suspend fun createArticle(input: CreateArticle): Article } fun articleService( @@ -29,11 +29,12 @@ fun articleService( userPersistence: UserPersistence, ): ArticleService = object : ArticleService { - override suspend fun createArticle(input: CreateArticle): Either = either { + context(EffectScope) + override suspend fun createArticle(input: CreateArticle): Article { val slug = slugGenerator - .generateSlug(input.title) { slug -> articlePersistence.exists(slug).bind() } - .bind() + .generateSlug(input.title) { slug -> articlePersistence.exists(slug) } + val createdAt = OffsetDateTime.now() val articleId = articlePersistence @@ -46,11 +47,9 @@ fun articleService( createdAt, createdAt, input.tags - ) - .bind() - .serial - val user = userPersistence.select(input.userId).bind() - Article( + ).serial + val user = userPersistence.select(input.userId) + return Article( articleId, slug.value, input.title, diff --git a/src/main/kotlin/io/github/nomisrev/service/JwtService.kt b/src/main/kotlin/io/github/nomisrev/service/JwtService.kt index 7a06f764..8fae87a8 100644 --- a/src/main/kotlin/io/github/nomisrev/service/JwtService.kt +++ b/src/main/kotlin/io/github/nomisrev/service/JwtService.kt @@ -2,6 +2,7 @@ package io.github.nomisrev.service import arrow.core.Either import arrow.core.computations.ensureNotNull +import arrow.core.continuations.EffectScope import arrow.core.continuations.either import arrow.core.continuations.ensureNotNull import io.github.nefilim.kjwt.JWSAlgorithm @@ -23,15 +24,18 @@ import kotlin.time.toJavaDuration interface JwtService { /** Generate a new JWT token for userId and password. Doesn't invalidate old password */ - suspend fun generateJwtToken(userId: UserId): Either + context(EffectScope) + suspend fun generateJwtToken(userId: UserId): JwtToken /** Verify a JWT token. Checks if userId exists in database, and token is not expired. */ - suspend fun verifyJwtToken(token: JwtToken): Either + context(EffectScope) + suspend fun verifyJwtToken(token: JwtToken): UserId } fun jwtService(config: Config.Auth, repo: UserPersistence) = object : JwtService { - override suspend fun generateJwtToken(userId: UserId): Either = + context(EffectScope) + override suspend fun generateJwtToken(userId: UserId): JwtToken = JWT .hs512 { val now = LocalDateTime.now(ZoneId.of("UTC")) @@ -41,10 +45,11 @@ fun jwtService(config: Config.Auth, repo: UserPersistence) = claim("id", userId.serial) } .sign(config.secret) - .toUserServiceError() - .map { JwtToken(it.rendered) } + .bind() + .let { JwtToken(it.rendered) } - override suspend fun verifyJwtToken(token: JwtToken): Either = either { + context(EffectScope) + override suspend fun verifyJwtToken(token: JwtToken): UserId { val jwt = JWT.decodeT(token.value, JWSHMAC512Algorithm).mapLeft { JwtInvalid(it.toString()) }.bind() val userId = @@ -54,17 +59,18 @@ fun jwtService(config: Config.Auth, repo: UserPersistence) = val expiresAt = ensureNotNull(jwt.expiresAt().orNull()) { JwtInvalid("exp missing from JWT Token") } ensure(expiresAt.isAfter(LocalDateTime.now())) { JwtInvalid("JWT Token expired") } - repo.select(UserId(userId)).bind() - UserId(userId) + repo.select(UserId(userId)) + return UserId(userId) } } -private fun Either>.toUserServiceError(): - Either> = mapLeft { jwtError -> - when (jwtError) { - KJWTSignError.InvalidKey -> JwtGeneration("JWT singing error: invalid Secret Key.") - KJWTSignError.InvalidJWTData -> - JwtGeneration("JWT singing error: Generated with incorrect JWT data") - is KJWTSignError.SigningError -> JwtGeneration("JWT singing error: ${jwtError.cause}") - } -} +context(EffectScope) +private suspend fun Either>.bind(): SignedJWT = + mapLeft { jwtError -> + when (jwtError) { + KJWTSignError.InvalidKey -> JwtGeneration("JWT singing error: invalid Secret Key.") + KJWTSignError.InvalidJWTData -> + JwtGeneration("JWT singing error: Generated with incorrect JWT data") + is KJWTSignError.SigningError -> JwtGeneration("JWT singing error: ${jwtError.cause}") + } + }.bind() diff --git a/src/main/kotlin/io/github/nomisrev/service/SlugGenerator.kt b/src/main/kotlin/io/github/nomisrev/service/SlugGenerator.kt index ac60a259..411e860d 100644 --- a/src/main/kotlin/io/github/nomisrev/service/SlugGenerator.kt +++ b/src/main/kotlin/io/github/nomisrev/service/SlugGenerator.kt @@ -1,8 +1,8 @@ package io.github.nomisrev.service -import arrow.core.Either -import arrow.core.continuations.either +import arrow.core.continuations.EffectScope import com.github.slugify.Slugify +import io.github.nomisrev.ApiError import io.github.nomisrev.ApiError.CannotGenerateSlug import kotlin.random.Random @@ -16,10 +16,11 @@ fun interface SlugGenerator { * @param verifyUnique Allows checking uniqueness with some business rules. i.e. check database * that slug is actually unique for domain. */ + context(EffectScope) suspend fun generateSlug( title: String, verifyUnique: suspend (Slug) -> Boolean - ): Either + ): Slug } fun slugifyGenerator( @@ -34,23 +35,25 @@ fun slugifyGenerator( private fun makeUnique(slug: String): String = "${slug}_${random.nextInt(minRandomSuffix, maxRandomSuffix)}" - private suspend fun recursiveGen( + context(EffectScope) + private tailrec suspend fun recursiveGen( title: String, verifyUnique: suspend (Slug) -> Boolean, maxAttempts: Int, isFirst: Boolean - ): Either = either { + ): Slug { ensure(maxAttempts != 0) { CannotGenerateSlug("Failed to generate unique slug from $title") } val slug = Slug(if (isFirst) slg.slugify(title) else makeUnique(slg.slugify(title))) val isUnique = verifyUnique(slug) - if (isUnique) slug else recursiveGen(title, verifyUnique, maxAttempts - 1, false).bind() + return if (isUnique) slug else recursiveGen(title, verifyUnique, maxAttempts - 1, false) } + context(EffectScope) override suspend fun generateSlug( title: String, verifyUnique: suspend (Slug) -> Boolean - ): Either = + ): Slug = recursiveGen(title, verifyUnique, defaultMaxAttempts, true) } diff --git a/src/main/kotlin/io/github/nomisrev/service/UserService.kt b/src/main/kotlin/io/github/nomisrev/service/UserService.kt index 4409e559..3bf27905 100644 --- a/src/main/kotlin/io/github/nomisrev/service/UserService.kt +++ b/src/main/kotlin/io/github/nomisrev/service/UserService.kt @@ -1,7 +1,6 @@ package io.github.nomisrev.service -import arrow.core.Either -import arrow.core.continuations.either +import arrow.core.continuations.EffectScope import io.github.nomisrev.ApiError import io.github.nomisrev.ApiError.EmptyUpdate import io.github.nomisrev.auth.JwtToken @@ -26,46 +25,57 @@ data class UserInfo(val email: String, val username: String, val bio: String, va interface UserService { /** Registers the user and returns its unique identifier */ - suspend fun register(input: RegisterUser): Either + context(EffectScope) + suspend fun register(input: RegisterUser): JwtToken /** Updates a user with all the provided fields, returns resulting info */ - suspend fun update(input: Update): Either + context(EffectScope) + suspend fun update(input: Update): UserInfo /** Logs in a user based on email and password. */ - suspend fun login(input: Login): Either> + context(EffectScope) + suspend fun login(input: Login): Pair /** Retrieve used based on userId */ - suspend fun getUser(userId: UserId): Either + context(EffectScope) + suspend fun getUser(userId: UserId): UserInfo /** Retrieve used based on username */ - suspend fun getUser(username: String): Either + context(EffectScope) + suspend fun getUser(username: String): UserInfo } fun userService(repo: UserPersistence, jwtService: JwtService) = object : UserService { - override suspend fun register(input: RegisterUser): Either = either { + context(EffectScope) + override suspend fun register(input: RegisterUser): JwtToken { val (username, email, password) = input.validate().bind() - val userId = repo.insert(username, email, password).bind() - jwtService.generateJwtToken(userId).bind() + val userId = repo.insert(username, email, password) + return jwtService.generateJwtToken(userId) } - override suspend fun login(input: Login): Either> = either { + context(EffectScope) + override suspend fun login(input: Login): Pair { val (email, password) = input.validate().bind() - val (userId, info) = repo.verifyPassword(email, password).bind() - val token = jwtService.generateJwtToken(userId).bind() - Pair(token, info) + val (userId, info) = repo.verifyPassword(email, password) + val token = jwtService.generateJwtToken(userId) + return Pair(token, info) } - override suspend fun update(input: Update): Either = either { + context(EffectScope) + override suspend fun update(input: Update): UserInfo { val (userId, username, email, password, bio, image) = input.validate().bind() ensure(email != null || username != null || bio != null || image != null) { EmptyUpdate("Cannot update user with $userId with only null values") } - repo.update(userId, email, username, password, bio, image).bind() + return repo.update(userId, email, username, password, bio, image) } - override suspend fun getUser(userId: UserId): Either = repo.select(userId) + context(EffectScope) + override suspend fun getUser(userId: UserId): UserInfo = + repo.select(userId) - override suspend fun getUser(username: String): Either = + context(EffectScope) + override suspend fun getUser(username: String): UserInfo = repo.select(username) } diff --git a/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt b/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt index e77cc23c..99c9de82 100644 --- a/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt @@ -1,6 +1,9 @@ package io.github.nomisrev.routes +import arrow.core.continuations.either +import io.github.nomisrev.ApiError import io.github.nomisrev.PostgreSQLContainer +import io.github.nomisrev.auth.JwtToken import io.github.nomisrev.config.Config import io.github.nomisrev.config.dependencies import io.github.nomisrev.config.hikari @@ -35,6 +38,10 @@ class UserRouteSpec : afterTest { dataSource.query("TRUNCATE users CASCADE") } + suspend fun registerUser(): JwtToken = either { + userService.register(RegisterUser(validUsername, validEmail, validPw)) + }.shouldBeRight() + "Can register user" { withService(dependencies) { val response = @@ -55,7 +62,7 @@ class UserRouteSpec : } "Can log in a registered user" { - userService.register(RegisterUser(validUsername, validEmail, validPw)).shouldBeRight() + registerUser() withService(dependencies) { val response = client.post("/users/login") { @@ -75,8 +82,7 @@ class UserRouteSpec : } "Can get current user" { - val token = - userService.register(RegisterUser(validUsername, validEmail, validPw)).shouldBeRight() + val token = registerUser() withService(dependencies) { val response = client.get("/user") { bearerAuth(token.value) } @@ -93,8 +99,7 @@ class UserRouteSpec : } "Update user" { - val token = - userService.register(RegisterUser(validUsername, validEmail, validPw)).shouldBeRight() + val token = registerUser() val newUsername = "newUsername" withService(dependencies) { val response = @@ -117,8 +122,7 @@ class UserRouteSpec : } "Update user invalid email" { - val token = - userService.register(RegisterUser(validUsername, validEmail, validPw)).shouldBeRight() + val token = registerUser() val inalidEmail = "invalidEmail" withService(dependencies) { val response = diff --git a/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt b/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt index 346bfca0..71f15acf 100644 --- a/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt @@ -1,8 +1,10 @@ package io.github.nomisrev.service +import arrow.core.continuations.either import arrow.core.nonEmptyListOf import io.github.nefilim.kjwt.JWSHMAC512Algorithm import io.github.nefilim.kjwt.JWT +import io.github.nomisrev.ApiError import io.github.nomisrev.ApiError.EmptyUpdate import io.github.nomisrev.ApiError.IncorrectInput import io.github.nomisrev.ApiError.UsernameAlreadyExists @@ -37,66 +39,76 @@ class UserServiceSpec : "register" - { "username cannot be empty" { - val res = userService.register(RegisterUser("", validEmail, validPw)) val errors = nonEmptyListOf("Cannot be blank", "is too short (minimum is 1 characters)") val expected = IncorrectInput(InvalidUsername(errors)) - res shouldBeLeft expected + either { + userService.register(RegisterUser("", validEmail, validPw)) + } shouldBeLeft expected } "username longer than 25 chars" { val name = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - val res = userService.register(RegisterUser(name, validEmail, validPw)) val errors = nonEmptyListOf("is too long (maximum is 25 characters)") val expected = IncorrectInput(InvalidUsername(errors)) - res shouldBeLeft expected + either { + userService.register(RegisterUser(name, validEmail, validPw)) + } shouldBeLeft expected } "email cannot be empty" { - val res = userService.register(RegisterUser(validUsername, "", validPw)) val errors = nonEmptyListOf("Cannot be blank", "'' is invalid email") val expected = IncorrectInput(InvalidEmail(errors)) - res shouldBeLeft expected + either { + userService.register(RegisterUser(validUsername, "", validPw)) + } shouldBeLeft expected } "email too long" { val email = "${(0..340).joinToString("") { "A" }}@domain.com" - val res = userService.register(RegisterUser(validUsername, email, validPw)) val errors = nonEmptyListOf("is too long (maximum is 350 characters)") val expected = IncorrectInput(InvalidEmail(errors)) - res shouldBeLeft expected + either { + userService.register(RegisterUser(validUsername, email, validPw)) + } shouldBeLeft expected } "email is not valid" { val email = "AAAA" - val res = userService.register(RegisterUser(validUsername, email, validPw)) val errors = nonEmptyListOf("'$email' is invalid email") val expected = IncorrectInput(InvalidEmail(errors)) - res shouldBeLeft expected + either { + userService.register(RegisterUser(validUsername, email, validPw)) + } shouldBeLeft expected } "password cannot be empty" { - val res = userService.register(RegisterUser(validUsername, validEmail, "")) val errors = nonEmptyListOf("Cannot be blank", "is too short (minimum is 8 characters)") val expected = IncorrectInput(InvalidPassword(errors)) - res shouldBeLeft expected + either { + userService.register(RegisterUser(validUsername, validEmail, "")) + } shouldBeLeft expected } "password can be max 100" { val password = (0..100).joinToString("") { "A" } - val res = userService.register(RegisterUser(validUsername, validEmail, password)) val errors = nonEmptyListOf("is too long (maximum is 100 characters)") val expected = IncorrectInput(InvalidPassword(errors)) - res shouldBeLeft expected + either { + userService.register(RegisterUser(validUsername, validEmail, password)) + } shouldBeLeft expected } "All valid returns a token" { - userService.register(RegisterUser(validUsername, validEmail, validPw)).shouldBeRight() + either { + userService.register(RegisterUser(validUsername, validEmail, validPw)) + }.shouldBeRight() } - "Register twice results in" { - userService.register(RegisterUser(validUsername, validEmail, validPw)).shouldBeRight() - val res = userService.register(RegisterUser(validUsername, validEmail, validPw)) - res shouldBeLeft UsernameAlreadyExists(validUsername) + "Register twice results in UsernameAlreadyExists" { + either { + userService.register(RegisterUser(validUsername, validEmail, validPw)) + userService.register(RegisterUser(validUsername, validEmail, validPw)) + } shouldBeLeft UsernameAlreadyExists(validUsername) } } @@ -104,10 +116,13 @@ class UserServiceSpec : { "Update with all null" { val token = - userService.register(RegisterUser(validUsername, validEmail, validPw)).shouldBeRight() - val res = userService.update(Update(token.id(), null, null, null, null, null)) - res shouldBeLeft - EmptyUpdate("Cannot update user with ${token.id()} with only null values") + either { + userService.register(RegisterUser(validUsername, validEmail, validPw)) + }.shouldBeRight() + + either { + userService.update(Update(token.id(), null, null, null, null, null)) + } shouldBeLeft EmptyUpdate("Cannot update user with ${token.id()} with only null values") } } }) From 5fa2b571f9f5066c6c4e067f48cae63260bb38d5 Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Thu, 21 Apr 2022 18:23:57 +0200 Subject: [PATCH 2/5] Clean-up --- .../github/nomisrev/routes/UserRouteSpec.kt | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt b/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt index 99c9de82..b89ea46f 100644 --- a/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt @@ -32,14 +32,14 @@ class UserRouteSpec : val dependencies by resource(dependencies(config)) val userService by lazy { dependencies.userService } - val validUsername = "username" - val validEmail = "valid@domain.com" - val validPw = "123456789" + val username = "username" + val email = "valid@domain.com" + val password = "123456789" afterTest { dataSource.query("TRUNCATE users CASCADE") } suspend fun registerUser(): JwtToken = either { - userService.register(RegisterUser(validUsername, validEmail, validPw)) + userService.register(RegisterUser(username, email, password)) }.shouldBeRight() "Can register user" { @@ -47,14 +47,14 @@ class UserRouteSpec : val response = client.post("/users") { contentType(ContentType.Application.Json) - setBody(UserWrapper(NewUser(validUsername, validEmail, validPw))) + setBody(UserWrapper(NewUser(username, email, password))) } response.status shouldBe HttpStatusCode.Created assertSoftly { val user = response.body>().user - user.username shouldBe validUsername - user.email shouldBe validEmail + user.username shouldBe username + user.email shouldBe email user.bio shouldBe "" user.image shouldBe "" } @@ -67,14 +67,14 @@ class UserRouteSpec : val response = client.post("/users/login") { contentType(ContentType.Application.Json) - setBody(UserWrapper(LoginUser(validEmail, validPw))) + setBody(UserWrapper(LoginUser(email, password))) } response.status shouldBe HttpStatusCode.OK assertSoftly { val user = response.body>().user - user.username shouldBe validUsername - user.email shouldBe validEmail + user.username shouldBe username + user.email shouldBe email user.bio shouldBe "" user.image shouldBe "" } @@ -89,8 +89,8 @@ class UserRouteSpec : response.status shouldBe HttpStatusCode.OK assertSoftly { val user = response.body>().user - user.username shouldBe validUsername - user.email shouldBe validEmail + user.username shouldBe username + user.email shouldBe email user.token shouldBe token.value user.bio shouldBe "" user.image shouldBe "" @@ -113,7 +113,7 @@ class UserRouteSpec : assertSoftly { val user = response.body>().user user.username shouldBe newUsername - user.email shouldBe validEmail + user.email shouldBe email user.token shouldBe token.value user.bio shouldBe "" user.image shouldBe "" @@ -123,18 +123,18 @@ class UserRouteSpec : "Update user invalid email" { val token = registerUser() - val inalidEmail = "invalidEmail" + val invalid = "invalid" withService(dependencies) { val response = client.put("/user") { bearerAuth(token.value) contentType(ContentType.Application.Json) - setBody(UserWrapper(UpdateUser(email = inalidEmail))) + setBody(UserWrapper(UpdateUser(email = invalid))) } response.status shouldBe HttpStatusCode.UnprocessableEntity response.body().errors.body shouldBe - listOf("email: 'invalidEmail' is invalid email") + listOf("email: '$invalid' is invalid email") } } }) From 906836b6662aa27d6e6e105be8ec007f17b1b768 Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Mon, 16 May 2022 17:12:05 +0200 Subject: [PATCH 3/5] Iteration 2, remove service layer --- build.gradle.kts | 2 +- .../io/github/nomisrev/auth/kjwt-auth.kt | 28 ++++--- .../io/github/nomisrev/config/Dependencies.kt | 36 +++----- src/main/kotlin/io/github/nomisrev/main.kt | 4 +- src/main/kotlin/io/github/nomisrev/predef.kt | 26 ++++++ .../nomisrev/repo/ArticlePersistence.kt | 2 +- .../kotlin/io/github/nomisrev/routes/error.kt | 2 +- .../io/github/nomisrev/routes/health.kt | 34 +++++--- .../kotlin/io/github/nomisrev/routes/users.kt | 83 ++++++++++--------- .../github/nomisrev/service/ArticleService.kt | 67 ++++++--------- .../github/nomisrev/service/DatabasePool.kt | 16 ---- .../io/github/nomisrev/service/JwtService.kt | 71 +++++++--------- .../io/github/nomisrev/service/UserService.kt | 75 +++++------------ .../github/nomisrev/routes/UserRouteSpec.kt | 7 +- .../nomisrev/service/UserServiceSpec.kt | 30 ++++--- 15 files changed, 226 insertions(+), 257 deletions(-) create mode 100644 src/main/kotlin/io/github/nomisrev/predef.kt delete mode 100644 src/main/kotlin/io/github/nomisrev/service/DatabasePool.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6e34fb5b..e2322512 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,7 +36,7 @@ tasks { withType().configureEach { kotlinOptions { jvmTarget = "1.8" - freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" + freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" + "-opt-in=kotlin.RequiresOptIn" } sourceCompatibility = "1.8" targetCompatibility = "1.8" diff --git a/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt b/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt index f5dfd308..1f54c781 100644 --- a/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt +++ b/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt @@ -4,9 +4,11 @@ package io.github.nomisrev.auth import arrow.core.continuations.effect import io.github.nomisrev.ApiError +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.JwtService +import io.github.nomisrev.service.verifyJwtToken import io.ktor.http.HttpStatusCode import io.ktor.http.auth.HttpAuthHeader import io.ktor.server.application.ApplicationCall @@ -21,28 +23,30 @@ 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 -suspend inline fun PipelineContext.jwtAuth( - jwtService: JwtService, - crossinline body: suspend PipelineContext.(JwtContext) -> Unit +context(UserPersistence, Config.Auth, PipelineContext) +suspend inline fun jwtAuth( + crossinline body: suspend /*context(PipelineContext)*/ (JwtContext) -> Unit ) { - optionalJwtAuth(jwtService) { context -> - context?.let { body(this, it) } ?: call.respond(HttpStatusCode.Unauthorized) + optionalJwtAuth { context -> + context?.let { body(it) } ?: call.respond(HttpStatusCode.Unauthorized) } } -suspend inline fun PipelineContext.optionalJwtAuth( - jwtService: JwtService, - crossinline body: suspend PipelineContext.(JwtContext?) -> Unit +// TODO - read Pipeline context to lambda context +context(PipelineContext, UserPersistence, Config.Auth) +suspend inline fun optionalJwtAuth( + crossinline body: suspend /*context(PipelineContext)*/ (JwtContext?) -> Unit ) = effect { jwtToken()?.let { token -> - val userId = jwtService.verifyJwtToken(JwtToken(token)) + val userId = verifyJwtToken(JwtToken(token)) JwtContext(JwtToken(token), userId) } }.fold( { error -> respond(error) }, - { context -> body(this, context) } + { context -> body(context) } ) -fun PipelineContext.jwtToken(): String? = +context(PipelineContext) +fun jwtToken(): String? = (call.request.parseAuthorizationHeader() as? HttpAuthHeader.Single) ?.blob diff --git a/src/main/kotlin/io/github/nomisrev/config/Dependencies.kt b/src/main/kotlin/io/github/nomisrev/config/Dependencies.kt index 7f502a0f..c7a89791 100644 --- a/src/main/kotlin/io/github/nomisrev/config/Dependencies.kt +++ b/src/main/kotlin/io/github/nomisrev/config/Dependencies.kt @@ -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 = 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) } diff --git a/src/main/kotlin/io/github/nomisrev/main.kt b/src/main/kotlin/io/github/nomisrev/main.kt index 523941c7..12c62769 100644 --- a/src/main/kotlin/io/github/nomisrev/main.kt +++ b/src/main/kotlin/io/github/nomisrev/main.kt @@ -27,6 +27,6 @@ fun main(): Unit = fun Application.app(module: Dependencies) { configure() - healthRoute(module.pool) - userRoutes(module.userService, module.jwtService) + healthRoute(module.hikariDataSource) + userRoutes(module.userPersistence, module.config.auth) } diff --git a/src/main/kotlin/io/github/nomisrev/predef.kt b/src/main/kotlin/io/github/nomisrev/predef.kt new file mode 100644 index 00000000..32ba3266 --- /dev/null +++ b/src/main/kotlin/io/github/nomisrev/predef.kt @@ -0,0 +1,26 @@ +package io.github.nomisrev + +import arrow.core.continuations.EffectScope +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +inline fun with(a: A, b: B, block: context(A, B) (TypeWrapper) -> R): R { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return block(a, b, TypeWrapper.IMPL) +} + +sealed interface TypeWrapper { + object IMPL: TypeWrapper +} + +// TODO - temp fix for ambiguity bug in compiler +context(EffectScope) +@OptIn(ExperimentalContracts::class) +public suspend fun ensureNotNull(value: B?, shift: () -> R): B { + contract { returns() implies (value != null) } + return value ?: shift(shift()) +} diff --git a/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt b/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt index 29a6dd4f..21ada563 100644 --- a/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt +++ b/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt @@ -31,7 +31,7 @@ interface ArticlePersistence { suspend fun exists(slug: Slug): Boolean } -fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) = +fun articlePersistence(articles: ArticlesQueries, tagsQueries: TagsQueries) = object : ArticlePersistence { context(EffectScope) override suspend fun create( diff --git a/src/main/kotlin/io/github/nomisrev/routes/error.kt b/src/main/kotlin/io/github/nomisrev/routes/error.kt index 6a79b3ff..99cfed05 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/error.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/error.kt @@ -72,5 +72,5 @@ private suspend inline fun PipelineContext.unprocessable( error: String ): Unit = call.respond(HttpStatusCode.UnprocessableEntity, GenericErrorModel(error)) -private suspend inline fun PipelineContext.internal(error: String): Unit = +suspend inline fun PipelineContext.internal(error: String): Unit = call.respond(HttpStatusCode.InternalServerError, GenericErrorModel(error)) diff --git a/src/main/kotlin/io/github/nomisrev/routes/health.kt b/src/main/kotlin/io/github/nomisrev/routes/health.kt index 4fa1ee25..f2a102ff 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/health.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/health.kt @@ -2,11 +2,11 @@ package io.github.nomisrev.routes -import arrow.core.Either -import arrow.core.computations.ensureNotNull +import arrow.core.continuations.EffectScope import arrow.core.continuations.either -import arrow.core.continuations.ensureNotNull -import io.github.nomisrev.service.DatabasePool +import com.zaxxer.hikari.HikariDataSource +import io.github.nomisrev.ensureNotNull +import io.github.nomisrev.utils.queryOneOrNull import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.application.call @@ -14,21 +14,29 @@ import io.ktor.server.response.respond import io.ktor.server.routing.Routing import io.ktor.server.routing.get import io.ktor.server.routing.routing +import javax.sql.DataSource import kotlinx.serialization.Serializable @Serializable data class HealthCheck(val postgresVersion: String) -fun Application.healthRoute(pool: DatabasePool): Routing = routing { +fun Application.healthRoute(pool: HikariDataSource): Routing = routing { get("/health") { - when (val res = pool.healthCheck()) { - is Either.Right -> call.respond(HttpStatusCode.OK, res.value) - is Either.Left -> call.respond(HttpStatusCode.InternalServerError, res.value) - } + either { + with(pool) { healthCheck() } + }.fold( + { internal(it) }, + { call.respond(HttpStatusCode.OK, it) } + ) } } -private suspend fun DatabasePool.healthCheck(): Either = either { - ensure(isRunning()) { "DatabasePool is not running" } - val version = ensureNotNull(version()) { "Could not reach database. ConnectionPool is running." } - HealthCheck(version) +context(HikariDataSource, EffectScope) +private suspend fun healthCheck(): HealthCheck { + ensure(isRunning) { "DatabasePool is not running" } + val version = ensureNotNull(showPostgresVersion()) { "Could not reach database. ConnectionPool is running." } + return HealthCheck(version) } + +context(DataSource) +private suspend fun showPostgresVersion(): String? = + queryOneOrNull("SHOW server_version;") { string() } diff --git a/src/main/kotlin/io/github/nomisrev/routes/users.kt b/src/main/kotlin/io/github/nomisrev/routes/users.kt index 35897e40..82da0b12 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/users.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/users.kt @@ -3,14 +3,18 @@ package io.github.nomisrev.routes import arrow.core.Either import arrow.core.continuations.EffectScope import arrow.core.continuations.either +import io.github.nomisrev.with import io.github.nomisrev.ApiError import io.github.nomisrev.ApiError.Unexpected import io.github.nomisrev.auth.jwtAuth -import io.github.nomisrev.service.JwtService +import io.github.nomisrev.config.Config +import io.github.nomisrev.repo.UserPersistence import io.github.nomisrev.service.Login import io.github.nomisrev.service.RegisterUser import io.github.nomisrev.service.Update -import io.github.nomisrev.service.UserService +import io.github.nomisrev.service.login +import io.github.nomisrev.service.register +import io.github.nomisrev.service.update import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.application.ApplicationCall @@ -52,47 +56,50 @@ data class User( data class LoginUser(val email: String, val password: String) fun Application.userRoutes( - userService: UserService, - jwtService: JwtService, + userPersistence: UserPersistence, + auth: Config.Auth ) = routing { - route("/users") { - /* Registration: POST /api/users */ - post { - either> { - val (username, email, password) = receiveCatching>().user - val token = userService.register(RegisterUser(username, email, password)).value - UserWrapper(User(email, token, username, "", "")) - }.respond(HttpStatusCode.Created) - } - post("/login") { - either> { - val (email, password) = receiveCatching>().user - val (token, info) = userService.login(Login(email, password)) - UserWrapper(User(email, token.value, info.username, info.bio, info.image)) - }.respond(HttpStatusCode.OK) - } + with(userPersistence, auth) { + route("/users") { + /* Registration: POST /api/users */ + post { + either> { + val (username, email, password) = receiveCatching>().user + val token = register(RegisterUser(username, email, password)).value + UserWrapper(User(email, token, username, "", "")) + }.respond(HttpStatusCode.Created) + } + + /* Login: POST /api/users/login */ + post("/login") { + either> { + val (email, password) = receiveCatching>().user + val (token, info) = login(Login(email, password)) + UserWrapper(User(email, token.value, info.username, info.bio, info.image)) + }.respond(HttpStatusCode.OK) + } } - /* Get Current User: GET /api/user */ - get("/user") { - jwtAuth(jwtService) { (token, userId) -> - either> { - val info = userService.getUser(userId) - UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) - }.respond(HttpStatusCode.OK) + /* Get Current User: GET /api/user */ + get("/user") { + jwtAuth { (token, userId) -> + either> { + val info = select(userId) + UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) + }.respond(HttpStatusCode.OK) + } } - } - /* Update current user: PUT /api/user */ - put("/user") { - jwtAuth(jwtService) { (token, userId) -> - either> { - val (email, username, password, bio, image) = - receiveCatching>().user - val info = - userService.update(Update(userId, username, email, password, bio, image)) - UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) - }.respond(HttpStatusCode.OK) + /* Update current user: PUT /api/user */ + put("/user") { + jwtAuth { (token, userId) -> + either> { + val (email, username, password, bio, image) = + receiveCatching>().user + val info = update(Update(userId, username, email, password, bio, image)) + UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) + }.respond(HttpStatusCode.OK) + } } } } diff --git a/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt b/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt index e3fd1e38..7b33099d 100644 --- a/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt +++ b/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package io.github.nomisrev.service import arrow.core.continuations.EffectScope @@ -10,46 +12,30 @@ import io.github.nomisrev.routes.Profile import java.time.OffsetDateTime data class CreateArticle( - val userId: UserId, - val title: String, - val description: String, - val body: String, - val tags: Set + val userId: UserId, + val title: String, + val description: String, + val body: String, + val tags: Set ) -interface ArticleService { - /** Creates a new article and returns the resulting Article */ - context(EffectScope) - suspend fun createArticle(input: CreateArticle): Article -} - -fun articleService( - slugGenerator: SlugGenerator, - articlePersistence: ArticlePersistence, - userPersistence: UserPersistence, -): ArticleService = - object : ArticleService { - context(EffectScope) - override suspend fun createArticle(input: CreateArticle): Article { - val slug = - slugGenerator - .generateSlug(input.title) { slug -> articlePersistence.exists(slug) } - - val createdAt = OffsetDateTime.now() - val articleId = - articlePersistence - .create( - input.userId, - slug, - input.title, - input.description, - input.body, - createdAt, - createdAt, - input.tags - ).serial - val user = userPersistence.select(input.userId) - return Article( +/** Creates a new article and returns the resulting Article */ +context(EffectScope, SlugGenerator, ArticlePersistence, UserPersistence) +suspend fun createArticle(input: CreateArticle): Article { + val slug = generateSlug(input.title) { slug -> exists(slug) } + val createdAt = OffsetDateTime.now() + val articleId = create( + input.userId, + slug, + input.title, + input.description, + input.body, + createdAt, + createdAt, + input.tags + ).serial + val user = select(input.userId) + return Article( articleId, slug.value, input.title, @@ -61,6 +47,5 @@ fun articleService( createdAt, createdAt, input.tags.toList() - ) - } - } + ) +} diff --git a/src/main/kotlin/io/github/nomisrev/service/DatabasePool.kt b/src/main/kotlin/io/github/nomisrev/service/DatabasePool.kt deleted file mode 100644 index a03d8349..00000000 --- a/src/main/kotlin/io/github/nomisrev/service/DatabasePool.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.github.nomisrev.service - -import com.zaxxer.hikari.HikariDataSource -import io.github.nomisrev.utils.queryOneOrNull - -interface DatabasePool { - fun isRunning(): Boolean - suspend fun version(): String? -} - -fun databasePool(hikari: HikariDataSource) = - object : DatabasePool { - override fun isRunning(): Boolean = hikari.isRunning - override suspend fun version(): String? = - hikari.queryOneOrNull("SHOW server_version;") { string() } - } diff --git a/src/main/kotlin/io/github/nomisrev/service/JwtService.kt b/src/main/kotlin/io/github/nomisrev/service/JwtService.kt index 8fae87a8..c8d532b9 100644 --- a/src/main/kotlin/io/github/nomisrev/service/JwtService.kt +++ b/src/main/kotlin/io/github/nomisrev/service/JwtService.kt @@ -1,10 +1,7 @@ package io.github.nomisrev.service import arrow.core.Either -import arrow.core.computations.ensureNotNull import arrow.core.continuations.EffectScope -import arrow.core.continuations.either -import arrow.core.continuations.ensureNotNull import io.github.nefilim.kjwt.JWSAlgorithm import io.github.nefilim.kjwt.JWSHMAC512Algorithm import io.github.nefilim.kjwt.JWT @@ -16,53 +13,43 @@ import io.github.nomisrev.ApiError.JwtGeneration import io.github.nomisrev.ApiError.JwtInvalid import io.github.nomisrev.auth.JwtToken import io.github.nomisrev.config.Config +import io.github.nomisrev.ensureNotNull import io.github.nomisrev.repo.UserId import io.github.nomisrev.repo.UserPersistence import java.time.LocalDateTime import java.time.ZoneId import kotlin.time.toJavaDuration -interface JwtService { - /** Generate a new JWT token for userId and password. Doesn't invalidate old password */ - context(EffectScope) - suspend fun generateJwtToken(userId: UserId): JwtToken - - /** Verify a JWT token. Checks if userId exists in database, and token is not expired. */ - context(EffectScope) - suspend fun verifyJwtToken(token: JwtToken): UserId -} - -fun jwtService(config: Config.Auth, repo: UserPersistence) = - object : JwtService { - context(EffectScope) - override suspend fun generateJwtToken(userId: UserId): JwtToken = - JWT - .hs512 { - val now = LocalDateTime.now(ZoneId.of("UTC")) - issuedAt(now) - expiresAt(now + config.duration.toJavaDuration()) - issuer(config.issuer) - claim("id", userId.serial) - } - .sign(config.secret) - .bind() - .let { JwtToken(it.rendered) } +/** Generate a new JWT token for userId and password. Doesn't invalidate old password */ +context(EffectScope, Config.Auth) +suspend fun generateJwtToken(userId: UserId): JwtToken = + JWT + .hs512 { + val now = LocalDateTime.now(ZoneId.of("UTC")) + issuedAt(now) + expiresAt(now + duration.toJavaDuration()) + issuer(issuer) + claim("id", userId.serial) + } + .sign(secret) + .bind() + .let { JwtToken(it.rendered) } - context(EffectScope) - override suspend fun verifyJwtToken(token: JwtToken): UserId { - val jwt = - JWT.decodeT(token.value, JWSHMAC512Algorithm).mapLeft { JwtInvalid(it.toString()) }.bind() - val userId = - ensureNotNull(jwt.claimValueAsLong("id").orNull()) { - JwtInvalid("id missing from JWT Token") - } - val expiresAt = - ensureNotNull(jwt.expiresAt().orNull()) { JwtInvalid("exp missing from JWT Token") } - ensure(expiresAt.isAfter(LocalDateTime.now())) { JwtInvalid("JWT Token expired") } - repo.select(UserId(userId)) - return UserId(userId) +/** Verify a JWT token. Checks if userId exists in database, and token is not expired. */ +context(EffectScope, UserPersistence) +suspend fun verifyJwtToken(token: JwtToken): UserId { + val jwt = + JWT.decodeT(token.value, JWSHMAC512Algorithm).mapLeft { JwtInvalid(it.toString()) }.bind() + val userId = + ensureNotNull(jwt.claimValueAsLong("id").orNull()) { + JwtInvalid("id missing from JWT Token") } - } + val expiresAt = + ensureNotNull(jwt.expiresAt().orNull()) { JwtInvalid("exp missing from JWT Token") } + ensure(expiresAt.isAfter(LocalDateTime.now())) { JwtInvalid("JWT Token expired") } + select(UserId(userId)) + return UserId(userId) +} context(EffectScope) private suspend fun Either>.bind(): SignedJWT = diff --git a/src/main/kotlin/io/github/nomisrev/service/UserService.kt b/src/main/kotlin/io/github/nomisrev/service/UserService.kt index 3bf27905..862916bf 100644 --- a/src/main/kotlin/io/github/nomisrev/service/UserService.kt +++ b/src/main/kotlin/io/github/nomisrev/service/UserService.kt @@ -4,6 +4,7 @@ import arrow.core.continuations.EffectScope import io.github.nomisrev.ApiError import io.github.nomisrev.ApiError.EmptyUpdate import io.github.nomisrev.auth.JwtToken +import io.github.nomisrev.config.Config import io.github.nomisrev.repo.UserId import io.github.nomisrev.repo.UserPersistence import io.github.nomisrev.validate @@ -23,59 +24,29 @@ data class Login(val email: String, val password: String) data class UserInfo(val email: String, val username: String, val bio: String, val image: String) -interface UserService { - /** Registers the user and returns its unique identifier */ - context(EffectScope) - suspend fun register(input: RegisterUser): JwtToken - - /** Updates a user with all the provided fields, returns resulting info */ - context(EffectScope) - suspend fun update(input: Update): UserInfo - - /** Logs in a user based on email and password. */ - context(EffectScope) - suspend fun login(input: Login): Pair - - /** Retrieve used based on userId */ - context(EffectScope) - suspend fun getUser(userId: UserId): UserInfo - - /** Retrieve used based on username */ - context(EffectScope) - suspend fun getUser(username: String): UserInfo +/** Registers the user and returns its unique identifier */ +context(EffectScope, UserPersistence, Config.Auth) +suspend fun register(input: RegisterUser): JwtToken { + val (username, email, password) = input.validate().bind() + val userId = insert(username, email, password) + return generateJwtToken(userId) } -fun userService(repo: UserPersistence, jwtService: JwtService) = - object : UserService { - context(EffectScope) - override suspend fun register(input: RegisterUser): JwtToken { - val (username, email, password) = input.validate().bind() - val userId = repo.insert(username, email, password) - return jwtService.generateJwtToken(userId) - } - - context(EffectScope) - override suspend fun login(input: Login): Pair { - val (email, password) = input.validate().bind() - val (userId, info) = repo.verifyPassword(email, password) - val token = jwtService.generateJwtToken(userId) - return Pair(token, info) - } - - context(EffectScope) - override suspend fun update(input: Update): UserInfo { - val (userId, username, email, password, bio, image) = input.validate().bind() - ensure(email != null || username != null || bio != null || image != null) { - EmptyUpdate("Cannot update user with $userId with only null values") - } - return repo.update(userId, email, username, password, bio, image) - } - - context(EffectScope) - override suspend fun getUser(userId: UserId): UserInfo = - repo.select(userId) +/** Logs in a user based on email and password. */ +context(EffectScope, UserPersistence, Config.Auth) +suspend fun login(input: Login): Pair { + val (email, password) = input.validate().bind() + val (userId, info) = verifyPassword(email, password) + val token = generateJwtToken(userId) + return Pair(token, info) +} - context(EffectScope) - override suspend fun getUser(username: String): UserInfo = - repo.select(username) +/** Updates a user with all the provided fields, returns resulting info */ +context(EffectScope, UserPersistence) +suspend fun update(input: Update): UserInfo { + val (userId, username, email, password, bio, image) = input.validate().bind() + ensure(email != null || username != null || bio != null || image != null) { + EmptyUpdate("Cannot update user with $userId with only null values") } + return update(userId, email, username, password, bio, image) +} diff --git a/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt b/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt index b89ea46f..37cd5f21 100644 --- a/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt @@ -1,6 +1,7 @@ package io.github.nomisrev.routes import arrow.core.continuations.either +import io.github.nomisrev.with import io.github.nomisrev.ApiError import io.github.nomisrev.PostgreSQLContainer import io.github.nomisrev.auth.JwtToken @@ -9,6 +10,7 @@ import io.github.nomisrev.config.dependencies import io.github.nomisrev.config.hikari import io.github.nomisrev.resource import io.github.nomisrev.service.RegisterUser +import io.github.nomisrev.service.register import io.github.nomisrev.utils.query import io.github.nomisrev.withService import io.kotest.assertions.arrow.core.shouldBeRight @@ -30,7 +32,6 @@ class UserRouteSpec : val config = Config().copy(dataSource = PostgreSQLContainer.config()) val dataSource by resource(hikari(config.dataSource)) val dependencies by resource(dependencies(config)) - val userService by lazy { dependencies.userService } val username = "username" val email = "valid@domain.com" @@ -39,7 +40,9 @@ class UserRouteSpec : afterTest { dataSource.query("TRUNCATE users CASCADE") } suspend fun registerUser(): JwtToken = either { - userService.register(RegisterUser(username, email, password)) + with(dependencies.userPersistence, dependencies.config.auth) { + register(RegisterUser(username, email, password)) + } }.shouldBeRight() "Can register user" { diff --git a/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt b/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt index 71f15acf..659886c0 100644 --- a/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt @@ -4,6 +4,7 @@ import arrow.core.continuations.either import arrow.core.nonEmptyListOf import io.github.nefilim.kjwt.JWSHMAC512Algorithm import io.github.nefilim.kjwt.JWT +import io.github.nomisrev.with import io.github.nomisrev.ApiError import io.github.nomisrev.ApiError.EmptyUpdate import io.github.nomisrev.ApiError.IncorrectInput @@ -28,7 +29,7 @@ class UserServiceSpec : FreeSpec({ val config = Config().copy(dataSource = PostgreSQLContainer.config()) val dataSource by resource(hikari(config.dataSource)) - val userService by resource(dependencies(config).map { it.userService }) + val dep by resource(dependencies(config)) val validUsername = "username" val validEmail = "valid@domain.com" @@ -36,13 +37,15 @@ class UserServiceSpec : afterTest { dataSource.query("TRUNCATE users CASCADE") } + with(dep.userPersistence, config.auth) { + "register" - { "username cannot be empty" { val errors = nonEmptyListOf("Cannot be blank", "is too short (minimum is 1 characters)") val expected = IncorrectInput(InvalidUsername(errors)) either { - userService.register(RegisterUser("", validEmail, validPw)) + register(RegisterUser("", validEmail, validPw)) } shouldBeLeft expected } @@ -51,7 +54,7 @@ class UserServiceSpec : val errors = nonEmptyListOf("is too long (maximum is 25 characters)") val expected = IncorrectInput(InvalidUsername(errors)) either { - userService.register(RegisterUser(name, validEmail, validPw)) + register(RegisterUser(name, validEmail, validPw)) } shouldBeLeft expected } @@ -59,7 +62,7 @@ class UserServiceSpec : val errors = nonEmptyListOf("Cannot be blank", "'' is invalid email") val expected = IncorrectInput(InvalidEmail(errors)) either { - userService.register(RegisterUser(validUsername, "", validPw)) + register(RegisterUser(validUsername, "", validPw)) } shouldBeLeft expected } @@ -68,7 +71,7 @@ class UserServiceSpec : val errors = nonEmptyListOf("is too long (maximum is 350 characters)") val expected = IncorrectInput(InvalidEmail(errors)) either { - userService.register(RegisterUser(validUsername, email, validPw)) + register(RegisterUser(validUsername, email, validPw)) } shouldBeLeft expected } @@ -77,7 +80,7 @@ class UserServiceSpec : val errors = nonEmptyListOf("'$email' is invalid email") val expected = IncorrectInput(InvalidEmail(errors)) either { - userService.register(RegisterUser(validUsername, email, validPw)) + register(RegisterUser(validUsername, email, validPw)) } shouldBeLeft expected } @@ -85,7 +88,7 @@ class UserServiceSpec : val errors = nonEmptyListOf("Cannot be blank", "is too short (minimum is 8 characters)") val expected = IncorrectInput(InvalidPassword(errors)) either { - userService.register(RegisterUser(validUsername, validEmail, "")) + register(RegisterUser(validUsername, validEmail, "")) } shouldBeLeft expected } @@ -94,20 +97,20 @@ class UserServiceSpec : val errors = nonEmptyListOf("is too long (maximum is 100 characters)") val expected = IncorrectInput(InvalidPassword(errors)) either { - userService.register(RegisterUser(validUsername, validEmail, password)) + register(RegisterUser(validUsername, validEmail, password)) } shouldBeLeft expected } "All valid returns a token" { either { - userService.register(RegisterUser(validUsername, validEmail, validPw)) + register(RegisterUser(validUsername, validEmail, validPw)) }.shouldBeRight() } "Register twice results in UsernameAlreadyExists" { either { - userService.register(RegisterUser(validUsername, validEmail, validPw)) - userService.register(RegisterUser(validUsername, validEmail, validPw)) + register(RegisterUser(validUsername, validEmail, validPw)) + register(RegisterUser(validUsername, validEmail, validPw)) } shouldBeLeft UsernameAlreadyExists(validUsername) } } @@ -117,14 +120,15 @@ class UserServiceSpec : "Update with all null" { val token = either { - userService.register(RegisterUser(validUsername, validEmail, validPw)) + register(RegisterUser(validUsername, validEmail, validPw)) }.shouldBeRight() either { - userService.update(Update(token.id(), null, null, null, null, null)) + update(Update(token.id(), null, null, null, null, null)) } shouldBeLeft EmptyUpdate("Cannot update user with ${token.id()} with only null values") } } + } }) private fun JwtToken.id(): UserId = From 9c6369d0f3ad44e420a83007c489556f0673ba73 Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Fri, 20 May 2022 15:38:28 +0200 Subject: [PATCH 4/5] Further clean-up --- .../kotlin/io/github/nomisrev/ApiError.kt | 4 +- .../io/github/nomisrev/auth/kjwt-auth.kt | 10 +-- src/main/kotlin/io/github/nomisrev/main.kt | 6 +- src/main/kotlin/io/github/nomisrev/predef.kt | 4 +- .../github/nomisrev/repo/UserPersistence.kt | 4 +- .../kotlin/io/github/nomisrev/routes/error.kt | 10 +++ .../io/github/nomisrev/routes/health.kt | 9 +-- .../kotlin/io/github/nomisrev/routes/users.kt | 69 +++++++++---------- 8 files changed, 62 insertions(+), 54 deletions(-) diff --git a/src/main/kotlin/io/github/nomisrev/ApiError.kt b/src/main/kotlin/io/github/nomisrev/ApiError.kt index 61880998..97199492 100644 --- a/src/main/kotlin/io/github/nomisrev/ApiError.kt +++ b/src/main/kotlin/io/github/nomisrev/ApiError.kt @@ -14,9 +14,7 @@ import io.github.nomisrev.routes.Profile // data class Unexpected(val error: Throwable): UserError, ArticleError sealed interface ApiError { object PasswordNotMatched : ApiError - data class IncorrectInput(val errors: NonEmptyList) : ApiError { - constructor(field: InvalidField) : this(nonEmptyListOf(field)) - } + data class IncorrectInput(val errors: NonEmptyList) : ApiError data class EmptyUpdate(val description: String) : ApiError data class UserNotFound(val property: String) : ApiError data class UserFollowingHimself(val profile: Profile) : ApiError diff --git a/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt b/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt index 1f54c781..8c1a5715 100644 --- a/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt +++ b/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt @@ -24,7 +24,7 @@ data class JwtContext(val token: JwtToken, val userId: UserId) // Small middleware to validate JWT token without using Ktor Auth / Nullable principle context(UserPersistence, Config.Auth, PipelineContext) -suspend inline fun jwtAuth( +suspend inline fun jwtAuth( // BUG: inline + same context as lambda as function crossinline body: suspend /*context(PipelineContext)*/ (JwtContext) -> Unit ) { optionalJwtAuth { context -> @@ -32,12 +32,12 @@ suspend inline fun jwtAuth( } } -// TODO - read Pipeline context to lambda context +// TODO Report YT: BUG: inline + same context as lambda as function context(PipelineContext, UserPersistence, Config.Auth) -suspend inline fun optionalJwtAuth( +suspend inline fun optionalJwtAuth( // BUG: inline + same context as lambda as function crossinline body: suspend /*context(PipelineContext)*/ (JwtContext?) -> Unit ) = effect { - jwtToken()?.let { token -> + jwtTokenStringOrNul()?.let { token -> val userId = verifyJwtToken(JwtToken(token)) JwtContext(JwtToken(token), userId) } @@ -47,6 +47,6 @@ suspend inline fun optionalJwtAuth( ) context(PipelineContext) -fun jwtToken(): String? = +fun jwtTokenStringOrNul(): String? = (call.request.parseAuthorizationHeader() as? HttpAuthHeader.Single) ?.blob diff --git a/src/main/kotlin/io/github/nomisrev/main.kt b/src/main/kotlin/io/github/nomisrev/main.kt index 12c62769..32566299 100644 --- a/src/main/kotlin/io/github/nomisrev/main.kt +++ b/src/main/kotlin/io/github/nomisrev/main.kt @@ -27,6 +27,8 @@ fun main(): Unit = fun Application.app(module: Dependencies) { configure() - healthRoute(module.hikariDataSource) - userRoutes(module.userPersistence, module.config.auth) + with(module.userPersistence, module.config.auth, module.hikariDataSource) { + healthRoute() + userRoutes() + } } diff --git a/src/main/kotlin/io/github/nomisrev/predef.kt b/src/main/kotlin/io/github/nomisrev/predef.kt index 32ba3266..7922cf82 100644 --- a/src/main/kotlin/io/github/nomisrev/predef.kt +++ b/src/main/kotlin/io/github/nomisrev/predef.kt @@ -6,11 +6,11 @@ import kotlin.contracts.InvocationKind import kotlin.contracts.contract @OptIn(ExperimentalContracts::class) -inline fun with(a: A, b: B, block: context(A, B) (TypeWrapper) -> R): R { +inline fun with(a: A, b: B, c: C, block: context(A, B, C) (TypeWrapper) -> R): R { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } - return block(a, b, TypeWrapper.IMPL) + return block(a, b, c, TypeWrapper.IMPL) } sealed interface TypeWrapper { diff --git a/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt b/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt index c37ff8e5..2f28ed25 100644 --- a/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt +++ b/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt @@ -8,6 +8,7 @@ import arrow.core.continuations.ensureNotNull import io.github.nomisrev.ApiError import io.github.nomisrev.ApiError.Unexpected import io.github.nomisrev.ApiError.UserNotFound +import io.github.nomisrev.ApiError.UsernameAlreadyExists import io.github.nomisrev.service.UserInfo import io.github.nomisrev.sqldelight.UsersQueries import java.util.UUID @@ -69,7 +70,7 @@ fun userPersistence( val key = generateKey(password, salt) return Either.catch { usersQueries.create(salt, key, username, email) }.mapLeft { error -> if (error is PSQLException && error.sqlState == PSQLState.UNIQUE_VIOLATION.state) { - ApiError.UsernameAlreadyExists(username) + UsernameAlreadyExists(username) } else { Unexpected("Failed to persist user: $username:$email", error) } @@ -91,6 +92,7 @@ fun userPersistence( return Pair(userId, UserInfo(email, username, bio, image)) } + context(EffectScope) override suspend fun select(userId: UserId): UserInfo { val userInfo = diff --git a/src/main/kotlin/io/github/nomisrev/routes/error.kt b/src/main/kotlin/io/github/nomisrev/routes/error.kt index 99cfed05..558273ab 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/error.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/error.kt @@ -1,6 +1,8 @@ package io.github.nomisrev.routes import arrow.core.Either +import arrow.core.continuations.EffectScope +import arrow.core.continuations.effect import io.github.nomisrev.ApiError import io.github.nomisrev.ApiError.ArticleNotFound import io.github.nomisrev.ApiError.CannotGenerateSlug @@ -33,6 +35,14 @@ data class GenericErrorModelErrors(val body: List) fun GenericErrorModel(vararg msg: String): GenericErrorModel = GenericErrorModel(GenericErrorModelErrors(msg.toList())) +context(PipelineContext) +suspend inline fun conduit( + status: HttpStatusCode, + crossinline block: suspend context(EffectScope) () -> A +): Unit = effect { + block(this) +}.fold({ respond(it) }, { call.respond(status, it) }) + context(PipelineContext) suspend inline fun Either.respond(status: HttpStatusCode): Unit = when (this) { diff --git a/src/main/kotlin/io/github/nomisrev/routes/health.kt b/src/main/kotlin/io/github/nomisrev/routes/health.kt index f2a102ff..89927279 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/health.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/health.kt @@ -3,7 +3,7 @@ package io.github.nomisrev.routes import arrow.core.continuations.EffectScope -import arrow.core.continuations.either +import arrow.core.continuations.effect import com.zaxxer.hikari.HikariDataSource import io.github.nomisrev.ensureNotNull import io.github.nomisrev.utils.queryOneOrNull @@ -19,10 +19,11 @@ import kotlinx.serialization.Serializable @Serializable data class HealthCheck(val postgresVersion: String) -fun Application.healthRoute(pool: HikariDataSource): Routing = routing { +context(Application, HikariDataSource) +fun healthRoute(): Routing = routing { get("/health") { - either { - with(pool) { healthCheck() } + effect { + healthCheck() }.fold( { internal(it) }, { call.respond(HttpStatusCode.OK, it) } diff --git a/src/main/kotlin/io/github/nomisrev/routes/users.kt b/src/main/kotlin/io/github/nomisrev/routes/users.kt index 82da0b12..73136839 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/users.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/users.kt @@ -55,56 +55,51 @@ data class User( @Serializable data class LoginUser(val email: String, val password: String) -fun Application.userRoutes( - userPersistence: UserPersistence, - auth: Config.Auth -) = routing { - with(userPersistence, auth) { - route("/users") { - /* Registration: POST /api/users */ - post { - either> { - val (username, email, password) = receiveCatching>().user - val token = register(RegisterUser(username, email, password)).value - UserWrapper(User(email, token, username, "", "")) - }.respond(HttpStatusCode.Created) +context(Application, UserPersistence, Config.Auth) +fun userRoutes() = routing { + route("/users") { + /* Registration: POST /api/users */ + post { + conduit(HttpStatusCode.Created) { + val (username, email, password) = receiveCatching>().user + val token = register(RegisterUser(username, email, password)).value + UserWrapper(User(email, token, username, "", "")) } + } - /* Login: POST /api/users/login */ - post("/login") { - either> { - val (email, password) = receiveCatching>().user - val (token, info) = login(Login(email, password)) - UserWrapper(User(email, token.value, info.username, info.bio, info.image)) - }.respond(HttpStatusCode.OK) + /* Login: POST /api/users/login */ + post("/login") { + conduit(HttpStatusCode.OK) { + val (email, password) = receiveCatching>().user + val (token, info) = login(Login(email, password)) + UserWrapper(User(email, token.value, info.username, info.bio, info.image)) } + } } - /* Get Current User: GET /api/user */ - get("/user") { - jwtAuth { (token, userId) -> - either> { - val info = select(userId) - UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) - }.respond(HttpStatusCode.OK) + /* Get Current User: GET /api/user */ + get("/user") { + jwtAuth { (token, userId) -> + conduit(HttpStatusCode.OK) { + val info = select(userId) + UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) } } + } - /* Update current user: PUT /api/user */ - put("/user") { - jwtAuth { (token, userId) -> - either> { - val (email, username, password, bio, image) = - receiveCatching>().user - val info = update(Update(userId, username, email, password, bio, image)) - UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) - }.respond(HttpStatusCode.OK) + /* Update current user: PUT /api/user */ + put("/user") { + jwtAuth { (token, userId) -> + conduit(HttpStatusCode.OK) { + val (email, username, password, bio, image) = + receiveCatching>().user + val info = update(Update(userId, username, email, password, bio, image)) + UserWrapper(User(info.email, token.value, info.username, info.bio, info.image)) } } } } -// TODO improve how we receive models with validation context(EffectScope) private suspend inline fun PipelineContext< Unit, ApplicationCall>.receiveCatching(): A = From e5dc95af7862872f90a9d14ca5edafb4d6f8b1ab Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Sat, 21 May 2022 10:47:09 +0000 Subject: [PATCH 5/5] Small nits --- .../kotlin/io/github/nomisrev/ApiError.kt | 4 ++- .../auth/{kjwt-auth.kt => JwtToken.kt} | 15 ++++------ .../io/github/nomisrev/config/Config.kt | 1 + .../kotlin/io/github/nomisrev/config/ktor.kt | 12 ++------ src/main/kotlin/io/github/nomisrev/predef.kt | 28 +++++++++++++------ .../kotlin/io/github/nomisrev/routes/error.kt | 17 ++++------- .../io/github/nomisrev/routes/health.kt | 11 ++++---- .../kotlin/io/github/nomisrev/routes/users.kt | 2 -- 8 files changed, 42 insertions(+), 48 deletions(-) rename src/main/kotlin/io/github/nomisrev/auth/{kjwt-auth.kt => JwtToken.kt} (72%) diff --git a/src/main/kotlin/io/github/nomisrev/ApiError.kt b/src/main/kotlin/io/github/nomisrev/ApiError.kt index 97199492..9612f126 100644 --- a/src/main/kotlin/io/github/nomisrev/ApiError.kt +++ b/src/main/kotlin/io/github/nomisrev/ApiError.kt @@ -14,7 +14,9 @@ import io.github.nomisrev.routes.Profile // data class Unexpected(val error: Throwable): UserError, ArticleError sealed interface ApiError { object PasswordNotMatched : ApiError - data class IncorrectInput(val errors: NonEmptyList) : ApiError + data class IncorrectInput(val errors: NonEmptyList) : ApiError { + constructor(head: InvalidField): this(nonEmptyListOf(head)) + } data class EmptyUpdate(val description: String) : ApiError data class UserNotFound(val property: String) : ApiError data class UserFollowingHimself(val profile: Profile) : ApiError diff --git a/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt b/src/main/kotlin/io/github/nomisrev/auth/JwtToken.kt similarity index 72% rename from src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt rename to src/main/kotlin/io/github/nomisrev/auth/JwtToken.kt index 8c1a5715..73b84ed4 100644 --- a/src/main/kotlin/io/github/nomisrev/auth/kjwt-auth.kt +++ b/src/main/kotlin/io/github/nomisrev/auth/JwtToken.kt @@ -1,9 +1,8 @@ -@file:Suppress("MatchingDeclarationName") - 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 @@ -11,11 +10,9 @@ 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.ApplicationCall import io.ktor.server.application.call import io.ktor.server.auth.parseAuthorizationHeader import io.ktor.server.response.respond -import io.ktor.util.pipeline.PipelineContext @JvmInline value class JwtToken(val value: String) @@ -23,9 +20,9 @@ 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(UserPersistence, Config.Auth, PipelineContext) +context(KtorCtx, UserPersistence, Config.Auth) suspend inline fun jwtAuth( // BUG: inline + same context as lambda as function - crossinline body: suspend /*context(PipelineContext)*/ (JwtContext) -> Unit + crossinline body: suspend /*context(KtorCtx)*/ (JwtContext) -> Unit ) { optionalJwtAuth { context -> context?.let { body(it) } ?: call.respond(HttpStatusCode.Unauthorized) @@ -33,9 +30,9 @@ suspend inline fun jwtAuth( // BUG: inline + same context as lambda as function } // TODO Report YT: BUG: inline + same context as lambda as function -context(PipelineContext, UserPersistence, Config.Auth) +context(KtorCtx, UserPersistence, Config.Auth) suspend inline fun optionalJwtAuth( // BUG: inline + same context as lambda as function - crossinline body: suspend /*context(PipelineContext)*/ (JwtContext?) -> Unit + crossinline body: suspend /*context(KtorCtx)*/ (JwtContext?) -> Unit ) = effect { jwtTokenStringOrNul()?.let { token -> val userId = verifyJwtToken(JwtToken(token)) @@ -46,7 +43,7 @@ suspend inline fun optionalJwtAuth( // BUG: inline + same context as lambda as f { context -> body(context) } ) -context(PipelineContext) +context(KtorCtx) fun jwtTokenStringOrNul(): String? = (call.request.parseAuthorizationHeader() as? HttpAuthHeader.Single) ?.blob diff --git a/src/main/kotlin/io/github/nomisrev/config/Config.kt b/src/main/kotlin/io/github/nomisrev/config/Config.kt index 645cbabe..b679d5d6 100644 --- a/src/main/kotlin/io/github/nomisrev/config/Config.kt +++ b/src/main/kotlin/io/github/nomisrev/config/Config.kt @@ -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(), diff --git a/src/main/kotlin/io/github/nomisrev/config/ktor.kt b/src/main/kotlin/io/github/nomisrev/config/ktor.kt index 0a1560de..1a81583c 100644 --- a/src/main/kotlin/io/github/nomisrev/config/ktor.kt +++ b/src/main/kotlin/io/github/nomisrev/config/ktor.kt @@ -7,8 +7,8 @@ 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 @@ -16,17 +16,9 @@ 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>) - // 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()) } } diff --git a/src/main/kotlin/io/github/nomisrev/predef.kt b/src/main/kotlin/io/github/nomisrev/predef.kt index 7922cf82..0662b8f7 100644 --- a/src/main/kotlin/io/github/nomisrev/predef.kt +++ b/src/main/kotlin/io/github/nomisrev/predef.kt @@ -1,26 +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 +import kotlin.contracts.InvocationKind.EXACTLY_ONCE import kotlin.contracts.contract +typealias KtorCtx = PipelineContext + +// Work-around for bug with context receiver lambda +// https://youtrack.jetbrains.com/issue/KT-51243 +@OptIn(ExperimentalContracts::class) +inline fun with(a: A, b: B, block: context(A, B) (TypePlacedHolder) -> R): R { + contract { callsInPlace(block, EXACTLY_ONCE) } + return block(a, b, TypePlacedHolder) +} + @OptIn(ExperimentalContracts::class) -inline fun with(a: A, b: B, c: C, block: context(A, B, C) (TypeWrapper) -> R): R { - contract { - callsInPlace(block, InvocationKind.EXACTLY_ONCE) - } - return block(a, b, c, TypeWrapper.IMPL) +inline fun with(a: A, b: B, c: C, block: context(A, B, C) (TypePlacedHolder) -> R): R { + contract { callsInPlace(block, EXACTLY_ONCE) } + return block(a, b, c, TypePlacedHolder) } -sealed interface TypeWrapper { - object IMPL: TypeWrapper +sealed interface TypePlacedHolder { + companion object : TypePlacedHolder } // TODO - temp fix for ambiguity bug in compiler context(EffectScope) @OptIn(ExperimentalContracts::class) -public suspend fun ensureNotNull(value: B?, shift: () -> R): B { +suspend fun ensureNotNull(value: B?, shift: () -> R): B { contract { returns() implies (value != null) } return value ?: shift(shift()) } diff --git a/src/main/kotlin/io/github/nomisrev/routes/error.kt b/src/main/kotlin/io/github/nomisrev/routes/error.kt index 558273ab..91a10ec8 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/error.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/error.kt @@ -1,6 +1,5 @@ package io.github.nomisrev.routes -import arrow.core.Either import arrow.core.continuations.EffectScope import arrow.core.continuations.effect import io.github.nomisrev.ApiError @@ -19,6 +18,7 @@ import io.github.nomisrev.ApiError.UserFollowingHimself import io.github.nomisrev.ApiError.UserNotFound import io.github.nomisrev.ApiError.UserUnfollowingHimself import io.github.nomisrev.ApiError.UsernameAlreadyExists +import io.github.nomisrev.KtorCtx import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.application.call @@ -35,7 +35,7 @@ data class GenericErrorModelErrors(val body: List) fun GenericErrorModel(vararg msg: String): GenericErrorModel = GenericErrorModel(GenericErrorModelErrors(msg.toList())) -context(PipelineContext) +context(KtorCtx) suspend inline fun conduit( status: HttpStatusCode, crossinline block: suspend context(EffectScope) () -> A @@ -43,15 +43,8 @@ suspend inline fun conduit( block(this) }.fold({ respond(it) }, { call.respond(status, it) }) -context(PipelineContext) - suspend inline fun Either.respond(status: HttpStatusCode): Unit = - when (this) { - is Either.Left -> respond(value) - is Either.Right -> call.respond(status, value) - } - @Suppress("ComplexMethod") -suspend fun PipelineContext.respond(error: ApiError): Unit = +suspend fun KtorCtx.respond(error: ApiError): Unit = when (error) { PasswordNotMatched -> call.respond(HttpStatusCode.Unauthorized) is IncorrectInput -> @@ -78,9 +71,9 @@ suspend fun PipelineContext.respond(error: ApiError): Uni is JwtInvalid -> unprocessable(error.description) } -private suspend inline fun PipelineContext.unprocessable( +private suspend inline fun KtorCtx.unprocessable( error: String ): Unit = call.respond(HttpStatusCode.UnprocessableEntity, GenericErrorModel(error)) -suspend inline fun PipelineContext.internal(error: String): Unit = +suspend inline fun KtorCtx.internal(error: String): Unit = call.respond(HttpStatusCode.InternalServerError, GenericErrorModel(error)) diff --git a/src/main/kotlin/io/github/nomisrev/routes/health.kt b/src/main/kotlin/io/github/nomisrev/routes/health.kt index 89927279..97c1f3c7 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/health.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/health.kt @@ -22,19 +22,20 @@ import kotlinx.serialization.Serializable context(Application, HikariDataSource) fun healthRoute(): Routing = routing { get("/health") { - effect { + effect { healthCheck() }.fold( - { internal(it) }, + { call.respond(HttpStatusCode.ServiceUnavailable) }, { call.respond(HttpStatusCode.OK, it) } ) } } -context(HikariDataSource, EffectScope) +context(HikariDataSource, EffectScope) private suspend fun healthCheck(): HealthCheck { - ensure(isRunning) { "DatabasePool is not running" } - val version = ensureNotNull(showPostgresVersion()) { "Could not reach database. ConnectionPool is running." } + val version = ensureNotNull(showPostgresVersion()) { } + ensure(isRunning) { } + isRunning return HealthCheck(version) } diff --git a/src/main/kotlin/io/github/nomisrev/routes/users.kt b/src/main/kotlin/io/github/nomisrev/routes/users.kt index 73136839..971e673e 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/users.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/users.kt @@ -2,8 +2,6 @@ package io.github.nomisrev.routes import arrow.core.Either import arrow.core.continuations.EffectScope -import arrow.core.continuations.either -import io.github.nomisrev.with import io.github.nomisrev.ApiError import io.github.nomisrev.ApiError.Unexpected import io.github.nomisrev.auth.jwtAuth