IniToStruct.java

/***************************************************************************
   Copyright 2012 Emily Estes

   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.
***************************************************************************/
package net.metanotion.util;


import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
import java.lang.reflect.ParameterizedType;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;

/** Populate a "configuration data structure" class with values from an INI file, using another
"defaults data structure" class to populate missing values. Basically: this class looks for fields in the ini file
with names that match the instance variable names in the config class. If it can't find a field like that, it looks
for an instance variable in the Defaults class with the same name and uses that value. If that fails, it looks to
see if the value's name is in an optional Set<String> that has field names that are "required". If it's in that list,
it throws a RuntimeException, otherwise it gives up. And if the optional Set<String> of "ignore" fields is provided,
it ignores those instance variables in the cconfig class.
	@param <B> The type of configuration object this instance creates.
*/
public final class IniToStruct<B> {
	private static final Set<String> emptySet = Collections.emptySet();
	private final Class<B> struct;
	private final Set<String> required;
	private final Set<String> ignore;

	/** Create an INI reader for the given class that loads and requires all variables.
		@param struct The type of configuration object to produce.
	*/
	public IniToStruct(final Class<B> struct) { this(struct, emptySet, emptySet); }

	/** Create an INI reader for the given class that requires all the values named in the required set and ignores any
		fields listed in the ignore set.
		@param struct The type of configuration object to produce.
		@param required A list of all the field names required to be non-null.
		@param ignore A list of all the field names to ignore in the class.
	*/
	public IniToStruct(final Class<B> struct, Set<String> required, Set<String> ignore) {
		this.struct = struct;
		this.required = required;
		this.ignore = ignore;
	}

	/** Create an instance of the configuration object given the defaults and configuration file.
		@param defaults The default object instance containing default values in public, non-static fields for missing
			values.
		@param config A reader providing the INI formatted stream to parse.
		@return An instance of the configuration object populated with values from the config file with missing values
			supplied by the default object.
		@throws InstantiationException if it cannot create the configuration object instance.
		@throws IllegalAccessException if the fields are inaccessible.
		@throws IOException if there is a problem reading the configuration file.
	*/
	public B getInstance(final Object defaults, final Reader config)
			throws InstantiationException, IllegalAccessException, IOException {
		final IniProperties ini = new IniProperties().load(config);
		return getInstance(defaults, ini);
	}

	/** Create an instance of the configuration object given the defaults and the INI properties instance.
		@param defaults The default object instance containing default values in public, non-static fields for missing
			values.
		@param config The INI file properties instance containing the vaules to store in the configuration object.
		@return An instance of the configuration object populated with values from the config file with missing values
			supplied by the default object.
		@throws InstantiationException if it cannot create the configuration object instance.
		@throws IllegalAccessException if the fields are inaccessible.
	*/
	public B getInstance(final Object defaults, final IniProperties config)
			throws InstantiationException, IllegalAccessException {
		return this.processSection(config, "", struct, defaults);
	}

	private boolean isBasic(final Class type) {
		if(isSimple(type)) {
			if((type == Integer.class) ||
				(type == Integer.TYPE) ||
				(type == String.class) ||
				(type == File.class) ||
				(type == Boolean.class) ||
				(type == Boolean.TYPE)) { return true; }
		}
		return false;
	}

	private static boolean isSimple(final Class type) {
		if(Map.class.isAssignableFrom(type)) { return false; }
		return true;
	}

	private static Object getDefault(final String name, final Class defClass, final Object defaults)
			throws IllegalAccessException {
		try {
			return defClass.getField(name).get(defaults);
		} catch (NoSuchFieldException nsfe) {
			return null;
		}
	}

	private static final String DOT = ".";
	private static final String ESC_DOT = "\\.";

	private void populateMap(final IniProperties ini,
			final String section,
			final String name,
			final Field f,
			final Map instMap,
			final Map defaultMap) throws InstantiationException, IllegalAccessException {
		final ParameterizedType params = (ParameterizedType) f.getGenericType();
		final Type[] paramsClass = params.getActualTypeArguments();
		final Class mType = (Class) paramsClass[1];
		if(isBasic((Class) paramsClass[1])) {
			final Iterator<String> i = ini.keys(section);
			while(i.hasNext()) {
				final String k = i.next();
				if(k.startsWith(name + DOT)) {
					final String[] s = k.split(ESC_DOT);
					final String key = s[s.length - 1];
					final Object def = defaultMap.get(key.trim());
					final Object o = this.parseSimpleType(ini, ini.getProperty(k, section), mType, null, null, def);
					if(o != null) { instMap.put(key, o); }
				}
			}
		}
	}

	private <V> V processSection(final IniProperties ini,
			final String section,
			final Class<V> sectionType,
			final Object defaults) throws InstantiationException, IllegalAccessException {
		final V inst = sectionType.newInstance();
		final Class defClass = defaults.getClass();
		for(final Field f: sectionType.getFields()) {
			final String name = f.getName();
			final String fqname = (section.length() == 0) ? name : (section + DOT + name);
			if(ignore.contains(fqname)) { continue; }
			final Class type = f.getType();
			if(isSimple(type)) {
				final Object defValue = getDefault(name, defClass, defaults);
				final Object o = this.parseSimpleType(ini, ini.getProperty(name, section), type, name, section, defValue);
				if(o != null) {
					f.setAccessible(true);
					f.set(inst, o);
				} else if(required.contains(fqname)) {
					throw new RuntimeException("Required field '" + fqname + "' is missing.");
				}
			} else if (Map.class.isAssignableFrom(type)) {
				final Object defValue = getDefault(name, defClass, defaults);
				final Map defaultMap = (defValue instanceof Map) ? (Map) defValue : new HashMap();
				populateMap(ini, section, name, f, (Map) f.get(inst), defaultMap);
			}
		}
		return inst;
	}

	private Object parseSimpleType(final IniProperties ini,
			final String prop,
			final Class type,
			final String name,
			final String section,
			final Object def) throws InstantiationException, IllegalAccessException {
		Object o = null;
		if((type == Integer.class) || (type == Integer.TYPE)) {
			if(prop == null) {
				o = (Integer) def;
			} else {
				try {
					o = Integer.parseInt(prop);
				} catch (NumberFormatException nfe) {
					o = (Integer) def;
				}
			}
		} else if (type == String.class) {
			o = prop;
			if(o == null) {o = (String) def; }
		} else if (type == File.class) {
			if(prop != null) {
				o = new File(prop);
			} else {
				o = (File) def;
			}
		} else if ((type == Boolean.class) || (type == Boolean.TYPE)) {
			if(prop != null) {
				if("true".compareToIgnoreCase(prop) == 0) {
					o = true;
				} else {
					o = false;
				}
			} else {
				o = (Boolean) def;
			}
		} else {
			final String newSection = (section.length() == 0) ? name : (section + DOT + name);
			o = processSection(ini, newSection, type, (def != null) ? def : new Object());
		}
		return o;
	}
}