XmlUtils.java

package dev.orne.config.impl;

/*-
 * #%L
 * Orne Config
 * %%
 * Copyright (C) 2019 - 2025 Orne Developments
 * %%
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Lesser Public License for more details.
 * 
 * You should have received a copy of the GNU General Lesser Public
 * License along with this program.  If not, see
 * <http://www.gnu.org/licenses/lgpl-3.0.html>.
 * #L%
 */

import java.io.StringWriter;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.xml.XMLConstants;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apiguardian.api.API;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Attr;
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;
import org.w3c.dom.Text;

import dev.orne.config.ConfigException;

/**
 * Utility class for XML operations.
 * 
 * @author <a href="https://github.com/ihernaez">(w) Iker Hernaez</a>
 * @version 1.0, 2025-07
 * @since 1.0
 */
@API(status = API.Status.INTERNAL, since = "1.0")
public final class XmlUtils {

    /** The class logger. */
    private static final Logger LOG = LoggerFactory.getLogger(XmlUtils.class);

    /** The XML transformer factory instance. */
    private static final TransformerFactory TRANSFORMER_FACTORY = TransformerFactory.newInstance();
    static {
        try {
            TRANSFORMER_FACTORY.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
        } catch (final TransformerConfigurationException e) {
            LOG.warn("Error setting secure processing feature on XML TransformerFactory", e);
        }
        try {
            TRANSFORMER_FACTORY.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
        } catch (final IllegalArgumentException e) {
            LOG.debug("Error disabling external DTD access on XML TransformerFactory", e);
        }
        try {
            TRANSFORMER_FACTORY.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
        } catch (final IllegalArgumentException e) {
            LOG.debug("Error disabling external stylesheet access on XML TransformerFactory", e);
        }
    }

    /**
     * Private constructor to prevent instantiation of this utility class.
     */
    private XmlUtils() {
        // Utility class
    }

    /**
     * Checks if the given XML document contains a specific property.
     * 
     * @param document The XML document to search within.
     * @param property The property name, which may include nested properties.
     * @param propertySeparator The separator used for nested properties.
     * @param attributePrefix The prefix used for attributes.
     * @return {@code true} if the property exists, {@code false} otherwise.
     */
    static boolean contains(
            final @NotNull Document document,
            final @NotNull String property,
            final @NotNull String propertySeparator,
            final @NotNull String attributePrefix) {
        final Element root = document.getDocumentElement();
        final Pair<List<String>, String> split = splitProperty(property, propertySeparator);
        final List<String> parts = split.getLeft();
        final String leaf = split.getRight();
        return findElement(root, parts)
                .flatMap(n -> findLeaf(n, leaf, attributePrefix))
                .isPresent();
    }

    /**
     * Extracts the keys from the XML document.
     * <p>
     * Recursively traverses the XML document, extracting keys
     * from the element's tag name (when element contains text content),
     * its attributes, and its child elements.
     * 
     * @param document The XML document to extract keys from.
     * @param propertySeparator The separator used for nested properties.
     * @param attributePrefix The prefix used for attributes.
     * @return A stream of keys extracted from the document.
     */
    static @NotNull Stream<String> extractKeys(
            final @NotNull Document document,
            final @NotNull String propertySeparator,
            final @NotNull String attributePrefix) {
        final NodeList children = document.getDocumentElement().getChildNodes();
        return IntStream.range(0, children.getLength())
                .mapToObj(children::item)
                .filter(Element.class::isInstance)
                .map(Element.class::cast)
                .flatMap(child -> extractKeys(
                        child,
                        "",
                        propertySeparator,
                        attributePrefix));
    }

