RestApiComparator.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.spring.test.api.comparator;

import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorAssertionType.ANNOTATION_MUST_NOT_BE_NULL;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorAssertionType.CLASS_MUST_BE_INTERFACE;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorAssertionType.METHOD_MUST_NOT_BE_NULL;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorAssertionType.SAME_ANNOTATION_ATTRIBUTES_SIZE;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorAssertionType.SAME_ANNOTATION_ATTRIBUTE_VALUE;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorAssertionType.SAME_ANNOTATION_SIZE;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorAssertionType.SAME_METHOD_SIZE;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorExclusion.isExcluded;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorPath.PathType.ANNOTATION;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorPath.PathType.ATTRIBUTE;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorPath.PathType.CLASS;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorPath.PathType.METHOD;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorPath.PathType.METHOD_PARAMETER;
import static org.bremersee.spring.test.api.comparator.RestApiComparatorPath.pathBuilder;
import static org.springframework.util.ObjectUtils.isEmpty;

import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;
import org.assertj.core.api.SoftAssertions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;

/**
 * The rest api tester.
 *
 * @author Christian Bremer
 */
public class RestApiComparator {

  private static final Logger log = LoggerFactory.getLogger(RestApiComparator.class);

  private RestApiComparator() {
  }

  /**
   * Assert same api.
   *
   * @param expected the expected
   * @param actual the actual
   * @param exclusions the exclusions
   */
  public static void assertSameApi(
      final Class<?> expected,
      final Class<?> actual,
      final RestApiComparatorExclusion... exclusions) {

    assertSameApi(null, true, expected, actual, exclusions);
  }

  /**
   * Assert same api.
   *
   * @param softAssertions the soft assertions
   * @param assertAll the assert all
   * @param expected the expected
   * @param actual the actual
   * @param exclusions the exclusions
   */
  public static void assertSameApi(
      final SoftAssertions softAssertions,
      final boolean assertAll,
      final Class<?> expected,
      final Class<?> actual,
      final RestApiComparatorExclusion... exclusions) {

    log.info("Assert same api: expected = {}, actual = {}", name(expected),
        name(actual));
    Assert.notNull(expected, "Expected api class must not be null.");
    Assert.notNull(actual, "Actual api class must not be null.");

    SoftAssertions softly;
    boolean internalAssertAll;
    if (nonNull(softAssertions)) {
      softly = softAssertions;
      internalAssertAll = assertAll;
    } else {
      softly = new SoftAssertions();
      internalAssertAll = true;
    }

    RestApiComparatorPath path = pathBuilder().add(CLASS, expected.getSimpleName()).build();
    if (!isExcluded(path, CLASS_MUST_BE_INTERFACE, exclusions)) {
      log.info("""
              Assert given class is an interface:
                - path = {}
                - type = {}""",
          path, CLASS_MUST_BE_INTERFACE);
      softly.assertThat(expected.isInterface())
          .as("Expected api class must be an interface.")
          .isTrue();
    }

    path = pathBuilder().add(CLASS, actual.getSimpleName()).build();
    if (!isExcluded(path, CLASS_MUST_BE_INTERFACE, exclusions)) {
      log.info("""
              Assert given class is an interface:
                - path = {}
                - type = {}""",
          path, CLASS_MUST_BE_INTERFACE);
      softly.assertThat(actual.isInterface())
          .as("Actual api class must be an interface.")
          .isTrue();
    }

    assertSameClassAnnotations(softly, expected, actual, exclusions);
    assertSameMethodAnnotations(softly, expected, actual, exclusions);

    if (internalAssertAll) {
      softly.assertAll();
    }
  }

  private static void assertSameClassAnnotations(
      SoftAssertions softly,
      final Class<?> expected,
      final Class<?> actual,
      final RestApiComparatorExclusion... exclusions) {

    final Annotation[] expectedAnnotations = expected.getAnnotations();
    final Annotation[] actualAnnotations = actual.getAnnotations();
    assertSameAnnotations(
        softly,
        pathBuilder().add(CLASS, actual.getSimpleName()).build(),
        expectedAnnotations,
        actualAnnotations,
        actual,
        exclusions);
  }

