Skip to content

Commit

Permalink
Add article listing functionality with integration tests
Browse files Browse the repository at this point in the history
  • Loading branch information
gunkim committed Feb 14, 2025
1 parent 0bffad1 commit ee6f0e2
Show file tree
Hide file tree
Showing 15 changed files with 302 additions and 5 deletions.
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ dependencies {
testImplementation("org.springframework.security:spring-security-test")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
testImplementation("io.mockk:mockk:1.13.12")

implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2")
}

tasks.withType<KotlinCompile> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package io.github.gunkim.realworld.config

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class ObjectMapperConfig {
@Bean
fun objectMapper() = ObjectMapper().registerKotlinModule()
fun objectMapper() = ObjectMapper().apply {
registerKotlinModule()
registerModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,13 @@ class SecurityConfiguration(
* @param it The AuthorizationManagerRequestMatcherRegistry instance to configure
*/
private fun configureAuthorization(it: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry) {
// users
it.requestMatchers("/api/users/**").permitAll()
it.requestMatchers(HttpMethod.GET, "/api/profiles/**").permitAll()

// articles
it.requestMatchers("/api/articles/**").permitAll()

// H2 Console
it.requestMatchers(PathRequest.toH2Console()).permitAll()
it.anyRequest().authenticated()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import java.util.UUID

interface Article : Editable<Article>, DateAuditable {
val uuid: UUID
val slug: String
val title: String
val description: String
val body: String
Expand All @@ -18,12 +19,55 @@ interface Article : Editable<Article>, DateAuditable {
override fun edit(): Article = this

interface Editor : Article {
override var slug: String
override var title: String
override var description: String
override var body: String

override fun edit(): Article = this
}

companion object {
class Model(
override val uuid: UUID,
override var slug: String,
override var title: String,
override var description: String,
override var body: String,
override val author: User,
override val comments: List<Comment>,
override val tags: List<Tag>,
override val createdAt: Instant,
override val updatedAt: Instant,
) : Editor

fun create(
uuid: UUID = UUID.randomUUID(),
slug: String,
title: String,
description: String,
body: String,
author: User,
comments: List<Comment> = listOf(),
tags: List<Tag> = listOf(),
createdAt: Instant? = null,
): Article {
val now = Instant.now()

return Model(
uuid = uuid,
slug = slug,
title = title,
description = description,
body = body,
author = author,
comments = comments,
tags = tags,
createdAt = createdAt ?: now,
updatedAt = now
)
}
}
}

interface Comment : Editable<Comment>, DateAuditable {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.github.gunkim.realworld.domain.article.service

import io.github.gunkim.realworld.domain.article.ArticleCountProjection
import io.github.gunkim.realworld.domain.article.ArticleReadRepository
import java.util.UUID
import org.springframework.stereotype.Service

@Service
class FavoriteArticleService(
private val articleReadRepository: ArticleReadRepository,
) {
fun getFavoritesCount(articleUuids: List<UUID>): List<ArticleCountProjection> =
articleReadRepository.getCountAllByArticleUuids(articleUuids)

fun getFavoritesArticles(userUuid: UUID): List<UUID> = articleReadRepository.getFavoritesArticles(userUuid)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.github.gunkim.realworld.domain.article.service

import io.github.gunkim.realworld.domain.article.Article
import io.github.gunkim.realworld.domain.article.ArticleReadRepository
import org.springframework.stereotype.Service

@Service
class GetArticleService(
private val articleReadRepository: ArticleReadRepository,
) {
fun getArticles(
tag: String?,
author: String?,
limit: Int = 20,
offset: Int = 0,
): List<Article> {
return articleReadRepository.find(
tag = tag,
author = author,
limit = limit,
offset = offset
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ class FollowUserService(

return userRepository.existsFollowingIdAndFollowerUsername(uuid, targetUser.uuid)
}

fun getFollowingUserUuids(userUuid: UUID): List<UUID> = userRepository.findFollowingUserUuids(userUuid)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import java.util.UUID
class ArticleJpaEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val articleId: Int?,
val articleId: Int? = null,
override val uuid: UUID,
slug: String,
title: String,
description: String,
body: String,
Expand All @@ -30,10 +31,15 @@ class ArticleJpaEntity(
@JoinColumn(name = "articleId", nullable = false)
override val tags: List<TagJpaEntity> = listOf(),
@OneToMany(fetch = FetchType.LAZY, mappedBy = "article")
override val comments: List<CommentJpaEntity>,
override val comments: List<CommentJpaEntity> = listOf(),
override val createdAt: Instant,
override var updatedAt: Instant = Instant.now(),
) : Article.Editor {
override var slug: String = slug
set(value) {
field = value
updatedAt = Instant.now()
}
override var title: String = title
set(value) {
field = value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@ import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.ManyToOne
import java.time.Instant

//TODO : Refactoring
@Entity(name = "tag")
class TagJpaEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val tagId: Int?,
val tagId: Int? = null,
override val name: String,
override val createdAt: Instant = Instant.now(),
override val updatedAt: Instant = createdAt,
) : Tag {
@ManyToOne
val article: ArticleJpaEntity? = null

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package io.github.gunkim.realworld.web.api.article

import io.github.gunkim.realworld.domain.article.Article
import io.github.gunkim.realworld.domain.article.service.FavoriteArticleService
import io.github.gunkim.realworld.domain.article.service.GetArticleService
import io.github.gunkim.realworld.domain.user.service.FollowUserService
import io.github.gunkim.realworld.share.AuthenticatedUser
import io.github.gunkim.realworld.web.api.article.model.request.GetArticlesRequest
import io.github.gunkim.realworld.web.api.article.model.response.ArticleResponse
import io.github.gunkim.realworld.web.api.article.model.response.ArticlesResponse
import java.util.UUID
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RequestMapping("/api/articles")
interface ArticleResource {
@GetMapping
fun getArticles(
request: GetArticlesRequest,
@AuthenticationPrincipal authenticatedUser: AuthenticatedUser?,
): ArticlesResponse
}

@RestController
class ArticlesController(
private val getArticleService: GetArticleService,
private val favoriteArticleService: FavoriteArticleService,
private val followUserService: FollowUserService,
) : ArticleResource {
override fun getArticles(
request: GetArticlesRequest,
authenticatedUser: AuthenticatedUser?,
): ArticlesResponse {
val articles = getArticleService.getArticles(
tag = request.tag,
author = request.author,
offset = request.offset,
limit = request.limit,
)
return articlesResponse(articles, authenticatedUser)
}

private fun articlesResponse(
articles: List<Article>,
authenticatedUser: AuthenticatedUser?,
): ArticlesResponse {
val articleUuids = articles.map(Article::uuid)
val favoritesCountMap = if (articleUuids.isEmpty()) {
emptyList()
} else {
favoriteArticleService.getFavoritesCount(articleUuids)
}
val (favoritedArticleUuids, followingUserUuids) = getUserContext(authenticatedUser)

return ArticlesResponse(articles.map { article ->
ArticleResponse.from(
article,
favoritesCountMap.firstOrNull { it.getUuid() == article.uuid }?.getCount() ?: 0,
favoritedArticleUuids.contains(article.uuid),
followingUserUuids.contains(article.author.uuid)
)
})
}

private fun getUserContext(authenticatedUser: AuthenticatedUser?): Pair<List<UUID>, List<UUID>> {
return if (authenticatedUser != null) {
val userUuid = authenticatedUser.uuid
val favoritedArticleUuids = favoriteArticleService.getFavoritesArticles(userUuid)
val followingUserUuids = followUserService.getFollowingUserUuids(userUuid)
favoritedArticleUuids to followingUserUuids
} else {
emptyList<UUID>() to emptyList()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.github.gunkim.realworld.web.api.article.model.request

data class GetArticlesRequest(
val tag: String?,
val author: String?,
val favorited: String?,
val limit: Int = 20,
val offset: Int = 0,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package io.github.gunkim.realworld.web.api.article.model.response

import com.fasterxml.jackson.annotation.JsonTypeInfo
import io.github.gunkim.realworld.domain.article.Article
import io.github.gunkim.realworld.web.api.user.model.response.ProfileResponse
import java.time.Instant

data class ArticleResponse(
val slug: String,
val title: String,
val description: String,
val body: String,
val tagList: List<String>,
val createdAt: Instant,
val updatedAt: Instant,
val favorited: Boolean,
val favoritesCount: Int,
@JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
val author: ProfileResponse,
) {
companion object {
fun from(article: Article, favoritesCount: Int, favorited: Boolean, authorFollowing: Boolean) = ArticleResponse(
slug = article.slug,
title = article.title,
description = article.description,
body = article.body,
tagList = article.tags.map { it.name },
createdAt = article.createdAt,
updatedAt = article.updatedAt,
favorited = favorited,
favoritesCount = favoritesCount,
author = ProfileResponse.of(
article.author,
authorFollowing
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.github.gunkim.realworld.web.api.article.model.response

data class ArticlesResponse(
val articles: List<ArticleResponse>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.github.gunkim.realworld.domain.user.service.UpdateUserService
import io.github.gunkim.realworld.share.AuthenticatedUser
import io.github.gunkim.realworld.web.api.user.model.request.UserUpdateRequest
import io.github.gunkim.realworld.web.api.user.model.response.UserResponse
import java.util.UUID
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping
Expand Down Expand Up @@ -42,4 +43,4 @@ class UserController(
val token = authenticationService.createToken(updatedUser.uuid)
return UserResponse.from(updatedUser, token)
}
}
}
Loading

0 comments on commit ee6f0e2

Please sign in to comment.