Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Post article comment #205

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/kotlin/io/github/nomisrev/env/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ 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 articleRepo =
articleRepo(sqlDelight.articlesQueries, sqlDelight.commentsQueries, sqlDelight.tagsQueries)
val tagPersistence = tagPersistence(sqlDelight.tagsQueries)
val favouritePersistence = favouritePersistence(sqlDelight.favoritesQueries)
val jwtService = jwtService(env.auth, userRepo)
Expand Down
40 changes: 39 additions & 1 deletion src/main/kotlin/io/github/nomisrev/repo/ArticlePersistence.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import io.github.nomisrev.routes.Profile
import io.github.nomisrev.service.Slug
import io.github.nomisrev.sqldelight.Articles
import io.github.nomisrev.sqldelight.ArticlesQueries
import io.github.nomisrev.sqldelight.Comments
import io.github.nomisrev.sqldelight.CommentsQueries
import io.github.nomisrev.sqldelight.TagsQueries
import java.time.OffsetDateTime

Expand All @@ -37,9 +39,17 @@ interface ArticlePersistence {
suspend fun getFeed(userId: UserId, limit: FeedLimit, offset: FeedOffset): List<Article>

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

suspend fun insertCommentForArticleSlug(
slug: Slug,
userId: UserId,
comment: String,
articleId: ArticleId,
createdAt: OffsetDateTime,
): Comments
}

fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) =
fun articleRepo(articles: ArticlesQueries, comments: CommentsQueries, tagsQueries: TagsQueries) =
object : ArticlePersistence {
override suspend fun create(
authorId: UserId,
Expand Down Expand Up @@ -108,4 +118,32 @@ fun articleRepo(articles: ArticlesQueries, tagsQueries: TagsQueries) =
val article = articles.selectBySlug(slug.value).executeAsOneOrNull()
ensureNotNull(article) { ArticleBySlugNotFound(slug.value) }
}

override suspend fun insertCommentForArticleSlug(
slug: Slug,
userId: UserId,
comment: String,
articleId: ArticleId,
createdAt: OffsetDateTime,
) =
comments.transactionWithResult {
comments
.insertAndGetComment(
article_id = articleId.serial,
body = comment,
author = userId.serial,
createdAt = createdAt,
updatedAt = createdAt
) { id, article_id, body, author, createdAt, updatedAt ->
Comments(
id = id,
body = body,
author = author,
createdAt = createdAt,
updatedAt = updatedAt,
article_id = article_id
)
}
.executeAsOne()
}
}
47 changes: 47 additions & 0 deletions src/main/kotlin/io/github/nomisrev/routes/articles.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package io.github.nomisrev.routes