  private static void assertSameMethodAnnotations(
      SoftAssertions softly,
      final Class<?> expected,
      final Class<?> actual,
      final RestApiComparatorExclusion... exclusions) {

    final RestApiComparatorPath classPath = pathBuilder().add(CLASS, actual.getSimpleName())
        .build();
    final Method[] expectedMethods = ReflectionUtils.getDeclaredMethods(expected);
    final Method[] actualMethods = ReflectionUtils.getDeclaredMethods(actual);

    if (!isExcluded(classPath, SAME_METHOD_SIZE, exclusions)) {
      log.info("""
              Assert same method size:
                - path = {}
                - type = {}""",
          classPath, SAME_METHOD_SIZE);
      softly.assertThat(actualMethods.length)
          .as("Methods must have the same size on %s and %s.",
              expected.getSimpleName(), actual.getSimpleName())
          .isEqualTo(expectedMethods.length);
    }

    for (final Method expectedMethod : expectedMethods) {
      final RestApiComparatorPath methodPath = classPath.toPathBuilder()
          .add(METHOD, expectedMethod.getName())
          .build();
      final Method actualMethod = ReflectionUtils.findMethod(
          actual, expectedMethod.getName(), expectedMethod.getParameterTypes());
      if (!isExcluded(methodPath, METHOD_MUST_NOT_BE_NULL, exclusions)) {
        log.info("""
                Assert method exists:
                  - path = {}
                  - type = {}""",
            methodPath, METHOD_MUST_NOT_BE_NULL);
        softly.assertThat(actualMethod)
            .as("Method %s (%s) must be present on %s", expectedMethod.getName(),
                parameters(expectedMethod.getParameterTypes()), name(actual))
            .isNotNull();
      }
      if (isNull(actualMethod)) {
        continue;
      }

      final Annotation[] expectedAnnotations = expectedMethod.getAnnotations();
      final Annotation[] actualAnnotations = actualMethod.getAnnotations();
      assertSameAnnotations(
          softly,
          methodPath,
          expectedAnnotations,
          actualAnnotations,
          actualMethod,
          exclusions);

      if (expectedMethod.getParameterCount() > 0) {
        final Parameter[] expectedParameters = expectedMethod.getParameters();
        final Parameter[] actualParameters = actualMethod.getParameters();
        for (int i = 0; i < expectedParameters.length; i++) {
          final Parameter expectedParameter = expectedParameters[i];
          final Parameter actualParameter = actualParameters[i];
          final RestApiComparatorPath methodParameterPath = methodPath.toPathBuilder()
              .add(METHOD_PARAMETER, String.valueOf(i))
              .build();
          assertSameAnnotations(
              softly,
              methodParameterPath,
              expectedParameter.getAnnotations(),
              actualParameter.getAnnotations(),
              actualParameter,
              exclusions);
        }
      }
    }
  }

  private static void assertSameAnnotations(
      final SoftAssertions softly,
      final RestApiComparatorPath path,
      final Annotation[] expectedAnnotations,
      final Annotation[] actualAnnotations,
      final AnnotatedElement actualAnnotatedElement,
      final RestApiComparatorExclusion... exclusions) {

    if (!isExcluded(path, SAME_ANNOTATION_SIZE, exclusions)) {
      log.info("""
              Assert same annotation size:
                - path = {}
                - type = {}""",
          path, SAME_ANNOTATION_SIZE);
      softly.assertThat(actualAnnotations.length)
          .as("Annotations must have the same size on %s.", path)
          .isEqualTo(expectedAnnotations.length);
    }
    for (final Annotation expectedAnnotation : expectedAnnotations) {
      final Annotation actualAnnotation = AnnotationUtils.getAnnotation(
          actualAnnotatedElement, expectedAnnotation.annotationType());
      assertSameAnnotation(
          softly,
          path,
          expectedAnnotation,
          actualAnnotation,
          exclusions);
    }
  }

  private static void assertSameAnnotation(
      final SoftAssertions softly,
      final RestApiComparatorPath path,
      final Annotation expected,
      final Annotation actual,
      final RestApiComparatorExclusion... exclusions) {

    final RestApiComparatorPath annotationPath = path.toPathBuilder()
        .add(ANNOTATION, expected.annotationType().getSimpleName())
        .build();
    if (!isExcluded(annotationPath, ANNOTATION_MUST_NOT_BE_NULL, exclusions)) {
      log.info("""
              Assert annotation is not null:
                - path = {}
                - type = {}""",
          annotationPath, ANNOTATION_MUST_NOT_BE_NULL);
      softly.assertThat(actual)
          .as("Annotation %s is missing on %s.", name(expected), path)
          .isNotNull();
    }
    if (isNull(actual)) {
      return;
    }
    final Map<String, Object> expectedAttributes = AnnotationUtils.getAnnotationAttributes(
        expected, true, false);
    final Map<String, Object> actualAttributes = AnnotationUtils.getAnnotationAttributes(
        actual, true, false);
    assertSameAttributes(
        softly,
        annotationPath,
        expectedAttributes,
        actualAttributes,
        exclusions);
  }

