ISSUE-257: ErrorResponse format description (#294)

* ISSUE-257: ErrorResponse format description & processing added
This commit is contained in:
iliax 2021-03-23 16:44:57 +03:00 committed by GitHub
parent 992e8b0898
commit 106c42e4cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 222 additions and 84 deletions

View file

@ -1,7 +1,7 @@
package com.provectus.kafka.ui.config; package com.provectus.kafka.ui.config;
import com.provectus.kafka.ui.exception.NotFoundException; import com.provectus.kafka.ui.exception.NotFoundException;
import com.provectus.kafka.ui.exception.ReadOnlyException; import com.provectus.kafka.ui.exception.ReadOnlyModeException;
import com.provectus.kafka.ui.service.ClustersStorage; import com.provectus.kafka.ui.service.ClustersStorage;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -45,6 +45,6 @@ public class ReadOnlyModeFilter implements WebFilter {
return chain.filter(exchange); return chain.filter(exchange);
} }
return Mono.error(ReadOnlyException::new); return Mono.error(ReadOnlyModeException::new);
} }
} }

View file

@ -1,6 +1,5 @@
package com.provectus.kafka.ui.exception; package com.provectus.kafka.ui.exception;
import org.springframework.http.HttpStatus;
public abstract class CustomBaseException extends RuntimeException { public abstract class CustomBaseException extends RuntimeException {
public CustomBaseException() { public CustomBaseException() {
@ -23,5 +22,5 @@ public abstract class CustomBaseException extends RuntimeException {
super(message, cause, enableSuppression, writableStackTrace); super(message, cause, enableSuppression, writableStackTrace);
} }
public abstract HttpStatus getResponseStatusCode(); public abstract ErrorCode getErrorCode();
} }

View file

@ -1,7 +1,5 @@
package com.provectus.kafka.ui.exception; package com.provectus.kafka.ui.exception;
import org.springframework.http.HttpStatus;
public class DuplicateEntityException extends CustomBaseException { public class DuplicateEntityException extends CustomBaseException {
public DuplicateEntityException(String message) { public DuplicateEntityException(String message) {
@ -9,7 +7,7 @@ public class DuplicateEntityException extends CustomBaseException {
} }
@Override @Override
public HttpStatus getResponseStatusCode() { public ErrorCode getErrorCode() {
return HttpStatus.CONFLICT; return ErrorCode.DUPLICATED_ENTITY;
} }
} }

View file

@ -0,0 +1,45 @@
package com.provectus.kafka.ui.exception;
import java.util.HashSet;
import org.apache.logging.log4j.LogManager;
import org.springframework.http.HttpStatus;
public enum ErrorCode {
UNEXPECTED(5000, HttpStatus.INTERNAL_SERVER_ERROR),
BINDING_FAIL(4001, HttpStatus.BAD_REQUEST),
VALIDATION_FAIL(4002, HttpStatus.BAD_REQUEST),
ENTITY_NOT_FOUND(4003, HttpStatus.NOT_FOUND),
READ_ONLY_MODE_ENABLE(4004, HttpStatus.METHOD_NOT_ALLOWED),
REBALANCE_IN_PROGRESS(4005, HttpStatus.CONFLICT),
DUPLICATED_ENTITY(4006, HttpStatus.CONFLICT),
UNPROCESSABLE_ENTITY(4007, HttpStatus.UNPROCESSABLE_ENTITY);
static {
// codes uniqueness check
var codes = new HashSet<Integer>();
for (ErrorCode value : ErrorCode.values()) {
if (!codes.add(value.code())) {
LogManager.getLogger()
.warn("Multiple {} values refer to code {}", ErrorCode.class, value.code);
}
}
}
private final int code;
private final HttpStatus httpStatus;
ErrorCode(int code, HttpStatus httpStatus) {
this.code = code;
this.httpStatus = httpStatus;
}
public int code() {
return code;
}
public HttpStatus httpStatus() {
return httpStatus;
}
}

View file

@ -1,31 +0,0 @@
package com.provectus.kafka.ui.exception;
import java.util.Map;
import org.springframework.boot.web.reactive.error.DefaultErrorAttributes;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.reactive.function.server.ServerRequest;
@Component
public class GlobalErrorAttributes extends DefaultErrorAttributes {
public static final String STATUS = "status";
@Override
public Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {
Map<String, Object> errorAttrs = super.getErrorAttributes(request, includeStackTrace);
includeCustomErrorAttributes(request, errorAttrs);
return errorAttrs;
}
private void includeCustomErrorAttributes(ServerRequest request, Map<String, Object> errorAttrs) {
Throwable error = getError(request);
if (error instanceof WebClientResponseException) {
var webClientError = (WebClientResponseException) error;
errorAttrs.put(STATUS, webClientError.getStatusCode());
} else if (error instanceof CustomBaseException) {
var customBaseError = (CustomBaseException) error;
errorAttrs.put(STATUS, customBaseError.getResponseStatusCode());
}
}
}

View file

@ -1,34 +1,40 @@
package com.provectus.kafka.ui.exception; package com.provectus.kafka.ui.exception;
import com.google.common.collect.Sets;
import com.provectus.kafka.ui.model.ErrorResponse;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.boot.autoconfigure.web.ResourceProperties; import org.springframework.boot.autoconfigure.web.ResourceProperties;
import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler; import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler;
import org.springframework.boot.web.reactive.error.ErrorAttributes; import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters; import org.springframework.validation.FieldError;
import org.springframework.web.bind.support.WebExchangeBindException;
import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
/**
* The order of our global error handler is -2 to give it a higher priority than the default
* {@link org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler}
* which is registered at <code>@Order(-1)</code>.
*/
@Component @Component
@Order(-2) @Order(Ordered.HIGHEST_PRECEDENCE)
public class GlobalErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler { public class GlobalErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler {
public GlobalErrorWebExceptionHandler(GlobalErrorAttributes errorAttributes, public GlobalErrorWebExceptionHandler(ErrorAttributes errorAttributes,
ResourceProperties resourceProperties, ResourceProperties resourceProperties,
ApplicationContext applicationContext, ApplicationContext applicationContext,
ServerCodecConfigurer codecConfigurer) { ServerCodecConfigurer codecConfigurer) {
@ -42,14 +48,107 @@ public class GlobalErrorWebExceptionHandler extends AbstractErrorWebExceptionHan
} }
private Mono<ServerResponse> renderErrorResponse(ServerRequest request) { private Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
Map<String, Object> errorAttributes = getErrorAttributes(request, false); Throwable throwable = getError(request);
HttpStatus statusCode = Optional.ofNullable(errorAttributes.get(GlobalErrorAttributes.STATUS))
.map(code -> code instanceof Integer ? HttpStatus.valueOf((Integer) code) : // validation and params binding errors
(HttpStatus) code) if (throwable instanceof WebExchangeBindException) {
.orElse(HttpStatus.BAD_REQUEST); return render((WebExchangeBindException) throwable, request);
return ServerResponse }
.status(statusCode)
.contentType(MediaType.APPLICATION_JSON) // requests mapping & access errors
.body(BodyInserters.fromValue(errorAttributes)); if (throwable instanceof ResponseStatusException) {
return render((ResponseStatusException) throwable, request);
}
// custom exceptions
if (throwable instanceof CustomBaseException) {
return render((CustomBaseException) throwable, request);
}
return renderDefault(throwable, request);
} }
private Mono<ServerResponse> renderDefault(Throwable throwable, ServerRequest request) {
var response = new ErrorResponse()
.code(ErrorCode.UNEXPECTED.code())
.message(throwable.getMessage())
.requestId(requestId(request))
.timestamp(currentTimestamp());
return ServerResponse
.status(ErrorCode.UNEXPECTED.httpStatus())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response);
}
private Mono<ServerResponse> render(CustomBaseException baseException, ServerRequest request) {
ErrorCode errorCode = baseException.getErrorCode();
var response = new ErrorResponse()
.code(errorCode.code())
.message(baseException.getMessage())
.requestId(requestId(request))
.timestamp(currentTimestamp());
return ServerResponse
.status(errorCode.httpStatus())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response);
}
private Mono<ServerResponse> render(WebExchangeBindException exception, ServerRequest request) {
Map<String, Set<String>> fieldErrorsMap = exception.getFieldErrors().stream()
.collect(Collectors
.toMap(FieldError::getField, f -> Set.of(extractFieldErrorMsg(f)), Sets::union));
var fieldsErrors = fieldErrorsMap.entrySet().stream()
.map(e -> {
var err = new com.provectus.kafka.ui.model.FieldError();
err.setFieldName(e.getKey());
err.setRestrictions(List.copyOf(e.getValue()));
return err;
}).collect(Collectors.toList());
var message = fieldsErrors.isEmpty()
? exception.getMessage()
: "Fields validation failure";
var response = new ErrorResponse()
.code(ErrorCode.BINDING_FAIL.code())
.message(message)
.requestId(requestId(request))
.timestamp(currentTimestamp())
.fieldsErrors(fieldsErrors);
return ServerResponse
.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response);
}
private Mono<ServerResponse> render(ResponseStatusException exception, ServerRequest request) {
String msg = coalesce(exception.getReason(), exception.getMessage(), "Server error");
var response = new ErrorResponse()
.code(ErrorCode.UNEXPECTED.code())
.message(msg)
.requestId(requestId(request))
.timestamp(currentTimestamp());
return ServerResponse
.status(exception.getStatus())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response);
}
private String requestId(ServerRequest request) {
return request.exchange().getRequest().getId();
}
private BigDecimal currentTimestamp() {
return BigDecimal.valueOf(System.currentTimeMillis());
}
private String extractFieldErrorMsg(FieldError fieldError) {
return coalesce(fieldError.getDefaultMessage(), fieldError.getCode(), "Invalid field value");
}
private <T> T coalesce(T... items) {
return Stream.of(items).filter(Objects::nonNull).findFirst().orElse(null);
}
} }

