001package org.andromda.core.common;
002
003import java.io.IOException;
004import java.io.InputStream;
005import java.io.Reader;
006import java.io.StringReader;
007import java.net.URL;
008import java.util.HashMap;
009import java.util.Map;
010import javax.xml.XMLConstants;
011import org.apache.commons.digester.Digester;
012import org.apache.commons.digester.xmlrules.DigesterLoader;
013import org.apache.commons.io.IOUtils;
014import org.apache.commons.lang.StringUtils;
015import org.apache.log4j.Logger;
016import org.xml.sax.EntityResolver;
017import org.xml.sax.ErrorHandler;
018import org.xml.sax.InputSource;
019import org.xml.sax.SAXException;
020import org.xml.sax.SAXParseException;
021
022/**
023 * <p>
024 * Creates and returns Objects based on a set of Apache Digester rules in a consistent manner, providing validation in
025 * the process.
026 * </p>
027 * <p>
028 * This XML object factory allows us to define a consistent/clean of configuring java objects from XML configuration
029 * files (i.e. it uses the class name of the java object to find what rule file and what XSD file to use). It also
030 * allows us to define a consistent way in which schema validation is performed.
031 * </p>
032 * <p>
033 * It separates each concern into one file, for example: to configure and perform validation on the MetafacadeMappings
034 * class, we need 3 files 1.) the java object (MetafacadeMappings.java), 2.) the rules file which tells the apache
035 * digester how to populate the java object from the XML configuration file (MetafacadeMappings-Rules.xml), and 3.) the
036 * XSD schema validation file (MetafacadeMappings.xsd). Note that each file is based on the name of the java object:
037 * 'java object name'.xsd and 'java object name'-Rules.xml'. After you have these three files then you just need to call
038 * the method #getInstance(java.net.URL objectClass) in this class from the java object you want to configure. This
039 * keeps the dependency to digester (or whatever XML configuration tool we are using at the time) to this single file.
040 * </p>
041 * <p>
042 * In order to add/modify an existing element/attribute in your configuration file, first make the modification in your
043 * java object, then modify its rules file to instruct the digester on how to configure your new attribute/method in
044 * the java object, and then modify your XSD file to provide correct validation for this new method/attribute. Please
045 * see the org.andromda.core.metafacade.MetafacadeMappings* files for an example on how to do this.
046 * </p>
047 *
048 * @author Chad Brandon
049 * @author Bob Fields
050 * @author Michail Plushnikov
051 */
052public class XmlObjectFactory
053{
054    /**
055     * The class logger. Note: visibility is protected to improve access within {@link XmlObjectValidator}
056     */
057    protected static final Logger logger = Logger.getLogger(XmlObjectFactory.class);
058
059    /**
060     * The expected suffixes for rule files.
061     */
062    private static final String RULES_SUFFIX = "-Rules.xml";
063
064    /**
065     * The expected suffix for XSD files.
066     */
067    private static final String SCHEMA_SUFFIX = ".xsd";
068
069    /**
070     * The digester instance.
071     */
072    private Digester digester = null;
073
074    /**
075     * The class of which the object we're instantiating.
076     */
077    private Class objectClass = null;
078
079    /**
080     * The URL to the object rules.
081     */
082    private URL objectRulesXml = null;
083
084    /**
085     * The URL of the schema.
086     */
087    private URL schemaUri = null;
088
089    /**
090     * Whether or not validation should be turned on by default when using this factory to load XML configuration
091     * files.
092     */
093    private static boolean defaultValidating = true;
094
095    /**
096     * Cache containing XmlObjectFactory instances which have already been configured for given objectRulesXml
097     */
098    private static final Map<Class, XmlObjectFactory> factoryCache = new HashMap<Class, XmlObjectFactory>();
099
100    /**
101     * Creates an instance of this XmlObjectFactory with the given <code>objectRulesXml</code>
102     *
103     * @param objectRulesXml URL to the XML file defining the digester rules for object
104     */
105    private XmlObjectFactory(final URL objectRulesXml)
106    {
107        ExceptionUtils.checkNull(
108                "objectRulesXml",
109                objectRulesXml);
110        this.digester = DigesterLoader.createDigester(objectRulesXml);
111        this.digester.setUseContextClassLoader(true);
112    }
113
114    /**
115     * Gets an instance of this XmlObjectFactory using the digester rules belonging to the <code>objectClass</code>.
116     *
117     * @param objectClass the Class of the object from which to configure this factory.
118     * @return the XmlObjectFactoy instance.
119     */
120    public static XmlObjectFactory getInstance(final Class objectClass)
121    {
122        ExceptionUtils.checkNull(
123                "objectClass",
124                objectClass);
125
126        XmlObjectFactory factory = factoryCache.get(objectClass);
127        if (factory == null)
128        {
129            final URL objectRulesXml =
130                    XmlObjectFactory.class.getResource('/' + objectClass.getName().replace(
131                            '.',
132                            '/') + RULES_SUFFIX);
133            if (objectRulesXml == null)
134            {
135                throw new XmlObjectFactoryException("No configuration rules found for class --> '" + objectClass + '\'');
136            }
137            factory = new XmlObjectFactory(objectRulesXml);
138            factory.objectClass = objectClass;
139            factory.objectRulesXml = objectRulesXml;
140            factory.setValidating(defaultValidating);
141            factoryCache.put(
142                    objectClass,
143                    factory);
144        }
145
146        return factory;
147    }
148
149    /**
150     * Allows us to set default validation to true/false for all instances of objects instantiated by this factory. This
151     * is necessary in some cases where the underlying parser doesn't support schema validation (such as when performing
152     * JUnit tests)
153     *
154     * @param validating true/false
155     */
156    public static void setDefaultValidating(final boolean validating)
157    {
158        defaultValidating = validating;
159    }
160
161    /**
162     * Sets whether or not the XmlObjectFactory should be validating, default is <code>true</code>. If it IS set to be
163     * validating, then there needs to be a schema named objectClass.xsd in the same package as the objectClass that
164     * this factory was created from.
165     *
166     * @param validating true/false
167     */
168    public void setValidating(final boolean validating)
169    {
170        this.digester.setValidating(validating);
171        if (validating)
172        {
173            if (this.schemaUri == null)
174            {
175                final String schemaLocation = '/' + this.objectClass.getName().replace(
176                        '.',
177                        '/') + SCHEMA_SUFFIX;
178                this.schemaUri = XmlObjectFactory.class.getResource(schemaLocation);
179                InputStream stream = null;
180                try
181                {
182                    if (this.schemaUri != null)
183                    {
184                        stream = this.schemaUri.openStream();
185                        IOUtils.closeQuietly(stream);
186                    }
187                }
188                catch (final IOException exception)
189                {
190                    this.schemaUri = null;
191                }
192                finally
193                {
194                    if (stream != null)
195                    {
196                        IOUtils.closeQuietly(stream);
197                    }
198                }
199                if (this.schemaUri == null)
200                {
201                    logger.warn(
202                            "WARNING! Was not able to find schemaUri --> '" + schemaLocation +
203                                    "' continuing in non validating mode");
204                }
205            }
206            if (this.schemaUri != null)
207            {
208                try
209                {
210                    this.digester.setProperty("http://apache.org/xml/properties/schema/external-noNamespaceSchemaLocation", this.schemaUri.toString());
211                    this.digester.setProperty("http://java.sun.com/xml/jaxp/properties/schemaLanguage", XMLConstants.W3C_XML_SCHEMA_NS_URI);
212
213                    this.digester.setErrorHandler(new XmlObjectValidator());
214                }
215                catch (final Exception exception)
216                {
217                    logger.warn(
218                            "WARNING! Your parser does NOT support the " +
219                                    " schema validation continuing in non validation mode",
220                            exception);
221                }
222            }
223        }
224    }
225
226    /**
227     * Returns a configured Object based on the objectXml configuration file
228     *
229     * @param objectXml the path to the Object XML config file.
230     * @return Object the created instance.
231     */
232    public Object getObject(final URL objectXml)
233    {
234        return this.getObject(
235                objectXml != null ? ResourceUtils.getContents(objectXml) : null,
236                objectXml);
237    }
238
239    /**
240     * Returns a configured Object based on the objectXml configuration reader.
241     *
242     * @param objectXml the path to the Object XML config file.
243     * @return Object the created instance.
244     */
245    public Object getObject(final Reader objectXml)
246    {
247        return getObject(ResourceUtils.getContents(objectXml));
248    }
249
250    /**
251     * Returns a configured Object based on the objectXml configuration file passed in as a String.
252     *
253     * @param objectXml the path to the Object XML config file.
254     * @return Object the created instance.
255     */
256    public Object getObject(String objectXml)
257    {
258        return this.getObject(
259                objectXml,
260                null);
261    }
262
263    /**
264     * Returns a configured Object based on the objectXml configuration file passed in as a String.
265     *
266     * @param objectXml the path to the Object XML config file.
267     * @param resource  the resource from which the objectXml was retrieved (this is needed to resolve
268     *                  any relative references; like XML entities).
269     * @return Object the created instance.
270     */
271    public Object getObject(
272            String objectXml,
273            final URL resource)
274    {
275        ExceptionUtils.checkNull(
276                "objectXml",
277                objectXml);
278        Object object = null;
279        try
280        {
281            this.digester.setEntityResolver(new XmlObjectEntityResolver(resource));
282            object = this.digester.parse(new StringReader(objectXml));
283            objectXml = null;
284            if (object == null)
285            {
286                final String message =
287                        "Was not able to instantiate an object using objectRulesXml '" + this.objectRulesXml +
288                                "' with objectXml '" + objectXml + "', please check either the objectXml " +
289                                "or objectRulesXml file for inconsistencies";
290                throw new XmlObjectFactoryException(message);
291            }
292        }
293        catch (final SAXException exception)
294        {
295            final Throwable cause = ExceptionUtils.getRootCause(exception);
296            logger.warn("SAXException " + schemaUri, cause);
297            if (cause instanceof SAXException)
298            {
299                final String message =
300                        "VALIDATION FAILED for --> '" + objectXml + "' against SCHEMA --> '" + this.schemaUri +
301                                "' --> message: '" + exception.getMessage() + '\'';
302                throw new XmlObjectFactoryException(message);
303            }
304            throw new XmlObjectFactoryException(cause);
305        }
306        catch (final Throwable throwable)
307        {
308            final String message = "XML resource could not be loaded --> '" + objectXml + '\'';
309            throw new XmlObjectFactoryException(message, throwable);
310        }
311        return object;
312    }
313
314    /**
315     * Handles the validation errors.
316     */
317    static final class XmlObjectValidator
318            implements ErrorHandler
319    {
320        /**
321         * @see org.xml.sax.ErrorHandler#error(org.xml.sax.SAXParseException)
322         */
323        public final void error(final SAXParseException exception)
324                throws SAXException
325        {
326            throw new SAXException(getMessage(exception));
327        }
328
329        /**
330         * @see org.xml.sax.ErrorHandler#fatalError(org.xml.sax.SAXParseException)
331         */
332        public final void fatalError(final SAXParseException exception)
333                throws SAXException
334        {
335            throw new SAXException(getMessage(exception));
336        }
337
338        /**
339         * @see org.xml.sax.ErrorHandler#warning(org.xml.sax.SAXParseException)
340         */
341        public final void warning(final SAXParseException exception)
342        {
343            logger.warn("WARNING!: " + getMessage(exception));
344        }
345
346        /**
347         * Constructs and returns the appropriate error message.
348         *
349         * @param exception the exception from which to extract the message.
350         * @return the message.
351         */
352        @SuppressWarnings("static-method")
353        private String getMessage(final SAXParseException exception)
354        {
355            final StringBuilder message = new StringBuilder(100);
356
357            message.append(exception.getMessage());
358            message.append(", line: ");
359            message.append(exception.getLineNumber());
360            message.append(", column: ").append(exception.getColumnNumber());
361
362            return message.toString();
363        }
364    }
365
366    /**
367     * The prefix that the systemId should start with when attempting
368     * to resolve it within a jar.
369     */
370    private static final String SYSTEM_ID_FILE = "file:";
371
372    /**
373     * Provides the resolution of external entities from the classpath.
374     */
375    private static final class XmlObjectEntityResolver
376            implements EntityResolver
377    {
378        private URL xmlResource;
379
380        XmlObjectEntityResolver(final URL xmlResource)
381        {
382            this.xmlResource = xmlResource;
383        }
384
385        /**
386         * @see org.xml.sax.EntityResolver#resolveEntity(String, String)
387         */
388        public InputSource resolveEntity(
389                final String publicId,
390                final String systemId)
391                throws SAXException, IOException
392        {
393            InputSource source = null;
394            if (this.xmlResource != null)
395            {
396                String path = systemId;
397                if (path != null && path.startsWith(SYSTEM_ID_FILE))
398                {
399                    final String xmlResource = this.xmlResource.toString();
400                    path = path.replaceFirst(
401                            SYSTEM_ID_FILE,
402                            "");
403
404                    // - remove any extra starting slashes
405                    path = ResourceUtils.normalizePath(path);
406
407                    // - if we still have one starting slash, remove it
408                    if (path.startsWith("/"))
409                    {
410                        path = path.substring(
411                                1,
412                                path.length());
413                    }
414                    final String xmlResourceName = xmlResource.replaceAll(
415                            ".*(\\+|/)",
416                            "");
417                    URL uri = null;
418                    InputStream inputStream = null;
419                    try
420                    {
421                        uri = ResourceUtils.toURL(StringUtils.replace(
422                                xmlResource,
423                                xmlResourceName,
424                                path));
425                        if (uri != null)
426                        {
427                            inputStream = uri.openStream();
428                        }
429                    }
430                    catch (final IOException exception)
431                    {
432                        // - ignore
433                    }
434                    if (inputStream == null)
435                    {
436                        try
437                        {
438                            uri = ResourceUtils.getResource(path);
439                            if (uri != null)
440                            {
441                                inputStream = uri.openStream();
442                            }
443                        }
444                        catch (final IOException exception)
445                        {
446                            // - ignore
447                        }
448                    }
449                    if (inputStream != null && uri != null)
450                    {
451                        source = new InputSource(inputStream);
452                        source.setPublicId(publicId);
453                        source.setSystemId(uri.toString());
454                    }
455                    // TODO Close InputStream while still returning a valid InputSource
456                }
457            }
458            return source;
459        }
460    }
461
462    /**
463     * @see Object#toString()
464     */
465    @Override
466    public String toString()
467    {
468        StringBuilder builder = new StringBuilder();
469        builder.append(super.toString()).append(" [digester=").append(this.digester)
470                .append(", objectClass=").append(this.objectClass).append(", objectRulesXml=")
471                .append(this.objectRulesXml).append(", schemaUri=").append(this.schemaUri)
472                .append("]");
473        return builder.toString();
474    }
475}