Merge pull request #11 from TouchInstinct/exception-handler-spring-web

add exception-handler-spring-web
This commit is contained in:
Alexander Buntakov 2021-06-08 13:17:16 +03:00 committed by GitHub
commit 6b3581f652
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 459 additions and 0 deletions

View File

@ -71,3 +71,7 @@
## logger-spring-web
Interceptor для логирования запросов/ответов.
## exception-handler-spring-web
Перехватывает ошибки сервера, определяет код ошибки и возвращает их в правильный `response`

View File

@ -56,6 +56,9 @@ subprojects {
dependency("org.junit.jupiter:junit-jupiter-api:5.4.2")
dependency("org.junit.jupiter:junit-jupiter-params:5.4.2")
dependency("org.junit.jupiter:junit-jupiter-engine:5.4.2")
dependency("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
dependency("org.mockito:mockito-inline:2.13.0")
}
}

View File

@ -0,0 +1,16 @@
plugins {
id("kotlin")
id("kotlin-spring")
id("maven-publish")
}
dependencies {
api(project(":common-spring-web"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.springframework.boot:spring-boot-starter-web")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin")
}

View File

@ -0,0 +1,31 @@
package ru.touchin.exception.handler.dto
import org.springframework.http.HttpStatus
import ru.touchin.common.spring.web.dto.ApiError
import ru.touchin.common.spring.web.dto.DefaultApiError
data class ExceptionResolverResult(
val apiError: ApiError,
val status: HttpStatus,
val exception: Exception?,
) {
companion object {
fun createInternalError(errorMessage: String?): ExceptionResolverResult {
return ExceptionResolverResult(
apiError = DefaultApiError.createFailure(errorMessage),
status = HttpStatus.INTERNAL_SERVER_ERROR,
exception = null
)
}
fun createInternalError(exception: Exception?): ExceptionResolverResult {
return ExceptionResolverResult(
apiError = DefaultApiError.createFailure(exception?.message),
status = HttpStatus.INTERNAL_SERVER_ERROR,
exception = exception
)
}
}
}

View File

@ -0,0 +1,8 @@
@file:Suppress("unused")
package ru.touchin.exception.handler.spring
import org.springframework.context.annotation.Import
import ru.touchin.exception.handler.spring.configurations.ExceptionHandlerConfiguration
@Import(value = [ExceptionHandlerConfiguration::class])
annotation class EnableSpringExceptionHandler

View File

@ -0,0 +1,36 @@
package ru.touchin.exception.handler.spring.advices
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import ru.touchin.exception.handler.dto.ExceptionResolverResult
import ru.touchin.exception.handler.spring.creators.ExceptionResponseBodyCreator
import ru.touchin.exception.handler.spring.logger.Logger
import ru.touchin.exception.handler.spring.resolvers.ExceptionResolver
@RestControllerAdvice
class ExceptionHandlerAdvice(
exceptionResolversList: List<ExceptionResolver>,
private val logger: Logger,
private val exceptionResponseBodyCreator: ExceptionResponseBodyCreator,
) {
private val exceptionResolvers = exceptionResolversList.asSequence()
@ExceptionHandler(Exception::class)
fun handleException(
exception: Exception,
): ResponseEntity<Any> {
val result: ExceptionResolverResult = exceptionResolvers
.mapNotNull { it.invoke(exception) }
.firstOrNull()
?: ExceptionResolverResult.createInternalError("Unexpected exception occurred: $exception")
logger.log(this::class.java, result)
val body = exceptionResponseBodyCreator(result.apiError)
return ResponseEntity(body, result.status)
}
}

View File

@ -0,0 +1,31 @@
package ru.touchin.exception.handler.spring.configurations
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration
import ru.touchin.exception.handler.spring.creators.DefaultExceptionResponseBodyCreatorImpl
import ru.touchin.exception.handler.spring.creators.ExceptionResponseBodyCreator
import ru.touchin.exception.handler.spring.logger.FallbackLogger
import ru.touchin.exception.handler.spring.logger.Logger
@Configuration
@ComponentScan(
"ru.touchin.exception.handler.spring.advices",
"ru.touchin.exception.handler.spring.resolvers",
)
class ExceptionHandlerConfiguration {
@Bean
@ConditionalOnMissingBean
fun exceptionResponseBodyCreator(): ExceptionResponseBodyCreator {
return DefaultExceptionResponseBodyCreatorImpl()
}
@Bean
@ConditionalOnMissingBean
fun logger(): Logger {
return FallbackLogger()
}
}

View File

@ -0,0 +1,16 @@
@file:Suppress("unused")
package ru.touchin.exception.handler.spring.creators
import ru.touchin.common.spring.web.dto.ApiError
import ru.touchin.common.spring.web.dto.DefaultApiError
class DefaultExceptionResponseBodyCreatorImpl : ExceptionResponseBodyCreator {
override fun invoke(apiError: ApiError): Any {
return DefaultApiError(
errorCode = apiError.errorCode,
errorMessage = apiError.errorMessage
)
}
}

View File

@ -0,0 +1,9 @@
package ru.touchin.exception.handler.spring.creators
import ru.touchin.common.spring.web.dto.ApiError
interface ExceptionResponseBodyCreator {
operator fun invoke(apiError: ApiError): Any?
}

View File

@ -0,0 +1,11 @@
package ru.touchin.exception.handler.spring.logger
import ru.touchin.exception.handler.dto.ExceptionResolverResult
class FallbackLogger : Logger {
override fun log(clazz: Class<*>, exceptionResolverResult: ExceptionResolverResult) {
// do nothing
}
}

View File

@ -0,0 +1,9 @@
package ru.touchin.exception.handler.spring.logger
import ru.touchin.exception.handler.dto.ExceptionResolverResult
interface Logger {
fun log(clazz: Class<*>, exceptionResolverResult: ExceptionResolverResult)
}

View File

@ -0,0 +1,9 @@
package ru.touchin.exception.handler.spring.resolvers
import ru.touchin.exception.handler.dto.ExceptionResolverResult
interface ExceptionResolver {
operator fun invoke(exception: Exception): ExceptionResolverResult?
}

View File

@ -0,0 +1,16 @@
package ru.touchin.exception.handler.spring.resolvers
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.stereotype.Component
import ru.touchin.exception.handler.dto.ExceptionResolverResult
@Order(Ordered.LOWEST_PRECEDENCE)
@Component
class FallbackExceptionResolver : ExceptionResolver {
override fun invoke(exception: Exception): ExceptionResolverResult {
return ExceptionResolverResult.createInternalError(exception)
}
}

View File

@ -0,0 +1,8 @@
package ru.touchin.exception.handler
import org.springframework.boot.autoconfigure.SpringBootApplication
import ru.touchin.exception.handler.spring.EnableSpringExceptionHandler
@SpringBootApplication
@EnableSpringExceptionHandler
class TestApplication

View File

@ -0,0 +1,116 @@
package ru.touchin.exception.handler.spring.advices
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.atLeastOnce
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.verify
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito.spy
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import ru.touchin.exception.handler.spring.EnableSpringExceptionHandler
import ru.touchin.exception.handler.spring.creators.ExceptionResponseBodyCreator
import ru.touchin.exception.handler.spring.logger.Logger
import ru.touchin.exception.handler.spring.resolvers.FallbackExceptionResolver
import ru.touchin.exception.handler.spring.resolvers.IllegalStateExceptionResolver1
import ru.touchin.exception.handler.spring.resolvers.IllegalStateExceptionResolver2
@RestController
@RequestMapping("/api/errors")
class ErrorController {
@GetMapping("/runtime")
fun runtimeError() {
throw RuntimeException("my runtime error")
}
@GetMapping("/illegal")
fun illegalError() {
throw IllegalStateException("my illegal error")
}
}
@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc
internal class ExceptionHandlerAdviceMvcTest {
@Autowired
lateinit var mockMvc: MockMvc
@Autowired
lateinit var logger: Logger
@Test
@DisplayName("Тест должен проходить")
fun shouldBeWork() {
assertTrue(true, "Not passed")
}
@Test
@DisplayName("Должна вернуться ошибка InternalServerError с кодом -1")
fun shouldGetInternalServerError() {
mockMvc
.perform(get("/api/errors/runtime"))
.andDo(print())
.andExpect(status().isInternalServerError)
.andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
.andExpect(jsonPath("$.errorCode", `is`(-1)))
.andExpect(jsonPath("$.errorMessage", `is`("my runtime error")))
}
@Test
@DisplayName("Должна вернуться ошибка BadRequest с кодом -2")
fun shouldGetBadRequest() {
mockMvc
.perform(get("/api/errors/illegal"))
.andDo(print())
.andExpect(status().isBadRequest)
.andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
.andExpect(jsonPath("$.errorCode", `is`(-2)))
.andExpect(jsonPath("$.errorMessage", `is`("my illegal error")))
}
@Test
@DisplayName("Должен отработать только первый ExceptionResolver")
fun shouldBeCorrectOrder() {
val resolver1 = spy(IllegalStateExceptionResolver1::class.java)
val resolver2 = spy(IllegalStateExceptionResolver2::class.java)
val resolver3 = spy(FallbackExceptionResolver::class.java)
val resolvers = listOf(resolver1, resolver2, resolver3)
val exceptionResponseBodyCreator: ExceptionResponseBodyCreator = mock { }
val exceptionHandlerAdvice = ExceptionHandlerAdvice(
exceptionResolversList = resolvers,
exceptionResponseBodyCreator = exceptionResponseBodyCreator,
logger = logger
)
exceptionHandlerAdvice.handleException(IllegalStateException("error"))
verify(resolver1, atLeastOnce()).invoke(any())
verify(resolver2, never()).invoke(any())
verify(resolver3, never()).invoke(any())
}
}

View File

@ -0,0 +1,88 @@
package ru.touchin.exception.handler.spring.advices
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.only
import com.nhaarman.mockitokotlin2.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito.spy
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import ru.touchin.exception.handler.spring.creators.ExceptionResponseBodyCreator
import ru.touchin.exception.handler.spring.logger.Logger
import ru.touchin.exception.handler.spring.resolvers.FallbackExceptionResolver
import ru.touchin.exception.handler.spring.resolvers.IllegalStateExceptionResolver1
import ru.touchin.exception.handler.spring.resolvers.IllegalStateExceptionResolver2
@ActiveProfiles("test")
@SpringBootTest
internal class ExceptionHandlerAdviceTest {
private lateinit var resolver1: IllegalStateExceptionResolver1
private lateinit var resolver2: IllegalStateExceptionResolver2
private lateinit var resolver3: FallbackExceptionResolver
private lateinit var exceptionHandlerAdvice: ExceptionHandlerAdvice
@Autowired
lateinit var logger: Logger
@BeforeEach
fun setUp() {
resolver1 = spy(IllegalStateExceptionResolver1::class.java)
resolver2 = spy(IllegalStateExceptionResolver2::class.java)
resolver3 = spy(FallbackExceptionResolver::class.java)
val resolvers = listOf(resolver1, resolver2, resolver3)
val exceptionResponseBodyCreator: ExceptionResponseBodyCreator = mock { }
exceptionHandlerAdvice = ExceptionHandlerAdvice(
exceptionResolversList = resolvers,
exceptionResponseBodyCreator = exceptionResponseBodyCreator,
logger = logger,
)
}
@Test
@DisplayName("Должен отработать только первый ExceptionResolver")
fun shouldBeExecuteOnlyResolver1() {
exceptionHandlerAdvice.handleException(IllegalStateException("error"))
verify(resolver1, only()).invoke(any())
verify(resolver2, never()).invoke(any())
verify(resolver3, never()).invoke(any())
}
@Test
@DisplayName("Должны отработать все resolvers и поймать ошибку в FallbackResolver")
fun shouldBeExecuteAllResolversAndResolvedInFallback() {
exceptionHandlerAdvice.handleException(RuntimeException("error"))
verify(resolver1, only()).invoke(any())
verify(resolver2, only()).invoke(any())
verify(resolver3, only()).invoke(any())
}
@Test
@DisplayName("Должны отработать все resolvers и НЕ поймать ошибку в FallbackResolver")
fun shouldVBeExecuteAllResolversWithoutResolve() {
val resolvers = listOf(resolver1, resolver2)
val exceptionResponseBodyCreator: ExceptionResponseBodyCreator = mock { }
exceptionHandlerAdvice = ExceptionHandlerAdvice(
exceptionResolversList = resolvers,
exceptionResponseBodyCreator = exceptionResponseBodyCreator,
logger = logger,
)
exceptionHandlerAdvice.handleException(RuntimeException("error"))
verify(resolver1, only()).invoke(any())
verify(resolver2, only()).invoke(any())
}
}

View File

@ -0,0 +1,23 @@
package ru.touchin.exception.handler.spring.resolvers
import org.springframework.core.annotation.Order
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import ru.touchin.common.spring.web.dto.DefaultApiError
import ru.touchin.exception.handler.dto.ExceptionResolverResult
@Component
@Order(1)
class IllegalStateExceptionResolver1: ExceptionResolver {
override fun invoke(exception: Exception): ExceptionResolverResult? {
return (exception as? IllegalStateException)?.let {
ExceptionResolverResult(
apiError = DefaultApiError(errorCode = -2, errorMessage = exception.message),
status = HttpStatus.BAD_REQUEST,
exception = exception,
)
}
}
}

View File

@ -0,0 +1,24 @@
package ru.touchin.exception.handler.spring.resolvers
import org.springframework.core.annotation.Order
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import ru.touchin.common.spring.web.dto.DefaultApiError
import ru.touchin.exception.handler.dto.ExceptionResolverResult
import java.lang.IllegalStateException
@Component
@Order(2)
class IllegalStateExceptionResolver2: ExceptionResolver {
override fun invoke(exception: Exception): ExceptionResolverResult? {
return (exception as? IllegalStateException)?.let {
ExceptionResolverResult(
apiError = DefaultApiError(errorCode = -2, errorMessage = "Should not be executed"),
status = HttpStatus.BAD_GATEWAY,
exception = exception,
)
}
}
}

View File

@ -28,3 +28,4 @@ include("common-spring-test-jpa")
include("logger")
include("logger-spring")
include("logger-spring-web")
include("exception-handler-spring-web")