org.opentripplanner.standalone.config.NodeAdapter Maven / Gradle / Ivy
package org.opentripplanner.standalone.config;
import com.fasterxml.jackson.databind.JsonNode;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
import java.time.LocalDate;
import java.time.Period;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.DoubleFunction;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import javax.validation.constraints.NotNull;
import org.opentripplanner.api.parameter.QualifiedModeSet;
import org.opentripplanner.model.FeedScopedId;
import org.opentripplanner.routing.api.request.RequestFunctions;
import org.opentripplanner.routing.api.request.RequestModes;
import org.opentripplanner.util.OtpAppException;
import org.opentripplanner.util.time.DurationUtils;
import org.slf4j.Logger;
/**
* This class wrap a {@link JsonNode} and decorate it with type-safe parsing
* of types used in OTP like enums, date, time, URIs and so on. By wrapping
* the JsonNode we get consistent parsing rules and the possibility to log unused
* parameters when the end of parsing a file. Also the configuration POJOs become
* cleaner because they do not have any parsing logic in them any more.
*
* This class have 100% test coverage - keep it that way, for the individual configuration
* POJOs a smoke test is good enough.
*/
public class NodeAdapter {
private final JsonNode json;
/**
* The source is the origin of the configuration. The source can be "DEFAULT", the name of the
* JSON source files or the "SerializedGraph".
*/
private final String source;
/**
* This class wrap a {@link JsonNode} which might be a child of another node. We
* keep the path string for logging and debugging purposes
*/
private final String contextPath;
/**
* This parameter is used internally in this class to be able to produce a
* list of parameters which is NOT requested.
*/
private final List parameterNames = new ArrayList<>();
/**
* The collection of children is used to be able to produce a list of unused parameters
* for all children.
*/
private final List children = new ArrayList<>();
public NodeAdapter(@NotNull JsonNode node, String source) {
this(node, source, null);
}
/**
* Constructor for nested configuration nodes.
*/
private NodeAdapter(@NotNull JsonNode node, String source, String contextPath) {
this.json = node;
this.source = source;
this.contextPath = contextPath;
}
public List asList() {
List result = new ArrayList<>();
// Count elements starting at 1
int i = 1;
for (JsonNode node : json) {
String pName = "[" + i + "]";
NodeAdapter child = new NodeAdapter(node, source, fullPath(pName));
children.add(child);
result.add(child);
++i;
}
return result;
}
public boolean isNonEmptyArray() {
return json.isArray() && json.size() > 0;
}
public String getSource() {
return source;
}
JsonNode asRawNode(String paramName) {
return param(paramName);
}
public boolean isEmpty() {
return json.isMissingNode();
}
public NodeAdapter path(String paramName) {
NodeAdapter child = new NodeAdapter(
param(paramName),
source,
fullPath(paramName)
);
if(!child.isEmpty()) {
parameterNames.add(paramName);
children.add(child);
}
return child;
}
/** Delegates to {@link JsonNode#has(String)} */
public boolean exist(String paramName) {
return json.has(paramName);
}
public Boolean asBoolean(String paramName, boolean defaultValue) {
return param(paramName).asBoolean(defaultValue);
}
/**
* Get a required parameter as a boolean value.
* @throws OtpAppException if parameter is missing.
*/
public boolean asBoolean(String paramName) {
assertRequiredFieldExist(paramName);
return param(paramName).asBoolean();
}
public double asDouble(String paramName, double defaultValue) {
return param(paramName).asDouble(defaultValue);
}
public double asDouble(String paramName) {
assertRequiredFieldExist(paramName);
return param(paramName).asDouble();
}
public Optional asDoubleOptional(String paramName) {
JsonNode node = param(paramName);
if(node.isMissingNode()) { return Optional.empty(); }
return Optional.of(node.asDouble());
}
public List asDoubles(String paramName, List defaultValue) {
if(!exist(paramName)) return defaultValue;
return arrayAsList(paramName, JsonNode::asDouble);
}
public int asInt(String paramName, int defaultValue) {
return param(paramName).asInt(defaultValue);
}
public int asInt(String paramName) {
assertRequiredFieldExist(paramName);
return param(paramName).asInt();
}
public long asLong(String paramName, long defaultValue) {
return param(paramName).asLong(defaultValue);
}
public String asText(String paramName, String defaultValue) {
return param(paramName).asText(defaultValue);
}
public Set asTextSet(String paramName, Set defaultValue) {
if(!exist(paramName)) return defaultValue;
return new HashSet<>(arrayAsList(paramName, JsonNode::asText));
}
public RequestModes asRequestModes(String paramName, RequestModes defaultValue) {
var node = param(paramName);
return node == null || node.asText().isBlank() ? defaultValue : new QualifiedModeSet(node.asText()).getRequestModes();
}
/**
* Get a required parameter as a text String value.
* @throws OtpAppException if parameter is missing.
*/
public String asText(String paramName) {
assertRequiredFieldExist(paramName);
return param(paramName).asText();
}
/** Get required enum value. Parser is not case sensitive. */
public > T asEnum(String paramName, Class ofType) {
return asEnum(paramName, asText(paramName), ofType);
}
/** Get optional enum value. Parser is not case sensitive. */
@SuppressWarnings("unchecked")
public > T asEnum(String paramName, T defaultValue) {
var value = asText(paramName, defaultValue.name());
return asEnum(paramName, value, (Class) defaultValue.getClass());
}
private > T asEnum(String paramName, String value, Class ofType) {
var upperCaseValue = value.toUpperCase();
return Stream.of(ofType.getEnumConstants())
.filter(it -> it.name().toUpperCase().equals(upperCaseValue))
.findFirst()
.orElseThrow(() -> {
List legalValues = List.of(ofType.getEnumConstants());
throw new OtpAppException(
"The parameter '" + fullPath(paramName)
+ "': '" + value + "' is not in legal. Expected one of "
+ legalValues + ". Source: " + source + "."
);
});
}
/**
* Get a map of enum values listed in the config like this:
* (This example have Boolean values)
*
* key : {
* A : true, // turned on
* B : false // turned off
* // Commented out to use default value
* // C : true
* }
*
*
* @param The enum type
* @param The map value type.
* @param mapper The function to use to map a node in the JSON tree into a value of type T.
* The second argument to the function is the enum NAME(String).
* @return a map of listed enum values as keys with value, or an empty map if not set.
*/
public > Map asEnumMap(
String paramName,
Class enumClass,
BiFunction mapper
) {
return localAsEnumMap(paramName, enumClass, mapper, false);
}
/**
* Get a map of enum values listed in the config like the
* {@link #asEnumMap(String, Class, BiFunction)}, but verify that all enum keys
* are listed. This can be used for settings where there is appropriate no default
* value. Note! This method return {@code null} if the given parameter is not present.
*/
public > Map asEnumMapAllKeysRequired(
String paramName,
Class enumClass,
BiFunction mapper
) {
Map map = localAsEnumMap(paramName, enumClass, mapper, true);
return map.isEmpty() ? null : map;
}
public > Set asEnumSet(String paramName, Class enumClass) {
if(!exist(paramName)) { return Set.of(); }
Set result = EnumSet.noneOf(enumClass);
JsonNode param = param(paramName);
if(param.isArray()) {
for (JsonNode it : param) {
result.add(Enum.valueOf(enumClass, it.asText()));
}
}
// Assume all values is concatenated in one string separated by ','
else {
String[] values = asText(paramName).split("[,\\s]+");
for (String value : values) {
if(value.isBlank()) { continue; }
try {
result.add(Enum.valueOf(enumClass, value));
}
catch (IllegalArgumentException e) {
throw new OtpAppException("The parameter '" + fullPath(paramName)
+ "': '" + value + "' is not an enum value of "
+ enumClass.getSimpleName() + ". Source: " + source + "."
);
}
}
}
return result;
}
public FeedScopedId asFeedScopedId(String paramName, FeedScopedId defaultValue) {
if(!exist(paramName)) { return defaultValue; }
return FeedScopedId.parseId(asText(paramName));
}
public Locale asLocale(String paramName, Locale defaultValue) {
if(!exist(paramName)) { return defaultValue; }
String[] parts = asText(paramName).split("[-_ ]+");
if(parts.length == 1) { return new Locale(parts[0]); }
if(parts.length == 2) { return new Locale(parts[0], parts[1]); }
if(parts.length == 3) { return new Locale(parts[0], parts[1], parts[2]); }
throw new OtpAppException("The parameter: '" + fullPath(paramName)
+ "' is not recognized as a valid Locale. Use: [_[_]]. "
+ "Source: " + source + "."
);
}
public LocalDate asDateOrRelativePeriod(String paramName, String defaultValue) {
String text = asText(paramName, defaultValue);
try {
if (text == null || text.isBlank()) {
return null;
}
if (text.startsWith("-") || text.startsWith("P")) {
return LocalDate.now().plus(Period.parse(text));
}
else {
return LocalDate.parse(text);
}
}
catch (DateTimeParseException e) {
throw new OtpAppException("The parameter '" + fullPath(paramName)
+ "': '" + text + "' is not a Period or LocalDate. "
+ "Source: " + source + ". Details: " + e.getLocalizedMessage()
);
}
}
public Duration asDuration(String paramName, Duration defaultValue) {
return exist(paramName) ? DurationUtils.duration(param(paramName).asText()) : defaultValue;
}
public List asDurations(String paramName, List defaultValues) {
JsonNode array = param(paramName);
if(array.isMissingNode()) {
return defaultValues;
}
assertIsArray(paramName, array);
List durations = new ArrayList<>();
for (JsonNode it : array) {
durations.add(DurationUtils.duration(it.asText()));
}
return durations;
}
public Pattern asPattern(String paramName, String defaultValue) {
return Pattern.compile(asText(paramName, defaultValue));
}
public List asUris(String paramName) {
List uris = new ArrayList<>();
JsonNode array = param(paramName);
if(array.isMissingNode()) {
return uris;
}
assertIsArray(paramName, array);
for (JsonNode it : array) {
uris.add(uriFromString(paramName, it.asText()));
}
return uris;
}
public URI asUri(String paramName) {
assertRequiredFieldExist(paramName);
return asUri(paramName, null);
}
public URI asUri(String paramName, String defaultValue) {
return uriFromString(paramName, asText(paramName, defaultValue));
}
public DoubleFunction asLinearFunction(String paramName, DoubleFunction defaultValue) {
String text = param(paramName).asText();
if (text == null || text.isBlank()) {
return defaultValue;
}
try {
return RequestFunctions.parse(text);
}
catch (Exception e) {
throw new OtpAppException(
"Unable to parse parameter '" + fullPath(paramName) + "'. The value '" + text
+ "' is not a valid function on the form \"a + b x\" (\"2.0 + 7.1 x\")."
+ "Source: " + source + "."
);
}
}
/**
* Log unused parameters for the entire configuration file/noe tree. Call this method for
* thew root adapter for each config file read.
*/
public void logAllUnusedParameters(Logger log) {
for (String p : unusedParams()) {
log.warn(
"Unexpected config parameter: '{}' in '{}'. Is the spelling correct?",
p, source
);
}
}
/**
* This method list all unused parameters(full path), also nested ones.
* It uses recursion to get child nodes.
*/
private List unusedParams() {
List unusedParams = new ArrayList<>();
Iterator it = json.fieldNames();
while (it.hasNext()) {
String fieldName = it.next();
if(!parameterNames.contains(fieldName)) {
unusedParams.add(fullPath(fieldName) + ":" + json.get(fieldName));
}
}
for (NodeAdapter c : children) {
// Recursive call to get child unused parameters
unusedParams.addAll(c.unusedParams());
}
unusedParams.sort(String::compareTo);
return unusedParams;
}
/* private methods */
private JsonNode param(String paramName) {
parameterNames.add(paramName);
return json.path(paramName);
}
private String fullPath(String paramName) {
return contextPath == null ? paramName : concatPath(contextPath, paramName);
}
private String concatPath(String a, String b) {
return a + "." + b;
}
private URI uriFromString(String paramName, String text) {
if (text == null || text.isBlank()) {
return null;
}
try {
return new URI(text);
}
catch (URISyntaxException e) {
throw new OtpAppException(
"Unable to parse parameter '" + fullPath(paramName) + "'. The value '" + text
+ "' is is not a valid URI, it should be parsable by java.net.URI class. "
+ "Source: " + source + "."
);
}
}
private List arrayAsList(String paramName, Function parse) {
List values = new ArrayList<>();
for (JsonNode node : param(paramName)) {
values.add(parse.apply(node));
}
return values;
}
private void assertRequiredFieldExist(String paramName) {
if(!exist(paramName)) {
throw requiredFieldMissingException(paramName);
}
}
private OtpAppException requiredFieldMissingException(String paramName) {
return new OtpAppException("Required parameter '" + fullPath(paramName) + "' not found in '" + source + "'.");
}
private void assertIsArray(String paramName, JsonNode array) {
if(!array.isArray()) {
throw new OtpAppException(
"Unable to parse parameter '" + fullPath(paramName) + "': '" + array.asText()
+ "' expected an ARRAY. Source: " + source + "."
);
}
}
private > EnumMap localAsEnumMap(
String paramName, Class enumClass,
BiFunction mapper,
boolean requireAllValues
) {
NodeAdapter node = path(paramName);
EnumMap result = new EnumMap<>(enumClass);
if(node.isEmpty()) { return result; }
for (E v : enumClass.getEnumConstants()) {
if(node.exist(v.name())) {
result.put(v, mapper.apply(node, v.name()));
}
else if(requireAllValues) {
throw requiredFieldMissingException(concatPath(paramName, v.name()));
}
}
return result;
}
public Map asMap(String paramName, BiFunction mapper) {
NodeAdapter node = path(paramName);
if(node.isEmpty()) { return Map.of(); }
Map result = new HashMap<>();
Iterator names = node.json.fieldNames();
while (names.hasNext()) {
String key = names.next();
result.put(key, mapper.apply(node, key));
}
return result;
}
}