All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.github.tomakehurst.wiremock.matching.EqualToXmlPattern Maven / Gradle / Ivy

There is a newer version: 3.10.0
Show newest version
/*
 * Copyright (C) 2016-2025 Thomas Akehurst
 *
 * 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 com.github.tomakehurst.wiremock.matching;

import static com.github.tomakehurst.wiremock.common.Exceptions.throwUnchecked;
import static com.github.tomakehurst.wiremock.common.LocalNotifier.notifier;
import static com.github.tomakehurst.wiremock.common.Strings.isNullOrEmpty;
import static org.xmlunit.diff.ComparisonType.*;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.tomakehurst.wiremock.common.xml.Xml;
import com.github.tomakehurst.wiremock.stubbing.SubEvent;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.*;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.xmlunit.XMLUnitException;
import org.xmlunit.builder.DiffBuilder;
import org.xmlunit.builder.Input;
import org.xmlunit.diff.*;
import org.xmlunit.placeholder.PlaceholderDifferenceEvaluator;

public class EqualToXmlPattern extends StringValuePattern {

  private static final Set COUNTED_COMPARISONS =
      Set.of(
          ELEMENT_TAG_NAME,
          SCHEMA_LOCATION,
          NO_NAMESPACE_SCHEMA_LOCATION,
          NODE_TYPE,
          NAMESPACE_URI,
          TEXT_VALUE,
          PROCESSING_INSTRUCTION_TARGET,
          PROCESSING_INSTRUCTION_DATA,
          ELEMENT_NUM_ATTRIBUTES,
          ATTR_VALUE,
          CHILD_NODELIST_LENGTH,
          CHILD_LOOKUP,
          ATTR_NAME_LOOKUP);

  private final DocumentBuilderFactory documentBuilderFactory;

  private final Boolean enablePlaceholders;
  private final String placeholderOpeningDelimiterRegex;
  private final String placeholderClosingDelimiterRegex;
  private final DifferenceEvaluator diffEvaluator;
  private final Set exemptedComparisons;
  private final Boolean ignoreOrderOfSameNode;
  private final NamespaceAwareness namespaceAwareness;
  private final Set countedComparisons;
  private final Document expectedXmlDoc;

  public EqualToXmlPattern(@JsonProperty("equalToXml") String expectedValue) {
    this(expectedValue, null, null, null, null, null, null);
  }

  public EqualToXmlPattern(
      @JsonProperty("equalToXml") String expectedValue,
      @JsonProperty("enablePlaceholders") Boolean enablePlaceholders,
      @JsonProperty("ignoreOrderOfSameNode") boolean ignoreOrderOfSameNode) {
    this(expectedValue, enablePlaceholders, null, null, null, ignoreOrderOfSameNode, null);
  }

  @JsonCreator
  public EqualToXmlPattern(
      @JsonProperty("equalToXml") String expectedValue,
      @JsonProperty("enablePlaceholders") Boolean enablePlaceholders,
      @JsonProperty("placeholderOpeningDelimiterRegex") String placeholderOpeningDelimiterRegex,
      @JsonProperty("placeholderClosingDelimiterRegex") String placeholderClosingDelimiterRegex,
      @JsonProperty("exemptedComparisons") Set exemptedComparisons,
      @JsonProperty("ignoreOrderOfSameNode") Boolean ignoreOrderOfSameNode,
      @JsonProperty("namespaceAwareness") NamespaceAwareness namespaceAwareness) {

    super(expectedValue);
    documentBuilderFactory = newDocumentBuilderFactory(namespaceAwareness);
    // Throw an exception if we can't parse the document
    expectedXmlDoc = Xml.read(expectedValue, documentBuilderFactory);
    this.enablePlaceholders = enablePlaceholders;
    this.placeholderOpeningDelimiterRegex = placeholderOpeningDelimiterRegex;
    this.placeholderClosingDelimiterRegex = placeholderClosingDelimiterRegex;
    this.exemptedComparisons = exemptedComparisons;
    this.ignoreOrderOfSameNode = ignoreOrderOfSameNode;
    this.namespaceAwareness = namespaceAwareness;
    Set comparisonsToExempt = new HashSet<>();
    if (exemptedComparisons != null) {
      comparisonsToExempt.addAll(exemptedComparisons);
    }
    this.countedComparisons =
        COUNTED_COMPARISONS.stream()
            .filter(e -> !comparisonsToExempt.contains(e))
            .collect(Collectors.toSet());

    IgnoreUncountedDifferenceEvaluator baseDifferenceEvaluator =
        new IgnoreUncountedDifferenceEvaluator(comparisonsToExempt);
    if (enablePlaceholders != null && enablePlaceholders) {
      diffEvaluator =
          DifferenceEvaluators.chain(
              baseDifferenceEvaluator,
              new PlaceholderDifferenceEvaluator(
                  placeholderOpeningDelimiterRegex, placeholderClosingDelimiterRegex));
    } else {
      diffEvaluator = baseDifferenceEvaluator;
    }
  }

  public String getEqualToXml() {
    return expectedValue;
  }

  @Override
  public String getExpected() {
    return Xml.prettyPrint(getValue());
  }

  public Boolean isEnablePlaceholders() {
    return enablePlaceholders;
  }

  public Boolean isIgnoreOrderOfSameNode() {
    return ignoreOrderOfSameNode;
  }

  public String getPlaceholderOpeningDelimiterRegex() {
    return placeholderOpeningDelimiterRegex;
  }

  public String getPlaceholderClosingDelimiterRegex() {
    return placeholderClosingDelimiterRegex;
  }

  public Set getExemptedComparisons() {
    return exemptedComparisons;
  }

  public NamespaceAwareness getNamespaceAwareness() {
    return namespaceAwareness;
  }

  @Override
  public MatchResult match(final String value) {
    return new MatchResult() {
      @Override
      public boolean isExactMatch() {
        if (isNullOrEmpty(value)) {
          return false;
        }
        try {
          DiffBuilder diffBuilder =
              DiffBuilder.compare(Input.from(expectedXmlDoc))
                  .withTest(value)
                  .withComparisonController(ComparisonControllers.StopWhenDifferent)
                  .ignoreWhitespace()
                  .withDifferenceEvaluator(diffEvaluator)
                  .withNodeMatcher(new OrderInvariantNodeMatcher(ignoreOrderOfSameNode))
                  .withDocumentBuilderFactory(documentBuilderFactory);
          if (namespaceAwareness == NamespaceAwareness.LEGACY) {
            // See NamespaceAwareness javadoc for details of why this is set here.
            diffBuilder.ignoreComments();
          }
          Diff diff = diffBuilder.build();

          return !diff.hasDifferences();
        } catch (XMLUnitException e) {
          appendSubEvent(SubEvent.warning(e.getMessage()));

          notifier()
              .info(
                  "Failed to process XML. "
                      + e.getMessage()
                      + "\nExpected:\n"
                      + expectedValue
                      + "\n\nActual:\n"
                      + value);
          return false;
        }
      }

      @Override
      public double getDistance() {
        if (isNullOrEmpty(value)) {
          return 1.0;
        }

        final AtomicInteger totalComparisons = new AtomicInteger(0);
        final AtomicInteger differences = new AtomicInteger(0);

        Diff diff;
        try {
          DiffBuilder diffBuilder =
              DiffBuilder.compare(Input.from(expectedValue))
                  .withTest(value)
                  .ignoreWhitespace()
                  .withDifferenceEvaluator(diffEvaluator)
                  .withComparisonListeners(
                      (comparison, outcome) -> {
                        if (countedComparisons.contains(comparison.getType())
                            && comparison.getControlDetails().getValue() != null) {
                          totalComparisons.incrementAndGet();
                          if (outcome == ComparisonResult.DIFFERENT) {
                            differences.incrementAndGet();
                          }
                        }
                      })
                  .withDocumentBuilderFactory(documentBuilderFactory);
          if (namespaceAwareness == NamespaceAwareness.LEGACY) {
            diffBuilder.ignoreComments();
          }
          diff = diffBuilder.build();
        } catch (XMLUnitException e) {
          notifier()
              .info(
                  "Failed to process XML. "
                      + e.getMessage()
                      + "\nExpected:\n"
                      + expectedValue
                      + "\n\nActual:\n"
                      + value);
          return 1.0;
        }

        notifier()
            .info(
                StreamSupport.stream(diff.getDifferences().spliterator(), false)
                    .map(Object::toString)
                    .collect(Collectors.joining("\n")));

        return differences.doubleValue() / totalComparisons.doubleValue();
      }
    };
  }

  private static DocumentBuilderFactory newDocumentBuilderFactory(
      NamespaceAwareness namespaceAwareness) {
    DocumentBuilderFactory factory = Xml.newDocumentBuilderFactory();
    try {
      factory.setFeature("http://apache.org/xml/features/include-comments", false);
      factory.setFeature(
          "http://xml.org/sax/features/namespaces",
          namespaceAwareness == null || namespaceAwareness == NamespaceAwareness.STRICT);
    } catch (ParserConfigurationException e) {
      throwUnchecked(e);
    }
    return factory;
  }

  private static class IgnoreUncountedDifferenceEvaluator implements DifferenceEvaluator {

    private final Set finalCountedComparisons;

    public IgnoreUncountedDifferenceEvaluator(Set exemptedComparisons) {
      finalCountedComparisons =
          exemptedComparisons != null
              ? COUNTED_COMPARISONS.stream()
                  .filter(e -> !exemptedComparisons.contains(e))
                  .collect(Collectors.toSet())
              : COUNTED_COMPARISONS;
    }

    @Override
    public ComparisonResult evaluate(Comparison comparison, ComparisonResult outcome) {
      if (finalCountedComparisons.contains(comparison.getType())
          && comparison.getControlDetails().getValue() != null) {
        return outcome;
      }

      return ComparisonResult.EQUAL;
    }
  }

  public EqualToXmlPattern exemptingComparisons(ComparisonType... comparisons) {
    return new EqualToXmlPattern(
        expectedValue,
        enablePlaceholders,
        placeholderOpeningDelimiterRegex,
        placeholderClosingDelimiterRegex,
        new HashSet<>(Arrays.asList(comparisons)),
        ignoreOrderOfSameNode,
        namespaceAwareness);
  }

  public EqualToXmlPattern withNamespaceAwareness(NamespaceAwareness namespaceAwareness) {
    return new EqualToXmlPattern(
        expectedValue,
        enablePlaceholders,
        placeholderOpeningDelimiterRegex,
        placeholderClosingDelimiterRegex,
        exemptedComparisons,
        ignoreOrderOfSameNode,
        namespaceAwareness);
  }

  private static final class OrderInvariantNodeMatcher extends DefaultNodeMatcher {
    private static Boolean secondaryOrderByTextContent;

    public OrderInvariantNodeMatcher(Boolean secondaryOrderByTextContent) {
      OrderInvariantNodeMatcher.secondaryOrderByTextContent = secondaryOrderByTextContent;
    }

    @Override
    public Iterable> match(
        Iterable controlNodes, Iterable testNodes) {

      return super.match(sort(controlNodes), sort(testNodes));
    }

    private static Iterable sort(Iterable nodes) {
      return StreamSupport.stream(nodes.spliterator(), false)
          .sorted(getComparator())
          .collect(Collectors.toList());
    }

    private static Comparator getComparator() {
      if (Objects.nonNull(secondaryOrderByTextContent) && secondaryOrderByTextContent) {
        return Comparator.comparing(Node::getLocalName).thenComparing(Node::getTextContent);
      } else {
        return Comparator.comparing(Node::getLocalName);
      }
    }
  }

  /**
   * This enum represents how the pattern will treat XML namespaces when matching.
   *
   * 

{@link NamespaceAwareness#LEGACY} represents the old way that namespaces were treated. This * had a lot of unpredictability and some behaviours were more of a side effect of other * implementation details, rather than intentional. A key detail is that the original {@link * DocumentBuilderFactory} was not namespace aware, but the XSLT transform performed by {@link * DiffBuilder#ignoreComments()} seems to return a document that is semi-namespace aware, so some * namespace aware functionality was available. Now {@link DiffBuilder#ignoreComments()} has been * replaced by setting the {@link DocumentBuilderFactory} to ignore comment on read (much more * performant and predictable), so is only used to produce the legacy namespace aware behaviour. * *

{@link NamespaceAwareness#STRICT} and {@link NamespaceAwareness#NONE} represent firmer, more * intentional behaviour around how namespaces are handled. The details of how each option behaves * are documented below: * *

{@link NamespaceAwareness#LEGACY} behaviour: * *

    *
  • Namespace prefixes do not need to be bound to a namespace URI. *
  • Element namespace prefixes (and their corresponding namespace URIs) are ignored (e.g. * `<th:thing>Match this</th:thing>` == `<st:thing>Match this</st:thing>`) *
      *
    • Element prefixes seem to effectively be totally removed from the document by the * XSLT transform performed by {@link DiffBuilder#ignoreComments()} (and no namespace * URI is assigned to the element). *
    *
  • Attributes are compared by their full name (i.e. namespace prefixes are NOT ignored) * (e.g. `<thing th:attr="abc">Match this</thing>` != `<thing st:attr="abc">Match * this</thing>`) *
      *
    • The XSLT transform performed by {@link DiffBuilder#ignoreComments()} does not * assign a namespace URI to attributes, so XMLUnit uses the attribute's full name. *
    *
  • xmlns namespaced attributes are ignored (e.g. `<thing * xmlns:th="https://thing.com">Match this</thing>` == `<thing * xmlns:st="https://stuff.com">Match this</thing>`) *
      *
    • XMLUnit ignores all attributes namespaced to http://www.w3.org/2000/xmlns/, which * all xmlns prefixed attributes are assigned to by the XSLT transform performed by * {@link DiffBuilder#ignoreComments()}. *
    *
  • Element default namespace attributes (i.e. `xmlns` attributes) are NOT ignored unless * NAMESPACE_URI comparison type is explicitly excluded (e.g. `<thing * xmlns="https://thing.com">Match this</thing>` != `<thing * xmlns="https://stuff.com">Match this</thing>`) *
      *
    • Like xmlns namespaced attributes, XMLUnit ignores all attributes namespaced to * http://www.w3.org/2000/xmlns/, which all xmlns attributes are assigned to by the * XSLT transform performed by {@link DiffBuilder#ignoreComments()}. *
    • The difference between default xmlns attributes and xmlns prefixed * attributes is that the XSLT transform performed by {@link * DiffBuilder#ignoreComments()} assigns the namespace URI of default xmlns attributes * to the attributed element, which is why matching will fail (unless NAMESPACE_URI * comparison type is explicitly excluded). *
    *
* *

{@link NamespaceAwareness#STRICT} behaviour: * *

    *
  • Namespace prefixes need to be bound to a namespace URI. *
  • Element and attribute namespace URIs are compared, but their prefixes are ignored. *
      *
    • Namespace URIs can be explicitly excluded. Although, due to how XMLUnit's engine is * implemented, excluding NAMESPACE_URI does not work with attributes (see XMLUnit issue). *
    *
  • The namespaces defined by xmlns namespaced attributes are compared, but the attributes * themselves are ignored (e.g. `<thing xmlns:th="https://thing.com">Match * this</thing>` == `<thing xmlns:st="https://stuff.com">Match this</thing>`) *
      *
    • XMLUnit ignores all attributes namespaced to http://www.w3.org/2000/xmlns/, which * all default and prefixed xmlns attributes are assigned to by when the document * builder factory is namespace aware. *
    *
* *

{@link NamespaceAwareness#NONE} behaviour: * *

    *
  • Namespace prefixes do not need to be bound to a namespace URI. *
  • Element and attribute are compared by their full name and all namespace URIs are ignored. *
  • xmlns attributes are not ignored and are treated like any other attribute. *
*/ public enum NamespaceAwareness { STRICT, LEGACY, NONE, } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy