001package com.streamconverter.command.impl.xml;
002
003import com.streamconverter.command.AbstractStreamCommand;
004import com.streamconverter.command.rule.IRule;
005import com.streamconverter.path.TreePath;
006import com.streamconverter.security.SecureXmlConfiguration;
007import java.io.IOException;
008import java.io.InputStream;
009import java.io.OutputStream;
010import java.util.ArrayList;
011import java.util.List;
012import javax.xml.stream.XMLEventFactory;
013import javax.xml.stream.XMLEventReader;
014import javax.xml.stream.XMLEventWriter;
015import javax.xml.stream.XMLInputFactory;
016import javax.xml.stream.XMLOutputFactory;
017import javax.xml.stream.XMLStreamException;
018import javax.xml.stream.events.XMLEvent;
019import org.slf4j.Logger;
020import org.slf4j.LoggerFactory;
021
022/**
023 * XML Navigate Command for applying transformations to XML data.
024 *
025 * <p>This command navigates through XML structures and applies transformations using rules while
026 * preserving the overall XML structure. It identifies specific elements using TreePath
027 * slash-delimited path expressions (e.g., {@code product/name}) and applies {@link IRule}
028 * transformations to the text content of matching nodes.
029 *
030 * <p>The full XML event stream — including the XML declaration, all elements, attributes, and text
031 * nodes — is written to the output. Only character data at nodes whose path exactly matches the
032 * configured {@link TreePath} is transformed by the rule; all other events are passed through
033 * unchanged.
034 */
035public class XmlNavigateCommand extends AbstractStreamCommand {
036  private static final Logger LOGGER = LoggerFactory.getLogger(XmlNavigateCommand.class);
037  private static final XMLEventFactory EVENT_FACTORY = XMLEventFactory.newInstance();
038
039  private TreePath treePath;
040  private IRule rule;
041
042  /**
043   * Constructor for XML navigation with TreePath selector and transformation rule.
044   *
045   * @param treePath the TreePath to select elements
046   * @param rule the transformation rule to apply to selected elements
047   * @throws IllegalArgumentException if treePath or rule is null
048   */
049  private XmlNavigateCommand(TreePath treePath, IRule rule) {
050    this.treePath = treePath;
051    this.rule = rule;
052  }
053
054  /**
055   * Factory method for creating an XML navigation command with TreePath and rule.
056   *
057   * @param treePath the TreePath to select elements
058   * @param rule the transformation rule to apply to selected elements
059   * @return an XmlNavigateCommand that transforms the specified TreePath elements with the given
060   *     rule
061   * @throws IllegalArgumentException if treePath or rule is null
062   */
063  public static XmlNavigateCommand create(TreePath treePath, IRule rule) {
064    if (treePath == null) {
065      throw new IllegalArgumentException("TreePath cannot be null");
066    }
067    if (rule == null) {
068      throw new IllegalArgumentException("Rule cannot be null");
069    }
070    return new XmlNavigateCommand(treePath, rule);
071  }
072
073  @Override
074  public void execute(InputStream inputStream, OutputStream outputStream) throws IOException {
075    XMLEventReader eventReader = null;
076    XMLEventWriter eventWriter = null;
077    IOException primaryException = null;
078
079    try {
080      eventReader = createXMLEventReader(inputStream);
081      eventWriter = createXMLEventWriter(outputStream);
082      navigateXmlWithRule(eventReader, eventWriter, treePath, rule);
083      eventWriter.flush();
084    } catch (XMLStreamException e) {
085      primaryException = buildXmlException(e);
086      throw primaryException;
087    } finally {
088      closeResources(eventReader, eventWriter, primaryException);
089    }
090  }
091
092  private void navigateXmlWithRule(
093      XMLEventReader eventReader, XMLEventWriter eventWriter, TreePath treePath, IRule rule)
094      throws XMLStreamException {
095    List<String> currentPath = new ArrayList<>();
096
097    while (eventReader.hasNext()) {
098      XMLEvent event = eventReader.nextEvent();
099
100      if (event.isStartElement()) {
101        currentPath.add(event.asStartElement().getName().getLocalPart());
102      } else if (event.isEndElement()) {
103        if (!currentPath.isEmpty()) {
104          currentPath.remove(currentPath.size() - 1);
105        } else {
106          LOGGER.warn(
107              "Unexpected end element '{}' with empty path stack — possible malformed XML",
108              event.asEndElement().getName().getLocalPart());
109        }
110      } else if (event.isCharacters() && treePath.matches(currentPath)) {
111        String data = event.asCharacters().getData();
112        String transformed;
113        try {
114          transformed = rule.apply(data);
115        } catch (RuntimeException ruleEx) {
116          throw new XMLStreamException("Rule application failed at path " + currentPath, ruleEx);
117        }
118        // Write every event unconditionally; character events at the target path are replaced
119        // above.
120        event = EVENT_FACTORY.createCharacters(transformed);
121      }
122
123      eventWriter.add(event);
124    }
125  }
126
127  private XMLEventReader createXMLEventReader(InputStream inputStream) throws XMLStreamException {
128    XMLInputFactory inputFactory = SecureXmlConfiguration.createSecureXMLInputFactory();
129    XMLEventReader reader = inputFactory.createXMLEventReader(inputStream);
130    if (!reader.hasNext()) {
131      try {
132        reader.close();
133      } catch (XMLStreamException closeEx) {
134        LOGGER.warn("Failed to close empty XMLEventReader", closeEx);
135      }
136      throw new XMLStreamException("Empty XML input");
137    }
138    return reader;
139  }
140
141  private XMLEventWriter createXMLEventWriter(OutputStream outputStream) throws XMLStreamException {
142    XMLOutputFactory outputFactory = XMLOutputFactory.newInstance();
143    return outputFactory.createXMLEventWriter(outputStream);
144  }
145
146  private IOException buildXmlException(XMLStreamException e) {
147    String location = "";
148    if (e.getLocation() != null) {
149      int line = e.getLocation().getLineNumber();
150      int column = e.getLocation().getColumnNumber();
151      if (line > 0 && column > 0) {
152        location = String.format(" at line %d, column %d", line, column);
153      }
154    }
155    LOGGER.error("XML processing failed{}", location, e);
156    return new IOException("XML processing failed" + location, e);
157  }
158
159  private void closeResources(
160      XMLEventReader eventReader, XMLEventWriter eventWriter, IOException primaryException) {
161    if (eventWriter != null) {
162      try {
163        eventWriter.flush();
164      } catch (XMLStreamException e) {
165        LOGGER.warn("Failed to flush XMLEventWriter during cleanup", e);
166      }
167      try {
168        eventWriter.close();
169      } catch (XMLStreamException e) {
170        LOGGER.warn("Failed to close XMLEventWriter", e);
171      } catch (RuntimeException e) {
172        if (primaryException != null) {
173          primaryException.addSuppressed(e);
174        } else {
175          throw e;
176        }
177      }
178    }
179    if (eventReader != null) {
180      try {
181        eventReader.close();
182      } catch (XMLStreamException e) {
183        LOGGER.warn("Failed to close XMLEventReader", e);
184      } catch (RuntimeException e) {
185        if (primaryException != null) {
186          primaryException.addSuppressed(e);
187        } else {
188          throw e;
189        }
190      }
191    }
192  }
193}