  private static void assertSameAttributes(
      final SoftAssertions softly,
      final RestApiComparatorPath annotationPath,
      final Map<String, Object> expected,
      final Map<String, Object> actual,
      final RestApiComparatorExclusion... exclusions) {

    if (!isExcluded(annotationPath, SAME_ANNOTATION_ATTRIBUTES_SIZE, exclusions)) {
      log.info("""
              Assert same annotation attributes size:
                - path = {}
                - type = {}""",
          annotationPath, SAME_ANNOTATION_ATTRIBUTES_SIZE);
      softly.assertThat(actual.size())
          .as("Attributes of annotation (%s) must have the same size.", annotationPath)
          .isEqualTo(expected.size());
    }
    for (final Map.Entry<String, Object> expectedAttribute : expected.entrySet()) {
      final Object expectedAttributeValue = expectedAttribute.getValue();
      final Object actualAttributeValue = actual.get(expectedAttribute.getKey());
      assertSameAttributeValues(
          softly,
          annotationPath.toPathBuilder().add(ATTRIBUTE, expectedAttribute.getKey()).build(),
          expectedAttributeValue,
          actualAttributeValue,
          exclusions);
    }
  }

  private static void assertSameAttributeValues(
      final SoftAssertions softly,
      final RestApiComparatorPath attributePath,
      final Object expected,
      final Object actual,
      final RestApiComparatorExclusion... exclusions) {

    if (!isExcluded(attributePath, SAME_ANNOTATION_ATTRIBUTE_VALUE, exclusions)) {
      log.info("""
              Assert same attribute values:
                - path     = {}
                - expected = {}
                - actual   = {}
                - type     = {}""",
          attributePath, expected, actual, SAME_ANNOTATION_ATTRIBUTE_VALUE);

      if (isNull(exclusions)) {
        softly.assertThat(actual)
            .as("Attribute (%s) must be null.", attributePath)
            .isNull();
      } else if (expected instanceof Annotation[] expectedAnnotations) {
        softly.assertThat(actual)
            .as("Attributes must be instance of 'Annotation[]' on %s", attributePath)
            .isInstanceOf(Annotation[].class);
        final Annotation[] actualAnnotations = (Annotation[]) actual;
        softly.assertThat(actualAnnotations.length)
            .as("Annotations must have same size on %s", attributePath)
            .isEqualTo(expectedAnnotations.length);
        for (int i = 0; i < actualAnnotations.length; i++) {
          final Annotation expectedAnnotation = expectedAnnotations[i];
          final Annotation actualAnnotation = actualAnnotations[i];
          assertSameAnnotation(
              softly,
              attributePath,
              expectedAnnotation,
              actualAnnotation,
              exclusions);
        }
      } else if (expected instanceof Annotation expectedAnnotation) {
        final Annotation actualAnnotation = (Annotation) actual;
        assertSameAnnotation(
            softly,
            attributePath,
            expectedAnnotation,
            actualAnnotation,
            exclusions);
      } else {
        if (expected.getClass().isArray()) {
          final Object[] expectedArray = (Object[]) expected;
          final Object[] actualArray = (Object[]) actual;
          softly.assertThat(actualArray)
              .as("Arrays must be equal on %s", attributePath)
              .isEqualTo(expectedArray);
        } else {
          softly.assertThat(actual)
              .as("Objects must be equal on %s", attributePath)
              .isEqualTo(expected);
        }
      }
    }
  }

  private static String name(final Annotation annotation) {
    return nonNull(annotation) ? name(annotation.annotationType()) : "null";
  }

  private static String name(final Class<?> clazz) {
    return nonNull(clazz) ? clazz.getName() : "null";
  }

  private static String parameters(final Class<?>[] parameters) {
    if (isEmpty(parameters)) {
      return "";
    }
    return StringUtils.collectionToCommaDelimitedString(Arrays.stream(parameters)
        .map(RestApiComparator::name).collect(Collectors.toList()));
  }

}