JsonUtil.java
/*
* Copyright ConsenSys AG.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.config;
import org.hyperledger.besu.util.number.PositiveNumber;
import java.io.File;
import java.io.IOException;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.OptionalLong;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonParser.Feature;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import com.fasterxml.jackson.databind.node.ObjectNode;
/** The Json util class. */
public class JsonUtil {
/**
* Converts all the object keys (but none of the string values) to lowercase for easier lookup.
* This is useful in cases such as the 'genesis.json' file where all keys are assumed to be case
* insensitive.
*
* @param objectNode The ObjectNode to be normalized
* @return a copy of the json object with all keys in lower case.
*/
public static ObjectNode normalizeKeys(final ObjectNode objectNode) {
final ObjectNode normalized = JsonUtil.createEmptyObjectNode();
objectNode
.fields()
.forEachRemaining(
entry -> {
final String key = entry.getKey();
final JsonNode value = entry.getValue();
final String normalizedKey = key.toLowerCase(Locale.US);
if (value instanceof ObjectNode) {
normalized.set(normalizedKey, normalizeKeys((ObjectNode) value));
} else if (value instanceof ArrayNode) {
normalized.set(normalizedKey, normalizeKeysInArray((ArrayNode) value));
} else {
normalized.set(normalizedKey, value);
}
});
return normalized;
}
private static ArrayNode normalizeKeysInArray(final ArrayNode arrayNode) {
final ArrayNode normalizedArray = JsonUtil.createEmptyArrayNode();
arrayNode.forEach(
value -> {
if (value instanceof ObjectNode) {
normalizedArray.add(normalizeKeys((ObjectNode) value));
} else if (value instanceof ArrayNode) {
normalizedArray.add(normalizeKeysInArray((ArrayNode) value));
} else {
normalizedArray.add(value);
}
});
return normalizedArray;
}
/**
* Get the string representation of the value at {@code key}. For example, a numeric value like 5
* will be returned as "5".
*
* @param node The {@code ObjectNode} from which the value will be extracted.
* @param key The key corresponding to the value to extract.
* @return The value at the given key as a string if it exists.
*/
public static Optional<String> getValueAsString(final ObjectNode node, final String key) {
return getValue(node, key).map(JsonNode::asText);
}
/**
* Get the string representation of the value at {@code key}. For example, a numeric value like 5
* will be returned as "5".
*
* @param node The {@code ObjectNode} from which the value will be extracted.
* @param key The key corresponding to the value to extract.
* @param defaultValue The value to return if no value is found at {@code key}.
* @return The value at the given key as a string if it exists, otherwise {@code defaultValue}
*/
public static String getValueAsString(
final ObjectNode node, final String key, final String defaultValue) {
return getValueAsString(node, key).orElse(defaultValue);
}
/**
* Checks whether an {@code ObjectNode} contains the given key.
*
* @param node The {@code ObjectNode} to inspect.
* @param key The key to check.
* @return Returns true if the given key is set.
*/
public static boolean hasKey(final ObjectNode node, final String key) {
return node.has(key);
}
/**
* Returns textual (string) value at {@code key}. See {@link #getValueAsString} for retrieving
* non-textual values in string form.
*
* @param node The {@code ObjectNode} from which the value will be extracted.
* @param key The key corresponding to the value to extract.
* @return The textual value at {@code key} if it exists.
*/
public static Optional<String> getString(final ObjectNode node, final String key) {
return getValue(node, key)
.filter(jsonNode -> validateType(jsonNode, JsonNodeType.STRING))
.map(JsonNode::asText);
}
/**
* Returns textual (string) value at {@code key}. See {@link #getValueAsString} for retrieving
* non-textual values in string form.
*
* @param node The {@code ObjectNode} from which the value will be extracted.
* @param key The key corresponding to the value to extract.
* @param defaultValue The value to return if no value is found at {@code key}.
* @return The textual value at {@code key} if it exists, otherwise {@code defaultValue}
*/
public static String getString(
final ObjectNode node, final String key, final String defaultValue) {
return getString(node, key).orElse(defaultValue);
}
/**
* Gets int.
*
* @param node the node
* @param key the key
* @return the int
*/
public static OptionalInt getInt(final ObjectNode node, final String key) {
return getValue(node, key)
.filter(jsonNode -> validateType(jsonNode, JsonNodeType.NUMBER))
.filter(JsonUtil::validateInt)
.map(JsonNode::asInt)
.map(OptionalInt::of)
.orElse(OptionalInt.empty());
}
/**
* Gets int.
*
* @param node the node
* @param key the key
* @param defaultValue the default value
* @return the int
*/
public static int getInt(final ObjectNode node, final String key, final int defaultValue) {
return getInt(node, key).orElse(defaultValue);
}
/**
* Gets positive int.
*
* @param node the node
* @param key the key
* @return the positive int
*/
public static OptionalInt getPositiveInt(final ObjectNode node, final String key) {
return getValueAsString(node, key)
.map(v -> OptionalInt.of(parsePositiveInt(key, v)))
.orElse(OptionalInt.empty());
}
/**
* Gets positive int.
*
* @param node the node
* @param key the key
* @param defaultValue the default value
* @return the positive int
*/
public static int getPositiveInt(
final ObjectNode node, final String key, final int defaultValue) {
final String value = getValueAsString(node, key, String.valueOf(defaultValue));
return parsePositiveInt(key, value);
}
private static int parsePositiveInt(final String key, final String value) {
try {
return PositiveNumber.fromString(value).getValue();
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Invalid property value, " + key + " should be a positive integer: " + value);
}
}
/**
* Gets long.
*
* @param json the json
* @param key the key
* @return the long
*/
public static OptionalLong getLong(final ObjectNode json, final String key) {
return getValue(json, key)
.filter(jsonNode -> validateType(jsonNode, JsonNodeType.NUMBER))
.filter(JsonUtil::validateLong)
.map(JsonNode::asLong)
.map(OptionalLong::of)
.orElse(OptionalLong.empty());
}
/**
* Gets long.
*
* @param json the json
* @param key the key
* @param defaultValue the default value
* @return the long
*/
public static long getLong(final ObjectNode json, final String key, final long defaultValue) {
return getLong(json, key).orElse(defaultValue);
}
/**
* Gets boolean.
*
* @param node the node
* @param key the key
* @return the boolean
*/
public static Optional<Boolean> getBoolean(final ObjectNode node, final String key) {
return getValue(node, key)
.filter(jsonNode -> validateType(jsonNode, JsonNodeType.BOOLEAN))
.map(JsonNode::asBoolean);
}
/**
* Gets boolean.
*
* @param node the node
* @param key the key
* @param defaultValue the default value
* @return the boolean
*/
public static boolean getBoolean(
final ObjectNode node, final String key, final boolean defaultValue) {
return getBoolean(node, key).orElse(defaultValue);
}
/**
* Create empty object node object node.
*
* @return the object node
*/
public static ObjectNode createEmptyObjectNode() {
final ObjectMapper mapper = getObjectMapper();
return mapper.createObjectNode();
}
/**
* Create empty array node array node.
*
* @return the array node
*/
public static ArrayNode createEmptyArrayNode() {
final ObjectMapper mapper = getObjectMapper();
return mapper.createArrayNode();
}
/**
* Object node from map object node.
*
* @param map the map
* @return the object node
*/
public static ObjectNode objectNodeFromMap(final Map<String, Object> map) {
return (ObjectNode) getObjectMapper().valueToTree(map);
}
/**
* Object node from string object node.
*
* @param jsonData the json data
* @return the object node
*/
public static ObjectNode objectNodeFromString(final String jsonData) {
return objectNodeFromString(jsonData, false);
}
/**
* Object node from string object node.
*
* @param jsonData the json data
* @param allowComments true to allow comments
* @return the object node
*/
public static ObjectNode objectNodeFromString(
final String jsonData, final boolean allowComments) {
final ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(Feature.ALLOW_COMMENTS, allowComments);
try {
final JsonNode jsonNode = objectMapper.readTree(jsonData);
validateType(jsonNode, JsonNodeType.OBJECT);
return (ObjectNode) jsonNode;
} catch (final IOException e) {
// Reading directly from a string should not raise an IOException, just catch and rethrow
throw new RuntimeException(e);
}
}
/**
* Object node from string without some field.
*
* @param jsonData the json data
* @param allowComments true to allow comments
* @param withoutField the without field
* @return the object node
*/
public static ObjectNode objectNodeFromStringWithout(
final String jsonData, final boolean allowComments, final String withoutField) {
final ObjectMapper objectMapper = new ObjectMapper();
JsonFactory jsonFactory =
JsonFactory.builder()
.configure(JsonFactory.Feature.INTERN_FIELD_NAMES, false)
.configure(JsonFactory.Feature.CANONICALIZE_FIELD_NAMES, false)
.build();
jsonFactory.configure(JsonParser.Feature.ALLOW_COMMENTS, allowComments);
ObjectNode root = objectMapper.createObjectNode();
try (JsonParser jp = jsonFactory.createParser(jsonData)) {
if (jp.nextToken() != JsonToken.START_OBJECT) {
throw new RuntimeException("Expected data to start with an Object");
}
while (jp.nextToken() != JsonToken.END_OBJECT) {
String fieldName = jp.getCurrentName();
if (withoutField.equals(fieldName)) {
jp.nextToken();
jp.skipChildren();
} else {
jp.nextToken();
root.set(fieldName, objectMapper.readTree(jp));
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
return root;
}
/**
* Gets json.
*
* @param objectNode the object node
* @return the json
* @throws JsonProcessingException the json processing exception
*/
public static String getJson(final Object objectNode) throws JsonProcessingException {
return getJson(objectNode, true);
}
/**
* Gets json.
*
* @param objectNode the object node
* @param prettyPrint true for pretty print
* @return the json
* @throws JsonProcessingException the json processing exception
*/
public static String getJson(final Object objectNode, final boolean prettyPrint)
throws JsonProcessingException {
final ObjectMapper mapper = getObjectMapper();
if (prettyPrint) {
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(objectNode);
} else {
return mapper.writeValueAsString(objectNode);
}
}
/**
* Gets object mapper.
*
* @return the object mapper
*/
public static ObjectMapper getObjectMapper() {
return new ObjectMapper();
}
/**
* Gets object node.
*
* @param json the json
* @param fieldKey the field key
* @return the object node
*/
public static Optional<ObjectNode> getObjectNode(final ObjectNode json, final String fieldKey) {
return getObjectNode(json, fieldKey, true);
}
/**
* Gets object node.
*
* @param json the json
* @param fieldKey the field key
* @param strict true for strict mode
* @return the object node
*/
public static Optional<ObjectNode> getObjectNode(
final ObjectNode json, final String fieldKey, final boolean strict) {
final JsonNode obj = json.get(fieldKey);
if (obj == null || obj.isNull()) {
return Optional.empty();
}
if (!obj.isObject()) {
if (strict) {
validateType(obj, JsonNodeType.OBJECT);
} else {
return Optional.empty();
}
}
return Optional.of((ObjectNode) obj);
}
/**
* Gets array node.
*
* @param json the json
* @param fieldKey the field key
* @return the array node
*/
public static Optional<ArrayNode> getArrayNode(final ObjectNode json, final String fieldKey) {
return getArrayNode(json, fieldKey, true);
}
/**
* Gets array node.
*
* @param json the json
* @param fieldKey the field key
* @param strict true for strict mode
* @return the array node
*/
public static Optional<ArrayNode> getArrayNode(
final ObjectNode json, final String fieldKey, final boolean strict) {
final JsonNode obj = json.get(fieldKey);
if (obj == null || obj.isNull()) {
return Optional.empty();
}
if (!obj.isArray()) {
if (strict) {
validateType(obj, JsonNodeType.ARRAY);
} else {
return Optional.empty();
}
}
return Optional.of((ArrayNode) obj);
}
private static Optional<JsonNode> getValue(final ObjectNode node, final String key) {
final JsonNode jsonNode = node.get(key);
if (jsonNode == null || jsonNode.isNull()) {
return Optional.empty();
}
return Optional.of(jsonNode);
}
private static boolean validateType(final JsonNode node, final JsonNodeType expectedType) {
if (node.getNodeType() != expectedType) {
final String errorMessage =
String.format(
"Expected %s value but got %s",
expectedType.toString().toLowerCase(Locale.ROOT),
node.getNodeType().toString().toLowerCase(Locale.ROOT));
throw new IllegalArgumentException(errorMessage);
}
return true;
}
private static boolean validateLong(final JsonNode node) {
if (!node.canConvertToLong()) {
throw new IllegalArgumentException("Cannot convert value to long: " + node.toString());
}
return true;
}
private static boolean validateInt(final JsonNode node) {
if (!node.canConvertToInt()) {
throw new IllegalArgumentException("Cannot convert value to integer: " + node.toString());
}
return true;
}
/**
* Get the JSON representation of a genesis file without a specific field.
*
* @param genesisFile The genesis file to read.
* @param excludedFieldName The field to exclude from the JSON representation.
* @return The JSON representation of the genesis file without the excluded field.
*/
public static String getJsonFromFileWithout(
final File genesisFile, final String excludedFieldName) {
StringBuilder jsonBuilder = new StringBuilder();
JsonFactory jsonFactory =
JsonFactory.builder()
.configure(JsonFactory.Feature.INTERN_FIELD_NAMES, false)
.configure(JsonFactory.Feature.CANONICALIZE_FIELD_NAMES, false)
.build();
try (JsonParser parser = jsonFactory.createParser(genesisFile)) {
JsonToken token;
while ((token = parser.nextToken()) != null) {
if (token == JsonToken.START_OBJECT) {
jsonBuilder.append(handleObject(parser, excludedFieldName));
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
return jsonBuilder.toString();
}
private static String handleObject(final JsonParser parser, final String excludedFieldName)
throws IOException {
StringBuilder objectBuilder = new StringBuilder();
objectBuilder.append("{");
String fieldName;
boolean isFirstField = true;
while (parser.nextToken() != JsonToken.END_OBJECT) {
fieldName = parser.getCurrentName();
if (fieldName != null && fieldName.equals(excludedFieldName)) {
parser.skipChildren(); // Skip this field
continue;
}
if (!isFirstField) objectBuilder.append(", ");
parser.nextToken(); // move to value
objectBuilder
.append("\"")
.append(fieldName)
.append("\":")
.append(handleValue(parser, excludedFieldName));
isFirstField = false;
}
objectBuilder.append("}");
return objectBuilder.toString();
}
private static String handleValue(final JsonParser parser, final String excludedFieldName)
throws IOException {
JsonToken token = parser.getCurrentToken();
switch (token) {
case START_OBJECT:
return handleObject(parser, excludedFieldName);
case START_ARRAY:
return handleArray(parser, excludedFieldName);
case VALUE_STRING:
return "\"" + parser.getText() + "\"";
case VALUE_NUMBER_INT:
case VALUE_NUMBER_FLOAT:
return parser.getNumberValue().toString();
case VALUE_TRUE:
case VALUE_FALSE:
return parser.getBooleanValue() ? "true" : "false";
case VALUE_NULL:
return "null";
default:
throw new IllegalStateException("Unrecognized token: " + token);
}
}
private static String handleArray(final JsonParser parser, final String excludedFieldName)
throws IOException {
StringBuilder arrayBuilder = new StringBuilder();
arrayBuilder.append("[");
boolean isFirstElement = true;
while (parser.nextToken() != JsonToken.END_ARRAY) {
if (!isFirstElement) arrayBuilder.append(", ");
arrayBuilder.append(handleValue(parser, excludedFieldName));
isFirstElement = false;
}
arrayBuilder.append("]");
return arrayBuilder.toString();
}
}