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

org.yamcs.Spec Maven / Gradle / Ivy

There is a newer version: 5.10.9
Show newest version
package org.yamcs;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Specifies the valid structure of a {@link YConfiguration} instance.
 * 

* While not strictly 'validation', the spec also allows defining additional metadata like the 'default' keyword which * is used in the merged result of a validation. *

* Furthermore, a spec validation applies a limited set of type transformations. */ public class Spec { private static final Logger log = LoggerFactory.getLogger(Spec.class); /** * Spec implementation that allows any key. */ public static final Spec ANY = new Spec(); static { ANY.allowUnknownKeys = true; } private static final Spec OPTION_DESCRIPTOR = new Spec(); static { OPTION_DESCRIPTOR.addOption("title", OptionType.STRING).withRequired(true); OPTION_DESCRIPTOR.addOption("description", OptionType.LIST_OR_ELEMENT) .withElementType(OptionType.STRING); OPTION_DESCRIPTOR.addOption("type", OptionType.STRING) .withRequired(true) .withChoices(OptionType.class); OPTION_DESCRIPTOR.addOption("required", OptionType.BOOLEAN).withDefault(false); OPTION_DESCRIPTOR.addOption("secret", OptionType.BOOLEAN).withDefault(false); OPTION_DESCRIPTOR.addOption("hidden", OptionType.BOOLEAN).withDefault(false); OPTION_DESCRIPTOR.addOption("default", OptionType.ANY); OPTION_DESCRIPTOR.addOption("versionAdded", OptionType.STRING); OPTION_DESCRIPTOR.addOption("deprecationMessage", OptionType.STRING); OPTION_DESCRIPTOR.addOption("elementType", OptionType.STRING) .withChoices(OptionType.class); OPTION_DESCRIPTOR.addOption("choices", OptionType.LIST) .withElementType(OptionType.ANY); OPTION_DESCRIPTOR.addOption("suboptions", OptionType.MAP) .withSpec(ANY); OPTION_DESCRIPTOR.addOption("applySpecDefaults", OptionType.BOOLEAN) .withDefault(false); } private Map options = new LinkedHashMap<>(); private Map aliases = new HashMap<>(); private boolean allowUnknownKeys = false; private List> requiredOneOfGroups = new ArrayList<>(0); private List> requireTogetherGroups = new ArrayList<>(0); private List> mutuallyExclusiveGroups = new ArrayList<>(0); private List whenConditions = new ArrayList<>(0); /** * Returns true if this spec contains the specified option. */ public boolean containsOption(String name) { return options.containsKey(name); } /** * Add an {@link Option} to this spec. * * @throws IllegalArgumentException * if an option with this name is already defined. */ public Option addOption(String name, OptionType type) { if (options.containsKey(name) || aliases.containsKey(name)) { throw new IllegalArgumentException("Option '" + name + "' is already defined"); } var option = new Option(this, name, type); options.put(name, option); return option; } /** * Remove an {@link Option} from this spec. */ public void removeOption(String name) { options.remove(name); } public void allowUnknownKeys(boolean allowUnknownKeys) { this.allowUnknownKeys = allowUnknownKeys; } /** * Specify a set of keys of which at least one must be specified. Note that this not enforce that only one is * specified. You can combine this check with {@link #mutuallyExclusive(String...)} if that is required. */ public void requireOneOf(String... keys) { verifyKeys(keys); requiredOneOfGroups.add(Arrays.asList(keys)); } /** * Specify a set of keys that must appear together. This check only applies as soon as at least one of these keys * has been specified. */ public void requireTogether(String... keys) { verifyKeys(keys); requireTogetherGroups.add(Arrays.asList(keys)); } /** * Specify a set of keys that are mutually exclusive. i.e. at most one of them may be specified. */ public void mutuallyExclusive(String... keys) { verifyKeys(keys); mutuallyExclusiveGroups.add(Arrays.asList(keys)); } /** * Add a condition that is only verified when {@code key.equals(value)} * * @param key * the name of an option * @param value * the value that triggers the conditional check * @return an instance of {@link WhenCondition} for further configuration options */ public WhenCondition when(String key, Object value) { verifyKeys(key); var whenCondition = new WhenCondition(this, key, value); whenConditions.add(whenCondition); return whenCondition; } /** * Validate the given arguments according to this spec. * * @param args * the arguments to validate. * @return the validation result where defaults have been added to the input arguments * @throws ValidationException * when the specified arguments did not match this specification */ public YConfiguration validate(YConfiguration args) throws ValidationException { var ctx = new ValidationContext(args.getPath()); var result = doValidate(ctx, args.getRoot(), "", false); var wrapped = YConfiguration.wrap(result); wrapped.parent = args.parent; wrapped.parentKey = args.parentKey; wrapped.rootLocation = args.rootLocation; return wrapped; } /** * Validate the given arguments according to this spec. * * @param args * the arguments to validate, keyed by argument name. * @return the validation result where defaults have been added to the input arguments * @throws ValidationException * when the specified arguments did not match this specification */ public Map validate(Map args) throws ValidationException { return doValidate(new ValidationContext(""), args, "", false); } private Map doValidate(ValidationContext ctx, Map args, String parent, boolean suppressWarnings) throws ValidationException { for (var group : requiredOneOfGroups) { if (count(args, group) == 0) { var msg = "One of the following is required: " + group; if (!"".equals(parent)) { msg += " at " + parent; } throw new ValidationException(ctx, msg); } } for (var group : mutuallyExclusiveGroups) { if (count(args, group) > 1) { var msg = "The following arguments are mutually exclusive: " + group; if (!"".equals(parent)) { msg += " at " + parent; } throw new ValidationException(ctx, msg); } } for (var group : requireTogetherGroups) { int n = count(args, group); if (n > 0 && n != group.size()) { var msg = "The following arguments are required together: " + group; if (!"".equals(parent)) { msg += " at " + parent; } throw new ValidationException(ctx, msg); } } var satisfiedWhenConditions = whenConditions.stream().filter(whenCondition -> { var arg = args.get(whenCondition.key); return arg != null && arg.equals(whenCondition.value); }).toList(); for (var whenCondition : satisfiedWhenConditions) { var missing = whenCondition.requiredKeys.stream() .filter(key -> !args.containsKey(key)) .collect(Collectors.toList()); if (!missing.isEmpty()) { var path = "".equals(parent) ? whenCondition.key : (parent + "->" + whenCondition.key); throw new ValidationException(ctx, String.format( "%s is %s but the following arguments are missing: %s", path, whenCondition.value, missing)); } } // Build a new set of args where defaults have been entered // Make this a linked hashmap to keep the defined order var result = new LinkedHashMap(); // Check the provided arguments for (var entry : args.entrySet()) { var argName = entry.getKey(); var path = "".equals(parent) ? argName : (parent + "->" + argName); var option = getOption(argName); if (option == null) { for (var whenCondition : satisfiedWhenConditions) { option = whenCondition.options.get(argName); if (option != null) { break; } } } if (option == null) { if (allowUnknownKeys) { result.put(argName, entry.getValue()); } else { throw new ValidationException(ctx, "Unknown argument " + path); } } else if (result.containsKey(option.name)) { throw new ValidationException(ctx, String.format("Argument '%s' already specified. Check for aliases.", option.name)); } else { var arg = entry.getValue(); var resultArg = option.validate(ctx, arg, path, suppressWarnings); result.put(option.name, resultArg); } } var effectiveOptions = new ArrayList<>(options.values()); for (var whenCondition : satisfiedWhenConditions) { effectiveOptions.addAll(whenCondition.options.values()); } for (var option : effectiveOptions) { var specified = args.containsKey(option.name); for (var alias : aliases.entrySet()) { if (alias.getValue().equals(option.name) && args.containsKey(alias.getKey())) { specified = true; } } if (!specified) { var path = "".equals(parent) ? option.name : parent + "->" + option.name; if (option.required) { throw new ValidationException(ctx, "Missing required argument " + path); } var defaultValue = option.validate(ctx, option.computeDefaultValue(), path, true /* suppressWarnings */); if (defaultValue != null) { result.put(option.name, defaultValue); } } } return result; } public Collection

* This method does not validate the arguments, however it will throw random exceptions if the input does not match * the expected structure. It is therefore best to validate the arguments before passing them. */ public Map removeSecrets(Map unsafeArgs) { return makeSafe(unsafeArgs, false); } /** * Returns a copy of the given arguments but with all secret arguments masked as {@code *****}. *

* This method does not validate the arguments, however it will throw random exceptions if the input does not match * the expected structure. It is therefore best to validate the arguments before passing them. */ public Map maskSecrets(Map unsafeArgs) { return makeSafe(unsafeArgs, true); } @SuppressWarnings("unchecked") private Map makeSafe(Map unsafeArgs, boolean mask) { var safeArgs = new LinkedHashMap(); for (var arg : unsafeArgs.entrySet()) { var option = getOption(arg.getKey()); if (option == null) { // No exception. Often this method is called while we are already // handling another exception. safeArgs.put(arg.getKey(), arg.getValue()); continue; } var type = option.type; var argValue = arg.getValue(); if (type == OptionType.LIST_OR_ELEMENT && !(argValue instanceof List)) { type = OptionType.LIST; argValue = Arrays.asList(argValue); } if (option.secret) { if (mask) { safeArgs.put(arg.getKey(), "*****"); } } else if (type == OptionType.MAP) { var map = (Map) argValue; var safeMap = option.spec.makeSafe(map, mask); safeArgs.put(arg.getKey(), safeMap); } else if (type == OptionType.LIST) { var list = (List) argValue; var safeList = new ArrayList<>(); for (var element : list) { if (option.elementType == OptionType.MAP) { var mapElement = (Map) element; var safeMapElement = option.spec.makeSafe(mapElement, mask); if (!safeMapElement.isEmpty()) { safeList.add(safeMapElement); } } else { safeList.add(element); } } safeArgs.put(arg.getKey(), safeList); } else { safeArgs.put(arg.getKey(), argValue); } } return safeArgs; } private void verifyKeys(String... keys) { for (var key : keys) { if (!options.containsKey(key)) { throw new IllegalArgumentException("Unknown option " + key); } } } private int count(Map args, List check) { return (int) check.stream() .filter(args::containsKey) .count(); } public static enum OptionType { /** * Arguments for an ANY option are unvalidated. */ ANY, BOOLEAN, INTEGER, FLOAT, LIST, /** * This option converts arguments automatically to a list if the argument is not a list. */ LIST_OR_ELEMENT, MAP, STRING; Object convertArgument(ValidationContext ctx, String path, Object arg, OptionType elementType) throws ValidationException { if (this == ANY) { return arg; } if (arg == null) { return null; } if (arg instanceof String) { try { arg = YConfiguration.expandString(null, (String) arg); } catch (ConfigurationException e) { throw new ValidationException(ctx, String.format("%s: %s", path, e)); } } var argType = forArgument(arg); if (this == argType) { return arg; } else if (this == LIST_OR_ELEMENT) { if (argType == LIST) { return arg; } else { var elementArg = elementType.convertArgument(ctx, path, arg, null); return Arrays.asList(elementArg); } } else if (this == INTEGER) { if (arg instanceof String) { var stringValue = (String) arg; try { return Integer.parseInt(stringValue); } catch (NumberFormatException e) { try { return Long.parseLong(stringValue); } catch (NumberFormatException e2) { throw new ValidationException( ctx, String.format("%s: invalid integer '%s'", path, stringValue)); } } } else if ((arg instanceof Float) && (((Float) arg) % 1) == 0) { return ((Float) arg).intValue(); } else if ((arg instanceof Double) && ((Double) arg % 1) == 0) { return ((Double) arg).intValue(); } } else if (this == FLOAT) { if (arg instanceof Integer) { return Double.valueOf((Integer) arg); } else if (arg instanceof Long) { return Double.valueOf((Long) arg); } else if (arg instanceof String) { var stringValue = (String) arg; try { return Double.parseDouble(stringValue); } catch (NumberFormatException e) { throw new ValidationException( ctx, String.format("%s: invalid float '%s'", path, stringValue)); } } } else if (this == BOOLEAN) { if (arg instanceof String) { var stringValue = (String) arg; switch (stringValue) { case "yes": case "true": case "on": return true; case "no": case "false": case "off": return false; default: // Fall } } } throw new ValidationException(ctx, String.format( "%s is of type %s, but should be %s instead", path, argType, this)); } static OptionType forArgument(Object arg) { if (arg instanceof String || arg instanceof Enum) { return STRING; } else if (arg instanceof Boolean) { return BOOLEAN; } else if (arg instanceof Integer || arg instanceof Long) { return INTEGER; } else if (arg instanceof Float || arg instanceof Double) { return FLOAT; } else if (arg instanceof List) { return LIST; } else if (arg instanceof Map) { return MAP; } else if (arg == null) { throw new IllegalArgumentException("Cannot derive type for null argument"); } else { throw new IllegalArgumentException( "Cannot derive type for argument of class " + arg.getClass().getName()); } } } public static final class WhenCondition { private Spec spec; private String key; private Object value; private Map options = new LinkedHashMap<>(0); private List requiredKeys = new ArrayList<>(0); private List> requiredOneOfGroups = new ArrayList<>(0); private List> requireTogetherGroups = new ArrayList<>(0); private List> mutuallyExclusiveGroups = new ArrayList<>(0); public WhenCondition(Spec spec, String key, Object value) { this.spec = spec; this.key = key; this.value = value; } /** * Add an {@link Option} when the condition is satisfied. * * @throws IllegalArgumentException * if an option with this name is already defined. */ public Option addOption(String name, OptionType type) { var option = new Option(spec, name, type); options.put(name, option); return option; } /** * Apply options from the given spec into the current spec when the condition is satisfied. * * @throws IllegalArgumentException * if an option with the same name is already defined. */ public WhenCondition mergeSpec(Spec spec) { var parentSpec = this.spec; for (var option : spec.options.values()) { var copy = new Option(parentSpec, option); options.put(option.name, copy); } parentSpec.requiredOneOfGroups.addAll(spec.requiredOneOfGroups); parentSpec.requireTogetherGroups.addAll(spec.requireTogetherGroups); parentSpec.mutuallyExclusiveGroups.addAll(spec.mutuallyExclusiveGroups); return this; } public Collection