mirror of https://github.com/halo-dev/halo
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
parent
edfc9ac1e1
commit
a76ade8aa8
|
@ -14,4 +14,8 @@ public class NotFoundException extends HaloException {
|
|||
public NotFoundException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public NotFoundException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue