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

io.bdeploy.common.cfg.Configuration Maven / Gradle / Ivy

Go to download

Public API including dependencies, ready to be used for integrations and plugins.

The newest version!
/*******************************************************************************
 *  Copyright (c) 2017 SSI Schaefer IT Solutions GmbH and others.
 *  All rights reserved. This program and the accompanying materials
 *  are made available under the terms of the Eclipse Public License v1.0
 *  which accompanies this distribution, and is available at
 *  http://www.eclipse.org/legal/epl-v10.html
 *
 *  Contributors:
 *      SSI Schaefer IT Solutions GmbH
 *******************************************************************************/
package io.bdeploy.common.cfg;

import java.io.PrintStream;
import java.lang.annotation.Annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;

import com.google.common.base.Splitter;

import io.bdeploy.common.cli.ToolBase;
import io.bdeploy.common.cli.data.DataTable;

/**
 * The {@link Configuration} is basically a wrapper around a {@link Map} which
 * exposes access to the {@link Map} through {@link Annotation}s.
 * 

* Any arbitrary {@link Annotation} can be defined, including default values, * and mapped to the {@link Configuration} using {@link #get(Class)}. *

* The mapped {@link Annotation} will access the underlying {@link Map} on every * method call. If a key exists in the {@link Map} that corresponds to the name * of the {@link Annotation}'s {@link Method}, it will be converted to the * target type and returned. Otherwise the default value of the {@link Method} * is returned. *

* There is (limited) type conversion capabilities. Mainly this functionality * exists to be able to map {@link String}s (e.g. when mapping a command line * using {@link #add(String...)}) to the target types of the {@link Annotation} * {@link Method}s. */ public class Configuration { private final Map objects = new TreeMap<>(); private final Map conversions = new HashMap<>(); private final List remaining = new ArrayList<>(); /** * Add a set of command line arguments to the mapping. Arguments must currently * start with '--'. * * @param arguments the command line argument as passed to the program. */ public void add(String... arguments) { boolean inRemaining = false; for (String arg : arguments) { if (inRemaining) { remaining.add(arg); } else if ("--".equals(arg)) { inRemaining = true; } else if (arg.startsWith("--")) { String stripped = arg.substring(2); int equalsIndex = stripped.indexOf('='); if (equalsIndex != -1) { addKeyValueArgument(stripped, equalsIndex); } else { objects.put(stripped, Boolean.TRUE); } } else { // unsupported right now throw new IllegalStateException("Unsupported argument format: " + arg); } } } @SuppressWarnings("unchecked") private void addKeyValueArgument(String stripped, int equalsIndex) { String key = stripped.substring(0, equalsIndex); String value = stripped.substring(equalsIndex + 1); if (objects.containsKey(key)) { Object existing = objects.get(key); if (existing instanceof List) { ((List) existing).add(value); } else { List l = new ArrayList<>(); l.add(existing); l.add(value); objects.put(key, l); } } else { objects.put(key, value); } } public Map getAllRawObjects() { return objects; } /** * Adds arbitrary ({@link String}) properties to the mapping. *

* Typically used to add a configuration file or system properties to the * mapping * * @param properties the entries to add to the mapping. */ public void add(Properties properties) { properties.forEach((k, v) -> objects.put((String) k, v)); } /** * Returns an instance of the given {@link Annotation}, mapping each * {@link Method} to a value in the mapping where the {@link Method} name is the * key into the wrapped {@link Map}. * * @param target the {@link Class} to map this {@link Configuration} to. * @return a proxy mapping to the {@link Configuration}. */ public T get(Class target) { @SuppressWarnings("unchecked") T proxy = (T) Proxy.newProxyInstance(target.getClassLoader(), new Class[] { target }, this::doMap); // access each method once to initialize cache, run conversions and especially validations. List validationProblems = new ArrayList<>(); for (Method m : target.getDeclaredMethods()) { try { doMap(proxy, m, null); } catch (Exception e) { validationProblems.add(e); } } if (!validationProblems.isEmpty()) { ConfigValidationException collector = new ConfigValidationException(); for (Exception problem : validationProblems) { collector.addSuppressed(problem); } throw collector; } return proxy; } private Object doMap(Object proxy, Method method, Object[] arguments) { String key = method.getName(); RemainingArguments isRemaining = method.getAnnotation(RemainingArguments.class); ConfigurationNameMapping mapping = method.getAnnotation(ConfigurationNameMapping.class); if (mapping != null) { key = mapping.value(); } if (isRemaining != null) { // instead of mapping, we simply inject all strings from "remaining" if (!method.getReturnType().isAssignableFrom(String[].class)) { throw new IllegalStateException("Receiver for remaining arguments must be of type String[]"); } return remaining.toArray(new String[remaining.size()]); } Object value = objects.get(key); if (value == null && !ToolBase.isTestMode()) { EnvironmentFallback fallback = method.getAnnotation(EnvironmentFallback.class); if (fallback != null) { value = System.getenv(fallback.value()); } } if (value == null && !method.getReturnType().isAnnotation()) { return method.getDefaultValue(); } return doConvert(method, value); } private Object doConvert(Method method, Object object) { // lookup existing conversion Object conversion = conversions.get(method); if (conversion != null) { return conversion; } Validator validator = method.getAnnotation(Validator.class); Class>[] cvs = null; if (validator != null) { cvs = validator.value(); } UnaryOperator mapper = getMapper(method); Class returnType = method.getReturnType(); if (object != null && returnType.isAssignableFrom(object.getClass())) { validateOrThrow(object, method, cvs); Object result = object; if (returnType.isAssignableFrom(String.class)) { result = mapper.apply(object); } conversions.put(method, result); return result; } if (returnType.isPrimitive() && !(object instanceof String)) { // implicit conversion through boxing/unboxing. let's just hope the types match // ;) validateOrThrow(object, method, cvs); conversions.put(method, object); return object; } if (object instanceof List && returnType.isArray()) { List list = (List) object; // perform conversion for each of the elements. Object targetArray = Array.newInstance(returnType.getComponentType(), list.size()); for (int i = 0; i < list.size(); ++i) { Array.set(targetArray, i, convertType(returnType.getComponentType(), (String) list.get(i), mapper)); } validateOrThrow(targetArray, method, cvs); conversions.put(method, targetArray); return targetArray; } // check source type if (!(object instanceof String) && !returnType.isAnnotation()) { throw new IllegalStateException(getParameterConfigurationHint(returnType, method.getName(), object)); } // do actual conversion conversion = convertType(returnType, (String) object, mapper); validateOrThrow(conversion, method, cvs); // remember the result in the mapping, so we don't need to convert back and // forth all the time. conversions.put(method, conversion); return conversion; } private static String getParameterConfigurationHint(Class returnType, String methodName, Object object) { StringBuilder hint = new StringBuilder(); hint.append("Could not resolve " + methodName + " parameter. "); if (returnType.isArray()) { hint.append("Please specify parameter like this: " + methodName + "= " + methodName + "=... or like this: " + methodName + "=,... "); } else { hint.append("Please specify parameter like this: " + methodName + "=. "); } hint.append("Illegal conversion from non-string object to different type: " + (object == null ? "null" : object.getClass()) + " to " + returnType); return hint.toString(); } private static UnaryOperator getMapper(Method method) { ConfigurationValueMapping mapping = method.getAnnotation(ConfigurationValueMapping.class); UnaryOperator mapper = s -> s; if (mapping != null) { if (mapping.value() == ValueMapping.TO_LOWERCASE) { mapper = s -> s.toString().toLowerCase(); } else if (mapping.value() == ValueMapping.TO_UPPERCASE) { mapper = s -> s.toString().toUpperCase(); } } return mapper; } private static void validateOrThrow(Object value, Method m, Class>[] validators) { if (validators == null) { return; } for (Class> validator : validators) { try { ValidationMessage msg = validator.getAnnotation(ValidationMessage.class); if (msg == null) { throw new IllegalStateException("No validation message set on validator class: " + validator); } ConfigValidator v = validator.getDeclaredConstructor().newInstance(); if (!v.test(cast(value))) { throw new IllegalArgumentException("--" + m.getName() + ": " + String.format(msg.value(), value)); } } catch (InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException e) { throw new IllegalStateException("Cannot validate value: " + value + " using validator: " + validator, e); } } } @SuppressWarnings("unchecked") public static T cast(Object object) { return (T) object; } @SuppressWarnings("unchecked") private Object convertType(Class target, String source, UnaryOperator mapper) { if (target.isAnnotation()) { return get((Class) target); } if (source == null) { throw new IllegalArgumentException("Conversion of null source not possible"); } if (target.equals(String.class)) { return mapper.apply(source); } if (target.equals(long.class)) { return Long.parseLong(source); } if (target.equals(int.class)) { return Integer.parseInt(source); } if (target.equals(short.class)) { return Short.parseShort(source); } if (target.equals(byte.class)) { return Byte.parseByte(source); } if (target.equals(boolean.class)) { return Boolean.parseBoolean(source); } if (target.equals(double.class)) { return Double.parseDouble(source); } if (target.equals(float.class)) { return Float.parseFloat(source); } if (target.equals(char.class)) { if (source.length() > 1) { throw new IllegalArgumentException("Character conversion with input length > 1: " + source); } return source.charAt(0); } if (target.isEnum()) { try { return target.getMethod("valueOf", String.class).invoke(null, mapper.apply(source)); } catch (NoSuchMethodException | IllegalAccessException e) { throw new IllegalStateException( "internal error resolving enumeration literal for " + target + " '" + source + "'", e); } catch (InvocationTargetException e) { throw new IllegalStateException(e.getTargetException()); } } if (target.isArray()) { List split = Splitter.on(',').trimResults().omitEmptyStrings().splitToList(source); Object targetArray = Array.newInstance(target.getComponentType(), split.size()); for (int i = 0; i < split.size(); ++i) { Array.set(targetArray, i, convertType(target.getComponentType(), split.get(i), mapper)); } return targetArray; } throw new IllegalStateException("Unsupported conversion to " + target); } /** * Analyze {@link Help} annotations on the target {@link Annotation} and print * out help information on the given {@link PrintStream}. */ public static void formatHelp(Class cfg, DataTable target) { List declaredMethods = Arrays.asList(cfg.getDeclaredMethods()).stream() .sorted((a, b) -> a.getName().compareTo(b.getName())).collect(Collectors.toList()); for (Method m : declaredMethods) { if (m.getAnnotation(RemainingArguments.class) != null) { continue; // skip in help. } Help h = m.getAnnotation(Help.class); ConfigurationNameMapping mapping = m.getAnnotation(ConfigurationNameMapping.class); String name = m.getName(); if (mapping != null) { name = mapping.value(); } EnvironmentFallback env = m.getAnnotation(EnvironmentFallback.class); String envFb = ""; if (env != null) { envFb = String.format(" (Environment variable '%1$s' is used as fallback if not given).", env.value()); } String defVal = ""; if (m.getDefaultValue() != null && h.arg()) { if (m.getDefaultValue().getClass().isArray()) { defVal = Arrays.asList((Object[]) m.getDefaultValue()).toString(); } else { defVal = m.getDefaultValue().toString(); } } if (h != null) { target.row().cell(" --" + name + (h.arg() ? "=ARG" : "")).cell(h.value() + envFb).cell(defVal).build(); } else { target.row().cell(" --" + name).cell(env != null ? envFb.substring(1) : "").cell(defVal).build(); } } } /** * Maps the annotated method to another property name in the context. *

* This can be used to map an arbitrary {@link Annotation} {@link Method} to * another name, e.g. to access system properties with property names that are * not valid method names in Java. */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface ConfigurationNameMapping { String value(); } /** * Marker annotation which denotes an element to receive all remaining arguments. The type of the receiver must be String[] */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface RemainingArguments { } public enum ValueMapping { TO_UPPERCASE, TO_LOWERCASE } /** * Annotated field's value will be mapped on injection using the given {@link ValueMapping} policy. */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface ConfigurationValueMapping { ValueMapping value(); } @Documented @Retention(RetentionPolicy.RUNTIME) @Target(value = { ElementType.METHOD, ElementType.TYPE }) public @interface Help { /** * @return the help text for the annotated element. */ String value(); /** * @return whether the option accepts an argument (will be rendered accordingly * in help text). */ boolean arg() default true; } @Documented @Retention(RetentionPolicy.RUNTIME) @Target(value = { ElementType.METHOD }) public @interface Validator { Class>[] value(); } @Documented @Retention(RetentionPolicy.RUNTIME) @Target(value = ElementType.TYPE) public @interface ValidationMessage { String value() default "Validation failed for $1%s"; } @Documented @Retention(RetentionPolicy.RUNTIME) @Target(value = { ElementType.METHOD }) public @interface EnvironmentFallback { /** * @return the name of the environment variable to query in case the parameter is not explicitly set. */ String value(); } /** * Used to validate a value. *

* This interface is just a wrapper for {@link Predicate}. * * @param The type of the value that shall be tested */ public interface ConfigValidator extends Predicate { } }