<filter-name>CSpaceFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
+
+ <!--
+ A filter that converts JSON requests to XML.
+ -->
+ <filter>
+ <filter-name>JsonToXmlFilter</filter-name>
+ <filter-class>org.collectionspace.services.common.xmljson.JsonToXmlFilter</filter-class>
+ </filter>
+
+ <filter-mapping>
+ <filter-name>JsonToXmlFilter</filter-name>
+ <url-pattern>/*</url-pattern>
+ </filter-mapping>
<!--
A filter that converts XML responses to JSON if needed.
<version>3.4</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.xmlunit</groupId>
+ <artifactId>xmlunit-core</artifactId>
+ <version>2.2.1</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
--- /dev/null
+package org.collectionspace.services.common.xmljson;
+
+import javax.xml.namespace.QName;
+
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Utility methods for doing XML/JSON conversion.
+ */
+public class ConversionUtils {
+ /**
+ * Prefix to prepend to XML attribute names when converting to JSON
+ * field names.
+ */
+ public static final String XML_ATTRIBUTE_PREFIX = "@";
+
+ /**
+ * Prefix to prepend to XML namespace prefixes when converting to
+ * JSON field names.
+ */
+ public static final String XML_NAMESPACE_PREFIX = XML_ATTRIBUTE_PREFIX + "xmlns:";
+
+ /**
+ * Converts an XML attribute name to a JSON field name.
+ *
+ * @param xmlAttributeName the XML attribute name
+ * @return the JSON field name
+ */
+ public static String xmlAttributeNameToJsonFieldName(String xmlAttributeName) {
+ return XML_ATTRIBUTE_PREFIX + xmlAttributeName;
+ }
+
+ /**
+ * Converts a JSON field name to an XML attribute name.
+ * The field name must be in the expected format, as determined by
+ * isXmlAttribute().
+ *
+ * @param jsonFieldName the JSON field name
+ * @return the XML attribute name
+ */
+ public static String jsonFieldNameToXmlAttributeName(String jsonFieldName) {
+ return jsonFieldName.substring(XML_ATTRIBUTE_PREFIX.length());
+ }
+
+ /**
+ * Converts an XML namespace prefix to a JSON field name.
+ *
+ * @param xmlNamespacePrefix the XML namespace prefix
+ * @return the JSON field name
+ */
+ public static String xmlNamespacePrefixToJsonFieldName(String xmlNamespacePrefix) {
+ return XML_NAMESPACE_PREFIX + xmlNamespacePrefix;
+ }
+
+ /**
+ * Converts a JSON field name to an XML namespace prefix.
+ * The field name must be in the expected format, as determined by
+ * isXmlNamespace().
+ *
+ * @param jsonFieldName the JSON field name
+ * @return the XML namespace prefix
+ */
+ public static String jsonFieldNameToXmlNamespacePrefix(String jsonFieldName) {
+ return jsonFieldName.substring(XML_NAMESPACE_PREFIX.length());
+ }
+
+ /**
+ * Determines if a JSON field name represents an XML
+ * attribute.
+ *
+ * @param jsonFieldName the field name to test
+ * @return true if the field name represents an XML attribute,
+ * false otherwise
+ */
+ public static boolean isXmlAttribute(String jsonFieldName) {
+ return jsonFieldName.startsWith(XML_ATTRIBUTE_PREFIX);
+ }
+
+ /**
+ * Determines if a JSON field name represents an XML
+ * namespace prefix.
+ *
+ * @param jsonFieldName the field name to test
+ * @return true if the field name represents an XML namespace prefix,
+ * false otherwise
+ */
+ public static boolean isXmlNamespace(String jsonFieldName) {
+ return jsonFieldName.startsWith(XML_NAMESPACE_PREFIX);
+ }
+
+ /**
+ * Converts an XML element QName to a JSON field name.
+ *
+ * @param name the XML element QName
+ * @return the JSON field name
+ */
+ public static String jsonFieldNameFromXMLQName(QName name) {
+ String prefix = name.getPrefix();
+ String localPart = name.getLocalPart();
+
+ if (StringUtils.isNotEmpty(prefix)) {
+ return prefix + ":" + localPart;
+ }
+
+ return localPart;
+ }
+}
--- /dev/null
+package org.collectionspace.services.common.xmljson;
+
+/**
+ * A lightweight representation of a JSON field. Instances are created
+ * by JsonToXmlStreamConverter in the course of parsing JSON, in order
+ * to track the current state.
+ *
+ * Each JSON field has a name and a type, which is either scalar, array,
+ * or object.
+ */
+public class JsonField {
+ private String name;
+ private Type type;
+
+ /**
+ * Creates an unnamed JsonField.
+ */
+ public JsonField() {
+
+ }
+
+ /**
+ * Creates a JsonField with a given name, and scalar type.
+ *
+ * @param name the name
+ */
+ public JsonField(String name) {
+ this.setName(name);
+ this.setType(Type.SCALAR);
+ }
+
+ /**
+ * Returns the name of the field.
+ *
+ * @return the name
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Sets the name of the field.
+ *
+ * @param name the name
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Returns the type of the field.
+ *
+ * @return the type
+ */
+ public Type getType() {
+ return type;
+ }
+
+ /**
+ * Sets the type of the field.
+ *
+ * @param type the type
+ */
+ public void setType(Type type) {
+ this.type = type;
+ }
+
+ /**
+ * Determines if this is a scalar field.
+ *
+ * @return true if this is a scalar field, false otherwise
+ */
+ public boolean isScalar() {
+ return (getType() == Type.SCALAR);
+ }
+
+ /**
+ * Determines if this is an array field.
+ *
+ * @return true if this is an array field, false otherwise
+ */
+ public boolean isArray() {
+ return (getType() == Type.ARRAY);
+ }
+
+ /**
+ * The possible field types.
+ */
+ public enum Type {
+ SCALAR,
+ ARRAY,
+ OBJECT
+ }
+}
\ No newline at end of file
--- /dev/null
+package org.collectionspace.services.common.xmljson;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletInputStream;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.ws.rs.core.MediaType;
+import javax.xml.stream.XMLStreamException;
+
+import org.apache.commons.io.output.ByteArrayOutputStream;
+
+/**
+ * A filter that translates JSON to XML.
+ */
+public class JsonToXmlFilter implements Filter {
+
+ @Override
+ public void init(FilterConfig filterConfig) throws ServletException {
+
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
+ HttpServletRequest httpRequest = (HttpServletRequest) request;
+
+ if (RequestUtils.isJsonContent(httpRequest)) {
+ // The request contains a JSON payload. Wrap the request.
+
+ RequestWrapper requestWrapper = new RequestWrapper(httpRequest);
+
+ chain.doFilter(requestWrapper, response);
+ }
+ else {
+ // The request doesn't contain a JSON payload. Just pass it along.
+
+ chain.doFilter(request, response);
+ }
+ }
+
+ @Override
+ public void destroy() {
+
+ }
+
+ /**
+ * A request wrapper that .
+ */
+ public class RequestWrapper extends HttpServletRequestWrapper {
+ public RequestWrapper(HttpServletRequest request) {
+ super(request);
+ }
+
+ @Override
+ public String getContentType() {
+ return MediaType.APPLICATION_XML;
+ }
+
+ @Override
+ public ServletInputStream getInputStream() throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ try {
+ JsonToXmlStreamConverter converter = new JsonToXmlStreamConverter(super.getInputStream(), out);
+ converter.convert();
+ } catch (XMLStreamException e) {
+ throw new IOException("error converting JSON stream to XML", e);
+ }
+
+ final InputStream xmlInput = out.toInputStream();
+
+ return new ServletInputStream() {
+ @Override
+ public int read() throws IOException {
+ return xmlInput.read();
+ }
+ };
+ }
+ }
+}
--- /dev/null
+package org.collectionspace.services.common.xmljson;
+
+import static org.collectionspace.services.common.xmljson.ConversionUtils.*;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Stack;
+
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamWriter;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+
+/**
+ * Converts a CSpace JSON payload to an XML payload.
+ *
+ * This class is not intended to serve as a general purpose JSON to XML
+ * translator. It is instead a lightweight processor tuned for the kinds
+ * of JSON generated by CSpace, and the particular transformations needed
+ * to generate XML for CSpace.
+ *
+ * The conversion is performed as follows:
+ * <ul>
+ * <li>JSON fields starting with "@xmlns:" are converted XML namespace declarations.</li>
+ * <li>JSON fields starting with "@" are converted to XML attributes.</li>
+ * <li>Other JSON fields are converted to identically-named XML elements.</li>
+ * <li>The contents of JSON objects are converted to XML child elements.</li>
+ * <li>The contents of JSON arrays are expanded into multiple XML elements, each
+ * named with the field name of the JSON array.</li>
+ * </ul>
+ *
+ * This implementation is schema-unaware. It operates by examining only the input
+ * document, without utilizing any XML schema information.
+ *
+ * Example:
+ *
+ * JSON
+ * <pre>
+ * {
+ * "document": {
+ * "@name": "collectionobjects",
+ * "ns2:collectionobjects_common": {
+ * "@xmlns:ns2": "http://collectionspace.org/services/collectionobject",
+ * "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
+ * "objectNumber": "2016.1.1",
+ * "objectNameList": {
+ * "objectNameGroup": [
+ * {
+ * "objectNameCurrency": null,
+ * "objectNameLanguage": null,
+ * "objectName": "Object name",
+ * "objectNameSystem": null,
+ * "objectNameType": null,
+ * "objectNameNote": null,
+ * "objectNameLevel": null
+ * },
+ * {
+ * "objectNameCurrency": null,
+ * "objectNameLanguage": null,
+ * "objectName": "Another name",
+ * "objectNameSystem": null,
+ * "objectNameType": null,
+ * "objectNameNote": null,
+ * "objectNameLevel": null
+ * }
+ * ]
+ * },
+ * "comments": {
+ * "comment": [
+ * "Some comment text",
+ * "Another comment"
+ * ]
+ * }
+ * }
+ * }
+ * }
+ * </pre>
+ *
+ * XML
+ * <pre>
+ * <document name="collectionobjects">
+ * <ns2:collectionobjects_common xmlns:ns2="http://collectionspace.org/services/collectionobject" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ * <objectNumber>2016.1.1</objectNumber>
+ * <objectNameList>
+ * <objectNameGroup>
+ * <objectNameCurrency/>
+ * <objectNameLanguage/>
+ * <objectName>Object name</objectName>
+ * <objectNameSystem/>
+ * <objectNameType/>
+ * <objectNameNote/>
+ * <objectNameLevel/>
+ * </objectNameGroup>
+ * <objectNameGroup>
+ * <objectNameCurrency/>
+ * <objectNameLanguage/>
+ * <objectName>Another name</objectName>
+ * <objectNameSystem/>
+ * <objectNameType/>
+ * <objectNameNote/>
+ * <objectNameLevel/>
+ * </objectNameGroup>
+ * </objectNameList>
+ * <comments>
+ * <comment>Some comment text</comment>
+ * <comment>Another comment</comment>
+ * </comments>
+ * </ns2:collectionobjects_common>
+ * </document>
+ * </pre>
+ *
+ * This implementation uses a streaming JSON parser and a streaming
+ * XML writer to do a direct stream-to-stream conversion, without
+ * building a complete in-memory representation of the document.
+ */
+public class JsonToXmlStreamConverter {
+
+ /**
+ * The JSON parser used to parse the input stream.
+ */
+ protected JsonParser jsonParser;
+
+ /**
+ * The XML writer used to write to the output stream.
+ */
+ protected XMLStreamWriter xmlWriter;
+
+ /**
+ * A stack used to track the state of JSON parsing.
+ * JsonField instances are pushed onto the stack as fields
+ * are entered, and popped off as fields are exited.
+ * References to fields are not retained once they
+ * are popped from the stack, so there is never a full
+ * representation of the JSON document in memory.
+ */
+ protected Stack<JsonField> stack = new Stack<JsonField>();
+
+ /**
+ * Creates an JsonToXmlStreamConverter that reads JSON from an input stream,
+ * and writes XML to an output stream.
+ *
+ * @param in the JSON input stream
+ * @param out the XML output stream
+ * @throws JsonParseException
+ * @throws IOException
+ * @throws XMLStreamException
+ */
+ public JsonToXmlStreamConverter(InputStream in, OutputStream out) throws JsonParseException, IOException, XMLStreamException {
+ JsonFactory jsonFactory = new JsonFactory();
+ XMLOutputFactory xmlFactory = XMLOutputFactory.newInstance();
+
+ jsonParser = jsonFactory.createParser(in);
+ xmlWriter = xmlFactory.createXMLStreamWriter(out);
+ }
+
+ /**
+ * Performs the conversion.
+ *
+ * @throws JsonParseException
+ * @throws IOException
+ * @throws XMLStreamException
+ */
+ public void convert() throws JsonParseException, IOException, XMLStreamException {
+ xmlWriter.writeStartDocument();
+
+ // Read tokens from the input stream, and dispatch to handlers.
+ // Handlers may write XML to the output stream.
+
+ while (jsonParser.nextToken() != null) {
+ JsonToken token = jsonParser.currentToken();
+
+ switch(token) {
+ case FIELD_NAME:
+ onFieldName(jsonParser.getText());
+ break;
+ case VALUE_STRING:
+ onScalar(jsonParser.getText());
+ break;
+ case VALUE_NULL:
+ onScalar("");
+ break;
+ case START_OBJECT:
+ onStartObject();
+ break;
+ case END_OBJECT:
+ onEndObject();
+ break;
+ case START_ARRAY:
+ onStartArray();
+ break;
+ case END_ARRAY:
+ onEndArray();
+ break;
+ case VALUE_TRUE:
+ onScalar("true");
+ break;
+ case VALUE_FALSE:
+ onScalar("false");
+ break;
+ case VALUE_NUMBER_INT:
+ onScalar(jsonParser.getValueAsString());
+ break;
+ case VALUE_NUMBER_FLOAT:
+ onScalar(jsonParser.getValueAsString());
+ break;
+ default:
+ }
+ }
+
+ xmlWriter.writeEndDocument();
+ }
+
+ /**
+ * Event handler executed when a field name is encountered
+ * in the input stream.
+ *
+ * @param name the field name
+ */
+ public void onFieldName(String name) {
+ // Push the field onto the stack.
+
+ stack.push(new JsonField(name));
+ }
+
+ /**
+ * Event handler executed when a scalar field value is encountered
+ * in the input stream. Boolean, integer, float, and null values are
+ * converted to strings prior to being passed in.
+ *
+ * @param value the scalar value, as a string
+ * @throws XMLStreamException
+ * @throws IOException
+ */
+ public void onScalar(String value) throws XMLStreamException, IOException {
+ JsonField field = stack.peek();
+ String name = field.getName();
+
+ if (field.isScalar() && isXmlAttribute(name)) {
+ // We're in a scalar field whose name looks like an XML attribute
+ // or namespace declaration.
+
+ if (isXmlNamespace(name)) {
+ // It looks like a namespace declaration.
+ // Output an XML namespace declaration.
+
+ String prefix = jsonFieldNameToXmlNamespacePrefix(name);
+ String namespaceUri = value;
+
+ xmlWriter.writeNamespace(prefix, namespaceUri);
+ }
+ else {
+ // It looks like an attribute.
+ // Output an XML attribute.
+
+ String localName = jsonFieldNameToXmlAttributeName(name);
+
+ xmlWriter.writeAttribute(localName, value);
+ }
+ }
+ else {
+ // It doesn't look like an XML attribute or namespace declaration.
+ // Output an XML element with the same name as the field, whose
+ // contents are the value.
+
+ xmlWriter.writeStartElement(name);
+ xmlWriter.writeCharacters(value);
+ xmlWriter.writeEndElement();
+ }
+
+ if (!field.isArray()) {
+ // If the field we're in is not an array, we're done with it.
+ // Pop it off the stack.
+
+ // If it is an array, there may be more values to come. The
+ // field shouldn't be popped until the end of array is
+ // found.
+
+ stack.pop();
+ }
+ }
+
+ /**
+ * Event handler executed when an object start ({) is encountered
+ * in the input stream.
+ *
+ * @throws XMLStreamException
+ */
+ public void onStartObject() throws XMLStreamException {
+ if (stack.isEmpty()) {
+ // This is the root object. Do nothing.
+
+ return;
+ }
+
+ JsonField field = stack.peek();
+
+ if (field.isArray()) {
+ // If we're in an array, an object should be expanded
+ // into a field with the same name as the array.
+
+ field = new JsonField(field.getName());
+ stack.push(field);
+ }
+
+ field.setType(JsonField.Type.OBJECT);
+
+ // Write an XML start tag to the output stream.
+
+ xmlWriter.writeStartElement(field.getName());
+ }
+
+ /**
+ * Event handler executed when an object end (}) is encountered
+ * in the input stream.
+ *
+ * @throws XMLStreamException
+ */
+ public void onEndObject() throws XMLStreamException {
+ if (stack.isEmpty()) {
+ // This is the root object. Do nothing.
+
+ return;
+ }
+
+ // Pop the current field off the stack.
+
+ stack.pop();
+
+ // Write an XML end tag to the output stream.
+
+ xmlWriter.writeEndElement();
+ }
+
+ /**
+ * Event handler executed when an array start ([) is encountered
+ * in the input stream.
+ *
+ * @throws IOException
+ * @throws XMLStreamException
+ */
+ public void onStartArray() throws IOException, XMLStreamException {
+ // Set the current field type to array.
+
+ JsonField field = stack.peek();
+
+ field.setType(JsonField.Type.ARRAY);
+ }
+
+ /**
+ * Event handler executed when an array end (]) is encountered
+ * in the input stream.
+ *
+ * @throws XMLStreamException
+ */
+ public void onEndArray() throws XMLStreamException {
+ // Pop the current field off the stack.
+
+ stack.pop();
+ }
+}
+
* XML/JSON conversion.
*/
public class RequestUtils {
+
+ /**
+ * Determines if a request's content type is JSON.
+ *
+ * @param request the request
+ * @return true if the request contains JSON content, false otherwise
+ */
+ public static boolean isJsonContent(HttpServletRequest request) {
+ return StringUtils.equals(request.getContentType(), MediaType.APPLICATION_JSON);
+ }
+
/**
* Determines if a request's preferred response content
* type is JSON.
--- /dev/null
+package org.collectionspace.services.common.xmljson;
+
+import static org.collectionspace.services.common.xmljson.ConversionUtils.*;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+/**
+ * A lightweight representation of an XML node. Instances are created
+ * by XmlToJsonStreamConverter in the course of parsing XML. This class
+ * differs from a DOM node in that it is intended to contain just the
+ * information needed to generate JSON for CSpace, in a structure that
+ * is optimized for doing that generation.
+ *
+ * Each XML node has a name, and optionally namespaces and attributes.
+ * The node may contain either text or child nodes (not both, as CSpace
+ * XML is assumed not to contain mixed-content elements).
+ */
+public class XmlNode {
+ /**
+ * The name of the node.
+ */
+ private String name;
+
+ /**
+ * The text content of the node, if this is a text node.
+ */
+ private String text = "";
+
+ /**
+ * The namespaces (prefix and uri) of the node, keyed by prefix.
+ */
+ private Map<String, String> namespaces = new LinkedHashMap<String, String>();
+
+ /**
+ * The attributes (name and value) of the node, keyed by name.
+ */
+ private Map<String, String> attributes = new LinkedHashMap<String, String>();
+
+ /**
+ * The children of the node, keyed by name. A child may be a single XmlNode,
+ * or a list of XmlNodes, if more than one child has the same name.
+ */
+ private Map<String, Object> children = new LinkedHashMap<String, Object>();
+
+ /**
+ * Is text is allowed in this node? This starts off as true.
+ * Adding a child node causes it to become false.
+ */
+ private boolean isTextAllowed = true;
+
+ /**
+ * Should empty children be retained? If false, children that
+ * contain no content at the time of addition are not retained.
+ */
+ private boolean isRetainEmptyChildren = true;
+
+ /**
+ * Creates an XmlNode.
+ */
+ public XmlNode() {
+
+ }
+
+ /**
+ * Creates an XmlNode with a name.
+ *
+ * @param name the name
+ */
+ public XmlNode(String name) {
+ setName(name);
+ }
+
+ /**
+ * Gets the value of the node. If this is a text node, the
+ * value is a String. Otherwise it's a map of the node's
+ * namespaces, attributes, and children, via
+ * getCombinedMap().
+ *
+ * Note that namespaces and attributes are not returned
+ * as part of a text node's value. It is assumed that text
+ * nodes do not have namespace declarations or attributes.
+ *
+ * @return the node's value
+ */
+ @JsonValue
+ public Object getValue() {
+ if (hasChildren()) {
+ return getCombinedMap();
+ }
+
+ if (hasText()) {
+ return getText();
+ }
+
+ return null;
+ }
+
+ /**
+ * Determines if this node has content. A node has
+ * content if it contains non-empty text, or if it has
+ * any children.
+ *
+ * @return true if the node has no content, false otherwise
+ */
+ public boolean isEmpty() {
+ return (!(hasChildren() || hasText()));
+ }
+
+ /**
+ * Returns a map containing the node's namespaces, attributes, and
+ * children. The keys for namespaces and attributes are computed
+ * using ConversionUtils.xmlNamespacePrefixToJsonFieldName() and
+ * ConversionUtils.xmlNamespacePrefixToJsonFieldName() respectively.
+ * Children are keyed by their names.
+ *
+ * @return a map of namespaces, attributes, and children
+ */
+ public Map<String, Object> getCombinedMap() {
+ Map<String, Object> combined = new LinkedHashMap<String, Object>();
+ Map<String, String> namespaces = getNamespaces();
+ Map<String, String> attributes = getAttributes();
+
+ for (String prefix : namespaces.keySet()) {
+ combined.put(xmlNamespacePrefixToJsonFieldName(prefix), namespaces.get(prefix));
+ }
+
+ for (String name : attributes.keySet()) {
+ combined.put(xmlAttributeNameToJsonFieldName(name), attributes.get(name));
+ }
+
+ combined.putAll(getChildren());
+
+ return combined;
+ }
+
+ /**
+ * Returns the name of the node.
+ *
+ * @return the name
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Sets the name of the node.
+ *
+ * @param name the name
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Returns the text content of the node.
+ *
+ * @return the text content
+ */
+ public String getText() {
+ return text;
+ }
+
+ /**
+ * Sets the text content of the node. This has
+ * no effect of text is not allowed, as determined
+ * by isTextAllowed().
+ *
+ * @param text the text content
+ */
+ public void setText(String text) {
+ if (!isTextAllowed()) {
+ return;
+ }
+
+ this.text = text;
+ }
+
+ /**
+ * Adds text to the text content of the node.
+ * This has no effect of text is not allowed, as
+ * determined by isTextAllowed().
+ *
+ * @param text the text to append
+ */
+ public void addText(String text) {
+ if (!isTextAllowed()) {
+ return;
+ }
+
+ this.text = this.text + text;
+ }
+
+ /**
+ * Determines if this node contains text content.
+ *
+ * @return true if the node contains text, false if text is not
+ * allowed in the node, or if the text content is empty
+ */
+ public boolean hasText() {
+ return (isTextAllowed() && StringUtils.isNotEmpty(text));
+ }
+
+ /**
+ * Returns the namespaces of the node.
+ *
+ * @return a map of namespaces, where keys are namespace prefixes
+ * and values are namespace uris
+ */
+ public Map<String, String> getNamespaces() {
+ return namespaces;
+ }
+
+ /**
+ * Sets the namespaces of the node.
+ *
+ * @param namespaces a map of namespaces, where keys are namespace
+ * prefixes and values are namespace uris
+ */
+ public void setNamespaces(Map<String, String> namespaces) {
+ this.namespaces = namespaces;
+ }
+
+ /**
+ * Adds a namespace to the node.
+ *
+ * @param prefix the namespace prefix
+ * @param uri the namespace uri
+ */
+ public void addNamespace(String prefix, String uri) {
+ this.namespaces.put(prefix, uri);
+ }
+
+ /**
+ * Returns the attributes of the node.
+ *
+ * @return a map of attributes, where keys are attribute names
+ * and values are attribute values
+ */
+ public Map<String, String> getAttributes() {
+ return attributes;
+ }
+
+ /**
+ * Sets the attributes of the node.
+ *
+ * @param attributes a map of attributes, where keys are attribute
+ * names and values are attribute values
+ */
+ public void setAttributes(Map<String, String> attributes) {
+ this.attributes = attributes;
+ }
+
+ /**
+ * Adds an attribute to the node.
+ *
+ * @param name the attribute name
+ * @param value the attribute value
+ */
+ public void addAttribute(String name, String value) {
+ this.attributes.put(name, value);
+ }
+
+ /**
+ * Returns the children of the node.
+ *
+ * @return a map of children, where keys are node names and
+ * values are nodes or lists of nodes (where multiple
+ * child nodes have the same name)
+ */
+ public Map<String, Object> getChildren() {
+ return children;
+ }
+
+ /**
+ * Sets the children of the node.
+ *
+ * @param children a map of children, where keys are node names and
+ * values are nodes or lists of nodes (where multiple
+ * child nodes have the same name)
+ */
+ public void setChildren(Map<String, Object> children) {
+ this.children = children;
+ }
+
+ /**
+ * Determines if the node has children.
+ *
+ * @return true if the node has any children, false otherwise
+ */
+ public boolean hasChildren() {
+ return this.children.size() > 0;
+ }
+
+ /**
+ * Adds a child node to this node.
+ *
+ * If the node contains any text content, the text content
+ * is removed, and text content is disallowed from being added
+ * in the future.
+ *
+ * If the node to be added contains no content, and
+ * isDiscardEmptyChildren() is true, the node is not added.
+ *
+ * @param node the node to add as a child
+ */
+ public void addChild(XmlNode node) {
+ // Assume mixed content is not allowed. If a child node is
+ // added, text is no longer allowed, and any existing
+ // text is removed.
+
+ setTextAllowed(false);
+
+ if (node.isEmpty() && !isRetainEmptyChildren()) {
+ return;
+ }
+
+ Map<String, Object> children = this.getChildren();
+ String name = node.getName();
+
+ if (children.containsKey(name)) {
+ Object existing = children.get(name);
+
+ if (existing instanceof List) {
+ ((List<XmlNode>) existing).add(node);
+ }
+ else if (existing instanceof XmlNode) {
+ List<XmlNode> list = new ArrayList<XmlNode>();
+
+ list.add((XmlNode) existing);
+ list.add(node);
+
+ children.put(name, list);
+ }
+ }
+ else {
+ children.put(name, node);
+ }
+ }
+
+ /**
+ * Determines if text content is allowed in this node.
+ *
+ * @return true if text content is allowed, false otherwise
+ */
+ public boolean isTextAllowed() {
+ return isTextAllowed;
+ }
+
+ /**
+ * Sets whether or not text content is allowed in this node.
+ *
+ * @param isTextAllowed true if text content should be allowed,
+ * false otherwise
+ */
+ public void setTextAllowed(boolean isTextAllowed) {
+ if (!isTextAllowed) {
+ setText("");
+ }
+
+ this.isTextAllowed = isTextAllowed;
+ }
+
+ /**
+ * Determines if empty children should be retained.
+ *
+ * @return true if empty children should be retained,
+ * false otherwise
+ */
+ public boolean isRetainEmptyChildren() {
+ return isRetainEmptyChildren;
+ }
+
+ /**
+ * Sets whether or not empty children should be retained.
+ *
+ * @param isRetainEmptyChildren true if empty children should be retained,
+ * false otherwise
+ */
+ public void setRetainEmptyChildren(boolean isRetainEmptyChildren) {
+ this.isRetainEmptyChildren = isRetainEmptyChildren;
+ }
+}
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
-import java.io.PrintWriter;
import java.util.Iterator;
import java.util.Stack;
import javax.xml.stream.events.StartElement;
import javax.xml.stream.events.XMLEvent;
-import org.apache.commons.lang3.StringUtils;
-import org.collectionspace.services.common.xmljson.parsetree.Node;
-
import com.fasterxml.jackson.core.JsonGenerationException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+/**
+ * Converts a CSpace XML payload to a JSON payload.
+ *
+ * This class is not intended to serve as a general purpose XML to JSON
+ * translator. It is instead a lightweight processor tuned for the kinds
+ * of XML generated by CSpace, and the particular transformations needed
+ * to generate JSON for CSpace. The XML input is expected to conform to
+ * conventions (described below) of CSpace XML payloads.
+ *
+ * The conversion is performed as follows:
+ * <ul>
+ * <li>XML elements are converted to identically-named JSON fields.</li>
+ * <li>XML attributes are converted to JSON fields prepended with "@".</li>
+ * <li>XML namespace declarations are converted to JSON fields prepended with "@xmlns:".</li>
+ * <li>Sibling XML elements that have the same name are converted to JSON arrays.</li>
+ * </ul>
+ *
+ * This implementation is schema-unaware. It operates by examining only the input
+ * document, without utilizing any XML schema information. This allows for speed
+ * and simplicity, but has some consequences:
+ *
+ * <ul>
+ * <li>Since type information is not available, all text content is converted to
+ * JSON strings.</li>
+ * <li>Lists are inferred by the presence of multiple child elements with
+ * the same name. If an element contains only one child with a given name, it
+ * will not be converted to a JSON array, even if multiples are allowed by
+ * the XML schema.</li>
+ * <li>Lists are not known ahead of time, and must be inferred by the presence of
+ * multiple identically-named children. This means that all children of an element
+ * must be known before JSON for that element can be generated. This makes it
+ * necessary to read the entire XML document into memory first, instead of
+ * doing a direct stream-to-stream conversion.</li>
+ * </ul>
+ *
+ * Example:
+ *
+ * XML
+ * <pre>
+ * <document name="collectionobjects">
+ * <ns2:collectionspace_core xmlns:ns2="http://collectionspace.org/collectionspace_core/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ * <createdBy>admin@core.collectionspace.org</createdBy>
+ * <createdAt>2016-07-27T04:31:38.290Z</createdAt>
+ * </ns2:collectionspace_core>
+ * <ns2:collectionobjects_common xmlns:ns2="http://collectionspace.org/services/collectionobject" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ * <objectNumber>2016.1.1</objectNumber>
+ * <objectNameList>
+ * <objectNameGroup>
+ * <objectNameCurrency/>
+ * <objectNameLanguage/>
+ * <objectName>Object name</objectName>
+ * <objectNameSystem/>
+ * <objectNameType/>
+ * <objectNameNote/>
+ * <objectNameLevel/>
+ * </objectNameGroup>
+ * <objectNameGroup>
+ * <objectNameCurrency/>
+ * <objectNameLanguage/>
+ * <objectName>Another name</objectName>
+ * <objectNameSystem/>
+ * <objectNameType/>
+ * <objectNameNote/>
+ * <objectNameLevel/>
+ * </objectNameGroup>
+ * </objectNameList>
+ * <comments>
+ * <comment>Some comment text</comment>
+ * <comment>Another comment</comment>
+ * </comments>
+ * </ns2:collectionobjects_common>
+ * </document>
+ * </pre>
+ *
+ * JSON
+ * <pre>
+ * {
+ * "document": {
+ * "@name": "collectionobjects",
+ * "ns2:collectionspace_core": {
+ * "@xmlns:ns2": "http://collectionspace.org/collectionspace_core/",
+ * "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
+ * "createdBy": "admin@core.collectionspace.org",
+ * "createdAt": "2016-07-27T04:31:38.290Z"
+ * },
+ * "ns2:collectionobjects_common": {
+ * "@xmlns:ns2": "http://collectionspace.org/services/collectionobject",
+ * "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
+ * "objectNumber": "2016.1.1",
+ * "objectNameList": {
+ * "objectNameGroup": [
+ * {
+ * "objectNameCurrency": null,
+ * "objectNameLanguage": null,
+ * "objectName": "Object name",
+ * "objectNameSystem": null,
+ * "objectNameType": null,
+ * "objectNameNote": null,
+ * "objectNameLevel": null
+ * },
+ * {
+ * "objectNameCurrency": null,
+ * "objectNameLanguage": null,
+ * "objectName": "Another name",
+ * "objectNameSystem": null,
+ * "objectNameType": null,
+ * "objectNameNote": null,
+ * "objectNameLevel": null
+ * }
+ * ]
+ * },
+ * "comments": {
+ * "comment": [
+ * "Some comment text",
+ * "Another comment"
+ * ]
+ * }
+ * }
+ * }
+ * }
+ * </pre>
+ *
+ * The conversion algorithm assumes that the input XML adheres to the following
+ * conventions:
+ *
+ * <ul>
+ * <li>The XML does not contain mixed-content elements. Elements may contain text
+ * or child elements, but not both. If an element contains child elements,
+ * any text adjacent to those child elements is discarded.</li>
+ * <li>The XML does not have namespace declarations or attributes on text elements.
+ * If namespace declarations or attributes appear on elements containing
+ * only text, they are discarded.</li>
+ * <li>The XML does not contain sequences of identically-named elements that are
+ * interrupted by other elements; or if it does, those interruptions are not
+ * important. For example, the parent node below contains a list of item
+ * elements, interrupted by an other element:
+ *
+ * <pre>
+ * <parent>
+ * <item>a</item>
+ * <item>b</item>
+ * <item>c</item>
+ * <other>uh oh</other>
+ * <item>d</item>
+ * <item>e</item>
+ * </parent>
+ * </pre>
+ *
+ * This is translated to:
+ *
+ * <pre>
+ * "parent": {
+ * "item": [
+ * "a",
+ * "b",
+ * "c",
+ * "d",
+ * "e"
+ * ],
+ * "other": "uh oh"
+ * }
+ * </pre>
+ *
+ * All of the item children of parent are converted into a single
+ * list, so the placement of the other element is not retained in
+ * JSON.
+ * </li>
+ * </ul>
+ *
+ * This implementation uses a StAX parser to generate a lightweight
+ * representation of the input XML document in memory, performs the
+ * necessary transformations, and outputs a JSON rendering of the
+ * transformed document. A direct stream-to-stream conversion is
+ * not possible because of the need to collect identically-named
+ * XML elements for output as a JSON array; for any element, all children
+ * must be known before JSON for that element may be written to the
+ * output stream.
+ */
public class XmlToJsonStreamConverter {
- protected XMLEventReader eventReader;
- protected PrintWriter writer;
- protected Stack<Node> stack = new Stack<Node>();
- protected Node parseResult = null;
+ /**
+ * The StAX event reader used to parse the XML input stream.
+ */
+ protected XMLEventReader xmlEventReader;
- public static String nodeNameForQName(QName name) {
- String prefix = name.getPrefix();
- String localPart = name.getLocalPart();
-
- if (StringUtils.isNotEmpty(prefix)) {
- return prefix + ":" + localPart;
- }
-
- return localPart;
- }
+ /**
+ * The JSON output stream.
+ */
+ protected OutputStream jsonStream;
+
+ /**
+ * A stack used to track the current state of XML parsing.
+ * XmlNode instances are pushed onto the stack as elements
+ * are entered, and popped off as elements are exited.
+ */
+ protected Stack<XmlNode> stack = new Stack<XmlNode>();
+
+ /**
+ * The result of parsing the XML.
+ */
+ protected XmlNode parseResult = null;
+
+ /**
+ * Creates an XmlToJsonStreamConverter that reads XML from an input stream,
+ * and writes JSON to an output stream.
+ *
+ * @param in the XML input stream
+ * @param out the JSON output stream
+ * @throws XMLStreamException
+ */
public XmlToJsonStreamConverter(InputStream in, OutputStream out) throws XMLStreamException {
XMLInputFactory factory = XMLInputFactory.newInstance();
- eventReader = factory.createXMLEventReader(in);
- writer = new PrintWriter(out);
+ xmlEventReader = factory.createXMLEventReader(in);
+ jsonStream = out;
}
+ /**
+ * Performs the conversion.
+ *
+ * @throws XMLStreamException
+ * @throws JsonGenerationException
+ * @throws JsonMappingException
+ * @throws IOException
+ */
public void convert() throws XMLStreamException, JsonGenerationException, JsonMappingException, IOException {
- while(eventReader.hasNext()){
- XMLEvent event = eventReader.nextEvent();
+ // Read in the XML stream.
+
+ while(xmlEventReader.hasNext()) {
+ XMLEvent event = xmlEventReader.nextEvent();
switch(event.getEventType()) {
case XMLStreamConstants.CHARACTERS:
}
}
+ // The XML has been parsed into parseResult.
+ // Write it out as JSON.
+
ObjectMapper objectMapper = new ObjectMapper();
- objectMapper.writeValue(writer, parseResult);
+ objectMapper.writeValue(jsonStream, parseResult);
- writer.flush();
+ jsonStream.flush();
}
+ /**
+ * Event handler executed when the start of the XML document is
+ * encountered in the input stream.
+ *
+ * @param event the event
+ */
protected void onStartDocument(XMLEvent event) {
- stack.push(new Node());
+ // Push an unnamed node on the stack to represent the
+ // document.
+
+ stack.push(new XmlNode());
}
+ /**
+ * Event handler executed when the end of the XML document is
+ * encountered in the input stream.
+ *
+ * @param event the event
+ */
protected void onEndDocument(XMLEvent event) {
+ // The last remaining node on the stack should be
+ // the one representing the document. Pop it and
+ // store it in parseResult.
+
parseResult = stack.pop();
}
+ /**
+ * Event handler executed when the start of an XML element is
+ * encountered in the input stream.
+ *
+ * @param event the event
+ */
@SuppressWarnings("unchecked")
protected void onStartElement(XMLEvent event) {
+ // Create a node to represent the element.
+
StartElement element = event.asStartElement();
QName name = element.getName();
- Node node = new Node(nodeNameForQName(name));
+ XmlNode node = new XmlNode(ConversionUtils.jsonFieldNameFromXMLQName(name));
- Iterator<Attribute> attrIter = element.getAttributes();
-
- while(attrIter.hasNext()) {
- Attribute attr = attrIter.next();
-
- node.addAttribute(attr.getName().toString(), attr.getValue());
- }
+ // Add namespace declarations, if any.
Iterator<Namespace> nsIter = element.getNamespaces();
node.addNamespace(ns.getPrefix(), ns.getNamespaceURI());
}
+
+ // Add attributes, if any.
+
+ Iterator<Attribute> attrIter = element.getAttributes();
+
+ while(attrIter.hasNext()) {
+ Attribute attr = attrIter.next();
+
+ node.addAttribute(attr.getName().toString(), attr.getValue());
+ }
+
+ // Push the node onto the stack.
stack.push(node);
}
-
+
+ /**
+ * Event handler executed when the end of an XML element is
+ * encountered in the input stream.
+ *
+ * @param event the event
+ */
+ protected void onEndElement(XMLEvent event) {
+ // Pop the node corresponding to this element off the stack.
+
+ XmlNode node = stack.pop();
+ XmlNode parent = stack.peek();
+
+ // Add the node to its parent. This is done here instead of
+ // in onStartElement(), because we now know the entire contents
+ // of the element. This gives us the possibility to prevent
+ // adding elements that are empty. In onStartElement(), we don't
+ // yet know if the element is going to be empty.
+
+ parent.addChild(node);
+ }
+
+ /**
+ * Event handler executed when character content is
+ * encountered in the input stream.
+ *
+ * @param event the event
+ */
protected void onCharacters(XMLEvent event) {
+ // Add the text to the parent element.
+
String text = event.asCharacters().getData();
- Node parent = stack.peek();
+ XmlNode parent = stack.peek();
parent.addText(text);
}
-
- protected void onEndElement(XMLEvent event) {
- Node node = stack.pop();
- Node parent = stack.peek();
-
- parent.addChild(node);
- }
}
+++ /dev/null
-package org.collectionspace.services.common.xmljson.parsetree;
-
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-import org.apache.commons.lang3.StringUtils;
-
-import com.fasterxml.jackson.annotation.JsonValue;
-
-/**
- *
- */
-public class Node {
- private String name;
- private String text = "";
- private Map<String, String> namespaces = new LinkedHashMap<String, String>();
- private Map<String, String> attributes = new LinkedHashMap<String, String>();
- private Map<String, Object> children = new LinkedHashMap<String, Object>();
- private boolean isTextAllowed = true;
-
- public static String hashKeyForAttributeName(String name) {
- return "@" + name;
- }
-
- public static String hashKeyForNamespacePrefix(String prefix) {
- return hashKeyForAttributeName("xmlns:" + prefix);
- }
-
- public Node() {
-
- }
-
- public Node(String name) {
- setName(name);
- }
-
- @JsonValue
- public Object getValue() {
- if (hasChildren()) {
- return getCombinedMap();
- }
-
- if (hasText()) {
- return getText();
- }
-
- return null;
- }
-
- public boolean isEmpty() {
- return (!(hasChildren() || hasText()));
- }
-
- public Map<String, Object> getCombinedMap() {
- Map<String, Object> combined = new LinkedHashMap<String, Object>();
- Map<String, String> namespaces = getNamespaces();
- Map<String, String> attributes = getAttributes();
-
- for (String prefix : namespaces.keySet()) {
- combined.put(hashKeyForNamespacePrefix(prefix), namespaces.get(prefix));
- }
-
- for (String name : attributes.keySet()) {
- combined.put(hashKeyForAttributeName(name), attributes.get(name));
- }
-
- combined.putAll(getChildren());
-
- return combined;
- }
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getText() {
- return text;
- }
-
- public void setText(String text) {
- if (!isTextAllowed()) {
- return;
- }
-
- this.text = text;
- }
-
- public void addText(String text) {
- if (!isTextAllowed()) {
- return;
- }
-
- this.text = this.text + text;
- }
-
- public boolean hasText() {
- return (isTextAllowed() && StringUtils.isNotEmpty(text));
- }
-
- public Map<String, String> getNamespaces() {
- return namespaces;
- }
-
- public void setNamespaces(Map<String, String> namespaces) {
- this.namespaces = namespaces;
- }
-
- public void addNamespace(String prefix, String uri) {
- this.namespaces.put(prefix, uri);
- }
-
- public Map<String, String> getAttributes() {
- return attributes;
- }
-
- public void setAttributes(Map<String, String> attributes) {
- this.attributes = attributes;
- }
-
- public void addAttribute(String name, String value) {
- this.attributes.put(name, value);
- }
-
- public Map<String, Object> getChildren() {
- return children;
- }
-
- public void setChildren(Map<String, Object> children) {
- this.children = children;
- }
-
- public boolean hasChildren() {
- return this.children.size() > 0;
- }
-
- public void addChild(Node node) {
- // Assume mixed content is not allowed. If a child node is
- // added, text is no longer allowed, and any existing
- // text is removed.
-
- if (isTextAllowed()) {
- setText("");
- setTextAllowed(false);
- }
-
- if (node.isEmpty()) {
- return;
- }
-
- Map<String, Object> children = this.getChildren();
- String name = node.getName();
-
- if (children.containsKey(name)) {
- Object existing = children.get(name);
-
- if (existing instanceof List) {
- ((List<Node>) existing).add(node);
- }
- else if (existing instanceof Node) {
- List<Node> list = new ArrayList<Node>();
-
- list.add((Node) existing);
- list.add(node);
-
- children.put(name, list);
- }
- }
- else {
- children.put(name, node);
- }
- }
-
- public boolean isTextAllowed() {
- return isTextAllowed;
- }
-
- public void setTextAllowed(boolean isTextAllowed) {
- this.isTextAllowed = isTextAllowed;
- }
-}
--- /dev/null
+package org.collectionspace.services.common.xmljson.test;
+
+import static org.collectionspace.services.common.xmljson.ConversionUtils.*;
+import static org.testng.Assert.*;
+
+import javax.xml.namespace.QName;
+
+import org.testng.annotations.Test;
+
+public class ConversionUtilsTest {
+
+ @Test
+ public void testXmlAttributeNameToJsonFieldName() {
+ assertEquals(xmlAttributeNameToJsonFieldName("xyz"), "@xyz");
+ }
+
+ @Test
+ public void testJsonFieldNameToXmlAttributeName() {
+ assertEquals(jsonFieldNameToXmlAttributeName("@xyz"), "xyz");
+ }
+
+ @Test
+ public void testXmlNamespacePrefixToJsonFieldName() {
+ assertEquals(xmlNamespacePrefixToJsonFieldName("ns2"), "@xmlns:ns2");
+ }
+
+ @Test
+ public void testJsonFieldNameToXmlNamespacePrefix() {
+ assertEquals(jsonFieldNameToXmlNamespacePrefix("@xmlns:ns2"), "ns2");
+ }
+
+ @Test
+ public void testIsXmlAttribute() {
+ assertTrue(isXmlAttribute("@name"));
+ assertTrue(isXmlAttribute("@xmlns:hello"));
+
+ assertFalse(isXmlAttribute("name"));
+ assertFalse(isXmlAttribute("xmlns:hello"));
+ }
+
+ @Test
+ public void testIsXmlNamespace() {
+ assertTrue(isXmlNamespace("@xmlns:hello"));
+
+ assertFalse(isXmlNamespace("@name"));
+ assertFalse(isXmlNamespace("name"));
+ assertFalse(isXmlNamespace("xmlns:hello"));
+ }
+
+ @Test
+ public void testJsonFieldNameFromXMLQName() {
+ assertEquals(jsonFieldNameFromXMLQName(new QName("foo")), "foo");
+ assertEquals(jsonFieldNameFromXMLQName(new QName("http://foo.com", "foo", "ns")), "ns:foo");
+ }
+}
--- /dev/null
+package org.collectionspace.services.common.xmljson.test;
+
+import static org.testng.Assert.*;
+
+import org.collectionspace.services.common.xmljson.JsonField;
+import org.testng.annotations.Test;
+
+public class JsonFieldTest {
+ @Test
+ public void testJsonField() {
+ JsonField field = new JsonField("name");
+
+ assertEquals(field.getName(), "name");
+ assertTrue(field.isScalar());
+ assertFalse(field.isArray());
+ assertEquals(field.getType(), JsonField.Type.SCALAR);
+
+ field.setName("newName");
+
+ assertEquals(field.getName(), "newName");
+
+ field.setType(JsonField.Type.ARRAY);
+
+ assertFalse(field.isScalar());
+ assertTrue(field.isArray());
+ assertEquals(field.getType(), JsonField.Type.ARRAY);
+
+ field.setType(JsonField.Type.OBJECT);
+
+ assertFalse(field.isScalar());
+ assertFalse(field.isArray());
+ assertEquals(field.getType(), JsonField.Type.OBJECT);
+ }
+}
--- /dev/null
+package org.collectionspace.services.common.xmljson.test;
+
+import static org.testng.Assert.*;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+import javax.xml.stream.XMLStreamException;
+
+import org.apache.commons.io.output.ByteArrayOutputStream;
+import org.collectionspace.services.common.xmljson.JsonToXmlStreamConverter;
+import org.testng.annotations.Test;
+import org.xmlunit.builder.DiffBuilder;
+import org.xmlunit.builder.Input;
+import org.xmlunit.diff.Diff;
+
+import com.fasterxml.jackson.core.JsonParseException;
+
+public class JsonToXmlStreamConverterTest {
+ public final String FILE_PATH = "test-data/xmljson/";
+
+ @Test
+ public void testConvert() throws XMLStreamException, JsonParseException, IOException {
+ testConvert("record");
+ testConvert("collectionobject");
+ testConvert("collectionobject-list");
+ testConvert("accountperms");
+ testConvert("permissions");
+ testConvert("vocabulary-items");
+ testConvert("numeric-json");
+ testConvert("boolean-json");
+ testConvertThrows("empty-json", XMLStreamException.class);
+ }
+
+ private void testConvert(String fileName) throws XMLStreamException, IOException {
+ System.out.println("---------------------------------------------------------");
+ System.out.println("Converting JSON to XML: " + fileName);
+ System.out.println("---------------------------------------------------------");
+
+ ClassLoader classLoader = getClass().getClassLoader();
+ File jsonFile = new File(classLoader.getResource(FILE_PATH + fileName + ".json").getFile());
+ File xmlFile = new File(classLoader.getResource(FILE_PATH + fileName + ".xml").getFile());
+
+ FileInputStream in = new FileInputStream(jsonFile);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ JsonToXmlStreamConverter converter = new JsonToXmlStreamConverter(in, out);
+ converter.convert();
+
+ System.out.println(out.toString("UTF-8"));
+
+ Diff diff = DiffBuilder
+ .compare(Input.fromStream(out.toInputStream()))
+ .withTest(Input.fromFile(xmlFile))
+ .ignoreComments()
+ .ignoreWhitespace()
+ .build();
+
+ System.out.println(diff.toString());
+
+ assertFalse(diff.hasDifferences());
+ }
+
+ private void testConvertThrows(String fileName, Class<?> exceptionClass) throws XMLStreamException, IOException {
+ boolean caught = false;
+
+ try {
+ testConvert(fileName);
+ }
+ catch(XMLStreamException|IOException e) {
+ if (e.getClass().isAssignableFrom(exceptionClass)) {
+ caught = true;
+
+ System.out.println(e.toString());
+ }
+ else {
+ throw e;
+ }
+ }
+
+ assertTrue(caught);
+ }
+}
public class RequestUtilsTest {
+ @Test
+ public void testIsJsonContent() {
+ assertFalse(isJsonContent(requestWithContentType(null)));
+ assertFalse(isJsonContent(requestWithContentType("application/xml")));
+ assertTrue(isJsonContent(requestWithContentType("application/json")));
+ }
+
@Test
public void testIsJsonPreferred() {
assertEquals(
return request;
}
+
+ private HttpServletRequest requestWithContentType(String contentType) {
+ HttpServletRequest request = EasyMock.createMock(HttpServletRequest.class);
+
+ EasyMock.expect(request.getContentType())
+ .andReturn(contentType);
+
+ EasyMock.replay(request);
+
+ return request;
+ }
}
--- /dev/null
+package org.collectionspace.services.common.xmljson.test;
+
+import java.util.HashMap;
+
+import org.apache.commons.lang3.StringUtils;
+import org.collectionspace.services.common.xmljson.XmlNode;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.*;
+
+public class XmlNodeTest {
+
+ @Test void testConstructor() {
+ XmlNode node = new XmlNode();
+
+ assertNull(node.getName());
+
+ XmlNode namedNode = new XmlNode("name");
+
+ assertEquals(namedNode.getName(), "name");
+ }
+
+ @Test
+ public void testXmlNodeWithText() {
+ XmlNode node = new XmlNode("collectionspace_core");
+
+ assertEquals(node.getName(), "collectionspace_core");
+
+ node.setName("collectionobjects_common");
+
+ assertEquals(node.getName(), "collectionobjects_common");
+ assertTrue(node.isEmpty());
+ assertFalse(node.hasChildren());
+ assertFalse(node.hasText());
+ assertTrue(node.isTextAllowed());
+
+ node.setText("some text");
+
+ assertEquals(node.getText(), "some text");
+ assertTrue(node.hasText());
+
+ node.addText(" and more");
+
+ assertEquals(node.getText(), "some text and more");
+ assertTrue(node.hasText());
+
+ node.addAttribute("name", "value");
+ node.addNamespace("ns", "http://collectionspace.org");
+
+ assertEquals(node.getAttributes().get("name"), "value");
+ assertEquals(node.getNamespaces().get("ns"), "http://collectionspace.org");
+
+ assertEquals(node.getCombinedMap(), new HashMap<String, Object>() {{
+ put("@name", "value");
+ put("@xmlns:ns", "http://collectionspace.org");
+ }});
+
+ assertEquals(node.getValue(), "some text and more");
+
+ node.setTextAllowed(false);
+
+ assertFalse(node.isTextAllowed());
+ assertTrue(StringUtils.isEmpty(node.getText()));
+
+ node.addText("hello");
+
+ assertTrue(StringUtils.isEmpty(node.getText()));
+
+ node.setText("hello");
+
+ assertTrue(StringUtils.isEmpty(node.getText()));
+ assertNull(node.getValue());
+ }
+
+ @Test
+ public void testXmlNodeWithChildren() {
+ XmlNode node = new XmlNode("collectionspace_core");
+
+ assertFalse(node.hasChildren());
+ assertEquals(node.getCombinedMap(), new HashMap<String, Object>());
+ assertNull(node.getValue());
+ assertTrue(node.isRetainEmptyChildren());
+
+ final XmlNode descNode = new XmlNode("description");
+
+ node.addChild(descNode);
+
+ assertEquals(node.getChildren(), new HashMap<String, Object>() {{
+ put("description", descNode);
+ }});
+
+ assertEquals(node.getCombinedMap(), new HashMap<String, Object>() {{
+ put("description", descNode);
+ }});
+
+ assertEquals(node.getValue(), new HashMap<String, Object>() {{
+ put("description", descNode);
+ }});
+
+ assertFalse(node.isTextAllowed());
+
+ node.addAttribute("name", "value");
+ node.addNamespace("ns", "http://collectionspace.org");
+
+ assertEquals(node.getCombinedMap(), new HashMap<String, Object>() {{
+ put("@name", "value");
+ put("@xmlns:ns", "http://collectionspace.org");
+ put("description", descNode);
+ }});
+
+ assertEquals(node.getValue(), new HashMap<String, Object>() {{
+ put("@name", "value");
+ put("@xmlns:ns", "http://collectionspace.org");
+ put("description", descNode);
+ }});
+
+ node.setRetainEmptyChildren(false);
+
+ assertFalse(node.isRetainEmptyChildren());
+
+ final XmlNode nameNode = new XmlNode("name");
+
+ assertTrue(nameNode.isEmpty());
+
+ node.addChild(nameNode);
+
+ // Should not have been retained
+
+ assertEquals(node.getChildren(), new HashMap<String, Object>() {{
+ put("description", descNode);
+ }});
+ }
+}
package org.collectionspace.services.common.xmljson.test;
-import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.*;
import java.io.File;
import java.io.FileInputStream;
private JsonFactory jsonFactory = mapper.getFactory();
@Test
- public void testConvert() throws XMLStreamException, JsonParseException, IOException {
+ public void testConvert() throws XMLStreamException, IOException {
testConvert("record");
testConvert("collectionobject");
testConvert("collectionobject-list");
testConvert("vocabulary-items");
}
- private void testConvert(String fileName) throws XMLStreamException, JsonParseException, IOException {
- System.out.println("-------------------------------------------");
- System.out.println("Converting " + fileName);
- System.out.println("-------------------------------------------");
+ private void testConvert(String fileName) throws XMLStreamException, IOException {
+ System.out.println("---------------------------------------------------------");
+ System.out.println("Converting XML to JSON: " + fileName);
+ System.out.println("---------------------------------------------------------");
ClassLoader classLoader = getClass().getClassLoader();
File xmlFile = new File(classLoader.getResource(FILE_PATH + fileName + ".xml").getFile());
--- /dev/null
+{
+ "document": {
+ "@name": "examples",
+ "ns:examples_common": {
+ "@xmlns:ns": "http://collectionspace.org/services/example",
+ "boolTrue": true,
+ "boolFalse": false
+ }
+ }
+}
--- /dev/null
+<document name="examples">
+ <ns:examples_common xmlns:ns="http://collectionspace.org/services/example">
+ <boolTrue>true</boolTrue>
+ <boolFalse>false</boolFalse>
+ </ns:examples_common>
+</document>
"ns2:collectionobjects_common": {
"@xmlns:ns2": "http://collectionspace.org/services/collectionobject",
"@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
+ "objectProductionDateGroupList": null,
+ "fieldCollectionMethods": null,
+ "titleGroupList": null,
+ "assocEventPeoples": null,
+ "nonTextualInscriptionGroupList": null,
+ "assocActivityGroupList": null,
"responsibleDepartments": {
"responsibleDepartment": "new DEPT"
},
+ "assocOrganizationGroupList": null,
+ "measuredPartGroupList": null,
+ "contentPositions": null,
+ "styles": null,
+ "assocObjectGroupList": null,
+ "assocPeopleGroupList": null,
+ "objectProductionOrganizationGroupList": null,
+ "ownershipDateGroupList": null,
+ "owners": null,
+ "objectProductionReasons": null,
+ "contentLanguages": null,
+ "otherNumberList": null,
+ "assocCulturalContextGroupList": null,
+ "objectProductionPersonGroupList": null,
"objectNameList": {
"objectNameGroup": {
- "objectName": "new OBJNAME"
+ "objectNameCurrency": null,
+ "objectNameLanguage": null,
+ "objectName": "new OBJNAME",
+ "objectNameSystem": null,
+ "objectNameType": null,
+ "objectNameNote": null,
+ "objectNameLevel": null
}
},
+ "objectStatusList": null,
+ "assocDateGroupList": null,
+ "viewersReferences": null,
+ "assocEventPersons": null,
+ "assocPlaceGroupList": null,
"comments": {
"comment": "new COMMENTS"
},
+ "textualInscriptionGroupList": null,
+ "briefDescriptions": null,
+ "contentOrganizations": null,
+ "objectProductionPlaceGroupList": null,
+ "contentActivities": null,
+ "contentPersons": null,
+ "contentScripts": null,
"objectNumber": "2",
- "distinguishingFeatures": "new DISTFEATURES"
+ "colors": null,
+ "ownersReferences": null,
+ "contentConcepts": null,
+ "fieldColEventNames": null,
+ "techniqueGroupList": null,
+ "assocEventPlaces": null,
+ "fieldCollectionDateGroup": {
+ "dateEarliestSingleQualifier": null,
+ "scalarValuesComputed": null,
+ "dateLatestDay": null,
+ "dateLatestYear": null,
+ "dateAssociation": null,
+ "dateEarliestSingleEra": null,
+ "dateDisplayDate": null,
+ "dateEarliestSingleCertainty": null,
+ "dateLatestEra": null,
+ "dateEarliestSingleQualifierValue": null,
+ "dateLatestCertainty": null,
+ "dateEarliestSingleYear": null,
+ "dateLatestQualifier": null,
+ "dateLatestQualifierValue": null,
+ "dateEarliestSingleQualifierUnit": null,
+ "datePeriod": null,
+ "dateEarliestScalarValue": null,
+ "dateLatestMonth": null,
+ "dateNote": null,
+ "dateLatestScalarValue": null,
+ "dateLatestQualifierUnit": null,
+ "dateEarliestSingleDay": null,
+ "dateEarliestSingleMonth": null
+ },
+ "contentPlaces": null,
+ "contentPeoples": null,
+ "objectComponentGroupList": null,
+ "technicalAttributeGroupList": null,
+ "referenceGroupList": null,
+ "fieldCollectionSources": null,
+ "forms": null,
+ "distinguishingFeatures": "new DISTFEATURES",
+ "assocConceptGroupList": null,
+ "contentDateGroup": {
+ "dateEarliestSingleQualifier": null,
+ "scalarValuesComputed": null,
+ "dateLatestDay": null,
+ "dateLatestYear": null,
+ "dateAssociation": null,
+ "dateEarliestSingleEra": null,
+ "dateDisplayDate": null,
+ "dateEarliestSingleCertainty": null,
+ "dateLatestEra": null,
+ "dateEarliestSingleQualifierValue": null,
+ "dateLatestCertainty": null,
+ "dateEarliestSingleYear": null,
+ "dateLatestQualifier": null,
+ "dateLatestQualifierValue": null,
+ "dateEarliestSingleQualifierUnit": null,
+ "datePeriod": null,
+ "dateEarliestScalarValue": null,
+ "dateLatestMonth": null,
+ "dateNote": null,
+ "dateLatestScalarValue": null,
+ "dateLatestQualifierUnit": null,
+ "dateEarliestSingleDay": null,
+ "dateEarliestSingleMonth": null
+ },
+ "usageGroupList": null,
+ "fieldCollectors": null,
+ "assocPersonGroupList": null,
+ "assocEventOrganizations": null,
+ "contentEventNameGroupList": null,
+ "contentOtherGroupList": null,
+ "materialGroupList": null,
+ "contentObjectGroupList": null,
+ "objectProductionPeopleGroupList": null
},
"ns2:account_permission": {
"@xmlns:ns2": "http://collectionspace.org/services/authorization",
--- /dev/null
+{}
\ No newline at end of file
--- /dev/null
+<!-- Intentionally blank. Attempting to convert empty-json.xml should throw an exception. -->
\ No newline at end of file
--- /dev/null
+{
+ "document": {
+ "@name": "examples",
+ "ns:examples_common": {
+ "@xmlns:ns": "http://collectionspace.org/services/example",
+ "integer": 123,
+ "negInt": -8,
+ "float": 3.14159,
+ "negFloat": -341.24
+ }
+ }
+}
--- /dev/null
+<document name="examples">
+ <ns:examples_common xmlns:ns="http://collectionspace.org/services/example">
+ <integer>123</integer>
+ <negInt>-8</negInt>
+ <float>3.14159</float>
+ <negFloat>-341.24</negFloat>
+ </ns:examples_common>
+</document>
"repeating scalar field value 4"
]
},
+ "empty": null,
"repeatstruct": {
"innerstruct": [
{
}
}
]
+ },
+ "emptytop": {
+ "emptynested": null
}
}
}
</ns:collectionspace_core>
<ns:examples_common xmlns:ns="http://collectionspace.org/services/example">
<scalar>This is a scalar field value</scalar>
+ <!-- A random comment -->
<struct>
<field1>A value in a structured field</field1>
<field2>Another structured field value</field2>
<artifactId>itext</artifactId>
<groupId>com.lowagie</groupId>
</exclusion>
+ <exclusion>
+ <!-- The services/common module requires a later version of jackson than the 2.1.4 used by jasper, so exclude it here. -->
+ <artifactId>jackson-core</artifactId>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ </exclusion>
</exclusions>
</dependency>
<!-- Jasper Reports fonts are still at 4.0.0, but are compatible with later