package com.cloudhopper.commons.xbean; /* * #%L * ch-commons-xbean * %% * Copyright (C) 2012 Cloudhopper by Twitter * %% * 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. * #L% */ // java imports import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.util.HashMap; import java.io.InputStream; import org.xml.sax.SAXException; // my imports import com.cloudhopper.commons.util.BeanProperty; import com.cloudhopper.commons.util.BeanUtil; import com.cloudhopper.commons.util.ClassUtil; import com.cloudhopper.commons.util.StringUtil; import com.cloudhopper.commons.xml.XPath; import com.cloudhopper.commons.xml.XmlParser; import com.cloudhopper.commons.xml.XmlParser.Attribute; import java.io.File; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.Collection; import java.util.Map; /** * Represents a Java bean configured via XML. This class essentially is a much * more simple version of Spring. Java objects are loosely configured with this * class using reflection and method/field names matching elements in an xml * document. * <br><br> * An element in an xml document represents a property that will be set on a * Java object. This class supports nested Java objects and their configuration * as well. Nested objects are first retrieved via a getter. If they are null, * this class will create a new instance with the default empty constructor. * <br><br> * Java objects can first be programmatically configured via Java code followed * by overrides in an xml document. Or, Java objects can be configured over and * over again by multiple xml documents. Also, a subset of "xpath" is supported * to allow elements inside an xml document to become the "root" of the xml document. * <br><br> * Also, a Java object is permitted to throw exceptions when a property is set * and that exception will be rethrown inside a PropertyInvocationException. * Properties are converted to their Java equivalents based on the Class type * of the property of the Java object. * <br><br> * <b>Sample Java Classes:</b> * <pre>{@code * public class Server { * public void setPort(int port) { this.port = value; } * public void setHost(String value) { this.host = value; } * } * * public class Config { * public void setServer(Server value) { this.server = value; } * }}</pre> * * <b>Sample XML:</b> * <pre>{@code * <configuration> * <server> * <host>www.google.com</host> * <port>80</port> * </server> * </configuration> * }</pre> * * <b>Sample Client Java Code:</b> * <pre>{@code * XmlBean bean = new XmlBean(); * Config config = new Config(); * bean.configure(xml, config); * }</pre> * * @author joelauer */ public class XmlBean { private String rootTag; private boolean accessPrivateProperties; public XmlBean() { rootTag = null; accessPrivateProperties = false; } /** * If non-null, will check that the root tag's value matches this value. * @param value The root node tag's value to match such as "Configuration" */ public void setRootTag(String value) { this.rootTag = value; } /** * Controls if private properties (without associated public getter/setter method) * are okay to set on a bean. If false, then a specific permission exception * will be thrown. This option is false by default. * @param value True if underlying fields should be exposed, otherwise set to false. */ public void setAccessPrivateProperties(boolean value) { this.accessPrivateProperties = value; } /** * Configures the object by setting the value of its properties with the * values from the xml document. Each element of the xml document represents * a named property of the object. For example, if "setFirstName" is a property * of the object, then an element of "<firstName>Joe</firstName>" would set * that value to "Joe". * @param xml A string representation of the xml document * @param obj The object to configure * @throws com.cloudhopper.commons.xbean.XmlBeanException Thrown if an exception * occurs while configuring the object. * @throws java.io.IOException Thrown if an exception occurs while attempting * to parse the input for the xml document. * @throws org.xml.sax.SAXException Thrown if an exception occurs while parsing * the xml document. */ public void configure(String xml, Object obj) throws XmlBeanException, IOException, SAXException { configure(xml, obj, null); } /** * Parses the xml document and configures the object by setting the value of its properties with the * values from the xml document. Each element of the xml document represents * a named property of the object. For example, if "setFirstName" is a property * of the object, then an element of "<firstName>Joe</firstName>" would set * that value to "Joe". * @param xml A string representation of the xml document * @param obj The object to configure * @param xpath The xpath used to select a new "root" node when configuring * the object. For example, "/nodeA/nodeB" would effectively configure * the object starting as though nodeB was the root node. Set to null * to ignore using the xpath. * @throws com.cloudhopper.commons.xbean.XPathNotFoundException Thrown if the * provided xpath isn't found in the xml document. * @throws com.cloudhopper.commons.xbean.XmlBeanException Thrown if an exception * occurs while configuring the object. * @throws java.io.IOException Thrown if an exception occurs while attempting * to parse the input for the xml document. * @throws org.xml.sax.SAXException Thrown if an exception occurs while parsing * the xml document. */ public void configure(String xml, Object obj, String xpath) throws XPathNotFoundException, XmlBeanException, IOException, SAXException { XmlParser parser = new XmlParser(); XmlParser.Node rootNode = parser.parse(xml); configure(rootNode, obj, xpath); } /** * Parses the xml document and configures the object by setting the value of its properties with the * values from the xml document. Each element of the xml document represents * a named property of the object. For example, if "setFirstName" is a property * of the object, then an element of "<firstName>Joe</firstName>" would set * that value to "Joe". * @param in An inputstream that contains the xml document * @param obj The object to configure * @throws com.cloudhopper.commons.xbean.XPathNotFoundException Thrown if the * provided xpath isn't found in the xml document. * @throws com.cloudhopper.commons.xbean.XmlBeanException Thrown if an exception * occurs while configuring the object. * @throws java.io.IOException Thrown if an exception occurs while attempting * to parse the input for the xml document. * @throws org.xml.sax.SAXException Thrown if an exception occurs while parsing * the xml document. */ public void configure(InputStream in, Object obj) throws XmlBeanException, IOException, SAXException { configure(in, obj, null); } /** * Parses the xml document and configures the object by setting the value of its properties with the * values from the xml document. Each element of the xml document represents * a named property of the object. For example, if "setFirstName" is a property * of the object, then an element of "<firstName>Joe</firstName>" would set * that value to "Joe". * @param in An inputstream that contains the xml document * @param obj The object to configure * @param xpath The xpath used to select a new "root" node when configuring * the object. For example, "/nodeA/nodeB" would effectively configure * the object starting as though nodeB was the root node. Set to null * to ignore using the xpath. * @throws com.cloudhopper.commons.xbean.XPathNotFoundException Thrown if the * provided xpath isn't found in the xml document. * @throws com.cloudhopper.commons.xbean.XmlBeanException Thrown if an exception * occurs while configuring the object. * @throws java.io.IOException Thrown if an exception occurs while attempting * to parse the input for the xml document. * @throws org.xml.sax.SAXException Thrown if an exception occurs while parsing * the xml document. */ public void configure(InputStream in, Object obj, String xpath) throws XPathNotFoundException, XmlBeanException, IOException, SAXException { XmlParser parser = new XmlParser(); XmlParser.Node rootNode = parser.parse(in); configure(rootNode, obj); } /** * Parses the xml document and configures the object by setting the value of its properties with the * values from the xml document. Each element of the xml document represents * a named property of the object. For example, if "setFirstName" is a property * of the object, then an element of "<firstName>Joe</firstName>" would set * that value to "Joe". * @param file The file object containing the xml document * @param obj The object to configure * @throws com.cloudhopper.commons.xbean.XPathNotFoundException Thrown if the * provided xpath isn't found in the xml document. * @throws com.cloudhopper.commons.xbean.XmlBeanException Thrown if an exception * occurs while configuring the object. * @throws java.io.IOException Thrown if an exception occurs while attempting * to parse the input for the xml document. * @throws org.xml.sax.SAXException Thrown if an exception occurs while parsing * the xml document. */ public void configure(File file, Object obj) throws XmlBeanException, IOException, SAXException { configure(file, obj, null); } /** * Parses the xml document and configures the object by setting the value of its properties with the * values from the xml document. Each element of the xml document represents * a named property of the object. For example, if "setFirstName" is a property * of the object, then an element of "<firstName>Joe</firstName>" would set * that value to "Joe". * @param file The file object containing the xml document * @param obj The object to configure * @param xpath The xpath used to select a new "root" node when configuring * the object. For example, "/nodeA/nodeB" would effectively configure * the object starting as though nodeB was the root node. Set to null * to ignore using the xpath. * @throws com.cloudhopper.commons.xbean.XPathNotFoundException Thrown if the * provided xpath isn't found in the xml document. * @throws com.cloudhopper.commons.xbean.XmlBeanException Thrown if an exception * occurs while configuring the object. * @throws java.io.IOException Thrown if an exception occurs while attempting * to parse the input for the xml document. * @throws org.xml.sax.SAXException Thrown if an exception occurs while parsing * the xml document. */ public void configure(File file, Object obj, String xpath) throws XPathNotFoundException, XmlBeanException, IOException, SAXException { XmlParser parser = new XmlParser(); XmlParser.Node rootNode = parser.parse(file); configure(rootNode, obj); } /** * Configures the object by setting the value of its properties with the * values from the xml document. Each element of the xml document represents * a named property of the object. For example, if "setFirstName" is a property * of the object, then an element of "<firstName>Joe</firstName>" would set * that value to "Joe". * @param rootNode The root node of the xml document * @param obj The object to configure * @param xpath The xpath used to select a new "root" node when configuring * the object. For example, "/nodeA/nodeB" would effectively configure * the object starting as though nodeB was the root node. Set to null * to ignore using the xpath. * @throws com.cloudhopper.commons.xbean.XPathNotFoundException Thrown if the * provided xpath isn't found in the xml document. * @throws com.cloudhopper.commons.xbean.XmlBeanException Thrown if an exception * occurs while configuring the object. */ public void configure(XmlParser.Node rootNode, Object obj, String xpath) throws XPathNotFoundException, XmlBeanException { // if xpath isn't null if (xpath != null) { // attempt to select a new rootNode using the xpath rootNode = XPath.select(rootNode, xpath); // if node is null, then this xpath wasn't found if (rootNode == null) { throw new XPathNotFoundException("Xpath '" + xpath + "' not found from root node"); } } // delegate to configure method configure(rootNode, obj); } /** * Configures the object by setting the value of its properties with the * values from the xml document. Each element of the xml document represents * a named property of the object. For example, if "setFirstName" is a property * of the object, then an element of "<firstName>Joe</firstName>" would set * that value to "Joe". * @param rootNode The root node of the xml document * @param obj The object to configure * @throws com.cloudhopper.commons.xbean.XmlBeanException Thrown if an exception * occurs while configuring the object. */ public void configure(XmlParser.Node rootNode, Object obj) throws XmlBeanException { // root node doesn't matter -- unless we're going to validate its name if (this.rootTag != null) { if (!this.rootTag.equals(rootNode.getTag())) { throw new RootTagMismatchException("Root tag mismatch [expected=" + this.rootTag + ", actual=" + rootNode.getTag() + "]"); } } // were any attributes included in the root? if (rootNode.hasAttributes()) { throw new PropertyNoAttributesExpectedException("[root node]", rootNode.getPath(), obj.getClass(), "No xml attributes expected for root node"); } // create a hashmap to track properties HashMap<String,String> properties = new HashMap<String,String>(); doConfigure(rootNode, obj, properties, true, null); } /** * Internal method for handling the configuration of an object. This method * is recursively called for simple and complex properties. */ private void doConfigure(XmlParser.Node rootNode, Object obj, HashMap<String,String> properties, boolean checkForDuplicates, CollectionHelper ch) throws XmlBeanException { // loop thru all child nodes if (rootNode.hasChildren()) { for (XmlParser.Node node : rootNode.getChildren()) { // tag name represents a property we're going to set String propertyName = node.getTag(); // find the property if it exists BeanProperty property = null; if (ch != null) { // make sure node tag name matches propertyName if (!propertyName.equals(ch.getValueProperty().getName())) { throw new PropertyNotFoundException(propertyName, node.getPath(), obj.getClass(), "Collection can only be configured with a property name of [" + ch.getValueProperty().getName() + "] but [" + propertyName + "] was used instead"); } property = ch.getValueProperty(); } else { try { property = BeanUtil.findBeanProperty(obj.getClass(), propertyName, true); } catch (IllegalAccessException e) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Illegal access while attempting to reflect property from class", e); } } // if property is null, then this isn't a valid property on this object if (property == null) { throw new PropertyNotFoundException(propertyName, node.getPath(), obj.getClass(), "Property [" + propertyName + "] not found"); } // only some attributes are permitted if we're dealing with a // collection or map at this point boolean isCollection = (Collection.class.isAssignableFrom(property.getType())); boolean isMap = (Map.class.isAssignableFrom(property.getType())); // were any attributes included? String typeAttrString = null; String valueAttrString = null; String keyAttrString = null; // check if an annotation is present for the field if (property.getField() != null) { XmlBeanProperty annotation = property.getField().getAnnotation(XmlBeanProperty.class); if (annotation != null) { if (!StringUtil.isEmpty(annotation.value())) { valueAttrString = annotation.value(); } if (!StringUtil.isEmpty(annotation.key())) { keyAttrString = annotation.key(); } } } // process attributes within the xml itself if (node.hasAttributes()) { for (Attribute attr : node.getAttributes()) { if (attr.getName().equals("type")) { typeAttrString = attr.getValue(); } else if (attr.getName().equals("value") && (isCollection || isMap)) { // only permitted on collections or map valueAttrString = attr.getValue(); } else if (attr.getName().equals("key") && (isMap || (ch != null && ch.isMapType()))) { // only permitted on map type OR on a map value keyAttrString = attr.getValue(); } else { throw new PropertyNoAttributesExpectedException(propertyName, node.getPath(), obj.getClass(), "One or more attributes not allowed for property [" + propertyName + "]"); } } } // // otherwise, the property exists, attempt to set it // // is there actually a "setter" method -- we shouldn't let // user's be able to configure fields in this case // unless accessing private properties is allowed // unless a collection helper is also null if (ch == null && !this.accessPrivateProperties && property.getAddMethod() == null && property.getSetMethod() == null) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Not permitted to add or set property [" + propertyName + "]"); } // if we can "add" this property, then turn off checkForDuplicates // we also don't check for duplicates in the case of a collection if (ch != null || property.canAdd()) { checkForDuplicates = false; } // was this property already previously set? // only use this check if an "add" method doesn't exist for the bean if (checkForDuplicates && properties.containsKey(node.getPath())) { throw new PropertyAlreadySetException(propertyName, node.getPath(), obj.getClass(), "Property [" + propertyName + "] was already previously set in the xml"); } // add this property to our hashmap properties.put(node.getPath(), null); // if a "type" attribute was included - check that it both exists // and is compatible with the type of the property it is being added/set to Class typeAttrClass = null; if (typeAttrString != null) { try { typeAttrClass = Class.forName(typeAttrString); } catch (ClassNotFoundException e) { throw new PropertyInvalidTypeException(propertyName, node.getPath(), obj.getClass(), "Unable to find class [" + typeAttrString + "] specified in type attribute of property '" + propertyName + "'"); } if (!property.getType().isAssignableFrom(typeAttrClass)) { throw new PropertyInvalidTypeException(propertyName, node.getPath(), obj.getClass(), "Unable to assign a value of specified type [" + typeAttrString + "] to property [" + propertyName + "] which is a type [" + property.getType().getName() + "]"); } } // the object we'll eventually add or set Object value = null; // get the node's text value String nodeText = node.getText(); // is this a simple conversion? if (TypeConverterUtil.isSupported(property.getType())) { // was any text set? if not, throw an exception if (nodeText == null) { throw new PropertyIsEmptyException(propertyName, node.getPath(), obj.getClass(), "Value for property [" + propertyName + "] was empty in xml"); } // try to convert this to a Java object value try { value = TypeConverterUtil.convert(nodeText, property.getType()); } catch (ConversionException e) { throw new PropertyConversionException(propertyName, node.getPath(), obj.getClass(), "The value [" + nodeText + "] for property [" + propertyName + "] could not be converted to a(n) " + property.getType().getSimpleName() + ". " + e.getMessage()); } // otherwise, this is a "complicated" type } else { // only "get" the property if its possible -- e.g. if there // is only an addXXXX method available, then this would throw // an exception, so we'll check to see if getting the property // is possible first if (property.canGet()) { try { value = property.get(obj); } catch (IllegalAccessException e) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Illegal access while attempting to get property value from object", e); } catch (InvocationTargetException e) { Throwable t = e; // this generally means the setXXXX method on the object // threw an exception -- we want to unwrap that and just // return that exception instead if (e.getCause() != null) { t = e.getCause(); } throw new PropertyInvocationException(propertyName, node.getPath(), obj.getClass(), "The existing value for property [" + propertyName + "] caused an exception during get", t.getMessage(), t); } } // if null, then we need to create a new instance of it if (value == null) { Class newType = property.getType(); // create a new instance of either the actual type OR the // type specified in the "type" attribute if (typeAttrClass != null) { newType = typeAttrClass; } try { value = newType.newInstance(); } catch (InstantiationException e) { throw new XmlBeanClassException("Failed while attempting to create object of type " + newType.getName(), e); } catch (IllegalAccessException e) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Illegal access while attempting to create new instance of " + newType.getName(), e); } } // special handling for "collections" -- required for handling // the values in configuring of child objects CollectionHelper newch = null; if (value instanceof Collection || value instanceof Map) { newch = createCollectionHelper(node, obj, value, propertyName, property, valueAttrString, keyAttrString); } // recursively configure the next object doConfigure(node, value, properties, checkForDuplicates, newch); } // save this reference object back (since it was successfully configured) if (ch != null) { if (ch.isCollectionType()) { ch.getCollectionObject().add(value); } else if (ch.isMapType()) { // need to figure out the key value -- it may either be a value from the value OR a simple type Object keyValue = null; if (ch.getKeyProperty() == null) { // a KEY must have been set! if (StringUtil.isEmpty(keyAttrString)) { throw new PropertyIsEmptyException(propertyName, node.getPath(), obj.getClass(), "The XML attribute [key] was null or empty and is required"); } else { try { keyValue = TypeConverterUtil.convert(keyAttrString, ch.getKeyClass()); } catch (ConversionException e) { throw new PropertyConversionException(propertyName, node.getPath(), obj.getClass(), "Unable to cleanly convert key value [" + keyAttrString + "] into type [" + ch.getKeyClass().getName() + ": " + e.getMessage(), e); } } } else { try { // extract the key value from the object keyValue = ch.getKeyProperty().get(value); } catch (Exception e) { throw new PropertyPermissionException(propertyName, node.getPath(), value.getClass(), "Unable to access property to get the value of the key: " + e.getMessage(), e); } if (keyValue == null) { throw new PropertyIsEmptyException(propertyName, node.getPath(), obj.getClass(), "The value of the key [" + ch.getKeyProperty().getName() + "] was null; unable to put value onto the map"); } } ch.getMapObject().put(keyValue, value); } else { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Unsupported collection/map type used"); } } else { try { property.addOrSet(obj, value); } catch (InvocationTargetException e) { Throwable t = e; // this generally means the setXXXX method on the object // threw an exception -- we want to unwrap that and just // return that exception instead if (e.getCause() != null) { t = e.getCause(); } throw new PropertyInvocationException(propertyName, node.getPath(), obj.getClass(), "The value '" + nodeText + "' for property '" + propertyName + "' caused an exception", t.getMessage(), t); } catch (IllegalAccessException e) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Illegal access while setting property", e); } } } } } public CollectionHelper createCollectionHelper(XmlParser.Node node, Object obj, Object value, String propertyName, BeanProperty property, String valueAttrString, String keyAttrString) throws PropertyPermissionException, XmlBeanClassException { // special handling for "collections" CollectionHelper newch = null; // determine "propertyName" for elements within the collection String valuePropertyName = (valueAttrString != null ? valueAttrString : "value"); // determine type of objects to create by determining generic type if (property.getField() == null) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Unable to access field for collection type [" + value.getClass().getName() + "]"); } else { Type type = property.getField().getGenericType(); if (!(type instanceof ParameterizedType)) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Only collection types with a parameterized type are supported"); } else { ParameterizedType pt = (ParameterizedType)type; Type[] pts = pt.getActualTypeArguments(); if (value instanceof Map) { // Maps must have 2 parameterized types if (pts.length != 2) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Only map types with 2 parameterized types are supported; actual [" + pts.length + "]"); } Type keyType = pts[0]; Type valueType = pts[1]; if (!(keyType instanceof Class && valueType instanceof Class)) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Only a map with paramertized types of concrete classes are supported (e.g. TreeMap<String,Integer> rather than TreeMap<String,ArrayList<String>>)"); } Class keyClass = (Class)keyType; Class valueClass = (Class)valueType; Map m = (Map)value; newch = CollectionHelper.createMapType(m, valuePropertyName, valueClass, keyAttrString, keyClass); } else { // Collections must have 1 parameterized type if (pts.length != 1) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Only collection types with 1 parameterized type are supported; actual [" + pts.length + "]"); } Type valueType = pts[0]; if (!(valueType instanceof Class)) { throw new PropertyPermissionException(propertyName, node.getPath(), obj.getClass(), "Only a collection with a parameterized type of a concrete class is supported (e.g. ArrayList<String> rather than ArrayList<ArrayList<String>>)"); } Class valueClass = (Class)valueType; Collection c = (Collection)value; newch = CollectionHelper.createCollectionType(c, valuePropertyName, valueClass); } } } return newch; } }