diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..5b388a8 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,28 @@ +# This is a basic workflow to help you get started with Actions + +name: Codecov + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + push: + pull_request: + + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + + - name: Run cobertura + run: mvn cobertura:cobertura + + - name: Upload report + run: bash <(curl -s https://codecov.io/bash) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..87ce66d --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,31 @@ +# This workflow will build a Java project with Maven +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven + +name: Build + +on: + push: + pull_request: + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: [7, 8, 9.0.x, 10, 11, 12, 13] + + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK + uses: actions/setup-java@v1 + with: + java-version: ${{ matrix.java }} + + - name: Clean Build + run: mvn clean + + - name: Build with Maven + run: mvn -B package --file pom.xml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 762f6e9..84a1064 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,8 @@ /.classpath /.project /.settings +/.envrc /mvn -/HOWTO_RELEASE.txt /bin /target /java-xmlbuilder diff --git a/CHANGES.md b/CHANGES.md index 7b6a569..4b2435f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,13 +1,65 @@ Release Notes for java-xmlbuilder ================================= +Version 1.3 - 8 July 2020 +------------------------- + +Fixes: + +* Update source version from 1.5 to 1.7 to work with Java versions 11+ which no + longer supports `javax.annotation` and because 6 no longer builds with Maven. + +* Tweaks to pom.xml to get Maven builds to work with Java 12. + +* Replace references to my defunct website with working alternatives. + +Version 1.2 - 1 September 2017 +------------------------------ + +Fixes: + +* Prevent XML External Entity (XXE) injection attacks by disabling parsing of + general and parameter external entities by default (#6). External entities + are now only parsed if this feature is explicitly enabled by passing a boolean + flag value to the #create and #parse methods. + WARNING: This will break code that expects external entities to be parsed. + +Enhancements: + +* Permit users to disable namespace-awareness in the underlying + DocumentBuilderFactory when constructing the builder with extended `create()` + and `parse()` methods. Namespace awareness is enabled by default unless you + use the more explicit versions of these methods that take additional + `enableExternalEntities` and `isNamespaceAware` parameters. + +Version 1.1 - 22 July 2014 +-------------------------- + +Added a new `XMLBuilder2` implementation that avoids checked exceptions in the +API and throws runtime exceptions instead. This should make the library much +more pleasant to use, and your code much cleaner, in situations where low-level +exceptions are unlikely -- which is probably most situations where you would +use this library. + +For example when creating a new document with the `#create` method, instead of +needing to explicitly catch the unlikely `ParserConfigurationException`, if you +use `XMLBuilder2` this exception automatically gets wrapped in the new +`XMLBuilderRuntimeException` class and can be left to propagate out. + +Aside from the removal of checked exceptions, `XMLBuilder2` has the same API as +the original `XMLBuilder` and should therefore be a drop-in replacement in +existing code. + +For further discussion and rationale see: +https://github.com/jmurty/java-xmlbuilder/issues/4 + Version 1.0 - 6 March 2014 -------------------------- Jumped version number from 0.7 to 1.0 to better reflect this project's age -and stability, as well as to celebrate the move to GitHub. +and stability, as well as to celebrate the move to GitHub. -* Migrated project from +* Migrated project from [Google Code](https://code.google.com/p/java-xmlbuilder/) to [GitHub](https://github.com/jmurty/java-xmlbuilder). Whew, that's better! * Test cases for edge-case issues and questions reported by users. @@ -51,7 +103,7 @@ Version 0.3 - 2 July 2010 * First release to Maven repository. * Parse existing XML documents with `parse` method. -* Find specific nodes in document with an XPath with `xpathFind`. +* Find specific nodes in document with an XPath with `xpathFind`. * Added JUnit tests Version 0.2 - 6 January 2009 diff --git a/README.md b/README.md index 4c51a1a..51bda75 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ java-xmlbuilder =============== +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.jamesmurty.utils/java-xmlbuilder/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.jamesmurty.utils/java-xmlbuilder) + +![Build](https://github.com/jmurty/java-xmlbuilder/workflows/Build/badge.svg) ![Codecov](https://github.com/jmurty/java-xmlbuilder/workflows/Codecov/badge.svg) + +[![codecov](https://codecov.io/gh/jmurty/java-xmlbuilder/branch/master/graph/badge.svg)](https://codecov.io/gh/jmurty/java-xmlbuilder) + XML Builder is a utility that allows simple XML documents to be constructed using relatively sparse Java code. @@ -17,6 +23,25 @@ further if you have special requirements. [Apache License Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) +XMLBuilder versus XMLBuilder2 +----------------------------- + +Since version 1.1 this library provides two builder implementations and APIs: + + * `XMLBuilder` – the original API – follows standard Java practice of + re-throwing lower level checked exceptions when you do things like create a + new document. + You must explicitly `catch` these checked exceptions in your codebase, even + though they are unlikely to occur in tested code. + * `XMLBuilder2` is a newer API that removes checked exceptions altogether, and + will instead wrap and propagate lower level exceptions in an unchecked + `XMLBuilderRuntimeException`. + Use this class if you don't like the code mess or overhead of try/catching + many low-level exceptions that are unlikely to occur in practice. + +Both these versions work identically apart from the handling of errors, so you +can use whichever version you prefer or "upgrade" from one to the other in +existing code. Quick Example ------------- @@ -26,7 +51,7 @@ Easily build XML documents using code structured like the final document. This code: ```java -XMLBuilder builder = XMLBuilder.create("Projects") +XMLBuilder2 builder = XMLBuilder2.create("Projects") .e("java-xmlbuilder").a("language", "Java").a("scm","SVN") .e("Location").a("type", "URL") .t("http://code.google.com/p/java-xmlbuilder/") @@ -54,11 +79,11 @@ Produces this XML document: Getting Started --------------- -See further example usage below and the -[JavaDoc documentation](http://s3.jamesmurty.com/java-xmlbuilder/index.html). +See further example usage below and in the +[JavaDoc documentation](http://s3.james.murty.co/java-xmlbuilder/index.html). Download a Jar file containing the latest version -[java-xmlbuilder-1.0.jar](http://s3.jamesmurty.com/java-xmlbuilder/java-xmlbuilder-1.0.jar). +[java-xmlbuilder-1.3.jar](http://s3.james.murty.co/java-xmlbuilder/java-xmlbuilder-1.3.jar). Maven users can add this project as a dependency with the following additions to a POM.xml file: @@ -69,7 +94,7 @@ to a POM.xml file: com.jamesmurty.utils java-xmlbuilder - 1.0 + 1.3 . . . @@ -174,7 +199,7 @@ methods. XMLBuilder builder = XMLBuilder.create("Projects") .e("java-xmlbuilder") .a("language", "Java") - .a("scm","SVN") + .a("scm","SVN") .e("Location") .a("type", "URL") .t("http://code.google.com/p/java-xmlbuilder/") @@ -202,7 +227,7 @@ The following methods are available for adding items to the XML document: ### Output -XMLBuilder includes two convenient methods for outputting a document. +XMLBuilder includes two convenient methods for outputting a document. You can use the `toWriter` method to print the document to an output stream or file: @@ -219,7 +244,7 @@ builder.asString(outputProperties); Both of these output methods take an `outputProperties` parameter that you can use to control how the output is generated. Any output properties you provide are forwarded to the underlying Transformer object that is used to serialize -the XML document. +the XML document. You might specify any non-standard properties like so: @@ -265,7 +290,7 @@ represents the document's root element, no matter deep an element hierarchy your code has built: ```java -org.w3c.dom.Element rootElement = +org.w3c.dom.Element rootElement = XMLBuilder.create("This") .e("Element") .e("Hierarchy") @@ -332,6 +357,22 @@ To produce: ``` +### Configuring advanced features + +When creating or parsing a document you can enable and disable advanced +features by using the more explicit versions of the `parse()` and `create()` +constructors. + +You can: + +* use the `enableExternalEntities` flag to enable or disable external entities. + NOTE: you should leave these disabled, as they are by default, unless you + really need them because they open you to XML External Entity (XXE) injection + attacks. +* use the `isNamespaceAware` flag to enable or disable namespace awareness in + the underlying `DocumentBuilderFactory`. + + Release History --------------- diff --git a/pom.xml b/pom.xml index 6d38f34..cecbdb2 100644 --- a/pom.xml +++ b/pom.xml @@ -4,10 +4,15 @@ java-xmlbuilder jar - 1.1-SNAPSHOT + 1.4-SNAPSHOT java-xmlbuilder XML Builder is a utility that creates simple XML documents using relatively sparse Java code https://github.com/jmurty/java-xmlbuilder + + + 1.7 + 1.7 + @@ -28,7 +33,7 @@ jmurty James Murty - http://jamesmurty.com + https://github.com/jmurty developer @@ -46,7 +51,7 @@ junit junit - 4.11 + 4.13.1 test @@ -67,6 +72,7 @@ v@{project.version} false + -Dgpgsign=true @@ -88,6 +94,9 @@ org.apache.maven.plugins maven-javadoc-plugin 2.9 + + 7 + attach-javadocs @@ -99,21 +108,48 @@ - org.apache.maven.plugins - maven-gpg-plugin - 1.4 - - - sign-artifacts - verify - - sign - - - + org.codehaus.mojo + cobertura-maven-plugin + 2.7 + + + html + xml + + + - + + + + release-sign-artifacts + + + gpgsign + true + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.4 + + + sign-artifacts + verify + + sign + + + + + + + + diff --git a/src/main/java/com/jamesmurty/utils/BaseXMLBuilder.java b/src/main/java/com/jamesmurty/utils/BaseXMLBuilder.java new file mode 100644 index 0000000..3122a30 --- /dev/null +++ b/src/main/java/com/jamesmurty/utils/BaseXMLBuilder.java @@ -0,0 +1,1445 @@ +/* + * Copyright 2008-2020 James Murty (github.com/jmurty) + * + * 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. + * + * + * This code is available from the GitHub code repository at: + * https://github.com/jmurty/java-xmlbuilder + */ +package com.jamesmurty.utils; + +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.util.Map.Entry; +import java.util.Properties; + +import javax.xml.namespace.NamespaceContext; +import javax.xml.namespace.QName; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.FactoryConfigurationError; +import javax.xml.parsers.ParserConfigurationException; +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 net.iharder.Base64; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +/** + * Base abstract class for all XML Builder implementations. + * Most of the work is done here. + * + * @author jmurty + */ +public abstract class BaseXMLBuilder { + + /** + * A DOM Document that stores the underlying XML document operated on by + * BaseXMLBuilder instances. This document object belongs to the root node + * of a document, and is shared by this node with all other BaseXMLBuilder + * instances via the {@link #getDocument()} method. + * This instance variable must only be created once, by the root node for + * any given document. + */ + private Document xmlDocument = null; + + /** + * The underlying node represented by this builder node. + */ + private Node xmlNode = null; + + /** + * If true, the builder will raise an {@link XMLBuilderRuntimeException} + * if external general and parameter entities cannot be explicitly enabled + * or disabled. + */ + public static boolean failIfExternalEntityParsingCannotBeConfigured = true; + + /** + * Construct a new builder object that wraps the given XML document. + * This constructor is for internal use only. + * + * @param xmlDocument + * an XML document that the builder will manage and manipulate. + */ + protected BaseXMLBuilder(Document xmlDocument) { + this.xmlDocument = xmlDocument; + this.xmlNode = xmlDocument.getDocumentElement(); + } + + /** + * Construct a new builder object that wraps the given XML document and node. + * This constructor is for internal use only. + * + * @param myNode + * the XML node that this builder node will wrap. This node may + * be part of the XML document, or it may be a new element that is to be + * added to the document. + * @param parentNode + * If not null, the given myElement will be appended as child node of the + * parentNode node. + */ + protected BaseXMLBuilder(Node myNode, Node parentNode) { + this.xmlNode = myNode; + if (myNode instanceof Document) { + this.xmlDocument = (Document) myNode; + } else { + this.xmlDocument = myNode.getOwnerDocument(); + } + if (parentNode != null) { + parentNode.appendChild(myNode); + } + } + + /** + * Explicitly enable or disable the 'external-general-entities' and + * 'external-parameter-entities' features of the underlying + * DocumentBuilderFactory. + * + * TODO This is a naive approach that simply tries to apply all known + * feature name/URL values in turn until one succeeds, or none do. + * + * @param factory + * factory which will have external general and parameter entities enabled + * or disabled. + * @param enableExternalEntities + * if true external entities will be explicitly enabled, otherwise they + * will be explicitly disabled. + */ + protected static void enableOrDisableExternalEntityParsing( + DocumentBuilderFactory factory, boolean enableExternalEntities) + { + // Feature list drawn from: + // https://www.owasp.org/index.php/XML_External_Entity_(XXE)_Processing + + /* Enable or disable external general entities */ + String[] externalGeneralEntitiesFeatures = { + // General + "http://xml.org/sax/features/external-general-entities", + // Xerces 1 + "http://xerces.apache.org/xerces-j/features.html#external-general-entities", + // Xerces 2 + "http://xerces.apache.org/xerces2-j/features.html#external-general-entities", + }; + boolean success = false; + for (String feature: externalGeneralEntitiesFeatures) { + try { + factory.setFeature(feature, enableExternalEntities); + success = true; + break; + } catch (ParserConfigurationException e) { + } + } + if (!success && failIfExternalEntityParsingCannotBeConfigured) { + throw new XMLBuilderRuntimeException( + new ParserConfigurationException( + "Failed to set 'external-general-entities' feature to " + + enableExternalEntities)); + } + + /* Enable or disable external parameter entities */ + String[] externalParameterEntitiesFeatures = { + // General + "http://xml.org/sax/features/external-parameter-entities", + // Xerces 1 + "http://xerces.apache.org/xerces-j/features.html#external-parameter-entities", + // Xerces 2 + "http://xerces.apache.org/xerces2-j/features.html#external-parameter-entities", + }; + success = false; + for (String feature: externalParameterEntitiesFeatures) { + try { + factory.setFeature(feature, enableExternalEntities); + success = true; + break; + } catch (ParserConfigurationException e) { + } + } + if (!success && failIfExternalEntityParsingCannotBeConfigured) { + throw new XMLBuilderRuntimeException( + new ParserConfigurationException( + "Failed to set 'external-parameter-entities' feature to " + + enableExternalEntities)); + } + } + + /** + * Construct an XML Document with a default namespace with the given + * root element. + * + * @param name + * the name of the document's root element. + * @param namespaceURI + * default namespace URI for document, ignored if null or empty. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} + * @return + * an XML Document. + * + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz + */ + protected static Document createDocumentImpl( + String name, String namespaceURI, boolean enableExternalEntities, + boolean isNamespaceAware) + throws ParserConfigurationException, FactoryConfigurationError + { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(isNamespaceAware); + enableOrDisableExternalEntityParsing(factory, enableExternalEntities); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.newDocument(); + Element rootElement = null; + if (namespaceURI != null && namespaceURI.length() > 0) { + rootElement = document.createElementNS(namespaceURI, name); + } else { + rootElement = document.createElement(name); + } + document.appendChild(rootElement); + return document; + } + + /** + * Return an XML Document parsed from the given input source. + * + * @param inputSource + * an XML document input source that will be parsed into a DOM. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} + * @return + * a builder node that can be used to add more nodes to the XML document. + * + * @throws ParserConfigurationException xyz + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz + * @throws IOException xyz + * @throws SAXException xyz + */ + protected static Document parseDocumentImpl( + InputSource inputSource, boolean enableExternalEntities, + boolean isNamespaceAware) + throws ParserConfigurationException, SAXException, IOException + { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(isNamespaceAware); + enableOrDisableExternalEntityParsing(factory, enableExternalEntities); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.parse(inputSource); + return document; + } + + /** + * Find and delete from the underlying Document any text nodes that + * contain nothing but whitespace, such as newlines and tab or space + * characters used to indent or pretty-print an XML document. + * + * Uses approach I documented on StackOverflow: + * http://stackoverflow.com/a/979606/4970 + * + * @throws XPathExpressionException xyz + */ + protected void stripWhitespaceOnlyTextNodesImpl() + throws XPathExpressionException + { + XPathFactory xpathFactory = XPathFactory.newInstance(); + // XPath to find empty text nodes. + XPathExpression xpathExp = xpathFactory.newXPath().compile( + "//text()[normalize-space(.) = '']"); + NodeList emptyTextNodes = (NodeList) xpathExp.evaluate( + this.getDocument(), XPathConstants.NODESET); + + // Remove each empty text node from document. + for (int i = 0; i < emptyTextNodes.getLength(); i++) { + Node emptyTextNode = emptyTextNodes.item(i); + emptyTextNode.getParentNode().removeChild(emptyTextNode); + } + } + + /** + * Find and delete from the underlying Document any text nodes that + * contain nothing but whitespace, such as newlines and tab or space + * characters used to indent or pretty-print an XML document. + * + * Uses approach I documented on StackOverflow: + * http://stackoverflow.com/a/979606/4970 + * + * @return + * a builder node at the same location as before the operation. + * @throws XPathExpressionException xyz + */ + public abstract BaseXMLBuilder stripWhitespaceOnlyTextNodes() + throws XPathExpressionException; + + /** + * Imports another BaseXMLBuilder document into this document at the + * current position. The entire document provided is imported. + * + * @param builder + * the BaseXMLBuilder document to be imported. + */ + protected void importXMLBuilderImpl(BaseXMLBuilder builder) { + assertElementContainsNoOrWhitespaceOnlyTextNodes(this.xmlNode); + Node importedNode = getDocument().importNode( + builder.getDocument().getDocumentElement(), true); + this.xmlNode.appendChild(importedNode); + } + + /** + * @return + * true if the XML Document and Element objects wrapped by this + * builder are equal to the other's wrapped objects. + */ + @Override + public boolean equals(Object obj) { + if (obj != null && obj instanceof BaseXMLBuilder) { + BaseXMLBuilder other = (BaseXMLBuilder) obj; + return + this.xmlDocument.equals(other.getDocument()) + && this.xmlNode.equals(other.getElement()); + } + return false; + } + + /** + * @return + * the XML element wrapped by this builder node, or null if the builder node wraps the + * root Document node. + */ + public Element getElement() { + if (this.xmlNode instanceof Element) { + return (Element) this.xmlNode; + } else { + return null; + } + } + + /** + * @return + * the XML document constructed by all builder nodes. + */ + public Document getDocument() { + return this.xmlDocument; + } + + /** + * Imports another XMLBuilder document into this document at the + * current position. The entire document provided is imported. + * + * @param builder + * the XMLBuilder document to be imported. + * + * @return + * a builder node at the same location as before the import, but + * now containing the entire document tree provided. + */ + public abstract BaseXMLBuilder importXMLBuilder(BaseXMLBuilder builder); + + /** + * @return + * the builder node representing the root element of the XML document. + */ + public abstract BaseXMLBuilder root(); + + /** + * Return the result of evaluating an XPath query on the builder's DOM + * using the given namespace. Returns null if the query finds nothing, + * or finds a node that does not match the type specified by returnType. + * + * @param xpath + * an XPath expression + * @param type + * the type the XPath is expected to resolve to, e.g: + * {@link XPathConstants#NODE}, {@link XPathConstants#NODESET}, + * {@link XPathConstants#STRING}. + * @param nsContext + * a mapping of prefixes to namespace URIs that allows the XPath expression + * to use namespaces, or null for a non-namespaced document. + * + * @return + * a builder node representing the first Element that matches the + * XPath expression. + * + * @throws XPathExpressionException + * If the XPath is invalid, or if does not resolve to at least one + * {@link Node#ELEMENT_NODE}. + */ + public Object xpathQuery(String xpath, QName type, NamespaceContext nsContext) + throws XPathExpressionException { + XPathFactory xpathFactory = XPathFactory.newInstance(); + XPath xPath = xpathFactory.newXPath(); + if (nsContext != null) { + xPath.setNamespaceContext(nsContext); + } + XPathExpression xpathExp = xPath.compile(xpath); + try { + return xpathExp.evaluate(this.xmlNode, type); + } catch (IllegalArgumentException e) { + // Thrown if item found does not match expected type + return null; + } + } + + /** + * Return the result of evaluating an XPath query on the builder's DOM. + * Returns null if the query finds nothing, + * or finds a node that does not match the type specified by returnType. + * + * @param xpath + * an XPath expression + * @param type + * the type the XPath is expected to resolve to, e.g: + * {@link XPathConstants#NODE}, {@link XPathConstants#NODESET}, + * {@link XPathConstants#STRING} + * + * @return + * a builder node representing the first Element that matches the + * XPath expression. + * + * @throws XPathExpressionException + * If the XPath is invalid, or if does not resolve to at least one + * {@link Node#ELEMENT_NODE}. + */ + public Object xpathQuery(String xpath, QName type) + throws XPathExpressionException { + return xpathQuery(xpath, type, null); + } + + /** + * Find the first element in the builder's DOM matching the given + * XPath expression, where the expression may include namespaces if + * a {@link NamespaceContext} is provided. + * + * @param xpath + * An XPath expression that *must* resolve to an existing Element within + * the document object model. + * @param nsContext + * a mapping of prefixes to namespace URIs that allows the XPath expression + * to use namespaces. + * + * @return + * a builder node representing the first Element that matches the + * XPath expression. + * + * @throws XPathExpressionException + * If the XPath is invalid, or if does not resolve to at least one + * {@link Node#ELEMENT_NODE}. + */ + public abstract BaseXMLBuilder xpathFind(String xpath, NamespaceContext nsContext) + throws XPathExpressionException; + + /** + * Find the first element in the builder's DOM matching the given + * XPath expression. + * + * @param xpath + * An XPath expression that *must* resolve to an existing Element within + * the document object model. + * + * @return + * a builder node representing the first Element that matches the + * XPath expression. + * + * @throws XPathExpressionException + * If the XPath is invalid, or if does not resolve to at least one + * {@link Node#ELEMENT_NODE}. + */ + public abstract BaseXMLBuilder xpathFind(String xpath) + throws XPathExpressionException; + + /** + * Find the first element in the builder's DOM matching the given + * XPath expression, where the expression may include namespaces if + * a {@link NamespaceContext} is provided. + * + * @param xpath + * An XPath expression that *must* resolve to an existing Element within + * the document object model. + * @param nsContext + * a mapping of prefixes to namespace URIs that allows the XPath expression + * to use namespaces. + * + * @return + * the first Element that matches the XPath expression. + * + * @throws XPathExpressionException + * If the XPath is invalid, or if does not resolve to at least one + * {@link Node#ELEMENT_NODE}. + */ + protected Node xpathFindImpl(String xpath, NamespaceContext nsContext) + throws XPathExpressionException + { + Node foundNode = (Node) this.xpathQuery(xpath, XPathConstants.NODE, nsContext); + if (foundNode == null || foundNode.getNodeType() != Node.ELEMENT_NODE) { + throw new XPathExpressionException("XPath expression \"" + + xpath + "\" does not resolve to an Element in context " + + this.xmlNode + ": " + foundNode); + } + return foundNode; + } + + /** + * Look up the namespace matching the current builder node's qualified + * name prefix (if any) or the document's default namespace. + * + * @param name + * the name of the XML element. + * + * @return + * The namespace URI, or null if none applies. + */ + protected String lookupNamespaceURIImpl(String name) { + String prefix = getPrefixFromQualifiedName(name); + String namespaceURI = this.xmlNode.lookupNamespaceURI(prefix); + return namespaceURI; + } + + /** + * Add a named and namespaced XML element to the document as a child of + * this builder's node. + * + * @param name + * the name of the XML element. + * @param namespaceURI + * a namespace URI + * @return xyz + * + * @throws IllegalStateException + * if you attempt to add a child element to an XML node that already + * contains a text node value. + */ + protected Element elementImpl(String name, String namespaceURI) { + assertElementContainsNoOrWhitespaceOnlyTextNodes(this.xmlNode); + if (namespaceURI == null) { + return getDocument().createElement(name); + } else { + return getDocument().createElementNS(namespaceURI, name); + } + } + + /** + * Add a named XML element to the document as a child of this builder node, + * and return the builder node representing the new child. + * + * When adding an element to a namespaced document, the new node will be + * assigned a namespace matching it's qualified name prefix (if any) or + * the document's default namespace. NOTE: If the element has a prefix that + * does not match any known namespaces, the element will be created + * without any namespace. + * + * @param name + * the name of the XML element. + * + * @return + * a builder node representing the new child. + * + * @throws IllegalStateException + * if you attempt to add a child element to an XML node that already + * contains a text node value. + */ + public abstract BaseXMLBuilder element(String name); + + /** + * Synonym for {@link #element(String)}. + * + * @param name + * the name of the XML element. + * + * @return + * a builder node representing the new child. + * + * @throws IllegalStateException + * if you attempt to add a child element to an XML node that already + * contains a text node value. + */ + public abstract BaseXMLBuilder elem(String name); + + /** + * Synonym for {@link #element(String)}. + * + * @param name + * the name of the XML element. + * + * @return + * a builder node representing the new child. + * + * @throws IllegalStateException + * if you attempt to add a child element to an XML node that already + * contains a text node value. + */ + public abstract BaseXMLBuilder e(String name); + + /** + * Add a named and namespaced XML element to the document as a child of + * this builder node, and return the builder node representing the new child. + * + * @param name + * the name of the XML element. + * @param namespaceURI + * a namespace URI + * + * @return + * a builder node representing the new child. + * + * @throws IllegalStateException + * if you attempt to add a child element to an XML node that already + * contains a text node value. + */ + public abstract BaseXMLBuilder element(String name, String namespaceURI); + + /** + * Add a named XML element to the document as a sibling element + * that precedes the position of this builder node, and return the builder node + * representing the new child. + * + * When adding an element to a namespaced document, the new node will be + * assigned a namespace matching it's qualified name prefix (if any) or + * the document's default namespace. NOTE: If the element has a prefix that + * does not match any known namespaces, the element will be created + * without any namespace. + * + * @param name + * the name of the XML element. + * + * @return + * a builder node representing the new child. + * + * @throws IllegalStateException + * if you attempt to add a sibling element to a node where there are already + * one or more siblings that are text nodes. + */ + public abstract BaseXMLBuilder elementBefore(String name); + + /** + * Add a named and namespaced XML element to the document as a sibling element + * that precedes the position of this builder node, and return the builder node + * representing the new child. + * + * @param name + * the name of the XML element. + * @param namespaceURI + * a namespace URI + * + * @return + * a builder node representing the new child. + * + * @throws IllegalStateException + * if you attempt to add a sibling element to a node where there are already + * one or more siblings that are text nodes. + */ + public abstract BaseXMLBuilder elementBefore(String name, String namespaceURI); + + /** + * Add a named attribute value to the element represented by this builder + * node, and return the node representing the element to which the + * attribute was added (not the new attribute node). + * + * @param name + * the attribute's name. + * @param value + * the attribute's value. + * + * @return + * the builder node representing the element to which the attribute was + * added. + */ + public abstract BaseXMLBuilder attribute(String name, String value); + + /** + * Synonym for {@link #attribute(String, String)}. + * + * @param name + * the attribute's name. + * @param value + * the attribute's value. + * + * @return + * the builder node representing the element to which the attribute was + * added. + */ + public abstract BaseXMLBuilder attr(String name, String value); + + /** + * Synonym for {@link #attribute(String, String)}. + * + * @param name + * the attribute's name. + * @param value + * the attribute's value. + * + * @return + * the builder node representing the element to which the attribute was + * added. + */ + public abstract BaseXMLBuilder a(String name, String value); + + + /** + * Add or replace the text value of an element represented by this builder + * node, and return the node representing the element to which the text + * was added (not the new text node). + * + * @param value + * the text value to set or add to the element. + * @param replaceText + * if True any existing text content of the node is replaced with the + * given text value, if the given value is appended to any existing text. + * + * @return + * the builder node representing the element to which the text was added. + */ + public abstract BaseXMLBuilder text(String value, boolean replaceText); + + /** + * Add a text value to the element represented by this builder node, and + * return the node representing the element to which the text + * was added (not the new text node). + * + * @param value + * the text value to add to the element. + * + * @return + * the builder node representing the element to which the text was added. + */ + public abstract BaseXMLBuilder text(String value); + + /** + * Synonym for {@link #text(String)}. + * + * @param value + * the text value to add to the element. + * + * @return + * the builder node representing the element to which the text was added. + */ + public abstract BaseXMLBuilder t(String value); + + /** + * Add a named XML element to the document as a sibling element + * that precedes the position of this builder node. + * + * When adding an element to a namespaced document, the new node will be + * assigned a namespace matching it's qualified name prefix (if any) or + * the document's default namespace. NOTE: If the element has a prefix that + * does not match any known namespaces, the element will be created + * without any namespace. + * + * @param name + * the name of the XML element. + * @return xyz + * + * @throws IllegalStateException + * if you attempt to add a sibling element to a node where there are already + * one or more siblings that are text nodes. + */ + protected Element elementBeforeImpl(String name) { + String prefix = getPrefixFromQualifiedName(name); + String namespaceURI = this.xmlNode.lookupNamespaceURI(prefix); + return elementBeforeImpl(name, namespaceURI); + } + + /** + * Add a named and namespaced XML element to the document as a sibling element + * that precedes the position of this builder node. + * + * @param name + * the name of the XML element. + * @param namespaceURI + * a namespace URI + * @return xyz + * + * @throws IllegalStateException + * if you attempt to add a sibling element to a node where there are already + * one or more siblings that are text nodes. + */ + protected Element elementBeforeImpl(String name, String namespaceURI) { + Node parentNode = this.xmlNode.getParentNode(); + assertElementContainsNoOrWhitespaceOnlyTextNodes(parentNode); + + Element newElement = (namespaceURI == null + ? getDocument().createElement(name) + : getDocument().createElementNS(namespaceURI, name)); + + // Insert new element before the current element + parentNode.insertBefore(newElement, this.xmlNode); + // Return a new builder node pointing at the new element + return newElement; + } + + /** + * Add a named attribute value to the element for this builder node. + * + * @param name + * the attribute's name. + * @param value + * the attribute's value. + */ + protected void attributeImpl(String name, String value) { + if (! (this.xmlNode instanceof Element)) { + throw new RuntimeException( + "Cannot add an attribute to non-Element underlying node: " + + this.xmlNode); + } + ((Element) xmlNode).setAttribute(name, value); + } + + /** + * Add or replace the text value of an element for this builder node. + * + * @param value + * the text value to set or add to the element. + * @param replaceText + * if True any existing text content of the node is replaced with the + * given text value, if the given value is appended to any existing text. + */ + protected void textImpl(String value, boolean replaceText) { + // Issue 10: null text values cause exceptions on subsequent call to + // Transformer to render document, so we fail-fast here on bad data. + if (value == null) { + throw new IllegalArgumentException("Illegal null text value"); + } + + if (replaceText) { + xmlNode.setTextContent(value); + } else { + xmlNode.appendChild(getDocument().createTextNode(value)); + } + } + + + /** + * Add a CDATA node with String content to the element for this builder node. + * + * @param data + * the String value that will be added to a CDATA element. + */ + protected void cdataImpl(String data) { + xmlNode.appendChild( + getDocument().createCDATASection(data)); + } + + /** + * Add a CDATA node with Base64-encoded byte data content to the element + * for this builder node. + * + * @param data + * the data value that will be Base64-encoded and added to a CDATA element. + */ + protected void cdataImpl(byte[] data) { + xmlNode.appendChild( + getDocument().createCDATASection( + Base64.encodeBytes(data))); + } + + /** + * Add a comment to the element represented by this builder node. + * + * @param comment + * the comment to add to the element. + */ + protected void commentImpl(String comment) { + xmlNode.appendChild(getDocument().createComment(comment)); + } + + /** + * Add an instruction to the element represented by this builder node. + * + * @param target + * the target value for the instruction. + * @param data + * the data value for the instruction + */ + protected void instructionImpl(String target, String data) { + xmlNode.appendChild(getDocument().createProcessingInstruction(target, data)); + } + + /** + * Insert an instruction before the element represented by this builder node. + * + * @param target + * the target value for the instruction. + * @param data + * the data value for the instruction + */ + protected void insertInstructionImpl(String target, String data) { + getDocument().insertBefore( + getDocument().createProcessingInstruction(target, data), + xmlNode); + } + + /** + * Add a reference to the element represented by this builder node. + * + * @param name + * the name value for the reference. + */ + protected void referenceImpl(String name) { + xmlNode.appendChild(getDocument().createEntityReference(name)); + } + + /** + * Add an XML namespace attribute to this builder's element node. + * + * @param prefix + * a prefix for the namespace URI within the document, may be null + * or empty in which case a default "xmlns" attribute is created. + * @param namespaceURI + * a namespace uri + */ + protected void namespaceImpl(String prefix, String namespaceURI) { + if (! (this.xmlNode instanceof Element)) { + throw new RuntimeException( + "Cannot add an attribute to non-Element underlying node: " + + this.xmlNode); + } + if (prefix != null && prefix.length() > 0) { + ((Element) xmlNode).setAttributeNS("http://www.w3.org/2000/xmlns/", + "xmlns:" + prefix, namespaceURI); + } else { + ((Element) xmlNode).setAttributeNS("http://www.w3.org/2000/xmlns/", + "xmlns", namespaceURI); + } + } + + /** + * Add an XML namespace attribute to this builder's element node + * without a prefix. + * + * @param namespaceURI + * a namespace uri + */ + protected void namespaceImpl(String namespaceURI) { + namespaceImpl(null, namespaceURI); + } + + /** + * Add a CDATA node with String content to the element represented by this + * builder node, and return the node representing the element to which the + * data was added (not the new CDATA node). + * + * @param data + * the String value that will be added to a CDATA element. + * + * @return + * the builder node representing the element to which the data was added. + */ + public abstract BaseXMLBuilder cdata(String data); + + /** + * Synonym for {@link #cdata(String)}. + * + * @param data + * the String value that will be added to a CDATA element. + * + * @return + * the builder node representing the element to which the data was added. + */ + public abstract BaseXMLBuilder data(String data); + + /** + * Synonym for {@link #cdata(String)}. + * + * @param data + * the String value that will be added to a CDATA element. + * + * @return + * the builder node representing the element to which the data was added. + */ + public abstract BaseXMLBuilder d(String data); + + /** + * Add a CDATA node with Base64-encoded byte data content to the element represented + * by this builder node, and return the node representing the element to which the + * data was added (not the new CDATA node). + * + * @param data + * the data value that will be Base64-encoded and added to a CDATA element. + * + * @return + * the builder node representing the element to which the data was added. + */ + public abstract BaseXMLBuilder cdata(byte[] data); + + /** + * Synonym for {@link #cdata(byte[])}. + * + * @param data + * the data value that will be Base64-encoded and added to a CDATA element. + * + * @return + * the builder node representing the element to which the data was added. + */ + public abstract BaseXMLBuilder data(byte[] data); + + /** + * Synonym for {@link #cdata(byte[])}. + * + * @param data + * the data value that will be Base64-encoded and added to a CDATA element. + * + * @return + * the builder node representing the element to which the data was added. + */ + public abstract BaseXMLBuilder d(byte[] data); + + /** + * Add a comment to the element represented by this builder node, and + * return the node representing the element to which the comment + * was added (not the new comment node). + * + * @param comment + * the comment to add to the element. + * + * @return + * the builder node representing the element to which the comment was added. + */ + public abstract BaseXMLBuilder comment(String comment); + + /** + * Synonym for {@link #comment(String)}. + * + * @param comment + * the comment to add to the element. + * + * @return + * the builder node representing the element to which the comment was added. + */ + public abstract BaseXMLBuilder cmnt(String comment); + + /** + * Synonym for {@link #comment(String)}. + * + * @param comment + * the comment to add to the element. + * + * @return + * the builder node representing the element to which the comment was added. + */ + public abstract BaseXMLBuilder c(String comment); + + /** + * Add an instruction to the element represented by this builder node, and + * return the node representing the element to which the instruction + * was added (not the new instruction node). + * + * @param target + * the target value for the instruction. + * @param data + * the data value for the instruction + * + * @return + * the builder node representing the element to which the instruction was + * added. + */ + public abstract BaseXMLBuilder instruction(String target, String data); + + /** + * Synonym for {@link #instruction(String, String)}. + * + * @param target + * the target value for the instruction. + * @param data + * the data value for the instruction + * + * @return + * the builder node representing the element to which the instruction was + * added. + */ + public abstract BaseXMLBuilder inst(String target, String data); + + /** + * Synonym for {@link #instruction(String, String)}. + * + * @param target + * the target value for the instruction. + * @param data + * the data value for the instruction + * + * @return + * the builder node representing the element to which the instruction was + * added. + */ + public abstract BaseXMLBuilder i(String target, String data); + + /** + * Insert an instruction before the element represented by this builder node, + * and return the node representing that same element + * (not the new instruction node). + * + * @param target + * the target value for the instruction. + * @param data + * the data value for the instruction + * + * @return + * the builder node representing the element before which the instruction was inserted. + */ + public abstract BaseXMLBuilder insertInstruction(String target, String data); + + /** + * Add a reference to the element represented by this builder node, and + * return the node representing the element to which the reference + * was added (not the new reference node). + * + * @param name + * the name value for the reference. + * + * @return + * the builder node representing the element to which the reference was + * added. + */ + public abstract BaseXMLBuilder reference(String name); + + /** + * Synonym for {@link #reference(String)}. + * + * @param name + * the name value for the reference. + * + * @return + * the builder node representing the element to which the reference was + * added. + */ + public abstract BaseXMLBuilder ref(String name); + + /** + * Synonym for {@link #reference(String)}. + * + * @param name + * the name value for the reference. + * + * @return + * the builder node representing the element to which the reference was + * added. + */ + public abstract BaseXMLBuilder r(String name); + + /** + * Add an XML namespace attribute to this builder's element node. + * + * @param prefix + * a prefix for the namespace URI within the document, may be null + * or empty in which case a default "xmlns" attribute is created. + * @param namespaceURI + * a namespace uri + * + * @return + * the builder node representing the element to which the attribute was added. + */ + public abstract BaseXMLBuilder namespace(String prefix, String namespaceURI); + + /** + * Synonym for {@link #namespace(String, String)}. + * + * @param prefix + * a prefix for the namespace URI within the document, may be null + * or empty in which case a default xmlns attribute is created. + * @param namespaceURI + * a namespace uri + * + * @return + * the builder node representing the element to which the attribute was added. + */ + public abstract BaseXMLBuilder ns(String prefix, String namespaceURI); + + /** + * Add an XML namespace attribute to this builder's element node + * without a prefix. + * + * @param namespaceURI + * a namespace uri + * + * @return + * the builder node representing the element to which the attribute was added. + */ + public abstract BaseXMLBuilder namespace(String namespaceURI); + + /** + * Synonym for {@link #namespace(String)}. + * + * @param namespaceURI + * a namespace uri + * + * @return + * the builder node representing the element to which the attribute was added. + */ + public abstract BaseXMLBuilder ns(String namespaceURI); + + + /** + * Return the builder node representing the nth ancestor element + * of this node, or the root node if n exceeds the document's depth. + * + * @param steps + * the number of parent elements to step over while navigating up the chain + * of node ancestors. A steps value of 1 will find a node's parent, 2 will + * find its grandparent etc. + * + * @return + * the nth ancestor of this node, or the root node if this is + * reached before the nth parent is found. + */ + public abstract BaseXMLBuilder up(int steps); + + /** + * Return the builder node representing the parent of the current node. + * + * @return + * the parent of this node, or the root node if this method is called on the + * root node. + */ + public abstract BaseXMLBuilder up(); + + /** + * BEWARE: The builder returned by this method represents a Document node, not + * an Element node as is usually the case, so attempts to use the attribute or + * namespace methods on this builder will likely fail. + * + * @return + * the builder node representing the root XML document. + */ + public abstract BaseXMLBuilder document(); + + /** + * Return the Document node representing the nth ancestor element + * of this node, or the root node if n exceeds the document's depth. + * + * @param steps + * the number of parent elements to step over while navigating up the chain + * of node ancestors. A steps value of 1 will find a node's parent, 2 will + * find its grandparent etc. + * + * @return + * the nth ancestor of this node, or the root node if this is + * reached before the nth parent is found. + */ + protected Node upImpl(int steps) { + Node currNode = this.xmlNode; + int stepCount = 0; + while (currNode.getParentNode() != null && stepCount < steps) { + currNode = currNode.getParentNode(); + stepCount++; + } + return currNode; + } + + /** + * @param anXmlElement xyz + * + * @throws IllegalStateException + * if the current element contains any child text nodes that aren't pure whitespace. + * We allow whitespace so parsed XML documents containing indenting or pretty-printing + * can still be amended, per issue #17. + */ + protected void assertElementContainsNoOrWhitespaceOnlyTextNodes(Node anXmlElement) + { + Node textNodeWithNonWhitespace = null; + NodeList childNodes = anXmlElement.getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + if (Element.TEXT_NODE == childNodes.item(i).getNodeType()) { + Node textNode = childNodes.item(i); + String textWithoutWhitespace = + textNode.getTextContent().replaceAll("\\s", ""); + if (textWithoutWhitespace.length() > 0) { + textNodeWithNonWhitespace = textNode; + break; + } + } + } + if (textNodeWithNonWhitespace != null) { + throw new IllegalStateException( + "Cannot add sub-element to element <" + anXmlElement.getNodeName() + + "> that contains a Text node that isn't purely whitespace: " + + textNodeWithNonWhitespace); + } + } + + /** + * Serialize either the specific Element wrapped by this BaseXMLBuilder, + * or its entire XML document, to the given writer using the default + * {@link TransformerFactory} and {@link Transformer} classes. + * If output options are provided, these options are provided to the + * {@link Transformer} serializer. + * + * @param wholeDocument + * if true the whole XML document (i.e. the document root) is serialized, + * if false just the current Element and its descendants are serialized. + * @param writer + * a writer to which the serialized document is written. + * @param outputProperties + * settings for the {@link Transformer} serializer. This parameter may be + * null or an empty Properties object, in which case the default output + * properties will be applied. + * + * @throws TransformerException xyz + */ + public void toWriter(boolean wholeDocument, Writer writer, Properties outputProperties) + throws TransformerException { + StreamResult streamResult = new StreamResult(writer); + + DOMSource domSource = null; + if (wholeDocument) { + domSource = new DOMSource(getDocument()); + } else { + domSource = new DOMSource(getElement()); + } + + TransformerFactory tf = TransformerFactory.newInstance(); + Transformer serializer = tf.newTransformer(); + + if (outputProperties != null) { + for (Entry entry: outputProperties.entrySet()) { + serializer.setOutputProperty( + (String) entry.getKey(), + (String) entry.getValue()); + } + } + serializer.transform(domSource, streamResult); + } + + /** + * Serialize the XML document to the given writer using the default + * {@link TransformerFactory} and {@link Transformer} classes. If output + * options are provided, these options are provided to the + * {@link Transformer} serializer. + * + * @param writer + * a writer to which the serialized document is written. + * @param outputProperties + * settings for the {@link Transformer} serializer. This parameter may be + * null or an empty Properties object, in which case the default output + * properties will be applied. + * + * @throws TransformerException xyz + */ + public void toWriter(Writer writer, Properties outputProperties) + throws TransformerException { + this.toWriter(true, writer, outputProperties); + } + + /** + * Serialize the XML document to a string by delegating to the + * {@link #toWriter(Writer, Properties)} method. If output options are + * provided, these options are provided to the {@link Transformer} + * serializer. + * + * @param outputProperties + * settings for the {@link Transformer} serializer. This parameter may be + * null or an empty Properties object, in which case the default output + * properties will be applied. + * + * @return + * the XML document as a string + * + * @throws TransformerException xyz + */ + public String asString(Properties outputProperties) throws TransformerException { + StringWriter writer = new StringWriter(); + toWriter(writer, outputProperties); + return writer.toString(); + } + + /** + * Serialize the current XML Element and its descendants to a string by + * delegating to the {@link #toWriter(Writer, Properties)} method. + * If output options are provided, these options are provided to the + * {@link Transformer} serializer. + * + * @param outputProperties + * settings for the {@link Transformer} serializer. This parameter may be + * null or an empty Properties object, in which case the default output + * properties will be applied. + * + * @return + * the XML document as a string + * + * @throws TransformerException xyz + */ + public String elementAsString(Properties outputProperties) throws TransformerException { + StringWriter writer = new StringWriter(); + toWriter(false, writer, outputProperties); + return writer.toString(); + } + + /** + * Serialize the XML document to a string excluding the XML declaration. + * + * @return + * the XML document as a string without the XML declaration at the + * beginning of the output. + * + * @throws TransformerException xyz + */ + public String asString() throws TransformerException { + Properties outputProperties = new Properties(); + outputProperties.put(javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes"); + return asString(outputProperties); + } + + /** + * Serialize the current XML Element and its descendants to a string + * excluding the XML declaration. + * + * @return + * the XML document as a string without the XML declaration at the + * beginning of the output. + * + * @throws TransformerException xyz + */ + public String elementAsString() throws TransformerException { + Properties outputProperties = new Properties(); + outputProperties.put(javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes"); + return elementAsString(outputProperties); + } + + /** + * @return + * a namespace context containing the prefixes and namespace URI's used + * within this builder's document, to assist in running namespace-aware + * XPath queries against the document. + */ + protected NamespaceContextImpl buildDocumentNamespaceContext() { + return new NamespaceContextImpl(xmlDocument.getDocumentElement()); + } + + protected String getPrefixFromQualifiedName(String qualifiedName) { + int colonPos = qualifiedName.indexOf(':'); + if (colonPos > 0) { + return qualifiedName.substring(0, colonPos); + } else { + return null; + } + } + +} diff --git a/src/main/java/com/jamesmurty/utils/NamespaceContextImpl.java b/src/main/java/com/jamesmurty/utils/NamespaceContextImpl.java index e64e7fc..9b20dcb 100644 --- a/src/main/java/com/jamesmurty/utils/NamespaceContextImpl.java +++ b/src/main/java/com/jamesmurty/utils/NamespaceContextImpl.java @@ -31,6 +31,9 @@ public NamespaceContextImpl() { /** * Create a namespace context that will lookup namespace * information in the given element. + * + * @param element + * Element in which to look up namespace information. */ public NamespaceContextImpl(Element element) { this.element = element; @@ -77,8 +80,8 @@ public String getPrefix(String namespaceURI) { return null; } - // No implemented - @SuppressWarnings("unchecked") + // Not implemented + @SuppressWarnings({ "rawtypes" }) public Iterator getPrefixes(String namespaceURI) { return Collections.EMPTY_LIST.iterator(); } diff --git a/src/main/java/com/jamesmurty/utils/XMLBuilder.java b/src/main/java/com/jamesmurty/utils/XMLBuilder.java index 6e255bd..144293f 100644 --- a/src/main/java/com/jamesmurty/utils/XMLBuilder.java +++ b/src/main/java/com/jamesmurty/utils/XMLBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2014 James Murty (www.jamesmurty.com) + * Copyright 2008-2020 James Murty (github.com/jmurty) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,8 +14,8 @@ * limitations under the License. * * - * This code is available from the Google Code repository at: - * http://code.google.com/p/java-xmlbuilder + * This code is available from the GitHub code repository at: + * https://github.com/jmurty/java-xmlbuilder */ package com.jamesmurty.utils; @@ -23,36 +23,17 @@ import java.io.FileReader; import java.io.IOException; import java.io.StringReader; -import java.io.StringWriter; -import java.io.Writer; -import java.util.Iterator; -import java.util.Properties; -import java.util.Map.Entry; import javax.xml.namespace.NamespaceContext; -import javax.xml.namespace.QName; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.FactoryConfigurationError; import javax.xml.parsers.ParserConfigurationException; -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 net.iharder.Base64; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import org.w3c.dom.Text; import org.xml.sax.InputSource; import org.xml.sax.SAXException; @@ -79,23 +60,7 @@ * * @author James Murty */ -public class XMLBuilder { - /** - * A DOM Document that stores the underlying XML document operated on by - * XMLBuilder instances. This document object belongs to the root node - * of a document, and is shared by this node with all other XMLBuilder - * instances via the {@link #getDocument()} method. - * This instance variable must only be created once, by the root node for - * any given document. - */ - private Document xmlDocument = null; - - /** - * The underlying node represented by this builder node. - */ - private Node xmlNode = null; - - private static boolean isNamespaceAware = true; // TODO: Make this configurable? +public final class XMLBuilder extends BaseXMLBuilder { /** * Construct a new builder object that wraps the given XML document. @@ -105,8 +70,7 @@ public class XMLBuilder { * an XML document that the builder will manage and manipulate. */ protected XMLBuilder(Document xmlDocument) { - this.xmlDocument = xmlDocument; - this.xmlNode = xmlDocument.getDocumentElement(); + super(xmlDocument); } /** @@ -122,15 +86,7 @@ protected XMLBuilder(Document xmlDocument) { * parentNode node. */ protected XMLBuilder(Node myNode, Node parentNode) { - this.xmlNode = myNode; - if (myNode instanceof Document) { - this.xmlDocument = (Document) myNode; - } else { - this.xmlDocument = myNode.getOwnerDocument(); - } - if (parentNode != null) { - parentNode.appendChild(myNode); - } + super(myNode, parentNode); } /** @@ -143,28 +99,72 @@ protected XMLBuilder(Node myNode, Node parentNode) { * the name of the document's root element. * @param namespaceURI * default namespace URI for document, ignored if null or empty. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} + * @return + * a builder node that can be used to add more nodes to the XML document. + * + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz + */ + public static XMLBuilder create(String name, String namespaceURI, + boolean enableExternalEntities, boolean isNamespaceAware) + throws ParserConfigurationException, FactoryConfigurationError + { + return new XMLBuilder( + createDocumentImpl( + name, namespaceURI, enableExternalEntities, isNamespaceAware)); + } + + /** + * Construct a builder for new XML document. The document will be created + * with the given root element, and the builder returned by this method + * will serve as the starting-point for any further document additions. + * + * @param name + * the name of the document's root element. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} * @return * a builder node that can be used to add more nodes to the XML document. * - * @throws FactoryConfigurationError - * @throws ParserConfigurationException + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz + */ + public static XMLBuilder create(String name, boolean enableExternalEntities, + boolean isNamespaceAware) + throws ParserConfigurationException, FactoryConfigurationError + { + return create(name, null, enableExternalEntities, isNamespaceAware); + } + + /** + * Construct a builder for new XML document with a default namespace. + * The document will be created with the given root element, and the builder + * returned by this method will serve as the starting-point for any further + * document additions. + * + * @param name + * the name of the document's root element. + * @param namespaceURI + * default namespace URI for document, ignored if null or empty. + + * @return + * a builder node that can be used to add more nodes to the XML document. + * + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz */ public static XMLBuilder create(String name, String namespaceURI) throws ParserConfigurationException, FactoryConfigurationError { - // Init DOM builder and Document. - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(isNamespaceAware); - DocumentBuilder builder = factory.newDocumentBuilder(); - Document document = builder.newDocument(); - Element rootElement = null; - if (namespaceURI != null && namespaceURI.length() > 0) { - rootElement = document.createElementNS(namespaceURI, name); - } else { - rootElement = document.createElement(name); - } - document.appendChild(rootElement); - return new XMLBuilder(document); + return create(name, namespaceURI, false, true); } /** @@ -177,8 +177,8 @@ public static XMLBuilder create(String name, String namespaceURI) * @return * a builder node that can be used to add more nodes to the XML document. * - * @throws FactoryConfigurationError - * @throws ParserConfigurationException + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz */ public static XMLBuilder create(String name) throws ParserConfigurationException, FactoryConfigurationError @@ -193,23 +193,28 @@ public static XMLBuilder create(String name) * * @param inputSource * an XML document input source that will be parsed into a DOM. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} * @return * a builder node that can be used to add more nodes to the XML document. - * @throws ParserConfigurationException * - * @throws FactoryConfigurationError - * @throws ParserConfigurationException - * @throws IOException - * @throws SAXException + * @throws ParserConfigurationException xyz + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz + * @throws IOException xyz + * @throws SAXException xyz */ - public static XMLBuilder parse(InputSource inputSource) - throws ParserConfigurationException, SAXException, IOException + public static XMLBuilder parse( + InputSource inputSource, boolean enableExternalEntities, + boolean isNamespaceAware) + throws ParserConfigurationException, SAXException, IOException { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(isNamespaceAware); - DocumentBuilder builder = factory.newDocumentBuilder(); - Document document = builder.parse(inputSource); - return new XMLBuilder(document); + return new XMLBuilder( + parseDocumentImpl( + inputSource, enableExternalEntities, isNamespaceAware)); } /** @@ -219,19 +224,29 @@ public static XMLBuilder parse(InputSource inputSource) * * @param xmlString * an XML document string that will be parsed into a DOM. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} * @return * a builder node that can be used to add more nodes to the XML document. * - * @throws ParserConfigurationException - * @throws FactoryConfigurationError - * @throws ParserConfigurationException - * @throws IOException - * @throws SAXException + * @throws ParserConfigurationException xyz + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz + * @throws IOException xyz + * @throws SAXException xyz */ - public static XMLBuilder parse(String xmlString) + public static XMLBuilder parse( + String xmlString, boolean enableExternalEntities, + boolean isNamespaceAware) throws ParserConfigurationException, SAXException, IOException { - return XMLBuilder.parse(new InputSource(new StringReader(xmlString))); + return XMLBuilder.parse( + new InputSource(new StringReader(xmlString)), + enableExternalEntities, + isNamespaceAware); } /** @@ -241,838 +256,306 @@ public static XMLBuilder parse(String xmlString) * * @param xmlFile * an XML document file that will be parsed into a DOM. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} * @return * a builder node that can be used to add more nodes to the XML document. * - * @throws ParserConfigurationException - * @throws FactoryConfigurationError - * @throws ParserConfigurationException - * @throws IOException - * @throws SAXException + * @throws ParserConfigurationException xyz + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz + * @throws IOException xyz + * @throws SAXException xyz */ - public static XMLBuilder parse(File xmlFile) + public static XMLBuilder parse(File xmlFile, boolean enableExternalEntities, + boolean isNamespaceAware) throws ParserConfigurationException, SAXException, IOException { - return XMLBuilder.parse(new InputSource(new FileReader(xmlFile))); + return XMLBuilder.parse( + new InputSource(new FileReader(xmlFile)), + enableExternalEntities, + isNamespaceAware); } /** - * Find and delete from the underlying Document any text nodes that - * contain nothing but whitespace, such as newlines and tab or space - * characters used to indent or pretty-print an XML document. - * - * Uses approach I documented on StackOverflow: - * http://stackoverflow.com/a/979606/4970 + * Construct a builder from an existing XML document. The provided XML + * document will be parsed and an XMLBuilder object referencing the + * document's root element will be returned. * + * @param inputSource + * an XML document input source that will be parsed into a DOM. * @return - * a builder node at the same location as before the operation. - * @throws XPathExpressionException + * a builder node that can be used to add more nodes to the XML document. + * + * @throws ParserConfigurationException xyz + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz + * @throws IOException xyz + * @throws SAXException xyz */ - public XMLBuilder stripWhitespaceOnlyTextNodes() - throws XPathExpressionException + public static XMLBuilder parse(InputSource inputSource) + throws ParserConfigurationException, SAXException, IOException { - XPathFactory xpathFactory = XPathFactory.newInstance(); - // XPath to find empty text nodes. - XPathExpression xpathExp = xpathFactory.newXPath().compile( - "//text()[normalize-space(.) = '']"); - NodeList emptyTextNodes = (NodeList) xpathExp.evaluate( - this.getDocument(), XPathConstants.NODESET); - - // Remove each empty text node from document. - for (int i = 0; i < emptyTextNodes.getLength(); i++) { - Node emptyTextNode = emptyTextNodes.item(i); - emptyTextNode.getParentNode().removeChild(emptyTextNode); - } - return this; + return XMLBuilder.parse(inputSource, false, true); } /** - * Imports another XMLBuilder document into this document at the - * current position. The entire document provided is imported. - * - * @param builder - * the XMLBuilder document to be imported. + * Construct a builder from an existing XML document string. + * The provided XML document will be parsed and an XMLBuilder + * object referencing the document's root element will be returned. * + * @param xmlString + * an XML document string that will be parsed into a DOM. * @return - * a builder node at the same location as before the import, but - * now containing the entire document tree provided. - */ - public XMLBuilder importXMLBuilder(XMLBuilder builder) { - assertElementContainsNoOrWhitespaceOnlyTextNodes(this.xmlNode); - Node importedNode = getDocument().importNode( - builder.root().getElement(), true); - this.xmlNode.appendChild(importedNode); - return this; - } - - /** - * @return - * true if the XML Document and Element objects wrapped by this - * builder are equal to the other's wrapped objects. - */ - @Override - public boolean equals(Object obj) { - if (obj != null && obj instanceof XMLBuilder) { - XMLBuilder other = (XMLBuilder) obj; - return - this.xmlDocument.equals(other.getDocument()) - && this.xmlNode.equals(other.getElement()); - } - return false; - } - - /** - * @return - * the XML element wrapped by this builder node, or null if the builder node wraps the - * root Document node. - */ - public Element getElement() { - if (this.xmlNode instanceof Element) { - return (Element) this.xmlNode; - } else { - return null; - } - } - - /** - * BEWARE: The builder returned by this method respresents a Document node, not - * an Element node as is usually the case, so attempts to use the attribute or - * namespace methods on this builder will likely fail. + * a builder node that can be used to add more nodes to the XML document. * - * @return - * the builder node representing the root XML document. + * @throws ParserConfigurationException xyz + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz + * @throws IOException xyz + * @throws SAXException xyz */ - public XMLBuilder document() { - return new XMLBuilder(getDocument(), null); - } - - /** - * @return - * the builder node representing the root element of the XML document. - * In other words, the same builder node returned by the initial - * {@link #create(String)} or {@link #parse(InputSource)} method. - */ - public XMLBuilder root() { - return new XMLBuilder(getDocument()); - } - - /** - * @return - * the XML document constructed by all builder nodes. - */ - public Document getDocument() { - return this.xmlDocument; + public static XMLBuilder parse(String xmlString) + throws ParserConfigurationException, SAXException, IOException + { + return XMLBuilder.parse(xmlString, false, true); } /** - * Return the result of evaluating an XPath query on the builder's DOM - * using the given namespace. Returns null if the query finds nothing, - * or finds a node that does not match the type specified by returnType. - * - * @param xpath - * an XPath expression - * @param type - * the type the XPath is expected to resolve to, e.g: - * {@link XPathConstants#NODE}, {@link XPathConstants#NODESET}, - * {@link XPathConstants#STRING}. - * @param nsContext - * a mapping of prefixes to namespace URIs that allows the XPath expression - * to use namespaces, or null for a non-namespaced document. + * Construct a builder from an existing XML document file. + * The provided XML document will be parsed and an XMLBuilder + * object referencing the document's root element will be returned. * + * @param xmlFile + * an XML document file that will be parsed into a DOM. * @return - * a builder node representing the first Element that matches the - * XPath expression. + * a builder node that can be used to add more nodes to the XML document. * - * @throws XPathExpressionException - * If the XPath is invalid, or if does not resolve to at least one - * {@link Node#ELEMENT_NODE}. + * @throws ParserConfigurationException xyz + * @throws FactoryConfigurationError xyz + * @throws ParserConfigurationException xyz + * @throws IOException xyz + * @throws SAXException xyz */ - public Object xpathQuery(String xpath, QName type, NamespaceContext nsContext) - throws XPathExpressionException + public static XMLBuilder parse(File xmlFile) + throws ParserConfigurationException, SAXException, IOException { - XPathFactory xpathFactory = XPathFactory.newInstance(); - XPath xPath = xpathFactory.newXPath(); - if (nsContext != null) { - xPath.setNamespaceContext(nsContext); - } - XPathExpression xpathExp = xPath.compile(xpath); - try { - return xpathExp.evaluate(this.xmlNode, type); - } catch (IllegalArgumentException e) { - // Thrown if item found does not match expected type - return null; - } + return XMLBuilder.parse(xmlFile, false, true); } - /** - * Return the result of evaluating an XPath query on the builder's DOM. - * Returns null if the query finds nothing, - * or finds a node that does not match the type specified by returnType. - * - * @param xpath - * an XPath expression - * @param type - * the type the XPath is expected to resolve to, e.g: - * {@link XPathConstants#NODE}, {@link XPathConstants#NODESET}, - * {@link XPathConstants#STRING} - * - * @return - * a builder node representing the first Element that matches the - * XPath expression. - * - * @throws XPathExpressionException - * If the XPath is invalid, or if does not resolve to at least one - * {@link Node#ELEMENT_NODE}. - */ - public Object xpathQuery(String xpath, QName type) + @Override + public XMLBuilder stripWhitespaceOnlyTextNodes() throws XPathExpressionException { - return xpathQuery(xpath, type, null); + super.stripWhitespaceOnlyTextNodesImpl(); + return this; } - /** - * Find the first element in the builder's DOM matching the given - * XPath expression, where the expression may include namespaces if - * a {@link NamespaceContext} is provided. - * - * @param xpath - * An XPath expression that *must* resolve to an existing Element within - * the document object model. - * @param nsContext - * a mapping of prefixes to namespace URIs that allows the XPath expression - * to use namespaces. - * - * @return - * a builder node representing the first Element that matches the - * XPath expression. - * - * @throws XPathExpressionException - * If the XPath is invalid, or if does not resolve to at least one - * {@link Node#ELEMENT_NODE}. - */ + @Override + public XMLBuilder importXMLBuilder(BaseXMLBuilder builder) { + super.importXMLBuilderImpl(builder); + return this; + } + + @Override + public XMLBuilder root() { + return new XMLBuilder(getDocument()); + } + + @Override public XMLBuilder xpathFind(String xpath, NamespaceContext nsContext) throws XPathExpressionException { - Node foundNode = (Node) this.xpathQuery(xpath, XPathConstants.NODE, nsContext); - if (foundNode == null || foundNode.getNodeType() != Node.ELEMENT_NODE) { - throw new XPathExpressionException("XPath expression \"" - + xpath + "\" does not resolve to an Element in context " - + this.xmlNode + ": " + foundNode); - } - return new XMLBuilder(foundNode, null); + Node foundNode = super.xpathFindImpl(xpath, nsContext); + return new XMLBuilder(foundNode, null); } - /** - * Find the first element in the builder's DOM matching the given - * XPath expression. - * - * @param xpath - * An XPath expression that *must* resolve to an existing Element within - * the document object model. - * - * @return - * a builder node representing the first Element that matches the - * XPath expression. - * - * @throws XPathExpressionException - * If the XPath is invalid, or if does not resolve to at least one - * {@link Node#ELEMENT_NODE}. - */ + @Override public XMLBuilder xpathFind(String xpath) throws XPathExpressionException { return xpathFind(xpath, null); } - /** - * Add a named XML element to the document as a child of this builder node, - * and return the builder node representing the new child. - * - * When adding an element to a namespaced document, the new node will be - * assigned a namespace matching it's qualified name prefix (if any) or - * the document's default namespace. NOTE: If the element has a prefix that - * does not match any known namespaces, the element will be created - * without any namespace. - * - * @param name - * the name of the XML element. - * - * @return - * a builder node representing the new child. - * - * @throws IllegalStateException - * if you attempt to add a child element to an XML node that already - * contains a text node value. - */ + @Override public XMLBuilder element(String name) { - String prefix = getPrefixFromQualifiedName(name); - String namespaceURI = this.xmlNode.lookupNamespaceURI(prefix); + String namespaceURI = super.lookupNamespaceURIImpl(name); return element(name, namespaceURI); } - /** - * Synonym for {@link #element(String)}. - * - * @param name - * the name of the XML element. - * - * @return - * a builder node representing the new child. - * - * @throws IllegalStateException - * if you attempt to add a child element to an XML node that already - * contains a text node value. - */ + @Override public XMLBuilder elem(String name) { return element(name); } - /** - * Synonym for {@link #element(String)}. - * - * @param name - * the name of the XML element. - * - * @return - * a builder node representing the new child. - * - * @throws IllegalStateException - * if you attempt to add a child element to an XML node that already - * contains a text node value. - */ + @Override public XMLBuilder e(String name) { return element(name); } - /** - * Add a named and namespaced XML element to the document as a child of - * this builder node, and return the builder node representing the new child. - * - * @param name - * the name of the XML element. - * @param namespaceURI - * a namespace URI - * - * @return - * a builder node representing the new child. - * - * @throws IllegalStateException - * if you attempt to add a child element to an XML node that already - * contains a text node value. - */ + @Override public XMLBuilder element(String name, String namespaceURI) { - assertElementContainsNoOrWhitespaceOnlyTextNodes(this.xmlNode); - return new XMLBuilder( - (namespaceURI == null - ? getDocument().createElement(name) - : getDocument().createElementNS(namespaceURI, name)), - this.xmlNode); + Element elem = super.elementImpl(name, namespaceURI); + return new XMLBuilder(elem, this.getElement()); } - /** - * Add a named XML element to the document as a sibling element - * that precedes the position of this builder node, and return the builder node - * representing the new child. - * - * When adding an element to a namespaced document, the new node will be - * assigned a namespace matching it's qualified name prefix (if any) or - * the document's default namespace. NOTE: If the element has a prefix that - * does not match any known namespaces, the element will be created - * without any namespace. - * - * @param name - * the name of the XML element. - * - * @return - * a builder node representing the new child. - * - * @throws IllegalStateException - * if you attempt to add a sibling element to a node where there are already - * one or more siblings that are text nodes. - */ + @Override public XMLBuilder elementBefore(String name) { - String prefix = getPrefixFromQualifiedName(name); - String namespaceURI = this.xmlNode.lookupNamespaceURI(prefix); - return elementBefore(name, namespaceURI); + Element newElement = super.elementBeforeImpl(name); + return new XMLBuilder(newElement, null); } - /** - * Add a named and namespaced XML element to the document as a sibling element - * that precedes the position of this builder node, and return the builder node - * representing the new child. - * - * @param name - * the name of the XML element. - * @param namespaceURI - * a namespace URI - * - * @return - * a builder node representing the new child. - * - * @throws IllegalStateException - * if you attempt to add a sibling element to a node where there are already - * one or more siblings that are text nodes. - */ + @Override public XMLBuilder elementBefore(String name, String namespaceURI) { - Node parentNode = this.xmlNode.getParentNode(); - assertElementContainsNoOrWhitespaceOnlyTextNodes(parentNode); - - Element newElement = (namespaceURI == null - ? getDocument().createElement(name) - : getDocument().createElementNS(namespaceURI, name)); - - // Insert new element before the current element - parentNode.insertBefore(newElement, this.xmlNode); - // Return a new builder node pointing at the new element + Element newElement = super.elementBeforeImpl(name, namespaceURI); return new XMLBuilder(newElement, null); } - /** - * Add a named attribute value to the element represented by this builder - * node, and return the node representing the element to which the - * attribute was added (not the new attribute node). - * - * @param name - * the attribute's name. - * @param value - * the attribute's value. - * - * @return - * the builder node representing the element to which the attribute was - * added. - */ + @Override public XMLBuilder attribute(String name, String value) { - if (! (this.xmlNode instanceof Element)) { - throw new RuntimeException( - "Cannot add an attribute to non-Element underlying node: " - + this.xmlNode); - } - ((Element) xmlNode).setAttribute(name, value); + super.attributeImpl(name, value); return this; } - /** - * Synonym for {@link #attribute(String, String)}. - * - * @param name - * the attribute's name. - * @param value - * the attribute's value. - * - * @return - * the builder node representing the element to which the attribute was - * added. - */ + @Override public XMLBuilder attr(String name, String value) { return attribute(name, value); } - /** - * Synonym for {@link #attribute(String, String)}. - * - * @param name - * the attribute's name. - * @param value - * the attribute's value. - * - * @return - * the builder node representing the element to which the attribute was - * added. - */ + @Override public XMLBuilder a(String name, String value) { return attribute(name, value); } - /** - * Add or replace the text value of an element represented by this builder - * node, and return the node representing the element to which the text - * was added (not the new text node). - * - * @param value - * the text value to set or add to the element. - * @param replaceText - * if True any existing text content of the node is replaced with the - * given text value, if the given value is appended to any existing text. - * - * @return - * the builder node representing the element to which the text was added. - */ - public XMLBuilder text(String value, boolean replaceText) { - // Issue 10: null text values cause exceptions on subsequent call to - // Transformer to render document, so we fail-fast here on bad data. - if (value == null) { - throw new IllegalArgumentException("Illegal null text value"); - } - if (replaceText) { - xmlNode.setTextContent(value); - } else { - xmlNode.appendChild(getDocument().createTextNode(value)); - } + @Override + public XMLBuilder text(String value, boolean replaceText) { + super.textImpl(value, replaceText); return this; } - /** - * Add a text value to the element represented by this builder node, and - * return the node representing the element to which the text - * was added (not the new text node). - * - * @param value - * the text value to add to the element. - * - * @return - * the builder node representing the element to which the text was added. - */ + @Override public XMLBuilder text(String value) { return this.text(value, false); } - /** - * Synonym for {@link #text(String)}. - * - * @param value - * the text value to add to the element. - * - * @return - * the builder node representing the element to which the text was added. - */ + @Override public XMLBuilder t(String value) { return text(value); } - /** - * Add a CDATA node with String content to the element represented by this - * builder node, and return the node representing the element to which the - * data was added (not the new CDATA node). - * - * @param data - * the String value that will be added to a CDATA element. - * - * @return - * the builder node representing the element to which the data was added. - */ + @Override public XMLBuilder cdata(String data) { - xmlNode.appendChild( - getDocument().createCDATASection(data)); + super.cdataImpl(data); return this; } - /** - * Synonym for {@link #cdata(String)}. - * - * @param data - * the String value that will be added to a CDATA element. - * - * @return - * the builder node representing the element to which the data was added. - */ + @Override public XMLBuilder data(String data) { return cdata(data); } - /** - * Synonym for {@link #cdata(String)}. - * - * @param data - * the String value that will be added to a CDATA element. - * - * @return - * the builder node representing the element to which the data was added. - */ + @Override public XMLBuilder d(String data) { return cdata(data); } - /** - * Add a CDATA node with Base64-encoded byte data content to the element represented - * by this builder node, and return the node representing the element to which the - * data was added (not the new CDATA node). - * - * @param data - * the data value that will be Base64-encoded and added to a CDATA element. - * - * @return - * the builder node representing the element to which the data was added. - */ + @Override public XMLBuilder cdata(byte[] data) { - xmlNode.appendChild( - getDocument().createCDATASection( - Base64.encodeBytes(data))); + super.cdataImpl(data); return this; } - /** - * Synonym for {@link #cdata(byte[])}. - * - * @param data - * the data value that will be Base64-encoded and added to a CDATA element. - * - * @return - * the builder node representing the element to which the data was added. - */ + @Override public XMLBuilder data(byte[] data) { return cdata(data); } - /** - * Synonym for {@link #cdata(byte[])}. - * - * @param data - * the data value that will be Base64-encoded and added to a CDATA element. - * - * @return - * the builder node representing the element to which the data was added. - */ + @Override public XMLBuilder d(byte[] data) { return cdata(data); } - /** - * Add a comment to the element represented by this builder node, and - * return the node representing the element to which the comment - * was added (not the new comment node). - * - * @param comment - * the comment to add to the element. - * - * @return - * the builder node representing the element to which the comment was added. - */ + @Override public XMLBuilder comment(String comment) { - xmlNode.appendChild(getDocument().createComment(comment)); + super.commentImpl(comment); return this; } - /** - * Synonym for {@link #comment(String)}. - * - * @param comment - * the comment to add to the element. - * - * @return - * the builder node representing the element to which the comment was added. - */ + @Override public XMLBuilder cmnt(String comment) { return comment(comment); } - /** - * Synonym for {@link #comment(String)}. - * - * @param comment - * the comment to add to the element. - * - * @return - * the builder node representing the element to which the comment was added. - */ + @Override public XMLBuilder c(String comment) { return comment(comment); } - /** - * Add an instruction to the element represented by this builder node, and - * return the node representing the element to which the instruction - * was added (not the new instruction node). - * - * @param target - * the target value for the instruction. - * @param data - * the data value for the instruction - * - * @return - * the builder node representing the element to which the instruction was - * added. - */ + @Override public XMLBuilder instruction(String target, String data) { - xmlNode.appendChild(getDocument().createProcessingInstruction(target, data)); + super.instructionImpl(target, data); return this; } - /** - * Synonym for {@link #instruction(String, String)}. - * - * @param target - * the target value for the instruction. - * @param data - * the data value for the instruction - * - * @return - * the builder node representing the element to which the instruction was - * added. - */ + @Override public XMLBuilder inst(String target, String data) { return instruction(target, data); } - /** - * Synonym for {@link #instruction(String, String)}. - * - * @param target - * the target value for the instruction. - * @param data - * the data value for the instruction - * - * @return - * the builder node representing the element to which the instruction was - * added. - */ + @Override public XMLBuilder i(String target, String data) { return instruction(target, data); } - /** - * Insert an instruction before the element represented by this builder node, and - * return the node representing that same element - * (not the new instruction node). - * - * @param target - * the target value for the instruction. - * @param data - * the data value for the instruction - * - * @return - * the builder node representing the element before which the instruction was inserted. - */ + @Override public XMLBuilder insertInstruction(String target, String data) { - getDocument().insertBefore(getDocument().createProcessingInstruction(target, data), xmlNode); + super.insertInstructionImpl(target, data); return this; } - /** - * Add a reference to the element represented by this builder node, and - * return the node representing the element to which the reference - * was added (not the new reference node). - * - * @param name - * the name value for the reference. - * - * @return - * the builder node representing the element to which the reference was - * added. - */ + @Override public XMLBuilder reference(String name) { - xmlNode.appendChild(getDocument().createEntityReference(name)); + super.referenceImpl(name); return this; } - /** - * Synonym for {@link #reference(String)}. - * - * @param name - * the name value for the reference. - * - * @return - * the builder node representing the element to which the reference was - * added. - */ + @Override public XMLBuilder ref(String name) { return reference(name); } - /** - * Synonym for {@link #reference(String)}. - * - * @param name - * the name value for the reference. - * - * @return - * the builder node representing the element to which the reference was - * added. - */ + @Override public XMLBuilder r(String name) { return reference(name); } - /** - * Add an XML namespace attribute to this builder's element node. - * - * @param prefix - * a prefix for the namespace URI within the document, may be null - * or empty in which case a default "xmlns" attribute is created. - * @param namespaceURI - * a namespace uri - * - * @return - * the builder node representing the element to which the attribute was added. - */ + @Override public XMLBuilder namespace(String prefix, String namespaceURI) { - if (! (this.xmlNode instanceof Element)) { - throw new RuntimeException( - "Cannot add an attribute to non-Element underlying node: " - + this.xmlNode); - } - if (prefix != null && prefix.length() > 0) { - ((Element) xmlNode).setAttributeNS("http://www.w3.org/2000/xmlns/", - "xmlns:" + prefix, namespaceURI); - } else { - ((Element) xmlNode).setAttributeNS("http://www.w3.org/2000/xmlns/", - "xmlns", namespaceURI); - } + super.namespaceImpl(prefix, namespaceURI); return this; } - /** - * Synonym for {@link #namespace(String, String)}. - * - * @param prefix - * a prefix for the namespace URI within the document, may be null - * or empty in which case a default xmlns attribute is created. - * @param namespaceURI - * a namespace uri - * - * @return - * the builder node representing the element to which the attribute was added. - */ + @Override public XMLBuilder ns(String prefix, String namespaceURI) { return attribute(prefix, namespaceURI); } - /** - * Add an XML namespace attribute to this builder's element node - * without a prefix. - * - * @param namespaceURI - * a namespace uri - * - * @return - * the builder node representing the element to which the attribute was added. - */ + @Override public XMLBuilder namespace(String namespaceURI) { this.namespace(null, namespaceURI); return this; } - /** - * Synonym for {@link #namespace(String)}. - * - * @param namespaceURI - * a namespace uri - * - * @return - * the builder node representing the element to which the attribute was added. - */ + @Override public XMLBuilder ns(String namespaceURI) { return namespace(namespaceURI); } - /** - * Return the builder node representing the nth ancestor element - * of this node, or the root node if n exceeds the document's depth. - * - * @param steps - * the number of parent elements to step over while navigating up the chain - * of node ancestors. A steps value of 1 will find a node's parent, 2 will - * find its grandparent etc. - * - * @return - * the nth ancestor of this node, or the root node if this is - * reached before the nth parent is found. - */ + @Override public XMLBuilder up(int steps) { - Node currNode = this.xmlNode; - int stepCount = 0; - while (currNode.getParentNode() != null && stepCount < steps) { - currNode = currNode.getParentNode(); - stepCount++; - } + Node currNode = super.upImpl(steps); if (currNode instanceof Document) { return new XMLBuilder((Document) currNode); } else { @@ -1080,206 +563,14 @@ public XMLBuilder up(int steps) { } } - /** - * Return the builder node representing the parent of the current node. - * - * @return - * the parent of this node, or the root node if this method is called on the - * root node. - */ + @Override public XMLBuilder up() { return up(1); } - /** - * @throws IllegalStateException - * if the current element contains any child text nodes that aren't pure whitespace. - * We allow whitespace so parsed XML documents containing indenting or pretty-printing - * can still be amended, per issue #17. - */ - protected void assertElementContainsNoOrWhitespaceOnlyTextNodes(Node anXmlElement) { - Node textNodeWithNonWhitespace = null; - NodeList childNodes = anXmlElement.getChildNodes(); - for (int i = 0; i < childNodes.getLength(); i++) { - if (Element.TEXT_NODE == childNodes.item(i).getNodeType()) { - Node textNode = childNodes.item(i); - String textWithoutWhitespace = - textNode.getTextContent().replaceAll("\\s", ""); - if (textWithoutWhitespace.length() > 0) { - textNodeWithNonWhitespace = textNode; - break; - } - } - } - if (textNodeWithNonWhitespace != null) { - throw new IllegalStateException( - "Cannot add sub-element to element <" + anXmlElement.getNodeName() - + "> that contains a Text node that isn't purely whitespace: " - + textNodeWithNonWhitespace); - } - } - - /** - * Serialize either the specific Element wrapped by this XMLBuilder, or its entire - * XML document, to the given writer using the default {@link TransformerFactory} - * and {@link Transformer} classes. - * If output options are provided, these options are provided to the - * {@link Transformer} serializer. - * - * @param wholeDocument - * if true the whole XML document (i.e. the document root) is serialized, - * if false just the current Element and its descendants are serialized. - * @param writer - * a writer to which the serialized document is written. - * @param outputProperties - * settings for the {@link Transformer} serializer. This parameter may be - * null or an empty Properties object, in which case the default output - * properties will be applied. - * - * @throws TransformerException - */ - public void toWriter(boolean wholeDocument, Writer writer, Properties outputProperties) - throws TransformerException - { - StreamResult streamResult = new StreamResult(writer); - - DOMSource domSource = null; - if (wholeDocument) { - domSource = new DOMSource(getDocument()); - } else { - domSource = new DOMSource(getElement()); - } - - TransformerFactory tf = TransformerFactory.newInstance(); - Transformer serializer = tf.newTransformer(); - - if (outputProperties != null) { - Iterator iter = outputProperties.entrySet().iterator(); - while (iter.hasNext()) { - Entry entry = (Entry) iter.next(); - serializer.setOutputProperty((String) entry.getKey(), (String) entry.getValue()); - } - } - serializer.transform(domSource, streamResult); - } - - /** - * Serialize the XML document to the given writer using the default - * {@link TransformerFactory} and {@link Transformer} classes. If output - * options are provided, these options are provided to the - * {@link Transformer} serializer. - * - * @param writer - * a writer to which the serialized document is written. - * @param outputProperties - * settings for the {@link Transformer} serializer. This parameter may be - * null or an empty Properties object, in which case the default output - * properties will be applied. - * - * @throws TransformerException - */ - public void toWriter(Writer writer, Properties outputProperties) - throws TransformerException - { - this.toWriter(true, writer, outputProperties); - } - - /** - * Serialize the XML document to a string by delegating to the - * {@link #toWriter(Writer, Properties)} method. If output options are - * provided, these options are provided to the {@link Transformer} - * serializer. - * - * @param outputProperties - * settings for the {@link Transformer} serializer. This parameter may be - * null or an empty Properties object, in which case the default output - * properties will be applied. - * - * @return - * the XML document as a string - * - * @throws TransformerException - */ - public String asString(Properties outputProperties) - throws TransformerException - { - StringWriter writer = new StringWriter(); - toWriter(writer, outputProperties); - return writer.toString(); - } - - /** - * Serialize the current XML Element and its descendants to a string by - * delegating to the {@link #toWriter(Writer, Properties)} method. - * If output options are provided, these options are provided to the - * {@link Transformer} serializer. - * - * @param outputProperties - * settings for the {@link Transformer} serializer. This parameter may be - * null or an empty Properties object, in which case the default output - * properties will be applied. - * - * @return - * the XML document as a string - * - * @throws TransformerException - */ - public String elementAsString(Properties outputProperties) - throws TransformerException - { - StringWriter writer = new StringWriter(); - toWriter(false, writer, outputProperties); - return writer.toString(); - } - - /** - * Serialize the XML document to a string excluding the XML declaration. - * - * @return - * the XML document as a string without the XML declaration at the - * beginning of the output. - * - * @throws TransformerException - */ - public String asString() throws TransformerException { - Properties outputProperties = new Properties(); - outputProperties.put(javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes"); - return asString(outputProperties); - } - - /** - * Serialize the current XML Element and its descendants to a string - * excluding the XML declaration. - * - * @return - * the XML document as a string without the XML declaration at the - * beginning of the output. - * - * @throws TransformerException - */ - public String elementAsString() throws TransformerException { - Properties outputProperties = new Properties(); - outputProperties.put(javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes"); - return elementAsString(outputProperties); - } - - /** - * @return - * a namespace context containing the prefixes and namespace URI's used - * within this builder's document, to assist in running namespace-aware - * XPath queries against the document. - */ - public NamespaceContextImpl buildDocumentNamespaceContext() { - return new NamespaceContextImpl(this.root().getElement()); - } - - protected String getPrefixFromQualifiedName(String qualifiedName) { - int colonPos = qualifiedName.indexOf(':'); - if (colonPos > 0) { - return qualifiedName.substring(0, colonPos); - } else { - return null; - } + @Override + public XMLBuilder document() { + return new XMLBuilder(getDocument(), null); } } diff --git a/src/main/java/com/jamesmurty/utils/XMLBuilder2.java b/src/main/java/com/jamesmurty/utils/XMLBuilder2.java new file mode 100644 index 0000000..6f0cda6 --- /dev/null +++ b/src/main/java/com/jamesmurty/utils/XMLBuilder2.java @@ -0,0 +1,703 @@ +/* + * Copyright 2008-2020 James Murty (github.com/jmurty) + * + * 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. + * + * + * This code is available from the GitHub code repository at: + * https://github.com/jmurty/java-xmlbuilder + */ +package com.jamesmurty.utils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.io.StringReader; +import java.io.Writer; +import java.util.Properties; + +import javax.xml.namespace.NamespaceContext; +import javax.xml.namespace.QName; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.TransformerException; +import javax.xml.xpath.XPathExpressionException; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +/** + * XML Builder is a utility that creates simple XML documents using relatively + * sparse Java code. It is intended to allow for quick and painless creation of + * XML documents where you might otherwise be tempted to use concatenated + * strings, rather than face the tedium and verbosity of coding with + * JAXP (http://jaxp.dev.java.net/). + *

