HalComparisonRecursionImpl.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2018 wcm.io
 * %%
 * 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.
 * #L%
 */
package io.wcm.caravan.hal.comparison.impl;

import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import io.wcm.caravan.hal.comparison.HalComparisonContext;
import io.wcm.caravan.hal.comparison.HalComparisonSource;
import io.wcm.caravan.hal.comparison.HalDifference;
import io.wcm.caravan.hal.comparison.impl.context.HalComparisonContextImpl;
import io.wcm.caravan.hal.comparison.impl.embedded.EmbeddedProcessing;
import io.wcm.caravan.hal.comparison.impl.links.LinkProcessing;
import io.wcm.caravan.hal.comparison.impl.properties.PropertyProcessing;
import io.wcm.caravan.hal.resource.HalResource;
import io.wcm.caravan.hal.resource.Link;
import rx.Observable;

/**
 * Implements the main recursion logic and asynchronous loading of resources (but delegates the actual comparison via
 * the {@link PropertyProcessing}, {@link EmbeddedProcessing} and {@link LinkProcessing} interfaces)
 */
public class HalComparisonRecursionImpl {

  private final HalComparisonSource expectedSource;
  private final HalComparisonSource actualSource;

  private final PropertyProcessing propertyProcessing;
  private final EmbeddedProcessing embeddedProcessing;
  private final LinkProcessing linkProcessing;

  private final Set<String> expectedUrlsToIgnore = Collections.synchronizedSet(new HashSet<>());

  HalComparisonRecursionImpl(HalComparisonSource expectedSource, HalComparisonSource actualSource,
      PropertyProcessing propertyProcessing, EmbeddedProcessing embeddedProcessing, LinkProcessing linkProcessing) {

    this.expectedSource = expectedSource;
    this.actualSource = actualSource;

    this.propertyProcessing = propertyProcessing;
    this.embeddedProcessing = embeddedProcessing;
    this.linkProcessing = linkProcessing;

    this.expectedUrlsToIgnore.add(expectedSource.getEntryPointUrl());
  }

  /**
   * This method is initially called by the {@link HalComparisonImpl} with the API entry point resources, and then again
   * for each embedded and linked resource that is to be processed (as decided via the {@link EmbeddedProcessing} and
   * {@link LinkProcessing} interfaces)
   * @param context specifies in which part of the tree the comparison is currently being executed
   * @param expected the "ground truth" resource
   * @param actual the resource to be compared against the ground truth
   * @return an {@link Observable} that emits one {@link HalDifference} object for each difference that was detected in
   *         the given resources (and its linked/embedded resources)
   */
  public Observable<HalDifference> compareRecursively(HalComparisonContextImpl context, HalResource expected, HalResource actual) {

    // don't follow links to any resources later that are already embedded (and have a self link) in the current resource
    expected.collectLinks("self").stream()
        .map(Link::getHref)
        .forEach(url -> expectedUrlsToIgnore.add(url));

    return collectLocalDifferences(context, expected, actual)
        .concatWith(collectEmbeddedDifferences(context, expected, actual))
        .concatWith(collectLinkedDifferences(context, expected, actual));
  }

  Observable<HalDifference> collectLocalDifferences(HalComparisonContextImpl context, HalResource expected, HalResource actual) {

    List<HalDifference> diffs = propertyProcessing.process(context, expected, actual);

    return Observable.from(diffs);
  }

  Observable<HalDifference> collectEmbeddedDifferences(HalComparisonContextImpl context, HalResource expected, HalResource actual) {

    ProcessingResult<HalResource> processingResult = embeddedProcessing.process(context, expected, actual);

    Observable<HalDifference> diffsFromRecursion = processingResult.getPairsToCompare()
        .concatMap(pair -> recurseWithEmbeddedResourcePair(context, expected, pair));

    return processingResult.getDifferences().concatWith(diffsFromRecursion);
  }

  private Observable<HalDifference> recurseWithEmbeddedResourcePair(HalComparisonContextImpl context, HalResource expected,
      PairWithRelation<HalResource> pair) {

    HalComparisonContextImpl newContext = context.withHalPathOfEmbeddedResource(pair, expected);

    return compareRecursively(newContext, pair.getExpected(), pair.getActual());
  }

  Observable<HalDifference> collectLinkedDifferences(HalComparisonContextImpl context, HalResource expected, HalResource actual) {

    ProcessingResult<Link> processingResult = linkProcessing.process(context, expected, actual);

    Observable<HalDifference> diffsFromRecursion = processingResult.getPairsToCompare()
        .filter(pair -> !expectedUrlsToIgnore.contains(pair.getExpected().getHref()))
        .concatMap(pair -> recurseWithLinkedResourcePair(context, expected, pair));

    return processingResult.getDifferences().concatWith(diffsFromRecursion);
  }

  private Observable<HalDifference> recurseWithLinkedResourcePair(HalComparisonContextImpl context, HalResource parentOfExpected, PairWithRelation<Link> pair) {

    String expectedUrl = pair.getExpected().getHref();
    String actualUrl = pair.getActual().getHref();

    expectedUrlsToIgnore.add(expectedUrl);

    HalComparisonContextImpl newContext = context
        .withHalPathOfLinkedResource(pair, parentOfExpected)
        .withNewExpectedUrl(expectedUrl)
        .withNewActualUrl(actualUrl);

    Observable<HalResource> expectedObs = resolveLinkAndAddContextToErrors(
        expectedSource, expectedUrl, context.getExpectedUrl(), newContext);

    Observable<HalResource> actualObs = resolveLinkAndAddContextToErrors(
        actualSource, actualUrl, context.getActualUrl(), newContext);

    return expectedObs
        // wait for both resources to be retrieved before they can be compared
        .zipWith(actualObs, (expectedResource, actualResource) -> compareRecursively(newContext, expectedResource, actualResource))
        // then flatten the Observable<Observable<HalDifference>> returned by zipWith
        .flatMap(diffs -> diffs);

  }

  private Observable<HalResource> resolveLinkAndAddContextToErrors(HalComparisonSource source, String resourceUrl, String contextUrl,
      HalComparisonContext halContext) {
    return source.resolveLink(resourceUrl)
        // we want to wrap any exceptions thrown when resolving this link and add more context information
        // (e.g. where the link that could not be resolved can be found)
        // this seems to be only possible with Observable's #onErrorResumeNext,
        // not Single#onErrorReturn (which leads to a CompositeException arriving at the subscriber
        // that makes it a lot harder to understand the causal chain)
        .toObservable()
        .onErrorResumeNext(ex -> {
          throw new HalComparisonException(halContext, contextUrl, ex);
        });
  }

}