RestApiExceptionMapperForWeb.java

/*
 * Copyright 2019-2022 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.bremersee.exception;

import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation;
import static org.springframework.util.ClassUtils.getUserClass;

import java.lang.reflect.Method;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.AccessLevel;
import lombok.Getter;
import org.bremersee.exception.RestApiExceptionMapperProperties.ExceptionMappingConfig;
import org.bremersee.exception.annotation.ErrorCode;
import org.bremersee.exception.model.Handler;
import org.bremersee.exception.model.RestApiException;
import org.bremersee.exception.model.StackTraceItem;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.lang.Nullable;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.server.ResponseStatusException;

/**
 * The implementation of a rest api exception mapper for spring web.
 *
 * @author Christian Bremer
 */
public class RestApiExceptionMapperForWeb implements RestApiExceptionMapper {

  @Getter(AccessLevel.PROTECTED)
  private final RestApiExceptionMapperProperties properties;

  @Getter(AccessLevel.PROTECTED)
  private final String applicationName;

  /**
   * Instantiates a new rest api exception mapper.
   *
   * @param properties the properties
   * @param applicationName the application name
   */
  public RestApiExceptionMapperForWeb(
      RestApiExceptionMapperProperties properties,
      String applicationName) {
    this.properties = properties;
    this.applicationName = applicationName;
  }

  /**
   * Detect http status http status.
   *
   * @param exception the exception
   * @param handler the handler
   * @return the http status
   */
  protected HttpStatusCode detectHttpStatus(Throwable exception, Object handler) {
    return Optional.of(exception)
        .flatMap(exc -> {
          if (exc instanceof HttpStatusAware hsa) {
            return Optional.of(HttpStatusCode.valueOf(hsa.status()));
          }
          if (exc instanceof ResponseStatusException rse) {
            return Optional.of(rse.getStatusCode());
          }
          return Optional.empty();
        })
        .or(() -> findHandlerMethod(handler)
            .map(method -> findMergedAnnotation(method, ResponseStatus.class))
            .map(ResponseStatus::code))
        .or(() -> findHandlerClass(handler)
            .map(handlerClass -> findMergedAnnotation(handlerClass, ResponseStatus.class))
            .map(ResponseStatus::code))
        .or(() -> Optional
            .ofNullable(findMergedAnnotation(exception.getClass(), ResponseStatus.class))
            .map(ResponseStatus::code))
        .or(() -> fromStatus(properties.findExceptionMapping(exception).getStatus()))
        .orElse(HttpStatus.INTERNAL_SERVER_ERROR);
  }

  /**
   * From status optional.
   *
   * @param status the status
   * @return the optional
   */
  protected Optional<HttpStatusCode> fromStatus(Integer status) {
    return Optional.ofNullable(status)
        .map(HttpStatus::valueOf);
  }

  /**
   * Gets error.
   *
   * @param throwable the throwable
   * @param httpStatusCode the http status code
   * @return the error
   */
  @Nullable
  protected String getError(Throwable throwable, HttpStatusCode httpStatusCode) {
    if ((throwable instanceof ResponseStatusException rse)
        && !(ObjectUtils.isEmpty(rse.getReason()))) {
      return rse.getReason();
    }
    if (httpStatusCode instanceof HttpStatus hs) {
      return hs.getReasonPhrase();
    }
    return null;
  }

  @Override
  public RestApiException build(
      Throwable exception,
      String requestPath,
      Object handler) {

    HttpStatusCode httpStatus = detectHttpStatus(exception, handler);

    RestApiException restApiException = new RestApiException();

    restApiException.setTimestamp(OffsetDateTime.now(ZoneOffset.UTC));

    restApiException.setStatus(httpStatus.value());

    restApiException.setError(getError(exception, httpStatus));

    ExceptionMappingConfig config = getProperties().findExceptionMappingConfig(exception);

    restApiException = setErrorCode(restApiException, exception, handler, config);

    restApiException = setMessage(restApiException, exception, handler, config);

    restApiException = setClassName(restApiException, exception, config);

    restApiException = setApplication(restApiException, config);

    restApiException = setPath(restApiException, requestPath, config);

    restApiException = setHandler(restApiException, handler, config);

    restApiException = setStackTrace(restApiException, exception.getStackTrace(), config);

    return setCause(restApiException, exception, config);
  }