View file

@ -1,6 +1,5 @@
package com.provectus.kafka.ui.exception; package com.provectus.kafka.ui.exception;
import org.springframework.http.HttpStatus;
public class NotFoundException extends CustomBaseException { public class NotFoundException extends CustomBaseException {
@ -9,7 +8,7 @@ public class NotFoundException extends CustomBaseException {
} }
@Override @Override
public HttpStatus getResponseStatusCode() { public ErrorCode getErrorCode() {
return HttpStatus.NOT_FOUND; return ErrorCode.ENTITY_NOT_FOUND;
} }
} }

View file

@ -1,15 +0,0 @@
package com.provectus.kafka.ui.exception;
import org.springframework.http.HttpStatus;
public class ReadOnlyException extends CustomBaseException {
public ReadOnlyException() {
super("This cluster is in read-only mode.");
}
@Override
public HttpStatus getResponseStatusCode() {
return HttpStatus.METHOD_NOT_ALLOWED;
}
}

View file

@ -0,0 +1,14 @@
package com.provectus.kafka.ui.exception;
public class ReadOnlyModeException extends CustomBaseException {
public ReadOnlyModeException() {
super("This cluster is in read-only mode.");
}
@Override
public ErrorCode getErrorCode() {
return ErrorCode.READ_ONLY_MODE_ENABLE;
}
}

