package org.zalando.problem.spring.web.advice; import com.google.gag.annotation.remark.Hack; import lombok.SneakyThrows; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.HttpMediaTypeNotAcceptableException; import org.springframework.web.accept.HeaderContentNegotiationStrategy; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.NativeWebRequest; import org.zalando.problem.Problem; import org.zalando.problem.ProblemBuilder; import org.zalando.problem.ThrowableProblem; import org.zalando.problem.spring.web.advice.custom.CustomAdviceTrait; import org.zalando.problem.spring.web.advice.general.GeneralAdviceTrait; import org.zalando.problem.spring.web.advice.http.HttpAdviceTrait; import org.zalando.problem.spring.web.advice.io.IOAdviceTrait; import org.zalando.problem.spring.web.advice.routing.RoutingAdviceTrait; import org.zalando.problem.spring.web.advice.validation.ValidationAdviceTrait; import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.Response.StatusType; import java.net.URI; import java.util.List; import java.util.Optional; import static java.util.Arrays.asList; import static javax.servlet.RequestDispatcher.ERROR_EXCEPTION; import static org.springframework.http.HttpStatus.NOT_ACCEPTABLE; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.web.context.request.RequestAttributes.SCOPE_REQUEST; import static org.zalando.problem.spring.web.advice.Lists.lengthOfTrailingPartialSubList; import static org.zalando.problem.spring.web.advice.MediaTypes.PROBLEM; import static org.zalando.problem.spring.web.advice.MediaTypes.WILDCARD_JSON; import static org.zalando.problem.spring.web.advice.MediaTypes.X_PROBLEM; /** * <p> * Advice traits are simple interfaces that provide a single method with a default * implementation. They are used to provide {@link ExceptionHandler} implementations to be used in * {@link Controller Controllers} and/or in a {@link ControllerAdvice}. Clients can choose which traits they what to * use à la carte. * </p> * <p> * Advice traits are grouped in packages, based on they use cases. Every package has a composite advice trait that * bundles all traits of that package. Additionally there is one {@link ProblemHandling major composite advice trait} * that in turn bundles all other composites. * </p> * * @see ControllerAdvice * @see ExceptionHandler * @see Throwable * @see Exception * @see Problem * @see ProblemHandling * @see CustomAdviceTrait * @see GeneralAdviceTrait * @see HttpAdviceTrait * @see IOAdviceTrait * @see RoutingAdviceTrait * @see ValidationAdviceTrait */ public interface AdviceTrait { Logger LOG = LoggerFactory.getLogger(AdviceTrait.class); default ResponseEntity<Problem> create(final StatusType status, final Throwable throwable, final NativeWebRequest request) { return create(status, throwable, request, new HttpHeaders()); } default ResponseEntity<Problem> create(final StatusType status, final Throwable throwable, final NativeWebRequest request, final HttpHeaders headers) { return create(throwable, toProblem(throwable, status), request, headers); } default ResponseEntity<Problem> create(final StatusType status, final Throwable throwable, final NativeWebRequest request, URI type) { return create(status, throwable, request, new HttpHeaders(), type); } default ResponseEntity<Problem> create(final StatusType status, final Throwable throwable, final NativeWebRequest request, final HttpHeaders headers, URI type) { return create(throwable, toProblem(throwable, status, type), request, headers); } default ThrowableProblem toProblem(final Throwable throwable, final StatusType status, URI type) { final Throwable cause = throwable.getCause(); final ProblemBuilder builder = Problem.builder() .withTitle(status.getReasonPhrase()) .withStatus(status) .withType(type) .withDetail(throwable.getMessage()); final StackTraceElement[] stackTrace; if (cause == null || !isCausalChainsEnabled()) { stackTrace = throwable.getStackTrace(); } else { builder.withCause(toProblem(cause, status)); final StackTraceElement[] next = cause.getStackTrace(); final StackTraceElement[] current = throwable.getStackTrace(); final int length = current.length - lengthOfTrailingPartialSubList(asList(next), asList(current)); stackTrace = new StackTraceElement[length]; System.arraycopy(current, 0, stackTrace, 0, length); } final ThrowableProblem problem = builder.build(); problem.setStackTrace(stackTrace); return problem; } default ThrowableProblem toProblem(final Throwable throwable, final StatusType status) { return toProblem(throwable, status, Problem.DEFAULT_TYPE); } default boolean isCausalChainsEnabled() { return false; } default ResponseEntity<Problem> create(final ThrowableProblem problem, final NativeWebRequest request) { return create(problem, request, new HttpHeaders()); } default ResponseEntity<Problem> create(final ThrowableProblem problem, final NativeWebRequest request, final HttpHeaders headers) { return create(problem, problem, request, headers); } default ResponseEntity<Problem> create(final Throwable throwable, final Problem problem, final NativeWebRequest request) { return create(throwable, problem, request, new HttpHeaders()); } default ResponseEntity<Problem> create(final Throwable throwable, final Problem problem, final NativeWebRequest request, final HttpHeaders headers) { final HttpStatus status = HttpStatus.valueOf(Optional.ofNullable(problem.getStatus()) .orElse(Status.INTERNAL_SERVER_ERROR) .getStatusCode()); log(throwable, problem, request, status); if (status == HttpStatus.INTERNAL_SERVER_ERROR) { request.setAttribute(ERROR_EXCEPTION, throwable, SCOPE_REQUEST); } return process(negotiate(request).map(contentType -> ResponseEntity.status(status) .headers(headers) .contentType(contentType) .body(problem)) .orElseGet(() -> fallback(throwable, problem, request, headers))); } default void log( @SuppressWarnings("UnusedParameters") final Throwable throwable, @SuppressWarnings("UnusedParameters") final Problem problem, @SuppressWarnings("UnusedParameters") final NativeWebRequest request, final HttpStatus status) { if (status.is4xxClientError()) { LOG.warn("{}: {}", status.getReasonPhrase(), throwable.getMessage()); } else if (status.is5xxServerError()) { LOG.error(status.getReasonPhrase(), throwable); } } @SneakyThrows(HttpMediaTypeNotAcceptableException.class) default Optional<MediaType> negotiate(final NativeWebRequest request) { final HeaderContentNegotiationStrategy negotiator = new HeaderContentNegotiationStrategy(); final List<MediaType> mediaTypes = negotiator.resolveMediaTypes(request); if (mediaTypes.isEmpty()) { return Optional.of(PROBLEM); } for (final MediaType mediaType : mediaTypes) { if (mediaType.includes(APPLICATION_JSON) || mediaType.includes(PROBLEM)) { return Optional.of(PROBLEM); } else if (mediaType.includes(X_PROBLEM)) { return Optional.of(X_PROBLEM); } } @Hack("Accepting application/something+json doesn't make you understand application/problem+json, " + "but a lot of clients miss to send it correctly") final boolean isNeitherAcceptingJsonNorProblemJsonButSomeVendorSpecificJson = mediaTypes.stream().anyMatch(WILDCARD_JSON::includes); if (isNeitherAcceptingJsonNorProblemJsonButSomeVendorSpecificJson) { return Optional.of(PROBLEM); } return Optional.empty(); } default ResponseEntity<Problem> fallback( @SuppressWarnings("UnusedParameters") final Throwable throwable, @SuppressWarnings("UnusedParameters") final Problem problem, @SuppressWarnings("UnusedParameters") final NativeWebRequest request, @SuppressWarnings("UnusedParameters") final HttpHeaders headers) { return ResponseEntity.status(NOT_ACCEPTABLE).body(null); } default ResponseEntity<Problem> process(final ResponseEntity<Problem> entity) { return entity; } }