/*
* 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 org.atteo.config;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import javax.xml.bind.Binder;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.SchemaOutputResolver;
import javax.xml.bind.UnmarshalException;
import javax.xml.bind.Unmarshaller;
import javax.xml.bind.ValidationEvent;
import javax.xml.bind.ValidationEventHandler;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlElementRef;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Result;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.sax.SAXResult;
import javax.xml.transform.sax.SAXTransformerFactory;
import javax.xml.transform.sax.TransformerHandler;
import javax.xml.transform.stream.StreamResult;
import org.atteo.classindex.ClassFilter;
import org.atteo.classindex.ClassIndex;
import org.atteo.config.jaxb.FilteringAnnotationReader;
import org.atteo.config.jaxb.JaxbBindings;
import org.atteo.filtering.CompoundPropertyResolver;
import org.atteo.filtering.Filtering;
import org.atteo.filtering.PropertiesPropertyResolver;
import org.atteo.filtering.PropertyFilter;
import org.atteo.filtering.PropertyNotFoundException;
import org.atteo.filtering.PropertyResolver;
import org.atteo.xmlcombiner.CombineChildren;
import org.atteo.xmlcombiner.CombineSelf;
import org.atteo.xmlcombiner.XmlCombiner;
import org.eclipse.persistence.descriptors.ClassDescriptor;
import org.eclipse.persistence.internal.sessions.AbstractSession;
import org.eclipse.persistence.jaxb.JAXBContextFactory;
import org.eclipse.persistence.jaxb.JAXBContextProperties;
import org.eclipse.persistence.jaxb.JAXBHelper;
import org.eclipse.persistence.jaxb.javamodel.reflection.AnnotationHelper;
import org.eclipse.persistence.mappings.DatabaseMapping;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.xml.sax.ErrorHandler;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import com.google.common.collect.Iterables;
/**
* Generic configuration facility based on JAXB.
*
* <h3>Overview</h3>
* <p>
* Atteo Config opens one or more XML files, merges their content, filters them
* and then converts into the tree of objects using JAXB.
* </p>
* <h3>Defining XML schema</h3>
* <p>
* To use Atteo Config you need to define the schema for your configuration file.
* This is achieved by creating a number of classes which extend {@link Configurable} abstract class
* and annotating them with
* <a href="http://jaxb.java.net/2.2.6/docs/ch03.html#annotating-your-classes">JAXB annotations</a>.
* Let's start by defining classes for generic service and some specific database service:
* <pre>
* {@code
* abstract class Service extends Configurable {
* public abstract void start();
* }
*
*. @XmlRootElement(name = "database")
* class Database extends Service {
*. @XmlElement
* private String url;
*
* public void start() {
* System.out.println("Connecting to database: " + url);
* }
* }
* }
* </pre>
* </p>
* <p>
* Also let's create the class which will define root of the configuration schema. Here the common idiom
* is to create field of type {@link List} annotated with {@link XmlElementRef}. Using this JAXB
* will be able to unmarshal any subclass of list element type. In this way the schema is open-ended,
* allowing anyone to implement our Service. There is no need to list all the implementations:
* <pre>
* {@code
*. @XmlRootElement(name = "config")
* class Config extends Configurable {
*. @XmlElementRef
*. @XmlElementWrapper(name = "services")
*. @Valid
* private List<Service> services;
* }
* }
* </pre>
* </p>
* <p>
* The above schema will match the following XML:
*
* <pre>
* {@code
* <config>
* <services>
* <database>
* <url>jdbc:h2:file:/data/sample</url>
* </database>
* <database>
* <url>jdbc:h2:tcp://localhost/~/test</url>
* </database>
* </services>
* </config>
* }
* </pre>
* </p>
* </p>
*
* <h3>Reading configuration files</h3>
*
* <pre>
* Configuration configuration = new Configuration();
* configuration.combine("first.xml");
* configuration.combine("second.xml");
* configuration.filter(properties);
* Root root = configuration.read(Root.class);
* </pre>
* </p>
* <p>
* The following actions will be performed:
* <ul>
* <li>{@link JAXBContext} will be created for all the classes extending {@link Configurable},
* those classes are indexed at compile-time using {@link ClassIndex} facility,</li>
* <li>provided XML files will be parsed and combined using {@link XmlCombiner} facility,</li>
* <li>any property references in the form of <code>${name}</code> will be substituted
* with the value using registered {@link PropertyResolver}, see {@link Filtering} for details,</li>
* <li>the result will be unmarshalled using {@link Unmarshaller JAXB} into provided root class,</li>
* <li>finally the unmarshalled object tree will be validated using JSR 303
* - {@link Validation Bean Validation framework}.</li>
* </ul>
* </p>
*/
public class Configuration {
private JAXBContext context;
private Binder<Node> binder;
private final Iterable<Class<? extends Configurable>> klasses;
private DocumentBuilder builder;
private Document document;
private PropertyFilter propertyFilter;
//private RuntimeAnnotationReader annotationReader = new RuntimeInlineAnnotationReader();
/**
* Create Configuration by discovering all {@link Configurable}s.
*
* <p>
* Uses {@link ClassIndex#getSubclasses(Class)} to get list of top-level classes implementing {@link Configurable}
* interface.
* </p>
*/
public Configuration() {
this(ClassFilter.only().topLevel().from(ClassIndex.getSubclasses(Configurable.class)));
}
/**
* Create Configuration by manually specifying all {@link Configurable}s.
* @param klasses list of {@link Configurable} classes.
* @throws JAXBException when JAXB context creation fails
*/
public Configuration(Iterable<Class<? extends Configurable>> klasses) {
this.klasses = klasses;
propertyFilter = Filtering.getFilter((PropertyResolver) (String name, PropertyFilter filter) -> {
throw new PropertyNotFoundException(name);
});
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
try {
builder = factory.newDocumentBuilder();
// register null error handler, fatal errors will be reported with exception anyway
builder.setErrorHandler(new ErrorHandler() {
@Override
public void warning(SAXParseException exception) throws SAXException {
}
@Override
public void error(SAXParseException exception) throws SAXException {
}
@Override
public void fatalError(SAXParseException exception) throws SAXException {
}
});
context = JAXBContextFactory.createContext(Iterables.toArray(klasses, Class.class), Collections.emptyMap());
binder = context.createBinder();
// JAXB Moxy does not allow to set resolver on binder
// binder.setProperty(UnmarshallerProperties.ID_RESOLVER, new ScopedIdResolver());
binder.setEventHandler((ValidationEvent event) -> true);
document = builder.newDocument();
} catch (ParserConfigurationException e) {
throw new RuntimeException("Cannot configure XML parser", e);
} catch (JAXBException e) {
throw new RuntimeException("Cannot configure unmarshaller", e);
}
}
/**
* Generate an XSD schema for the configuration file.
* @param filename file to store the schema to
* @throws IOException when IO error occurs
*/
public void generateSchema(final File filename) throws IOException {
context.generateSchema(new SchemaOutputResolver() {
@Override
public Result createOutput(String namespaceUri, String suggestedFileName)
throws IOException {
// We should just call:
// return new StreamResult(filename);
// but this does not work due to the https://java.net/jira/browse/JAXB-974
try {
SAXTransformerFactory factory = (SAXTransformerFactory) TransformerFactory.newInstance();
TransformerHandler transformer = factory.newTransformerHandler();
transformer.setResult(new StreamResult(new FileOutputStream(filename)));
SAXResult saxResult = new SAXResult(transformer);
saxResult.setSystemId("dummy");
return saxResult;
} catch (TransformerConfigurationException e) {
throw new RuntimeException(e);
}
}
});
}
/**
* Filter {@code ${name}} placeholders using given properties.
* <p>
* This method wraps given properties into {@link PropertiesPropertyResolver}
* and calls {@link #filter(PropertyResolver)}.
* </p>
* @param properties properties to filter into configuration files
*/
public void filter(Properties properties) throws IncorrectConfigurationException {
filter(new PropertiesPropertyResolver(properties));
}
/**
* Filter {@code ${name}} placeholders using values from given {@link PropertyResolver}.
*
* @param resolver property resolver used for filtering the configuration files
*
* @see CompoundPropertyResolver
*/
public void filter(PropertyResolver resolver) throws IncorrectConfigurationException {
propertyFilter = Filtering.getFilter(resolver);
if (document.getDocumentElement() == null) {
return;
}
try {
propertyFilter.filter(document.getDocumentElement());
} catch (PropertyNotFoundException e) {
throw new IncorrectConfigurationException("Cannot resolve configuration properties: "
+ e.getMessage(), e);
}
}
/**
* Parse an XML file and combine it with the currently stored DOM tree.
* @param stream stream with the XML file
* @throws IncorrectConfigurationException when configuration file is invalid
* @throws IOException when the stream cannot be read
*/
public void combine(InputStream stream) throws IncorrectConfigurationException, IOException {
Document parentDocument = document;
try {
document = builder.parse(stream);
Element root = parentDocument.getDocumentElement();
if (root != null) {
// Combine with parent
XmlCombiner combiner = new XmlCombiner(builder, "id");
combiner.combine(parentDocument);
combiner.combine(document);
document = combiner.buildDocument();
}
} catch (SAXException e) {
throw new IncorrectConfigurationException("Parse error: " + e.getMessage(), e);
}
}
/**
* Unmarshals stored configuration DOM tree as object of the given class.
* @param rootClass the class to which unmarshal the DOM tree
* @param <T> type of the rootClass
* @return unmarshalled class tree, or null if no streams were provided
* @throws IncorrectConfigurationException if configuration is incorrect
*/
public <T extends Configurable> T read(Class<T> rootClass) throws IncorrectConfigurationException {
if (document.getDocumentElement() == null) {
return null;
}
T result;
final StringBuilder errors = new StringBuilder();
try {
Map<String, Object> properties = new HashMap<>();
AnnotationHelper helper = new FilteringAnnotationReader(propertyFilter);
properties.put(JAXBContextProperties.ANNOTATION_HELPER, helper);
context = JAXBContextFactory.createContext(Iterables.toArray(klasses, Class.class), properties);
binder = context.createBinder();
// JAXB Moxy does not allow to set resolver on binder
// binder.setProperty(UnmarshallerProperties.ID_RESOLVER, new ScopedIdResolver());
binder.setEventHandler((ValidationEvent event) -> {
if (event.getLocator().getLineNumber() != -1) {
errors.append("\n At line ").append(event.getLocator().getLineNumber());
} else if (event.getLocator().getNode() != null &&
event.getLocator().getNode().getParentNode() != null) {
errors.append("\n In <");
errors.append(event.getLocator().getNode().getParentNode().getNodeName());
errors.append(">");
}
errors.append(": ").append(event.getMessage());
return false;
});
result = rootClass.cast(binder.unmarshal(document.getDocumentElement()));
JaxbBindings.iterate(document.getDocumentElement(), binder,
new DefaultsSetter(context, propertyFilter));
ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
Validator validator = validatorFactory.getValidator();
Set<ConstraintViolation<T>> violations = validator.validate(result);
if (!violations.isEmpty()) {
for (ConstraintViolation<T> violation : violations) {
errors.append(" Error at: ").append(violation.getPropertyPath()).append("\n")
.append(" for value: ").append(violation.getInvalidValue()).append("\n")
.append(" with message: ").append(violation.getMessage());
}
throw new IncorrectConfigurationException("Constraints violation:" + errors.toString());
}
} catch (UnmarshalException e) {
if (e.getLinkedException() != null) {
throw new IncorrectConfigurationException("Parse error: " + e.getLinkedException().getMessage(),
e.getLinkedException());
} else if (errors.length() > 0) {
throw new IncorrectConfigurationException("Parse error: " + errors.toString(), e);
} else {
throw new IncorrectConfigurationException("Parse error:" + e.getMessage(), e);
}
} catch (JAXBException e) {
throw new IncorrectConfigurationException("Cannot unmarshall configuration file", e);
}
return result;
}
/**
* Get root XML {@link Element} of the combined configuration file.
* @return root {@link Element}
*/
public Element getRootElement() {
return document.getDocumentElement();
}
private static class DefaultsSetter implements JaxbBindings.Runnable {
private final JAXBContext context;
private final PropertyFilter properties;
public DefaultsSetter(JAXBContext context, PropertyFilter properties) {
this.context = context;
this.properties = properties;
}
@Override
public void run(Element element, Object object, Field field) {
Class<?> klass = object.getClass();
while (klass != Object.class) {
for (Field f : klass.getDeclaredFields()) {
XmlDefaultValue defaultValue = f.getAnnotation(XmlDefaultValue.class);
if (defaultValue != null) {
if (f.getType().isPrimitive()) {
throw new RuntimeException("@XmlDefaultValue cannot be specified on primitive type: "
+ klass.getCanonicalName() + "." + f.getName());
}
boolean accessible = f.isAccessible();
f.setAccessible(true);
try {
if (f.get(object) != null) {
continue;
}
} catch (IllegalArgumentException | IllegalAccessException e) {
throw new RuntimeException(e);
}
String value = defaultValue.value();
try {
value = properties.filter(value);
} catch (PropertyNotFoundException e) {
if (field != null) {
throw new RuntimeException("Property not found for field '"
+ field.getName() + "'", e);
} else {
throw new RuntimeException("Property not found", e);
}
}
AbstractSession session = JAXBHelper.getJAXBContext(context).getXMLContext().getSession(klass);
ClassDescriptor classDescriptor = session.getClassDescriptor(klass);
DatabaseMapping mapping = classDescriptor.getMappingForAttributeName(f.getName());
if (mapping == null) {
throw new RuntimeException("Field '" + f.getName() + "' cannot be annotated with"
+ " @" + XmlDefaultValue.class.getSimpleName() + ", because it is not mapped"
+ ", mark it with @" + XmlElement.class.getSimpleName());
}
mapping.setAttributeValueInObject(object, value);
f.setAccessible(accessible);
/**
* For reference, how it worked in JAXB RI:
*
RuntimeNonElement typeInfo = context.getRuntimeTypeInfoSet().getTypeInfo(f.getType());
Object v;
try {
v = typeInfo.getTransducer().parse(value);
} catch (AccessorException | SAXException e) {
throw new RuntimeException(e);
}
try {
f.set(object, v);
} catch (IllegalArgumentException | IllegalAccessException e) {
throw new RuntimeException(e);
}
*/
}
}
klass = klass.getSuperclass();
}
}
}
}