org.kiwiproject.json.JsonHelper Maven / Gradle / Ivy
Show all versions of kiwi Show documentation
package org.kiwiproject.json;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
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 static org.kiwiproject.jackson.KiwiTypeReferences.MAP_OF_STRING_TO_OBJECT_TYPE_REFERENCE;
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 org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.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,
* 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 need
* Dropwizard available at runtime as well, specifically {@code dropwizard-jackson}.
*/
@Slf4j
public class JsonHelper {
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/deserialized.
*
* @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 s && isJson(s)) {
return s;
}
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.
*
* Note that if the input object is {@code null}, then the returned value is
* the string literal {@code "null"}. The reason is that a {@code null} object
* is represented as a {@link NullNode}, and its {@code toString} method returns
* the literal {@code "null"}.
*
* @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
*/
@SuppressWarnings("unchecked")
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)) {
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_OF_STRING_TO_OBJECT_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