RestApiExceptionParserImpl.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.util.StringUtils.hasText;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.bremersee.exception.model.RestApiException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.util.ObjectUtils;

/**
 * The default implementation of a http response parser that creates a {@link RestApiException}.
 *
 * @author Christian Bremer
 */
@Slf4j
public class RestApiExceptionParserImpl implements RestApiExceptionParser {

  private final ObjectMapper objectMapper;

  private final XmlMapper xmlMapper;

  private final Charset defaultCharset;

  /**
   * Instantiates a new rest api exception parser.
   */
  public RestApiExceptionParserImpl() {
    this(new Jackson2ObjectMapperBuilder());
  }

  /**
   * Instantiates a new rest api exception parser.
   *
   * @param defaultCharset the default charset
   */
  public RestApiExceptionParserImpl(Charset defaultCharset) {
    this(new Jackson2ObjectMapperBuilder(), defaultCharset);
  }

  /**
   * Instantiates a new rest api exception parser.
   *
   * @param objectMapperBuilder the object mapper builder
   */
  public RestApiExceptionParserImpl(Jackson2ObjectMapperBuilder objectMapperBuilder) {
    this(objectMapperBuilder.build(), objectMapperBuilder.createXmlMapper(true).build());
  }

  /**
   * Instantiates a new rest api exception parser.
   *
   * @param objectMapperBuilder the object mapper builder
   * @param charset the charset
   */
  public RestApiExceptionParserImpl(
      Jackson2ObjectMapperBuilder objectMapperBuilder,
      Charset charset) {
    this(objectMapperBuilder.build(), objectMapperBuilder.createXmlMapper(true).build(), charset);
  }

  /**
   * Instantiates a new rest api exception parser.
   *
   * @param objectMapper the object mapper
   * @param xmlMapper the xml mapper
   */
  public RestApiExceptionParserImpl(ObjectMapper objectMapper, XmlMapper xmlMapper) {
    this(objectMapper, xmlMapper, null);
  }

  /**
   * Instantiates a new rest api exception parser.
   *
   * @param objectMapper the object mapper
   * @param xmlMapper the xml mapper
   * @param defaultCharset the default charset
   */
  public RestApiExceptionParserImpl(
      ObjectMapper objectMapper,
      XmlMapper xmlMapper,
      Charset defaultCharset) {
    this.objectMapper = objectMapper;
    this.xmlMapper = xmlMapper;
    this.defaultCharset = Optional.ofNullable(defaultCharset)
        .orElse(StandardCharsets.UTF_8);
  }

  private ObjectMapper getJsonMapper() {
    return objectMapper;
  }

  private XmlMapper getXmlMapper() {
    return xmlMapper;
  }

  /**
   * Gets object mapper.
   *
   * @param responseType the response type
   * @return the object mapper
   */
  Optional<ObjectMapper> getObjectMapper(RestApiResponseType responseType) {
    if (responseType == RestApiResponseType.JSON) {
      return Optional.of(getJsonMapper());
    }
    if (responseType == RestApiResponseType.XML) {
      return Optional.of(getXmlMapper());
    }
    return Optional.empty();
  }

  /**
   * Gets default charset.
   *
   * @return the default charset
   */
  protected Charset getDefaultCharset() {
    return defaultCharset;
  }

  @Override
  public RestApiException parseException(
      byte[] response,
      HttpStatusCode httpStatus,
      HttpHeaders headers) {

    String responseStr;
    if (isNull(response) || response.length == 0) {
      responseStr = null;
    } else {
      responseStr = new String(response, getContentTypeCharset(headers.getContentType()));
    }
    return parseException(responseStr, httpStatus, headers);
  }