    /**
     * Extracts the keys from the XML element and its children.
     * <p>
     * Recursively traverses the XML element, extracting keys
     * from the element's tag name (when element contains text content),
     * its attributes, and its child elements.
     * 
     * @param element The XML element to extract keys from.
     * @param prefix The prefix to prepend to the keys.
     * @return A stream of keys extracted from the element and its children.
     */
    static @NotNull Stream<String> extractKeys(
            final @NotNull Element element,
            final @NotNull String prefix,
            final @NotNull String propertySeparator,
            final @NotNull String attributePrefix) {
        final String value = XmlUtils.getValue(element);
        final Stream<String> elementKey;
        if (value == null) {
            elementKey = Stream.of();
        } else {
            elementKey = Stream.of(prefix + element.getTagName());
        }
        final String childPrefix = prefix + element.getTagName() + propertySeparator;
        final NamedNodeMap attributes = element.getAttributes();
        final Stream<String> attrKeys = IntStream.range(0, attributes.getLength())
                .mapToObj(attributes::item)
                .filter(Attr.class::isInstance)
                .map(node -> childPrefix + attributePrefix + node.getLocalName());
        final NodeList children = element.getChildNodes();
        final Stream<String> childKeys = IntStream.range(0, children.getLength())
                .mapToObj(children::item)
                .filter(Element.class::isInstance)
                .map(Element.class::cast)
                .flatMap(child -> extractKeys(child, childPrefix, propertySeparator, attributePrefix));
        return Stream.concat(
                Stream.concat(
                    elementKey,
                    attrKeys),
                childKeys);
    }

    /**
     * Returns the value of a property in the given XML document.
     * 
     * @param element The XML element to search within.
     * @param property The property name, which may include nested properties.
     * @param propertySeparator The separator used for nested properties.
     * @param attributePrefix The prefix used for attributes.
     * @return An Optional containing the value of the property, or empty if not found.
     */
    static Optional<String> getValue(
            final @NotNull Document document,
            final @NotNull String property,
            final @NotNull String propertySeparator,
            final @NotNull String attributePrefix) {
        final Element root = document.getDocumentElement();
        final Pair<List<String>, String> split = splitProperty(property, propertySeparator);
        final List<String> parts = split.getLeft();
        final String leaf = split.getRight();
        return findElement(root, parts)
                .flatMap(n -> findLeaf(n, leaf, attributePrefix))
                .map(XmlUtils::getLeafValue);
        
    }

    /**
     * Sets the value of a property in the given XML document.
     * If the value is null, it removes the leaf node value.
     * 
     * @param element The XML element to set the property in.
     * @param property The property name, which may include nested properties.
     * @param propertySeparator The separator used for nested properties.
     * @param attributePrefix The prefix used for attributes.
     * @param value The value to set, or null to remove the leaf node.
     */
    static void setValue(
            final @NotNull Document document,
            final @NotNull String property,
            final @NotNull String propertySeparator,
            final @NotNull String attributePrefix,
            final String value) {
        final Element root = document.getDocumentElement();
        final Pair<List<String>, String> split = splitProperty(property, propertySeparator);
        final List<String> parts = split.getLeft();
        final String leaf = split.getRight();
        if (value == null) {
            findElement(root, parts)
                    .flatMap(n -> findLeaf(n, leaf, attributePrefix))
                    .ifPresent(XmlUtils::removeLeafValue);
        } else {
            setLeafValue(
                    getLeaf(
                        getElement(root, parts),
                        leaf,
                        attributePrefix),
                    value);
        }
    }

    /**
     * Returns an Optional containing the element for the given parent and a
     * list of parts representing the path to the element.
     * 
     * @param parent The parent element to search within.
     * @param parts The list of parts representing the path to the element.
     * @return An Optional containing the found element, or empty if not found.
     */
    static @NotNull Optional<Element> findElement(
            final @NotNull Element parent,
            final @NotNull List<String> parts) {
        if (parts.isEmpty()) {
            return Optional.of(parent);
        } else {
            final String childName = parts.get(0);
            final List<String> remainingParts = parts.subList(1, parts.size());
            return getChild(parent, childName)
                    .flatMap(child -> findElement(child, remainingParts));
        }
    }