  /**
   * Find the handler class.
   *
   * @param handler the handler
   * @return the class
   */
  protected Optional<Class<?>> findHandlerClass(Object handler) {
    return Optional.ofNullable(handler)
        .map(h -> {
          if (h instanceof HandlerMethod) {
            return ((HandlerMethod) h).getBean().getClass();
          }
          return (h instanceof Class) ? (Class<?>) h : h.getClass();
        });
  }

  /**
   * Find the handler method.
   *
   * @param handler the handler
   * @return the method
   */
  protected Optional<Method> findHandlerMethod(Object handler) {
    return Optional.ofNullable(handler)
        .filter(h -> h instanceof HandlerMethod)
        .map(h -> ((HandlerMethod) h).getMethod());
  }

  /**
   * Sets error code.
   *
   * @param restApiException the rest api exception
   * @param exception the exception
   * @param handler the handler
   * @param config the config
   * @return the error code
   */
  protected RestApiException setErrorCode(
      RestApiException restApiException,
      Throwable exception,
      Object handler,
      ExceptionMappingConfig config) {

    return Optional.of(exception)
        .filter(exc -> (exc instanceof ErrorCodeAware) && !config.getEvaluateAnnotationFirst())
        .map(exc -> ((ErrorCodeAware) exc).getErrorCode())
        .or(() -> findHandlerMethod(handler)
            .map(method -> findMergedAnnotation(method, ErrorCode.class))
            .map(ErrorCode::value))
        .or(() -> findHandlerClass(handler)
            .map(handlerClass -> findMergedAnnotation(handlerClass, ErrorCode.class))
            .map(ErrorCode::value))
        .or(() -> Optional
            .ofNullable(findMergedAnnotation(getUserClass(exception), ErrorCode.class))
            .map(ErrorCode::value))
        .or(() -> Optional.ofNullable(getProperties().findExceptionMapping(exception).getCode()))
        .filter(errorCode -> !errorCode.isBlank())
        .map(errorCode -> restApiException.toBuilder()
            .errorCode(errorCode)
            .errorCodeInherited(false)
            .build())
        .orElse(restApiException);
  }

  /**
   * Sets message.
   *
   * @param restApiException the rest api exception
   * @param exception the exception
   * @param handler the handler
   * @param config the config
   * @return the message
   */
  protected RestApiException setMessage(
      RestApiException restApiException,
      Throwable exception,
      Object handler,
      ExceptionMappingConfig config) {

    if (!config.getIncludeMessage()) {
      return restApiException;
    }
    return Optional.ofNullable(exception.getMessage())
        .filter(msg -> !msg.isBlank() && !config.getEvaluateAnnotationFirst())
        .or(() -> findHandlerMethod(handler)
            .map(method -> findMergedAnnotation(method, ResponseStatus.class))
            .map(ResponseStatus::reason))
        .or(() -> findHandlerClass(handler)
            .map(handlerClass -> findMergedAnnotation(handlerClass, ResponseStatus.class))
            .map(ResponseStatus::reason))
        .or(() -> Optional
            .ofNullable(findMergedAnnotation(getUserClass(exception), ResponseStatus.class))
            .map(ResponseStatus::reason))
        .or(() -> Optional.ofNullable(getProperties().findExceptionMapping(exception).getMessage()))
        .map(msg -> restApiException.toBuilder().message(msg).build())
        .orElse(restApiException);
  }

  /**
   * Sets class name.
   *
   * @param restApiException the rest api exception
   * @param exception the exception
   * @param config the config
   * @return the class name
   */
  protected RestApiException setClassName(
      RestApiException restApiException,
      Throwable exception,
      ExceptionMappingConfig config) {

    if (!config.getIncludeException()) {
      return restApiException;
    }
    return restApiException.toBuilder()
        .exception(getUserClass(exception).getName())
        .build();
  }

  /**
   * Sets application.
   *
   * @param restApiException the rest api exception
   * @param config the config
   * @return the application
   */
  protected RestApiException setApplication(
      RestApiException restApiException,
      ExceptionMappingConfig config) {

    if (!config.getIncludeApplicationName()) {
      return restApiException;
    }
    return restApiException.toBuilder().application(getApplicationName()).build();
  }

  /**
   * Sets path.
   *
   * @param restApiException the rest api exception
   * @param path the path
   * @param config the config
   * @return the path
   */
  protected RestApiException setPath(
      RestApiException restApiException,
      String path,
      ExceptionMappingConfig config) {

    if (!config.getIncludePath() || isNull(path)) {
      return restApiException;
    }
    return restApiException.toBuilder().path(path).build();
  }

