package org.springframework.roo.support.util; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.io.Writer; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import javax.xml.xpath.XPath; import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathExpression; import javax.xml.xpath.XPathExpressionException; import javax.xml.xpath.XPathFactory; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Validate; import org.w3c.dom.DOMConfiguration; import org.w3c.dom.DOMImplementation; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.w3c.dom.bootstrap.DOMImplementationRegistry; import org.w3c.dom.ls.DOMImplementationLS; import org.w3c.dom.ls.LSException; import org.w3c.dom.ls.LSOutput; import org.w3c.dom.ls.LSSerializer; import org.xml.sax.SAXException; /** * Utilities related to XML usage. * * @author Stefan Schmidt * @author Ben Alex * @author Alan Stewart * @author Andrew Swan * @since 1.0 */ public final class XmlUtils { private static final Map<String, XPathExpression> COMPILED_EXPRESSION_CACHE = new HashMap<String, XPathExpression>(); private static final DocumentBuilderFactory FACTORY = DocumentBuilderFactory.newInstance(); private static final TransformerFactory TRANSFORMER_FACTORY = TransformerFactory.newInstance(); private static final XPath XPATH = XPathFactory.newInstance().newXPath(); /** * Checks the presented element for illegal characters that could cause * malformed XML. * * @param element the content of the XML element * @throws IllegalArgumentException if the element is null, has no text or * contains illegal characters */ public static void assertElementLegal(final String element) { if (StringUtils.isBlank(element)) { throw new IllegalArgumentException("Element required"); } // Note regular expression for legal characters found to be x5 slower in // profiling than this approach final char[] value = element.toCharArray(); for (final char c : value) { if (' ' == c || '>' == c || '<' == c || '!' == c || '@' == c || '%' == c || '^' == c || '?' == c || '(' == c || ')' == c || '~' == c || '`' == c || '{' == c || '}' == c || '[' == c || ']' == c || '|' == c || '\\' == c || '\'' == c || '+' == c) { throw new IllegalArgumentException("Illegal name '" + element + "' (illegal character)"); } } } /** * Compares two DOM {@link Node nodes} by comparing the representations of * the nodes as XML strings * * @param node1 the first node * @param node2 the second node * @return true if the XML representation node1 is the same as the XML * representation of node2, otherwise false */ public static boolean compareNodes(Node node1, Node node2) { Validate.notNull(node1, "First node required"); Validate.notNull(node2, "Second node required"); // The documents need to be cloned as normalization has side-effects node1 = node1.cloneNode(true); node2 = node2.cloneNode(true); // The documents need to be normalized before comparison takes place to // remove any formatting that interfere with comparison if (node1 instanceof Document && node2 instanceof Document) { ((Document) node1).normalizeDocument(); ((Document) node2).normalizeDocument(); } else { node1.normalize(); node2.normalize(); } return nodeToString(node1).equals(nodeToString(node2)); } /** * Converts a XHTML compliant id (used in jspx) to a CSS3 selector spec * compliant id. In that it will replace all '.,:,-' to '_' * * @param proposed Id * @return cleaned up Id */ public static String convertId(final String proposed) { return proposed.replaceAll("[:\\.-]", "_"); } /** * @return a transformer that indents entries by 4 characters (never null) */ public static Transformer createIndentingTransformer() { Transformer transformer; try { TRANSFORMER_FACTORY.setAttribute("indent-number", 4); transformer = TRANSFORMER_FACTORY.newTransformer(); } catch (final Exception e) { throw new IllegalStateException(e); } transformer.setOutputProperty(OutputKeys.INDENT, "yes"); transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4"); return transformer; } /** * Creates an {@link Element} containing the given text * * @param document the document to contain the new element * @param tagName the element's tag name (required) * @param text the text to set; can be <code>null</code> for none * @return a non-<code>null</code> element * @since 1.2.0 */ public static Element createTextElement(final Document document, final String tagName, final String text) { final Element element = document.createElement(tagName); element.setTextContent(text); return element; } /** * Creates a {@link StreamResult} by wrapping the given outputStream in an * {@link OutputStreamWriter} that transforms Windows line endings (\r\n) * into Unix line endings (\n) on Windows for consistency with Roo's * templates. * * @param outputStream * @return StreamResult * @throws UnsupportedEncodingException */ private static StreamResult createUnixStreamResultForEntry(final OutputStream outputStream) throws UnsupportedEncodingException { final Writer writer; if (IOUtils.LINE_SEPARATOR.equals("\r\n")) { writer = new OutputStreamWriter(outputStream, "ISO-8859-1") { @Override public void write(final char[] cbuf, final int off, final int len) throws IOException { for (int i = off; i < off + len; i++) { if (cbuf[i] != '\r' || i < cbuf.length - 1 && cbuf[i + 1] != '\n') { super.write(cbuf[i]); } } } @Override public void write(final int c) throws IOException { if (c != '\r') { super.write(c); } } @Override public void write(final String str, final int off, final int len) throws IOException { final String orig = str.substring(off, off + len); final String filtered = orig.replace("\r\n", "\n"); final int lengthDiff = orig.length() - filtered.length(); if (filtered.endsWith("\r")) { super.write(filtered.substring(0, filtered.length() - 1), 0, len - lengthDiff - 1); } else { super.write(filtered, 0, len - lengthDiff); } } }; } else { writer = new OutputStreamWriter(outputStream, "ISO-8859-1"); } return new StreamResult(writer); } /** * Checks in under a given root element whether it can find a child elements * which match the XPath expression supplied. Returns a {@link List} of * {@link Element} if they exist. Please note that the XPath parser used is * NOT namespace aware. So if you want to find a element <beans><sec:http> * you need to use the following XPath expression '/beans/http'. * * @param xPathExpression the xPathExpression * @param root the parent DOM element * @return a {@link List} of type {@link Element} if discovered, otherwise * an empty list (never null) */ public static List<Element> findElements(final String xPathExpression, final Element root) { final List<Element> elements = new ArrayList<Element>(); NodeList nodes = null; try { XPathExpression expr = COMPILED_EXPRESSION_CACHE.get(xPathExpression); if (expr == null) { expr = XPATH.compile(xPathExpression); COMPILED_EXPRESSION_CACHE.put(xPathExpression, expr); } nodes = (NodeList) expr.evaluate(root, XPathConstants.NODESET); } catch (final XPathExpressionException e) { throw new IllegalArgumentException("Unable evaluate xpath expression", e); } for (int i = 0, n = nodes.getLength(); i < n; i++) { elements.add((Element) nodes.item(i)); } return elements; } /** * Checks for a given element whether it can find an attribute which matches * the XPath expression supplied. Returns {@link Node} if exists. * * @param xPathExpression the xPathExpression (required) * @param element (required) * @return the Node if discovered (null if not found) */ public static Node findFirstAttribute(final String xPathExpression, final Element element) { Node attr = null; try { XPathExpression expr = COMPILED_EXPRESSION_CACHE.get(xPathExpression); if (expr == null) { expr = XPATH.compile(xPathExpression); COMPILED_EXPRESSION_CACHE.put(xPathExpression, expr); } attr = (Node) expr.evaluate(element, XPathConstants.NODE); } catch (final XPathExpressionException e) { throw new IllegalArgumentException("Unable evaluate xpath expression", e); } return attr; } /** * Searches the given parent element for a child element matching the given * XPath expression. Please note that the XPath parser used is NOT namespace * aware. So if you want to find an element * <code><beans><sec:http></code>, you need to use the following * XPath expression '/beans/http'. * * @param xPathExpression the xPathExpression (required) * @param parent the parent DOM element (required) * @return the Element if discovered (null if no such {@link Element} found) */ public static Element findFirstElement(final String xPathExpression, final Node parent) { final Node node = findNode(xPathExpression, parent); if (node instanceof Element) { return (Element) node; } return null; } /** * Checks in under a given root element whether it can find a child element * which matches the name supplied. Returns {@link Element} if exists. * * @param name the Element name (required) * @param root the parent DOM element (required) * @return the Element if discovered * @deprecated use {@link DomUtils#findFirstElementByName(String, Element)} * instead */ @Deprecated public static Element findFirstElementByName(final String name, final Element root) { return DomUtils.findFirstElementByName(name, root); } /** * Checks in under a given root element whether it can find a child node * which matches the XPath expression supplied. Returns {@link Node} if * exists. Please note that the XPath parser used is NOT namespace aware. So * if you want to find a element <code><beans><sec:http></code>, * you need to use the XPath expression '<code>/beans/http</code>'. * * @param xPathExpression the XPath expression (required) * @param root the parent DOM element (required) * @return the Node if discovered (null if not found) */ public static Node findNode(final String xPathExpression, final Node root) { Validate.notBlank(xPathExpression, "XPath expression required"); Validate.notNull(root, "Root element required"); Node node = null; try { XPathExpression expr = COMPILED_EXPRESSION_CACHE.get(xPathExpression); if (expr == null) { expr = XPATH.compile(xPathExpression); COMPILED_EXPRESSION_CACHE.put(xPathExpression, expr); } node = (Node) expr.evaluate(root, XPathConstants.NODE); } catch (final XPathExpressionException e) { throw new IllegalArgumentException("Unable evaluate XPath expression '" + xPathExpression + "'", e); } return node; } /** * Checks in under a given root element whether it can find a child element * which matches the XPath expression supplied. The {@link Element} must * exist. Returns {@link Element} if exists. Please note that the XPath * parser used is NOT namespace aware. So if you want to find a element * <beans><sec:http> you need to use the following XPath expression * '/beans/http'. * * @param xPathExpression the XPath expression (required) * @param root the parent DOM element (required) * @return the Element if discovered (never null; an exception is thrown if * cannot be found) */ public static Element findRequiredElement(final String xPathExpression, final Element root) { Validate.notBlank(xPathExpression, "XPath expression required"); Validate.notNull(root, "Root element required"); final Element element = findFirstElement(xPathExpression, root); Validate.notNull(element, "Unable to obtain required element '" + xPathExpression + "' from element '" + root + "'"); return element; } /** * Returns the root element of an addon's configuration file. * * @param clazz which owns the configuration * @return the configuration root element */ public static Element getConfiguration(final Class<?> clazz) { return getRootElement(clazz, "configuration.xml"); } /** * @return a new document builder (never null) */ public static DocumentBuilder getDocumentBuilder() { // factory.setNamespaceAware(true); try { return FACTORY.newDocumentBuilder(); } catch (final ParserConfigurationException e) { throw new IllegalStateException(e); } } /** * Returns the root element of the given XML file. * * @param clazz the class from whose package to open the file (required) * @param xmlFilePath the path of the XML file relative to the given class' * package (required) * @return a non-<code>null</code> element * @see Document#getDocumentElement() */ public static Element getRootElement(final Class<?> clazz, final String xmlFilePath) { final InputStream inputStream = FileUtils.getInputStream(clazz, xmlFilePath); Validate.notNull(inputStream, "Could not open the file '%s'", xmlFilePath); return readXml(inputStream).getDocumentElement(); } public static String getTextContent(final String path, final Element parentElement) { return getTextContent(path, parentElement, null); } public static String getTextContent(final String path, final Element parentElement, final String valueIfNull) { final Element element = XmlUtils.findFirstElement(path, parentElement); if (element != null) { return element.getTextContent(); } return valueIfNull; } /** * Converts a {@link Node node} to an XML string * * @param node the first element * @return the XML String representation of the node, never null */ public static String nodeToString(final Node node) { try { final StringWriter writer = new StringWriter(); createIndentingTransformer().transform(new DOMSource(node), new StreamResult(writer)); return writer.toString(); } catch (final TransformerException e) { throw new IllegalStateException(e); } } /** * Read an XML document from the supplied input stream and return a * document. * * @param inputStream the input stream to read from (required). The stream * is closed upon completion. * @return a document. * @throws IllegalStateException if the stream could not be read or parsed */ public static Document readXml(InputStream inputStream) { Validate.notNull(inputStream, "InputStream required"); try { if (!(inputStream instanceof BufferedInputStream)) { inputStream = new BufferedInputStream(inputStream); } return getDocumentBuilder().parse(inputStream); } catch (final Exception e) { throw new IllegalStateException(e); } finally { IOUtils.closeQuietly(inputStream); } } /** * Removes empty text nodes from the specified node * * @param node the element where empty text nodes will be removed * @deprecated use {@link DomUtils#removeTextNodes(Node)} instead */ @Deprecated public static void removeTextNodes(final Node node) { DomUtils.removeTextNodes(node); } /** * Returns the given XML as the root {@link Element} of a new * {@link Document} * * @param xml the XML to convert; can be blank * @return <code>null</code> if the given XML is blank * @since 1.2.0 */ public static Element stringToElement(final String xml) { if (StringUtils.isBlank(xml)) { return null; } try { return FACTORY.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes())) .getDocumentElement(); } catch (final IOException e) { throw new IllegalStateException(e); } catch (final ParserConfigurationException e) { throw new IllegalStateException(e); } catch (final SAXException e) { throw new IllegalStateException(e); } } /** * Write an XML document to the OutputStream provided. This method will * detect if the JDK supports the DOM Level 3 "format-pretty-print" * configuration and make use of it. If not found it will fall back to using * formatting offered by TrAX. * * @param outputStream the output stream to write to. The stream is closed * upon completion. * @param document the document to write. */ public static void writeFormattedXml(final OutputStream outputStream, final Document document) { // Note that the "format-pretty-print" DOM configuration parameter can // only be set in JDK 1.6+. final DOMImplementation domImplementation = document.getImplementation(); if (domImplementation.hasFeature("LS", "3.0") && domImplementation.hasFeature("Core", "2.0")) { DOMImplementationLS domImplementationLS = null; try { domImplementationLS = (DOMImplementationLS) domImplementation.getFeature("LS", "3.0"); } catch (final NoSuchMethodError nsme) { // Fall back to default LS DOMImplementationRegistry registry = null; try { registry = DOMImplementationRegistry.newInstance(); } catch (final Exception e) { // DOMImplementationRegistry not available. Falling back to // TrAX. writeXml(outputStream, document); return; } if (registry != null) { domImplementationLS = (DOMImplementationLS) registry.getDOMImplementation("LS"); } else { // DOMImplementationRegistry not available. Falling back to // TrAX. writeXml(outputStream, document); } } if (domImplementationLS != null) { final LSSerializer lsSerializer = domImplementationLS.createLSSerializer(); final DOMConfiguration domConfiguration = lsSerializer.getDomConfig(); if (domConfiguration.canSetParameter("format-pretty-print", Boolean.TRUE)) { lsSerializer.getDomConfig().setParameter("format-pretty-print", Boolean.TRUE); final LSOutput lsOutput = domImplementationLS.createLSOutput(); lsOutput.setEncoding("UTF-8"); lsOutput.setByteStream(outputStream); try { lsSerializer.write(document, lsOutput); } catch (final LSException lse) { throw new IllegalStateException(lse); } finally { IOUtils.closeQuietly(outputStream); } } else { // DOMConfiguration 'format-pretty-print' parameter not // available. Falling back to TrAX. writeXml(outputStream, document); } } else { // DOMImplementationLS not available. Falling back to TrAX. writeXml(outputStream, document); } } else { // DOM 3.0 LS and/or DOM 2.0 Core not supported. Falling back to // TrAX. writeXml(outputStream, document); } } /** * Write an XML document to the OutputStream provided. This will use the * pre-configured Roo provided Transformer. * * @param outputStream the output stream to write to. The stream is closed * upon completion. * @param document the document to write. */ public static void writeXml(final OutputStream outputStream, final Document document) { writeXml(createIndentingTransformer(), outputStream, document); } /** * Write an XML document to the OutputStream provided. This will use the * provided Transformer. * * @param transformer the transformer (can be obtained from * XmlUtils.createIndentingTransformer()) * @param outputStream the output stream to write to. The stream is closed * upon completion. * @param document the document to write. */ public static void writeXml(final Transformer transformer, OutputStream outputStream, final Document document) { Validate.notNull(transformer, "Transformer required"); Validate.notNull(outputStream, "OutputStream required"); Validate.notNull(document, "Document required"); transformer.setOutputProperty(OutputKeys.METHOD, "xml"); try { if (!(outputStream instanceof BufferedOutputStream)) { outputStream = new BufferedOutputStream(outputStream); } final StreamResult streamResult = createUnixStreamResultForEntry(outputStream); transformer.transform(new DOMSource(document), streamResult); } catch (final Exception e) { throw new IllegalStateException(e); } finally { IOUtils.closeQuietly(outputStream); } } /** * Constructor is private to prevent instantiation */ private XmlUtils() {} }