/**
* Copyright (c) Codice Foundation
* <p>
* This 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 any later version.
* <p>
* 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
* Lesser General Public License for more details. A copy of the GNU Lesser General Public License
* is distributed along with this program and can be found at
* <http://www.gnu.org/licenses/lgpl.html>.
*/
package org.codice.ddf.transformer.xml.streaming.lib;
import java.io.BufferedOutputStream;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.Attributes;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;
/**
* A library class used to turn SAX events back into their corresponding XML snippets
* <p>
* Not threadsafe
*/
public class SaxEventToXmlElementConverter {
private static final Logger LOGGER =
LoggerFactory.getLogger(SaxEventToXmlElementConverter.class);
private ByteArrayOutputStream outputStream;
private XMLOutputFactory xmlOutputFactory;
private XMLStreamWriter out;
/*
* Stack of scoped namespace URI to prefix mappings
*/
private Deque<Multimap<String, String>> scopeOfNamespacesAdded = new ArrayDeque<>();
private Deque<NamespaceMapping> namespaceStack = new ArrayDeque<>();
public SaxEventToXmlElementConverter() throws UnsupportedEncodingException, XMLStreamException {
outputStream = new ByteArrayOutputStream();
xmlOutputFactory = XMLOutputFactory.newInstance();
out =
xmlOutputFactory.createXMLStreamWriter(new BufferedWriter(new OutputStreamWriter(new BufferedOutputStream(
outputStream), StandardCharsets.UTF_8)));
}
/**
* Used to reconstruct the start tag of an XML element.
*
* @param uri the URI that is passed in by {@link SaxEventHandler}
* @param localName the localName that is passed in by {@link SaxEventHandler}
* @param atts the attributes that are passed in by {@link SaxEventHandler}
* @return this
* {@see SaxEventHandler#startElement}
*/
public SaxEventToXmlElementConverter toElement(String uri, String localName, Attributes atts)
throws XMLStreamException {
return startConstructingElement(uri, localName, atts);
}
private SaxEventToXmlElementConverter startConstructingElement(String uri, String localName,
Attributes atts) throws XMLStreamException {
Multimap<String, String> addedNamespaces = ArrayListMultimap.create();
if (scopeOfNamespacesAdded.peek() != null) {
addedNamespaces.putAll(scopeOfNamespacesAdded.peek());
}
scopeOfNamespacesAdded.push(addedNamespaces);
// URI to prefix
Map<String, String> scopedNamespaces = new HashMap<>();
Iterator<NamespaceMapping> iter = namespaceStack.descendingIterator();
while (iter.hasNext()) {
NamespaceMapping tmpPair = iter.next();
//switch prefix and URI
scopedNamespaces.put(tmpPair.getUri(), tmpPair.getPrefix());
}
/*
* Use the uri to look up the namespace prefix and append it and the localName to the start tag
*/
out.writeStartElement(scopedNamespaces.get(uri), localName, uri);
if (!checkNamespaceAdded(uri, scopedNamespaces)) {
out.writeNamespace(scopedNamespaces.get(uri), uri);
addedNamespaces.put(uri, scopedNamespaces.get(uri));
}
/*
* Loop through the attributes and append them, prefixed with the proper namespace
* We loop through the attributes twice to ensure all "xmlns" attributes are declared before
* other attributes
*/
for (int i = 0; i < atts.getLength(); i++) {
if (atts.getURI(i)
.isEmpty()) {
out.writeAttribute(atts.getLocalName(i), atts.getValue(i));
} else {
String attUri = atts.getURI(i);
if (!checkNamespaceAdded(attUri, scopedNamespaces)) {
out.writeNamespace(scopedNamespaces.get(attUri), attUri);
addedNamespaces.put(attUri, scopedNamespaces.get(attUri));
}
try {
out.writeAttribute(scopedNamespaces.get(attUri),
attUri,
atts.getLocalName(i),
atts.getValue(i));
/*
* XML doesn't allow for duplicate attributes in an element, e.g.
* no <element attribute=1 attribute=2>
* no <element ns1:attribute=1 ns2:attribute=2 xlmns:ns1=foobar xlmns:ns2=foobar>
* however - if one of the namespaces is the default namespace, this duplication is okay,
* yes <element attribute=1 ns1:attribute=2 xlmns=foobar xmlns:ns1=foobar>
*
* This catch block handles this edge case
*/
} catch (XMLStreamException e) {
/*
* Get the first non-empty prefix that is associated with the URI (the other, non-default prefix)
*/
String altNS = namespaceStack.stream().filter(p -> attUri.equals(p.getUri()) && !(p.getPrefix().isEmpty())).findFirst().get().getPrefix();
out.writeNamespace(altNS, attUri);
addedNamespaces.put(attUri, altNS);
out.writeAttribute(altNS, attUri, atts.getLocalName(i), atts.getValue(i));
}
}
}
return this;
}
/**
* Method used to reconstruct the end tag of an XML element.
*
* @param uri the namespaceURI that is passed in by {@link SaxEventHandler}
* @param localName the localName that is passed in by {@link SaxEventHandler}
* @return this
* {@see SaxEventHandler#endElement}
*/
public SaxEventToXmlElementConverter toElement(String uri, String localName)
throws XMLStreamException {
return finishConstructingElement();
}
private SaxEventToXmlElementConverter finishConstructingElement() throws XMLStreamException {
scopeOfNamespacesAdded.removeFirst();
/*
* Append the properly prefixed end tag to the XML snippet
*/
out.writeEndElement();
return this;
}
/**
* Method used to reconstruct the characters/value of an XML element.
*
* @param ch the ch that is passed in by {@link SaxEventHandler}
* @param start the start that is passed in by {@link SaxEventHandler}
* @param length the length that is passed in by {@link SaxEventHandler}
* @return this
* {@see SaxEventHandler#characters}
*/
public SaxEventToXmlElementConverter toElement(char[] ch, int start, int length)
throws XMLStreamException {
return addCharactersToElement(ch, start, length);
}
private SaxEventToXmlElementConverter addCharactersToElement(char[] ch, int start, int length)
throws XMLStreamException {
out.writeCharacters(ch, start, length);
return this;
}
/**
* Overridden toString method to return the XML snippet that has been reconstructed
*
* @return the reconstructed XML snippet
*/
@Override
public String toString() {
try {
out.flush();
return outputStream.toString(String.valueOf(StandardCharsets.UTF_8));
} catch (XMLStreamException | UnsupportedEncodingException e) {
LOGGER.debug("Could not convert XML Stream writer to String");
return "";
}
}
/**
* Resets all stateful variables of the {@link SaxEventToXmlElementConverter}
* Should be used before expecting a fresh XML snippet
* Can be used instead of declaring a new one
*
* @return this
*/
public SaxEventToXmlElementConverter reset() {
outputStream.reset();
try {
out = xmlOutputFactory.createXMLStreamWriter(outputStream);
} catch (XMLStreamException e) {
LOGGER.debug("Could not reset XMLStreamWriter");
}
return this;
}
/**
* Method used in a {@link SaxEventHandler#startElement(String, String, String, Attributes)}
* to populate the {@link SaxEventToXmlElementConverter#namespaceMapping}, which allows namespaceURI/prefix lookup. (Could potentially be used elsewhere, but one would have to ensure correct use)
*
* @param prefix the namespace prefix that is passed in by {@link SaxEventHandler}
* @param uri the namespace uri that is passed in by {@link SaxEventHandler}
*/
public void addNamespace(String prefix, String uri) throws XMLStreamException {
namespaceStack.push(new NamespaceMapping(prefix, uri));
}
public void removeNamespace(String prefix) {
Iterator<NamespaceMapping> iter = namespaceStack.iterator();
while (iter.hasNext()) {
NamespaceMapping mapping = iter.next();
if (mapping.getPrefix()
.equals(prefix)) {
iter.remove();
break;
}
}
}
private boolean checkNamespaceAdded(String uri, Map<String, String> scopedNamespaces) {
Multimap<String, String> peek = scopeOfNamespacesAdded.peek();
return peek != null && peek.containsKey(uri) && peek.get(uri)
.contains(scopedNamespaces.get(uri));
}
private static class NamespaceMapping {
private String prefix;
private String uri;
NamespaceMapping(String prefix, String uri) {
this.prefix = prefix;
this.uri = uri;
}
String getPrefix() {
return prefix;
}
String getUri() {
return uri;
}
}
}