/* * $Id$ * * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you 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 org.apache.struts2.json; import com.opensymphony.xwork2.util.logging.Logger; import com.opensymphony.xwork2.util.logging.LoggerFactory; import org.apache.struts2.json.annotations.JSON; import org.apache.struts2.json.annotations.JSONFieldBridge; import org.apache.struts2.json.annotations.JSONParameter; import org.apache.struts2.json.bridge.FieldBridge; import org.apache.struts2.json.bridge.ParameterizedBridge; import java.beans.BeanInfo; import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.lang.reflect.Array; import java.lang.reflect.Method; import java.text.CharacterIterator; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.text.StringCharacterIterator; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.regex.Pattern; /** * <p> * Serializes an object into JavaScript Object Notation (JSON). If cyclic * references are detected they will be nulled out. * </p> */ public class JSONWriter { private static final Logger LOG = LoggerFactory.getLogger(JSONWriter.class); /** * By default, enums are serialised as name=value pairs */ public static final boolean ENUM_AS_BEAN_DEFAULT = false; private static char[] hex = "0123456789ABCDEF".toCharArray(); private static final ConcurrentMap<Class<?>, BeanInfo> BEAN_INFO_CACHE_IGNORE_HIERARCHY = new ConcurrentHashMap<Class<?>, BeanInfo>(); private static final ConcurrentMap<Class<?>, BeanInfo> BEAN_INFO_CACHE = new ConcurrentHashMap<Class<?>, BeanInfo>(); private StringBuilder buf = new StringBuilder(); private Stack<Object> stack = new Stack<Object>(); private boolean ignoreHierarchy = true; private Object root; private boolean buildExpr = true; private String exprStack = ""; private Collection<Pattern> excludeProperties; private Collection<Pattern> includeProperties; private DateFormat formatter; private boolean enumAsBean = ENUM_AS_BEAN_DEFAULT; private boolean excludeNullProperties; /** * @param object Object to be serialized into JSON * @return JSON string for object * @throws JSONException */ public String write(Object object) throws JSONException { return this.write(object, null, null, false); } /** * @param object Object to be serialized into JSON * @return JSON string for object * @throws JSONException */ public String write(Object object, Collection<Pattern> excludeProperties, Collection<Pattern> includeProperties, boolean excludeNullProperties) throws JSONException { this.excludeNullProperties = excludeNullProperties; this.buf.setLength(0); this.root = object; this.exprStack = ""; this.buildExpr = ((excludeProperties != null) && !excludeProperties.isEmpty()) || ((includeProperties != null) && !includeProperties.isEmpty()); this.excludeProperties = excludeProperties; this.includeProperties = includeProperties; this.value(object, null); return this.buf.toString(); } /** * Detect cyclic references */ protected void value(Object object, Method method) throws JSONException { if (object == null) { this.add("null"); return; } if (this.stack.contains(object)) { Class clazz = object.getClass(); // cyclic reference if (clazz.isPrimitive() || clazz.equals(String.class)) { this.process(object, method); } else { if (LOG.isDebugEnabled()) { LOG.debug("Cyclic reference detected on " + object); } this.add("null"); } return; } this.process(object, method); } /** * Serialize object into json */ protected void process(Object object, Method method) throws JSONException { this.stack.push(object); if (object instanceof Class) { this.string(object); } else if (object instanceof Boolean) { this.bool((Boolean) object); } else if (object instanceof Number) { this.add(object); } else if (object instanceof String) { this.string(object); } else if (object instanceof Character) { this.string(object); } else if (object instanceof Map) { this.map((Map) object, method); } else if (object.getClass().isArray()) { this.array(object, method); } else if (object instanceof Iterable) { this.array(((Iterable) object).iterator(), method); } else if (object instanceof Date) { this.date((Date) object, method); } else if (object instanceof Calendar) { this.date(((Calendar) object).getTime(), method); } else if (object instanceof Locale) { this.string(object); } else if (object instanceof Enum) { this.enumeration((Enum) object); } else { processCustom(object, method); } this.stack.pop(); } /** * Serialize custom object into json */ protected void processCustom(Object object, Method method) throws JSONException { this.bean(object); } /** * Instrospect bean and serialize its properties */ protected void bean(Object object) throws JSONException { this.add("{"); BeanInfo info; try { Class clazz = object.getClass(); info = ((object == this.root) && this.ignoreHierarchy) ? getBeanInfoIgnoreHierarchy(clazz) : getBeanInfo(clazz); PropertyDescriptor[] props = info.getPropertyDescriptors(); boolean hasData = false; for (PropertyDescriptor prop : props) { String name = prop.getName(); Method accessor = prop.getReadMethod(); Method baseAccessor = findBaseAccessor(clazz, accessor); if (baseAccessor != null) { if (baseAccessor.isAnnotationPresent(JSON.class)) { JSONAnnotationFinder jsonFinder = new JSONAnnotationFinder(baseAccessor).invoke(); if (!jsonFinder.shouldSerialize()) continue; if (jsonFinder.getName() != null) { name = jsonFinder.getName(); } } // ignore "class" and others if (this.shouldExcludeProperty(prop)) { continue; } String expr = null; if (this.buildExpr) { expr = this.expandExpr(name); if (this.shouldExcludeProperty(expr)) { continue; } expr = this.setExprStack(expr); } Object value = accessor.invoke(object); if (baseAccessor.isAnnotationPresent(JSONFieldBridge.class)) { value = getBridgedValue(baseAccessor, value); } boolean propertyPrinted = this.add(name, value, accessor, hasData); hasData = hasData || propertyPrinted; if (this.buildExpr) { this.setExprStack(expr); } } } // special-case handling for an Enumeration - include the name() as // a property */ if (object instanceof Enum) { Object value = ((Enum) object).name(); this.add("_name", value, object.getClass().getMethod("name"), hasData); } } catch (Exception e) { throw new JSONException(e); } this.add("}"); } protected BeanInfo getBeanInfoIgnoreHierarchy(final Class<?> clazz) throws IntrospectionException { BeanInfo beanInfo = BEAN_INFO_CACHE_IGNORE_HIERARCHY.get(clazz); if (beanInfo != null) { return beanInfo; } beanInfo = Introspector.getBeanInfo(clazz, clazz.getSuperclass()); BEAN_INFO_CACHE_IGNORE_HIERARCHY.put(clazz, beanInfo); return beanInfo; } protected BeanInfo getBeanInfo(final Class<?> clazz) throws IntrospectionException { BeanInfo beanInfo = BEAN_INFO_CACHE.get(clazz); if (beanInfo != null) { return beanInfo; } beanInfo = Introspector.getBeanInfo(clazz); BEAN_INFO_CACHE.put(clazz, beanInfo); return beanInfo; } protected Object getBridgedValue(Method baseAccessor, Object value) throws InstantiationException, IllegalAccessException { JSONFieldBridge fieldBridgeAnn = baseAccessor.getAnnotation(JSONFieldBridge.class); if (fieldBridgeAnn != null) { Class impl = fieldBridgeAnn.impl(); FieldBridge instance = (FieldBridge) impl.newInstance(); if (fieldBridgeAnn.params().length > 0 && ParameterizedBridge.class.isAssignableFrom(impl)) { Map<String, String> params = new HashMap<String, String>(fieldBridgeAnn.params().length); for (JSONParameter param : fieldBridgeAnn.params()) { params.put(param.name(), param.value()); } ((ParameterizedBridge) instance).setParameterValues(params); } value = instance.objectToString(value); } return value; } protected Method findBaseAccessor(Class clazz, Method accessor) { Method baseAccessor = null; if (clazz.getName().contains("$$EnhancerByCGLIB$$")) { try { baseAccessor = Thread.currentThread().getContextClassLoader().loadClass( clazz.getName().substring(0, clazz.getName().indexOf("$$"))).getMethod( accessor.getName(), accessor.getParameterTypes()); } catch (Exception ex) { LOG.debug(ex.getMessage(), ex); } } else if (clazz.getName().contains("$$_javassist")) { try { baseAccessor = Class.forName( clazz.getName().substring(0, clazz.getName().indexOf("_$$"))) .getMethod(accessor.getName(), accessor.getParameterTypes()); } catch (Exception ex) { LOG.debug(ex.getMessage(), ex); } } else { return accessor; } return baseAccessor; } /** * Instrospect an Enum and serialize it as a name/value pair or as a bean * including all its own properties */ protected void enumeration(Enum enumeration) throws JSONException { if (enumAsBean) { this.bean(enumeration); } else { this.string(enumeration.name()); } } protected boolean shouldExcludeProperty(PropertyDescriptor prop) throws SecurityException, NoSuchFieldException { String name = prop.getName(); return name.equals("class") || name.equals("declaringClass") || name.equals("cachedSuperClass") || name.equals("metaClass"); } protected String expandExpr(int i) { return this.exprStack + "[" + i + "]"; } protected String expandExpr(String property) { if (this.exprStack.length() == 0) { return property; } return this.exprStack + "." + property; } protected String setExprStack(String expr) { String s = this.exprStack; this.exprStack = expr; return s; } protected boolean shouldExcludeProperty(String expr) { if (this.excludeProperties != null) { for (Pattern pattern : this.excludeProperties) { if (pattern.matcher(expr).matches()) { if (LOG.isDebugEnabled()) { LOG.debug("Ignoring property because of exclude rule: " + expr); } return true; } } } if (this.includeProperties != null) { for (Pattern pattern : this.includeProperties) { if (pattern.matcher(expr).matches()) { return false; } } if (LOG.isDebugEnabled()){ LOG.debug("Ignoring property because of include rule: " + expr); } return true; } return false; } /** * Add name/value pair to buffer */ protected boolean add(String name, Object value, Method method, boolean hasData) throws JSONException { if (excludeNullProperties && value == null) { return false; } if (hasData) { this.add(','); } this.add('"'); this.add(name); this.add("\":"); this.value(value, method); return true; } /** * Add map to buffer */ protected void map(Map map, Method method) throws JSONException { this.add("{"); Iterator it = map.entrySet().iterator(); boolean warnedNonString = false; // one report per map boolean hasData = false; while (it.hasNext()) { Map.Entry entry = (Map.Entry) it.next(); if (excludeNullProperties && entry.getValue() == null) { continue; } Object key = entry.getKey(); if (key == null) { LOG.error("Cannot build expression for null key in #0", exprStack); continue; } String expr = null; if (this.buildExpr) { expr = this.expandExpr(key.toString()); if (this.shouldExcludeProperty(expr)) { continue; } expr = this.setExprStack(expr); } if (hasData) { this.add(','); } hasData = true; if (!warnedNonString && !(key instanceof String)) { if (LOG.isWarnEnabled()) { LOG.warn("JavaScript doesn't support non-String keys, using toString() on #0", key.getClass().getName()); } warnedNonString = true; } this.value(key.toString(), method); this.add(":"); this.value(entry.getValue(), method); if (this.buildExpr) { this.setExprStack(expr); } } this.add("}"); } /** * Add date to buffer */ protected void date(Date date, Method method) { JSON json = null; if (method != null) json = method.getAnnotation(JSON.class); if (this.formatter == null) this.formatter = new SimpleDateFormat(JSONUtil.RFC3339_FORMAT); DateFormat formatter = (json != null) && (json.format().length() > 0) ? new SimpleDateFormat(json .format()) : this.formatter; this.string(formatter.format(date)); } /** * Add array to buffer */ protected void array(Iterator it, Method method) throws JSONException { this.add("["); boolean hasData = false; for (int i = 0; it.hasNext(); i++) { String expr = null; if (this.buildExpr) { expr = this.expandExpr(i); if (this.shouldExcludeProperty(expr)) { it.next(); continue; } expr = this.setExprStack(expr); } if (hasData) { this.add(','); } hasData = true; this.value(it.next(), method); if (this.buildExpr) { this.setExprStack(expr); } } this.add("]"); } /** * Add array to buffer */ protected void array(Object object, Method method) throws JSONException { this.add("["); int length = Array.getLength(object); boolean hasData = false; for (int i = 0; i < length; ++i) { String expr = null; if (this.buildExpr) { expr = this.expandExpr(i); if (this.shouldExcludeProperty(expr)) { continue; } expr = this.setExprStack(expr); } if (hasData) { this.add(','); } hasData = true; this.value(Array.get(object, i), method); if (this.buildExpr) { this.setExprStack(expr); } } this.add("]"); } /** * Add boolean to buffer */ protected void bool(boolean b) { this.add(b ? "true" : "false"); } /** * escape characters */ protected void string(Object obj) { this.add('"'); CharacterIterator it = new StringCharacterIterator(obj.toString()); for (char c = it.first(); c != CharacterIterator.DONE; c = it.next()) { if (c == '"') { this.add("\\\""); } else if (c == '\\') { this.add("\\\\"); } else if (c == '/') { this.add("\\/"); } else if (c == '\b') { this.add("\\b"); } else if (c == '\f') { this.add("\\f"); } else if (c == '\n') { this.add("\\n"); } else if (c == '\r') { this.add("\\r"); } else if (c == '\t') { this.add("\\t"); } else if (Character.isISOControl(c)) { this.unicode(c); } else { this.add(c); } } this.add('"'); } /** * Add object to buffer */ protected void add(Object obj) { this.buf.append(obj); } /** * Add char to buffer */ protected void add(char c) { this.buf.append(c); } /** * Represent as unicode * * @param c character to be encoded */ protected void unicode(char c) { this.add("\\u"); int n = c; for (int i = 0; i < 4; ++i) { int digit = (n & 0xf000) >> 12; this.add(hex[digit]); n <<= 4; } } public void setIgnoreHierarchy(boolean ignoreHierarchy) { this.ignoreHierarchy = ignoreHierarchy; } /** * If true, an Enum is serialized as a bean with a special property * _name=name() as all as all other properties defined within the enum.<br/> * If false, an Enum is serialized as a name=value pair (name=name()) * * @param enumAsBean true to serialize an enum as a bean instead of as a name=value * pair (default=false) */ public void setEnumAsBean(boolean enumAsBean) { this.enumAsBean = enumAsBean; } protected static class JSONAnnotationFinder { private boolean serialize = true; private Method accessor; private String name; public JSONAnnotationFinder(Method accessor) { this.accessor = accessor; } public boolean shouldSerialize() { return serialize; } public String getName() { return name; } public JSONAnnotationFinder invoke() { JSON json = accessor.getAnnotation(JSON.class); serialize = json.serialize(); if (serialize && json.name().length() > 0) { name = json.name(); } return this; } } }