package org.springframework.roo.support.util;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.Map.Entry;
import java.util.SortedMap;
import java.util.TreeMap;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.Validate;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* Utilities related to round-tripping XML documents
*
* @author Stefan Schmidt
* @since 1.1
*/
public final class XmlRoundTripUtils {
private static MessageDigest digest;
static {
try {
digest = MessageDigest.getInstance("sha-1");
} catch (final NoSuchAlgorithmException e) {
throw new IllegalStateException("Could not create hash key for identifier");
}
}
private static boolean addOrUpdateElements(final Element original, final Element proposed,
boolean originalDocumentChanged) {
final NodeList proposedChildren = proposed.getChildNodes();
// Check proposed elements and compare to originals to find out if we
// need to add or replace elements
for (int i = 0, n = proposedChildren.getLength(); i < n; i++) {
final Node node = proposedChildren.item(i);
if (node != null && node.getNodeType() == Node.ELEMENT_NODE) {
final Element proposedElement = (Element) node;
final String proposedId = proposedElement.getAttribute("id");
// Only proposed elements with
// an id will be considered
if (proposedId.length() != 0) {
final Element originalElement =
XmlUtils.findFirstElement("//*[@id='" + proposedId + "']", original);
// Insert proposed element given the original document has
// no element with a matching id
if (null == originalElement) {
final Element placeHolder =
DomUtils.findFirstElementByName("util:placeholder", original);
if (placeHolder != null) { // Insert right before place
// holder if we can find it
placeHolder.getParentNode().insertBefore(
original.getOwnerDocument().importNode(proposedElement, false), placeHolder);
}
// Find the best place to insert the element
else {
// Try to find the id of the proposed element's
// parent id in the original document
if (proposed.getAttribute("id").length() != 0) {
final Element originalParent =
XmlUtils.findFirstElement("//*[@id='" + proposed.getAttribute("id") + "']",
original);
// Found parent with the same id, so we can just
// add it as new child
if (originalParent != null) {
originalParent.appendChild(original.getOwnerDocument().importNode(
proposedElement, false));
}
// No parent found so we add it as a
// child of the root element (last
// resort)
else {
original.appendChild(original.getOwnerDocument().importNode(proposedElement,
false));
}
}
// No parent found so we add it as a child of
// the root element (last resort)
else {
original
.appendChild(original.getOwnerDocument().importNode(proposedElement, false));
}
}
originalDocumentChanged = true;
}
// We found an element in the original document with
// a matching id
else {
final String originalElementHashCode = originalElement.getAttribute("z");
// Only actif a hash code exists
if (originalElementHashCode.length() > 0) {
// Only act if hash codes match (no user changes in
// the element) or the user requests for the hash
// code to be regenerated
if ("?".equals(originalElementHashCode)
|| originalElementHashCode.equals(calculateUniqueKeyFor(originalElement))) {
// Check if the elements have equal contents
if (!equalElements(originalElement, proposedElement)) {
// ROO-3683: Updating proposedElement with
// originalElement user-managed childs
NodeList childs = originalElement.getChildNodes();
for (int x = 0; x < childs.getLength(); x++) {
Node childNode = childs.item(x);
if (childNode != null && childNode.getNodeType() == Node.ELEMENT_NODE) {
Element child = (Element) childNode;
String zAttribute = child.getAttribute("z");
if (zAttribute.equals("user-managed")) {
// Getting proposed element and
// replace it with user managed
Element proposedElementToReplace =
XmlUtils.findFirstElement(
"//*[@id='" + child.getAttribute("id") + "']", proposed);
proposedElementToReplace.getParentNode().replaceChild(
proposed.getOwnerDocument().importNode(child, false),
proposedElementToReplace);
}
}
}
// Replace the original with the proposed
// element
originalElement.getParentNode().replaceChild(
original.getOwnerDocument().importNode(proposedElement, false),
originalElement);
originalDocumentChanged = true;
}
// Replace z if the user sets its value to '?'
// as an indication that roo should take over
// the management of this element again
if ("?".equals(originalElementHashCode)) {
originalElement.setAttribute("z", calculateUniqueKeyFor(proposedElement));
originalDocumentChanged = true;
}
}
// If hash codes don't match we will mark the
// element as z="user-managed"
else {
// Mark the element as 'user-managed' if the
// hash codes don't match any more
if (!originalElementHashCode.equals("user-managed")) {
originalElement.setAttribute("z", "user-managed");
originalDocumentChanged = true;
}
}
}
}
}
// Walk through the document tree recursively
originalDocumentChanged =
addOrUpdateElements(original, proposedElement, originalDocumentChanged);
}
}
return originalDocumentChanged;
}
/**
* Create a base 64 encoded SHA1 hash key for a given XML element. The key
* is based on the element name, the attribute names and their values. Child
* elements are ignored. Attributes named 'z' are not concluded since they
* contain the hash key itself.
*
* @param element The element to create the base 64 encoded hash key for
* @return the unique key
*/
public static String calculateUniqueKeyFor(final Element element) {
final StringBuilder sb = new StringBuilder();
sb.append(element.getTagName());
final NamedNodeMap attributes = element.getAttributes();
final SortedMap<String, String> attrKVStore =
Collections.synchronizedSortedMap(new TreeMap<String, String>());
for (int i = 0, n = attributes.getLength(); i < n; i++) {
final Node attr = attributes.item(i);
if (!"z".equals(attr.getNodeName()) && !attr.getNodeName().startsWith("_")) {
attrKVStore.put(attr.getNodeName(), attr.getNodeValue());
}
}
for (final Entry<String, String> entry : attrKVStore.entrySet()) {
sb.append(entry.getKey()).append(entry.getValue());
}
return Base64.encodeBase64String(sha1(sb.toString().getBytes()));
}
/**
* Compare necessary namespace declarations between original and proposed
* document, if namespaces in the original are missing compared to the
* proposed, we add them to the original.
*
* @param original document as read from the file system
* @param proposed document as determined by the JspViewManager
* @return true if the document was adjusted, otherwise false
*/
private static boolean checkNamespaces(final Document original, final Document proposed) {
boolean originalDocumentChanged = false;
final NamedNodeMap nsNodes = proposed.getDocumentElement().getAttributes();
for (int i = 0; i < nsNodes.getLength(); i++) {
if (0 == original.getDocumentElement().getAttribute(nsNodes.item(i).getNodeName()).length()) {
original.getDocumentElement().setAttribute(nsNodes.item(i).getNodeName(),
nsNodes.item(i).getNodeValue());
originalDocumentChanged = true;
}
}
return originalDocumentChanged;
}
/**
* This method will compare the original document with the proposed document
* and return true if adjustments to the original document were necessary.
* Adjustments are only made if new elements or attributes are proposed.
* Changes to the order of attributes or elements in the original document
* will not result in an adjustment.
*
* @param original document as read from the file system
* @param proposed document as determined by the JspViewManager
* @return true if the document was adjusted, otherwise false
*/
public static boolean compareDocuments(final Document original, final Document proposed) {
boolean originalDocumentAdjusted = checkNamespaces(original, proposed);
originalDocumentAdjusted |=
addOrUpdateElements(original.getDocumentElement(), proposed.getDocumentElement(),
originalDocumentAdjusted);
originalDocumentAdjusted |=
removeElements(original.getDocumentElement(), proposed.getDocumentElement(),
originalDocumentAdjusted);
return originalDocumentAdjusted;
}
private static boolean equalElements(final Element a, final Element b) {
if (!a.getTagName().equals(b.getTagName())) {
return false;
}
final NamedNodeMap attributes = a.getAttributes();
int customAttributeCounter = 0;
for (int i = 0, n = attributes.getLength(); i < n; i++) {
final Node node = attributes.item(i);
if (node != null && !node.getNodeName().startsWith("_")) {
if (!node.getNodeName().equals("z")
&& (b.getAttribute(node.getNodeName()).length() == 0 || !b.getAttribute(
node.getNodeName()).equals(node.getNodeValue()))) {
return false;
}
} else {
customAttributeCounter++;
}
}
if (a.getAttributes().getLength() - customAttributeCounter != b.getAttributes().getLength()) {
return false;
}
return true;
}
private static boolean removeElements(final Element original, final Element proposed,
boolean originalDocumentChanged) {
final NodeList originalChildren = original.getChildNodes();
// Check original elements and compare to proposed to find out if we
// need to remove elements
for (int i = 0, n = originalChildren.getLength(); i < n; i++) {
final Node node = originalChildren.item(i);
if (node != null && node.getNodeType() == Node.ELEMENT_NODE) {
final Element originalElement = (Element) node;
final String originalId = originalElement.getAttribute("id");
if (originalId.length() != 0) {
// Only proposed elements with
// an id will be considered
final Element proposedElement =
XmlUtils.findFirstElement("//*[@id='" + originalId + "']", proposed);
if (null == proposedElement
&& (originalElement.getAttribute("z").equals(calculateUniqueKeyFor(originalElement)) || originalElement
.getAttribute("z").equals("?"))) {
// Remove original element given the proposed document
// has no element with a matching id
originalElement.getParentNode().removeChild(originalElement);
originalDocumentChanged = true;
}
}
// Walk through the document tree recursively
originalDocumentChanged =
removeElements(originalElement, proposed, originalDocumentChanged);
}
}
return originalDocumentChanged;
}
/**
* Creates a sha-1 hash value for the given data byte array.
*
* @param data to hash
* @return byte[] hash of the input data
*/
private static byte[] sha1(final byte[] data) {
Validate.notNull(digest, "Could not create hash key for identifier");
return digest.digest(data);
}
/**
* Constructor is private to prevent instantiation
*/
private XmlRoundTripUtils() {}
}