feat: add global error web exception handler (#2741)

#### What type of PR is this?
/kind feature
/milestone 2.0.0-rc.1
/area core

#### What this PR does / why we need it:
新增全局异常处理
参考文档:
- [web.reactive.webflux.error-handling](https://docs.spring.io/spring-boot/docs/3.0.0-RC2/reference/htmlsingle/#web.reactive.webflux.error-handling)
- [webflux-ann-rest-exceptions](https://docs.spring.io/spring-framework/docs/6.0.0-RC4/reference/html/web-reactive.html#webflux-ann-rest-exceptions)

发生异常时返回形如以下结构
```json
{
    "type": "about:blank",
    "title": "Not Found",
    "status": 404,
    "detail": "Hello not found test.",
    "instance": "/hello"
}
```
此结构遵循 [RFC 7807 Problem Details](https://www.rfc-editor.org/rfc/rfc7807.html)

#### Which issue(s) this PR fixes:

Fixes #2732

#### Special notes for your reviewer:
how to test it?
更新文章前把 title 字段删除,会出现校验异常 BadRequest

/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
新增全局异常处理
```
pull/2759/head^2
guqing 2022-11-24 20:45:07 +08:00 committed by GitHub
parent edfc9ac1e1
commit a76ade8aa8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 410 additions and 3 deletions

View File

@ -14,4 +14,8 @@ public class NotFoundException extends HaloException {
public NotFoundException(String message, Throwable cause) {
super(message, cause);
}
public NotFoundException(Throwable cause) {
super(cause);
}
}

View File

@ -0,0 +1,113 @@
package run.halo.app.infra.exception.handlers;
import java.net.URI;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.lang.Nullable;
import org.springframework.web.ErrorResponse;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.server.ServerWebExchange;
import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.extension.exception.SchemaViolationException;
import run.halo.app.extension.exception.SchemeNotFoundException;
import run.halo.app.infra.exception.AccessDeniedException;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.infra.exception.ThemeInstallationException;
import run.halo.app.infra.exception.ThemeUninstallException;
/**
* Handle exceptions and convert them to {@link ProblemDetail}.
*
* @author guqing
* @since 2.0.0
*/
public class ExceptionHandlingProblemDetailsHandler {
@ExceptionHandler({SchemeNotFoundException.class, ExtensionNotFoundException.class,
NotFoundException.class})
public ProblemDetail handleNotFoundException(Throwable error,
ServerWebExchange exchange) {
return createProblemDetail(error, HttpStatus.NOT_FOUND,
error.getMessage(), exchange);
}
@ExceptionHandler(SchemaViolationException.class)
public ProblemDetail handleSchemaViolationException(SchemaViolationException exception,
ServerWebExchange exchange) {
List<InvalidParam> invalidParams =
exception.getErrors().items()
.stream()
.map(item -> new InvalidParam(item.dataCrumbs(),
item.message())
)
.collect(Collectors.toList());
ProblemDetail problemDetail = createProblemDetail(exception, HttpStatus.BAD_REQUEST,
exception.getMessage(), exchange);
problemDetail.setTitle("Your request parameters didn't validate.");
problemDetail.setProperty("invalidParams", invalidParams);
return problemDetail;
}
@ExceptionHandler({ExtensionConvertException.class, ThemeInstallationException.class,
ThemeUninstallException.class, IllegalArgumentException.class, IllegalStateException.class})
public ProblemDetail handleBadRequestException(Throwable error,
ServerWebExchange exchange) {
return createProblemDetail(error, HttpStatus.BAD_REQUEST,
error.getMessage(), exchange);
}
@ExceptionHandler(AccessDeniedException.class)
public ProblemDetail handleAccessDeniedException(AccessDeniedException e,
ServerWebExchange exchange) {
return createProblemDetail(e, HttpStatus.FORBIDDEN, e.getMessage(), exchange);
}
@ExceptionHandler(OptimisticLockingFailureException.class)
public ProblemDetail handleOptimisticLockingFailureException(
OptimisticLockingFailureException e, ServerWebExchange exchange) {
return createProblemDetail(e, HttpStatus.CONFLICT,
e.getMessage(), exchange);
}
public record InvalidParam(String name, String reason) {
}
protected ProblemDetail createProblemDetail(Throwable ex, HttpStatusCode status,
String defaultDetail, ServerWebExchange exchange) {
ErrorResponse response = createFor(
ex, status, null, defaultDetail, null, null);
ProblemDetail problemDetail = response.getBody();
problemDetail.setInstance(URI.create(exchange.getRequest().getPath()
.pathWithinApplication().value()));
return problemDetail;
}
static ErrorResponse createFor(
Throwable ex, HttpStatusCode status, @Nullable HttpHeaders headers,
String defaultDetail, @Nullable String detailMessageCode,
@Nullable Object[] detailMessageArguments) {
if (detailMessageCode == null) {
detailMessageCode = ErrorResponse.getDefaultDetailMessageCode(ex.getClass(), null);
}
ErrorResponseException errorResponse = new ErrorResponseException(
status, ProblemDetail.forStatusAndDetail(status, defaultDetail), null,
detailMessageCode, detailMessageArguments);
if (headers != null) {
errorResponse.getHeaders().putAll(headers);
}
return errorResponse;
}
}

View File

@ -0,0 +1,91 @@
package run.halo.app.infra.exception.handlers;
import java.lang.reflect.Method;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.web.ErrorProperties;
import org.springframework.boot.autoconfigure.web.WebProperties;
import org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponse;
import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver;
import org.springframework.web.reactive.BindingContext;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.method.InvocableHandlerMethod;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* Global error web exception handler.
*
* @author guqing
* @see DefaultErrorWebExceptionHandler
* @see ExceptionHandlingProblemDetailsHandler
* @see ExceptionHandlerMethodResolver
* @since 2.0.0
*/
@Slf4j
public class GlobalErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler {
private final ExceptionHandlingProblemDetailsHandler exceptionHandler =
new ExceptionHandlingProblemDetailsHandler();
private final ExceptionHandlerMethodResolver handlerMethodResolver =
new ExceptionHandlerMethodResolver(ExceptionHandlingProblemDetailsHandler.class);
/**
* Create a new {@code DefaultErrorWebExceptionHandler} instance.
*
* @param errorAttributes the error attributes
* @param resources the resources configuration properties
* @param errorProperties the error configuration properties
* @param applicationContext the current application context
* @since 2.4.0
*/
public GlobalErrorWebExceptionHandler(ErrorAttributes errorAttributes,
WebProperties.Resources resources,
ErrorProperties errorProperties,
ApplicationContext applicationContext) {
super(errorAttributes, resources, errorProperties, applicationContext);
}
@Override
protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
Throwable error = getError(request);
log.error(error.getMessage(), error);
if (error instanceof ErrorResponse errorResponse) {
return ServerResponse.status(errorResponse.getStatusCode())
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(errorResponse.getBody()));
}
Method exceptionHandlerMethod = handlerMethodResolver.resolveMethodByThrowable(error);
if (exceptionHandlerMethod == null) {
return noMatchExceptionHandler(error);
}
InvocableHandlerMethod invocable =
new InvocableHandlerMethod(exceptionHandler, exceptionHandlerMethod);
BindingContext bindingContext = new BindingContext();
ServerWebExchange exchange = request.exchange();
return invocable.invoke(exchange, bindingContext, error, exchange)
.mapNotNull(handleResult -> (ProblemDetail) handleResult.getReturnValue())
.flatMap(problemDetail -> ServerResponse.status(problemDetail.getStatus())
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(problemDetail)))
.switchIfEmpty(Mono.defer(() -> noMatchExceptionHandler(error)));
}
Mono<ServerResponse> noMatchExceptionHandler(Throwable error) {
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(
ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
error.getMessage())
)
);
}
}

View File

@ -0,0 +1,54 @@
package run.halo.app.infra.exception.handlers;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.autoconfigure.web.WebProperties;
import org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.web.reactive.result.view.ViewResolver;
/**
* Global exception handler auto configuration.
*
* @author guqing
* @see GlobalErrorWebExceptionHandler
* @see
* <a href="https://docs.spring.io/spring-framework/docs/6.0.0-SNAPSHOT/reference/html/web-reactive.html#webflux-ann-rest-exceptions-render>webflux-ann-rest-exceptions-render</a>
* @since 2.0.0
*/
@Configuration
@EnableConfigurationProperties({ServerProperties.class, WebProperties.class})
public class HaloErrorWebFluxAutoConfiguration {
private final ServerProperties serverProperties;
public HaloErrorWebFluxAutoConfiguration(ServerProperties serverProperties) {
this.serverProperties = serverProperties;
}
/**
* The default exception handler order is <code>-1</code>, so it is set before it here.
*
* @see ErrorWebFluxAutoConfiguration
*/
@Bean
@Order(-2)
public ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes,
WebProperties webProperties, ObjectProvider<ViewResolver> viewResolvers,
ServerCodecConfigurer serverCodecConfigurer, ApplicationContext applicationContext) {
GlobalErrorWebExceptionHandler exceptionHandler =
new GlobalErrorWebExceptionHandler(errorAttributes,
webProperties.getResources(), this.serverProperties.getError(), applicationContext);
exceptionHandler.setViewResolvers(viewResolvers.orderedStream().toList());
exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters());
exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders());
return exceptionHandler;
}
}

View File

@ -1,6 +1,6 @@
package run.halo.app.plugin;
import org.pf4j.PluginRuntimeException;
import run.halo.app.infra.exception.NotFoundException;
/**
* Exception for plugin not found.
@ -8,7 +8,7 @@ import org.pf4j.PluginRuntimeException;
* @author guqing
* @since 2.0.0
*/
public class PluginNotFoundException extends PluginRuntimeException {
public class PluginNotFoundException extends NotFoundException {
public PluginNotFoundException(String message) {
super(message);
}

View File

@ -79,7 +79,7 @@ class UserEndpointTest {
.thenReturn(Mono.error(new ExtensionNotFoundException()));
webClient.get().uri("/apis/api.console.halo.run/v1alpha1/users/-")
.exchange()
.expectStatus().is5xxServerError();
.expectStatus().isNotFound();
verify(client).get(User.class, "fake-user");
}

View File

@ -0,0 +1,145 @@
package run.halo.app.infra.exception.handlers;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.infra.exception.AccessDeniedException;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.infra.exception.ThemeUninstallException;
/**
* Tests for {@link GlobalErrorWebExceptionHandler}.
*
* @author guqing
* @since 2.0.0
*/
@SpringBootTest
@AutoConfigureWebTestClient
class GlobalErrorWebExceptionHandlerTest {
@Autowired
private WebTestClient client;
@Test
void renderErrorResponseWhenNotFoundError() {
client.get()
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
.queryParam("type", "notFound")
.build())
.exchange()
.expectStatus().isNotFound()
.expectBody()
.jsonPath("$.title").isEqualTo("Not Found")
.jsonPath("$.detail").isEqualTo("Not Found")
.jsonPath("$.instance").isEqualTo("/hello/errors")
.jsonPath("$.status").isEqualTo(404);
}
@Test
void renderErrorResponseWhenBadRequestError() {
client.get()
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
.queryParam("type", "badRequest1")
.build())
.exchange()
.expectStatus().isBadRequest()
.expectBody()
.jsonPath("$.title").isEqualTo("Bad Request")
.jsonPath("$.detail").isEqualTo("Bad Request")
.jsonPath("$.instance").isEqualTo("/hello/errors")
.jsonPath("$.status").isEqualTo(400);
client.get()
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
.queryParam("type", "badRequest2")
.build())
.exchange()
.expectStatus().isBadRequest()
.expectBody()
.jsonPath("$.title").isEqualTo("Bad Request")
.jsonPath("$.detail").isEqualTo("Bad Request for state")
.jsonPath("$.instance").isEqualTo("/hello/errors")
.jsonPath("$.status").isEqualTo(400);
client.get()
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
.queryParam("type", "badRequest3")
.build())
.exchange()
.expectStatus().isBadRequest()
.expectBody()
.jsonPath("$.title").isEqualTo("Bad Request")
.jsonPath("$.detail").isEqualTo("theme install error")
.jsonPath("$.instance").isEqualTo("/hello/errors")
.jsonPath("$.status").isEqualTo(400);
}
@Test
void renderErrorResponseWhenAccessDeniedError() {
client.get()
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
.queryParam("type", "accessDenied")
.build())
.exchange()
.expectStatus().isForbidden()
.expectBody()
.jsonPath("$.title").isEqualTo("Forbidden")
.jsonPath("$.detail").isEqualTo("Access Denied")
.jsonPath("$.instance").isEqualTo("/hello/errors")
.jsonPath("$.status").isEqualTo(403);
}
@Test
void renderErrorResponseWhenOptimisticLockingFailureError() {
client.get()
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
.queryParam("type", "conflict")
.build())
.exchange()
.expectStatus().isEqualTo(HttpStatus.CONFLICT)
.expectBody()
.jsonPath("$.title").isEqualTo("Conflict")
.jsonPath("$.detail").isEqualTo("Version conflict")
.jsonPath("$.instance").isEqualTo("/hello/errors")
.jsonPath("$.status").isEqualTo(409);
}
@TestConfiguration
static class TestConfig {
@Bean
public RouterFunction<ServerResponse> routerFunction() {
return RouterFunctions.route()
.GET("/hello/errors", request -> {
String type = request.queryParam("type").orElse("other");
if (type.equals("notFound")) {
throw new NotFoundException("Not Found");
}
if (type.equals("badRequest1")) {
throw new IllegalArgumentException("Bad Request");
}
if (type.equals("badRequest2")) {
throw new IllegalStateException("Bad Request for state");
}
if (type.equals("badRequest3")) {
throw new ThemeUninstallException("theme install error");
}
if (type.equals("conflict")) {
throw new OptimisticLockingFailureException("Version conflict");
}
if (type.equals("accessDenied")) {
throw new AccessDeniedException("Access Denied");
}
throw new RuntimeException("Unknown Error");
})
.build();
}
}
}