mirror of https://github.com/usbharu/Hideout.git
feat: エラーレスポンスを返せるように
This commit is contained in:
parent
7a22c9e3f0
commit
b33f62b656
|
@ -92,6 +92,7 @@ import java.security.interfaces.RSAPrivateKey
|
|||
import java.security.interfaces.RSAPublicKey
|
||||
import java.util.*
|
||||
|
||||
|
||||
@EnableWebSecurity(debug = false)
|
||||
@Configuration
|
||||
@Suppress("FunctionMaxLength", "TooManyFunctions", "LongMethod")
|
||||
|
@ -239,6 +240,7 @@ class SecurityConfig {
|
|||
|
||||
authorize(POST, "/api/v1/media", rf.hasScope("write:media"))
|
||||
authorize(POST, "/api/v1/statuses", rf.hasScope("write:statuses"))
|
||||
authorize(GET, "/api/v1/statuses/*", permitAll)
|
||||
|
||||
authorize(GET, "/api/v1/timelines/public", permitAll)
|
||||
authorize(GET, "/api/v1/timelines/home", rf.hasScope("read:statuses"))
|
||||
|
|
|
@ -18,4 +18,21 @@ package dev.usbharu.hideout.mastodon.domain.exception
|
|||
|
||||
import dev.usbharu.hideout.mastodon.domain.model.MastodonApiErrorResponse
|
||||
|
||||
open class ClientException(response: MastodonApiErrorResponse<*>) : MastodonApiException(response)
|
||||
open class ClientException : MastodonApiException {
|
||||
constructor(response: MastodonApiErrorResponse<*>) : super(response)
|
||||
constructor(message: String?, response: MastodonApiErrorResponse<*>) : super(message, response)
|
||||
constructor(message: String?, cause: Throwable?, response: MastodonApiErrorResponse<*>) : super(
|
||||
message,
|
||||
cause,
|
||||
response
|
||||
)
|
||||
|
||||
constructor(cause: Throwable?, response: MastodonApiErrorResponse<*>) : super(cause, response)
|
||||
constructor(
|
||||
message: String?,
|
||||
cause: Throwable?,
|
||||
enableSuppression: Boolean,
|
||||
writableStackTrace: Boolean,
|
||||
response: MastodonApiErrorResponse<*>,
|
||||
) : super(message, cause, enableSuppression, writableStackTrace, response)
|
||||
}
|
|
@ -18,4 +18,38 @@ package dev.usbharu.hideout.mastodon.domain.exception
|
|||
|
||||
import dev.usbharu.hideout.mastodon.domain.model.MastodonApiErrorResponse
|
||||
|
||||
abstract class MastodonApiException(val response: MastodonApiErrorResponse<*>) : RuntimeException()
|
||||
abstract class MastodonApiException : RuntimeException {
|
||||
|
||||
val response: MastodonApiErrorResponse<*>
|
||||
|
||||
constructor(response: MastodonApiErrorResponse<*>) : super() {
|
||||
this.response = response
|
||||
}
|
||||
|
||||
constructor(message: String?, response: MastodonApiErrorResponse<*>) : super(message) {
|
||||
this.response = response
|
||||
}
|
||||
|
||||
constructor(message: String?, cause: Throwable?, response: MastodonApiErrorResponse<*>) : super(message, cause) {
|
||||
this.response = response
|
||||
}
|
||||
|
||||
constructor(cause: Throwable?, response: MastodonApiErrorResponse<*>) : super(cause) {
|
||||
this.response = response
|
||||
}
|
||||
|
||||
constructor(
|
||||
message: String?,
|
||||
cause: Throwable?,
|
||||
enableSuppression: Boolean,
|
||||
writableStackTrace: Boolean,
|
||||
response: MastodonApiErrorResponse<*>,
|
||||
) : super(
|
||||
message,
|
||||
cause,
|
||||
enableSuppression,
|
||||
writableStackTrace
|
||||
) {
|
||||
this.response = response
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright (C) 2024 usbharu
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.usbharu.hideout.mastodon.domain.exception
|
||||
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.NotFoundResponse
|
||||
import dev.usbharu.hideout.mastodon.domain.model.MastodonApiErrorResponse
|
||||
|
||||
class StatusNotFoundException : ClientException {
|
||||
|
||||
fun getTypedResponse(): MastodonApiErrorResponse<NotFoundResponse> {
|
||||
return response as MastodonApiErrorResponse<NotFoundResponse>
|
||||
}
|
||||
|
||||
constructor(response: MastodonApiErrorResponse<NotFoundResponse>) : super(response)
|
||||
constructor(message: String?, response: MastodonApiErrorResponse<NotFoundResponse>) : super(message, response)
|
||||
constructor(message: String?, cause: Throwable?, response: MastodonApiErrorResponse<NotFoundResponse>) : super(
|
||||
message,
|
||||
cause,
|
||||
response
|
||||
)
|
||||
|
||||
constructor(cause: Throwable?, response: MastodonApiErrorResponse<NotFoundResponse>) : super(cause, response)
|
||||
constructor(
|
||||
message: String?,
|
||||
cause: Throwable?,
|
||||
enableSuppression: Boolean,
|
||||
writableStackTrace: Boolean,
|
||||
response: MastodonApiErrorResponse<NotFoundResponse>,
|
||||
) : super(message, cause, enableSuppression, writableStackTrace, response)
|
||||
|
||||
companion object {
|
||||
fun ofId(id: Long): StatusNotFoundException = StatusNotFoundException(
|
||||
"id: $id was not found.",
|
||||
MastodonApiErrorResponse(
|
||||
NotFoundResponse(
|
||||
"Record not found"
|
||||
), 404
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -122,12 +122,13 @@ class StatusQueryServiceImpl : StatusQueryService {
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun findByPostId(id: Long): Status {
|
||||
override suspend fun findByPostId(id: Long): Status? {
|
||||
val map = Posts
|
||||
.leftJoin(PostsMedia)
|
||||
.leftJoin(Actors)
|
||||
.leftJoin(Media)
|
||||
.selectAll().where { Posts.id eq id }
|
||||
.selectAll()
|
||||
.where { Posts.id eq id }
|
||||
.groupBy { it[Posts.id] }
|
||||
.map { it.value }
|
||||
.map {
|
||||
|
@ -138,7 +139,7 @@ class StatusQueryServiceImpl : StatusQueryService {
|
|||
emojis = it.mapNotNull { resultRow -> resultRow.toCustomEmojiOrNull()?.toMastodonEmoji() }
|
||||
) to it.first()[Posts.repostId]
|
||||
}
|
||||
return resolveReplyAndRepost(map).single()
|
||||
return resolveReplyAndRepost(map).singleOrNull()
|
||||
}
|
||||
|
||||
private fun resolveReplyAndRepost(pairs: List<Pair<Status, Long?>>): List<Status> {
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright (C) 2024 usbharu
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.usbharu.hideout.mastodon.infrastructure.springweb
|
||||
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.NotFoundResponse
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.UnprocessableEntityResponse
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.UnprocessableEntityResponseDetails
|
||||
import dev.usbharu.hideout.mastodon.domain.exception.StatusNotFoundException
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.account.MastodonAccountApiController
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.apps.MastodonAppsApiController
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.filter.MastodonFilterApiController
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.instance.MastodonInstanceApiController
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.media.MastodonMediaApiController
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.notification.MastodonNotificationApiController
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.status.MastodonStatusesApiContoller
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.timeline.MastodonTimelineApiController
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.validation.BindException
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
|
||||
@ControllerAdvice(
|
||||
assignableTypes = [
|
||||
MastodonAccountApiController::class,
|
||||
MastodonAppsApiController::class,
|
||||
MastodonFilterApiController::class,
|
||||
MastodonInstanceApiController::class,
|
||||
MastodonMediaApiController::class,
|
||||
MastodonNotificationApiController::class,
|
||||
MastodonStatusesApiContoller::class,
|
||||
MastodonTimelineApiController::class
|
||||
]
|
||||
)
|
||||
class MastodonApiControllerAdvice {
|
||||
|
||||
@ExceptionHandler(BindException::class)
|
||||
fun handleException(ex: BindException): ResponseEntity<UnprocessableEntityResponse> {
|
||||
logger.debug("Failed bind entity.", ex)
|
||||
val error = ex.bindingResult.fieldErrors
|
||||
val message = error.map {
|
||||
"${it.field} ${it.defaultMessage}"
|
||||
}.joinToString(prefix = "Validation failed: ")
|
||||
|
||||
val details = error.associate {
|
||||
it.field to UnprocessableEntityResponseDetails(
|
||||
when (it.code) {
|
||||
"Email" -> "ERR_INVALID"
|
||||
"Pattern" -> "ERR_INVALID"
|
||||
else -> "ERR_INVALID"
|
||||
},
|
||||
it.defaultMessage
|
||||
)
|
||||
}
|
||||
|
||||
return ResponseEntity.unprocessableEntity().body(UnprocessableEntityResponse(message, details))
|
||||
}
|
||||
|
||||
@ExceptionHandler(StatusNotFoundException::class)
|
||||
fun handleException(ex: StatusNotFoundException): ResponseEntity<NotFoundResponse> {
|
||||
logger.warn("Status not found.", ex)
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getTypedResponse().response)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val logger = LoggerFactory.getLogger(MastodonApiControllerAdvice::class.java)
|
||||
}
|
||||
}
|
|
@ -43,6 +43,7 @@ class MastodonStatusesApiContoller(
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
override suspend fun apiV1StatusesIdEmojiReactionsEmojiDelete(id: String, emoji: String): ResponseEntity<Status> {
|
||||
val uid =
|
||||
loginUserContextHolder.getLoginUserId()
|
||||
|
@ -57,5 +58,9 @@ class MastodonStatusesApiContoller(
|
|||
return ResponseEntity.ok(statusesApiService.emojiReactions(id.toLong(), uid, emoji))
|
||||
}
|
||||
|
||||
override suspend fun apiV1StatusesIdGet(id: String): ResponseEntity<Status> = super.apiV1StatusesIdGet(id)
|
||||
override suspend fun apiV1StatusesIdGet(id: String): ResponseEntity<Status> {
|
||||
val uid = loginUserContextHolder.getLoginUserIdOrNull()
|
||||
|
||||
return ResponseEntity.ok(statusesApiService.findById(id.toLong(), uid))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,5 +37,5 @@ interface StatusQueryService {
|
|||
page: Page
|
||||
): PaginationList<Status, Long>
|
||||
|
||||
suspend fun findByPostId(id: Long): Status
|
||||
suspend fun findByPostId(id: Long): Status?
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import dev.usbharu.hideout.core.service.post.PostService
|
|||
import dev.usbharu.hideout.core.service.reaction.ReactionService
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.Status.Visibility.*
|
||||
import dev.usbharu.hideout.mastodon.domain.exception.StatusNotFoundException
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusesRequest
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.status.toPostVisibility
|
||||
import dev.usbharu.hideout.mastodon.interfaces.api.status.toStatusVisibility
|
||||
|
@ -43,25 +44,25 @@ import java.time.Instant
|
|||
interface StatusesApiService {
|
||||
suspend fun postStatus(
|
||||
statusesRequest: StatusesRequest,
|
||||
userId: Long
|
||||
userId: Long,
|
||||
): Status
|
||||
|
||||
suspend fun findById(
|
||||
id: Long,
|
||||
userId: Long?
|
||||
): Status?
|
||||
userId: Long?,
|
||||
): Status
|
||||
|
||||
suspend fun emojiReactions(
|
||||
postId: Long,
|
||||
userId: Long,
|
||||
emojiName: String
|
||||
): Status?
|
||||
emojiName: String,
|
||||
): Status
|
||||
|
||||
suspend fun removeEmojiReactions(
|
||||
postId: Long,
|
||||
userId: Long,
|
||||
emojiName: String
|
||||
): Status?
|
||||
emojiName: String,
|
||||
): Status
|
||||
}
|
||||
|
||||
@Service
|
||||
|
@ -76,12 +77,12 @@ class StatsesApiServiceImpl(
|
|||
private val statusQueryService: StatusQueryService,
|
||||
private val relationshipRepository: RelationshipRepository,
|
||||
private val reactionService: ReactionService,
|
||||
private val emojiService: EmojiService
|
||||
private val emojiService: EmojiService,
|
||||
) :
|
||||
StatusesApiService {
|
||||
override suspend fun postStatus(
|
||||
statusesRequest: StatusesRequest,
|
||||
userId: Long
|
||||
userId: Long,
|
||||
): Status = transaction.transaction {
|
||||
logger.debug("START create post by mastodon api. {}", statusesRequest)
|
||||
|
||||
|
@ -140,39 +141,51 @@ class StatsesApiServiceImpl(
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun findById(id: Long, userId: Long?): Status? {
|
||||
val status = statusQueryService.findByPostId(id)
|
||||
override suspend fun findById(id: Long, userId: Long?): Status = transaction.transaction {
|
||||
val status = statusQueryService.findByPostId(id) ?: statusNotFound(id)
|
||||
|
||||
return status(status, userId)
|
||||
return@transaction status(status, userId)
|
||||
}
|
||||
|
||||
private fun accessDenied(id: String): Nothing {
|
||||
logger.debug("Access Denied $id")
|
||||
throw StatusNotFoundException.ofId(id.toLong())
|
||||
}
|
||||
|
||||
private fun statusNotFound(id: Long): Nothing {
|
||||
logger.debug("Status Not Found $id")
|
||||
throw StatusNotFoundException.ofId(id)
|
||||
}
|
||||
|
||||
private suspend fun status(
|
||||
status: Status,
|
||||
userId: Long?
|
||||
): Status? {
|
||||
userId: Long?,
|
||||
): Status {
|
||||
|
||||
|
||||
return when (status.visibility) {
|
||||
public -> status
|
||||
unlisted -> status
|
||||
private -> {
|
||||
if (userId == null) {
|
||||
return null
|
||||
accessDenied(status.id)
|
||||
}
|
||||
|
||||
val relationship =
|
||||
relationshipRepository.findByUserIdAndTargetUserId(userId, status.account.id.toLong())
|
||||
?: return null
|
||||
?: accessDenied(status.id)
|
||||
if (relationship.following) {
|
||||
return status
|
||||
}
|
||||
return null
|
||||
accessDenied(status.id)
|
||||
}
|
||||
|
||||
direct -> null
|
||||
direct -> accessDenied(status.id)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun emojiReactions(postId: Long, userId: Long, emojiName: String): Status? {
|
||||
status(statusQueryService.findByPostId(postId), userId) ?: return null
|
||||
override suspend fun emojiReactions(postId: Long, userId: Long, emojiName: String): Status {
|
||||
status(statusQueryService.findByPostId(postId) ?: statusNotFound(postId), userId)
|
||||
|
||||
val emoji = try {
|
||||
if (EmojiUtil.isEmoji(emojiName)) {
|
||||
|
@ -186,13 +199,13 @@ class StatsesApiServiceImpl(
|
|||
UnicodeEmoji("❤")
|
||||
}
|
||||
reactionService.sendReaction(emoji, userId, postId)
|
||||
return statusQueryService.findByPostId(postId)
|
||||
return statusQueryService.findByPostId(postId) ?: statusNotFound(postId)
|
||||
}
|
||||
|
||||
override suspend fun removeEmojiReactions(postId: Long, userId: Long, emojiName: String): Status? {
|
||||
override suspend fun removeEmojiReactions(postId: Long, userId: Long, emojiName: String): Status {
|
||||
reactionService.removeReaction(userId, postId)
|
||||
|
||||
return status(statusQueryService.findByPostId(postId), userId)
|
||||
return status(statusQueryService.findByPostId(postId) ?: statusNotFound(postId), userId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -2840,6 +2840,18 @@ components:
|
|||
properties:
|
||||
error:
|
||||
type: string
|
||||
details:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/UnprocessableEntityResponseDetails"
|
||||
|
||||
UnprocessableEntityResponseDetails:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
|
||||
UnauthorizedResponse:
|
||||
type: object
|
||||
|
@ -2877,11 +2889,7 @@ components:
|
|||
|
||||
PartialContentResponse:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
required:
|
||||
- error
|
||||
|
||||
|
||||
responses:
|
||||
forbidden:
|
||||
|
|
Loading…
Reference in New Issue