Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
<artifactId>json</artifactId>
<version>20240303</version>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
Expand Down
5 changes: 1 addition & 4 deletions src/main/java/org/skyscreamer/jsonassert/Customization.java
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,6 @@ public boolean matches(Object actual, Object expected) {
*/
public boolean matches(String prefix, Object actual, Object expected,
JSONCompareResult result) throws ValueMatcherException {
if (comparator instanceof LocationAwareValueMatcher) {
return ((LocationAwareValueMatcher<Object>)comparator).equal(prefix, actual, expected, result);
}
return comparator.equal(actual, expected);
return CustomizationEvaluator.matches(comparator, prefix, actual, expected, result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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.skyscreamer.jsonassert;

final class CustomizationEvaluator {
private CustomizationEvaluator() {}

/**
* Return true if actual value matches expected value using this
* Customization's comparator. The equal method used for comparison depends
* on type of comparator.
*
* @param comparator Comparator to use while comparing JSON items
*
* @param prefix
* JSON path of the JSON item being tested (only used if
* comparator is a LocationAwareValueMatcher)
* @param actual
* JSON value being tested
* @param expected
* expected JSON value
* @param result
* JSONCompareResult to which match failure may be passed (only
* used if comparator is a LocationAwareValueMatcher)
* @return true if expected and actual equal or any difference has already
* been passed to specified result instance, false otherwise.
* @throws ValueMatcherException
* if expected and actual values not equal and ValueMatcher
* needs to override default comparison failure message that
* would be generated if this method returned false.
*/
public static boolean matches(ValueMatcher<Object> comparator, String prefix, Object actual, Object expected,
JSONCompareResult result) throws ValueMatcherException {
if (comparator instanceof LocationAwareValueMatcher) {
return ((LocationAwareValueMatcher<Object>)comparator).equal(prefix, actual, expected, result);
}
return comparator.equal(actual, expected);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* 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.skyscreamer.jsonassert;

import com.jayway.jsonpath.JsonPath;

/**
* Specifies a <a href="https://goessner.net/articles/JsonPath/">JSONPath</a>-based customisation.
* <br><br>
* For supported queries, refer to:
* <ul>
* <li><a href="https://github.com/json-path/JsonPath">Documentation</a> of the <b>Jayway JsonPath project</b></li>
* <li><a href="https://goessner.net/articles/JsonPath/">Documentation</a> of the <b>original Stefan Goessner JsonPath implementation</b></li>
* </ul>
*
* @author Shane B. (<a href="mailto:shane@wander.dev">shane@wander.dev</a>)
*/
public class JSONPathCustomization {
private static final ValueMatcher<Object> IGNORE_FIELD = (expected, actual) -> true;

private final JsonPath jsonPath;
private final ValueMatcher<Object> comparator;

/**
* Defines a JSONAssert customisation.
* <br>
* For all fields that match the specified path,
* the defined custom comparator {@link ValueMatcher} will be evaluated in place of comparing exactly
* to the <code>expected</code> JSON.
* @param jsonPath <a href="https://goessner.net/articles/JsonPath/">JSONPath</a> expression specifying which items the <code>comparator</code> should be evaluated for.
* @param comparator Comparator that should be applied for all items that match the <code>jsonPath</code> expression
*/
public JSONPathCustomization(String jsonPath, ValueMatcher<Object> comparator) {
assert jsonPath != null;
assert comparator != null;
this.jsonPath = JsonPath.compile(jsonPath);
this.comparator = comparator;
}

/**
* Utility that constructs a generic customisation that will ignore all fields matching the JSONPath query.
* @param jsonPath <a href="https://goessner.net/articles/JsonPath/">JSONPath</a>-query specifying which
* elements should get ignored
*/
public static JSONPathCustomization ofIgnore(String jsonPath) {
return new JSONPathCustomization(jsonPath, IGNORE_FIELD);
}

public JsonPath getJsonPath() {
return jsonPath;
}

/**
* Return true if actual value matches expected value using this
* Customization's comparator. The equal method used for comparison depends
* on type of comparator.
* See: {@link Customization}; this is an exact copy.
*
* @param prefix
* JSON path of the JSON item being tested (only used if
* comparator is a LocationAwareValueMatcher)
* @param actual
* JSON value being tested
* @param expected
* expected JSON value
* @param result
* JSONCompareResult to which match failure may be passed (only
* used if comparator is a LocationAwareValueMatcher)
* @return true if expected and actual equal or any difference has already
* been passed to specified result instance, false otherwise.
* @throws ValueMatcherException
* if expected and actual values not equal and ValueMatcher
* needs to override default comparison failure message that
* would be generated if this method returned false.
*/
public boolean matches(String prefix, Object actual, Object expected,
JSONCompareResult result) throws ValueMatcherException {
return CustomizationEvaluator.matches(comparator, prefix, actual, expected, result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* 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.skyscreamer.jsonassert.comparator;

import com.jayway.jsonpath.Configuration;
import com.jayway.jsonpath.spi.json.JsonOrgJsonProvider;
import org.json.JSONArray;
import org.json.JSONObject;
import org.skyscreamer.jsonassert.JSONCompareMode;
import org.skyscreamer.jsonassert.JSONCompareResult;
import org.skyscreamer.jsonassert.JSONPathCustomization;
import org.skyscreamer.jsonassert.ValueMatcherException;

import java.util.*;

/**
* Provides Custom matching support like {@link CustomComparator} but via <a href="https://goessner.net/articles/JsonPath/">JSONPath</a>
* <br><br>
* This class is thread-safe, but reusing instances of it would result in blocking against it.
* So <b>consider instantiating different instances of it per testcase</b> if running a multi-threaded/parallel test executor.
*
* @author Shane B. (<a href="mailto:shane@wander.dev">shane@wander.dev</a>)
*/
public class JSONPathComparator extends DefaultComparator {
private final Configuration jsonPathConfig;

private final Collection<JSONPathCustomization> customizations;

private final Map<JSONPathCustomization, Object> resultCache = new HashMap<>();

private JSONObject actual = null;

/**
* Create an instance of the JSONPath based comparator.
* <br>
* This is similar to {@link CustomComparator}, except that the path specification supports
* full <a href="https://goessner.net/articles/JsonPath/">JSONPath</a> via
* <a href="https://github.com/jayway/JsonPath">com.jayway.jsonpath:json-path</a>.
* <br>
* This means that {@link CustomComparator}'s more limited path specification is supported
* <b>and</b> full JSONPath queries are supported too.
* <br><br>
* If multiple {@link JSONPathCustomization} are specified that match the same JSON element,
* then all of them will be evaluated. If any of their comparators return <code>false</code> for the matching element,
* then the comparison will be considered as failed overall.
* <br><br>
* Refer to class {@link JSONPathCustomization} for examples.
*
* @param mode Comparator mode
* @param customizations Validation specification overrides for all nodes that match the specified JSONPath queries
*/
public JSONPathComparator(JSONCompareMode mode, JSONPathCustomization... customizations) {
super(mode);
this.jsonPathConfig = new Configuration.ConfigurationBuilder()
.jsonProvider(new JsonOrgJsonProvider()).build();

this.customizations = Arrays.asList(customizations);
}

@Override
public synchronized void compareJSON(String prefix, JSONObject expected, JSONObject actual, JSONCompareResult result) {
// synchronised to make reuse of a comparator technically possible.
// Main reason to capture and set the member here rather than in the constructor is
// to not hugely change the implementation details of JSONAssert, while still maintaining
// the necessary pointers to the top level of the JSONObject hierarchy.
// This means that in a multithreaded environment we will block on this method.
// So: devs should ideally not reuse instances of this comparator across tests that are meant to run in parallel.
boolean isRootCall = this.actual == null;
if (isRootCall) {
this.actual = actual;
this.resultCache.clear();
}

super.compareJSON(prefix, expected, actual, result);

if (isRootCall) {
this.actual = null;
}
}

@Override
public void compareValues(String prefix, Object expectedValue, Object actualValue, JSONCompareResult result) {
// This is very similar to CustomComparator.
// In fact, I question why CustomComparator supports some level of wildcard matching,
// but only compares a *single* Customisation being evaluated.
// It appears quite possible have multiple Customisations with a matching path in it...
List<JSONPathCustomization> customizations = getCustomization(actualValue);
if (!customizations.isEmpty()) {
try {
// Does *any* of the customisations result in a test failure?
for (JSONPathCustomization customization : customizations) {
if (!customization.matches(prefix, expectedValue, actualValue, result)) {
result.fail(prefix, expectedValue, actualValue);
}
}
} catch (ValueMatcherException e) {
result.fail(prefix, e);
}
} else {
super.compareValues(prefix, expectedValue, actualValue, result);
}
}

/**
* Some implementation details need to be known here:
* <br><br>
* {@link org.skyscreamer.jsonassert} uses the {@link org.json} implementation of JSON in Java,
* as does the {@link JsonOrgJsonProvider} for {@link com.jayway.jsonpath}.
* <br><br>
* Both libraries will ultimately recursively navigate the JSON tree of the parsed JSON objects.
* They will <b>reuse</b> the same instances (i.e. the same references/pointers) of the JSON objects
* in this tree, which is only parsed once (initially).
* <br>
* Furthermore, observe that in Java, due to
* <a href="https://docs.oracle.com/javase/tutorial/java/data/autoboxing.html">Primitive Boxing</a>,
* even primitive datatypes in the tree (<code>long</code>, <code>int</code>, ...) will actually
* be a pointer/reference.
* <br><br>
* Consequently, it is a completely valid strategy to evaluate a JSONPath expression against the JSON tree
* and find all results, and compare those against any node in the recursively navigated JSON tree
* by reference/pointer (i.e. <code>==</code>) to determine if they are the same object.
* <br><br>
* Here, we use this strategy to determine all the customisation rules applicable to a given JSON Tree node.
*
* @param actualValue The object to find all applicable {@link JSONPathCustomization}s for
* @return Customisations applicable to this object
*/
private List<JSONPathCustomization> getCustomization(Object actualValue) {
List<JSONPathCustomization> applicableCustomisations = new ArrayList<>();
for (JSONPathCustomization c : customizations) {
// some implementation details need to be known here.
// JSONAssert uses the org.json
Object results = this.getCachedResult(c);

if (results instanceof JSONArray) {
// multiple results for JSONPath expression
// (something like $.items[*] might return this)
for (Object o : (JSONArray) results) {
if (o == actualValue) {
applicableCustomisations.add(c);
}
}
} else if (results == actualValue) {
applicableCustomisations.add(c);
}
}
return applicableCustomisations;
}

/**
* Avoids performing queries multiple times. The result will always be the same.
*/
private Object getCachedResult(JSONPathCustomization c) {
if (this.resultCache.containsKey(c)) {
return this.resultCache.get(c);
}
Object result = c.getJsonPath().read(this.actual, this.jsonPathConfig);
this.resultCache.put(c, result);
return result;
}
}
Loading