diff --git a/build.gradle.kts b/build.gradle.kts index dadd85a6..b27d8d1a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.sqldelight) alias(libs.plugins.ktor) - alias(libs.plugins.spotless) } application { @@ -35,9 +34,15 @@ repositories { mavenCentral() } +java { + sourceCompatibility = JavaVersion.VERSION_19 + targetCompatibility = JavaVersion.VERSION_11 +} + tasks { withType().configureEach { kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" } } @@ -55,13 +60,6 @@ ktor { } } -spotless { - kotlin { - targetExclude("**/build/**") - ktfmt().googleStyle() - } -} - dependencies { implementation(libs.bundles.arrow) implementation(libs.bundles.ktor.server) diff --git a/src/main/kotlin/io/github/nomisrev/auth/JwtToken.kt b/src/main/kotlin/io/github/nomisrev/auth/JwtToken.kt new file mode 100644 index 00000000..98705138 --- /dev/null +++ b/src/main/kotlin/io/github/nomisrev/auth/JwtToken.kt @@ -0,0 +1,51 @@ +package io.github.nomisrev.auth + +import arrow.core.raise.effect +import arrow.core.raise.fold +import io.github.nomisrev.KtorCtx +import io.github.nomisrev.env.Env +import io.github.nomisrev.repo.UserId +import io.github.nomisrev.repo.UserPersistence +import io.github.nomisrev.routes.respond +import io.github.nomisrev.service.verifyJwtToken +import io.ktor.http.HttpStatusCode +import io.ktor.http.auth.HttpAuthHeader +import io.ktor.server.application.call +import io.ktor.server.auth.parseAuthorizationHeader +import io.ktor.server.response.respond + +@JvmInline +value class JwtToken(val value: String) + +// Small middleware to validate JWT token without using Ktor Auth / Nullable principle +context(KtorCtx, UserPersistence, Env.Auth) +suspend inline fun jwtAuth( // BUG: inline + same context as lambda as function + crossinline body: suspend /*context(KtorCtx)*/ (token: JwtToken, userId: UserId) -> Unit +) { + optionalJwtAuth { token, userId -> + token?.let { + userId?.let { + body(token, userId) + } + } ?: call.respond(HttpStatusCode.Unauthorized) + } +} + +// TODO Report YT: BUG: inline + same context as lambda as function +context(KtorCtx, UserPersistence, Env.Auth) +suspend inline fun optionalJwtAuth( // BUG: inline + same context as lambda as function + crossinline body: suspend /*context(KtorCtx)*/ (token: JwtToken?, userId: UserId?) -> Unit +) = effect { + jwtTokenStringOrNul()?.let { token -> + val userId = verifyJwtToken(JwtToken(token)) + Pair(JwtToken(token), userId) + } +}.fold( + { error -> respond(error) }, + { pair -> body(pair?.first, pair?.second) } +) + +context(KtorCtx) +fun jwtTokenStringOrNul(): String? = + (call.request.parseAuthorizationHeader() as? HttpAuthHeader.Single) + ?.blob diff --git a/src/main/kotlin/io/github/nomisrev/auth/jwt.kt b/src/main/kotlin/io/github/nomisrev/auth/jwt.kt deleted file mode 100644 index 0b27697d..00000000 --- a/src/main/kotlin/io/github/nomisrev/auth/jwt.kt +++ /dev/null @@ -1,49 +0,0 @@ -@file:Suppress("MatchingDeclarationName") - -package io.github.nomisrev.auth - -import arrow.core.Either -import io.github.nomisrev.repo.UserId -import io.github.nomisrev.routes.respond -import io.github.nomisrev.service.JwtService -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) - -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 -) { - optionalJwtAuth(jwtService) { context -> - context?.let { body(this, it) } ?: call.respond(HttpStatusCode.Unauthorized) - } -} - -suspend inline fun PipelineContext.optionalJwtAuth( - jwtService: JwtService, - crossinline body: suspend PipelineContext.(JwtContext?) -> Unit -) { - jwtToken()?.let { token -> - jwtService - .verifyJwtToken(JwtToken(token)) - .fold( - { error -> respond(error) }, - { userId -> body(this, JwtContext(JwtToken(token), userId)) } - ) - } - ?: body(this, null) -} - -fun PipelineContext.jwtToken(): String? = - Either.catch { (call.request.parseAuthorizationHeader() as? HttpAuthHeader.Single) } - .getOrNull() - ?.blob diff --git a/src/main/kotlin/io/github/nomisrev/env/Dependencies.kt b/src/main/kotlin/io/github/nomisrev/env/Dependencies.kt index 00895c4f..45df3abe 100644 --- a/src/main/kotlin/io/github/nomisrev/env/Dependencies.kt +++ b/src/main/kotlin/io/github/nomisrev/env/Dependencies.kt @@ -3,41 +3,32 @@ package io.github.nomisrev.env import arrow.fx.coroutines.continuations.ResourceScope import com.sksamuel.cohort.HealthCheckRegistry import com.sksamuel.cohort.hikari.HikariConnectionsHealthCheck +import io.github.nomisrev.repo.ArticlePersistence +import io.github.nomisrev.repo.FavouritePersistence import io.github.nomisrev.repo.TagPersistence import io.github.nomisrev.repo.UserPersistence -import io.github.nomisrev.repo.articleRepo +import io.github.nomisrev.repo.articlePersistence import io.github.nomisrev.repo.favouritePersistence import io.github.nomisrev.repo.tagPersistence import io.github.nomisrev.repo.userPersistence -import io.github.nomisrev.service.ArticleService -import io.github.nomisrev.service.JwtService -import io.github.nomisrev.service.UserService -import io.github.nomisrev.service.articleService -import io.github.nomisrev.service.jwtService -import io.github.nomisrev.service.slugifyGenerator -import io.github.nomisrev.service.userService import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers class Dependencies( - val userService: UserService, - val jwtService: JwtService, - val articleService: ArticleService, + val userPersistence: UserPersistence, + val articlePersistence: ArticlePersistence, val healthCheck: HealthCheckRegistry, val tagPersistence: TagPersistence, - val userPersistence: UserPersistence + val favouritePersistence: FavouritePersistence ) suspend fun ResourceScope.dependencies(env: Env): Dependencies { val hikari = hikari(env.dataSource) val sqlDelight = sqlDelight(hikari) - val userRepo = userPersistence(sqlDelight.usersQueries, sqlDelight.followingQueries) - val articleRepo = articleRepo(sqlDelight.articlesQueries, sqlDelight.tagsQueries) + val userPersistence = userPersistence(sqlDelight.usersQueries, sqlDelight.followingQueries) + val articlePersistence = articlePersistence(sqlDelight.articlesQueries, sqlDelight.tagsQueries) val tagPersistence = tagPersistence(sqlDelight.tagsQueries) val favouritePersistence = favouritePersistence(sqlDelight.favoritesQueries) - val jwtService = jwtService(env.auth, userRepo) - val slugGenerator = slugifyGenerator() - val userService = userService(userRepo, jwtService) val checks = HealthCheckRegistry(Dispatchers.Default) { @@ -45,12 +36,10 @@ suspend fun ResourceScope.dependencies(env: Env): Dependencies { } return Dependencies( - userService = userService, - jwtService = jwtService, - articleService = - articleService(slugGenerator, articleRepo, userRepo, tagPersistence, favouritePersistence), - healthCheck = checks, - tagPersistence = tagPersistence, - userPersistence = userRepo, + userPersistence, + articlePersistence, + checks, + tagPersistence, + favouritePersistence ) } diff --git a/src/main/kotlin/io/github/nomisrev/env/ktor.kt b/src/main/kotlin/io/github/nomisrev/env/ktor.kt index 0c7615a0..a24e7abb 100644 --- a/src/main/kotlin/io/github/nomisrev/env/ktor.kt +++ b/src/main/kotlin/io/github/nomisrev/env/ktor.kt @@ -18,7 +18,9 @@ import kotlinx.serialization.modules.polymorphic val kotlinXSerializersModule = SerializersModule { contextual(UserWrapper::class) { UserWrapper.serializer(LoginUser.serializer()) } - polymorphic(Any::class) { subclass(LoginUser::class, LoginUser.serializer()) } + polymorphic(Any::class) { + subclass(LoginUser::class, LoginUser.serializer()) + } } fun Application.configure() { diff --git a/src/main/kotlin/io/github/nomisrev/main.kt b/src/main/kotlin/io/github/nomisrev/main.kt index 1764e5b8..31f73c0c 100644 --- a/src/main/kotlin/io/github/nomisrev/main.kt +++ b/src/main/kotlin/io/github/nomisrev/main.kt @@ -9,6 +9,7 @@ import io.github.nomisrev.env.configure import io.github.nomisrev.env.dependencies import io.github.nomisrev.routes.health import io.github.nomisrev.routes.routes +import io.github.nomisrev.service.slugifyGenerator import io.ktor.server.application.Application import io.ktor.server.netty.Netty import kotlinx.coroutines.awaitCancellation @@ -17,13 +18,24 @@ fun main(): Unit = SuspendApp { val env = Env() resourceScope { val dependencies = dependencies(env) - server(Netty, host = env.http.host, port = env.http.port) { app(dependencies) } + server(Netty, host = env.http.host, port = env.http.port) { + app(env, dependencies) + } awaitCancellation() } } -fun Application.app(module: Dependencies) { +fun Application.app(env: Env, module: Dependencies) { configure() - routes(module) + with( + env.auth, + module.userPersistence, + module.articlePersistence, + module.tagPersistence, + module.favouritePersistence, + slugifyGenerator() + ) { + routes() + } health(module.healthCheck) } 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..a57397c6 --- /dev/null +++ b/src/main/kotlin/io/github/nomisrev/predef.kt @@ -0,0 +1,30 @@ +package io.github.nomisrev + +import io.ktor.server.application.ApplicationCall +import io.ktor.util.pipeline.PipelineContext +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind.EXACTLY_ONCE +import kotlin.contracts.contract + +typealias KtorCtx = PipelineContext + +// Work-around for bug with context receiver lambda +// https://youtrack.jetbrains.com/issue/KT-51243 +@OptIn(ExperimentalContracts::class) +@Suppress("SUBTYPING_BETWEEN_CONTEXT_RECEIVERS", "LongParameterList") +inline fun with( + a: A, + b: B, + c: C, + d: D, + e: E, + f: F, + block: context(A, B, C, D, E, F) (TypePlacedHolder) -> R +): R { + contract { callsInPlace(block, EXACTLY_ONCE) } + return block(a, b, c, d, e, f, TypePlacedHolder) +} + +sealed interface TypePlacedHolder { + companion object : TypePlacedHolder +} diff --git a/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt b/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt index 4d7921fe..565f4d1c 100644 --- a/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt +++ b/src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt @@ -34,12 +34,12 @@ interface ArticlePersistence { suspend fun exists(slug: Slug): Boolean /** Get recent articles from users you follow * */ - suspend fun getFeed(userId: UserId, limit: FeedLimit, offset: FeedOffset): List
+ suspend fun selectFeed(userId: UserId, limit: FeedLimit, offset: FeedOffset): List
- suspend fun getArticleBySlug(slug: Slug): Either + suspend fun selectArticleBySlug(slug: Slug): Either } -fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) = +fun articlePersistence(articles: ArticlesQueries, tagsQueries: TagsQueries) = object : ArticlePersistence { override suspend fun create( authorId: UserId, @@ -65,7 +65,7 @@ fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) = override suspend fun exists(slug: Slug): Boolean = articles.slugExists(slug.value).executeAsOne() - override suspend fun getFeed( + override suspend fun selectFeed( userId: UserId, limit: FeedLimit, offset: FeedOffset, @@ -81,10 +81,10 @@ fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) = articleTitle, articleDescription, articleBody, - articleAuthorId, + _, articleCreatedAt, articleUpdatedAt, - usersId, + _, usersUsername, usersImage -> Article( @@ -103,7 +103,7 @@ fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) = } .executeAsList() - override suspend fun getArticleBySlug(slug: Slug): Either = + override suspend fun selectArticleBySlug(slug: Slug): Either = either { val article = articles.selectBySlug(slug.value).executeAsOneOrNull() ensureNotNull(article) { ArticleBySlugNotFound(slug.value) } diff --git a/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt b/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt index d3ffdd9f..b9c6920d 100644 --- a/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt +++ b/src/main/kotlin/io/github/nomisrev/repo/UserPersistence.kt @@ -1,6 +1,7 @@ package io.github.nomisrev.repo import arrow.core.Either +import arrow.core.raise.Raise import arrow.core.raise.either import arrow.core.raise.ensure import arrow.core.raise.ensureNotNull @@ -18,26 +19,33 @@ import javax.crypto.spec.PBEKeySpec import org.postgresql.util.PSQLException import org.postgresql.util.PSQLState -@JvmInline value class UserId(val serial: Long) +@JvmInline +value class UserId(val serial: Long) 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(Raise) + suspend fun insert(username: String, email: String, password: String): UserId /** Verifies is a password is correct for a given email */ + context(Raise) suspend fun verifyPassword( email: String, password: String - ): Either> + ): Pair /** Select a User by its [UserId] */ - suspend fun select(userId: UserId): Either + context(Raise) + suspend fun select(userId: UserId): UserInfo /** Select a User by its username */ - suspend fun select(username: String): Either + context(Raise) + suspend fun select(username: String): UserInfo - suspend fun selectProfile(username: String): Either + context(Raise) + suspend fun selectProfile(username: String): Profile + context(Raise) @Suppress("LongParameterList") suspend fun update( userId: UserId, @@ -46,7 +54,7 @@ interface UserPersistence { password: String?, bio: String?, image: String? - ): Either + ): UserInfo suspend fun unfollowProfile(followedUsername: String, followerId: UserId): Unit } @@ -58,75 +66,80 @@ fun userPersistence( defaultIterations: Int = 64000, defaultKeyLength: Int = 512, secretKeysFactory: SecretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512") -) = - object : UserPersistence { - - override suspend fun insert( - username: String, - email: String, - password: String - ): Either { - val salt = generateSalt() - val key = generateKey(password, salt) - return Either.catchOrThrow { - usersQueries.create(salt, key, username, email) - } - .mapLeft { psqlException -> - if (psqlException.sqlState == PSQLState.UNIQUE_VIOLATION.state) - UsernameAlreadyExists(username) - else throw psqlException - } +) = object : UserPersistence { + + context(Raise) + override suspend fun insert( + username: String, + email: String, + password: String + ): UserId { + val salt = generateSalt() + val key = generateKey(password, salt) + return Either.catchOrThrow { + usersQueries.create(salt, key, username, email) } + .mapLeft { psqlException -> + if (psqlException.sqlState == PSQLState.UNIQUE_VIOLATION.state) + UsernameAlreadyExists(username) + else throw psqlException + }.bind() + } - override suspend fun verifyPassword( - email: String, - password: String - ): Either> = either { - val (userId, username, salt, key, bio, image) = - ensureNotNull(usersQueries.selectSecurityByEmail(email).executeAsOneOrNull()) { - UserNotFound("email=$email") - } + context(Raise) + override suspend fun verifyPassword( + email: String, + password: String + ): Pair { + val (userId, username, salt, key, bio, image) = + ensureNotNull(usersQueries.selectSecurityByEmail(email).executeAsOneOrNull()) { + UserNotFound("email=$email") + } + + val hash = generateKey(password, salt) + ensure(hash contentEquals key) { PasswordNotMatched } + return Pair(userId, UserInfo(email, username, bio, image)) + } - val hash = generateKey(password, salt) - ensure(hash contentEquals key) { PasswordNotMatched } - Pair(userId, UserInfo(email, username, bio, image)) - } + context(Raise) + override suspend fun select(userId: UserId): UserInfo { + val userInfo = + usersQueries + .selectById(userId) { email, username, _, _, bio, image -> + UserInfo(email, username, bio, image) + } + .executeAsOneOrNull() + return ensureNotNull(userInfo) { UserNotFound("userId=$userId") } + } - override suspend fun select(userId: UserId): Either = either { - val userInfo = - usersQueries - .selectById(userId) { email, username, _, _, bio, image -> - UserInfo(email, username, bio, image) - } - .executeAsOneOrNull() - ensureNotNull(userInfo) { UserNotFound("userId=$userId") } - } + context(Raise) + override suspend fun select(username: String): UserInfo { + val userInfo = usersQueries.selectByUsername(username, ::UserInfo).executeAsOneOrNull() + return ensureNotNull(userInfo) { UserNotFound("username=$username") } + } - override suspend fun select(username: String): Either = either { - val userInfo = usersQueries.selectByUsername(username, ::UserInfo).executeAsOneOrNull() - ensureNotNull(userInfo) { UserNotFound("username=$username") } - } + context(Raise) + override suspend fun selectProfile(username: String): Profile { + val profileInfo = usersQueries.selectProfile(username, ::toProfile).executeAsOneOrNull() + return ensureNotNull(profileInfo) { UserNotFound("username=$username") } + } - override suspend fun selectProfile(username: String): Either = either { - val profileInfo = usersQueries.selectProfile(username, ::toProfile).executeAsOneOrNull() - ensureNotNull(profileInfo) { UserNotFound("username=$username") } - } + fun toProfile(username: String, bio: String, image: String, following: Long): Profile = + Profile(username, bio, image, following > 0) - fun toProfile(username: String, bio: String, image: String, following: Long): Profile = - Profile(username, bio, image, following > 0) - - override suspend fun update( - userId: UserId, - email: String?, - username: String?, - password: String?, - bio: String?, - image: String? - ): Either = either { - val info = - usersQueries.transactionWithResult { - usersQueries.selectById(userId).executeAsOneOrNull()?.let { - (oldEmail, oldUsername, salt, oldPassword, oldBio, oldImage) -> + context(Raise) + override suspend fun update( + userId: UserId, + email: String?, + username: String?, + password: String?, + bio: String?, + image: String? + ): UserInfo { + val info = + usersQueries.transactionWithResult { + usersQueries.selectById(userId).executeAsOneOrNull() + ?.let { (oldEmail, oldUsername, salt, oldPassword, oldBio, oldImage) -> val newPassword = password?.let { generateKey(it, salt) } ?: oldPassword val newEmail = email ?: oldEmail val newUsername = username ?: oldUsername @@ -135,20 +148,20 @@ fun userPersistence( usersQueries.update(newEmail, newUsername, newPassword, newBio, newImage, userId) UserInfo(newEmail, newUsername, newBio, newImage) } - } - ensureNotNull(info) { UserNotFound("userId=$userId") } - } + } + return ensureNotNull(info) { UserNotFound("userId=$userId") } + } - override suspend fun unfollowProfile(followedUsername: String, followerId: UserId): Unit = + override suspend fun unfollowProfile(followedUsername: String, followerId: UserId): Unit = followingQueries.delete(followedUsername, followerId.serial) private fun generateSalt(): ByteArray = UUID.randomUUID().toString().toByteArray() - private fun generateKey(password: String, salt: ByteArray): ByteArray { - val spec = PBEKeySpec(password.toCharArray(), salt, defaultIterations, defaultKeyLength) - return secretKeysFactory.generateSecret(spec).encoded - } + private fun generateKey(password: String, salt: ByteArray): ByteArray { + val spec = PBEKeySpec(password.toCharArray(), salt, defaultIterations, defaultKeyLength) + return secretKeysFactory.generateSecret(spec).encoded } +} private fun UsersQueries.create( salt: ByteArray, @@ -157,11 +170,10 @@ private fun UsersQueries.create( email: String ): UserId = insertAndGetId( - username = username, - email = email, - salt = salt, - hashed_password = key, - bio = "", - image = "" - ) - .executeAsOne() + username = username, + email = email, + salt = salt, + hashed_password = key, + bio = "", + image = "" + ).executeAsOne() diff --git a/src/main/kotlin/io/github/nomisrev/routes/articles.kt b/src/main/kotlin/io/github/nomisrev/routes/articles.kt index 3d4554f7..ae1f0e8d 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/articles.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/articles.kt @@ -1,11 +1,17 @@ package io.github.nomisrev.routes -import arrow.core.raise.either import io.github.nomisrev.auth.jwtAuth -import io.github.nomisrev.service.ArticleService +import io.github.nomisrev.env.Env +import io.github.nomisrev.repo.ArticlePersistence +import io.github.nomisrev.repo.FavouritePersistence +import io.github.nomisrev.repo.TagPersistence +import io.github.nomisrev.repo.UserPersistence import io.github.nomisrev.service.CreateArticle -import io.github.nomisrev.service.JwtService import io.github.nomisrev.service.Slug +import io.github.nomisrev.service.SlugGenerator +import io.github.nomisrev.service.articleBySlug +import io.github.nomisrev.service.createArticle +import io.github.nomisrev.service.getUserFeed import io.github.nomisrev.validate import io.ktor.http.HttpStatusCode import io.ktor.resources.Resource @@ -23,7 +29,8 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -@Serializable data class ArticleWrapper(val article: T) +@Serializable +data class ArticleWrapper(val article: T) @Serializable data class Article( @@ -40,7 +47,8 @@ data class Article( val tagList: List ) -@Serializable data class SingleArticleResponse(val article: Article) +@Serializable +data class SingleArticleResponse(val article: Article) @Serializable data class MultipleArticlesResponse( @@ -48,9 +56,13 @@ data class MultipleArticlesResponse( val articlesCount: Int, ) -@JvmInline @Serializable value class FeedOffset(val offset: Int) +@JvmInline +@Serializable +value class FeedOffset(val offset: Int) -@JvmInline @Serializable value class FeedLimit(val limit: Int) +@JvmInline +@Serializable +value class FeedLimit(val limit: Int) @Serializable data class Comment( @@ -99,62 +111,61 @@ data class ArticlesResource(val parent: RootResource = RootResource) { data class Slug(val parent: ArticlesResource = ArticlesResource(), val slug: String) } -fun Route.articleRoutes( - articleService: ArticleService, - jwtService: JwtService, -) { +context ( + Env.Auth, + SlugGenerator, + ArticlePersistence, + UserPersistence, + TagPersistence, + FavouritePersistence +) +fun Route.articleRoutes() { get { feed -> - jwtAuth(jwtService) { (_, userId) -> - either { - val getFeed = feed.validate(userId).also(::println).bind() + jwtAuth { _, userId -> + conduit(HttpStatusCode.OK) { + val getFeed = feed.validate(userId).also(::println).bind() - val articlesFeed = articleService.getUserFeed(input = getFeed) - ArticleWrapper(articlesFeed) - } - .also(::println) - .respond(HttpStatusCode.OK) + val articlesFeed = getUserFeed(input = getFeed) + ArticleWrapper(articlesFeed) + } } } get { slug -> - articleService - .getArticleBySlug(Slug(slug.slug)) - .map { SingleArticleResponse(it) } - .respond(HttpStatusCode.OK) + conduit(HttpStatusCode.OK) { + SingleArticleResponse(articleBySlug(Slug(slug.slug))) + } } post { - jwtAuth(jwtService) { (_, userId) -> - either { - val article = call.receive>().article.validate().bind() - articleService - .createArticle( - CreateArticle( - userId, - article.title, - article.description, - article.body, - article.tagList.toSet() - ) - ) - .map { - ArticleResponse( - it.slug, - it.title, - it.description, - it.body, - it.author, - it.favorited, - it.favoritesCount, - it.createdAt, - it.updatedAt, - it.tagList - ) - } - .bind() + jwtAuth { _, userId -> + conduit(HttpStatusCode.Created) { + val newArticle = call.receive>().article.validate().bind() + val article = createArticle( + CreateArticle( + userId, + newArticle.title, + newArticle.description, + newArticle.body, + newArticle.tagList.toSet() + ) + ) + with(article) { + ArticleResponse( + slug, + title, + description, + body, + author, + favorited, + favoritesCount, + createdAt, + updatedAt, + tagList + ) } - .respond(HttpStatusCode.Created) + } } } } diff --git a/src/main/kotlin/io/github/nomisrev/routes/error.kt b/src/main/kotlin/io/github/nomisrev/routes/error.kt index 7cb33ff5..35489ffc 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/error.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/error.kt @@ -1,6 +1,7 @@ package io.github.nomisrev.routes -import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.either import io.github.nomisrev.ArticleBySlugNotFound import io.github.nomisrev.CannotGenerateSlug import io.github.nomisrev.DomainError @@ -14,6 +15,7 @@ import io.github.nomisrev.MissingParameter import io.github.nomisrev.PasswordNotMatched import io.github.nomisrev.UserNotFound import io.github.nomisrev.UsernameAlreadyExists +import io.github.nomisrev.KtorCtx import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.application.call @@ -22,27 +24,31 @@ import io.ktor.util.pipeline.PipelineContext import kotlinx.serialization.ExperimentalSerializationApi 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) -context(PipelineContext) - -suspend inline fun Either.respond(status: HttpStatusCode): Unit = - when (this) { - is Either.Left -> respond(value) - is Either.Right -> call.respond(status, value) - } +context(KtorCtx) +suspend inline fun conduit( + status: HttpStatusCode, + crossinline block: suspend context(Raise) () -> A +): Unit = either { + block(this) +}.fold({ respond(it) }, { call.respond(status, it) }) @OptIn(ExperimentalSerializationApi::class) @Suppress("ComplexMethod") -suspend fun PipelineContext.respond(error: DomainError): Unit = +suspend fun KtorCtx.respond(error: DomainError): Unit = when (error) { PasswordNotMatched -> call.respond(HttpStatusCode.Unauthorized) is IncorrectInput -> unprocessable(error.errors.map { field -> "${field.field}: ${field.errors.joinToString()}" }) + is IncorrectJson -> unprocessable("Json is missing fields: ${error.exception.missingFields.joinToString()}") + is EmptyUpdate -> unprocessable(error.description) is EmailAlreadyExists -> unprocessable("${error.email} is already registered") is JwtGeneration -> unprocessable(error.description) @@ -54,7 +60,7 @@ suspend fun PipelineContext.respond(error: DomainError): is MissingParameter -> unprocessable("Missing ${error.name} parameter in request") } -private suspend inline fun PipelineContext.unprocessable( +private suspend inline fun KtorCtx.unprocessable( error: String ): Unit = call.respond( diff --git a/src/main/kotlin/io/github/nomisrev/routes/profile.kt b/src/main/kotlin/io/github/nomisrev/routes/profile.kt index 84f1690c..0340a114 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/profile.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/profile.kt @@ -2,12 +2,11 @@ package io.github.nomisrev.routes -import arrow.core.raise.either import arrow.core.raise.ensure import io.github.nomisrev.MissingParameter import io.github.nomisrev.auth.jwtAuth +import io.github.nomisrev.env.Env import io.github.nomisrev.repo.UserPersistence -import io.github.nomisrev.service.JwtService import io.ktor.http.HttpStatusCode import io.ktor.resources.Resource import io.ktor.server.resources.delete @@ -34,25 +33,24 @@ data class ProfilesResource(val parent: RootResource = RootResource) { data class Follow(val parent: ProfilesResource = ProfilesResource(), val username: String) } -fun Route.profileRoutes(userPersistence: UserPersistence, jwtService: JwtService) { +context(Env.Auth, UserPersistence) +fun Route.profileRoutes() { get { route -> - either { + conduit(HttpStatusCode.OK) { ensure(!route.username.isNullOrBlank()) { MissingParameter("username") } - userPersistence.selectProfile(route.username).bind() + selectProfile(route.username) } - .respond(HttpStatusCode.OK) } delete { follow -> - jwtAuth(jwtService) { (_, userId) -> - either { - userPersistence.unfollowProfile(follow.username, userId) - val userUnfollowed = userPersistence.select(follow.username).bind() + jwtAuth { _, userId -> + conduit(HttpStatusCode.OK) { + unfollowProfile(follow.username, userId) + val userUnfollowed = select(follow.username) ProfileWrapper( Profile(userUnfollowed.username, userUnfollowed.bio, userUnfollowed.image, false) ) } - .respond(HttpStatusCode.OK) } } } diff --git a/src/main/kotlin/io/github/nomisrev/routes/root.kt b/src/main/kotlin/io/github/nomisrev/routes/root.kt index 4209e718..b7d377a7 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/root.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/root.kt @@ -1,15 +1,29 @@ package io.github.nomisrev.routes -import io.github.nomisrev.env.Dependencies +import io.github.nomisrev.env.Env +import io.github.nomisrev.repo.ArticlePersistence +import io.github.nomisrev.repo.FavouritePersistence +import io.github.nomisrev.repo.TagPersistence +import io.github.nomisrev.repo.UserPersistence +import io.github.nomisrev.service.SlugGenerator import io.ktor.resources.Resource import io.ktor.server.application.Application import io.ktor.server.routing.routing -fun Application.routes(deps: Dependencies) = routing { - userRoutes(deps.userService, deps.jwtService) - tagRoutes(deps.tagPersistence) - articleRoutes(deps.articleService, deps.jwtService) - profileRoutes(deps.userPersistence, deps.jwtService) +context( + Env.Auth, + SlugGenerator, + ArticlePersistence, + UserPersistence, + TagPersistence, + FavouritePersistence +) +fun Application.routes() = routing { + userRoutes() + tagRoutes() + articleRoutes() + profileRoutes() } -@Resource("/api") data object RootResource +@Resource("/api") +data object RootResource diff --git a/src/main/kotlin/io/github/nomisrev/routes/tags.kt b/src/main/kotlin/io/github/nomisrev/routes/tags.kt index 33513d96..e72f7a91 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/tags.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/tags.kt @@ -8,15 +8,16 @@ import io.ktor.server.application.call import io.ktor.server.resources.get import io.ktor.server.response.respond import io.ktor.server.routing.Route +import io.ktor.server.routing.Routing import kotlinx.serialization.Serializable @Serializable data class TagsResponse(val tags: List) @Resource("/tags") data class TagsResource(val parent: RootResource = RootResource) -fun Route.tagRoutes(tagPersistence: TagPersistence) { +context (Routing, TagPersistence) +fun tagRoutes(): Route = get { - val tags = tagPersistence.selectTags() + val tags = selectTags() call.respond(TagsResponse(tags)) } -} diff --git a/src/main/kotlin/io/github/nomisrev/routes/users.kt b/src/main/kotlin/io/github/nomisrev/routes/users.kt index 2ccfa7ec..ff94f04c 100644 --- a/src/main/kotlin/io/github/nomisrev/routes/users.kt +++ b/src/main/kotlin/io/github/nomisrev/routes/users.kt @@ -1,14 +1,17 @@ package io.github.nomisrev.routes -import arrow.core.Either -import arrow.core.raise.either +import arrow.core.raise.Raise +import arrow.core.raise.catch import io.github.nomisrev.IncorrectJson import io.github.nomisrev.auth.jwtAuth -import io.github.nomisrev.service.JwtService +import io.github.nomisrev.env.Env +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.resources.Resource import io.ktor.server.application.ApplicationCall @@ -17,15 +20,17 @@ import io.ktor.server.request.receive import io.ktor.server.resources.get import io.ktor.server.resources.post import io.ktor.server.resources.put -import io.ktor.server.routing.Route +import io.ktor.server.routing.Routing import io.ktor.util.pipeline.PipelineContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.MissingFieldException 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,64 +50,59 @@ 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) @Resource("/users") data class UsersResource(val parent: RootResource = RootResource) { - @Resource("/login") data class Login(val parent: UsersResource = UsersResource()) + @Resource("/login") + data class Login(val parent: UsersResource = UsersResource()) } -@Resource("/user") data class UserResource(val parent: RootResource = RootResource) +@Resource("/user") +data class UserResource(val parent: RootResource = RootResource) -fun Route.userRoutes( - userService: UserService, - jwtService: JwtService, -) { +context(Routing, UserPersistence, Env.Auth) +fun 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) + conduit(HttpStatusCode.Created) { + val (username, email, password) = receiveCatching>().user + val token = register(RegisterUser(username, email, password)).value + UserWrapper(User(email, token, username, "", "")) + } } post { - 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) + 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 { - 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) + 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 { - 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) + 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(Raise) @OptIn(ExperimentalSerializationApi::class) -private suspend inline fun PipelineContext - .receiveCatching(): Either = - Either.catchOrThrow { call.receive() }.mapLeft { IncorrectJson(it) } +private suspend inline fun PipelineContext.receiveCatching(): A = + catch({ call.receive() }) { e: MissingFieldException -> raise(IncorrectJson(e)) } diff --git a/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt b/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt index f230b1c1..d498c419 100644 --- a/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt +++ b/src/main/kotlin/io/github/nomisrev/service/ArticleService.kt @@ -1,7 +1,8 @@ +@file:Suppress("MatchingDeclarationName") + package io.github.nomisrev.service -import arrow.core.Either -import arrow.core.raise.either +import arrow.core.raise.Raise import io.github.nomisrev.DomainError import io.github.nomisrev.repo.ArticlePersistence import io.github.nomisrev.repo.FavouritePersistence @@ -29,92 +30,76 @@ data class GetFeed( val offset: Int, ) -interface ArticleService { - /** Creates a new article and returns the resulting Article */ - suspend fun createArticle(input: CreateArticle): Either - - /** Get the user's feed which contains articles of the authors the user followed */ - suspend fun getUserFeed(input: GetFeed): MultipleArticlesResponse - - /** Get article by Slug */ - suspend fun getArticleBySlug(slug: Slug): Either +/** Creates a new article and returns the resulting Article */ +context(Raise, 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, + input.description, + input.body, + Profile(user.username, user.bio, user.image, false), + false, + 0, + createdAt, + createdAt, + input.tags.toList() + ) } -fun articleService( - slugGenerator: SlugGenerator, - articlePersistence: ArticlePersistence, - userPersistence: UserPersistence, - tagPersistence: TagPersistence, - favouritePersistence: FavouritePersistence -): ArticleService = - object : ArticleService { - override suspend fun createArticle(input: CreateArticle): Either = - either { - val slug = - slugGenerator - .generateSlug(input.title) { slug -> articlePersistence.exists(slug).not() } - .bind() - 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).bind() - Article( - articleId, - slug.value, - input.title, - input.description, - input.body, - Profile(user.username, user.bio, user.image, false), - false, - 0, - createdAt, - createdAt, - input.tags.toList() - ) - } - - override suspend fun getUserFeed(input: GetFeed): MultipleArticlesResponse { - val articles = - articlePersistence.getFeed( - userId = input.userId, - limit = FeedLimit(input.limit), - offset = FeedOffset(input.offset) - ) +context(ArticlePersistence) +suspend fun getUserFeed(input: GetFeed): MultipleArticlesResponse { + val articles = + selectFeed( + userId = input.userId, + limit = FeedLimit(input.limit), + offset = FeedOffset(input.offset) + ) - return MultipleArticlesResponse( - articles = articles, - articlesCount = articles.size, - ) - } + return MultipleArticlesResponse( + articles = articles, + articlesCount = articles.size, + ) +} - override suspend fun getArticleBySlug(slug: Slug): Either = either { - val article = articlePersistence.getArticleBySlug(slug).bind() - val user = userPersistence.select(article.author_id).bind() - val articleTags = tagPersistence.selectTagsOfArticle(article.id) - val favouriteCount = favouritePersistence.favoriteCount(article.id) - Article( - article.id.serial, - slug.value, - article.title, - article.description, - article.body, - Profile(user.username, user.bio, user.image, false), - false, - favouriteCount, - article.createdAt, - article.createdAt, - articleTags - ) - } - } +context( + Raise, + SlugGenerator, + ArticlePersistence, + TagPersistence, + FavouritePersistence, + UserPersistence +) +suspend fun articleBySlug(slug: Slug): Article { + val article = selectArticleBySlug(slug).bind() + val user = select(article.author_id) + val articleTags = selectTagsOfArticle(article.id) + val favouriteCount = favoriteCount(article.id) + return Article( + article.id.serial, + slug.value, + article.title, + article.description, + article.body, + Profile(user.username, user.bio, user.image, false), + false, + favouriteCount, + article.createdAt, + article.createdAt, + articleTags + ) +} diff --git a/src/main/kotlin/io/github/nomisrev/service/JwtService.kt b/src/main/kotlin/io/github/nomisrev/service/JwtService.kt index 7dfa8598..378800f0 100644 --- a/src/main/kotlin/io/github/nomisrev/service/JwtService.kt +++ b/src/main/kotlin/io/github/nomisrev/service/JwtService.kt @@ -1,7 +1,7 @@ package io.github.nomisrev.service import arrow.core.Either -import arrow.core.raise.either +import arrow.core.raise.Raise import arrow.core.raise.ensure import arrow.core.raise.ensureNotNull import io.github.nefilim.kjwt.JWSAlgorithm @@ -21,49 +21,44 @@ import java.time.Clock import java.time.Instant 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 +/** Generate a new JWT token for userId and password. Doesn't invalidate old password */ +context(Raise, Env.Auth) +fun generateJwtToken(userId: UserId): JwtToken = + JWT + .hs512 { + val now = Instant.now(Clock.systemUTC()) + issuedAt(now) + expiresAt(now + duration.toJavaDuration()) + issuer(issuer) + claim("id", userId.serial) + } + .sign(secret) + .bind() + .let { JwtToken(it.rendered) } - /** Verify a JWT token. Checks if userId exists in database, and token is not expired. */ - suspend fun verifyJwtToken(token: JwtToken): Either +/** Verify a JWT token. Checks if userId exists in database, and token is not expired. */ +context(Raise, 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").getOrNull()) { + JwtInvalid("id missing from JWT Token") + } + val expiresAt = + ensureNotNull(jwt.expiresAt().getOrNull()) { JwtInvalid("exp missing from JWT Token") } + ensure(expiresAt.isAfter(Instant.now(Clock.systemUTC()))) { JwtInvalid("JWT Token expired") } + select(UserId(userId)) + return UserId(userId) } -fun jwtService(env: Env.Auth, repo: UserPersistence) = - object : JwtService { - override suspend fun generateJwtToken(userId: UserId): Either = - JWT.hs512 { - val now = Instant.now(Clock.systemUTC()) - issuedAt(now) - expiresAt(now + env.duration.toJavaDuration()) - issuer(env.issuer) - claim("id", userId.serial) - } - .sign(env.secret) - .toUserServiceError() - .map { JwtToken(it.rendered) } - - override suspend fun verifyJwtToken(token: JwtToken): Either = either { - 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(Instant.now(Clock.systemUTC()))) { JwtInvalid("JWT Token expired") } - repo.select(UserId(userId)).bind() - UserId(userId) +context(Raise) +private 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}") } - } - -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}") - } -} + }.bind() diff --git a/src/main/kotlin/io/github/nomisrev/service/SlugGenerator.kt b/src/main/kotlin/io/github/nomisrev/service/SlugGenerator.kt index 9e4b1e65..b2f1a7ac 100644 --- a/src/main/kotlin/io/github/nomisrev/service/SlugGenerator.kt +++ b/src/main/kotlin/io/github/nomisrev/service/SlugGenerator.kt @@ -1,14 +1,13 @@ package io.github.nomisrev.service -import arrow.core.Either import arrow.core.raise.Raise -import arrow.core.raise.either import arrow.core.raise.ensure import com.github.slugify.Slugify import io.github.nomisrev.CannotGenerateSlug import kotlin.random.Random -@JvmInline value class Slug(val value: String) +@JvmInline +value class Slug(val value: String) fun interface SlugGenerator { /** @@ -18,10 +17,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(Raise) suspend fun generateSlug( title: String, verifyUnique: suspend (Slug) -> Boolean - ): Either + ): Slug } fun slugifyGenerator( @@ -50,10 +50,9 @@ fun slugifyGenerator( return if (isUnique) slug else recursiveGen(title, verifyUnique, maxAttempts - 1, false) } + context(Raise) override suspend fun generateSlug( title: String, verifyUnique: suspend (Slug) -> Boolean - ): Either = either { - recursiveGen(title, verifyUnique, defaultMaxAttempts, true) - } + ): 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 7e156d68..e693511e 100644 --- a/src/main/kotlin/io/github/nomisrev/service/UserService.kt +++ b/src/main/kotlin/io/github/nomisrev/service/UserService.kt @@ -1,11 +1,11 @@ package io.github.nomisrev.service -import arrow.core.Either -import arrow.core.raise.either +import arrow.core.raise.Raise import arrow.core.raise.ensure import io.github.nomisrev.DomainError import io.github.nomisrev.EmptyUpdate import io.github.nomisrev.auth.JwtToken +import io.github.nomisrev.env.Env import io.github.nomisrev.repo.UserId import io.github.nomisrev.repo.UserPersistence import io.github.nomisrev.validate @@ -25,50 +25,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 */ - suspend fun register(input: RegisterUser): Either - - /** Updates a user with all the provided fields, returns resulting info */ - suspend fun update(input: Update): Either - - /** Logs in a user based on email and password. */ - suspend fun login(input: Login): Either> - - /** Retrieve used based on userId */ - suspend fun getUser(userId: UserId): Either - - /** Retrieve used based on username */ - suspend fun getUser(username: String): Either +/** Registers the user and returns its unique identifier */ +context(Raise, UserPersistence, Env.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 { - override suspend fun register(input: RegisterUser): Either = either { - val (username, email, password) = input.validate().bind() - val userId = repo.insert(username, email, password).bind() - jwtService.generateJwtToken(userId).bind() - } - - override suspend fun login(input: Login): Either> = - either { - val (email, password) = input.validate().bind() - val (userId, info) = repo.verifyPassword(email, password).bind() - val token = jwtService.generateJwtToken(userId).bind() - Pair(token, info) - } - - override suspend fun update(input: Update): Either = either { - 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() - } - - override suspend fun getUser(userId: UserId): Either = - repo.select(userId) +/** Logs in a user based on email and password. */ +context(Raise, UserPersistence, Env.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) +} - override suspend fun getUser(username: String): Either = - repo.select(username) +/** Updates a user with all the provided fields, returns resulting info */ +context(Raise, 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/KotestProject.kt b/src/test/kotlin/io/github/nomisrev/KotestProject.kt index d2633897..ce68cbb2 100644 --- a/src/test/kotlin/io/github/nomisrev/KotestProject.kt +++ b/src/test/kotlin/io/github/nomisrev/KotestProject.kt @@ -31,7 +31,7 @@ object KotestProject : AbstractProjectConfig() { Env.DataSource(postgres.jdbcUrl, postgres.username, postgres.password, postgres.driverClassName) } - private val env: Env by lazy { Env().copy(dataSource = dataSource) } + val env: Env by lazy { Env().copy(dataSource = dataSource) } val dependencies = ProjectResource(resource { dependencies(env) }) private val hikari = ProjectResource(resource { hikari(env.dataSource) }) diff --git a/src/test/kotlin/io/github/nomisrev/ktor.kt b/src/test/kotlin/io/github/nomisrev/ktor.kt index 9886dc3a..d54ccfec 100644 --- a/src/test/kotlin/io/github/nomisrev/ktor.kt +++ b/src/test/kotlin/io/github/nomisrev/ktor.kt @@ -2,35 +2,65 @@ package io.github.nomisrev -import io.github.nomisrev.env.Dependencies +import arrow.core.raise.recover +import io.github.nomisrev.auth.JwtToken +import io.github.nomisrev.env.Env import io.github.nomisrev.env.kotlinXSerializersModule +import io.github.nomisrev.repo.ArticlePersistence +import io.github.nomisrev.repo.TagPersistence +import io.github.nomisrev.repo.UserPersistence +import io.github.nomisrev.service.RegisterUser +import io.github.nomisrev.service.SlugGenerator +import io.github.nomisrev.service.register +import io.github.nomisrev.service.slugifyGenerator import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.resources.Resources import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.testing.ApplicationTestBuilder -import io.ktor.server.testing.TestApplication import io.ktor.server.testing.testApplication import kotlinx.serialization.json.Json -suspend fun withServer(test: suspend HttpClient.(dep: Dependencies) -> Unit): Unit { +suspend fun withDependencies( + block: suspend context(Env.Auth, UserPersistence) () -> A +): A { + val dependencies = KotestProject.dependencies.get() + return block(KotestProject.env.auth, dependencies.userPersistence) +} + +suspend fun withServer(test: suspend context( + HttpClient, + Env.Auth, + UserPersistence, + ArticlePersistence, + TagPersistence, + SlugGenerator +) () -> Unit): Unit { val dependencies = KotestProject.dependencies.get() testApplication { - application { app(dependencies) } + application { app(KotestProject.env, dependencies) } createClient { - expectSuccess = false - install(ContentNegotiation) { json(Json { serializersModule = kotlinXSerializersModule }) } - install(Resources) { serializersModule = kotlinXSerializersModule } + expectSuccess = false + install(ContentNegotiation) { json(Json { serializersModule = kotlinXSerializersModule }) } + install(Resources) { serializersModule = kotlinXSerializersModule } + } + .use { client -> + test( + client, + KotestProject.env.auth, + dependencies.userPersistence, + dependencies.articlePersistence, + dependencies.tagPersistence, + slugifyGenerator(), + ) } - .use { client -> test(client, dependencies) } } } -// Small optimisation to avoid runBlocking from Ktor impl -@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") -private suspend fun testApplication(block: suspend ApplicationTestBuilder.() -> Unit) { - val builder = ApplicationTestBuilder().apply { block() } - val testApplication = TestApplication(builder) - testApplication.engine.start() - testApplication.stop() -} +suspend fun registerUser(name: String): JwtToken = + with(KotestProject.env.auth) { + with(KotestProject.dependencies.get().userPersistence) { + recover({ + register(RegisterUser(name, "$name@email.com", "password")) + }) { throw AssertionError("Registering user: $name failed", RuntimeException(it.toString())) } + } + } diff --git a/src/test/kotlin/io/github/nomisrev/routes/ArticleRouteSpec.kt b/src/test/kotlin/io/github/nomisrev/routes/ArticleRouteSpec.kt index e6350f5a..f20915f2 100644 --- a/src/test/kotlin/io/github/nomisrev/routes/ArticleRouteSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/routes/ArticleRouteSpec.kt @@ -1,13 +1,11 @@ package io.github.nomisrev.routes -import io.github.nomisrev.KotestProject import io.github.nomisrev.MIN_FEED_LIMIT import io.github.nomisrev.MIN_FEED_OFFSET import io.github.nomisrev.SuspendFun import io.github.nomisrev.auth.JwtToken -import io.github.nomisrev.service.RegisterUser +import io.github.nomisrev.registerUser import io.github.nomisrev.withServer -import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.matchers.shouldBe import io.ktor.client.call.body import io.ktor.client.plugins.resources.get @@ -22,8 +20,6 @@ import kotlin.properties.Delegates class ArticleRouteSpec : SuspendFun({ val username = "username3" - val email = "valid1@domain.com" - val password = "123456789" val tags = setOf("arrow", "ktor", "kotlin", "sqldelight") val title = "Fake Article Arrow" val description = "This is a fake article description." @@ -32,12 +28,7 @@ class ArticleRouteSpec : var token: JwtToken by Delegates.notNull() beforeTest { - token = - KotestProject.dependencies - .get() - .userService - .register(RegisterUser(username, email, password)) - .shouldBeRight() + token = registerUser(username) } "Check for empty feed" { @@ -124,7 +115,6 @@ class ArticleRouteSpec : setBody(ArticleWrapper(NewArticle(title, description, body, tags.toList()))) } - response.status shouldBe HttpStatusCode.Created with(response.body()) { title shouldBe title description shouldBe description @@ -134,6 +124,7 @@ class ArticleRouteSpec : author.username shouldBe username tagList.toSet() shouldBe tags } + response.status shouldBe HttpStatusCode.Created } } @@ -146,7 +137,6 @@ class ArticleRouteSpec : setBody(ArticleWrapper(NewArticle(title, description, body, emptyList()))) } - response.status shouldBe HttpStatusCode.Created with(response.body()) { title shouldBe title description shouldBe description @@ -156,6 +146,7 @@ class ArticleRouteSpec : author.username shouldBe username tagList.size shouldBe 0 } + response.status shouldBe HttpStatusCode.Created } } diff --git a/src/test/kotlin/io/github/nomisrev/routes/ArticlesRouteSpec.kt b/src/test/kotlin/io/github/nomisrev/routes/ArticlesRouteSpec.kt index 47e1e751..d83b339e 100644 --- a/src/test/kotlin/io/github/nomisrev/routes/ArticlesRouteSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/routes/ArticlesRouteSpec.kt @@ -1,14 +1,13 @@ package io.github.nomisrev.routes -import arrow.core.flatMap -import io.github.nefilim.kjwt.JWSHMAC512Algorithm -import io.github.nefilim.kjwt.JWT +import arrow.core.raise.either +import io.github.nomisrev.registerUser import io.github.nomisrev.repo.UserId import io.github.nomisrev.service.CreateArticle -import io.github.nomisrev.service.RegisterUser +import io.github.nomisrev.service.createArticle +import io.github.nomisrev.service.shouldHaveUserId import io.github.nomisrev.withServer import io.kotest.assertions.arrow.core.shouldBeRight -import io.kotest.assertions.arrow.core.shouldBeSome import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import io.ktor.client.call.body @@ -17,11 +16,7 @@ import io.ktor.http.HttpStatusCode class ArticlesRouteSpec : StringSpec({ - // User val validUsername = "username2" - val validEmail = "valid2@domain.com" - val validPw = "123456789" - // Article val validTags = setOf("arrow", "kotlin", "ktor", "sqldelight") val validTitle = "Fake Article Arrow " val validDescription = "This is a fake article description." @@ -38,20 +33,14 @@ class ArticlesRouteSpec : } "Can get an article by slug" { - withServer { dependencies -> - val userId = - dependencies.userService - .register(RegisterUser(validUsername, validEmail, validPw)) - .flatMap { JWT.decodeT(it.value, JWSHMAC512Algorithm) } - .map { it.claimValueAsLong("id").shouldBeSome() } - .shouldBeRight() - - val article = - dependencies.articleService - .createArticle( - CreateArticle(UserId(userId), validTitle, validDescription, validBody, validTags) - ) - .shouldBeRight() + withServer { + val userId = registerUser(validUsername).shouldHaveUserId() + + val article = either { + createArticle( + CreateArticle(UserId(userId), validTitle, validDescription, validBody, validTags) + ) + }.shouldBeRight() val response = get(ArticlesResource.Slug(slug = article.slug)) diff --git a/src/test/kotlin/io/github/nomisrev/routes/ProfileRouteSpec.kt b/src/test/kotlin/io/github/nomisrev/routes/ProfileRouteSpec.kt index 450a3089..79fa1069 100644 --- a/src/test/kotlin/io/github/nomisrev/routes/ProfileRouteSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/routes/ProfileRouteSpec.kt @@ -1,9 +1,7 @@ package io.github.nomisrev.routes -import io.github.nomisrev.env.Dependencies -import io.github.nomisrev.service.RegisterUser import io.github.nomisrev.withServer -import io.kotest.assertions.arrow.core.shouldBeRight +import io.github.nomisrev.registerUser import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import io.ktor.client.call.body @@ -17,20 +15,12 @@ import io.ktor.http.contentType class ProfileRouteSpec : StringSpec({ val validUsername = "username" - val validEmail = "valid@domain.com" - val validPw = "123456789" val validUsernameFollowed = "username2" - val validEmailFollowed = "valid2@domain.com" "Can unfollow profile" { - withServer { dependencies -> - val token = - dependencies.userService - .register(RegisterUser(validUsername, validEmail, validPw)) - .shouldBeRight() - dependencies.userService - .register(RegisterUser(validUsernameFollowed, validEmailFollowed, validPw)) - .shouldBeRight() + withServer { + val token = registerUser(validUsername) + registerUser(validUsernameFollowed) val response = delete(ProfilesResource.Follow(username = validUsernameFollowed)) { @@ -56,11 +46,8 @@ class ProfileRouteSpec : } "Username invalid to unfollow" { - withServer { dependencies -> - val token = - dependencies.userService - .register(RegisterUser(validUsername, validEmail, validPw)) - .shouldBeRight() + withServer { + val token = registerUser(validUsername) val response = delete(ProfilesResource.Follow(username = validUsernameFollowed)) { @@ -72,10 +59,8 @@ class ProfileRouteSpec : } "Get profile with no following" { - withServer { dependencies: Dependencies -> - dependencies.userService - .register(RegisterUser(validUsername, validEmail, validPw)) - .shouldBeRight() + withServer { + registerUser(validUsername) val response = get(ProfilesResource.Username(username = validUsername)) { contentType(ContentType.Application.Json) diff --git a/src/test/kotlin/io/github/nomisrev/routes/TagRouteSpec.kt b/src/test/kotlin/io/github/nomisrev/routes/TagRouteSpec.kt index 27abde60..a7cbc23c 100644 --- a/src/test/kotlin/io/github/nomisrev/routes/TagRouteSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/routes/TagRouteSpec.kt @@ -1,11 +1,13 @@ package io.github.nomisrev.routes -import arrow.core.flatMap +import arrow.core.raise.either import io.github.nefilim.kjwt.JWSHMAC512Algorithm import io.github.nefilim.kjwt.JWT import io.github.nomisrev.repo.UserId import io.github.nomisrev.service.CreateArticle import io.github.nomisrev.service.RegisterUser +import io.github.nomisrev.service.createArticle +import io.github.nomisrev.service.register import io.github.nomisrev.withServer import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.assertions.arrow.core.shouldBeSome @@ -40,24 +42,25 @@ class TagRouteSpec : } "Can get all tags" { - withServer { dependencies -> - val userId = - dependencies.userService - .register(RegisterUser(validUsername, validEmail, validPw)) - .flatMap { JWT.decodeT(it.value, JWSHMAC512Algorithm) } - .map { it.claimValueAsLong("id").shouldBeSome() } + withServer { + either { + val token = + register(RegisterUser(validUsername, validEmail, validPw)) + + val userId = JWT.decodeT(token.value, JWSHMAC512Algorithm) .shouldBeRight() + .claimValueAsLong("id") + .shouldBeSome() - dependencies.articleService - .createArticle( + createArticle( CreateArticle(UserId(userId), validTitle, validDescription, validBody, validTags) ) - .shouldBeRight() - val response = get(TagsResource()) { contentType(ContentType.Application.Json) } + val response = get(TagsResource()) { contentType(ContentType.Application.Json) } - response.status shouldBe HttpStatusCode.OK - response.body().tags shouldHaveSize 4 + response.status shouldBe HttpStatusCode.OK + response.body().tags shouldHaveSize 4 + } } } }) diff --git a/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt b/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt index bbfadb6e..d8ed318e 100644 --- a/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/routes/UserRouteSpec.kt @@ -1,6 +1,11 @@ package io.github.nomisrev.routes +import arrow.core.raise.either +import io.github.nomisrev.auth.JwtToken +import io.github.nomisrev.env.Env +import io.github.nomisrev.repo.UserPersistence import io.github.nomisrev.service.RegisterUser +import io.github.nomisrev.service.register import io.github.nomisrev.withServer import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.core.spec.style.StringSpec @@ -15,24 +20,29 @@ import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType -class UserRouteSpec : - StringSpec({ - val validUsername = "username" - val validEmail = "valid@domain.com" - val validPw = "123456789" +class UserRouteSpec : StringSpec() { + val username = "username" + val email = "valid@domain.com" + val password = "123456789" + context(Env.Auth, UserPersistence) + suspend fun registerUser(): JwtToken = either { + register(RegisterUser(username, email, password)) + }.shouldBeRight() + + init { "Can register user" { withServer { val response = post(UsersResource()) { contentType(ContentType.Application.Json) - setBody(UserWrapper(NewUser(validUsername, validEmail, validPw))) + setBody(UserWrapper(NewUser(username, email, password))) } response.status shouldBe HttpStatusCode.Created with(response.body>().user) { - username shouldBe validUsername - email shouldBe validEmail + username shouldBe username + email shouldBe email bio shouldBe "" image shouldBe "" } @@ -40,21 +50,19 @@ class UserRouteSpec : } "Can log in a registered user" { - withServer { dependencies -> - dependencies.userService - .register(RegisterUser(validUsername, validEmail, validPw)) - .shouldBeRight() + withServer { + registerUser() val response = post(UsersResource.Login()) { contentType(ContentType.Application.Json) - setBody(UserWrapper(LoginUser(validEmail, validPw))) + setBody(UserWrapper(LoginUser(email, password))) } response.status shouldBe HttpStatusCode.OK with(response.body>().user) { - username shouldBe validUsername - email shouldBe validEmail + username shouldBe username + email shouldBe email bio shouldBe "" image shouldBe "" } @@ -62,18 +70,15 @@ class UserRouteSpec : } "Can get current user" { - withServer { dependencies -> - val expected = - dependencies.userService - .register(RegisterUser(validUsername, validEmail, validPw)) - .shouldBeRight() + withServer { + val expected = registerUser() val response = get(UserResource()) { bearerAuth(expected.value) } response.status shouldBe HttpStatusCode.OK with(response.body>().user) { - username shouldBe validUsername - email shouldBe validEmail + username shouldBe username + email shouldBe email token shouldBe expected.value bio shouldBe "" image shouldBe "" @@ -82,11 +87,8 @@ class UserRouteSpec : } "Update user" { - withServer { dependencies -> - val expected = - dependencies.userService - .register(RegisterUser(validUsername, validEmail, validPw)) - .shouldBeRight() + withServer { + val expected = registerUser() val newUsername = "newUsername" val response = @@ -99,7 +101,7 @@ class UserRouteSpec : response.status shouldBe HttpStatusCode.OK with(response.body>().user) { username shouldBe newUsername - email shouldBe validEmail + email shouldBe email token shouldBe expected.value bio shouldBe "" image shouldBe "" @@ -108,23 +110,21 @@ class UserRouteSpec : } "Update user invalid email" { - withServer { dependencies -> - val token = - dependencies.userService - .register(RegisterUser(validUsername, validEmail, validPw)) - .shouldBeRight() - val inalidEmail = "invalidEmail" + withServer { + val token = registerUser() + val invalid = "invalidEmail" val response = put(UserResource()) { 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") } } - }) + } +} diff --git a/src/test/kotlin/io/github/nomisrev/service/ArticleServiceSpec.kt b/src/test/kotlin/io/github/nomisrev/service/ArticleServiceSpec.kt index 7fbc121c..b563c440 100644 --- a/src/test/kotlin/io/github/nomisrev/service/ArticleServiceSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/service/ArticleServiceSpec.kt @@ -1,33 +1,27 @@ package io.github.nomisrev.service import arrow.core.Either -import arrow.core.flatMap +import arrow.core.raise.Raise +import arrow.core.raise.either import io.github.nefilim.kjwt.JWSHMAC512Algorithm import io.github.nefilim.kjwt.JWT import io.github.nomisrev.* import io.github.nomisrev.auth.JwtToken +import io.github.nomisrev.repo.ArticlePersistence +import io.github.nomisrev.repo.FavouritePersistence +import io.github.nomisrev.repo.TagPersistence import io.github.nomisrev.repo.UserId +import io.github.nomisrev.repo.UserPersistence import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.assertions.arrow.core.shouldBeSome import io.kotest.matchers.ints.shouldBeExactly class ArticleServiceSpec : SuspendFun({ - val articleService: ArticleService = KotestProject.dependencies.get().articleService - val userService: UserService = KotestProject.dependencies.get().userService - - // User val kaavehUsername = "kaaveh" - val kaavehEmail = "kaaveh@domain.com" - val kaavehPw = "123456789" val simonUsername = "simon" - val simonEmail = "simon@domain.com" - val simonPw = "123456789" val johnUsername = "john" - val johnEmail = "john@domain.com" - val johnPw = "123456789" - // Article val validTags = setOf("arrow", "ktor", "kotlin", "sqldelight") val validTitle = "Fake Article Arrow " val validDescription = "This is a fake article description." @@ -36,75 +30,61 @@ class ArticleServiceSpec : "getUserFeed" - { "get empty kaaveh's feed when he followed nobody" { - // Create users - val kaavehId = - userService - .register(RegisterUser(kaavehUsername, kaavehEmail, kaavehPw)) - .shouldHaveUserId() - val simonId = - userService - .register(RegisterUser(simonUsername, simonEmail, simonPw)) - .shouldHaveUserId() - val johnId = - userService.register(RegisterUser(johnUsername, johnEmail, johnPw)).shouldHaveUserId() + val kaavehId = registerUser(kaavehUsername).shouldHaveUserId() + val simonId = registerUser(simonUsername).shouldHaveUserId() + val johnId = registerUser(johnUsername).shouldHaveUserId() - // Create some articles - articleService - .createArticle( + userService { + createArticle( CreateArticle(UserId(simonId), validTitle, validDescription, validBody, validTags) ) - .shouldBeRight() - articleService - .createArticle( + createArticle( CreateArticle(UserId(johnId), validTitle, validDescription, validBody, validTags) ) - .shouldBeRight() - // Get Kaaveh's feed - val feed = - articleService.getUserFeed( + getUserFeed( input = GetFeed(userId = UserId(kaavehId), limit = 20, offset = 0) - ) + ).articlesCount shouldBeExactly 0 + } - feed.articlesCount shouldBeExactly 0 } "get kaaveh's feed when he followed simon" { - // Create users - val kaavehId = - userService - .register(RegisterUser(kaavehUsername, kaavehEmail, kaavehPw)) - .flatMap { JWT.decodeT(it.value, JWSHMAC512Algorithm) } - .map { it.claimValueAsLong("id").shouldBeSome() } - .shouldBeRight() - val simonId = - userService - .register(RegisterUser(simonUsername, simonEmail, simonPw)) - .flatMap { JWT.decodeT(it.value, JWSHMAC512Algorithm) } - .map { it.claimValueAsLong("id").shouldBeSome() } - .shouldBeRight() - val johnId = - userService - .register(RegisterUser(johnUsername, johnEmail, johnPw)) - .flatMap { JWT.decodeT(it.value, JWSHMAC512Algorithm) } - .map { it.claimValueAsLong("id").shouldBeSome() } - .shouldBeRight() + val simonId = registerUser(simonUsername).shouldHaveUserId() + val johnId = registerUser(johnUsername).shouldHaveUserId() - // Create some articles - articleService - .createArticle( + userService { + createArticle( CreateArticle(UserId(simonId), validTitle, validDescription, validBody, validTags) ) - .shouldBeRight() - articleService - .createArticle( + createArticle( CreateArticle(UserId(johnId), validTitle, validDescription, validBody, validTags) ) - .shouldBeRight() + }.shouldBeRight() } } }) -fun Either.shouldHaveUserId() = - flatMap { JWT.decodeT(it.value, JWSHMAC512Algorithm) } +private suspend fun userService( + block: suspend context( + Raise, + SlugGenerator, + ArticlePersistence, + TagPersistence, + FavouritePersistence, + UserPersistence + ) () -> A +): Either = either { + block( + this, + slugifyGenerator(), + KotestProject.dependencies.get().articlePersistence, + KotestProject.dependencies.get().tagPersistence, + KotestProject.dependencies.get().favouritePersistence, + KotestProject.dependencies.get().userPersistence, + ) +} + +fun JwtToken.shouldHaveUserId(): Long = + JWT.decodeT(value, JWSHMAC512Algorithm) .map { it.claimValueAsLong("id").shouldBeSome() } .shouldBeRight() diff --git a/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt b/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt index a37381aa..45fa2004 100644 --- a/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt +++ b/src/test/kotlin/io/github/nomisrev/service/UserServiceSpec.kt @@ -1,5 +1,6 @@ package io.github.nomisrev.service +import arrow.core.raise.either import arrow.core.nonEmptyListOf import io.github.nefilim.kjwt.JWSHMAC512Algorithm import io.github.nefilim.kjwt.JWT @@ -8,147 +9,150 @@ import io.github.nomisrev.IncorrectInput import io.github.nomisrev.InvalidEmail import io.github.nomisrev.InvalidPassword import io.github.nomisrev.InvalidUsername -import io.github.nomisrev.KotestProject import io.github.nomisrev.SuspendFun import io.github.nomisrev.UsernameAlreadyExists import io.github.nomisrev.auth.JwtToken import io.github.nomisrev.repo.UserId +import io.github.nomisrev.withDependencies import io.kotest.assertions.arrow.core.shouldBeLeft import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.assertions.arrow.core.shouldBeSome -import io.kotest.matchers.string.shouldNotBeBlank +import io.kotest.matchers.shouldBe class UserServiceSpec : SuspendFun({ - val userService: UserService = KotestProject.dependencies.get().userService - val validUsername = "username" val validEmail = "valid@domain.com" val validPw = "123456789" - "register" - - { + "register" - { + withDependencies { "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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + register(RegisterUser(validUsername, validEmail, password)) + } shouldBeLeft expected } "All valid returns a token" { - userService.register(RegisterUser(validUsername, validEmail, validPw)).shouldBeRight() + either { + 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 { + register(RegisterUser(validUsername, validEmail, validPw)) + register(RegisterUser(validUsername, validEmail, validPw)) + } shouldBeLeft UsernameAlreadyExists(validUsername) } } + } - "login" - - { + "login" - { + withDependencies { "email cannot be empty" { - val res = userService.login(Login("", validPw)) val errors = nonEmptyListOf("Cannot be blank", "'' is invalid email") val expected = IncorrectInput(InvalidEmail(errors)) - res shouldBeLeft expected + either { login(Login("", validPw)) } shouldBeLeft expected } "email too long" { val email = "${(0..340).joinToString("") { "A" }}@domain.com" - val res = userService.login(Login(email, validPw)) val errors = nonEmptyListOf("is too long (maximum is 350 characters)") val expected = IncorrectInput(InvalidEmail(errors)) - res shouldBeLeft expected + either { login(Login(email, validPw)) } shouldBeLeft expected } "email is not valid" { val email = "AAAA" - val res = userService.login(Login(email, validPw)) val errors = nonEmptyListOf("'$email' is invalid email") val expected = IncorrectInput(InvalidEmail(errors)) - res shouldBeLeft expected + either { login(Login(email, validPw)) } shouldBeLeft expected } "password cannot be empty" { - val res = userService.login(Login(validEmail, "")) val errors = nonEmptyListOf("Cannot be blank", "is too short (minimum is 8 characters)") val expected = IncorrectInput(InvalidPassword(errors)) - res shouldBeLeft expected + either { login(Login(validEmail, "")) } shouldBeLeft expected } "password can be max 100" { val password = (0..100).joinToString("") { "A" } - val res = userService.login(Login(validEmail, password)) val errors = nonEmptyListOf("is too long (maximum is 100 characters)") val expected = IncorrectInput(InvalidPassword(errors)) - res shouldBeLeft expected - } - - "All valid returns a token" { - userService.register(RegisterUser(validUsername, validEmail, validPw)) - val token = userService.login(Login(validEmail, validPw)).shouldBeRight() - token.first.value.shouldNotBeBlank() + either { login(Login(validEmail, password)) } shouldBeLeft expected } } + } - "update" - - { + "update" - { + withDependencies { "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 { + register(RegisterUser(validUsername, validEmail, validPw)) + }.shouldBeRight() + + either { + 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 =