  @Override
  public RestApiException parseException(
      String response,
      HttpStatusCode httpStatus,
      HttpHeaders headers) {

    RestApiResponseType responseType = RestApiResponseType
        .detectByContentType(headers.getContentType());
    return Optional.ofNullable(response)
        .filter(res -> !res.isBlank())
        .flatMap(res -> getObjectMapper(responseType).flatMap(om -> {
          try {
            return Optional.of(om.readValue(res, RestApiException.class));
          } catch (Exception ignored) {
            log.debug("Response is not a 'RestApiException' as {}.", responseType.name());
            return Optional.empty();
          }
        }))
        .map(restApiException -> {
          restApiException.setStatus(httpStatus.value());
          if ((httpStatus instanceof HttpStatus status)
              && ObjectUtils.isEmpty(restApiException.getError())) {
            restApiException.setError(status.getReasonPhrase());
          }
          return restApiException;
        })
        .orElseGet(() -> getRestApiExceptionFromHeaders(response, httpStatus, headers));
  }

  /**
   * Gets rest api exception from headers.
   *
   * @param response the response
   * @param httpStatus the http status
   * @param httpHeaders the http headers
   * @return the rest api exception from headers
   */
  protected RestApiException getRestApiExceptionFromHeaders(
      String response,
      HttpStatusCode httpStatus,
      HttpHeaders httpHeaders) {

    RestApiException restApiException = new RestApiException();

    String tmp = httpHeaders.getFirst(RestApiExceptionConstants.ID_HEADER_NAME);
    if (hasText(tmp)) {
      restApiException.setId(tmp);
    }

    tmp = httpHeaders.getFirst(RestApiExceptionConstants.TIMESTAMP_HEADER_NAME);
    restApiException.setTimestamp(parseErrorTimestamp(tmp));

    tmp = httpHeaders.getFirst(RestApiExceptionConstants.CODE_HEADER_NAME);
    if (hasText(tmp)) {
      restApiException.setErrorCode(tmp);

      tmp = httpHeaders.getFirst(RestApiExceptionConstants.CODE_INHERITED_HEADER_NAME);
      if (hasText(tmp)) {
        restApiException.setErrorCodeInherited(Boolean.valueOf(tmp));
      }
    }

    tmp = httpHeaders.getFirst(RestApiExceptionConstants.MESSAGE_HEADER_NAME);
    if (hasText(tmp)) {
      restApiException.setMessage(tmp);
    } else {
      restApiException.setMessage(response);
    }

    tmp = httpHeaders.getFirst(RestApiExceptionConstants.EXCEPTION_HEADER_NAME);
    if (hasText(tmp)) {
      restApiException.setException(tmp);
    }

    tmp = httpHeaders.getFirst(RestApiExceptionConstants.APPLICATION_HEADER_NAME);
    if (hasText(tmp)) {
      restApiException.setApplication(tmp);
    }

    tmp = httpHeaders.getFirst(RestApiExceptionConstants.PATH_HEADER_NAME);
    if (hasText(tmp)) {
      restApiException.setPath(tmp);
    }

    restApiException.setStatus(httpStatus.value());
    if (httpStatus instanceof HttpStatus status) {
      restApiException.setError(status.getReasonPhrase());
    }

    return restApiException;
  }

  /**
   * Gets content type charset.
   *
   * @param contentType the content type
   * @return the content type charset
   */
  protected Charset getContentTypeCharset(MediaType contentType) {
    return Optional.ofNullable(contentType)
        .map(ct -> {
          RestApiResponseType responseType = RestApiResponseType.detectByContentType(ct);
          if (RestApiResponseType.JSON == responseType) {
            return StandardCharsets.UTF_8;
          }
          Charset charset = ct.getCharset();
          if (isNull(charset) && RestApiResponseType.XML == responseType) {
            return StandardCharsets.UTF_8;
          }
          return charset;
        })
        .orElseGet(this::getDefaultCharset);
  }

  /**
   * Parse the 'timestamp' header value.
   *
   * @param value the 'timestamp' header value
   * @return the timestamp
   */
  protected OffsetDateTime parseErrorTimestamp(String value) {
    OffsetDateTime time = null;
    if (nonNull(value)) {
      try {
        time = OffsetDateTime.parse(value, RestApiExceptionConstants.TIMESTAMP_FORMATTER);
      } catch (Exception e) {
        log.debug("Parsing timestamp failed, timestamp = '{}'.", value);
      }
    }
    return time;
  }

}