mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(api): map Spring MVC client errors to 4xx
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
7b3b4c9337
commit
a92bb89ceb
4 changed files with 150 additions and 0 deletions
|
|
@ -10,17 +10,25 @@ import com.iflytek.skillhub.observability.RequestIdAccessor;
|
|||
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
|
||||
import com.iflytek.skillhub.storage.StorageAccessException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.HttpMediaTypeNotAcceptableException;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
/**
|
||||
* Translates application, domain, auth, and infrastructure exceptions into the platform's JSON API
|
||||
|
|
@ -83,6 +91,48 @@ public class GlobalExceptionHandler {
|
|||
apiResponseFactory.error(400, "error.badRequest"));
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
MissingServletRequestParameterException.class,
|
||||
HttpMessageNotReadableException.class,
|
||||
MethodArgumentTypeMismatchException.class
|
||||
})
|
||||
public ResponseEntity<ApiResponse<Void>> handleMvcBadRequest(Exception ex, HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.BAD_REQUEST, "error.badRequest", request);
|
||||
return ResponseEntity.badRequest().body(
|
||||
apiResponseFactory.error(400, "error.badRequest"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMethodNotAllowed(
|
||||
HttpRequestMethodNotSupportedException ex,
|
||||
HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.METHOD_NOT_ALLOWED, "error.methodNotAllowed", request);
|
||||
ResponseEntity.BodyBuilder response = ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED);
|
||||
Set<HttpMethod> supportedMethods = ex.getSupportedHttpMethods();
|
||||
if (supportedMethods != null && !supportedMethods.isEmpty()) {
|
||||
response.allow(supportedMethods.toArray(HttpMethod[]::new));
|
||||
}
|
||||
return response.body(apiResponseFactory.error(405, "error.methodNotAllowed"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnsupportedMediaType(
|
||||
HttpMediaTypeNotSupportedException ex,
|
||||
HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "error.unsupportedMediaType", request);
|
||||
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(
|
||||
apiResponseFactory.error(415, "error.unsupportedMediaType"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleNotAcceptable(
|
||||
HttpMediaTypeNotAcceptableException ex,
|
||||
HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.NOT_ACCEPTABLE, "error.notAcceptable", request);
|
||||
return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(
|
||||
apiResponseFactory.error(406, "error.notAcceptable"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(SecurityException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleForbidden(SecurityException ex, HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ error.auth.sessionBootstrap.disabled=Session bootstrap is disabled
|
|||
error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0}
|
||||
error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found
|
||||
error.badRequest=Invalid request
|
||||
error.methodNotAllowed=HTTP method is not supported
|
||||
error.unsupportedMediaType=Unsupported media type
|
||||
error.notAcceptable=Requested response media type is not acceptable
|
||||
error.forbidden=Forbidden
|
||||
error.apiToken.scope.missing=API token is missing required scope: {0}
|
||||
error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ error.auth.sessionBootstrap.disabled=会话引导能力未启用
|
|||
error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0}
|
||||
error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话
|
||||
error.badRequest=请求参数不合法
|
||||
error.methodNotAllowed=不支持的请求方法
|
||||
error.unsupportedMediaType=不支持的请求内容类型
|
||||
error.notAcceptable=无法返回客户端接受的内容类型
|
||||
error.forbidden=没有权限执行该操作
|
||||
error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0}
|
||||
error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0}
|
||||
|
|
|
|||
|
|
@ -29,10 +29,18 @@ import org.mockito.Mock;
|
|||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.support.StaticMessageSource;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.web.HttpMediaTypeNotAcceptableException;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
|
@ -59,6 +67,11 @@ class GlobalExceptionHandlerTest {
|
|||
void setUp() {
|
||||
StaticMessageSource messageSource = new StaticMessageSource();
|
||||
messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out");
|
||||
messageSource.addMessage("error.badRequest", java.util.Locale.getDefault(), "Invalid request");
|
||||
messageSource.addMessage("error.methodNotAllowed", java.util.Locale.getDefault(), "HTTP method is not supported");
|
||||
messageSource.addMessage("error.unsupportedMediaType", java.util.Locale.getDefault(), "Unsupported media type");
|
||||
messageSource.addMessage("error.notAcceptable", java.util.Locale.getDefault(),
|
||||
"Requested response media type is not acceptable");
|
||||
requestIdAccessor = new RequestIdAccessor();
|
||||
ApiResponseFactory responseFactory = new ApiResponseFactory(
|
||||
messageSource,
|
||||
|
|
@ -175,6 +188,82 @@ class GlobalExceptionHandlerTest {
|
|||
.isSameAs(ex);
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleMvcBadRequest_shouldReturn400WithoutUnhandledErrorLog() {
|
||||
attachAppender();
|
||||
prepareClientErrorRequest("POST", "/api/v1/skills?bad=value");
|
||||
|
||||
List<Exception> exceptions = List.of(
|
||||
new MissingServletRequestParameterException("namespace", "String"),
|
||||
new HttpMessageNotReadableException("Malformed request body"),
|
||||
new MethodArgumentTypeMismatchException(
|
||||
"bad-value", Long.class, "id", null, new NumberFormatException("bad-value"))
|
||||
);
|
||||
|
||||
for (Exception exception : exceptions) {
|
||||
ResponseEntity<ApiResponse<Void>> response = handler.handleMvcBadRequest(exception, request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo(400);
|
||||
}
|
||||
assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
|
||||
.contains("status=400")
|
||||
.contains("code=error.badRequest")
|
||||
.doesNotContain("Unhandled API exception"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleMethodNotAllowed_shouldReturn405AndAllowHeader() {
|
||||
attachAppender();
|
||||
prepareClientErrorRequest("DELETE", "/api/v1/skills/demo");
|
||||
|
||||
ResponseEntity<ApiResponse<Void>> response = handler.handleMethodNotAllowed(
|
||||
new HttpRequestMethodNotSupportedException("DELETE", List.of("GET", "POST")), request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.METHOD_NOT_ALLOWED);
|
||||
assertThat(response.getHeaders().getAllow()).containsExactlyInAnyOrder(HttpMethod.GET, HttpMethod.POST);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo(405);
|
||||
assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
|
||||
.contains("status=405")
|
||||
.contains("code=error.methodNotAllowed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleUnsupportedMediaType_shouldReturn415() {
|
||||
attachAppender();
|
||||
prepareClientErrorRequest("POST", "/api/v1/skills");
|
||||
|
||||
ResponseEntity<ApiResponse<Void>> response = handler.handleUnsupportedMediaType(
|
||||
new HttpMediaTypeNotSupportedException(
|
||||
MediaType.APPLICATION_XML,
|
||||
List.of(MediaType.APPLICATION_JSON)), request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo(415);
|
||||
assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
|
||||
.contains("status=415")
|
||||
.contains("code=error.unsupportedMediaType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleNotAcceptable_shouldReturn406() {
|
||||
attachAppender();
|
||||
prepareClientErrorRequest("GET", "/api/v1/skills");
|
||||
|
||||
ResponseEntity<ApiResponse<Void>> response = handler.handleNotAcceptable(
|
||||
new HttpMediaTypeNotAcceptableException(List.of(MediaType.APPLICATION_JSON)), request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_ACCEPTABLE);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo(406);
|
||||
assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
|
||||
.contains("status=406")
|
||||
.contains("code=error.notAcceptable"));
|
||||
}
|
||||
|
||||
private void authenticateRequest() {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
STABLE_USER_ID,
|
||||
|
|
@ -188,6 +277,11 @@ class GlobalExceptionHandlerTest {
|
|||
new UsernamePasswordAuthenticationToken(principal, null, List.of()));
|
||||
}
|
||||
|
||||
private void prepareClientErrorRequest(String method, String sanitizedTarget) {
|
||||
when(request.getMethod()).thenReturn(method);
|
||||
when(sensitiveLogSanitizer.sanitizeRequestTarget(request)).thenReturn(sanitizedTarget);
|
||||
}
|
||||
|
||||
private void attachAppender() {
|
||||
logger.setLevel(Level.INFO);
|
||||
appender = new ListAppender<>();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue