RestApiExceptionMapperProperties.java

/*
 * Copyright 2019 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 java.util.ArrayList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.http.HttpStatus;

/**
 * Configuration properties for the rest api exception handler or resolver.
 *
 * @author Christian Bremer
 */
@ConfigurationProperties(prefix = "bremersee.exception-mapping")
@Getter
@Setter
@ToString
@EqualsAndHashCode
public class RestApiExceptionMapperProperties {

  /**
   * The request paths the handler is responsible for. Default is empty.
   */
  private List<String> apiPaths = new ArrayList<>();

  /**
   * The default values of a rest api exception that will be set, if a value is not detected. The default values are:
   * <table style="border: 1px solid">
   * <thead>
   * <tr>
   * <th style="border: 1px solid">attribute</th>
   * <th style="border: 1px solid">value</th>
   * </tr>
   * </thead>
   * <tbody>
   * <tr>
   * <td style="border: 1px solid">status</td>
   * <td style="border: 1px solid">500</td>
   * </tr>
   * <tr>
   * <td style="border: 1px solid">message</td>
   * <td style="border: 1px solid">Internal Server Error</td>
   * </tr>
   * <tr>
   * <td style="border: 1px solid">code</td>
   * <td style="border: 1px solid">UNSPECIFIED</td>
   * </tr>
   * </tbody>
   * </table>
   */
  private ExceptionMapping defaultExceptionMapping;

  /**
   * Values ​​of errors whose values ​​can not be determined automatically. The name of the exception can be a package,
   * too (e. g. org.bremersee.foobar.*).
   *
   * <p>Examples application.yml:
   * <pre>
   * bremersee:
   *   exception-mapping:
   *     exception-mappings:
   *     - exception-class-name: org.springframework.security.access.AccessDeniedException
   *       status: 403
   *       message: Forbidden
   *       code: XYZ:0815
   *     - exception-class-name: javax.persistence.EntityNotFoundException
   *       status: 404
   *       message: Not Found
   *       code: GEN:404
   * </pre>
   */
  private List<ExceptionMapping> exceptionMappings = new ArrayList<>();

  /**
   * The default configuration of the exception mapping.
   *
   * <table style="border: 1px solid">
   * <thead>
   * <tr>
   * <th style="border: 1px solid">attribute</th>
   * <th style="border: 1px solid">value</th>
   * </tr>
   * </thead>
   * <tbody>
   * <tr>
   * <td style="border: 1px solid">includeExceptionClassName</td>
   * <td style="border: 1px solid">true</td>
   * </tr>
   * <tr>
   * <td style="border: 1px solid">includeApplicationName</td>
   * <td style="border: 1px solid">true</td>
   * </tr>
   * <tr>
   * <td style="border: 1px solid">includePath</td>
   * <td style="border: 1px solid">true</td>
   * </tr>
   * <tr>
   * <td style="border: 1px solid">includeHandler</td>
   * <td style="border: 1px solid">false</td>
   * </tr>
   * <tr>
   * <td style="border: 1px solid">includeStackTrace</td>
   * <td style="border: 1px solid">false</td>
   * </tr>
   * <tr>
   * <td style="border: 1px solid">includeCause</td>
   * <td style="border: 1px solid">true</td>
   * </tr>
   * <tr>
   * <td style="border: 1px solid">evaluateAnnotationFirst</td>
   * <td style="border: 1px solid">false</td>
   * </tr>
   * </tbody>
   * </table>
   *
   * <p>If two values of a key are available (e. g. per annotation (see {@link
   * org.springframework.web.bind.annotation.ResponseStatus}) and member attribute) it is possible
   * with {@code evaluateAnnotationFirst} to specify which one should be used.
   */
  private ExceptionMappingConfig defaultExceptionMappingConfig;

  /**
   * Specifies mapping configuration per exception class.  The name of the exception can be a package, too (e. g.
   * org.bremersee.foobar.*).
   *
   * <p>Examples application.yml:
   * <pre>
   * bremersee:
   *   exception-mapping:
   *     exception-mapping-configs:
   *     - exception-class-name: org.springframework.security.access.AccessDeniedException
   *       include-exception-class-name: false
   *       include-handler: true
   *       include-cause: true
   *     - exception-class-name: org.springframework.*
   *       include-exception-class-name: true
   *       include-handler: true
   *       include-cause: false
   * </pre>
   */
  private List<ExceptionMappingConfig> exceptionMappingConfigs = new ArrayList<>();

  /**
   * Instantiates rest api exception mapper properties.
   */
  public RestApiExceptionMapperProperties() {

    defaultExceptionMapping = new ExceptionMapping();
    defaultExceptionMapping.setCode(null);
    defaultExceptionMapping.setMessage(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
    defaultExceptionMapping.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
    defaultExceptionMapping.setExceptionClassName("*");

    defaultExceptionMappingConfig = new ExceptionMappingConfig();

    exceptionMappings.add(new ExceptionMapping(
        IllegalArgumentException.class.getName(),
        HttpStatus.BAD_REQUEST,
        null));

    exceptionMappings.add(new ExceptionMapping(
        "org.springframework.security.access.AccessDeniedException",
        HttpStatus.FORBIDDEN,
        null));

    exceptionMappings.add(new ExceptionMapping(
        "javax.persistence.EntityNotFoundException",
        HttpStatus.NOT_FOUND,
        null));
  }

  /**
   * Find exception mapping.
   *
   * @param throwable the throwable
   * @return the exception mapping
   */
  @SuppressWarnings("WeakerAccess")
  public ExceptionMapping findExceptionMapping(Throwable throwable) {
    return getExceptionMappings()
        .stream()
        .filter(exceptionMapping -> matches(
            throwable, exceptionMapping.getExceptionClassName()))
        .findFirst()
        .orElseGet(this::getDefaultExceptionMapping);
  }

  /**
   * Find exception mapping config.
   *
   * @param throwable the throwable
   * @return the exception mapping config
   */
  @SuppressWarnings("WeakerAccess")
  public ExceptionMappingConfig findExceptionMappingConfig(
      final Throwable throwable) {

    return getExceptionMappingConfigs()
        .stream()
        .filter(exceptionMappingConfig -> matches(
            throwable, exceptionMappingConfig.getExceptionClassName()))
        .findFirst()
        .orElseGet(this::getDefaultExceptionMappingConfig);
  }

  private boolean matches(final Throwable throwable, final String exceptionClassName) {
    if (throwable == null || exceptionClassName == null) {
      return false;
    }
    if (throwable.getClass().getName().equals(exceptionClassName)) {
      return true;
    }
    if (exceptionClassName.endsWith(".*")) {
      final String packagePrefix = exceptionClassName.substring(0, exceptionClassName.length() - 1);
      if (throwable.getClass().getName().startsWith(packagePrefix)) {
        return true;
      }
    }
    if (matches(throwable.getCause(), exceptionClassName)) {
      return true;
    }
    return matches(throwable.getClass().getSuperclass(), exceptionClassName);
  }

  private boolean matches(final Class<?> exceptionClass, final String exceptionClassName) {
    if (exceptionClass == null || exceptionClassName == null) {
      return false;
    }
    if (exceptionClass.getName().equals(exceptionClassName)) {
      return true;
    }
    if (exceptionClassName.endsWith(".*")) {
      final String packagePrefix = exceptionClassName.substring(0, exceptionClassName.length() - 1);
      if (exceptionClass.getName().startsWith(packagePrefix)) {
        return true;
      }
    }
    return matches(exceptionClass.getSuperclass(), exceptionClassName);
  }

  /**
   * The exception mapping.
   */
  @ToString
  @EqualsAndHashCode
  @NoArgsConstructor
  @AllArgsConstructor
  public static class ExceptionMapping {

    /**
     * Instantiates a new exception mapping.
     *
     * @param exceptionClassName the exception class name
     * @param httpStatus the http status
     * @param code the code
     */
    @SuppressWarnings("WeakerAccess")
    public ExceptionMapping(String exceptionClassName, HttpStatus httpStatus, String code) {
      this.exceptionClassName = exceptionClassName;
      if (httpStatus != null) {
        this.status = httpStatus.value();
        this.message = httpStatus.getReasonPhrase();
      }
      this.code = code;
    }

    @Getter
    @Setter
    private String exceptionClassName;

    @Setter
    private int status;

    @Getter
    @Setter
    private String message;

    @Getter
    @Setter
    private String code;

    /**
     * Gets status.
     *
     * @return the status
     */
    public int getStatus() {
      if (HttpStatus.resolve(status) == null) {
        return HttpStatus.INTERNAL_SERVER_ERROR.value();
      }
      return status;
    }

  }

  /**
   * The exception mapping config.
   */
  @SuppressWarnings("WeakerAccess")
  @ToString
  @EqualsAndHashCode
  @NoArgsConstructor
  @AllArgsConstructor
  public static class ExceptionMappingConfig {

    @Getter
    @Setter
    private String exceptionClassName;

    @Getter
    @Setter
    private boolean includeExceptionClassName = true;

    @Getter
    @Setter
    private boolean includeApplicationName = true;

    @Getter
    @Setter
    private boolean includePath = true;

    @Getter
    @Setter
    private boolean includeHandler = false;

    @Getter
    @Setter
    private boolean includeStackTrace = false;

    @Getter
    @Setter
    private boolean includeCause = true;

    @Getter
    @Setter
    private boolean evaluateAnnotationFirst = false;

  }

}