  /**
   * Sets handler.
   *
   * @param restApiException the rest api exception
   * @param handler the handler
   * @param config the config
   * @return the handler
   */
  protected RestApiException setHandler(
      RestApiException restApiException,
      Object handler,
      ExceptionMappingConfig config) {

    if (!config.getIncludeHandler() || isNull(handler)) {
      return restApiException;
    }
    return findHandlerMethod(handler)
        .map(method -> Handler.builder()
            .methodName(method.getName())
            .methodParameterTypes(Arrays.stream(method.getParameterTypes())
                .map(Class::getName)
                .collect(Collectors.toList()))
            .build())
        .flatMap(h -> findHandlerClass(handler)
            .map(cls -> h.toBuilder().className(cls.getName()).build()))
        .map(h -> restApiException.toBuilder().handler(h).build())
        .orElse(restApiException);
  }

  /**
   * Sets stack trace.
   *
   * @param restApiException the rest api exception
   * @param stackTrace the stack trace
   * @param config the config
   * @return the stack trace
   */
  protected RestApiException setStackTrace(
      RestApiException restApiException,
      StackTraceElement[] stackTrace,
      ExceptionMappingConfig config) {

    if (!config.getIncludeStackTrace() || isNull(stackTrace) || stackTrace.length == 0) {
      return restApiException;
    }
    return restApiException.toBuilder()
        .stackTrace(Arrays.stream(stackTrace)
            .map(st -> StackTraceItem
                .builder()
                .declaringClass(st.getClassName())
                .fileName(st.getFileName())
                .lineNumber(st.getLineNumber())
                .methodName(st.getMethodName())
                .build())
            .collect(Collectors.toList()))
        .build();
  }

  /**
   * Sets cause.
   *
   * @param restApiException the rest api exception
   * @param exception the exception
   * @param config the config
   * @return the cause
   */
  protected RestApiException setCause(
      RestApiException restApiException,
      Throwable exception,
      ExceptionMappingConfig config) {

    return Optional.ofNullable(exception)
        .filter(exc -> exc instanceof RestApiExceptionAware)
        .map(exc -> ((RestApiExceptionAware) exc).getRestApiException())
        .map(cause -> reconfigureRestApiException(cause, config))
        .or(() -> Optional.ofNullable(exception)
            .map(Throwable::getCause)
            .map(cause -> {
              RestApiException rae = new RestApiException();
              rae = setErrorCode(rae, cause, null, config);
              rae = setMessage(rae, cause, null, config);
              rae = setClassName(rae, cause, config);
              rae = setStackTrace(rae, cause.getStackTrace(), config);
              rae = setCause(rae, cause.getCause(), config);
              return rae;
            }))
        .map(cause -> {
          RestApiException.RestApiExceptionBuilder builder = restApiException.toBuilder();
          String causeErrorCode = cause.getErrorCode();
          if (nonNull(causeErrorCode) && !causeErrorCode.isBlank()) {
            builder = builder
                .errorCode(causeErrorCode)
                .errorCodeInherited(true);
          }
          if (config.getIncludeCause()) {
            builder = builder.cause(cause);
          }
          return builder.build();
        })
        .orElse(restApiException);
  }

  /**
   * Reconfigure rest api exception rest api exception.
   *
   * @param source the source
   * @param config the config
   * @return the rest api exception
   */
  protected RestApiException reconfigureRestApiException(
      RestApiException source,
      ExceptionMappingConfig config) {

    RestApiException target = new RestApiException();
    target.setId(source.getId());
    target.setTimestamp(source.getTimestamp());
    target.setStatus(source.getStatus());
    target.setError(source.getError());
    if (nonNull(source.getErrorCode()) && !source.getErrorCode().isBlank()) {
      target.setErrorCode(source.getErrorCode());
      target.setErrorCodeInherited(source.getErrorCodeInherited());
    }
    if (config.getIncludeMessage()) {
      target.setMessage(source.getMessage());
    }
    if (config.getIncludeException()) {
      target.setException(source.getException());
    }
    if (config.getIncludeApplicationName()) {
      target.setApplication(source.getApplication());
    }
    if (config.getIncludePath()) {
      target.setPath(source.getPath());
    }
    if (config.getIncludeHandler()) {
      target.setHandler(source.getHandler());
    }
    if (config.getIncludeStackTrace()) {
      target.setStackTrace(source.getStackTrace());
    }
    if (config.getIncludeCause() && nonNull(source.getCause())) {
      target.setCause(reconfigureRestApiException(source.getCause(), config));
    }
    source.furtherDetails().forEach(target::furtherDetails);
    return target;
  }

}