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

org.kiwiproject.json.JsonHelper Maven / Gradle / Ivy

Go to download

Kiwi is a utility library. We really like Google's Guava, and also use Apache Commons. But if they don't have something we need, and we think it is useful, this is where we put it.

There is a newer version: 4.5.2
Show newest version
package org.kiwiproject.json;

import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static java.util.stream.Collectors.toList;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotNull;
import static org.kiwiproject.base.KiwiPreconditions.checkEvenItemCount;
import static org.kiwiproject.base.KiwiPreconditions.requireNotNull;
import static org.kiwiproject.base.KiwiStrings.f;
import static org.kiwiproject.collect.KiwiLists.first;
import static org.kiwiproject.collect.KiwiLists.isNotNullOrEmpty;
import static org.kiwiproject.collect.KiwiLists.isNullOrEmpty;
import static org.kiwiproject.collect.KiwiMaps.newHashMap;

import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.format.DataFormatDetector;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ContainerNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Lists;
import io.dropwizard.jackson.Jackson;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * A bunch of utilities to make it easier to work with JSON.
 * 

* One specific note on methods that accept paths. The syntax used to indicate array paths consists of the * array property name, followed by a period, followed by the array index in square brackets. For example, if * to find the first value in an array property {@code luckyNumbers}, the path is {@code luckyNumbers.[0]}. * Similarly, to find the 13th lucky number the path is {@code luckyNumbers.[12]}. *

