Last active
April 23, 2026 16:15
-
-
Save robsonkades/203d1c4dc1fa6a15c58c69c2d277bd68 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| https://medium.com/javarevisited/one-endpoint-multiple-payloads-handling-polymorphic-request-bodies-in-spring-boot-4e354b72450c | |
| https://medium.com/@khanjani.hamid/advanced-jvm-heap-tuning-and-kubernetes-optimization-for-high-performance-java-applications-998b6604c70a | |
| import com.ctc.wstx.api.WstxInputProperties; | |
| import com.ctc.wstx.stax.WstxInputFactory; | |
| import com.ctc.wstx.stax.WstxOutputFactory; | |
| import com.fleekdocs.infrastructure.exception.XmlException; | |
| import javax.xml.stream.XMLInputFactory; | |
| import javax.xml.stream.XMLStreamConstants; | |
| import javax.xml.stream.XMLStreamException; | |
| import javax.xml.stream.XMLStreamReader; | |
| import javax.xml.stream.XMLStreamWriter; | |
| import java.io.StringReader; | |
| import java.io.StringWriter; | |
| import java.math.BigDecimal; | |
| import java.time.Instant; | |
| import java.util.ArrayDeque; | |
| import java.util.ArrayList; | |
| import java.util.Collections; | |
| import java.util.Deque; | |
| import java.util.LinkedHashMap; | |
| import java.util.List; | |
| import java.util.Map; | |
| import java.util.Objects; | |
| import java.util.Optional; | |
| import java.util.concurrent.CompletableFuture; | |
| import java.util.concurrent.Executor; | |
| import java.util.function.Function; | |
| /** | |
| * Safe StAX-based XML helper for lightweight queries and structural edits. | |
| * <p> | |
| * Prefer {@link #document(String)} and {@link XmlDocument#select(String...)} | |
| * for new code. The extract* methods are kept as compatibility shortcuts. | |
| * This utility intentionally avoids DOM loading and disables DTD/external | |
| * entity support in the underlying Woodstox parser. | |
| */ | |
| public final class XmlUtil { | |
| private static final int MAX_TEXT_LENGTH = 1_000_000; | |
| private static final ThreadLocal<WstxInputFactory> INPUT_FACTORY = ThreadLocal.withInitial(() -> { | |
| WstxInputFactory factory = new WstxInputFactory(); | |
| factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); | |
| factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); | |
| factory.setProperty(WstxInputProperties.P_MAX_TEXT_LENGTH, MAX_TEXT_LENGTH); | |
| factory.setProperty(WstxInputProperties.P_MAX_ELEMENT_DEPTH, 500); | |
| return factory; | |
| }); | |
| private static final ThreadLocal<WstxOutputFactory> OUTPUT_FACTORY = ThreadLocal.withInitial(WstxOutputFactory::new); | |
| private XmlUtil() { | |
| } | |
| /** | |
| * Produces a compact XML string while preserving structural content such as | |
| * attributes, namespaces, comments, processing instructions, and CDATA. | |
| * <p> | |
| * Blank input is returned unchanged. Malformed XML or parser limit | |
| * violations are reported as {@link XmlException}. | |
| * | |
| * @param xml XML payload to compact | |
| * @return compact XML, or the original value when the input is {@code null} | |
| * or blank | |
| */ | |
| public static String minify(final String xml) { | |
| if (xml == null || xml.isBlank()) return xml; | |
| return withReader(xml, reader -> { | |
| final var sw = new StringWriter(xml.length()); | |
| final var writer = OUTPUT_FACTORY.get().createXMLStreamWriter(sw); | |
| try { | |
| while (reader.hasNext()) { | |
| int event = reader.next(); | |
| copyEvent(reader, writer, event); | |
| } | |
| writer.flush(); | |
| return sw.toString(); | |
| } finally { | |
| writer.close(); | |
| } | |
| }); | |
| } | |
| /** | |
| * Compatibility factory for the previous fluent facade. | |
| * <p> | |
| * New code should prefer {@link #document(String)}, which exposes the same | |
| * query/edit operations under a clearer library-oriented name. | |
| * | |
| * @param xml XML payload to query or edit | |
| * @return immutable XML facade | |
| */ | |
| public static FluentXml from(final String xml) { | |
| return new FluentXml(xml); | |
| } | |
| /** | |
| * Creates an immutable query facade over an XML payload. | |
| * | |
| * @param xml XML payload to query or edit | |
| * @return immutable XML document facade | |
| */ | |
| public static XmlDocument document(final String xml) { | |
| return new XmlDocument(xml); | |
| } | |
| /** | |
| * Creates an immutable editor for structural XML insertions. | |
| * | |
| * @param xml source XML payload | |
| * @return immutable editor facade | |
| */ | |
| public static XmlEditor edit(final String xml) { | |
| return XmlEditor.from(xml); | |
| } | |
| /** | |
| * Selects nodes using descendant path semantics. | |
| * <p> | |
| * Descendant selection allows unrelated intermediate elements between the | |
| * provided segments, which preserves the legacy extract* behavior. | |
| * | |
| * @param xml XML payload to query | |
| * @param path element local-name segments | |
| * @return lazy materialization facade for the selected nodes | |
| */ | |
| public static XmlSelection select(final String xml, final String... path) { | |
| return select(xml, XmlPath.descendant(path)); | |
| } | |
| /** | |
| * Selects XML nodes using an {@link XmlPath}. The returned selection can be | |
| * materialized as text, XML fragments, attributes, or typed values. | |
| * | |
| * @param xml XML payload to query | |
| * @param path query path | |
| * @return lazy materialization facade for the selected nodes | |
| */ | |
| public static XmlSelection select(final String xml, final XmlPath path) { | |
| return new XmlSelection(xml, path, null); | |
| } | |
| /** | |
| * Extracts the text value of the first matching element using descendant | |
| * path semantics. | |
| * | |
| * @param xml XML payload to query | |
| * @param path element local-name segments | |
| * @return selected text, or {@link Optional#empty()} when no element matches | |
| */ | |
| public static Optional<String> extractValue(final String xml, final String... path) { | |
| if (xml == null || xml.isBlank()) return Optional.empty(); | |
| return extractValue(xml, XmlPath.descendant(path)); | |
| } | |
| /** | |
| * Extracts the text value of the first matching element. | |
| * | |
| * @param xml XML payload to query | |
| * @param path query path | |
| * @return selected text, or {@link Optional#empty()} when no element matches | |
| */ | |
| public static Optional<String> extractValue(final String xml, final XmlPath path) { | |
| if (xml == null || xml.isBlank()) return Optional.empty(); | |
| final var safePath = Objects.requireNonNull(path, "path nao pode ser nulo"); | |
| return withReader(xml, reader -> { | |
| if (navigateToPath(reader, safePath)) { | |
| return Optional.of(extractText(reader)); | |
| } | |
| return Optional.empty(); | |
| }); | |
| } | |
| /** | |
| * Extracts text values from all matching elements using descendant path | |
| * semantics. | |
| * | |
| * @param xml XML payload to query | |
| * @param path element local-name segments | |
| * @return immutable list of selected text values | |
| */ | |
| public static List<String> extractValues(final String xml, final String... path) { | |
| if (xml == null || xml.isBlank()) return List.of(); | |
| return extractValues(xml, XmlPath.descendant(path)); | |
| } | |
| /** | |
| * Extracts text values from matching elements. | |
| * | |
| * @param xml XML payload to query | |
| * @param path query path | |
| * @return immutable list of selected text values | |
| */ | |
| public static List<String> extractValues(final String xml, final XmlPath path) { | |
| if (xml == null || xml.isBlank()) return List.of(); | |
| final var safePath = Objects.requireNonNull(path, "path nao pode ser nulo"); | |
| return withReader(xml, reader -> { | |
| final var values = new ArrayList<String>(); | |
| collectPathMatches(reader, safePath, matchedReader -> values.add(extractText(matchedReader))); | |
| return List.copyOf(values); | |
| }); | |
| } | |
| /** | |
| * Extracts the attribute value of the first matching element using | |
| * descendant path semantics. | |
| * | |
| * @param xml XML payload to query | |
| * @param attributeName attribute local name | |
| * @param path element local-name segments | |
| * @return selected attribute value, or {@link Optional#empty()} when absent | |
| */ | |
| public static Optional<String> extractAttribute(final String xml, final String attributeName, final String... path) { | |
| if (xml == null || xml.isBlank()) return Optional.empty(); | |
| final var safePath = XmlPath.descendant(path); | |
| return extractAttribute(xml, attributeName, safePath); | |
| } | |
| /** | |
| * Extracts the attribute value of the first matching element. | |
| * | |
| * @param xml XML payload to query | |
| * @param attributeName attribute local name | |
| * @param path query path | |
| * @return selected attribute value, or {@link Optional#empty()} when absent | |
| */ | |
| public static Optional<String> extractAttribute(final String xml, final String attributeName, final XmlPath path) { | |
| if (xml == null || xml.isBlank()) return Optional.empty(); | |
| final var safePath = Objects.requireNonNull(path, "path nao pode ser nulo"); | |
| final var safeAttributeName = requireNonBlank(attributeName, "attributeName"); | |
| return withReader(xml, reader -> { | |
| if (navigateToPath(reader, safePath)) { | |
| return Optional.ofNullable(reader.getAttributeValue(null, safeAttributeName)); | |
| } | |
| return Optional.empty(); | |
| }); | |
| } | |
| /** | |
| * Extracts attribute values from all matching elements using descendant path | |
| * semantics. | |
| * | |
| * @param xml XML payload to query | |
| * @param attributeName attribute local name | |
| * @param path element local-name segments | |
| * @return immutable list of present attribute values | |
| */ | |
| public static List<String> extractAttributes(final String xml, final String attributeName, final String... path) { | |
| if (xml == null || xml.isBlank()) return List.of(); | |
| return extractAttributes(xml, attributeName, XmlPath.descendant(path)); | |
| } | |
| /** | |
| * Extracts attribute values from matching elements. | |
| * | |
| * @param xml XML payload to query | |
| * @param attributeName attribute local name | |
| * @param path query path | |
| * @return immutable list of present attribute values | |
| */ | |
| public static List<String> extractAttributes(final String xml, final String attributeName, final XmlPath path) { | |
| if (xml == null || xml.isBlank()) return List.of(); | |
| final var safePath = Objects.requireNonNull(path, "path nao pode ser nulo"); | |
| final var safeAttributeName = requireNonBlank(attributeName, "attributeName"); | |
| return withReader(xml, reader -> { | |
| final var values = new ArrayList<String>(); | |
| collectPathMatches(reader, safePath, matchedReader -> { | |
| final var value = matchedReader.getAttributeValue(null, safeAttributeName); | |
| if (value != null) { | |
| values.add(value); | |
| } | |
| skipCurrentElement(matchedReader); | |
| }); | |
| return List.copyOf(values); | |
| }); | |
| } | |
| /** | |
| * Extracts the first matching element as an XML fragment using descendant | |
| * path semantics. | |
| * | |
| * @param xml XML payload to query | |
| * @param path element local-name segments | |
| * @return selected XML fragment, or {@link Optional#empty()} when absent | |
| */ | |
| public static Optional<String> extractElementXml(final String xml, final String... path) { | |
| if (xml == null || xml.isBlank()) return Optional.empty(); | |
| return extractElementXml(xml, XmlPath.descendant(path)); | |
| } | |
| /** | |
| * Extracts the first matching element as an XML fragment. | |
| * <p> | |
| * The fragment writer preserves CDATA and declares inherited namespaces | |
| * needed by the extracted element. | |
| * | |
| * @param xml XML payload to query | |
| * @param path query path | |
| * @return selected XML fragment, or {@link Optional#empty()} when absent | |
| */ | |
| public static Optional<String> extractElementXml(final String xml, final XmlPath path) { | |
| if (xml == null || xml.isBlank()) return Optional.empty(); | |
| final var safePath = Objects.requireNonNull(path, "path nao pode ser nulo"); | |
| return withReader(xml, reader -> { | |
| if (!navigateToPath(reader, safePath)) { | |
| return Optional.empty(); | |
| } | |
| final var sw = new StringWriter(); | |
| final var writer = OUTPUT_FACTORY.get().createXMLStreamWriter(sw); | |
| try { | |
| writeCurrentElement(reader, writer); | |
| writer.flush(); | |
| return Optional.of(sw.toString()); | |
| } finally { | |
| writer.close(); | |
| } | |
| }); | |
| } | |
| /** | |
| * Extracts matching elements as XML fragments using descendant path | |
| * semantics. | |
| * | |
| * @param xml XML payload to query | |
| * @param path element local-name segments | |
| * @return immutable list of selected XML fragments | |
| */ | |
| public static List<String> extractElementsXml(final String xml, final String... path) { | |
| if (xml == null || xml.isBlank()) return List.of(); | |
| return extractElementsXml(xml, XmlPath.descendant(path)); | |
| } | |
| /** | |
| * Extracts matching elements as XML fragments. | |
| * | |
| * @param xml XML payload to query | |
| * @param path query path | |
| * @return immutable list of selected XML fragments | |
| */ | |
| public static List<String> extractElementsXml(final String xml, final XmlPath path) { | |
| if (xml == null || xml.isBlank()) return List.of(); | |
| final var safePath = Objects.requireNonNull(path, "path nao pode ser nulo"); | |
| return withReader(xml, reader -> { | |
| final var values = new ArrayList<String>(); | |
| collectPathMatches(reader, safePath, matchedReader -> { | |
| final var sw = new StringWriter(); | |
| final var writer = OUTPUT_FACTORY.get().createXMLStreamWriter(sw); | |
| try { | |
| writeCurrentElement(matchedReader, writer); | |
| writer.flush(); | |
| values.add(sw.toString()); | |
| } finally { | |
| writer.close(); | |
| } | |
| }); | |
| return List.copyOf(values); | |
| }); | |
| } | |
| /** | |
| * Extracts matching element fragments and maps each fragment to a domain | |
| * object or DTO. | |
| * | |
| * @param xml XML payload to query | |
| * @param path query path for repeated elements | |
| * @param mapper mapper that receives each matched element XML fragment | |
| * @param <T> mapped result type | |
| * @return immutable list of mapped values | |
| */ | |
| public static <T> List<T> extractEach( | |
| final String xml, | |
| final XmlPath path, | |
| final Function<String, T> mapper | |
| ) { | |
| if (xml == null || xml.isBlank()) return List.of(); | |
| final var safeMapper = Objects.requireNonNull(mapper, "mapper nao pode ser nulo"); | |
| final var elements = extractElementsXml(xml, path); | |
| final var mapped = new ArrayList<T>(elements.size()); | |
| for (String element : elements) { | |
| mapped.add(safeMapper.apply(element)); | |
| } | |
| return List.copyOf(mapped); | |
| } | |
| private static void writeCurrentElement(XMLStreamReader reader, XMLStreamWriter writer) throws XMLStreamException { | |
| final var namespaceScopes = new ArrayDeque<Map<String, String>>(); | |
| writeFragmentStartElement(reader, writer, namespaceScopes); | |
| int depth = 1; | |
| while (reader.hasNext() && depth > 0) { | |
| int event = reader.next(); | |
| copyFragmentEvent(reader, writer, event, namespaceScopes); | |
| if (event == XMLStreamConstants.START_ELEMENT) { | |
| depth++; | |
| } else if (event == XMLStreamConstants.END_ELEMENT) { | |
| depth--; | |
| } | |
| } | |
| } | |
| private static void copyFragmentEvent(final XMLStreamReader reader, final XMLStreamWriter writer, final int event, final Deque<Map<String, String>> namespaceScopes) throws XMLStreamException { | |
| switch (event) { | |
| case XMLStreamConstants.START_ELEMENT -> writeFragmentStartElement(reader, writer, namespaceScopes); | |
| case XMLStreamConstants.CHARACTERS -> { | |
| if (!reader.isWhiteSpace()) writer.writeCharacters(reader.getText()); | |
| } | |
| case XMLStreamConstants.CDATA -> writer.writeCData(reader.getText()); | |
| case XMLStreamConstants.END_ELEMENT -> { | |
| writer.writeEndElement(); | |
| namespaceScopes.removeLast(); | |
| } | |
| case XMLStreamConstants.COMMENT -> writer.writeComment(reader.getText()); | |
| case XMLStreamConstants.PROCESSING_INSTRUCTION -> writer.writeProcessingInstruction( | |
| reader.getPITarget(), | |
| reader.getPIData() | |
| ); | |
| default -> { | |
| } | |
| } | |
| } | |
| private static void writeFragmentStartElement( | |
| final XMLStreamReader reader, | |
| final XMLStreamWriter writer, | |
| final Deque<Map<String, String>> namespaceScopes | |
| ) throws XMLStreamException { | |
| final var namespace = reader.getNamespaceURI(); | |
| final var prefix = safePrefix(reader.getPrefix()); | |
| final var declarations = new LinkedHashMap<String, String>(); | |
| if (namespace != null) { | |
| writer.writeStartElement(prefix, reader.getLocalName(), namespace); | |
| } else { | |
| writer.writeStartElement(reader.getLocalName()); | |
| } | |
| for (int i = 0; i < reader.getNamespaceCount(); i++) { | |
| final var namespacePrefix = safePrefix(reader.getNamespacePrefix(i)); | |
| final var namespaceUri = reader.getNamespaceURI(i); | |
| writer.writeNamespace(namespacePrefix, namespaceUri); | |
| declarations.put(namespacePrefix, namespaceUri); | |
| } | |
| writeNamespaceIfMissing(writer, namespaceScopes, declarations, prefix, namespace); | |
| for (int i = 0; i < reader.getAttributeCount(); i++) { | |
| final var attributeNamespace = reader.getAttributeNamespace(i); | |
| final var attributePrefix = safePrefix(reader.getAttributePrefix(i)); | |
| if (attributeNamespace != null && !attributePrefix.isBlank()) { | |
| writeNamespaceIfMissing(writer, namespaceScopes, declarations, attributePrefix, attributeNamespace); | |
| } | |
| } | |
| for (int i = 0; i < reader.getAttributeCount(); i++) { | |
| final var attributeNamespace = reader.getAttributeNamespace(i); | |
| final var attributePrefix = safePrefix(reader.getAttributePrefix(i)); | |
| if (attributeNamespace != null) { | |
| writer.writeAttribute(attributePrefix, attributeNamespace, reader.getAttributeLocalName(i), reader.getAttributeValue(i)); | |
| } else { | |
| writer.writeAttribute(reader.getAttributeLocalName(i), reader.getAttributeValue(i)); | |
| } | |
| } | |
| namespaceScopes.addLast(declarations); | |
| } | |
| private static void writeNamespaceIfMissing( | |
| final XMLStreamWriter writer, | |
| final Deque<Map<String, String>> namespaceScopes, | |
| final Map<String, String> currentDeclarations, | |
| final String prefix, | |
| final String namespace | |
| ) throws XMLStreamException { | |
| final var safePrefix = safePrefix(prefix); | |
| if (namespace == null || namespace.isBlank() || isReservedNamespace(safePrefix)) { | |
| return; | |
| } | |
| if (namespace.equals(currentDeclarations.get(safePrefix)) | |
| || isNamespaceInScope(namespaceScopes, safePrefix, namespace)) { | |
| return; | |
| } | |
| writer.writeNamespace(safePrefix, namespace); | |
| currentDeclarations.put(safePrefix, namespace); | |
| } | |
| private static boolean isNamespaceInScope( | |
| final Deque<Map<String, String>> namespaceScopes, | |
| final String prefix, | |
| final String namespace | |
| ) { | |
| final var iterator = namespaceScopes.descendingIterator(); | |
| while (iterator.hasNext()) { | |
| final var scope = iterator.next(); | |
| if (scope.containsKey(prefix)) { | |
| return namespace.equals(scope.get(prefix)); | |
| } | |
| } | |
| return false; | |
| } | |
| private static boolean isReservedNamespace(final String prefix) { | |
| return "xml".equals(prefix) || "xmlns".equals(prefix); | |
| } | |
| private static void skipCurrentElement(final XMLStreamReader reader) throws XMLStreamException { | |
| int depth = 1; | |
| while (reader.hasNext() && depth > 0) { | |
| final var event = reader.next(); | |
| if (event == XMLStreamConstants.START_ELEMENT) { | |
| depth++; | |
| } else if (event == XMLStreamConstants.END_ELEMENT) { | |
| depth--; | |
| } | |
| } | |
| } | |
| private static boolean navigateToPath(final XMLStreamReader reader, final XmlPath path) throws XMLStreamException { | |
| return switch (path.mode()) { | |
| case DIRECT -> navigateToDirectPath(reader, path); | |
| case DESCENDANT -> navigateToDescendantPath(reader, path); | |
| }; | |
| } | |
| private static boolean navigateToDescendantPath(final XMLStreamReader reader, final XmlPath path) throws XMLStreamException { | |
| final var segments = path.segmentsArray(); | |
| int currentDepth = 0; | |
| int matches = 0; | |
| while (reader.hasNext()) { | |
| int event = reader.next(); | |
| if (event == XMLStreamConstants.START_ELEMENT) { | |
| if (reader.getLocalName().equals(segments[currentDepth])) { | |
| if (currentDepth == segments.length - 1) { | |
| matches++; | |
| if (matches == path.occurrence()) { | |
| return true; | |
| } | |
| skipCurrentElement(reader); | |
| } else { | |
| currentDepth++; | |
| } | |
| } | |
| } else if (event == XMLStreamConstants.END_ELEMENT) { | |
| if (currentDepth > 0 && reader.getLocalName().equals(segments[currentDepth - 1])) { | |
| currentDepth--; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| private static boolean navigateToDirectPath(final XMLStreamReader reader, final XmlPath path) throws XMLStreamException { | |
| final var currentPath = new ArrayDeque<String>(); | |
| int matches = 0; | |
| while (reader.hasNext()) { | |
| final var event = reader.next(); | |
| if (event == XMLStreamConstants.START_ELEMENT) { | |
| currentPath.addLast(reader.getLocalName()); | |
| if (path.matches(currentPath)) { | |
| matches++; | |
| if (matches == path.occurrence()) { | |
| return true; | |
| } | |
| } | |
| } else if (event == XMLStreamConstants.END_ELEMENT) { | |
| if (!currentPath.isEmpty()) { | |
| currentPath.removeLast(); | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| private static void collectPathMatches( | |
| final XMLStreamReader reader, | |
| final XmlPath path, | |
| final MatchedElementAction action | |
| ) throws XMLStreamException { | |
| switch (path.mode()) { | |
| case DIRECT -> collectDirectPathMatches(reader, path, action); | |
| case DESCENDANT -> collectDescendantPathMatches(reader, path, action); | |
| } | |
| } | |
| private static void collectDescendantPathMatches( | |
| final XMLStreamReader reader, | |
| final XmlPath path, | |
| final MatchedElementAction action | |
| ) throws XMLStreamException { | |
| final var segments = path.segmentsArray(); | |
| int currentDepth = 0; | |
| int matches = 0; | |
| while (reader.hasNext()) { | |
| final var event = reader.next(); | |
| if (event == XMLStreamConstants.START_ELEMENT) { | |
| if (reader.getLocalName().equals(segments[currentDepth])) { | |
| if (currentDepth == segments.length - 1) { | |
| matches++; | |
| if (path.shouldCollect(matches)) { | |
| action.execute(reader); | |
| if (path.occurrenceSpecified()) { | |
| return; | |
| } | |
| } else { | |
| skipCurrentElement(reader); | |
| } | |
| } else { | |
| currentDepth++; | |
| } | |
| } | |
| } else if (event == XMLStreamConstants.END_ELEMENT) { | |
| if (currentDepth > 0 && reader.getLocalName().equals(segments[currentDepth - 1])) { | |
| currentDepth--; | |
| } | |
| } | |
| } | |
| } | |
| private static void collectDirectPathMatches( | |
| final XMLStreamReader reader, | |
| final XmlPath path, | |
| final MatchedElementAction action | |
| ) throws XMLStreamException { | |
| final var currentPath = new ArrayDeque<String>(); | |
| int matches = 0; | |
| while (reader.hasNext()) { | |
| final var event = reader.next(); | |
| if (event == XMLStreamConstants.START_ELEMENT) { | |
| currentPath.addLast(reader.getLocalName()); | |
| if (path.matches(currentPath)) { | |
| matches++; | |
| if (path.shouldCollect(matches)) { | |
| action.execute(reader); | |
| currentPath.removeLast(); | |
| if (path.occurrenceSpecified()) { | |
| return; | |
| } | |
| } else { | |
| skipCurrentElement(reader); | |
| currentPath.removeLast(); | |
| } | |
| } | |
| } else if (event == XMLStreamConstants.END_ELEMENT) { | |
| if (!currentPath.isEmpty()) { | |
| currentPath.removeLast(); | |
| } | |
| } | |
| } | |
| } | |
| private static String extractText(XMLStreamReader reader) throws XMLStreamException { | |
| final var text = new StringBuilder(); | |
| int depth = 1; | |
| while (reader.hasNext() && depth > 0) { | |
| int event = reader.next(); | |
| if (event == XMLStreamConstants.CHARACTERS || event == XMLStreamConstants.CDATA) { | |
| text.append(reader.getText()); | |
| } else if (event == XMLStreamConstants.START_ELEMENT) { | |
| depth++; | |
| } else if (event == XMLStreamConstants.END_ELEMENT) { | |
| depth--; | |
| } | |
| } | |
| return text.toString().trim(); | |
| } | |
| private static String applyInsertions(String xml, List<InsertOperation> operations) { | |
| if (xml == null || xml.isBlank()) return xml; | |
| if (operations == null || operations.isEmpty()) return xml; | |
| return withReader(xml, reader -> { | |
| List<InsertState> states = new ArrayList<>(operations.size()); | |
| for (InsertOperation operation : operations) { | |
| states.add(new InsertState(operation)); | |
| } | |
| Deque<String> currentPath = new ArrayDeque<>(); | |
| int depth = 0; | |
| StringWriter sw = new StringWriter(xml.length() + (operations.size() * 96)); | |
| XMLStreamWriter writer = OUTPUT_FACTORY.get().createXMLStreamWriter(sw); | |
| try { | |
| while (reader.hasNext()) { | |
| int event = reader.next(); | |
| switch (event) { | |
| case XMLStreamConstants.START_ELEMENT -> { | |
| depth++; | |
| final var localName = reader.getLocalName(); | |
| currentPath.addLast(localName); | |
| for (InsertState state : states) { | |
| state.onStartElement(currentPath, depth, localName, writer); | |
| } | |
| if (!isInsideSkippedSubtree(states, depth)) { | |
| writeStartElement(reader, writer); | |
| } | |
| } | |
| case XMLStreamConstants.END_ELEMENT -> { | |
| boolean skipCurrentEnd = isInsideSkippedSubtree(states, depth); | |
| if (!skipCurrentEnd) { | |
| for (InsertState state : states) { | |
| state.beforeEndElement(depth, writer); | |
| } | |
| writer.writeEndElement(); | |
| for (InsertState state : states) { | |
| state.afterEndElement(depth, writer); | |
| } | |
| } | |
| for (InsertState state : states) { | |
| state.onEndElement(depth); | |
| } | |
| currentPath.removeLast(); | |
| depth--; | |
| } | |
| case XMLStreamConstants.CHARACTERS, XMLStreamConstants.SPACE -> { | |
| if (!isInsideSkippedSubtree(states, depth)) { | |
| writer.writeCharacters(reader.getText()); | |
| } | |
| } | |
| case XMLStreamConstants.CDATA -> { | |
| if (!isInsideSkippedSubtree(states, depth)) { | |
| writer.writeCData(reader.getText()); | |
| } | |
| } | |
| case XMLStreamConstants.COMMENT -> { | |
| if (!isInsideSkippedSubtree(states, depth)) { | |
| writer.writeComment(reader.getText()); | |
| } | |
| } | |
| case XMLStreamConstants.PROCESSING_INSTRUCTION -> { | |
| if (!isInsideSkippedSubtree(states, depth)) { | |
| writer.writeProcessingInstruction(reader.getPITarget(), reader.getPIData()); | |
| } | |
| } | |
| default -> { | |
| // Ignora eventos que não mudam estrutura de tags (DTD, START_DOCUMENT, END_DOCUMENT, etc.) | |
| } | |
| } | |
| } | |
| writer.flush(); | |
| validateAppliedOperations(states); | |
| return sw.toString(); | |
| } finally { | |
| writer.close(); | |
| } | |
| }); | |
| } | |
| private static boolean isInsideSkippedSubtree(List<InsertState> states, int depth) { | |
| for (InsertState state : states) { | |
| if (state.isSkippingDepth(depth)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| private static void validateAppliedOperations(List<InsertState> states) { | |
| for (InsertState state : states) { | |
| if (!state.applied()) { | |
| throw new XmlException("Nao foi possível inserir tag em " + state.operation().path().description() + " (posicao=" + state.operation().position() + ")", null); | |
| } | |
| } | |
| } | |
| private static void writeStartElement(XMLStreamReader reader, XMLStreamWriter writer) throws XMLStreamException { | |
| final var namespace = reader.getNamespaceURI(); | |
| final var prefix = safePrefix(reader.getPrefix()); | |
| if (namespace != null) { | |
| writer.writeStartElement(prefix, reader.getLocalName(), namespace); | |
| } else { | |
| writer.writeStartElement(reader.getLocalName()); | |
| } | |
| for (int i = 0; i < reader.getNamespaceCount(); i++) { | |
| writer.writeNamespace(safePrefix(reader.getNamespacePrefix(i)), reader.getNamespaceURI(i)); | |
| } | |
| for (int i = 0; i < reader.getAttributeCount(); i++) { | |
| final var attributeNamespace = reader.getAttributeNamespace(i); | |
| final var attributePrefix = safePrefix(reader.getAttributePrefix(i)); | |
| if (attributeNamespace != null) { | |
| writer.writeAttribute(attributePrefix, attributeNamespace, reader.getAttributeLocalName(i), reader.getAttributeValue(i)); | |
| } else { | |
| writer.writeAttribute(reader.getAttributeLocalName(i), reader.getAttributeValue(i)); | |
| } | |
| } | |
| } | |
| private static String safePrefix(String prefix) { | |
| return prefix == null ? "" : prefix; | |
| } | |
| private static void writeNode(XMLStreamWriter writer, XmlNode node) throws XMLStreamException { | |
| writer.writeStartElement(node.name()); | |
| for (Map.Entry<String, String> attribute : node.attributes().entrySet()) { | |
| writer.writeAttribute(attribute.getKey(), attribute.getValue()); | |
| } | |
| if (node.value() != null) { | |
| writer.writeCharacters(node.value()); | |
| } | |
| for (XmlNode child : node.children()) { | |
| writeNode(writer, child); | |
| } | |
| writer.writeEndElement(); | |
| } | |
| private static void copyEvent(XMLStreamReader reader, XMLStreamWriter writer, int event) throws XMLStreamException { | |
| switch (event) { | |
| case XMLStreamConstants.START_ELEMENT -> writeStartElement(reader, writer); | |
| case XMLStreamConstants.CHARACTERS -> { | |
| if (!reader.isWhiteSpace()) writer.writeCharacters(reader.getText()); | |
| } | |
| case XMLStreamConstants.CDATA -> writer.writeCData(reader.getText()); | |
| case XMLStreamConstants.END_ELEMENT -> writer.writeEndElement(); | |
| case XMLStreamConstants.COMMENT -> writer.writeComment(reader.getText()); | |
| case XMLStreamConstants.PROCESSING_INSTRUCTION -> writer.writeProcessingInstruction(reader.getPITarget(), reader.getPIData()); | |
| default -> { | |
| } | |
| } | |
| } | |
| private static <T> T withReader(String xml, ReaderAction<T> action) { | |
| if (xml == null || xml.isBlank()) return null; | |
| try (StringReader sr = new StringReader(xml)) { | |
| final var reader = INPUT_FACTORY.get().createXMLStreamReader(sr); | |
| try { | |
| return action.execute(reader); | |
| } finally { | |
| reader.close(); | |
| } | |
| } catch (XMLStreamException e) { | |
| throw new XmlException("Erro Woodstox no processamento", e); | |
| } | |
| } | |
| private static String requireNonBlank(String value, String fieldName) { | |
| if (value == null || value.isBlank()) { | |
| throw new XmlException(fieldName + " nao pode ser nulo ou vazio"); | |
| } | |
| return value; | |
| } | |
| private static String[] requirePath(String[] path) { | |
| if (path == null || path.length == 0) { | |
| throw new XmlException("path precisa ter ao menos um elemento"); | |
| } | |
| String[] safePath = new String[path.length]; | |
| for (int i = 0; i < path.length; i++) { | |
| safePath[i] = requireNonBlank(path[i], "path[" + i + "]"); | |
| } | |
| return safePath; | |
| } | |
| private enum InsertPosition { | |
| CHILD, | |
| BEFORE, | |
| AFTER | |
| } | |
| private enum XmlPathMode { | |
| DIRECT, | |
| DESCENDANT | |
| } | |
| @FunctionalInterface | |
| private interface ReaderAction<T> { | |
| T execute(XMLStreamReader reader) throws XMLStreamException; | |
| } | |
| @FunctionalInterface | |
| private interface MatchedElementAction { | |
| void execute(XMLStreamReader reader) throws XMLStreamException; | |
| } | |
| /** | |
| * Lazy query result produced by {@link XmlUtil#select(String, XmlPath)} or | |
| * {@link XmlDocument#select(XmlPath)}. | |
| * <p> | |
| * A selection can be materialized as text, XML fragments, typed values, or | |
| * attributes. Materialization methods parse the source XML and return | |
| * immutable results. | |
| */ | |
| public static final class XmlSelection { | |
| private final String xml; | |
| private final XmlPath path; | |
| private final String attributeName; | |
| private XmlSelection(final String xml, final XmlPath path, final String attributeName) { | |
| this.xml = xml; | |
| this.path = Objects.requireNonNull(path, "path nao pode ser nulo"); | |
| this.attributeName = attributeName; | |
| } | |
| /** | |
| * Narrows this selection to an attribute on each matched element. | |
| * | |
| * @param attributeName attribute local name | |
| * @return attribute selection | |
| */ | |
| public XmlSelection attribute(final String attributeName) { | |
| return new XmlSelection(xml, path, requireNonBlank(attributeName, "attributeName")); | |
| } | |
| /** | |
| * Checks whether this selection resolves to at least one element or | |
| * attribute. | |
| * | |
| * @return {@code true} when a selected element or attribute exists | |
| */ | |
| public boolean exists() { | |
| if (attributeName != null) { | |
| return string().isPresent(); | |
| } | |
| return elementXml().isPresent(); | |
| } | |
| /** | |
| * Counts selected elements, or present selected attributes when the | |
| * selection was narrowed with {@link #attribute(String)}. | |
| * | |
| * @return number of selected elements or present attributes | |
| */ | |
| public int count() { | |
| if (attributeName != null) { | |
| return strings().size(); | |
| } | |
| return elementsXml().size(); | |
| } | |
| /** | |
| * Reads the first selected text or attribute value. | |
| * | |
| * @return selected value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> string() { | |
| if (attributeName != null) { | |
| return XmlUtil.extractAttribute(xml, attributeName, path); | |
| } | |
| return XmlUtil.extractValue(xml, path); | |
| } | |
| /** | |
| * Alias for {@link #string()} using XML terminology. | |
| * | |
| * @return selected text, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> text() { | |
| return string(); | |
| } | |
| /** | |
| * Alias for {@link #string()} using conversion-style naming. | |
| * | |
| * @return selected value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> asString() { | |
| return string(); | |
| } | |
| /** | |
| * Reads all selected text or attribute values. | |
| * | |
| * @return immutable list of selected values | |
| */ | |
| public List<String> strings() { | |
| if (attributeName != null) { | |
| return XmlUtil.extractAttributes(xml, attributeName, path); | |
| } | |
| return XmlUtil.extractValues(xml, path); | |
| } | |
| /** | |
| * Alias for {@link #strings()} using XML terminology. | |
| * | |
| * @return immutable list of selected text values | |
| */ | |
| public List<String> texts() { | |
| return strings(); | |
| } | |
| /** | |
| * Alias for {@link #strings()} using conversion-style naming. | |
| * | |
| * @return immutable list of selected values | |
| */ | |
| public List<String> asStrings() { | |
| return strings(); | |
| } | |
| /** | |
| * Converts the first selected value to {@link Integer}. | |
| * | |
| * @return converted value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<Integer> integer() { | |
| return as(Integer::valueOf, "Integer"); | |
| } | |
| /** | |
| * Alias for {@link #integer()}. | |
| * | |
| * @return converted value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<Integer> asInteger() { | |
| return integer(); | |
| } | |
| /** | |
| * Converts all selected values to {@link Integer}. | |
| * | |
| * @return immutable list of converted values | |
| */ | |
| public List<Integer> integers() { | |
| return listAs(Integer::valueOf, "Integer"); | |
| } | |
| /** | |
| * Alias for {@link #integers()}. | |
| * | |
| * @return immutable list of converted values | |
| */ | |
| public List<Integer> asIntegers() { | |
| return integers(); | |
| } | |
| /** | |
| * Converts the first selected value to {@link Long}. | |
| * | |
| * @return converted value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<Long> longValue() { | |
| return as(Long::valueOf, "Long"); | |
| } | |
| /** | |
| * Alias for {@link #longValue()}. | |
| * | |
| * @return converted value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<Long> asLong() { | |
| return longValue(); | |
| } | |
| /** | |
| * Converts all selected values to {@link Long}. | |
| * | |
| * @return immutable list of converted values | |
| */ | |
| public List<Long> longValues() { | |
| return listAs(Long::valueOf, "Long"); | |
| } | |
| /** | |
| * Alias for {@link #longValues()}. | |
| * | |
| * @return immutable list of converted values | |
| */ | |
| public List<Long> asLongs() { | |
| return longValues(); | |
| } | |
| /** | |
| * Converts the first selected value to {@link BigDecimal}. | |
| * | |
| * @return converted value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<BigDecimal> decimal() { | |
| return as(BigDecimal::new, "BigDecimal"); | |
| } | |
| /** | |
| * Alias for {@link #decimal()}. | |
| * | |
| * @return converted value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<BigDecimal> asDecimal() { | |
| return decimal(); | |
| } | |
| /** | |
| * Converts all selected values to {@link BigDecimal}. | |
| * | |
| * @return immutable list of converted values | |
| */ | |
| public List<BigDecimal> decimals() { | |
| return listAs(BigDecimal::new, "BigDecimal"); | |
| } | |
| /** | |
| * Alias for {@link #decimals()}. | |
| * | |
| * @return immutable list of converted values | |
| */ | |
| public List<BigDecimal> asDecimals() { | |
| return decimals(); | |
| } | |
| /** | |
| * Converts the first selected value to {@link Boolean}. | |
| * <p> | |
| * Accepted values are {@code true}, {@code false}, {@code 1}, and | |
| * {@code 0}, ignoring surrounding whitespace and case. | |
| * | |
| * @return converted value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<Boolean> bool() { | |
| return as(XmlSelection::parseBoolean, "Boolean"); | |
| } | |
| /** | |
| * Alias for {@link #bool()}. | |
| * | |
| * @return converted value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<Boolean> asBoolean() { | |
| return bool(); | |
| } | |
| /** | |
| * Converts all selected values to {@link Boolean}. | |
| * | |
| * @return immutable list of converted values | |
| */ | |
| public List<Boolean> booleans() { | |
| return listAs(XmlSelection::parseBoolean, "Boolean"); | |
| } | |
| /** | |
| * Alias for {@link #booleans()}. | |
| * | |
| * @return immutable list of converted values | |
| */ | |
| public List<Boolean> asBooleans() { | |
| return booleans(); | |
| } | |
| /** | |
| * Converts the first selected value to {@link Instant} using | |
| * {@link Instant#parse(CharSequence)}. | |
| * | |
| * @return converted value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<Instant> instant() { | |
| return as(Instant::parse, "Instant"); | |
| } | |
| /** | |
| * Alias for {@link #instant()}. | |
| * | |
| * @return converted value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<Instant> asInstant() { | |
| return instant(); | |
| } | |
| /** | |
| * Converts all selected values to {@link Instant}. | |
| * | |
| * @return immutable list of converted values | |
| */ | |
| public List<Instant> instants() { | |
| return listAs(Instant::parse, "Instant"); | |
| } | |
| /** | |
| * Alias for {@link #instants()}. | |
| * | |
| * @return immutable list of converted values | |
| */ | |
| public List<Instant> asInstants() { | |
| return instants(); | |
| } | |
| /** | |
| * Reads the first selected element as an XML fragment. | |
| * | |
| * @return selected XML fragment, or {@link Optional#empty()} when absent | |
| * @throws XmlException when this selection was narrowed to an attribute | |
| */ | |
| public Optional<String> elementXml() { | |
| requireElementSelection(); | |
| return XmlUtil.extractElementXml(xml, path); | |
| } | |
| /** | |
| * Alias for {@link #elementXml()}. | |
| * | |
| * @return selected XML fragment, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> xml() { | |
| return elementXml(); | |
| } | |
| /** | |
| * Reads the first selected element as an {@link XmlElement} wrapper. | |
| * | |
| * @return selected XML element, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<XmlElement> element() { | |
| return elementXml().map(XmlElement::of); | |
| } | |
| /** | |
| * Reads all selected elements as XML fragments. | |
| * | |
| * @return immutable list of selected XML fragments | |
| * @throws XmlException when this selection was narrowed to an attribute | |
| */ | |
| public List<String> elementsXml() { | |
| requireElementSelection(); | |
| return XmlUtil.extractElementsXml(xml, path); | |
| } | |
| /** | |
| * Alias for {@link #elementsXml()}. | |
| * | |
| * @return immutable list of selected XML fragments | |
| */ | |
| public List<String> xmls() { | |
| return elementsXml(); | |
| } | |
| /** | |
| * Reads all selected elements as {@link XmlElement} wrappers. | |
| * | |
| * @return immutable list of selected XML elements | |
| */ | |
| public List<XmlElement> elements() { | |
| final var elementXmls = elementsXml(); | |
| final var elements = new ArrayList<XmlElement>(elementXmls.size()); | |
| for (String elementXml : elementXmls) { | |
| elements.add(XmlElement.of(elementXml)); | |
| } | |
| return List.copyOf(elements); | |
| } | |
| /** | |
| * Converts the first selected value using a custom mapper. | |
| * | |
| * @param mapper value mapper | |
| * @param <T> mapped type | |
| * @return mapped value, or {@link Optional#empty()} when absent | |
| */ | |
| public <T> Optional<T> as(final Function<String, T> mapper) { | |
| return as(mapper, "custom"); | |
| } | |
| /** | |
| * Converts all selected values using a custom mapper. | |
| * | |
| * @param mapper value mapper | |
| * @param <T> mapped type | |
| * @return immutable list of mapped values | |
| */ | |
| public <T> List<T> listAs(final Function<String, T> mapper) { | |
| return listAs(mapper, "custom"); | |
| } | |
| private <T> Optional<T> as(final Function<String, T> mapper, final String targetType) { | |
| final var safeMapper = Objects.requireNonNull(mapper, "mapper nao pode ser nulo"); | |
| return string().map(value -> convertValue(value, safeMapper, targetType)); | |
| } | |
| private <T> List<T> listAs(final Function<String, T> mapper, final String targetType) { | |
| final var safeMapper = Objects.requireNonNull(mapper, "mapper nao pode ser nulo"); | |
| final var values = strings(); | |
| final var converted = new ArrayList<T>(values.size()); | |
| for (String value : values) { | |
| converted.add(convertValue(value, safeMapper, targetType)); | |
| } | |
| return List.copyOf(converted); | |
| } | |
| private void requireElementSelection() { | |
| if (attributeName != null) { | |
| throw new XmlException("elementXml nao pode ser usado depois de attribute"); | |
| } | |
| } | |
| private static <T> T convertValue( | |
| final String value, | |
| final Function<String, T> mapper, | |
| final String targetType | |
| ) { | |
| try { | |
| return mapper.apply(value); | |
| } catch (RuntimeException e) { | |
| throw new XmlException("Nao foi possivel converter valor XML para " + targetType, e); | |
| } | |
| } | |
| private static Boolean parseBoolean(final String value) { | |
| final var normalized = value.trim().toLowerCase(); | |
| return switch (normalized) { | |
| case "true", "1" -> true; | |
| case "false", "0" -> false; | |
| default -> throw new XmlException("Valor booleano invalido: " + value); | |
| }; | |
| } | |
| } | |
| /** | |
| * Wrapper around a selected XML fragment. | |
| * <p> | |
| * This type is useful when processing repeated blocks because it allows | |
| * querying the fragment again without exposing callers to the lower-level | |
| * extract* shortcuts. | |
| */ | |
| public static final class XmlElement { | |
| private final String xml; | |
| private XmlElement(final String xml) { | |
| this.xml = requireNonBlank(xml, "xml"); | |
| } | |
| /** | |
| * Wraps an XML fragment as an {@link XmlElement}. | |
| * | |
| * @param xml XML fragment | |
| * @return element wrapper | |
| */ | |
| public static XmlElement of(final String xml) { | |
| return new XmlElement(xml); | |
| } | |
| /** | |
| * Returns the wrapped XML fragment. | |
| * | |
| * @return XML fragment | |
| */ | |
| public String xml() { | |
| return xml; | |
| } | |
| /** | |
| * Reads the text content of the wrapped element itself. | |
| * | |
| * @return element text, or {@link Optional#empty()} when the fragment has | |
| * no start element | |
| */ | |
| public Optional<String> text() { | |
| return withReader(xml, reader -> { | |
| while (reader.hasNext()) { | |
| final var event = reader.next(); | |
| if (event == XMLStreamConstants.START_ELEMENT) { | |
| return Optional.of(extractText(reader)); | |
| } | |
| } | |
| return Optional.empty(); | |
| }); | |
| } | |
| /** | |
| * Selects nodes inside this XML fragment using descendant path | |
| * semantics. | |
| * | |
| * @param path element local-name segments | |
| * @return lazy materialization facade for the selected nodes | |
| */ | |
| public XmlSelection select(final String... path) { | |
| return XmlUtil.select(xml, path); | |
| } | |
| /** | |
| * Selects nodes inside this XML fragment using an explicit path. | |
| * | |
| * @param path query path | |
| * @return lazy materialization facade for the selected nodes | |
| */ | |
| public XmlSelection select(final XmlPath path) { | |
| return XmlUtil.select(xml, path); | |
| } | |
| /** | |
| * Reads the first text value selected inside this fragment. | |
| * | |
| * @param path element local-name segments | |
| * @return selected text, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> text(final String... path) { | |
| return select(path).text(); | |
| } | |
| /** | |
| * Reads an attribute from the wrapped element itself. | |
| * | |
| * @param attributeName attribute local name | |
| * @return attribute value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> attribute(final String attributeName) { | |
| final var safeAttributeName = requireNonBlank(attributeName, "attributeName"); | |
| return withReader(xml, reader -> { | |
| while (reader.hasNext()) { | |
| final var event = reader.next(); | |
| if (event == XMLStreamConstants.START_ELEMENT) { | |
| return Optional.ofNullable(reader.getAttributeValue(null, safeAttributeName)); | |
| } | |
| } | |
| return Optional.empty(); | |
| }); | |
| } | |
| /** | |
| * Reads an attribute from the first element selected inside this | |
| * fragment. | |
| * | |
| * @param attributeName attribute local name | |
| * @param path element local-name segments | |
| * @return attribute value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> attribute(final String attributeName, final String... path) { | |
| return select(path).attribute(attributeName).text(); | |
| } | |
| /** | |
| * Creates an editor for this XML fragment. | |
| * | |
| * @return immutable editor facade | |
| */ | |
| public XmlEditor edit() { | |
| return XmlUtil.edit(xml); | |
| } | |
| @Override | |
| public String toString() { | |
| return xml; | |
| } | |
| } | |
| /** | |
| * Immutable fluent API for in-memory structural XML insertions. | |
| * <p> | |
| * Example: | |
| * <pre> | |
| * String out = XmlUtil.edit(xml) | |
| * .addChild(XmlUtil.XmlNode.simple("item", "A"), "list") | |
| * .addBefore(XmlUtil.XmlNode.simple("meta", "v1"), "header") | |
| * .addAfter(XmlUtil.XmlNode.simple("checksum", "ok"), "footer") | |
| * .build(); | |
| * </pre> | |
| */ | |
| public static final class XmlEditor { | |
| private final String sourceXml; | |
| private final List<InsertOperation> operations; | |
| private XmlEditor(String sourceXml, List<InsertOperation> operations) { | |
| this.sourceXml = sourceXml; | |
| this.operations = operations; | |
| } | |
| /** | |
| * Creates an editor for a source XML payload. | |
| * | |
| * @param sourceXml source XML payload | |
| * @return immutable editor | |
| */ | |
| public static XmlEditor from(final String sourceXml) { | |
| return new XmlEditor(sourceXml, List.of()); | |
| } | |
| /** | |
| * Adds or replaces a direct child under the first matching target path. | |
| * | |
| * @param node node to insert | |
| * @param targetPath direct target path segments | |
| * @return new editor instance with the queued operation | |
| */ | |
| public XmlEditor addChild(final XmlNode node, final String... targetPath) { | |
| return addChild(node, XmlPath.of(targetPath)); | |
| } | |
| /** | |
| * Inserts a node before the first matching target path. | |
| * | |
| * @param node node to insert | |
| * @param targetPath direct target path segments | |
| * @return new editor instance with the queued operation | |
| */ | |
| public XmlEditor addBefore(final XmlNode node, final String... targetPath) { | |
| return addBefore(node, XmlPath.of(targetPath)); | |
| } | |
| /** | |
| * Inserts a node after the first matching target path. | |
| * | |
| * @param node node to insert | |
| * @param targetPath direct target path segments | |
| * @return new editor instance with the queued operation | |
| */ | |
| public XmlEditor addAfter(final XmlNode node, final String... targetPath) { | |
| return addAfter(node, XmlPath.of(targetPath)); | |
| } | |
| /** | |
| * Adds or replaces a direct child under the matching target path. | |
| * | |
| * @param node node to insert | |
| * @param targetPath target path | |
| * @return new editor instance with the queued operation | |
| */ | |
| public XmlEditor addChild(final XmlNode node, final XmlPath targetPath) { | |
| return appendOperation(new InsertOperation(InsertPosition.CHILD, targetPath, node)); | |
| } | |
| /** | |
| * Inserts a node before the matching target path. | |
| * | |
| * @param node node to insert | |
| * @param targetPath target path | |
| * @return new editor instance with the queued operation | |
| */ | |
| public XmlEditor addBefore(final XmlNode node, final XmlPath targetPath) { | |
| return appendOperation(new InsertOperation(InsertPosition.BEFORE, targetPath, node)); | |
| } | |
| /** | |
| * Inserts a node after the matching target path. | |
| * | |
| * @param node node to insert | |
| * @param targetPath target path | |
| * @return new editor instance with the queued operation | |
| */ | |
| public XmlEditor addAfter(final XmlNode node, final XmlPath targetPath) { | |
| return appendOperation(new InsertOperation(InsertPosition.AFTER, targetPath, node)); | |
| } | |
| /** | |
| * Applies all queued insertions and returns the resulting XML. | |
| * | |
| * @return edited XML | |
| */ | |
| public String build() { | |
| return applyInsertions(sourceXml, operations); | |
| } | |
| /** | |
| * Applies all queued insertions asynchronously using the common pool. | |
| * | |
| * @return future containing the edited XML | |
| */ | |
| public CompletableFuture<String> buildAsync() { | |
| return CompletableFuture.supplyAsync(this::build); | |
| } | |
| /** | |
| * Applies all queued insertions asynchronously using the provided | |
| * executor. | |
| * | |
| * @param executor executor used to run the edit operation | |
| * @return future containing the edited XML | |
| */ | |
| public CompletableFuture<String> buildAsync(final Executor executor) { | |
| Objects.requireNonNull(executor, "executor nao pode ser nulo"); | |
| return CompletableFuture.supplyAsync(this::build, executor); | |
| } | |
| private XmlEditor appendOperation(InsertOperation operation) { | |
| Objects.requireNonNull(operation, "operation nao pode ser nulo"); | |
| List<InsertOperation> newOperations = new ArrayList<>(operations.size() + 1); | |
| newOperations.addAll(operations); | |
| newOperations.add(operation); | |
| return new XmlEditor(sourceXml, List.copyOf(newOperations)); | |
| } | |
| } | |
| /** | |
| * Immutable XML path descriptor used by query and edit operations. | |
| * <p> | |
| * Paths match element local names. Namespace URI matching is not part of | |
| * this path type; namespaced XML is queried by local name. | |
| */ | |
| public static final class XmlPath { | |
| private final List<String> segments; | |
| private final int occurrence; | |
| private final XmlPathMode mode; | |
| private final boolean occurrenceSpecified; | |
| private XmlPath(List<String> segments, int occurrence, XmlPathMode mode, boolean occurrenceSpecified) { | |
| this.segments = segments; | |
| this.occurrence = occurrence; | |
| this.mode = mode; | |
| this.occurrenceSpecified = occurrenceSpecified; | |
| } | |
| /** | |
| * Creates a direct-child path. | |
| * <p> | |
| * This method is kept as the default for editor compatibility. Query | |
| * shortcuts that receive {@code String...} use {@link #descendant(String...)} | |
| * instead to preserve legacy extraction behavior. | |
| * | |
| * @param segments element local-name segments | |
| * @return direct-child path | |
| */ | |
| public static XmlPath of(String... segments) { | |
| return direct(segments); | |
| } | |
| /** | |
| * Creates a path that requires each segment to be a direct child of the | |
| * previous segment. | |
| * | |
| * @param segments element local-name segments | |
| * @return direct-child path | |
| */ | |
| public static XmlPath direct(String... segments) { | |
| return from(XmlPathMode.DIRECT, segments); | |
| } | |
| /** | |
| * Creates a path that allows unrelated intermediate elements between | |
| * provided segments. | |
| * | |
| * @param segments element local-name segments | |
| * @return descendant path | |
| */ | |
| public static XmlPath descendant(String... segments) { | |
| return from(XmlPathMode.DESCENDANT, segments); | |
| } | |
| private static XmlPath from(final XmlPathMode mode, final String... segments) { | |
| String[] safeSegments = requirePath(segments); | |
| List<String> normalized = new ArrayList<>(safeSegments.length); | |
| Collections.addAll(normalized, safeSegments); | |
| return new XmlPath(List.copyOf(normalized), 1, mode, false); | |
| } | |
| /** | |
| * Restricts the path to the nth match, using a one-based index. | |
| * <p> | |
| * When no occurrence is specified, list materialization methods collect | |
| * all matches. | |
| * | |
| * @param occurrence one-based occurrence index | |
| * @return path restricted to the requested occurrence | |
| */ | |
| public XmlPath occurrence(int occurrence) { | |
| if (occurrence < 1) { | |
| throw new XmlException("occurrence precisa ser >= 1"); | |
| } | |
| return new XmlPath(segments, occurrence, mode, true); | |
| } | |
| private int occurrence() { | |
| return occurrence; | |
| } | |
| private boolean occurrenceSpecified() { | |
| return occurrenceSpecified; | |
| } | |
| private boolean shouldCollect(final int match) { | |
| return !occurrenceSpecified || match == occurrence; | |
| } | |
| private XmlPathMode mode() { | |
| return mode; | |
| } | |
| private String[] segmentsArray() { | |
| return segments.toArray(String[]::new); | |
| } | |
| private boolean matches(Deque<String> currentPath) { | |
| if (currentPath.size() < segments.size()) { | |
| return false; | |
| } | |
| int offset = currentPath.size() - segments.size(); | |
| int index = 0; | |
| for (String item : currentPath) { | |
| if (index >= offset) { | |
| if (!segments.get(index - offset).equals(item)) { | |
| return false; | |
| } | |
| } | |
| index++; | |
| } | |
| return true; | |
| } | |
| private String description() { | |
| return String.join("/", segments) + "[" + occurrence + "]"; | |
| } | |
| } | |
| /** | |
| * Immutable XML node used by {@link XmlEditor} insertion operations. | |
| */ | |
| public static final class XmlNode { | |
| private final String name; | |
| private final Map<String, String> attributes; | |
| private final String value; | |
| private final List<XmlNode> children; | |
| private XmlNode(String name, Map<String, String> attributes, String value, List<XmlNode> children) { | |
| this.name = name; | |
| this.attributes = attributes; | |
| this.value = value; | |
| this.children = children; | |
| } | |
| /** | |
| * Starts building an element node. | |
| * | |
| * @param name element local name | |
| * @return node builder | |
| */ | |
| public static Builder element(String name) { | |
| return new Builder(name); | |
| } | |
| /** | |
| * Creates an element with text content and no children. | |
| * | |
| * @param name element local name | |
| * @param value text value | |
| * @return immutable XML node | |
| */ | |
| public static XmlNode simple(String name, String value) { | |
| return element(name).value(value).build(); | |
| } | |
| private String name() { | |
| return name; | |
| } | |
| private Map<String, String> attributes() { | |
| return attributes; | |
| } | |
| private String value() { | |
| return value; | |
| } | |
| private List<XmlNode> children() { | |
| return children; | |
| } | |
| /** | |
| * Builder for immutable {@link XmlNode} instances. | |
| */ | |
| public static final class Builder { | |
| private final String name; | |
| private final Map<String, String> attributes = new LinkedHashMap<>(); | |
| private final List<XmlNode> children = new ArrayList<>(); | |
| private String value; | |
| private Builder(String name) { | |
| this.name = requireNonBlank(name, "name"); | |
| } | |
| /** | |
| * Adds an attribute to the node. | |
| * | |
| * @param name attribute local name | |
| * @param value attribute value | |
| * @return this builder | |
| */ | |
| public Builder attribute(String name, String value) { | |
| attributes.put(requireNonBlank(name, "attributeName"), Objects.requireNonNull(value, "attributeValue")); | |
| return this; | |
| } | |
| /** | |
| * Sets the node text value. | |
| * | |
| * @param value text value | |
| * @return this builder | |
| */ | |
| public Builder value(String value) { | |
| this.value = Objects.requireNonNull(value, "value nao pode ser nulo"); | |
| return this; | |
| } | |
| /** | |
| * Adds a child node. | |
| * | |
| * @param node child node | |
| * @return this builder | |
| */ | |
| public Builder child(XmlNode node) { | |
| children.add(Objects.requireNonNull(node, "child nao pode ser nulo")); | |
| return this; | |
| } | |
| /** | |
| * Builds an immutable node snapshot. | |
| * | |
| * @return immutable XML node | |
| */ | |
| public XmlNode build() { | |
| return new XmlNode( | |
| name, | |
| Collections.unmodifiableMap(new LinkedHashMap<>(attributes)), | |
| value, | |
| List.copyOf(children) | |
| ); | |
| } | |
| } | |
| } | |
| /** | |
| * Immutable facade over an XML payload. | |
| * <p> | |
| * This is the recommended entrypoint for new code: | |
| * <pre> | |
| * var document = XmlUtil.document(xml); | |
| * var number = document.select("NFe", "det", "prod", "cProd").text(); | |
| * </pre> | |
| */ | |
| public static class XmlDocument { | |
| private final String xml; | |
| private XmlDocument(final String xml) { | |
| this.xml = xml; | |
| } | |
| /** | |
| * Selects nodes using descendant path semantics. | |
| * | |
| * @param path element local-name segments | |
| * @return lazy materialization facade for the selected nodes | |
| */ | |
| public XmlSelection select(final String... path) { | |
| return XmlUtil.select(xml, path); | |
| } | |
| /** | |
| * Selects nodes using an explicit path. | |
| * | |
| * @param path query path | |
| * @return lazy materialization facade for the selected nodes | |
| */ | |
| public XmlSelection select(final XmlPath path) { | |
| return XmlUtil.select(xml, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting a single attribute value. | |
| * | |
| * @param attributeName attribute local name | |
| * @param path element local-name segments | |
| * @return attribute value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> extractAttribute(String attributeName, String... path) { | |
| return XmlUtil.extractAttribute(xml, attributeName, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting a single attribute value. | |
| * | |
| * @param attributeName attribute local name | |
| * @param path query path | |
| * @return attribute value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> extractAttribute(final String attributeName, final XmlPath path) { | |
| return XmlUtil.extractAttribute(xml, attributeName, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting multiple attribute values. | |
| * | |
| * @param attributeName attribute local name | |
| * @param path element local-name segments | |
| * @return immutable list of present attribute values | |
| */ | |
| public List<String> extractAttributes(final String attributeName, final String... path) { | |
| return XmlUtil.extractAttributes(xml, attributeName, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting multiple attribute values. | |
| * | |
| * @param attributeName attribute local name | |
| * @param path query path | |
| * @return immutable list of present attribute values | |
| */ | |
| public List<String> extractAttributes(final String attributeName, final XmlPath path) { | |
| return XmlUtil.extractAttributes(xml, attributeName, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting a single text value. | |
| * | |
| * @param path element local-name segments | |
| * @return text value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> extractValue(String... path) { | |
| return XmlUtil.extractValue(xml, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting a single text value. | |
| * | |
| * @param path query path | |
| * @return text value, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> extractValue(final XmlPath path) { | |
| return XmlUtil.extractValue(xml, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting multiple text values. | |
| * | |
| * @param path element local-name segments | |
| * @return immutable list of text values | |
| */ | |
| public List<String> extractValues(final String... path) { | |
| return XmlUtil.extractValues(xml, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting multiple text values. | |
| * | |
| * @param path query path | |
| * @return immutable list of text values | |
| */ | |
| public List<String> extractValues(final XmlPath path) { | |
| return XmlUtil.extractValues(xml, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting a single XML fragment. | |
| * | |
| * @param path element local-name segments | |
| * @return XML fragment, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> extractElementXml(String... path) { | |
| return XmlUtil.extractElementXml(xml, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting a single XML fragment. | |
| * | |
| * @param path query path | |
| * @return XML fragment, or {@link Optional#empty()} when absent | |
| */ | |
| public Optional<String> extractElementXml(final XmlPath path) { | |
| return XmlUtil.extractElementXml(xml, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting multiple XML fragments. | |
| * | |
| * @param path element local-name segments | |
| * @return immutable list of XML fragments | |
| */ | |
| public List<String> extractElementsXml(final String... path) { | |
| return XmlUtil.extractElementsXml(xml, path); | |
| } | |
| /** | |
| * Compatibility shortcut for extracting multiple XML fragments. | |
| * | |
| * @param path query path | |
| * @return immutable list of XML fragments | |
| */ | |
| public List<String> extractElementsXml(final XmlPath path) { | |
| return XmlUtil.extractElementsXml(xml, path); | |
| } | |
| /** | |
| * Compatibility shortcut for mapping repeated XML fragments. | |
| * | |
| * @param path query path for repeated elements | |
| * @param mapper mapper that receives each matched element XML fragment | |
| * @param <T> mapped result type | |
| * @return immutable list of mapped values | |
| */ | |
| public <T> List<T> extractEach(final XmlPath path, final Function<String, T> mapper) { | |
| return XmlUtil.extractEach(xml, path, mapper); | |
| } | |
| /** | |
| * Creates an editor for this XML payload. | |
| * | |
| * @return immutable editor facade | |
| */ | |
| public XmlEditor edit() { | |
| return XmlUtil.edit(xml); | |
| } | |
| } | |
| /** | |
| * Backward-compatible name for the original fluent facade. | |
| * <p> | |
| * New code should use {@link XmlDocument}, created through | |
| * {@link XmlUtil#document(String)}. | |
| */ | |
| public static final class FluentXml extends XmlDocument { | |
| private FluentXml(final String xml) { | |
| super(xml); | |
| } | |
| } | |
| private record InsertOperation(InsertPosition position, XmlPath path, XmlNode node) { | |
| private InsertOperation { | |
| Objects.requireNonNull(position, "position nao pode ser nulo"); | |
| Objects.requireNonNull(path, "path nao pode ser nulo"); | |
| Objects.requireNonNull(node, "node nao pode ser nulo"); | |
| } | |
| } | |
| private static final class InsertState { | |
| private final InsertOperation operation; | |
| private int matches; | |
| private int armedDepth = -1; | |
| private int skipDepth = -1; | |
| private boolean applied; | |
| private InsertState(InsertOperation operation) { | |
| this.operation = operation; | |
| } | |
| private void onStartElement(Deque<String> currentPath, int depth, String localName, XMLStreamWriter writer) throws XMLStreamException { | |
| if (applied || armedDepth != -1) { | |
| if (shouldSkipExistingChild(depth, localName)) { | |
| skipDepth = depth; | |
| } | |
| return; | |
| } | |
| if (!operation.path().matches(currentPath)) { | |
| return; | |
| } | |
| matches++; | |
| if (matches != operation.path().occurrence()) { | |
| return; | |
| } | |
| if (operation.position() == InsertPosition.BEFORE) { | |
| writeNode(writer, operation.node()); | |
| applied = true; | |
| return; | |
| } | |
| armedDepth = depth; | |
| } | |
| private boolean shouldSkipExistingChild(int depth, String localName) { | |
| return operation.position() == InsertPosition.CHILD | |
| && armedDepth > 0 | |
| && skipDepth == -1 | |
| && depth == armedDepth + 1 | |
| && operation.node().name().equals(localName); | |
| } | |
| private void beforeEndElement(int depth, XMLStreamWriter writer) throws XMLStreamException { | |
| if (applied || armedDepth != depth || operation.position() != InsertPosition.CHILD) { | |
| return; | |
| } | |
| writeNode(writer, operation.node()); | |
| applied = true; | |
| armedDepth = -1; | |
| } | |
| private void afterEndElement(int depth, XMLStreamWriter writer) throws XMLStreamException { | |
| if (applied || armedDepth != depth || operation.position() != InsertPosition.AFTER) { | |
| return; | |
| } | |
| writeNode(writer, operation.node()); | |
| applied = true; | |
| armedDepth = -1; | |
| } | |
| private void onEndElement(int depth) { | |
| if (skipDepth == depth) { | |
| skipDepth = -1; | |
| } | |
| } | |
| private boolean isSkippingDepth(int depth) { | |
| return skipDepth != -1 && depth >= skipDepth; | |
| } | |
| private boolean applied() { | |
| return applied; | |
| } | |
| private InsertOperation operation() { | |
| return operation; | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment