RestApiTester.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.test.web;

import static java.lang.String.format;
import static org.bremersee.test.web.RestApiAssertionType.ANNOTATION_MUST_NOT_BE_NULL;
import static org.bremersee.test.web.RestApiAssertionType.CLASS_MUST_BE_INTERFACE;
import static org.bremersee.test.web.RestApiAssertionType.METHOD_MUST_NOT_BE_NULL;
import static org.bremersee.test.web.RestApiAssertionType.SAME_ANNOTATION_ATTRIBUTES_SIZE;
import static org.bremersee.test.web.RestApiAssertionType.SAME_ANNOTATION_ATTRIBUTE_VALUE;
import static org.bremersee.test.web.RestApiAssertionType.SAME_ANNOTATION_SIZE;
import static org.bremersee.test.web.RestApiAssertionType.SAME_METHOD_SIZE;
import static org.bremersee.test.web.RestApiTesterExclusion.isExcluded;
import static org.bremersee.test.web.RestApiTesterPath.PathType.ANNOTATION;
import static org.bremersee.test.web.RestApiTesterPath.PathType.ATTRIBUTE;
import static org.bremersee.test.web.RestApiTesterPath.PathType.CLASS;
import static org.bremersee.test.web.RestApiTesterPath.PathType.METHOD;
import static org.bremersee.test.web.RestApiTesterPath.PathType.METHOD_PARAMETER;
import static org.bremersee.test.web.RestApiTesterPath.pathBuilder;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;

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

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

  private RestApiTester() {
  }

  /**
   * 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 RestApiTesterExclusion... exclusions) {

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

    RestApiTesterPath path = pathBuilder().add(CLASS, expected.getSimpleName()).build();
    if (!isExcluded(path, CLASS_MUST_BE_INTERFACE, exclusions)) {
      log.info("Assert given class is an interface:"
              + "\n  - path = {}"
              + "\n  - type = {}",
          path, CLASS_MUST_BE_INTERFACE);
      assertTrue(expected.isInterface(), "Expected api class must be an interface.");
    }

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

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

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

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

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

    final RestApiTesterPath 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:"
              + "\n  - path = {}"
              + "\n  - type = {}",
          classPath, SAME_METHOD_SIZE);
      assertEquals(
          expectedMethods.length,
          actualMethods.length,
          format("Methods must have the same size on %s and %s.",
              expected.getSimpleName(), actual.getSimpleName()));
    }

    for (final Method expectedMethod : expectedMethods) {
      final RestApiTesterPath 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:"
                + "\n  - path = {}"
                + "\n  - type = {}",
            methodPath, METHOD_MUST_NOT_BE_NULL);
        assertNotNull(
            actualMethod,
            format("Method %s (%s) is missing on %s", expectedMethod.getName(),
                parameters(expectedMethod.getParameterTypes()), name(actual)));
      } else if (actualMethod == null) {
        continue;
      }

      final Annotation[] expectedAnnotations = expectedMethod.getAnnotations();
      final Annotation[] actualAnnotations = actualMethod.getAnnotations();
      assertSameAnnotations(
          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 RestApiTesterPath methodParameterPath = methodPath.toPathBuilder()
              .add(METHOD_PARAMETER, String.valueOf(i))
              .build();
          assertSameAnnotations(
              methodParameterPath,
              expectedParameter.getAnnotations(),
              actualParameter.getAnnotations(),
              actualParameter,
              exclusions);
        }
      }
    }
  }

  private static void assertSameAnnotations(
      final RestApiTesterPath path,
      final Annotation[] expectedAnnotations,
      final Annotation[] actualAnnotations,
      final AnnotatedElement actualAnnotatedElement,
      final RestApiTesterExclusion... exclusions) {

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

  private static void assertSameAnnotation(
      final RestApiTesterPath path,
      final Annotation expected,
      final Annotation actual,
      final RestApiTesterExclusion... exclusions) {

    final RestApiTesterPath 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:"
              + "\n  - path = {}"
              + "\n  - type = {}",
          annotationPath, ANNOTATION_MUST_NOT_BE_NULL);
      assertNotNull(
          actual,
          format("Annotation %s is missing on %s.", name(expected), path));
    }
    if (actual == null) {
      return;
    }
    final Map<String, Object> expectedAttributes = AnnotationUtils.getAnnotationAttributes(
        expected, true, false);
    final Map<String, Object> actualAttributes = AnnotationUtils.getAnnotationAttributes(
        actual, true, false);
    assertSameAttributes(
        annotationPath,
        expectedAttributes,
        actualAttributes,
        exclusions);
  }

  private static void assertSameAttributes(
      final RestApiTesterPath annotationPath,
      final Map<String, Object> expected,
      final Map<String, Object> actual,
      final RestApiTesterExclusion... exclusions) {

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

  private static void assertSameAttributeValues(
      final RestApiTesterPath attributePath,
      final Object expected,
      final Object actual,
      final RestApiTesterExclusion... exclusions) {

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

      if (expected == null) {
        assertNull(
            actual,
            format("Attribute (%s) must be null.", attributePath));
      } else if (expected instanceof Annotation[]) {
        assertTrue(actual instanceof Annotation[]);
        final Annotation[] expectedAnnotations = (Annotation[]) expected;
        final Annotation[] actualAnnotations = (Annotation[]) actual;
        assertEquals(expectedAnnotations.length, actualAnnotations.length);
        for (int i = 0; i < actualAnnotations.length; i++) {
          final Annotation expectedAnnotation = expectedAnnotations[i];
          final Annotation actualAnnotation = actualAnnotations[i];
          assertSameAnnotation(
              attributePath,
              expectedAnnotation,
              actualAnnotation,
              exclusions);
        }
      } else if (expected instanceof Annotation) {
        final Annotation expectedAnnotation = (Annotation) expected;
        final Annotation actualAnnotation = (Annotation) actual;
        assertSameAnnotation(
            attributePath,
            expectedAnnotation,
            actualAnnotation,
            exclusions);
      } else {
        if (expected.getClass().isArray()) {
          final Object[] expectedArray = (Object[]) expected;
          final Object[] actualArray = (Object[]) actual;
          assertArrayEquals(expectedArray, actualArray);
        } else {
          assertEquals(
              expected, actual);
        }
      }
    }
  }

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

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

  private static String parameters(final Class<?>[] parameters) {
    if (parameters == null || parameters.length == 0) {
      return "";
    }
    return StringUtils.collectionToCommaDelimitedString(Arrays.stream(parameters)
        .map(RestApiTester::name).collect(Collectors.toList()));
  }

}