import arrow.core.raise.either
import io.github.nomisrev.auth.jwtAuth
import io.github.nomisrev.repo.UserId
import io.github.nomisrev.service.ArticleService
import io.github.nomisrev.service.CreateArticle
import io.github.nomisrev.service.JwtService
import io.github.nomisrev.service.Slug
import io.github.nomisrev.service.UserService
import io.github.nomisrev.validate
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
Expand Down Expand Up @@ -52,6 +54,10 @@ data class MultipleArticlesResponse(

@JvmInline @Serializable value class FeedLimit(val limit: Int)

@Serializable data class NewComment(val body: String)

@Serializable data class SingleCommentResponse(val comment: Comment)

@Serializable
data class Comment(
val commentId: Long,
Expand Down Expand Up @@ -97,6 +103,9 @@ data class ArticleResource(val parent: RootResource = RootResource) {
data class ArticlesResource(val parent: RootResource = RootResource) {
@Resource("{slug}")
data class Slug(val parent: ArticlesResource = ArticlesResource(), val slug: String)

@Resource("{slug}/comments")
data class Comments(val parent: ArticlesResource = ArticlesResource(), val slug: String)
}

fun Route.articleRoutes(
Expand Down Expand Up @@ -159,6 +168,44 @@ fun Route.articleRoutes(
}
}

fun Route.commentRoutes(
userService: UserService,
articleService: ArticleService,
jwtService: JwtService
) {
post<ArticlesResource.Comments> { slug ->
jwtAuth(jwtService) { (_, userId) ->
either {
val comments =
articleService
.insertCommentForArticleSlug(
slug = Slug(slug.slug),
userId = userId,
comment = call.receive<NewComment>().validate().bind().body,
)
.bind()
val userProfile = userService.getUser(UserId(comments.author)).bind()
SingleCommentResponse(
Comment(
commentId = comments.id,
createdAt = comments.createdAt,
updatedAt = comments.updatedAt,
body = comments.body,
author =
Profile(
username = userProfile.username,
bio = userProfile.bio,
image = userProfile.image,
following = false
)
)
)
}
.respond(HttpStatusCode.OK)
}
}
}

private object OffsetDateTimeIso8601Serializer : KSerializer<OffsetDateTime> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING)
Expand Down
1 change: 1 addition & 0 deletions src/main/kotlin/io/github/nomisrev/routes/root.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ fun Application.routes(deps: Dependencies) = routing {
userRoutes(deps.userService, deps.jwtService)
tagRoutes(deps.tagPersistence)
articleRoutes(deps.articleService, deps.jwtService)
commentRoutes(deps.userService, deps.articleService, deps.jwtService)
profileRoutes(deps.userPersistence, deps.jwtService)
}

Expand Down
20 changes: 20 additions & 0 deletions src/main/kotlin/io/github/nomisrev/service/ArticleService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.github.nomisrev.service
import arrow.core.Either
import arrow.core.raise.either
import io.github.nomisrev.DomainError
import io.github.nomisrev.repo.ArticleId
import io.github.nomisrev.repo.ArticlePersistence
import io.github.nomisrev.repo.FavouritePersistence
import io.github.nomisrev.repo.TagPersistence
Expand All @@ -13,6 +14,7 @@ import io.github.nomisrev.routes.FeedLimit
import io.github.nomisrev.routes.FeedOffset
import io.github.nomisrev.routes.MultipleArticlesResponse
import io.github.nomisrev.routes.Profile
import io.github.nomisrev.sqldelight.Comments
import java.time.OffsetDateTime

data class CreateArticle(
Expand All @@ -38,6 +40,12 @@ interface ArticleService {

/** Get article by Slug */
suspend fun getArticleBySlug(slug: Slug): Either<DomainError, Article>

suspend fun insertCommentForArticleSlug(
slug: Slug,
userId: UserId,
comment: String
): Either<DomainError, Comments>
}

fun articleService(
Expand Down Expand Up @@ -117,4 +125,16 @@ fun articleService(
articleTags
)
}

override suspend fun insertCommentForArticleSlug(slug: Slug, userId: UserId, comment: String) =
either {
val article = getArticleBySlug(slug).bind()
articlePersistence.insertCommentForArticleSlug(
slug,
userId,
comment,
ArticleId(article.articleId),
OffsetDateTime.now()
)
}
}
4 changes: 4 additions & 0 deletions src/main/kotlin/io/github/nomisrev/validation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import io.github.nomisrev.routes.ArticleResource
import io.github.nomisrev.routes.FeedLimit
import io.github.nomisrev.routes.FeedOffset
import io.github.nomisrev.routes.NewArticle
import io.github.nomisrev.routes.NewComment
import io.github.nomisrev.service.GetFeed
import io.github.nomisrev.service.Login
import io.github.nomisrev.service.RegisterUser
Expand Down Expand Up @@ -163,6 +164,9 @@ fun NewArticle.validate(): Either<IncorrectInput, NewArticle> =
)
.mapLeft(::IncorrectInput)

fun NewComment.validate(): Either<IncorrectInput, NewComment> =
body.validBody().map { NewComment(it) }.mapLeft(::IncorrectInput)

const val MIN_FEED_LIMIT = 1
const val MIN_FEED_OFFSET = 0

Expand Down
5 changes: 3 additions & 2 deletions src/main/sqldelight/io/github/nomisrev/sqldelight/Comments.sq
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ CREATE TABLE IF NOT EXISTS comments(
updatedAt VARCHAR(50) AS OffsetDateTime NOT NULL
);

insert:
insertAndGetComment:
INSERT INTO comments(article_id, body, author, createdAt, updatedAt)
VALUES (:article_id, :body, :author, :createdAt, :updatedAt);
VALUES (:article_id, :body, :author, :createdAt, :updatedAt)
RETURNING id, article_id, body, author, createdAt, updatedAt;

selectByArticleId:
SELECT id, article_id, body, author, createdAt, updatedAt
Expand Down
111 changes: 99 additions & 12 deletions src/test/kotlin/io/github/nomisrev/routes/ArticlesRouteSpec.kt
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
package io.github.nomisrev.routes

import arrow.core.flatMap
import io.github.nefilim.kjwt.JWSHMAC512Algorithm
import io.github.nefilim.kjwt.JWT
import io.github.nomisrev.KotestProject
import io.github.nomisrev.auth.JwtToken
import io.github.nomisrev.repo.UserId
import io.github.nomisrev.service.CreateArticle
import io.github.nomisrev.service.Login
import io.github.nomisrev.service.RegisterUser
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.ktor.client.call.body
import io.ktor.client.plugins.resources.get
import io.ktor.client.plugins.resources.post
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlin.properties.Delegates

class ArticlesRouteSpec :
StringSpec({
Expand All @@ -26,6 +31,28 @@ class ArticlesRouteSpec :
val validDescription = "This is a fake article description."
val validBody = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."

var token: JwtToken by Delegates.notNull()
var userId: UserId by Delegates.notNull()

beforeAny {
KotestProject.dependencies
.get()
.userService
.register(RegisterUser(validUsername, validEmail, validPw))
.shouldBeRight()
}

beforeTest {
token =
KotestProject.dependencies
.get()
.userService
.login(Login(validEmail, validPw))
.shouldBeRight()
.first
userId = KotestProject.dependencies.get().jwtService.verifyJwtToken(token).shouldBeRight()
}

"Article by slug not found" {
withServer {
val response = get(ArticlesResource.Slug(slug = "slug"))
Expand All @@ -39,17 +66,10 @@ 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)
CreateArticle(userId, validTitle, validDescription, validBody, validTags)
)
.shouldBeRight()

Expand All @@ -59,4 +79,71 @@ class ArticlesRouteSpec :
assert(response.body<SingleArticleResponse>().article == article)
}
}

"Can add a comment to an article" {
withServer { dependencies ->
val comment = "This is a comment"
val article =
dependencies.articleService
.createArticle(
CreateArticle(userId, validTitle, validDescription, validBody, validTags)
)
.shouldBeRight()

val response =
post(ArticlesResource.Comments(slug = article.slug)) {
contentType(ContentType.Application.Json)
bearerAuth(token.value)
setBody(NewComment(comment))
}

assert(response.status == HttpStatusCode.OK)
with(response.body<SingleCommentResponse>()) {
assert(this.comment.body == comment)
assert(this.comment.author.username == validUsername)
}
}
}

"Can not add a comment to an article with invalid token" {
withServer { dependencies ->
val comment = "This is a comment"
val article =
dependencies.articleService
.createArticle(
CreateArticle(userId, validTitle, validDescription, validBody, validTags)
)
.shouldBeRight()

val response =
post(ArticlesResource.Comments(slug = article.slug)) {
contentType(ContentType.Application.Json)
bearerAuth("invalid token")
setBody(NewComment(comment))
}

assert(response.status == HttpStatusCode.Unauthorized)
}
}

"Can not add a comment to an article with empty body" {
withServer { dependencies ->
val comment = ""
val article =
dependencies.articleService
.createArticle(
CreateArticle(userId, validTitle, validDescription, validBody, validTags)
)
.shouldBeRight()

val response =
post(ArticlesResource.Comments(slug = article.slug)) {
contentType(ContentType.Application.Json)
bearerAuth(token.value)
setBody(NewComment(comment))
}

assert(response.status == HttpStatusCode.UnprocessableEntity)
}
}
})