* Paths for nested JSON objects follow the syntax {@code objectName.propertyName}; for JSON that contains a * {@code homeAddress} object that contains a {@code zipCode}, the path is {@code homeAddress.zipCode}. * * @implNote This uses Jackson to perform JSON mapping to and from objects, so Jackson will need to be available * at runtime. In addition, if you use the no-args constructor, this relies on Dropwizard's {@link Jackson} class * which does a bunch of configuration on the default Jackson {@link ObjectMapper}. So you would also need * Dropwizard available at runtime as well, specifically {@code dropwizard-jackson}. */ @Slf4j public class JsonHelper { private static final TypeReference> MAP_TYPE_REFERENCE = new TypeReference<>() { }; private static final Pattern ARRAY_INDEX_PATTERN = Pattern.compile("\\[(\\d+)]"); private final ObjectMapper objectMapper; private final DataFormatDetector jsonFormatDetector; /** * Represents an output format when serializing an object to JSON. */ public enum OutputFormat { /** * JSON may or may not be formatted (but probably not). */ DEFAULT, /** * JSON will be formatted nicely. */ PRETTY; /** * Parse the given string as a boolean into an {@link OutputFormat}. Uses {@link Boolean#parseBoolean(String)}. * * @param pretty the boolean value as a string * @return the format */ public static OutputFormat ofPrettyValue(String pretty) { return ofPrettyValue(Boolean.parseBoolean(pretty)); } /** * Parse the (nullable) Boolean value into an {@link OutputFormat}. A null is treated as false. * * @param pretty the nullable value * @return the format */ public static OutputFormat ofPrettyValue(@Nullable Boolean pretty) { return nonNull(pretty) ? ofPrettyValue(pretty.booleanValue()) : DEFAULT; } /** * Convert the given boolean value to the appropriate {@link OutputFormat}. * * @param pretty true or false * @return {@link OutputFormat#PRETTY} if the argument is true; otherwise {@link OutputFormat#DEFAULT} */ public static OutputFormat ofPrettyValue(boolean pretty) { return pretty ? PRETTY : DEFAULT; } } /** * Create a new instance using an {@link ObjectMapper} created using {@link #newDropwizardObjectMapper()}. */ public JsonHelper() { this(newDropwizardObjectMapper()); } /** * Create a new instance using the given {@link ObjectMapper}. * * @param objectMapper the ObjectMapper to use */ public JsonHelper(ObjectMapper objectMapper) { checkArgumentNotNull(objectMapper, "ObjectMapper cannot be null"); this.objectMapper = objectMapper; this.jsonFormatDetector = new DataFormatDetector(objectMapper.getFactory()); } /** * Create a new {@link JsonHelper} with an {@link ObjectMapper} supplied by {@link #newDropwizardObjectMapper()}. * * @return a new JsonHelper instance */ public static JsonHelper newDropwizardJsonHelper() { var mapper = newDropwizardObjectMapper(); return new JsonHelper(mapper); } /** * Creates a new {@link ObjectMapper} configured using the Dropwizard {@link Jackson#newObjectMapper()} factory * method. It also configures the returned mapper to read and write timestamps as milliseconds. * * @return a new ObjectMapper * @see DeserializationFeature#READ_DATE_TIMESTAMPS_AS_NANOSECONDS * @see SerializationFeature#WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS * @see #configureForMillisecondDateTimestamps(ObjectMapper) */ public static ObjectMapper newDropwizardObjectMapper() { var mapper = Jackson.newObjectMapper(); return configureForMillisecondDateTimestamps(mapper); } /** * Configure the given {@link ObjectMapper} to read and write timestamps as milliseconds. * * @param mapper the {@link ObjectMapper} to change * @return the same instance, configured to write/read timestamps as milliseconds */ public static ObjectMapper configureForMillisecondDateTimestamps(ObjectMapper mapper) { mapper.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false); mapper.configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false); return mapper; } /** * Provides direct access to the underlying object mapper. Care should be taken when accessing the * {@link ObjectMapper} directly, particularly if any changes are made to how objects are serialized/de-serialized. * * @return the object mapper; any changes made to it will potentially change the behavior of this JsonHelper instance */ public ObjectMapper getObjectMapper() { return objectMapper; } /** * Use Jackson's data format detection to determine if the given content is JSON, assuming UTF-8 as the charset. * * @param content the content to check * @return true if detected as JSON, false otherwise (including if content is null or blank, or if an exception * is thrown detecting the format) */ public boolean isJson(@Nullable String content) { return isJson(content, StandardCharsets.UTF_8); } /** * Use Jackson's data format detection to determine if the given content is JSON. * * @param content the content to check * @param charset the character set to use * @return true if detected as JSON, false otherwise (including if content is null or blank, or if an exception * is thrown detecting the format) * @see DataFormatDetector#findFormat(byte[]) */ public boolean isJson(@Nullable String content, Charset charset) { return detectJson(content, charset, jsonFormatDetector).isJson(); } /** * Use Jackson's data format detection to determine if the given content is JSON, assuming UTF-8 as the charset. * * @param content the content to check * @return the detection result */ public JsonDetectionResult detectJson(@Nullable String content) { return detectJson(content, StandardCharsets.UTF_8); } /** * Use Jackson's data format detection to determine if the given content is JSON. * * @param content the content to check * @param charset the character set to use * @return the detection result */ public JsonDetectionResult detectJson(@Nullable String content, Charset charset) { return detectJson(content, charset, jsonFormatDetector); } @VisibleForTesting static JsonDetectionResult detectJson(@Nullable String content, Charset charset, DataFormatDetector formatDetector) { try { var result = isNotBlank(content) && formatDetector.findFormat(content.getBytes(charset)).hasMatch(); return new JsonDetectionResult(result, null); } catch (IOException ex) { LOG.warn("Unable to determine content format. " + "Enable TRACE logging to see exception details. Exception type: {}. Exception message: {}", ex.getClass().getName(), ex.getMessage()); LOG.trace("Exception details:", ex); return new JsonDetectionResult(null, ex); } } /** * Convert the given object to JSON using the {@link OutputFormat#DEFAULT} format. * * @param object the object to convert * @return a JSON representation of the given object, or {@code null} if the given object is {@code null} */ public String toJson(@Nullable Object object) { return toJson(object, OutputFormat.DEFAULT); } /** * Convert the given object to JSON using the given format. * * @param object the object to convert * @param format the format to use * @return a JSON representation of the given object, or {@code null} if the given object is {@code null} */ public String toJson(@Nullable Object object, OutputFormat format) { return toJson(object, format, null); } /** * Convert the given object to JSON using the given format and optionally a class representing the * {@link JsonView} to use. * * @param object the object to convert * @param format the format to use * @param jsonView the nullable {@link JsonView} class * @return a JSON representation of the given object, or {@code null} if the given object is {@code null} */ public String toJson(@Nullable Object object, OutputFormat format, @Nullable Class jsonView) { checkArgumentNotNull(format, "format is required"); if (isNull(object)) { return null; } if (object instanceof String && isJson((String) object)) { return (String) object; } var writer = objectMapper.writer(); if (nonNull(jsonView)) { writer = writer.withView(jsonView); } if (format == OutputFormat.PRETTY) { writer = writer.withDefaultPrettyPrinter(); } try { return writer.writeValueAsString(object); } catch (JsonProcessingException e) { throw new RuntimeJsonException(e); } } /** * Consider the given arguments as key/value pairs, and convert those pairs to a JSON representation. * * @param kvPairs the objects to treat as key/value pairs * @return the JSON representation * @throws IllegalArgumentException if an odd number of arguments is supplied */ public String toJsonFromKeyValuePairs(Object... kvPairs) { checkEvenItemCount(kvPairs, "must supply an even number of arguments"); return toJson(newHashMap(kvPairs)); } /** * Convert the given object to JSON, but ignoring (excluding) the given paths. * * @param object the object to convert * @param ignoredPaths the paths to ignore/exclude * @return the JSON representation without the ignored paths */ public String toJsonIgnoringPaths(@Nullable Object object, String... ignoredPaths) { var root = getRootNode(toJson(object)); Stream.of(ignoredPaths).forEach(path -> removePathNode(root, path)); return toJson(root); } /** * Convert the given JSON into the specified type. * * @param json the JSON content * @param targetClass the type of object to convert into * @param the object type * @return a new instance of the given type, or {@code null} if the given input JSON is blank */ public T toObject(@Nullable String json, Class targetClass) { if (isBlank(json)) { return null; } try { return objectMapper.readValue(json, targetClass); } catch (MismatchedInputException e) { if (nonNull(e.getTargetType()) && isNullOrEmpty(e.getPath()) && e.getTargetType().isAssignableFrom(String.class)) { //noinspection unchecked return (T) json; } else { throw new RuntimeJsonException(e); } } catch (JsonProcessingException e) { throw new RuntimeJsonException(e); } } /** * Convert the given JSON into the specified type, or return the given default value if input JSON is blank. * * @param json the JSON content * @param clazz the type of object to convert into * @param defaultValue the default value to use if necessary * @param the object type * @return a new instance of the given type, or return {@code defaultValue} if the given input JSON is blank */ public T toObjectOrDefault(@Nullable String json, Class clazz, T defaultValue) { if (isBlank(json)) { return defaultValue; } return toObject(json, clazz); } /** * Convert the given JSON into the specified type, or return the supplied default value if input JSON is blank. * * @param json the JSON content * @param clazz the type of object to convert into * @param defaultValueSupplier the default value {@link Supplier} to call if necessary * @param the object type * @return a new instance of the given type, or return the value supplied by {@code defaultValueSupplier} if * the given input JSON is blank */ public T toObjectOrSupply(@Nullable String json, Class clazz, Supplier defaultValueSupplier) { if (isBlank(json)) { return defaultValueSupplier.get(); } return toObject(json, clazz); } /** * Convert the given JSON into an object of type {@code T} using the given {@link TypeReference}. * * @param json the JSON content * @param targetType the {@link TypeReference} representing the target object type * @param the object type * @return a new instance of the type encapsulated by the TypeReference, or {@code null} if the input JSON is blank */ public T toObject(@Nullable String json, TypeReference targetType) { if (isBlank(json)) { return null; } try { return objectMapper.readValue(json, targetType); } catch (JsonProcessingException e) { throw new RuntimeJsonException(e); } } /** * Return an Optional that will contain an object of the expected type {@code T}, or an empty Optional if the * input JSON is blank * * @param json the JSON content * @param targetClass the type of object to convert into * @param the object type * @return an Optional that may contain a converted object */ public Optional toObjectOptional(@Nullable String json, Class targetClass) { if (isBlank(json)) { return Optional.empty(); } return Optional.of(toObject(json, targetClass)); } /** * Convert the given JSON into a List of objects of type {@code T}. * * @param json the JSON content * @param targetListType the {@link TypeReference} representing the list target type * @param the object type * @return a list containing objects of the given type, or {@code null} if the input is blank */ public List toObjectList(@Nullable String json, TypeReference> targetListType) { return toObject(json, targetListType); } /** * Convert the given JSON into a map with String keys and Object values. * * @param json the JSON content * @return the parsed map, or {@code null} if the input JSON is blank */ public Map toMap(@Nullable String json) { if (isBlank(json)) { return null; } try { return objectMapper.readValue(json, MAP_TYPE_REFERENCE); } catch (JsonProcessingException e) { throw new RuntimeJsonException(e); } } /** * Convert the given JSON into a map with keys of type {@code K} and values of type {@code V}. * * @param json the JSON content * @param targetMapType the {@link TypeReference} representing the target map type * @param the type of keys in the map * @param the type of values in the map * @return the parsed map, or {@code null} if the input JSON is blank */ public Map toMap(@Nullable String json, TypeReference> targetMapType) { if (isBlank(json)) { return null; } try { return objectMapper.readValue(json, targetMapType); } catch (JsonProcessingException e) { throw new RuntimeJsonException(e); } } /** * Parse the given object as JSON, then flatten all its properties to a map whose keys are the object property * names and whose values are converted to Strings. *

* For more details on the behavior, see {@link #toFlatMap(Object, Class)}. * * @param object the object to flatten * @return a map with string keys and values converted to strings, or {@code null} if the given * object is {@code null} */ public Map toFlatMap(@Nullable Object object) { return toFlatMap(object, String.class); } /** * Parse the given object as JSON, then flatten all its properties to a map whose keys are the object property * names and whose values are converted to the given {@code valueClass} type. In practice, this will often * just be {@code Object.class} but could be a more specific type, e.g. if you have a map containing student * names and grades then the values could all be of type {@code Double}. *

* This also flattens arrays/collections and maps. Flattened arrays use the following syntax: * {@code arrayPropertyName.[index]}. For example, {@code luckyNumbers.[0]} is the first element in a collection * named {@code luckyNumbers}. For maps, the syntax is: {@code mapPropertyName.key}. For example, * {@code emailAddresses.home} contains the value in the {@code emailAddresses} map under the key {@code home}. * * @param object the object to flatten * @param valueClass the target class for the map's values * @param the generic type of the map values * @return a map with string keys and values converted to the specified type, or {@code null} if the given * object is {@code null} */ public Map toFlatMap(@Nullable Object object, Class valueClass) { if (isNull(object)) { return null; } var paths = listObjectPaths(object); return paths.stream() .filter(Objects::nonNull) .collect(Collectors.toMap(path -> path, path -> getPath(object, path, valueClass))); } /** * Copies the given object by converting to JSON and then converting the JSON back * to an object of the same class as the copied object. * * @param object the object to copy * @param the type of object being copied * @return the copied object of the target class */ @SuppressWarnings("unchecked") public T copy(T object) { if (isNull(object)) { return null; } return copy(object, (Class) object.getClass()); } /** * Copies the given object by converting to JSON and then converting the JSON back * to an object of the given target class. * * @param object the object to copy * @param targetClass the target class (may be different than the original) * @param the type of object being copied * @param the type of object to copy into * @return the copied object of the target class */ public R copy(T object, Class targetClass) { return copyIgnoringPaths(object, targetClass); } /** * Copies the given object by converting to JSON, ignoring the given paths, and then converting the JSON back * to an object of the given target class. * * @param object the object to copy * @param targetClass the target class (may be different than the original) * @param ignoredPaths the paths to ignore during the copy * @param the type of object being copied * @param the type of object to copy into * @return the copied object of the target class */ public R copyIgnoringPaths(T object, Class targetClass, String... ignoredPaths) { var json = toJsonIgnoringPaths(object, ignoredPaths); return toObject(json, targetClass); } /** * Converts the given object to an object of the target type. * * @param fromObject the object to convert * @param targetType the type of object to convert to * @param the target type * @return a new instance of the target type * @see ObjectMapper#convertValue(Object, Class) */ public T convert(Object fromObject, Class targetType) { if (isNull(fromObject)) { return null; } if (targetType.isAssignableFrom(String.class)) { //noinspection unchecked return (T) toJson(fromObject); } return objectMapper.convertValue(fromObject, targetType); } /** * Converts the given object to an object of the target type described by the {@link TypeReference}. * * @param fromObject the object to convert * @param targetType a {@link TypeReference} that describes the target type * @param the target type * @return a new instance of the target type * @see ObjectMapper#convertValue(Object, TypeReference) */ public T convert(Object fromObject, TypeReference targetType) { if (isNull(fromObject)) { return null; } return objectMapper.convertValue(fromObject, targetType); } /** * Converts the given object to a map with String keys and Object values. * * @param fromObject the object to convert * @return a new map instance */ public Map convertToMap(Object fromObject) { return convertToMap(fromObject, MAP_TYPE_REFERENCE); } /** * Converts the given object to a map using the given {@link TypeReference}. *

* Unless you have specialized requirements, usually {@link #convertToMap(Object)} will be what you want. * * @param fromObject the object to convert * @param targetMapType the {@link TypeReference} describing the target map type * @param the type of keys in the map * @param the type of values in the map * @return a new map instance */ public Map convertToMap(Object fromObject, TypeReference> targetMapType) { if (isNull(fromObject)) { return null; } return objectMapper.convertValue(fromObject, targetMapType); } /** * Get the value at the given path in the object, with the type as the given target class. * * @param object the object to search * @param path the path within the object (e.g. "homeAddress.zipCode") * @param targetClass the type associated with the given path * @param the return type * @return an instance of the specified target class */ public T getPath(Object object, String path, Class targetClass) { var json = toJson(object); return getPath(json, path, targetClass); } /** * Get the value at the given path in the JSON, with the type as the given target class. * * @param json the JSON to search * @param path the path within the object (e.g. "homeAddress.zipCode") * @param targetClass the type associated with the given path * @param the return type * @return an instance of the specified target class */ public T getPath(String json, String path, Class targetClass) { var pathNodeJson = getPathNode(json, path).toString(); return toObject(pathNodeJson, targetClass); } /** * Get the value at the given path in the object, with the type as the given target class. * * @param object the object to search * @param path the path within the object (e.g. "homeAddress.zipCode") * @param targetType the type associated with the given path * @param the return type * @return an instance of the specified target class */ public T getPath(Object object, String path, TypeReference targetType) { var json = toJson(object); return getPath(json, path, targetType); } /** * Get the value at the given path in the JSON, with the type as the given target class. * * @param json the JSON to search * @param path the path within the object (e.g. "homeAddress.zipCode") * @param targetType the type associated with the given path * @param the return type * @return an instance of the specified target class */ public T getPath(String json, String path, TypeReference targetType) { var pathNodeJson = getPathNode(json, path).toString(); return toObject(pathNodeJson, targetType); } private JsonNode getPathNode(String json, String path) { var rootNode = getRootNode(json); return getPathNode(rootNode, path).getRight(); } /** * Remove the given path from the object. * * @param object the object from which to remove a path * @param path the path to remove * @return a {@link JsonNode} with the given path removed */ public JsonNode removePath(@Nullable Object object, String path) { var rootNode = getRootNode(toJson(object)); return removePathNode(rootNode, path); } private static JsonNode removePathNode(JsonNode root, String path) { var parts = path.split("\\."); ContainerNode parentNode = getPathNode(root, path).getLeft(); if (nonNull(parentNode)) { var lastPart = parts[parts.length - 1]; var matcher = ARRAY_INDEX_PATTERN.matcher(lastPart); if (matcher.matches() && parentNode instanceof ArrayNode) { asArrayNode(parentNode).remove(Integer.parseInt(matcher.group(1))); } else if (parentNode instanceof ObjectNode) { asObjectNode(parentNode).remove(lastPart); } else { throw new IllegalArgumentException(f("Unable to remove element: {} from node: {}", lastPart, root)); } } return root; } /** * Update the given path in the object with the new value, converting to the target class. * * @param object the original object * @param path the path to update * @param value the new value to use * @param targetClass the type of object to return * @param the type of the input object * @return a new instance of the given target type */ public T updatePath(@Nullable Object object, String path, Object value, Class targetClass) { var rootNode = getRootNode(toJson(object)); var jsonNode = convert(value, JsonNode.class); var updatedNode = updatePathNode(rootNode, path, jsonNode); return convert(updatedNode, targetClass); } private static JsonNode updatePathNode(JsonNode root, String path, JsonNode value) { var parts = path.split("\\."); var parentNode = getPathNode(root, path).getLeft(); if (nonNull(parentNode)) { var lastPart = parts[parts.length - 1]; var matcher = ARRAY_INDEX_PATTERN.matcher(lastPart); if (matcher.matches() && parentNode instanceof ArrayNode) { asArrayNode(parentNode).insert(Integer.parseInt(matcher.group(1)), value); } else if (parentNode instanceof ObjectNode) { asObjectNode(parentNode).replace(lastPart, value); } else { throw new IllegalArgumentException(f("Unable to set element: {} into parent root: {}", lastPart, root)); } } return root; } private static Pair, JsonNode> getPathNode(JsonNode root, String path) { ContainerNode parent = null; var parts = path.split("\\."); var node = root; for (var pathPart : parts) { parent = node instanceof ContainerNode ? (ContainerNode) node : null; var matcher = ARRAY_INDEX_PATTERN.matcher(pathPart); if (matcher.matches()) { node = node.path(Integer.parseInt(matcher.group(1))); } else { node = node.path(pathPart); } } return Pair.of(parent, node); } /** * Compare two objects via their JSON differences, optionally ignoring one or more paths. The diff is from the * perspective of the first object. *

* The returned map of differences has keys that are the properties that are different. The map values are * the values for the corresponding key/property in the first and second objects, respectively. *

* NOTE: This is an expensive operation so be careful of using it in production code in areas where performance * is critical. * * @param object1 the first object * @param object2 the second object * @param ignoredPaths the paths to ignore in the comparison * @return a map containing a list of differences */ public Map> jsonDiff(@Nullable Object object1, @Nullable Object object2, String... ignoredPaths) { return jsonDiff(Lists.newArrayList(object1, object2), ignoredPaths); } /** * Compare an object to an arbitrary number of other objects via their JSON differences. The diff is from the * perspective of the first object in the given list. *

* The returned map of differences has keys that are the properties that are different. The map values are * the values for the corresponding key/property in the first and subsequent objects, respectively. *

* NOTE: This is an expensive operation so be careful of using it in production code in areas where performance * is critical. * * @param objectList the list of objects to compare; the first object is the reference object * @param ignoredPaths the paths to ignore in the comparison * @return a map containing a list of differences */ public Map> jsonDiff(@Nonnull List objectList, String... ignoredPaths) { var jsonList = requireNotNull(objectList) .stream() .map(obj -> toJsonIgnoringPaths(obj, ignoredPaths)) .collect(toList()); return jsonDiff(jsonList); } /** * Compare a JSON object to an arbitrary number of other objects via their JSON differences. The diff is from the * perspective of the first JSON object in the given list. *

* The returned map of differences has keys that are the properties that are different. The map values are * the values for the corresponding key/property in the first and subsequent objects, respectively. *

* NOTE: This is an expensive operation so be careful of using it in production code in areas where performance * is critical. * * @param listOfJson the list of JSON objects to compare * @return map containing a list of differences */ public Map> jsonDiff(@Nonnull List listOfJson) { var resultMap = new HashMap>(); requireNotNull(listOfJson) .stream() .map(this::listObjectPaths) .flatMap(Collection::stream) .forEach(path -> { List results = listOfJson.stream() .map(json -> getPath(json, path, String.class)) .collect(toList()); if (isNotNullOrEmpty(results)) { var match = first(results); if (!results.stream().allMatch(s -> StringUtils.equals(s, match))) { resultMap.put(path, new ArrayList<>(results)); // must use mutable list to handle nulls } } }); return resultMap; } /** * Compare the JSON representation of multiple objects. * * @param objects the objects to compare * @return true if all the given objects have equal JSON representations */ public boolean jsonEquals(Object... objects) { var jsonNodeList = Stream.of(objects) .map(this::toJson) .map(this::getRootNode) .collect(toList()); return jsonNodeList.stream() .allMatch(jsonNode -> Objects.equals(jsonNode, first(jsonNodeList))); } /** * Compare the JSON representations of two objects, optionally ignoring paths. * * @param object1 the first object to compare * @param object2 the second object to compare * @param ignoredPaths the paths to ignore in the comparison * @return true if the objects have equal JSON representations, ignoring the given paths */ public boolean jsonEqualsIgnoringPaths(Object object1, Object object2, String... ignoredPaths) { var json1 = toJsonIgnoringPaths(object1, ignoredPaths); var json2 = toJsonIgnoringPaths(object2, ignoredPaths); return jsonEquals(json1, json2); } /** * Compare the values at a given path in two objects. * * @param object1 the first object * @param object2 the second object * @param path the path to compare * @param targetClass the type of object at the given path * @param the object type for the given path * @return true if the two objects have an equal value at the given path; the two values are compared using * {@link Objects#equals(Object, Object)} */ public boolean jsonPathsEqual(Object object1, Object object2, String path, Class targetClass) { var value1 = getPath(object1, path, targetClass); var value2 = getPath(object2, path, targetClass); return Objects.equals(value1, value2); } /** * Describes how objects are to be merged. * * @see #mergeObjects(Object, Object, MergeOption...) * @see #mergeNodes(JsonNode, JsonNode, MergeOption...) */ public enum MergeOption { /** * This option will cause arrays to be merged rather than replaced. The default is that arrays will be * replaced with a new value. Specify this option to merge them instead. */ MERGE_ARRAYS, /** * This option will ignore null values in an update object. The default is to respect null values and * set the updated value to null. Specify this option to ignore nulls instead. */ IGNORE_NULLS } /** * Merge values in {@code updateObject} into the original object, using the given merge options. *

* Note that the {@code originalObject} is not mutated in any way; it simply represents the original state * to be updated with values from {@code updateObject}. Both {@code originalObject} and {@code updateObject} * are converted to JSON before merging. * * @param originalObject the object into which updates will be merged (only used to read original state) * @param updateObject the object containing updates * @param mergeOptions zero or more {@link MergeOption} * @param the type of the merged object * @return a new instance of type T * @see MergeOption */ public T mergeObjects(T originalObject, Object updateObject, MergeOption... mergeOptions) { var originalObjJson = toJson(originalObject); var updateObjJson = toJson(updateObject); var originalNode = getRootNode(originalObjJson); var updateNode = getRootNode(updateObjJson); JsonNode updatedNode = mergeNodes(originalNode, updateNode, mergeOptions); //noinspection unchecked return (T) toObject(updatedNode.toString(), originalObject.getClass()); } /** * Updates (mutates) {@code destinationNode} with values from {@code updateNode}. * * @param destinationNode the node that will be updated (mutated) * @param updateNode the node containing updated values * @param mergeOptions zero or more {@link MergeOption} * @return the mutated {@code destinationNode} * @see MergeOption */ public JsonNode mergeNodes(JsonNode destinationNode, JsonNode updateNode, MergeOption... mergeOptions) { boolean mergeArrays = ArrayUtils.contains(mergeOptions, MergeOption.MERGE_ARRAYS); boolean ignoreNulls = ArrayUtils.contains(mergeOptions, MergeOption.IGNORE_NULLS); return mergeNodes(destinationNode, updateNode, mergeArrays, ignoreNulls); } private JsonNode mergeNodes(JsonNode destinationNode, JsonNode updateNode, boolean mergeArrays, boolean ignoreNulls) { updateNode.fieldNames().forEachRemaining(fieldName -> { var updateFieldNode = updateNode.get(fieldName); if (isNullNode(updateFieldNode)) { if (ignoreNulls) { return; // continues lambda with next fieldName } else if (isObjectNode(destinationNode)) { asObjectNode(destinationNode).putNull(fieldName); } } var destFieldNode = destinationNode.get(fieldName); if (isContainerNode(destFieldNode)) { mergeContainerNode(fieldName, destinationNode, destFieldNode, updateFieldNode, mergeArrays, ignoreNulls); } else if (isObjectNode(destinationNode)) { asObjectNode(destinationNode).replace(fieldName, updateFieldNode); } else { LOG.warn("Unhandled node {}: {}", fieldName, destFieldNode); } }); return destinationNode; } private static boolean isNullNode(JsonNode node) { return nonNull(node) && node.isNull(); } private static boolean isObjectNode(JsonNode node) { return nonNull(node) && node.isObject(); } private static boolean isContainerNode(JsonNode node) { return nonNull(node) && node.isContainerNode(); } private static ArrayNode asArrayNode(JsonNode node) { return (ArrayNode) node; } private static ObjectNode asObjectNode(JsonNode node) { return (ObjectNode) node; } private void mergeContainerNode(String fieldName, JsonNode destinationNode, JsonNode destFieldNode, JsonNode updateFieldNode, boolean mergeArrays, boolean ignoreNulls) { if (destFieldNode.isObject()) { mergeNodes(destFieldNode, updateFieldNode, mergeArrays, ignoreNulls); // recurse on contents } else if (destFieldNode.isArray()) { mergeOrReplaceArray(fieldName, destinationNode, (ArrayNode) destFieldNode, updateFieldNode, mergeArrays); } } private static void mergeOrReplaceArray(String fieldName, JsonNode destinationNode, ArrayNode destFieldNode, JsonNode updateFieldNode, boolean mergeArrays) { if (mergeArrays && updateFieldNode.isArray()) { // merge array contents updateFieldNode.forEach(destFieldNode::add); } else { // just replace array asObjectNode(destinationNode).replace(fieldName, updateFieldNode); } } /** * Parse the given object as JSON, and return a list containing the property paths in the object. The paths * include arrays, collections and maps. *

* For details on the property path syntax, see {@link #toFlatMap(Object, Class)}. * * @param object the object to list paths for * @return a list of the property paths */ public List listObjectPaths(@Nullable Object object) { var rootNode = getRootNode(toJson(object)); return listNodePaths(rootNode); } private JsonNode getRootNode(String json) { if (isBlank(json)) { return NullNode.getInstance(); } try { return objectMapper.readTree(json); } catch (JsonProcessingException e) { throw new RuntimeJsonException(e); } } private static List listNodePaths(JsonNode node) { var paths = new ArrayList(); node.fieldNames().forEachRemaining(fieldName -> { var child = node.get(fieldName); if (nonNull(child)) { var parentPrefix = fieldName + "."; if (child.isObject()) { appendChildPaths(paths, child, parentPrefix); } else if (child.isArray()) { appendArrayNodePaths(paths, child, parentPrefix); } else { // is a leaf node, so add the field name paths.add(fieldName); } } else { LOG.warn("Unhandled node {}", fieldName); } }); return paths; } private static void appendArrayNodePaths(List paths, JsonNode child, String parentPrefix) { var index = new AtomicInteger(); child.elements().forEachRemaining(arrayElement -> { var currentIndex = index.getAndIncrement(); var currentPath = parentPrefix + f("[%s]", currentIndex); if (!arrayElement.isContainerNode()) { // not a container (e.g. object or array), so add the path paths.add(currentPath); } appendChildPaths(paths, arrayElement, currentPath + "."); }); } private static void appendChildPaths(List paths, JsonNode child, String parentPrefix) { var childPaths = listNodePaths(child); paths.addAll( childPaths.stream() .map(path -> parentPrefix + path) .collect(toList()) ); } }