/* Copyright 2005-2006 Tim Fennell * * 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 net.sourceforge.stripes.ajax; import net.sourceforge.stripes.exception.StripesRuntimeException; import net.sourceforge.stripes.util.Log; import net.sourceforge.stripes.util.ReflectUtil; import java.beans.PropertyDescriptor; import java.io.StringWriter; import java.io.Writer; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Random; import java.util.Set; import java.lang.reflect.Method; import java.lang.reflect.Array; import net.sourceforge.stripes.action.ObjectOutputBuilder; /** * <p> * Builds a set of JavaScript statements that will re-construct the value of a * Java object, including all Number, String, Enum, Boolean, Collection, Map and * Array properties. Safely handles object graph circularities - each object * will be translated only once, and all references will be valid.</p> * * <p> * The JavaScript created by the builder can be evaluated in JavaScript * using:</p> * * <pre> * var myObject = eval(generatedFragment); * </pre> * * @author Tim Fennell * @author Rick Grashel * @since Stripes 1.1 */ public class JavaScriptBuilder extends ObjectOutputBuilder<JavaScriptBuilder> { /** * Log instance used to log messages. */ private static final Log log = Log.getInstance(JavaScriptBuilder.class); /** * Holds the set of objects that have been visited during conversion. */ private Set<Integer> visitedIdentities = new HashSet<Integer>(); /** * Holds a map of name to JSON value for JS Objects and Arrays. */ private Map<String, String> objectValues = new HashMap<String, String>(); /** * Holds a map of object.property = object. */ private Map<String, String> assignments = new HashMap<String, String>(); /** * Constructs a new JavaScriptBuilder to build JS for the root object * supplied. * * @param root The root object from which to being translation into * JavaScript * @param objectsToExclude Zero or more Strings and/or Classes to be * excluded from translation. */ public JavaScriptBuilder(Object root, Object... objectsToExclude) { super(root, objectsToExclude); setRootVariableName("_sj_root_" + new Random().nextInt(Integer.MAX_VALUE)); } /** * Causes the JavaScriptBuilder to navigate the properties of the supplied * object and convert them to JavaScript, writing them to the supplied * writer as it goes. */ @Override public void build(Writer writer) { try { // If for some reason a caller provided us with a simple scalar object, then // convert it and short-circuit return if (isScalarType(getRootObject())) { writer.write(getScalarAsString(getRootObject())); writer.write(";\n"); return; } buildNode(getRootVariableName(), getRootObject(), ""); writer.write("var "); writer.write(getRootVariableName()); writer.write(";\n"); for (Map.Entry<String, String> entry : objectValues.entrySet()) { writer.append("var "); writer.append(entry.getKey()); writer.append(" = "); writer.append(entry.getValue()); writer.append(";\n"); } for (Map.Entry<String, String> entry : assignments.entrySet()) { writer.append(entry.getKey()); writer.append(" = "); writer.append(entry.getValue()); writer.append(";\n"); } writer.append(getRootVariableName()).append(";\n"); } catch (Exception e) { throw new StripesRuntimeException("Could not build JavaScript for object. An " + "exception was thrown while trying to convert a property from Java to " + "JavaScript. The object being converted is: " + getRootObject(), e); } } /** * Quotes the supplied String and escapes all characters that could be * problematic when eval()'ing the String in JavaScript. * * @param string a String to be escaped and quoted * @return the escaped and quoted String * @since Stripes 1.2 (thanks to Sergey Pariev) */ public static String quote(String string) { if (string == null || string.length() == 0) { return "\"\""; } char c ; int len = string.length(); StringBuilder sb = new StringBuilder(len + 10); sb.append('"'); for (int i = 0; i < len; ++i) { c = string.charAt(i); switch (c) { case '\\': case '"': sb.append('\\').append(c); break; case '\b': sb.append("\\b"); break; case '\t': sb.append("\\t"); break; case '\n': sb.append("\\n"); break; case '\f': sb.append("\\f"); break; case '\r': sb.append("\\r"); break; default: if (c < ' ') { // The following takes lower order chars and creates unicode style // char literals for them (e.g. \u00F3) sb.append("\\u"); String hex = Integer.toHexString(c); int pad = 4 - hex.length(); for (int j = 0; j < pad; ++j) { sb.append("0"); } sb.append(hex); } else { sb.append(c); } } } sb.append('"'); return sb.toString(); } /** * Determines the type of the object being translated and dispatches to the * build*Node() method. Generates the temporary name of the object being * translated, checks to ensure that the object has not already been * translated, and ensure that the object is correctly inserted into the set * of assignments. * * @param name The name that should appear on the left hand side of the * assignment statement once a value for the object has been generated. * @param in The object being translated. */ void buildNode(String name, Object in, String propertyPrefix) throws Exception { int systemId = System.identityHashCode(in); String targetName = "_sj_" + systemId; if (this.visitedIdentities.contains(systemId)) { this.assignments.put(name, targetName); } else if (isExcludedType(in.getClass())) { // Do nothing, it's being excluded!! } else { this.visitedIdentities.add(systemId); if (Collection.class.isAssignableFrom(in.getClass())) { buildCollectionNode(targetName, (Collection<?>) in, propertyPrefix); } else if (in.getClass().isArray()) { buildArrayNode(targetName, in, propertyPrefix); } else if (Map.class.isAssignableFrom(in.getClass())) { buildMapNode(targetName, (Map<?, ?>) in, propertyPrefix); } else { buildObjectNode(targetName, in, propertyPrefix); } this.assignments.put(name, targetName); } } /** * <p> * Processes a Java Object that conforms to JavaBean conventions. Scalar * properties of the object are converted to a JSON format object * declaration which is inserted into the "objectValues" instance level map. * Nested non-scalar objects are processed separately and then setup for * re-attachment using the instance level "assignments" map.</p> * * <p> * In most cases just the JavaBean properties will be translated. In the * case of Java 5 enums, two additional properties will be translated, one * each for the enum's 'ordinal' and 'name' properties.</p> * * @param targetName The generated name assigned to the Object being * translated * @param in The Object who's JavaBean properties are to be translated */ void buildObjectNode(String targetName, Object in, String propertyPrefix) throws Exception { StringBuilder out = new StringBuilder(); out.append("{"); PropertyDescriptor[] props = ReflectUtil.getPropertyDescriptors(in.getClass()); for (PropertyDescriptor property : props) { try { Method readMethod = property.getReadMethod(); String fullPropertyName = (propertyPrefix != null && propertyPrefix.length() > 0 ? propertyPrefix + '.' : "") + property.getName(); if ((readMethod != null) && !getExcludedProperties().contains(fullPropertyName)) { Object value = property.getReadMethod().invoke(in); if (isExcludedType(property.getPropertyType())) { continue; } if (isScalarType(value)) { if (out.length() > 1) { out.append(", "); } out.append(property.getName()); out.append(":"); out.append(getScalarAsString(value)); } else { buildNode(targetName + "." + property.getName(), value, fullPropertyName); } } } catch (Exception e) { log.warn(e, "Could not translate property [", property.getName(), "] of type [", property.getPropertyType().getName(), "] due to an exception."); } } // Do something a little extra for enums if (Enum.class.isAssignableFrom(in.getClass())) { Enum<?> e = (Enum<?>) in; if (out.length() > 1) { out.append(", "); } out.append("ordinal:").append(getScalarAsString(e.ordinal())); out.append(", name:").append(getScalarAsString(e.name())); } out.append("}"); this.objectValues.put(targetName, out.toString()); } /** * Builds a JavaScript object node from a java Map. The keys of the map are * used to define the properties of the JavaScript object. As such it is * assumed that the keys are either primitives, Strings or toString() * cleanly. The values of the map are used to generate the values of the * object properties. Scalar values are inserted directly into the JSON * representation, while complex types are converted separately and then * attached using assignments. * * @param targetName The generated name assigned to the Map being translated * @param in The Map being translated */ void buildMapNode(String targetName, Map<?, ?> in, String propertyPrefix) throws Exception { StringBuilder out = new StringBuilder(); out.append("{"); for (Map.Entry<?, ?> entry : in.entrySet()) { String propertyName = getScalarAsString(entry.getKey()); Object value = entry.getValue(); if (getExcludedProperties().contains(propertyPrefix + '[' + propertyName + ']')) { // Do nothing, it's being excluded!! } else if (isScalarType(value)) { if (out.length() > 1) { out.append(", "); } out.append(propertyName); out.append(":"); out.append(getScalarAsString(value)); } else { buildNode(targetName + "[" + propertyName + "]", value, propertyPrefix + "[" + propertyName + "]"); } } out.append("}"); this.objectValues.put(targetName, out.toString()); } /** * Builds a JavaScript array node from a Java array. Scalar values are * inserted directly into the array definition. Complex values are processed * separately - they are inserted into the JSON array as null to maintain * ordering, and re-attached later using assignments. * * @param targetName The generated name of the array node being translated. * @param in The Array being translated. */ void buildArrayNode(String targetName, Object in, String propertyPrefix) throws Exception { StringBuilder out = new StringBuilder(); out.append("["); int length = Array.getLength(in); for (int i = 0; i < length; i++) { Object value = Array.get(in, i); if (getExcludedProperties().contains(propertyPrefix + '[' + i + ']')) { // It's being excluded but we should leave a placeholder in the array out.append("null"); } else if (isScalarType(value)) { out.append(getScalarAsString(value)); } else { out.append("null"); buildNode(targetName + "[" + i + "]", value, propertyPrefix + "[" + i + "]"); } if (i != length - 1) { out.append(", "); } } out.append("]"); this.objectValues.put(targetName, out.toString()); } /** * Builds an object node that is of type collection. Simply converts the * collection to an array, and delegates to buildArrayNode(). */ void buildCollectionNode(String targetName, Collection<?> in, String propertyPrefix) throws Exception { buildArrayNode(targetName, in.toArray(), propertyPrefix); } /** * Fetches the value of a scalar type as a String. The input to this method * may not be null, and must be a of a type that will return true when * supplied to isScalarType(). */ public String getScalarAsString(Object in) { if (in == null) { return "null"; } Class<? extends Object> type = in.getClass(); if (String.class.isAssignableFrom(type)) { return quote((String) in); } else if (Character.class.isAssignableFrom(type)) { return quote(((Character) in).toString()); } else if (Date.class.isAssignableFrom(type)) { return "new Date(" + ((Date) in).getTime() + ")"; } else { return in.toString(); } } }