feat: リアクションのAPIを作成

This commit is contained in:
usbharu 2023-08-02 11:05:00 +09:00
parent e340d68dc0
commit 105b8d520a
14 changed files with 118 additions and 17 deletions

View File

@ -25,6 +25,7 @@ import dev.usbharu.hideout.service.core.IdGenerateService
import dev.usbharu.hideout.service.core.TwitterSnowflakeIdGenerateService
import dev.usbharu.hideout.service.job.JobQueueParentService
import dev.usbharu.hideout.service.job.KJobJobQueueParentService
import dev.usbharu.hideout.service.reaction.IReactionService
import dev.usbharu.hideout.service.user.IUserAuthService
import dev.usbharu.hideout.service.user.IUserService
import dev.usbharu.kjob.exposed.ExposedKJob
@ -115,6 +116,7 @@ fun Application.parent() {
activityPubUserService = inject<ActivityPubUserService>().value,
postService = inject<IPostApiService>().value,
userApiService = inject<IUserApiService>().value,
reactionService = inject<IReactionService>().value
)
}

View File

@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.JsonNode
import dev.usbharu.hideout.service.activitypub.ExtendedActivityVocabulary
class ObjectDeserializer : JsonDeserializer<Object>() {
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Object {
requireNotNull(p)
val treeNode: JsonNode = requireNotNull(p.codec?.readTree(p))

View File

@ -0,0 +1,10 @@
package dev.usbharu.hideout.domain.model.hideout.dto
data class ReactionResponse(
val reaction: String,
val isUnicodeEmoji: Boolean = true,
val iconUrl: String,
val accounts: List<Account>
)
data class Account(val screenName: String, val iconUrl: String, val url: String)

View File

@ -0,0 +1,3 @@
package dev.usbharu.hideout.domain.model.hideout.form
data class Reaction(val reaction: String?)

View File

@ -23,3 +23,7 @@ object DeliverReactionJob : HideoutJob("DeliverReactionJob") {
val inbox = string("inbox")
val id = string("id")
}
object DeliverRemoveReactionJob : HideoutJob("DeliverRemoveReactionJob") {
}

View File

@ -11,6 +11,7 @@ import dev.usbharu.hideout.service.activitypub.ActivityPubUserService
import dev.usbharu.hideout.service.api.IPostApiService
import dev.usbharu.hideout.service.api.IUserApiService
import dev.usbharu.hideout.service.auth.HttpSignatureVerifyService
import dev.usbharu.hideout.service.reaction.IReactionService
import dev.usbharu.hideout.service.user.IUserService
import io.ktor.server.application.*
import io.ktor.server.plugins.autohead.*
@ -23,7 +24,8 @@ fun Application.configureRouting(
userService: IUserService,
activityPubUserService: ActivityPubUserService,
postService: IPostApiService,
userApiService: IUserApiService
userApiService: IUserApiService,
reactionService: IReactionService
) {
install(AutoHeadResponse)
routing {
@ -32,7 +34,7 @@ fun Application.configureRouting(
usersAP(activityPubUserService, userService)
webfinger(userService)
route("/api/internal/v1") {
posts(postService)
posts(postService, reactionService)
users(userService, userApiService)
}
}

View File

@ -6,4 +6,10 @@ interface ReactionRepository {
suspend fun generateId(): Long
suspend fun save(reaction: Reaction): Reaction
suspend fun reactionAlreadyExist(postId: Long, userId: Long, emojiId: Long): Boolean
suspend fun findByPostId(postId: Long): List<Reaction>
suspend fun delete(reaction: Reaction):Reaction
suspend fun deleteById(id:Long)
suspend fun deleteByPostId(postId:Long)
suspend fun deleteByUserId(userId: Long)
suspend fun deleteByPostIdAndUserId(postId: Long,userId: Long)
}

View File

@ -57,6 +57,14 @@ class ReactionRepositoryImpl(
}.empty().not()
}
}
override suspend fun findByPostId(postId: Long): List<Reaction> {
return query {
Reactions.select {
Reactions.postId.eq(postId)
}.map { it.toReaction() }
}
}
}
fun ResultRow.toReaction(): Reaction {

View File

@ -1,9 +1,11 @@
package dev.usbharu.hideout.routing.api.internal.v1
import dev.usbharu.hideout.domain.model.hideout.form.Post
import dev.usbharu.hideout.domain.model.hideout.form.Reaction
import dev.usbharu.hideout.exception.ParameterNotExistException
import dev.usbharu.hideout.plugins.TOKEN_AUTH
import dev.usbharu.hideout.service.api.IPostApiService
import dev.usbharu.hideout.service.reaction.IReactionService
import dev.usbharu.hideout.util.InstantParseUtil
import io.ktor.http.*
import io.ktor.server.application.*
@ -14,7 +16,7 @@ import io.ktor.server.response.*
import io.ktor.server.routing.*
@Suppress("LongMethod")
fun Route.posts(postApiService: IPostApiService) {
fun Route.posts(postApiService: IPostApiService, reactionService: IReactionService) {
route("/posts") {
authenticate(TOKEN_AUTH) {
post {
@ -26,6 +28,37 @@ fun Route.posts(postApiService: IPostApiService) {
call.response.header("Location", create.url)
call.respond(HttpStatusCode.OK)
}
route("/{id}/reactions") {
get {
val principal = call.principal<JWTPrincipal>() ?: throw IllegalStateException("no principal")
val userId = principal.payload.getClaim("uid").asLong()
val postId = (
call.parameters["id"]?.toLong()
?: throw ParameterNotExistException("Parameter(id='postsId') does not exist.")
)
call.respond(reactionService.findByPostIdForUser(postId, userId))
}
post {
val jwtPrincipal = call.principal<JWTPrincipal>() ?: throw IllegalStateException("no principal")
val userId = jwtPrincipal.payload.getClaim("uid").asLong()
val postId = call.parameters["id"]?.toLong()
?: throw ParameterNotExistException("Parameter(id='postsId') does not exist.")
val reaction = try {
call.receive<Reaction>()
} catch (e: ContentTransformationException) {
Reaction(null)
}
reactionService.sendReaction(reaction.reaction ?: "", userId, postId)
}
delete {
val jwtPrincipal = call.principal<JWTPrincipal>() ?: throw IllegalStateException("no principal")
val userId = jwtPrincipal.payload.getClaim("uid").asLong()
val postId = call.parameters["id"]?.toLong()
?: throw ParameterNotExistException("Parameter(id='postsId') does not exist.")
reactionService.removeReaction(userId, postId)
}
}
}
authenticate(TOKEN_AUTH, optional = true) {
get {

View File

@ -2,9 +2,12 @@ package dev.usbharu.hideout.service.activitypub
import dev.usbharu.hideout.domain.model.hideout.entity.Reaction
import dev.usbharu.hideout.domain.model.job.DeliverReactionJob
import dev.usbharu.hideout.domain.model.job.DeliverRemoveReactionJob
import kjob.core.job.JobProps
interface ActivityPubReactionService {
suspend fun reaction(like: Reaction)
suspend fun removeReaction(like: Reaction)
suspend fun reactionJob(props: JobProps<DeliverReactionJob>)
suspend fun removeReactionJob(props: JobProps<DeliverRemoveReactionJob>)
}

View File

@ -1,6 +1,10 @@
package dev.usbharu.hideout.service.reaction
import dev.usbharu.hideout.domain.model.hideout.dto.ReactionResponse
interface IReactionService {
suspend fun receiveReaction(name: String, domain: String, userId: Long, postId: Long)
suspend fun sendReaction(name: String, userId: Long, postId: Long)
suspend fun removeReaction(userId: Long, postId: Long)
suspend fun findByPostIdForUser(postId: Long, userId: Long): List<ReactionResponse>
}

View File

@ -1,8 +1,16 @@
package dev.usbharu.hideout.service.reaction
import dev.usbharu.hideout.domain.model.hideout.dto.Account
import dev.usbharu.hideout.domain.model.hideout.dto.ReactionResponse
import dev.usbharu.hideout.domain.model.hideout.entity.Reaction
import dev.usbharu.hideout.repository.ReactionRepository
import dev.usbharu.hideout.repository.Reactions
import dev.usbharu.hideout.repository.Users
import dev.usbharu.hideout.service.activitypub.ActivityPubReactionService
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.leftJoin
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.koin.core.annotation.Single
@Single
@ -21,10 +29,27 @@ class ReactionServiceImpl(
override suspend fun sendReaction(name: String, userId: Long, postId: Long) {
if (reactionRepository.reactionAlreadyExist(postId, userId, 0)) {
// delete
reactionRepository.deleteByPostIdAndUserId(postId, userId)
} else {
val reaction = Reaction(reactionRepository.generateId(), 0, postId, userId)
reactionRepository.save(reaction)
activityPubReactionService.reaction(reaction)
}
}
override suspend fun removeReaction(userId: Long, postId: Long) {
reactionRepository.deleteByPostIdAndUserId(postId, userId)
}
override suspend fun findByPostIdForUser(postId: Long, userId: Long): List<ReactionResponse> {
return newSuspendedTransaction {
Reactions
.leftJoin(Users, onColumn = { Reactions.userId }, otherColumn = { id })
.select { Reactions.postId.eq(postId) }
.groupBy { resultRow: ResultRow -> ReactionResponse("", true, "", listOf()) }
.map { entry: Map.Entry<ReactionResponse, List<ResultRow>> ->
entry.key.copy(accounts = entry.value.map { Account(it[Users.screenName], "", it[Users.url]) })
}
}
}
}

View File

@ -67,7 +67,7 @@ class PostsTest {
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -140,7 +140,7 @@ class PostsTest {
configureSerialization()
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -172,7 +172,7 @@ class PostsTest {
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -215,7 +215,7 @@ class PostsTest {
}
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -264,7 +264,7 @@ class PostsTest {
}
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
configureSerialization()
@ -326,7 +326,7 @@ class PostsTest {
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -378,7 +378,7 @@ class PostsTest {
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -430,7 +430,7 @@ class PostsTest {
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -482,7 +482,7 @@ class PostsTest {
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -514,7 +514,7 @@ class PostsTest {
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -546,7 +546,7 @@ class PostsTest {
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -578,7 +578,7 @@ class PostsTest {
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}
@ -610,7 +610,7 @@ class PostsTest {
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
posts(postService, mock())
}
}
}

View File

@ -25,7 +25,7 @@ import org.mockito.kotlin.*
import utils.JsonObjectMapper
import java.time.Instant
import kotlin.test.assertEquals
@Suppress("LargeClass")
class UsersTest {
@Test
fun `users にGETするとユーザー一覧を取得できる`() = testApplication {