    /**
     * Returns the element for the given parent and a list of parts.
     * If the element does not exist, it will be created.
     * 
     * @param parent The parent element to which the new child will be added.
     * @param parts The list of parts representing the path to the element.
     * @return The found or newly created element.
     */
    static @NotNull Element getElement(
            final @NotNull Element parent,
            final @NotNull List<String> parts) {
        if (parts.isEmpty()) {
            return parent;
        } else {
            final String childName = parts.get(0);
            final List<String> remainingParts = parts.subList(1, parts.size());
            return getElement(
                    getChild(parent, childName)
                        .orElseGet(() -> createChild(parent, childName)),
                    remainingParts);
        }
    }

    /**
     * Creates a new child element with the specified name, appending it to the
     * given parent element.
     * 
     * @param parent The parent element to which the new child will be added.
     * @param name The local name of the new child element.
     * @return The newly created child element.
     */
    static @NotNull Element createChild(
            final @NotNull Element parent,
            final @NotNull String name) {
        return createChild(parent, parent.getNamespaceURI(), name);
    }

    /**
     * Creates a new child element with the specified name and namespace,
     * appending it to the given parent element.
     * 
     * @param parent The parent element to which the new child will be added.
     * @param namespace The namespace URI for the new child element.
     * @param name The local name of the new child element.
     * @return The newly created child element.
     */
    static @NotNull Element createChild(
            final @NotNull Element parent,
            final String namespace,
            final @NotNull String name) {
        final Element child;
        if (namespace == null) {
            child = parent.getOwnerDocument().createElement(name);
        } else {
            child = parent.getOwnerDocument().createElementNS(namespace, name);
        }
        parent.appendChild(child);
        return child;
    }

    /**
     * Returns an Optional containing the first child element with the specified
     * name from the parent element, or an empty Optional if no such child exists.
     * 
     * @param parent The parent element to search for the child.
     * @param name The local name of the child element to find.
     * @return An Optional containing the found child element, or empty if not found.
     */
    static @NotNull Optional<Element> getChild(
            final @NotNull Element parent,
            final @NotNull String name) {
        final NodeList children = parent.getChildNodes();
        return IntStream.range(0, children.getLength())
                .mapToObj(children::item)
                .filter(Element.class::isInstance)
                .map(Element.class::cast)
                .filter(elem -> elem.getTagName().equals(name))
                .findFirst();
    }

    /**
     * Finds a leaf node (either an attribute or an element) for the given
     * parent element and name.
     * 
     * @param parent The parent element to which the leaf belongs.
     * @param name The name of the leaf node.
     * @param attributePrefix The prefix used to identify attributes.
     * @return An Optional containing the leaf node if found, or empty if not found.
     */
    static Optional<Node> findLeaf(
            final @NotNull Element parent,
            final @NotNull String name,
            final @NotNull String attributePrefix) {
        if (name.startsWith(attributePrefix)) {
            final String attrName = name.substring(attributePrefix.length());
            return Optional.ofNullable(parent.getAttributeNode(attrName));
        } else {
            return getChild(parent, name)
                    .map(Node.class::cast);
        }
    }

    /**
     * Returns a leaf node (either an attribute or an element) for the given
     * parent element and name. If the leaf does not exist, it will be created.
     * 
     * @param parent The parent element to which the leaf belongs.
     * @param name The name of the leaf node.
     * @param attributePrefix The prefix used to identify attributes.
     * @return The leaf node, either an attribute or an element.
     */
    static Node getLeaf(
            final @NotNull Element parent,
            final @NotNull String name,
            final @NotNull String attributePrefix) {
        if (name.startsWith(attributePrefix)) {
            final String attrName = name.substring(attributePrefix.length());
            Attr attr = parent.getAttributeNode(attrName);
            if (attr == null) {
                attr = parent.getOwnerDocument().createAttributeNS(parent.getNamespaceURI(), attrName);
                parent.setAttributeNode(attr);
            }
            return attr;
        } else {
            return getChild(parent, name)
                    .orElseGet(() -> createChild(parent, name));
        }
    }

    /**
     * Returns the value of the given XML node.
     * 
     * @param leaf The XML node to extract the value from.
     * @return The value of the node, or {@code null} if the node is not an
     * attribute or element with a text value.
     */
    static String getLeafValue(
            final @NotNull Node leaf) {
        if (leaf instanceof Attr) {
            return ((Attr) leaf).getValue();
        } else if (leaf instanceof Element) {
            return getValue((Element) leaf);
        } else {
            return null;
        }
    }

    /**
     * Sets the value of the given XML node.
     * 
     * @param leaf The XML node to set the value for.
     * @param value The value to set.
     */
    static void setLeafValue(
            final @NotNull Node leaf,
            final @NotNull String value) {
        if (leaf instanceof Attr) {
            ((Attr) leaf).setValue(value);
        } else if (leaf instanceof Element) {
            setValue((Element) leaf, value);
        }
    }

    /**
     * Removes the value from the given XML node.
     * 
     * @param leaf The XML node from which to remove the value.
     */
    static void removeLeafValue(
            final @NotNull Node leaf) {
        if (leaf instanceof Attr) {
            leaf.getParentNode().removeChild(leaf);
        } else if (leaf instanceof Element) {
            removeValue((Element) leaf);
        }
    }

    /**
     * Returns the text value of the given XML element.
     * 
     * @param element The XML element to extract the value from.
     * @return The trimmed text value of the element, or {@code null} if no text is found.
     */
    static String getValue(
            final @NotNull Element element) {
        final NodeList children = element.getChildNodes();
        final String value = IntStream.range(0, children.getLength())
                .mapToObj(children::item)
                .filter(Text.class::isInstance)
                .map(Node::getNodeValue)
                .filter(Objects::nonNull)
                .collect(Collectors.joining());
        if (StringUtils.isAllBlank(value)) {
            return null;
        } else {
            return value.trim();
        }
    }

    /**
     * Sets the text value of the given XML element.
     * 
     * @param element The XML element to set the value for.
     * @param value The value to set.
     */
    static void setValue(
            final @NotNull Element element,
            final @NotNull String value) {
        removeValue(element);
        final Text textNode = element.getOwnerDocument().createTextNode(value);
        element.appendChild(textNode);
    }

    /**
     * Removes all text nodes from the given XML element.
     * 
     * @param element The XML element from which to remove text nodes.
     */
    static void removeValue(
            final @NotNull Element element) {
        final NodeList children = element.getChildNodes();
        IntStream.range(0, children.getLength())
                .mapToObj(children::item)
                .filter(Text.class::isInstance)
                .forEach(element::removeChild);
    }

    /**
     * Splits a property string into its parent elements' names and
     * leaf node name.
     * 
     * @param property The property string to split.
     * @param propertySeparator The separator used to split the property.
     * @return A pair containing the list of parent elements' names and the
     * leaf node name.
     * @throws IllegalArgumentException If the property is null or empty.
     */
    static @NotNull Pair<List<String>, String> splitProperty(
            final @NotBlank String property,
            final @NotBlank String propertySeparator) {
        final List<String> parts = Arrays.asList(
                StringUtils.splitByWholeSeparator(property, propertySeparator));
        if (parts.isEmpty()) {
            throw new IllegalArgumentException("Property must not be null or empty");
        }
        final String leaf = parts.get(parts.size() - 1);
        return Pair.of(parts.subList(0, parts.size() - 1), leaf);
    }

    /**
     * Converts the given XML document to its string representation.
     * 
     * @param document The XML document to convert.
     * @return The string representation of the XML document.
     * @throws ConfigException If an error occurs during the transformation.
     */
    static @NotNull String getXml(
            final @NotNull Document document) {
        try {
            final Transformer transformer = TRANSFORMER_FACTORY.newTransformer();
            final DOMSource source = new DOMSource(document);
            final StringWriter writer = new StringWriter();
            transformer.transform(source, new StreamResult(writer));
            return writer.toString();
        } catch (final TransformerException e) {
            throw new ConfigException("Error converting XML document to string", e);
        }
    }
}