/* * Copyright (c) 2010 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing permissions and limitations under * the License. */ package com.google.api.client.xml; import com.google.api.client.http.HttpMediaType; import com.google.api.client.util.ArrayValueMap; import com.google.api.client.util.Beta; import com.google.api.client.util.Charsets; import com.google.api.client.util.ClassInfo; import com.google.api.client.util.Data; import com.google.api.client.util.FieldInfo; import com.google.api.client.util.Preconditions; import com.google.api.client.util.Types; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserFactory; import org.xmlpull.v1.XmlSerializer; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; /** * {@link Beta} <br/> * XML utilities. * * @since 1.0 * @author Yaniv Inbar */ @Beta public class Xml { /** * {@code "application/xml; charset=utf-8"} media type used as a default for XML parsing. * * <p> * Use {@link HttpMediaType#equalsIgnoreParameters} for comparing media types. * </p> * * @since 1.10 */ public static final String MEDIA_TYPE = new HttpMediaType("application/xml").setCharsetParameter(Charsets.UTF_8).build(); /** Text content. */ static final String TEXT_CONTENT = "text()"; /** XML pull parser factory. */ private static XmlPullParserFactory factory; private static synchronized XmlPullParserFactory getParserFactory() throws XmlPullParserException { if (factory == null) { factory = XmlPullParserFactory.newInstance( System.getProperty(XmlPullParserFactory.PROPERTY_NAME), null); factory.setNamespaceAware(true); } return factory; } /** * Returns a new XML serializer. * * @throws IllegalArgumentException if encountered an {@link XmlPullParserException} */ public static XmlSerializer createSerializer() { try { return getParserFactory().newSerializer(); } catch (XmlPullParserException e) { throw new IllegalArgumentException(e); } } /** Returns a new XML pull parser. */ public static XmlPullParser createParser() throws XmlPullParserException { return getParserFactory().newPullParser(); } /** * Shows a debug string representation of an element data object of key/value pairs. * <p> * It will make up something for the element name and XML namespaces. If those are known, it is * better to use {@link XmlNamespaceDictionary#toStringOf(String, Object)}. * * @param element element data object of key/value pairs ({@link GenericXml}, {@link Map}, or any * object with public fields) */ public static String toStringOf(Object element) { return new XmlNamespaceDictionary().toStringOf(null, element); } /** * Parses the string value of an attribute value or text content. * * @param stringValue string value * @param field field to set or {@code null} if not applicable * @param valueType value type (class, parameterized type, or generic array type) or {@code null} * for none * @param context context list, going from least specific to most specific type context, for * example container class and its field * @param destination destination object or {@code null} for none * @param genericXml generic XML or {@code null} if not applicable * @param destinationMap destination map or {@code null} if not applicable * @param name key name */ private static void parseAttributeOrTextContent(String stringValue, Field field, Type valueType, List<Type> context, Object destination, GenericXml genericXml, Map<String, Object> destinationMap, String name) { if (field != null || genericXml != null || destinationMap != null) { valueType = field == null ? valueType : field.getGenericType(); Object value = parseValue(valueType, context, stringValue); setValue(value, field, destination, genericXml, destinationMap, name); } } /** * Sets the value of a given field or map entry. * * @param value value * @param field field to set or {@code null} if not applicable * @param destination destination object or {@code null} for none * @param genericXml generic XML or {@code null} if not applicable * @param destinationMap destination map or {@code null} if not applicable * @param name key name */ private static void setValue(Object value, Field field, Object destination, GenericXml genericXml, Map<String, Object> destinationMap, String name) { if (field != null) { FieldInfo.setFieldValue(field, destination, value); } else if (genericXml != null) { genericXml.set(name, value); } else { destinationMap.put(name, value); } } /** * Customizes the behavior of XML parsing. Subclasses may override any methods they need to * customize behavior. * * <p> * Implementation has no fields and therefore thread-safe, but sub-classes are not necessarily * thread-safe. * </p> */ public static class CustomizeParser { /** * Returns whether to stop parsing when reaching the start tag of an XML element before it has * been processed. Only called if the element is actually being processed. By default, returns * {@code false}, but subclasses may override. * * @param namespace XML element's namespace URI * @param localName XML element's local name */ public boolean stopBeforeStartTag(String namespace, String localName) { return false; } /** * Returns whether to stop parsing when reaching the end tag of an XML element after it has been * processed. Only called if the element is actually being processed. By default, returns * {@code false}, but subclasses may override. * * @param namespace XML element's namespace URI * @param localName XML element's local name */ public boolean stopAfterEndTag(String namespace, String localName) { return false; } } /** * Parses an XML element using the given XML pull parser into the given destination object. * * <p> * Requires the the current event be {@link XmlPullParser#START_TAG} (skipping any initial * {@link XmlPullParser#START_DOCUMENT}) of the element being parsed. At normal parsing * completion, the current event will either be {@link XmlPullParser#END_TAG} of the element being * parsed, or the {@link XmlPullParser#START_TAG} of the requested {@code atom:entry}. * </p> * * @param parser XML pull parser * @param destination optional destination object to parser into or {@code null} to ignore XML * content * @param namespaceDictionary XML namespace dictionary to store unknown namespaces * @param customizeParser optional parser customizer or {@code null} for none */ public static void parseElement(XmlPullParser parser, Object destination, XmlNamespaceDictionary namespaceDictionary, CustomizeParser customizeParser) throws IOException, XmlPullParserException { ArrayList<Type> context = new ArrayList<Type>(); if (destination != null) { context.add(destination.getClass()); } parseElementInternal(parser, context, destination, null, namespaceDictionary, customizeParser); } /** * Returns whether the customize parser has requested to stop or reached end of document. * Otherwise, identical to * {@link #parseElement(XmlPullParser, Object, XmlNamespaceDictionary, CustomizeParser)} . */ private static boolean parseElementInternal(XmlPullParser parser, ArrayList<Type> context, Object destination, Type valueType, XmlNamespaceDictionary namespaceDictionary, CustomizeParser customizeParser) throws IOException, XmlPullParserException { // TODO(yanivi): method is too long; needs to be broken down into smaller methods and comment // better GenericXml genericXml = destination instanceof GenericXml ? (GenericXml) destination : null; @SuppressWarnings("unchecked") Map<String, Object> destinationMap = genericXml == null && destination instanceof Map<?, ?> ? Map.class.cast(destination) : null; ClassInfo classInfo = destinationMap != null || destination == null ? null : ClassInfo.of(destination.getClass()); if (parser.getEventType() == XmlPullParser.START_DOCUMENT) { parser.next(); } parseNamespacesForElement(parser, namespaceDictionary); // generic XML if (genericXml != null) { genericXml.namespaceDictionary = namespaceDictionary; String name = parser.getName(); String namespace = parser.getNamespace(); String alias = namespaceDictionary.getNamespaceAliasForUriErrorOnUnknown(namespace); genericXml.name = alias.length() == 0 ? name : alias + ":" + name; } // attributes if (destination != null) { int attributeCount = parser.getAttributeCount(); for (int i = 0; i < attributeCount; i++) { // TODO(yanivi): can have repeating attribute values, e.g. "@a=value1 @a=value2"? String attributeName = parser.getAttributeName(i); String attributeNamespace = parser.getAttributeNamespace(i); String attributeAlias = attributeNamespace.length() == 0 ? "" : namespaceDictionary.getNamespaceAliasForUriErrorOnUnknown(attributeNamespace); String fieldName = getFieldName(true, attributeAlias, attributeNamespace, attributeName); Field field = classInfo == null ? null : classInfo.getField(fieldName); parseAttributeOrTextContent(parser.getAttributeValue(i), field, valueType, context, destination, genericXml, destinationMap, fieldName); } } Field field; ArrayValueMap arrayValueMap = new ArrayValueMap(destination); boolean isStopped = false; // TODO(yanivi): support Void type as "ignore" element/attribute main: while (true) { int event = parser.next(); switch (event) { case XmlPullParser.END_DOCUMENT: isStopped = true; break main; case XmlPullParser.END_TAG: isStopped = customizeParser != null && customizeParser.stopAfterEndTag(parser.getNamespace(), parser.getName()); break main; case XmlPullParser.TEXT: // parse text content if (destination != null) { field = classInfo == null ? null : classInfo.getField(TEXT_CONTENT); parseAttributeOrTextContent(parser.getText(), field, valueType, context, destination, genericXml, destinationMap, TEXT_CONTENT); } break; case XmlPullParser.START_TAG: if (customizeParser != null && customizeParser.stopBeforeStartTag(parser.getNamespace(), parser.getName())) { isStopped = true; break main; } if (destination == null) { parseTextContentForElement(parser, context, true, null); } else { // element parseNamespacesForElement(parser, namespaceDictionary); String namespace = parser.getNamespace(); String alias = namespaceDictionary.getNamespaceAliasForUriErrorOnUnknown(namespace); String fieldName = getFieldName(false, alias, namespace, parser.getName()); field = classInfo == null ? null : classInfo.getField(fieldName); Type fieldType = field == null ? valueType : field.getGenericType(); fieldType = Data.resolveWildcardTypeOrTypeVariable(context, fieldType); // field type is now class, parameterized type, or generic array type // resolve a parameterized type to a class Class<?> fieldClass = fieldType instanceof Class<?> ? (Class<?>) fieldType : null; if (fieldType instanceof ParameterizedType) { fieldClass = Types.getRawClass((ParameterizedType) fieldType); } boolean isArray = Types.isArray(fieldType); // text content boolean ignore = field == null && destinationMap == null && genericXml == null; if (ignore || Data.isPrimitive(fieldType)) { int level = 1; while (level != 0) { switch (parser.next()) { case XmlPullParser.END_DOCUMENT: isStopped = true; break main; case XmlPullParser.START_TAG: level++; break; case XmlPullParser.END_TAG: level--; break; case XmlPullParser.TEXT: if (!ignore && level == 1) { parseAttributeOrTextContent(parser.getText(), field, valueType, context, destination, genericXml, destinationMap, fieldName); } break; default: break; } } } else if (fieldType == null || fieldClass != null && Types.isAssignableToOrFrom(fieldClass, Map.class)) { // store the element as a map Map<String, Object> mapValue = Data.newMapInstance(fieldClass); int contextSize = context.size(); if (fieldType != null) { context.add(fieldType); } Type subValueType = fieldType != null && Map.class.isAssignableFrom(fieldClass) ? Types.getMapValueParameter(fieldType) : null; subValueType = Data.resolveWildcardTypeOrTypeVariable(context, subValueType); isStopped = parseElementInternal(parser, context, mapValue, subValueType, namespaceDictionary, customizeParser); if (fieldType != null) { context.remove(contextSize); } if (destinationMap != null) { // map but not GenericXml: store as ArrayList of elements @SuppressWarnings("unchecked") Collection<Object> list = (Collection<Object>) destinationMap.get(fieldName); if (list == null) { list = new ArrayList<Object>(1); destinationMap.put(fieldName, list); } list.add(mapValue); } else if (field != null) { // not a map: store in field value FieldInfo fieldInfo = FieldInfo.of(field); if (fieldClass == Object.class) { // field is an Object: store as ArrayList of element maps @SuppressWarnings("unchecked") Collection<Object> list = (Collection<Object>) fieldInfo.getValue(destination); if (list == null) { list = new ArrayList<Object>(1); fieldInfo.setValue(destination, list); } list.add(mapValue); } else { // field is a Map: store as a single element map fieldInfo.setValue(destination, mapValue); } } else { // GenericXml: store as ArrayList of elements GenericXml atom = (GenericXml) destination; @SuppressWarnings("unchecked") Collection<Object> list = (Collection<Object>) atom.get(fieldName); if (list == null) { list = new ArrayList<Object>(1); atom.set(fieldName, list); } list.add(mapValue); } } else if (isArray || Types.isAssignableToOrFrom(fieldClass, Collection.class)) { // TODO(yanivi): some duplicate code here; isolate into reusable methods FieldInfo fieldInfo = FieldInfo.of(field); Object elementValue = null; Type subFieldType = isArray ? Types.getArrayComponentType(fieldType) : Types.getIterableParameter(fieldType); Class<?> rawArrayComponentType = Types.getRawArrayComponentType(context, subFieldType); subFieldType = Data.resolveWildcardTypeOrTypeVariable(context, subFieldType); Class<?> subFieldClass = subFieldType instanceof Class<?> ? (Class<?>) subFieldType : null; if (subFieldType instanceof ParameterizedType) { subFieldClass = Types.getRawClass((ParameterizedType) subFieldType); } if (Data.isPrimitive(subFieldType)) { elementValue = parseTextContentForElement(parser, context, false, subFieldType); } else if (subFieldType == null || subFieldClass != null && Types.isAssignableToOrFrom(subFieldClass, Map.class)) { elementValue = Data.newMapInstance(subFieldClass); int contextSize = context.size(); if (subFieldType != null) { context.add(subFieldType); } Type subValueType = subFieldType != null && Map.class.isAssignableFrom(subFieldClass) ? Types.getMapValueParameter( subFieldType) : null; subValueType = Data.resolveWildcardTypeOrTypeVariable(context, subValueType); isStopped = parseElementInternal(parser, context, elementValue, subValueType, namespaceDictionary, customizeParser); if (subFieldType != null) { context.remove(contextSize); } } else { elementValue = Types.newInstance(rawArrayComponentType); int contextSize = context.size(); context.add(fieldType); isStopped = parseElementInternal(parser, context, elementValue, null, namespaceDictionary, customizeParser); context.remove(contextSize); } if (isArray) { // array field: add new element to array value map if (field == null) { arrayValueMap.put(fieldName, rawArrayComponentType, elementValue); } else { arrayValueMap.put(field, rawArrayComponentType, elementValue); } } else { // collection: add new element to collection @SuppressWarnings("unchecked") Collection<Object> collectionValue = (Collection<Object>) (field == null ? destinationMap.get(fieldName) : fieldInfo.getValue(destination)); if (collectionValue == null) { collectionValue = Data.newCollectionInstance(fieldType); setValue(collectionValue, field, destination, genericXml, destinationMap, fieldName); } collectionValue.add(elementValue); } } else { // not an array/iterable or a map, but we do have a field Object value = Types.newInstance(fieldClass); int contextSize = context.size(); context.add(fieldType); isStopped = parseElementInternal(parser, context, value, null, namespaceDictionary, customizeParser); context.remove(contextSize); setValue(value, field, destination, genericXml, destinationMap, fieldName); } } if (isStopped || parser.getEventType() == XmlPullParser.END_DOCUMENT) { isStopped = true; break main; } break; } } arrayValueMap.setValues(); return isStopped; } private static String getFieldName( boolean isAttribute, String alias, String namespace, String name) { if (!isAttribute && alias.length() == 0) { return name; } StringBuilder buf = new StringBuilder(2 + alias.length() + name.length()); if (isAttribute) { buf.append('@'); } if (alias.length() != 0) { buf.append(alias).append(':'); } return buf.append(name).toString(); } private static Object parseTextContentForElement( XmlPullParser parser, List<Type> context, boolean ignoreTextContent, Type textContentType) throws XmlPullParserException, IOException { Object result = null; int level = 1; while (level != 0) { switch (parser.next()) { case XmlPullParser.END_DOCUMENT: level = 0; break; case XmlPullParser.START_TAG: level++; break; case XmlPullParser.END_TAG: level--; break; case XmlPullParser.TEXT: if (!ignoreTextContent && level == 1) { result = parseValue(textContentType, context, parser.getText()); } break; default: break; } } return result; } private static Object parseValue(Type valueType, List<Type> context, String value) { valueType = Data.resolveWildcardTypeOrTypeVariable(context, valueType); if (valueType == Double.class || valueType == double.class) { if (value.equals("INF")) { return new Double(Double.POSITIVE_INFINITY); } if (value.equals("-INF")) { return new Double(Double.NEGATIVE_INFINITY); } } if (valueType == Float.class || valueType == float.class) { if (value.equals("INF")) { return Float.POSITIVE_INFINITY; } if (value.equals("-INF")) { return Float.NEGATIVE_INFINITY; } } return Data.parsePrimitiveValue(valueType, value); } /** * Parses the namespaces declared on the current element into the namespace dictionary. * * @param parser XML pull parser * @param namespaceDictionary namespace dictionary */ private static void parseNamespacesForElement( XmlPullParser parser, XmlNamespaceDictionary namespaceDictionary) throws XmlPullParserException { int eventType = parser.getEventType(); Preconditions.checkState(eventType == XmlPullParser.START_TAG, "expected start of XML element, but got something else (event type %s)", eventType); int depth = parser.getDepth(); int nsStart = parser.getNamespaceCount(depth - 1); int nsEnd = parser.getNamespaceCount(depth); for (int i = nsStart; i < nsEnd; i++) { String namespace = parser.getNamespaceUri(i); // if namespace isn't already in our dictionary, add it now if (namespaceDictionary.getAliasForUri(namespace) == null) { String prefix = parser.getNamespacePrefix(i); String originalAlias = prefix == null ? "" : prefix; // find an available alias String alias = originalAlias; int suffix = 1; while (namespaceDictionary.getUriForAlias(alias) != null) { suffix++; alias = originalAlias + suffix; } namespaceDictionary.set(alias, namespace); } } } private Xml() { } }