View file

@ -1,6 +1,5 @@
package com.provectus.kafka.ui.exception; package com.provectus.kafka.ui.exception;
import org.springframework.http.HttpStatus;
public class RebalanceInProgressException extends CustomBaseException { public class RebalanceInProgressException extends CustomBaseException {
@ -9,7 +8,7 @@ public class RebalanceInProgressException extends CustomBaseException {
} }
@Override @Override
public HttpStatus getResponseStatusCode() { public ErrorCode getErrorCode() {
return HttpStatus.CONFLICT; return ErrorCode.REBALANCE_IN_PROGRESS;
} }
} }

View file

@ -1,6 +1,5 @@
package com.provectus.kafka.ui.exception; package com.provectus.kafka.ui.exception;
import org.springframework.http.HttpStatus;
public class UnprocessableEntityException extends CustomBaseException { public class UnprocessableEntityException extends CustomBaseException {
@ -9,7 +8,7 @@ public class UnprocessableEntityException extends CustomBaseException {
} }
@Override @Override
public HttpStatus getResponseStatusCode() { public ErrorCode getErrorCode() {
return HttpStatus.UNPROCESSABLE_ENTITY; return ErrorCode.UNPROCESSABLE_ENTITY;
} }
} }

View file

@ -1,6 +1,5 @@
package com.provectus.kafka.ui.exception; package com.provectus.kafka.ui.exception;
import org.springframework.http.HttpStatus;
public class ValidationException extends CustomBaseException { public class ValidationException extends CustomBaseException {
public ValidationException(String message) { public ValidationException(String message) {
@ -8,7 +7,7 @@ public class ValidationException extends CustomBaseException {
} }
@Override @Override
public HttpStatus getResponseStatusCode() { public ErrorCode getErrorCode() {
return HttpStatus.BAD_REQUEST; return ErrorCode.VALIDATION_FAIL;
} }
} }

View file

@ -1058,6 +1058,39 @@ paths:
components: components:
schemas: schemas:
ErrorResponse:
description: Error object that will be returned with 4XX and 5XX HTTP statuses
type: object
properties:
code:
type: integer
description: Internal error code (can be used for message formatting & localization on UI)
message:
type: string
description: Error message
timestamp:
type: number
description: Response unix timestamp in ms
requestId:
type: string
description: Unique server-defined request id for convenient debugging
fieldsErrors:
type: array
items:
$ref: '#/components/schemas/FieldError'
FieldError:
type: object
properties:
fieldName:
type: string
description: Name of field that violated format
restrictions:
description: Field format violations description (ex. ["size must be between 0 and 20", "must be a well-formed email address"])
type: array
items:
type: string
Cluster: Cluster:
type: object type: object
properties: properties: