From a76ade8aa80a99f59723962793c51aabefdf4f8c Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Thu, 24 Nov 2022 20:45:07 +0800 Subject: [PATCH] feat: add global error web exception handler (#2741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### 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 新增全局异常处理 ``` --- .../infra/exception/NotFoundException.java | 4 + ...xceptionHandlingProblemDetailsHandler.java | 113 ++++++++++++++ .../GlobalErrorWebExceptionHandler.java | 91 +++++++++++ .../HaloErrorWebFluxAutoConfiguration.java | 54 +++++++ .../app/plugin/PluginNotFoundException.java | 4 +- .../extension/endpoint/UserEndpointTest.java | 2 +- .../GlobalErrorWebExceptionHandlerTest.java | 145 ++++++++++++++++++ 7 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 src/main/java/run/halo/app/infra/exception/handlers/ExceptionHandlingProblemDetailsHandler.java create mode 100644 src/main/java/run/halo/app/infra/exception/handlers/GlobalErrorWebExceptionHandler.java create mode 100644 src/main/java/run/halo/app/infra/exception/handlers/HaloErrorWebFluxAutoConfiguration.java create mode 100644 src/test/java/run/halo/app/infra/exception/handlers/GlobalErrorWebExceptionHandlerTest.java diff --git a/src/main/java/run/halo/app/infra/exception/NotFoundException.java b/src/main/java/run/halo/app/infra/exception/NotFoundException.java index 91a179ec8..2e9936329 100644 --- a/src/main/java/run/halo/app/infra/exception/NotFoundException.java +++ b/src/main/java/run/halo/app/infra/exception/NotFoundException.java @@ -14,4 +14,8 @@ public class NotFoundException extends HaloException { public NotFoundException(String message, Throwable cause) { super(message, cause); } + + public NotFoundException(Throwable cause) { + super(cause); + } } diff --git a/src/main/java/run/halo/app/infra/exception/handlers/ExceptionHandlingProblemDetailsHandler.java b/src/main/java/run/halo/app/infra/exception/handlers/ExceptionHandlingProblemDetailsHandler.java new file mode 100644 index 000000000..09599a369 --- /dev/null +++ b/src/main/java/run/halo/app/infra/exception/handlers/ExceptionHandlingProblemDetailsHandler.java @@ -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 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; + } +} diff --git a/src/main/java/run/halo/app/infra/exception/handlers/GlobalErrorWebExceptionHandler.java b/src/main/java/run/halo/app/infra/exception/handlers/GlobalErrorWebExceptionHandler.java new file mode 100644 index 000000000..c52ece557 --- /dev/null +++ b/src/main/java/run/halo/app/infra/exception/handlers/GlobalErrorWebExceptionHandler.java @@ -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 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 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()) + ) + ); + } +} diff --git a/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorWebFluxAutoConfiguration.java b/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorWebFluxAutoConfiguration.java new file mode 100644 index 000000000..d535a6de4 --- /dev/null +++ b/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorWebFluxAutoConfiguration.java @@ -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 + * 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 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(); + } + } +}