+ * Internally, XML Builder uses JAXP to build a standard W3C + * {@link org.w3c.dom.Document} model (DOM) that you can easily export as a + * string, or access and manipulate further if you have special requirements. + *

+ *

+ * The XMLBuilder2 class serves as a wrapper of {@link org.w3c.dom.Element} nodes, + * and provides a number of utility methods that make it simple to + * manipulate the underlying element and the document to which it belongs. + * In essence, this class performs dual roles: it represents a specific XML + * node, and also allows manipulation of the entire underlying XML document. + * The platform's default {@link DocumentBuilderFactory} and + * {@link DocumentBuilder} classes are used to build the document. + *

+ *

+ * XMLBuilder2 has an feature set to the original XMLBuilder, but only ever + * throws runtime exceptions (as opposed to checked exceptions). Any internal + * checked exceptions are caught and wrapped in an + * {@link XMLBuilderRuntimeException} object. + *

+ * + * @author James Murty + */ +public final class XMLBuilder2 extends BaseXMLBuilder { + + /** + * Construct a new builder object that wraps the given XML document. + * This constructor is for internal use only. + * + * @param xmlDocument + * an XML document that the builder will manage and manipulate. + */ + protected XMLBuilder2(Document xmlDocument) { + super(xmlDocument); + } + + /** + * Construct a new builder object that wraps the given XML document and node. + * This constructor is for internal use only. + * + * @param myNode + * the XML node that this builder node will wrap. This node may + * be part of the XML document, or it may be a new element that is to be + * added to the document. + * @param parentNode + * If not null, the given myElement will be appended as child node of the + * parentNode node. + */ + protected XMLBuilder2(Node myNode, Node parentNode) { + super(myNode, parentNode); + } + + private static RuntimeException wrapExceptionAsRuntimeException(Exception e) { + // Don't wrap (or re-wrap) runtime exceptions. + if (e instanceof RuntimeException) { + return (RuntimeException) e; + } else { + return new XMLBuilderRuntimeException(e); + } + } + + /** + * Construct a builder for new XML document with a default namespace. + * The document will be created with the given root element, and the builder + * returned by this method will serve as the starting-point for any further + * document additions. + * + * @param name + * the name of the document's root element. + * @param namespaceURI + * default namespace URI for document, ignored if null or empty. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} + * @return + * a builder node that can be used to add more nodes to the XML document. + * @throws XMLBuilderRuntimeException + * to wrap {@link ParserConfigurationException} + */ + public static XMLBuilder2 create( + String name, String namespaceURI, boolean enableExternalEntities, + boolean isNamespaceAware) + { + try { + return new XMLBuilder2( + createDocumentImpl( + name, namespaceURI, enableExternalEntities, isNamespaceAware)); + } catch (ParserConfigurationException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + /** + * Construct a builder for new XML document. The document will be created + * with the given root element, and the builder returned by this method + * will serve as the starting-point for any further document additions. + * + * @param name + * the name of the document's root element. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} + * @return + * a builder node that can be used to add more nodes to the XML document. + * @throws XMLBuilderRuntimeException + * to wrap {@link ParserConfigurationException} + */ + public static XMLBuilder2 create(String name, + boolean enableExternalEntities, boolean isNamespaceAware) + { + return XMLBuilder2.create( + name, null, enableExternalEntities, isNamespaceAware); + } + + /** + * Construct a builder for new XML document with a default namespace. + * The document will be created with the given root element, and the builder + * returned by this method will serve as the starting-point for any further + * document additions. + * + * @param name + * the name of the document's root element. + * @param namespaceURI + * default namespace URI for document, ignored if null or empty. + * @return + * a builder node that can be used to add more nodes to the XML document. + * @throws XMLBuilderRuntimeException + * to wrap {@link ParserConfigurationException} + */ + public static XMLBuilder2 create(String name, String namespaceURI) + { + return XMLBuilder2.create(name, namespaceURI, false, true); + } + + /** + * Construct a builder for new XML document. The document will be created + * with the given root element, and the builder returned by this method + * will serve as the starting-point for any further document additions. + * + * @param name + * the name of the document's root element. + * @return + * a builder node that can be used to add more nodes to the XML document. + * @throws XMLBuilderRuntimeException + * to wrap {@link ParserConfigurationException} + */ + public static XMLBuilder2 create(String name) + { + return XMLBuilder2.create(name, null, false, true); + } + + /** + * Construct a builder from an existing XML document. The provided XML + * document will be parsed and an XMLBuilder2 object referencing the + * document's root element will be returned. + * + * @param inputSource + * an XML document input source that will be parsed into a DOM. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} + * @return + * a builder node that can be used to add more nodes to the XML document. + * @throws XMLBuilderRuntimeException + * to wrap {@link ParserConfigurationException}, {@link SAXException}, + * {@link IOException} + */ + public static XMLBuilder2 parse( + InputSource inputSource, boolean enableExternalEntities, + boolean isNamespaceAware) + { + try { + return new XMLBuilder2( + parseDocumentImpl( + inputSource, enableExternalEntities, isNamespaceAware)); + } catch (ParserConfigurationException e) { + throw wrapExceptionAsRuntimeException(e); + } catch (SAXException e) { + throw wrapExceptionAsRuntimeException(e); + } catch (IOException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + /** + * Construct a builder from an existing XML document string. + * The provided XML document will be parsed and an XMLBuilder2 + * object referencing the document's root element will be returned. + * + * @param xmlString + * an XML document string that will be parsed into a DOM. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} + * @return + * a builder node that can be used to add more nodes to the XML document. + */ + public static XMLBuilder2 parse( + String xmlString, boolean enableExternalEntities, boolean isNamespaceAware) + { + return XMLBuilder2.parse( + new InputSource(new StringReader(xmlString)), + enableExternalEntities, + isNamespaceAware); + } + + /** + * Construct a builder from an existing XML document file. + * The provided XML document will be parsed and an XMLBuilder2 + * object referencing the document's root element will be returned. + * + * @param xmlFile + * an XML document file that will be parsed into a DOM. + * @param enableExternalEntities + * enable external entities; beware of XML External Entity (XXE) injection. + * @param isNamespaceAware + * enable or disable namespace awareness in the underlying + * {@link DocumentBuilderFactory} + * @return + * a builder node that can be used to add more nodes to the XML document. + * @throws XMLBuilderRuntimeException + * to wrap {@link ParserConfigurationException}, {@link SAXException}, + * {@link IOException}, {@link FileNotFoundException} + */ + public static XMLBuilder2 parse(File xmlFile, boolean enableExternalEntities, + boolean isNamespaceAware) + { + try { + return XMLBuilder2.parse( + new InputSource(new FileReader(xmlFile)), + enableExternalEntities, + isNamespaceAware); + } catch (FileNotFoundException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + /** + * Construct a builder from an existing XML document. The provided XML + * document will be parsed and an XMLBuilder2 object referencing the + * document's root element will be returned. + * + * @param inputSource + * an XML document input source that will be parsed into a DOM. + * @return + * a builder node that can be used to add more nodes to the XML document. + * @throws XMLBuilderRuntimeException + * to wrap {@link ParserConfigurationException}, {@link SAXException}, + * {@link IOException} + */ + public static XMLBuilder2 parse(InputSource inputSource) + { + return XMLBuilder2.parse(inputSource, false, true); + } + + /** + * Construct a builder from an existing XML document string. + * The provided XML document will be parsed and an XMLBuilder2 + * object referencing the document's root element will be returned. + * + * @param xmlString + * an XML document string that will be parsed into a DOM. + * @return + * a builder node that can be used to add more nodes to the XML document. + */ + public static XMLBuilder2 parse(String xmlString) + { + return XMLBuilder2.parse(xmlString, false, true); + } + + /** + * Construct a builder from an existing XML document file. + * The provided XML document will be parsed and an XMLBuilder2 + * object referencing the document's root element will be returned. + * + * @param xmlFile + * an XML document file that will be parsed into a DOM. + * @return + * a builder node that can be used to add more nodes to the XML document. + * @throws XMLBuilderRuntimeException + * to wrap {@link ParserConfigurationException}, {@link SAXException}, + * {@link IOException}, {@link FileNotFoundException} + */ + public static XMLBuilder2 parse(File xmlFile) + { + return XMLBuilder2.parse(xmlFile, false, true); + } + + /** + * @throws XMLBuilderRuntimeException + * to wrap {@link XPathExpressionException} + */ + @Override + public XMLBuilder2 stripWhitespaceOnlyTextNodes() + { + try { + super.stripWhitespaceOnlyTextNodesImpl(); + return this; + } catch (XPathExpressionException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + @Override + public XMLBuilder2 importXMLBuilder(BaseXMLBuilder builder) { + super.importXMLBuilderImpl(builder); + return this; + } + + @Override + public XMLBuilder2 root() { + return new XMLBuilder2(getDocument()); + } + + /** + * @throws XMLBuilderRuntimeException + * to wrap {@link XPathExpressionException} + */ + @Override + public XMLBuilder2 xpathFind(String xpath, NamespaceContext nsContext) + { + try { + Node foundNode = super.xpathFindImpl(xpath, nsContext); + return new XMLBuilder2(foundNode, null); + } catch (XPathExpressionException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + @Override + public XMLBuilder2 xpathFind(String xpath) { + return xpathFind(xpath, null); + } + + @Override + public XMLBuilder2 element(String name) { + String namespaceURI = super.lookupNamespaceURIImpl(name); + return element(name, namespaceURI); + } + + @Override + public XMLBuilder2 elem(String name) { + return element(name); + } + + @Override + public XMLBuilder2 e(String name) { + return element(name); + } + + @Override + public XMLBuilder2 element(String name, String namespaceURI) { + Element elem = super.elementImpl(name, namespaceURI); + return new XMLBuilder2(elem, this.getElement()); + } + + @Override + public XMLBuilder2 elementBefore(String name) { + Element newElement = super.elementBeforeImpl(name); + return new XMLBuilder2(newElement, null); + } + + @Override + public XMLBuilder2 elementBefore(String name, String namespaceURI) { + Element newElement = super.elementBeforeImpl(name, namespaceURI); + return new XMLBuilder2(newElement, null); + } + + @Override + public XMLBuilder2 attribute(String name, String value) { + super.attributeImpl(name, value); + return this; + } + + @Override + public XMLBuilder2 attr(String name, String value) { + return attribute(name, value); + } + + @Override + public XMLBuilder2 a(String name, String value) { + return attribute(name, value); + } + + + @Override + public XMLBuilder2 text(String value, boolean replaceText) { + super.textImpl(value, replaceText); + return this; + } + + @Override + public XMLBuilder2 text(String value) { + return this.text(value, false); + } + + @Override + public XMLBuilder2 t(String value) { + return text(value); + } + + @Override + public XMLBuilder2 cdata(String data) { + super.cdataImpl(data); + return this; + } + + @Override + public XMLBuilder2 data(String data) { + return cdata(data); + } + + @Override + public XMLBuilder2 d(String data) { + return cdata(data); + } + + @Override + public XMLBuilder2 cdata(byte[] data) { + super.cdataImpl(data); + return this; + } + + @Override + public XMLBuilder2 data(byte[] data) { + return cdata(data); + } + + @Override + public XMLBuilder2 d(byte[] data) { + return cdata(data); + } + + @Override + public XMLBuilder2 comment(String comment) { + super.commentImpl(comment); + return this; + } + + @Override + public XMLBuilder2 cmnt(String comment) { + return comment(comment); + } + + @Override + public XMLBuilder2 c(String comment) { + return comment(comment); + } + + @Override + public XMLBuilder2 instruction(String target, String data) { + super.instructionImpl(target, data); + return this; + } + + @Override + public XMLBuilder2 inst(String target, String data) { + return instruction(target, data); + } + + @Override + public XMLBuilder2 i(String target, String data) { + return instruction(target, data); + } + + @Override + public XMLBuilder2 insertInstruction(String target, String data) { + super.insertInstructionImpl(target, data); + return this; + } + + @Override + public XMLBuilder2 reference(String name) { + super.referenceImpl(name); + return this; + } + + @Override + public XMLBuilder2 ref(String name) { + return reference(name); + } + + @Override + public XMLBuilder2 r(String name) { + return reference(name); + } + + @Override + public XMLBuilder2 namespace(String prefix, String namespaceURI) { + super.namespaceImpl(prefix, namespaceURI); + return this; + } + + @Override + public XMLBuilder2 ns(String prefix, String namespaceURI) { + return namespace(prefix, namespaceURI); + } + + @Override + public XMLBuilder2 namespace(String namespaceURI) { + this.namespace(null, namespaceURI); + return this; + } + + @Override + public XMLBuilder2 ns(String namespaceURI) { + return namespace(namespaceURI); + } + + @Override + public XMLBuilder2 up(int steps) { + Node currNode = super.upImpl(steps); + if (currNode instanceof Document) { + return new XMLBuilder2((Document) currNode); + } else { + return new XMLBuilder2(currNode, null); + } + } + + @Override + public XMLBuilder2 up() { + return up(1); + } + + @Override + public XMLBuilder2 document() { + return new XMLBuilder2(getDocument(), null); + } + + /** + * @throws XMLBuilderRuntimeException + * to wrap {@link TransformerException} + * + */ + @Override + public String asString() { + try { + return super.asString(); + } catch (TransformerException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + /** + * @throws XMLBuilderRuntimeException + * to wrap {@link TransformerException} + * + */ + @Override + public String asString(Properties properties) { + try { + return super.asString(properties); + } catch (TransformerException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + /** + * @throws XMLBuilderRuntimeException + * to wrap {@link TransformerException} + * + */ + @Override + public String elementAsString() { + try { + return super.elementAsString(); + } catch (TransformerException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + /** + * @throws XMLBuilderRuntimeException + * to wrap {@link TransformerException} + * + */ + @Override + public String elementAsString(Properties outputProperties) { + try { + return super.elementAsString(outputProperties); + } catch (TransformerException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + /** + * @throws XMLBuilderRuntimeException + * to wrap {@link TransformerException} + * + */ + @Override + public void toWriter(boolean wholeDocument, Writer writer, Properties outputProperties) + { + try { + super.toWriter(wholeDocument, writer, outputProperties); + } catch (TransformerException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + /** + * @throws XMLBuilderRuntimeException + * to wrap {@link TransformerException} + * + */ + @Override + public void toWriter(Writer writer, Properties outputProperties) + { + try { + super.toWriter(writer, outputProperties); + } catch (TransformerException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + /** + * @throws XMLBuilderRuntimeException + * to wrap {@link XPathExpressionException} + * + */ + @Override + public Object xpathQuery(String xpath, QName type, NamespaceContext nsContext) + { + try { + return super.xpathQuery(xpath, type, nsContext); + } catch (XPathExpressionException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + + /** + * @throws XMLBuilderRuntimeException + * to wrap {@link XPathExpressionException} + * + */ + @Override + public Object xpathQuery(String xpath, QName type) + { + try { + return super.xpathQuery(xpath, type); + } catch (XPathExpressionException e) { + throw wrapExceptionAsRuntimeException(e); + } + } + +} diff --git a/src/main/java/com/jamesmurty/utils/XMLBuilderRuntimeException.java b/src/main/java/com/jamesmurty/utils/XMLBuilderRuntimeException.java new file mode 100644 index 0000000..259d505 --- /dev/null +++ b/src/main/java/com/jamesmurty/utils/XMLBuilderRuntimeException.java @@ -0,0 +1,22 @@ +package com.jamesmurty.utils; + +/** + * A runtime exception class used in {@link XMLBuilder2} to wrap any exceptions + * that would otherwise lead to checked exceptions in the interface. + * + * @author jmurty + * + */ +public class XMLBuilderRuntimeException extends RuntimeException { + + private static final long serialVersionUID = -635323496745601589L; + + /** + * @param exception + * cause exception to be wrapped + */ + public XMLBuilderRuntimeException(Exception exception) { + super(exception); + } + +} diff --git a/src/test/java/com/jamesmurty/utils/TestXmlBuilder.java b/src/test/java/com/jamesmurty/utils/BaseXMLBuilderTests.java similarity index 63% rename from src/test/java/com/jamesmurty/utils/TestXmlBuilder.java rename to src/test/java/com/jamesmurty/utils/BaseXMLBuilderTests.java index ce5eea6..274d461 100644 --- a/src/test/java/com/jamesmurty/utils/TestXmlBuilder.java +++ b/src/test/java/com/jamesmurty/utils/BaseXMLBuilderTests.java @@ -1,28 +1,23 @@ package com.jamesmurty.utils; -import java.io.IOException; +import java.io.File; +import java.io.FileWriter; import java.io.StringReader; import java.io.StringWriter; -import java.io.UnsupportedEncodingException; import java.util.Properties; -import javax.xml.parsers.FactoryConfigurationError; -import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.OutputKeys; -import javax.xml.transform.TransformerException; import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathExpressionException; import junit.framework.TestCase; import net.iharder.Base64; -import org.w3c.dom.Document; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.InputSource; -import org.xml.sax.SAXException; -public class TestXmlBuilder extends TestCase { +public abstract class BaseXMLBuilderTests extends TestCase { public static final String EXAMPLE_XML_DOC_START = "" + @@ -38,11 +33,42 @@ public class TestXmlBuilder extends TestCase { public static final String EXAMPLE_XML_DOC = EXAMPLE_XML_DOC_START + EXAMPLE_XML_DOC_END; - public void testXmlDocumentCreation() throws ParserConfigurationException, - FactoryConfigurationError, TransformerException - { + protected abstract Class XMLBuilderToTest() throws Exception; + + protected abstract boolean isRuntimeExceptionsOnly(); + + protected BaseXMLBuilder XMLBuilder_create(String name) throws Exception { + return (BaseXMLBuilder) XMLBuilderToTest().getMethod( + "create", String.class).invoke(null, name); + } + + protected BaseXMLBuilder XMLBuilder_create(String name, String nsURI) throws Exception { + return (BaseXMLBuilder) XMLBuilderToTest().getMethod( + "create", String.class, String.class).invoke(null, name, nsURI); + } + + protected BaseXMLBuilder XMLBuilder_parse(InputSource source) throws Exception { + return (BaseXMLBuilder) XMLBuilderToTest().getMethod( + "parse", InputSource.class).invoke(null, source); + } + + protected BaseXMLBuilder XMLBuilder_parse( + String documentString, boolean enableExternalEntities, + boolean isNamespaceAware) throws Exception + { + return (BaseXMLBuilder) XMLBuilderToTest().getMethod( + "parse", String.class, boolean.class, boolean.class).invoke( + null, documentString, enableExternalEntities, isNamespaceAware); + } + + protected BaseXMLBuilder XMLBuilder_parse(String documentString) throws Exception { + return (BaseXMLBuilder) XMLBuilderToTest().getMethod( + "parse", String.class).invoke(null, documentString); + } + + public void testXmlDocumentCreation() throws Exception { /* Build XML document in-place */ - XMLBuilder builder = XMLBuilder.create("Projects") + BaseXMLBuilder builder = XMLBuilder_create("Projects") .e("java-xmlbuilder") .a("language", "Java") .a("scm","SVN") @@ -74,14 +100,14 @@ public void testXmlDocumentCreation() throws ParserConfigurationException, assertEquals(EXAMPLE_XML_DOC, writer.toString()); /* Build XML document in segments*/ - XMLBuilder projectsB = XMLBuilder.create("Projects"); + BaseXMLBuilder projectsB = XMLBuilder_create("Projects"); projectsB.e("java-xmlbuilder") .a("language", "Java") .a("scm","SVN") .e("Location") .a("type", "URL") .t("http://code.google.com/p/java-xmlbuilder/"); - XMLBuilder jets3tB = projectsB.e("JetS3t") + BaseXMLBuilder jets3tB = projectsB.e("JetS3t") .a("language", "Java") .a("scm","CVS"); jets3tB.e("Location") @@ -91,11 +117,9 @@ public void testXmlDocumentCreation() throws ParserConfigurationException, assertEquals(builder.asString(), projectsB.asString()); } - public void testParseAndXPath() throws ParserConfigurationException, SAXException, - IOException, XPathExpressionException, TransformerException - { + public void testParseAndXPath() throws Exception { // Parse an existing XML document - XMLBuilder builder = XMLBuilder.parse( + BaseXMLBuilder builder = XMLBuilder_parse( new InputSource(new StringReader(EXAMPLE_XML_DOC))); assertEquals("Projects", builder.root().getElement().getNodeName()); assertEquals("Invalid current element", "Projects", builder.getElement().getNodeName()); @@ -120,12 +144,17 @@ public void testParseAndXPath() throws ParserConfigurationException, SAXExceptio try { builder.xpathFind("//@language"); fail("Non-Element XPath expression should have failed"); - } catch (XPathExpressionException e) { + } catch (Exception e) { + if (isRuntimeExceptionsOnly()) { + assertEquals(XMLBuilderRuntimeException.class, e.getClass()); + e = (Exception) e.getCause(); + } + assertEquals(XPathExpressionException.class, e.getClass()); assertTrue(e.getMessage().contains("does not resolve to an Element")); } /* Perform full-strength XPath queries that do not have to - * resolve to an Element, and do not return XMLBuilder instances + * resolve to an Element, and do not return BaseXMLBuilder instances */ // Find the Location value for the JetS3t project @@ -159,11 +188,11 @@ public void testParseAndXPath() throws ParserConfigurationException, SAXExceptio /* Add a new XML element at a specific XPath location in an existing document */ // Use XPath to get a builder at the insert location - XMLBuilder xpathLocB = builder.xpathFind("//JetS3t"); + BaseXMLBuilder xpathLocB = builder.xpathFind("//JetS3t"); assertEquals("JetS3t", xpathLocB.getElement().getNodeName()); // Append a new element with the location's builder - XMLBuilder location2B = xpathLocB.elem("Location2").attr("type", "Testing"); + BaseXMLBuilder location2B = xpathLocB.elem("Location2").attr("type", "Testing"); assertEquals("Location2", location2B.getElement().getNodeName()); assertEquals("JetS3t", location2B.up().getElement().getNodeName()); assertEquals(xpathLocB.getElement(), location2B.up().getElement()); @@ -181,19 +210,16 @@ public void testParseAndXPath() throws ParserConfigurationException, SAXExceptio xmlAsString); } - public void testParseAndAmendDocWithWhitespaceNodes() - throws ParserConfigurationException, SAXException, IOException, - XPathExpressionException, TransformerException - { + public void testParseAndAmendDocWithWhitespaceNodes() throws Exception { // Parse example XML document and output with indenting, to add whitespace nodes Properties outputProperties = new Properties(); outputProperties.put(OutputKeys.INDENT, "yes"); outputProperties.put("{http://xml.apache.org/xslt}indent-amount", "2"); String xmlWithWhitespaceNodes = - XMLBuilder.parse(EXAMPLE_XML_DOC).asString(outputProperties); + XMLBuilder_parse(EXAMPLE_XML_DOC).asString(outputProperties); // Re-parse document that now has whitespace nodes - XMLBuilder builder = XMLBuilder.parse(xmlWithWhitespaceNodes); + BaseXMLBuilder builder = XMLBuilder_parse(xmlWithWhitespaceNodes); // Ensure we can add a node to the document (re issue #17) builder.xpathFind("//JetS3t") @@ -202,19 +228,16 @@ public void testParseAndAmendDocWithWhitespaceNodes() assertTrue(xmlWithAmendments.contains("")); } - public void testStripWhitespaceNodesFromDocument() - throws ParserConfigurationException, SAXException, IOException, - XPathExpressionException, TransformerException - { + public void testStripWhitespaceNodesFromDocument() throws Exception { // Parse example XML document and output with indenting, to add whitespace nodes Properties outputProperties = new Properties(); outputProperties.put(OutputKeys.INDENT, "yes"); outputProperties.put("{http://xml.apache.org/xslt}indent-amount", "2"); String xmlWithWhitespaceNodes = - XMLBuilder.parse(EXAMPLE_XML_DOC).asString(outputProperties); + XMLBuilder_parse(EXAMPLE_XML_DOC).asString(outputProperties); // Re-parse document that now has whitespace text nodes - XMLBuilder builder = XMLBuilder.parse(xmlWithWhitespaceNodes); + BaseXMLBuilder builder = XMLBuilder_parse(xmlWithWhitespaceNodes); assertTrue(builder.asString().contains("\n")); assertTrue(builder.asString().contains(" ")); @@ -226,8 +249,8 @@ public void testStripWhitespaceNodesFromDocument() public void testSimpleXpath() throws Exception { String xmlDoc = ""; - XMLBuilder builder = XMLBuilder.parse(xmlDoc); - XMLBuilder builderNode = builder.xpathFind("report_objects"); + BaseXMLBuilder builder = XMLBuilder_parse(xmlDoc); + BaseXMLBuilder builderNode = builder.xpathFind("report_objects"); assertTrue("report_objects".equals(builderNode.getElement().getNodeName())); assertTrue("".equals(builderNode.elementAsString())); } @@ -237,8 +260,8 @@ public void testSimpleXpath() throws Exception { * @throws Exception */ public void testAddElementsInLoop() throws Exception { - XMLBuilder builder = XMLBuilder.create("DocRoot"); - XMLBuilder parentBuilder = builder.element("Parent"); + BaseXMLBuilder builder = XMLBuilder_create("DocRoot"); + BaseXMLBuilder parentBuilder = builder.element("Parent"); // Add set of elements to Parent using a loop... for (int i = 1; i <= 10; i++) { @@ -257,10 +280,8 @@ public void testAddElementsInLoop() throws Exception { assertEquals("1", parentBuilder.getElement().getChildNodes().item(0).getTextContent()); } - public void testTraversalDuringBuild() throws ParserConfigurationException, SAXException, - IOException, XPathExpressionException, TransformerException - { - XMLBuilder builder = XMLBuilder.create("ElemDepth1") + public void testTraversalDuringBuild() throws Exception { + BaseXMLBuilder builder = XMLBuilder_create("ElemDepth1") .e("ElemDepth2") .e("ElemDepth3") .e("ElemDepth4"); @@ -272,15 +293,13 @@ public void testTraversalDuringBuild() throws ParserConfigurationException, SAXE assertEquals("ElemDepth1", builder.up(100).getElement().getNodeName()); } - public void testImport() throws ParserConfigurationException, - FactoryConfigurationError - { - XMLBuilder importer = XMLBuilder.create("Importer") + public void testImport() throws Exception { + BaseXMLBuilder importer = XMLBuilder_create("Importer") .elem("Imported") .elem("Element") .elem("Goes").attr("are-we-there-yet", "almost") .elem("Here"); - XMLBuilder importee = XMLBuilder.create("Importee") + BaseXMLBuilder importee = XMLBuilder_create("Importee") .elem("Importee").attr("awating-my", "new-home") .elem("IsEntireSubtree") .elem("Included"); @@ -298,7 +317,7 @@ public void testImport() throws ParserConfigurationException, fail("XMLBuilder import failed: " + e.getMessage()); } - XMLBuilder invalidImporter = XMLBuilder.create("InvalidImporter") + BaseXMLBuilder invalidImporter = XMLBuilder_create("InvalidImporter") .text("BadBadBad"); try { invalidImporter.importXMLBuilder(importee); @@ -309,15 +328,12 @@ public void testImport() throws ParserConfigurationException, } } - public void testCDataNodes() throws ParserConfigurationException, - FactoryConfigurationError, UnsupportedEncodingException, - XPathExpressionException, IOException - { + public void testCDataNodes() throws Exception { String text = "Text data -- left as it is"; String textForBytes = "Byte data is automatically base64-encoded"; String textEncoded = Base64.encodeBytes(textForBytes.getBytes("UTF-8")); - XMLBuilder builder = XMLBuilder.create("TestCDataNodes") + BaseXMLBuilder builder = XMLBuilder_create("TestCDataNodes") .elem("CDataTextElem") .cdata(text) .up() @@ -337,10 +353,8 @@ public void testCDataNodes() throws ParserConfigurationException, assertEquals(textForBytes, base64Decoded); } - public void testElementAsString() throws ParserConfigurationException, - FactoryConfigurationError, TransformerException, XPathExpressionException - { - XMLBuilder builder = XMLBuilder.create("This") + public void testElementAsString() throws Exception { + BaseXMLBuilder builder = XMLBuilder_create("This") .elem("Is").elem("My").text("Test"); // By default, entire XML document is serialized regardless of starting-point assertEquals("Test", builder.asString()); @@ -349,11 +363,8 @@ public void testElementAsString() throws ParserConfigurationException, assertEquals("Test", builder.xpathFind("//My").elementAsString()); } - public void testNamespaces() throws ParserConfigurationException, - FactoryConfigurationError, TransformerException, XPathExpressionException - { - XMLBuilder builder = XMLBuilder - .create("NamespaceTest", "urn:default") + public void testNamespaces() throws Exception { + BaseXMLBuilder builder = XMLBuilder_create("NamespaceTest", "urn:default") .namespace("prefix1", "urn:ns1") .element("NSDefaultImplicit").up() @@ -371,11 +382,23 @@ public void testNamespaces() throws ParserConfigurationException, try { builder.xpathFind("//:NSDefaultImplicit"); fail("Namespaced xpath query without context is invalid"); - } catch (XPathExpressionException e) {} + } catch (Exception e) { + if (isRuntimeExceptionsOnly()) { + assertEquals(XMLBuilderRuntimeException.class, e.getClass()); + e = (Exception) e.getCause(); + } + assertEquals(XPathExpressionException.class, e.getClass()); + } try { builder.xpathFind("//NSDefaultImplicit", context); fail("XPath query without prefixes on namespaced docs is invalid"); - } catch (XPathExpressionException e) {} + } catch (Exception e) { + if (isRuntimeExceptionsOnly()) { + assertEquals(XMLBuilderRuntimeException.class, e.getClass()); + e = (Exception) e.getCause(); + } + assertEquals(XPathExpressionException.class, e.getClass()); + } // Find nodes with default namespace builder.xpathFind("/:NamespaceTest", context); @@ -386,15 +409,33 @@ public void testNamespaces() throws ParserConfigurationException, try { builder.xpathFind("//NSDefaultExplicit"); fail(); - } catch (XPathExpressionException e) {} + } catch (Exception e) { + if (isRuntimeExceptionsOnly()) { + assertEquals(XMLBuilderRuntimeException.class, e.getClass()); + e = (Exception) e.getCause(); + } + assertEquals(XPathExpressionException.class, e.getClass()); + } try { builder.xpathFind("//:NSDefaultExplicit"); fail(); - } catch (XPathExpressionException e) {} + } catch (Exception e) { + if (isRuntimeExceptionsOnly()) { + assertEquals(XMLBuilderRuntimeException.class, e.getClass()); + e = (Exception) e.getCause(); + } + assertEquals(XPathExpressionException.class, e.getClass()); + } try { builder.xpathFind("//NSDefaultExplicit", context); fail(); - } catch (XPathExpressionException e) {} + } catch (Exception e) { + if (isRuntimeExceptionsOnly()) { + assertEquals(XMLBuilderRuntimeException.class, e.getClass()); + e = (Exception) e.getCause(); + } + assertEquals(XPathExpressionException.class, e.getClass()); + } // Find node with namespace prefix builder.xpathFind("//prefix1:NS1Explicit", context); @@ -415,19 +456,59 @@ public void testNamespaces() throws ParserConfigurationException, try { builder.xpathFind("//:NSDefaultExplicit", context); fail(); - } catch (XPathExpressionException e) {} + } catch (Exception e) { + if (isRuntimeExceptionsOnly()) { + assertEquals(XMLBuilderRuntimeException.class, e.getClass()); + e = (Exception) e.getCause(); + } + assertEquals(XPathExpressionException.class, e.getClass()); + } // Users are not prevented from creating elements that reference // an undefined namespace prefix -- user beware builder.element("undefined-prefix:ElementName"); } - public void testElementBefore() throws ParserConfigurationException, - FactoryConfigurationError, TransformerException, XPathExpressionException, - SAXException, IOException - { - XMLBuilder builder = XMLBuilder - .create("TestDocument", "urn:default") + public void testNamespaceUnawareBuilder() throws Exception { + String XML_WITH_NAMESPACES = + "" + + "Found me" + + ""; + + // Builder set to be unaware of namespaces can traverse DOM with + // namespaces without using namespace prefixes + BaseXMLBuilder result = XMLBuilder_parse( + XML_WITH_NAMESPACES, + false, // enableExternalEntities + false // isNamespaceAware + ).xpathFind("/NamespaceUnwareTest/NestedElement"); + assertEquals("Found me", result.getElement().getTextContent()); + + // Builder set to be aware of namespaces (per default) cannot traverse + // DOM with namespaces without using namespace prefixes + try { + result = XMLBuilder_parse(XML_WITH_NAMESPACES) + .xpathFind("/NamespaceUnwareTest/NestedElement"); + } catch (Exception ex) { + Throwable cause = null; + if (this instanceof TestXMLBuilder2) { + cause = ex.getCause(); // Exception wrapped in runtime ex + } else { + cause = ex; + } + assertEquals( + cause.getClass(), XPathExpressionException.class); + assertTrue( + cause.getMessage().contains( + "XPath expression \"/NamespaceUnwareTest/NestedElement\"" + + " does not resolve to an Element in context" + )); + } + assertEquals("Found me", result.getElement().getTextContent()); + } + + public void testElementBefore() throws Exception { + BaseXMLBuilder builder = XMLBuilder_create("TestDocument", "urn:default") .namespace("custom", "urn:custom") .elem("Before").up() .elem("After"); @@ -438,9 +519,9 @@ public void testElementBefore() throws ParserConfigurationException, + "", builder.asString()); // Insert an element before the "After" element, no explicit namespace (will use default) - XMLBuilder testDoc = XMLBuilder.parse(builder.asString()) + BaseXMLBuilder testDoc = XMLBuilder_parse(builder.asString()) .xpathFind("/:TestDocument/:After", context); - XMLBuilder insertedBuilder = testDoc.elementBefore("Inserted"); + BaseXMLBuilder insertedBuilder = testDoc.elementBefore("Inserted"); assertEquals("Inserted", insertedBuilder.getElement().getNodeName()); assertEquals("" + "", testDoc.asString()); @@ -461,15 +542,12 @@ public void testElementBefore() throws ParserConfigurationException, testDoc.asString()); } - public void testTextNodes() - throws ParserConfigurationException, FactoryConfigurationError, XPathExpressionException - { - XMLBuilder builder = XMLBuilder - .create("TestDocument") + public void testTextNodes() throws Exception { + BaseXMLBuilder builder = XMLBuilder_create("TestDocument") .elem("TextElement") .text("Initial"); - XMLBuilder textElementBuilder = builder.xpathFind("//TextElement"); + BaseXMLBuilder textElementBuilder = builder.xpathFind("//TextElement"); assertEquals("Initial", textElementBuilder.getElement().getTextContent()); // By default, text methods append value to existing text @@ -499,24 +577,27 @@ public void testTextNodes() public void testProcessingInstructionNodes() throws Exception { // Add instruction to root document element node (usual append-in-node behaviour) - XMLBuilder builder = XMLBuilder - .create("TestDocument").instruction("test", "data"); + BaseXMLBuilder builder = XMLBuilder_create("TestDocument").instruction("test", "data"); assertEquals("", builder.asString()); // Add instruction after the root document element (not within it) - builder = XMLBuilder.create("TestDocument3").document().instruction("test", "data"); + builder = XMLBuilder_create("TestDocument3").document().instruction("test", "data"); assertEquals("", builder.asString().trim()); // Insert instruction as first node of the root document - builder = XMLBuilder.create("TestDocument3").insertInstruction("test", "data"); - assertEquals("\n", builder.asString()); + builder = XMLBuilder_create("TestDocument3").insertInstruction("test", "data"); + assertEquals( + "", + // Remove newlines from XML as this differs across platforms + builder.asString().replace("\n", "")); // Insert instruction as first node of the root document, second example - builder = XMLBuilder.create("TestDocument4").elem("ChildElem") + builder = XMLBuilder_create("TestDocument4").elem("ChildElem") .root().insertInstruction("test", "data"); assertEquals( - "\n", - builder.asString()); + "", + // Remove newlines from XML as this differs across platforms + builder.asString().replace("\n", "")); } /** @@ -527,7 +608,7 @@ public void testProcessingInstructionNodes() throws Exception { */ public void testSetStandaloneToYes() throws Exception { String xmlDoc = ""; - XMLBuilder builder = XMLBuilder.parse( + BaseXMLBuilder builder = XMLBuilder_parse( new InputSource(new StringReader(xmlDoc))); // Basic output settings @@ -549,4 +630,65 @@ public void testSetStandaloneToYes() throws Exception { writer.toString()); } + /** + * Test the {@link BaseXMLBuilder#asString(Properties)} method output a document + * of the correct size when the document is moderately large, re issue + * #1 (on GitHub). + * + * @throws Exception + */ + public void testModerateDocumentSizeAsString() throws Exception { + // Create a moderate document around 0.5 MB + long expectedByteSize = 505021; + BaseXMLBuilder builder = XMLBuilder_create("RootNode"); + for (int i = 0; i < 5000; i++) { + builder + .e("TreeRoot") + .e("TreeTrunk") + .e("TreeBranch") + .e("TreeLeaf") + .t("Some Aphids"); + + } + // Omit XML declaration, which will otherwise be included in file + // via #toWriter but not in string via #asString + Properties outputProperties = new Properties(); + outputProperties.put( + javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes"); + // Ensure XML as string has expected length... + String xmlString = builder.asString(outputProperties); + assertEquals(expectedByteSize, xmlString.length()); + // ...and matches size of XML written to file + File f = File.createTempFile( + "java-xmlbuilder-testmoderatedocumentsizeasstring", ".xml"); + builder.toWriter(new FileWriter(f), outputProperties); + assertEquals(expectedByteSize, f.length()); + f.delete(); + } + + /** + * Ensure XML Builder parse methods use a default configuration that + * prevents XML External Entity (XXE) injection attacks. + * + * @throws Exception + */ + public void testXMLBuilderParserImmuneToXXEAttackByDefault() throws Exception { + String externalFilePath = "src/test/java/com/jamesmurty/utils/external.txt"; + File externalFile = new File(externalFilePath); + String XML_DOC_WITH_XXE = + "" + + "" + + " ]>" + + EXAMPLE_XML_DOC_START + "&xx1;" + EXAMPLE_XML_DOC_END; + // By default, builder is immune from XXE injection + BaseXMLBuilder builder = XMLBuilder_parse(XML_DOC_WITH_XXE); + String parsedXml = builder.asString(); + assertFalse(parsedXml.indexOf("Injected XXE Data") >= 0); + // If you enable external entity processing, builder becomes subject to XXE injection + builder = XMLBuilder_parse(XML_DOC_WITH_XXE, true, true); + parsedXml = builder.asString(); + assertTrue(parsedXml.indexOf("Injected XXE Data") >= 0); + } + } diff --git a/src/test/java/com/jamesmurty/utils/TestXMLBuilder.java b/src/test/java/com/jamesmurty/utils/TestXMLBuilder.java new file mode 100644 index 0000000..6e6fe9b --- /dev/null +++ b/src/test/java/com/jamesmurty/utils/TestXMLBuilder.java @@ -0,0 +1,15 @@ +package com.jamesmurty.utils; + +public class TestXMLBuilder extends BaseXMLBuilderTests { + + @Override + public Class XMLBuilderToTest() throws Exception { + return XMLBuilder.class; + } + + @Override + protected boolean isRuntimeExceptionsOnly() { + return false; + } + +} diff --git a/src/test/java/com/jamesmurty/utils/TestXMLBuilder2.java b/src/test/java/com/jamesmurty/utils/TestXMLBuilder2.java new file mode 100644 index 0000000..2bfb7ee --- /dev/null +++ b/src/test/java/com/jamesmurty/utils/TestXMLBuilder2.java @@ -0,0 +1,42 @@ +package com.jamesmurty.utils; + +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpressionException; + +public class TestXMLBuilder2 extends BaseXMLBuilderTests { + + @Override + public Class XMLBuilderToTest() throws Exception { + return XMLBuilder2.class; + } + + @Override + protected boolean isRuntimeExceptionsOnly() { + return true; + } + + // NOTE: No checked exceptions for API calls made in this test method + public void testNoCheckedExceptions() { + XMLBuilder2 builder = XMLBuilder2.create("Blah"); + builder = XMLBuilder2.parse(EXAMPLE_XML_DOC); + builder.stripWhitespaceOnlyTextNodes(); + builder.asString(); + builder.elementAsString(); + builder.xpathQuery("/*", XPathConstants.NODESET); + builder.xpathFind("/Projects"); + } + + public void testExceptionWrappedInXMLBuilderRuntimeException() { + XMLBuilder2 builder = XMLBuilder2.parse(EXAMPLE_XML_DOC); + try { + builder.xpathFind("/BadPath"); + fail("Expected XMLBuilderRuntimeException"); + } catch (XMLBuilderRuntimeException e) { + assertEquals(XMLBuilderRuntimeException.class, e.getClass()); + Throwable cause = e.getCause(); + assertEquals(XPathExpressionException.class, cause.getClass()); + assertTrue(cause.getMessage().contains("does not resolve to an Element")); + } + } + +} diff --git a/src/test/java/com/jamesmurty/utils/external.txt b/src/test/java/com/jamesmurty/utils/external.txt new file mode 100644 index 0000000..c067079 --- /dev/null +++ b/src/test/java/com/jamesmurty/utils/external.txt @@ -0,0 +1 @@ +Injected XXE Data \ No newline at end of file