diff --git a/subprojects/groovy-json/src/main/java/groovy/json/DefaultJsonGenerator.java b/subprojects/groovy-json/src/main/java/groovy/json/DefaultJsonGenerator.java new file mode 100644 index 0000000000..1dbd0ad9bc --- /dev/null +++ b/subprojects/groovy-json/src/main/java/groovy/json/DefaultJsonGenerator.java @@ -0,0 +1,549 @@ +/* + * 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 groovy.json; + +import groovy.json.internal.CharBuf; +import groovy.json.internal.Chr; +import groovy.lang.Closure; +import groovy.util.Expando; +import org.codehaus.groovy.runtime.DefaultGroovyMethods; + +import java.io.File; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.URL; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import java.util.UUID; + +import static groovy.json.JsonOutput.CLOSE_BRACE; +import static groovy.json.JsonOutput.CLOSE_BRACKET; +import static groovy.json.JsonOutput.COMMA; +import static groovy.json.JsonOutput.EMPTY_LIST_CHARS; +import static groovy.json.JsonOutput.EMPTY_MAP_CHARS; +import static groovy.json.JsonOutput.EMPTY_STRING_CHARS; +import static groovy.json.JsonOutput.OPEN_BRACE; +import static groovy.json.JsonOutput.OPEN_BRACKET; + +/** + * A JsonGenerator that can be configured with various {@link JsonGenerator.Options}. + * If the default options are sufficient consider using the static {@code JsonOutput.toJson} + * methods. + * + * @see JsonGenerator.Options#build() + * @since 2.5 + */ +public class DefaultJsonGenerator implements JsonGenerator { + + protected final boolean excludeNulls; + protected final boolean disableUnicodeEscaping; + protected final String dateFormat; + protected final Locale dateLocale; + protected final TimeZone timezone; + protected final Set converters = new LinkedHashSet(); + protected final Set excludedFieldNames = new HashSet(); + protected final Set> excludedFieldTypes = new HashSet>(); + + protected DefaultJsonGenerator(Options options) { + excludeNulls = options.excludeNulls; + disableUnicodeEscaping = options.disableUnicodeEscaping; + dateFormat = options.dateFormat; + dateLocale = options.dateLocale; + timezone = options.timezone; + if (!options.converters.isEmpty()) { + converters.addAll(options.converters); + } + if (!options.excludedFieldNames.isEmpty()) { + excludedFieldNames.addAll(options.excludedFieldNames); + } + if (!options.excludedFieldTypes.isEmpty()) { + excludedFieldTypes.addAll(options.excludedFieldTypes); + } + } + + /** + * {@inheritDoc} + */ + @Override + public String toJson(Object object) { + CharBuf buffer = CharBuf.create(255); + writeObject(object, buffer); + return buffer.toString(); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isExcludingFieldsNamed(String name) { + return excludedFieldNames.contains(name); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isExcludingValues(Object value) { + if (value == null) { + return excludeNulls; + } else { + return shouldExcludeType(value.getClass()); + } + } + + /** + * Serializes Number value and writes it into specified buffer. + */ + protected void writeNumber(Class numberClass, Number value, CharBuf buffer) { + if (numberClass == Integer.class) { + buffer.addInt((Integer) value); + } else if (numberClass == Long.class) { + buffer.addLong((Long) value); + } else if (numberClass == BigInteger.class) { + buffer.addBigInteger((BigInteger) value); + } else if (numberClass == BigDecimal.class) { + buffer.addBigDecimal((BigDecimal) value); + } else if (numberClass == Double.class) { + Double doubleValue = (Double) value; + if (doubleValue.isInfinite()) { + throw new JsonException("Number " + value + " can't be serialized as JSON: infinite are not allowed in JSON."); + } + if (doubleValue.isNaN()) { + throw new JsonException("Number " + value + " can't be serialized as JSON: NaN are not allowed in JSON."); + } + + buffer.addDouble(doubleValue); + } else if (numberClass == Float.class) { + Float floatValue = (Float) value; + if (floatValue.isInfinite()) { + throw new JsonException("Number " + value + " can't be serialized as JSON: infinite are not allowed in JSON."); + } + if (floatValue.isNaN()) { + throw new JsonException("Number " + value + " can't be serialized as JSON: NaN are not allowed in JSON."); + } + + buffer.addFloat(floatValue); + } else if (numberClass == Byte.class) { + buffer.addByte((Byte) value); + } else if (numberClass == Short.class) { + buffer.addShort((Short) value); + } else { // Handle other Number implementations + buffer.addString(value.toString()); + } + } + + protected void writeObject(Object object, CharBuf buffer) { + writeObject(null, object, buffer); + } + + /** + * Serializes object and writes it into specified buffer. + */ + protected void writeObject(String key, Object object, CharBuf buffer) { + + if (isExcludingValues(object)) { + return; + } + + if (object == null) { + buffer.addNull(); + return; + } + + Class objectClass = object.getClass(); + + Converter converter = findConverter(objectClass); + if (converter != null) { + writeRaw(converter.convert(object, key), buffer); + return; + } + + if (CharSequence.class.isAssignableFrom(objectClass)) { // Handle String, StringBuilder, GString and other CharSequence implementations + writeCharSequence((CharSequence) object, buffer); + } else if (objectClass == Boolean.class) { + buffer.addBoolean((Boolean) object); + } else if (Number.class.isAssignableFrom(objectClass)) { + writeNumber(objectClass, (Number) object, buffer); + } else if (Date.class.isAssignableFrom(objectClass)) { + writeDate((Date) object, buffer); + } else if (Calendar.class.isAssignableFrom(objectClass)) { + writeDate(((Calendar) object).getTime(), buffer); + } else if (Map.class.isAssignableFrom(objectClass)) { + writeMap((Map) object, buffer); + } else if (Iterable.class.isAssignableFrom(objectClass)) { + writeIterator(((Iterable) object).iterator(), buffer); + } else if (Iterator.class.isAssignableFrom(objectClass)) { + writeIterator((Iterator) object, buffer); + } else if (objectClass == Character.class) { + buffer.addJsonEscapedString(Chr.array((Character) object), disableUnicodeEscaping); + } else if (objectClass == URL.class) { + buffer.addJsonEscapedString(object.toString(), disableUnicodeEscaping); + } else if (objectClass == UUID.class) { + buffer.addQuoted(object.toString()); + } else if (objectClass == JsonOutput.JsonUnescaped.class) { + buffer.add(object.toString()); + } else if (Closure.class.isAssignableFrom(objectClass)) { + writeMap(JsonDelegate.cloneDelegateAndGetContent((Closure) object), buffer); + } else if (Expando.class.isAssignableFrom(objectClass)) { + writeMap(((Expando) object).getProperties(), buffer); + } else if (Enumeration.class.isAssignableFrom(objectClass)) { + List list = Collections.list((Enumeration) object); + writeIterator(list.iterator(), buffer); + } else if (objectClass.isArray()) { + writeArray(objectClass, object, buffer); + } else if (Enum.class.isAssignableFrom(objectClass)) { + buffer.addQuoted(((Enum) object).name()); + } else if (File.class.isAssignableFrom(objectClass)) { + Map properties = getObjectProperties(object); + //Clean up all recursive references to File objects + Iterator> iterator = properties.entrySet().iterator(); + while(iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if(entry.getValue() instanceof File) { + iterator.remove(); + } + } + writeMap(properties, buffer); + } else { + Map properties = getObjectProperties(object); + writeMap(properties, buffer); + } + } + + protected Map getObjectProperties(Object object) { + Map properties = DefaultGroovyMethods.getProperties(object); + properties.remove("class"); + properties.remove("declaringClass"); + properties.remove("metaClass"); + return properties; + } + + /** + * Serializes any char sequence and writes it into specified buffer. + */ + protected void writeCharSequence(CharSequence seq, CharBuf buffer) { + if (seq.length() > 0) { + buffer.addJsonEscapedString(seq.toString(), disableUnicodeEscaping); + } else { + buffer.addChars(EMPTY_STRING_CHARS); + } + } + + /** + * Serializes any char sequence and writes it into specified buffer + * without performing any manipulation of the given text. + */ + protected void writeRaw(CharSequence seq, CharBuf buffer) { + if (seq != null) { + buffer.add(seq.toString()); + } + } + + /** + * Serializes date and writes it into specified buffer. + */ + protected void writeDate(Date date, CharBuf buffer) { + SimpleDateFormat formatter = new SimpleDateFormat(dateFormat, dateLocale); + formatter.setTimeZone(timezone); + buffer.addQuoted(formatter.format(date)); + } + + /** + * Serializes array and writes it into specified buffer. + */ + protected void writeArray(Class arrayClass, Object array, CharBuf buffer) { + if (Object[].class.isAssignableFrom(arrayClass)) { + Object[] objArray = (Object[]) array; + writeIterator(Arrays.asList(objArray).iterator(), buffer); + return; + } + buffer.addChar(OPEN_BRACKET); + if (int[].class.isAssignableFrom(arrayClass)) { + int[] intArray = (int[]) array; + if (intArray.length > 0) { + buffer.addInt(intArray[0]); + for (int i = 1; i < intArray.length; i++) { + buffer.addChar(COMMA).addInt(intArray[i]); + } + } + } else if (long[].class.isAssignableFrom(arrayClass)) { + long[] longArray = (long[]) array; + if (longArray.length > 0) { + buffer.addLong(longArray[0]); + for (int i = 1; i < longArray.length; i++) { + buffer.addChar(COMMA).addLong(longArray[i]); + } + } + } else if (boolean[].class.isAssignableFrom(arrayClass)) { + boolean[] booleanArray = (boolean[]) array; + if (booleanArray.length > 0) { + buffer.addBoolean(booleanArray[0]); + for (int i = 1; i < booleanArray.length; i++) { + buffer.addChar(COMMA).addBoolean(booleanArray[i]); + } + } + } else if (char[].class.isAssignableFrom(arrayClass)) { + char[] charArray = (char[]) array; + if (charArray.length > 0) { + buffer.addJsonEscapedString(Chr.array(charArray[0]), disableUnicodeEscaping); + for (int i = 1; i < charArray.length; i++) { + buffer.addChar(COMMA).addJsonEscapedString(Chr.array(charArray[i]), disableUnicodeEscaping); + } + } + } else if (double[].class.isAssignableFrom(arrayClass)) { + double[] doubleArray = (double[]) array; + if (doubleArray.length > 0) { + buffer.addDouble(doubleArray[0]); + for (int i = 1; i < doubleArray.length; i++) { + buffer.addChar(COMMA).addDouble(doubleArray[i]); + } + } + } else if (float[].class.isAssignableFrom(arrayClass)) { + float[] floatArray = (float[]) array; + if (floatArray.length > 0) { + buffer.addFloat(floatArray[0]); + for (int i = 1; i < floatArray.length; i++) { + buffer.addChar(COMMA).addFloat(floatArray[i]); + } + } + } else if (byte[].class.isAssignableFrom(arrayClass)) { + byte[] byteArray = (byte[]) array; + if (byteArray.length > 0) { + buffer.addByte(byteArray[0]); + for (int i = 1; i < byteArray.length; i++) { + buffer.addChar(COMMA).addByte(byteArray[i]); + } + } + } else if (short[].class.isAssignableFrom(arrayClass)) { + short[] shortArray = (short[]) array; + if (shortArray.length > 0) { + buffer.addShort(shortArray[0]); + for (int i = 1; i < shortArray.length; i++) { + buffer.addChar(COMMA).addShort(shortArray[i]); + } + } + } + buffer.addChar(CLOSE_BRACKET); + } + + /** + * Serializes map and writes it into specified buffer. + */ + protected void writeMap(Map map, CharBuf buffer) { + if (map.isEmpty()) { + buffer.addChars(EMPTY_MAP_CHARS); + return; + } + buffer.addChar(OPEN_BRACE); + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() == null) { + throw new IllegalArgumentException("Maps with null keys can\'t be converted to JSON"); + } + String key = entry.getKey().toString(); + Object value = entry.getValue(); + if (isExcludingValues(value) || isExcludingFieldsNamed(key)) { + continue; + } + writeMapEntry(key, value, buffer); + buffer.addChar(COMMA); + } + buffer.removeLastChar(COMMA); // dangling comma + buffer.addChar(CLOSE_BRACE); + } + + /** + * Serializes a map entry and writes it into specified buffer. + */ + protected void writeMapEntry(String key, Object value, CharBuf buffer) { + buffer.addJsonFieldName(key, disableUnicodeEscaping); + writeObject(key, value, buffer); + } + + /** + * Serializes iterator and writes it into specified buffer. + */ + protected void writeIterator(Iterator iterator, CharBuf buffer) { + if (!iterator.hasNext()) { + buffer.addChars(EMPTY_LIST_CHARS); + return; + } + buffer.addChar(OPEN_BRACKET); + while (iterator.hasNext()) { + Object it = iterator.next(); + if (!isExcludingValues(it)) { + writeObject(it, buffer); + buffer.addChar(COMMA); + } + } + buffer.removeLastChar(COMMA); // dangling comma + buffer.addChar(CLOSE_BRACKET); + } + + /** + * Finds a converter that can handle the given type. The first converter + * that reports it can handle the type is returned, based on the order in + * which the converters were specified. A {@code null} value will be returned + * if no suitable converter can be found for the given type. + * + * @param type that this converter can handle + * @return first converter that can handle the given type; else {@code null} + * if no compatible converters are found for the given type. + */ + protected Converter findConverter(Class type) { + for (Converter c : converters) { + if (c.handles(type)) { + return c; + } + } + return null; + } + + /** + * Indicates whether the given type should be excluded from the generated output. + * + * @param type the type to check + * @return {@code true} if the given type should not be output, else {@code false} + */ + protected boolean shouldExcludeType(Class type) { + for (Class t : excludedFieldTypes) { + if (t.isAssignableFrom(type)) { + return true; + } + } + return false; + } + + /** + * A converter that handles converting a given type to a JSON value + * using a closure. + * + * @since 2.5 + */ + protected static class ClosureConverter implements Converter { + + protected final Class type; + protected final Closure closure; + protected final int paramCount; + + protected ClosureConverter(Class type, Closure closure) { + if (type == null) { + throw new NullPointerException("Type parameter must not be null"); + } + if (closure == null) { + throw new NullPointerException("Closure parameter must not be null"); + } + + int paramCount = closure.getMaximumNumberOfParameters(); + if (paramCount < 1) { + throw new IllegalArgumentException("Closure must accept at least one parameter"); + } + Class param1 = closure.getParameterTypes()[0]; + if (!param1.isAssignableFrom(type)) { + throw new IllegalArgumentException("Expected first parameter to be of type: " + type.toString()); + } + if (paramCount > 1) { + Class param2 = closure.getParameterTypes()[1]; + if (!param2.isAssignableFrom(String.class)) { + throw new IllegalArgumentException("Expected second parameter to be of type: " + String.class.toString()); + } + } + this.type = type; + this.closure = closure; + this.paramCount = paramCount; + } + + /** + * Returns {@code true} if this converter can handle conversions + * of the given type. + * + * @param type the type of the object to convert + * @return true if this converter can successfully convert values of + * the given type to a JSON value + */ + public boolean handles(Class type) { + return this.type.isAssignableFrom(type); + } + + /** + * Converts a given value to a JSON value. + * + * @param value the object to convert + * @return a JSON value representing the value + */ + public CharSequence convert(Object value) { + return convert(value, null); + } + + /** + * Converts a given value to a JSON value. + * + * @param value the object to convert + * @param key the key name for the value, may be {@code null} + * @return a JSON value representing the value + */ + public CharSequence convert(Object value, String key) { + return (paramCount == 1) ? + closure.call(value) : + closure.call(value, key); + } + + /** + * Any two Converter instances registered for the same type are considered + * to be equal. This comparison makes managing instances in a Set easier; + * since there is no chaining of Converters it makes sense to only allow + * one per type. + * + * @param o the object with which to compare. + * @return {@code true} if this object contains the same class; {@code false} otherwise. + */ + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (!(o instanceof ClosureConverter)) { + return false; + } + return this.type == ((ClosureConverter)o).type; + } + + @Override + public int hashCode() { + return this.type.hashCode(); + } + + @Override + public String toString() { + return super.toString() + "<" + this.type.toString() + ">"; + } + } + +} diff --git a/subprojects/groovy-json/src/main/java/groovy/json/JsonBuilder.java b/subprojects/groovy-json/src/main/java/groovy/json/JsonBuilder.java index 0a30b7d1b3..abaac0b494 100644 --- a/subprojects/groovy-json/src/main/java/groovy/json/JsonBuilder.java +++ b/subprojects/groovy-json/src/main/java/groovy/json/JsonBuilder.java @@ -65,12 +65,24 @@ */ public class JsonBuilder extends GroovyObjectSupport implements Writable { + private final JsonGenerator generator; private Object content; /** * Instantiates a JSON builder. */ public JsonBuilder() { + this.generator = JsonOutput.DEFAULT_GENERATOR; + } + + /** + * Instantiates a JSON builder with a configured generator. + * + * @param generator used to generate the output + * @since 2.5 + */ + public JsonBuilder(JsonGenerator generator) { + this.generator = generator; } /** @@ -80,6 +92,20 @@ public JsonBuilder() { */ public JsonBuilder(Object content) { this.content = content; + this.generator = JsonOutput.DEFAULT_GENERATOR; + } + + /** + * Instantiates a JSON builder with some existing data structure + * and a configured generator. + * + * @param content a pre-existing data structure + * @param generator used to generate the output + * @since 2.5 + */ + public JsonBuilder(Object content, JsonGenerator generator) { + this.content = content; + this.generator = generator; } public Object getContent() { @@ -344,7 +370,7 @@ private Object setAndGetContent(String name, Object value) { * @return a JSON output */ public String toString() { - return JsonOutput.toJson(content); + return generator.toJson(content); } /** diff --git a/subprojects/groovy-json/src/main/java/groovy/json/JsonGenerator.java b/subprojects/groovy-json/src/main/java/groovy/json/JsonGenerator.java new file mode 100644 index 0000000000..91b0e06cc3 --- /dev/null +++ b/subprojects/groovy-json/src/main/java/groovy/json/JsonGenerator.java @@ -0,0 +1,326 @@ +/* + * 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 groovy.json; + +import groovy.lang.Closure; +import groovy.transform.stc.ClosureParams; +import groovy.transform.stc.FromString; + +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.TimeZone; + +/** + * Generates JSON from objects. + * + * The {@link Options} builder can be used to configure an instance of a JsonGenerator. + * + * @see Options#build() + * @since 2.5 + */ +public interface JsonGenerator { + + /** + * Converts an object to its JSON representation. + * + * @param object to convert to JSON + * @return JSON + */ + String toJson(Object object); + + /** + * Indicates whether this JsonGenerator is configured to exclude fields by + * the given name. + * + * @param name of the field + * @return true if that field is being excluded, else false + */ + boolean isExcludingFieldsNamed(String name); + + /** + * Indicates whether this JsonGenerator is configured to exclude values + * of the given object (may be {@code null}). + * + * @param value an instance of an object + * @return true if values like this are being excluded, else false + */ + boolean isExcludingValues(Object value); + + /** + * Handles converting a given type to a JSON value. + * + * @since 2.5 + */ + interface Converter { + + /** + * Returns {@code true} if this converter can handle conversions + * of the given type. + * + * @param type the type of the object to convert + * @return {@code true} if this converter can successfully convert values of + * the given type to a JSON value, else {@code false} + */ + boolean handles(Class type); + + /** + * Converts a given object to a JSON value. + * + * @param value the object to convert + * @return a JSON value representing the object + */ + CharSequence convert(Object value); + + /** + * Converts a given object to a JSON value. + * + * @param value the object to convert + * @param key the key name for the value, may be {@code null} + * @return a JSON value representing the object + */ + CharSequence convert(Object value, String key); + + } + + /** + * A builder used to construct a {@link JsonGenerator} instance that allows + * control over the serialized JSON output. If you do not need to customize the + * output it is recommended to use the static {@code JsonOutput.toJson} methods. + * + *

+ * Example: + *


+     *     def generator = new groovy.json.JsonGenerator.Options()
+     *                         .excludeNulls()
+     *                         .dateFormat('yyyy')
+     *                         .excludeFieldsByName('bar', 'baz')
+     *                         .excludeFieldsByType(java.sql.Date)
+     *                         .build()
+     *
+     *     def input = [foo: null, lastUpdated: Date.parse('yyyy-MM-dd', '2014-10-24'),
+     *                   bar: 'foo', baz: 'foo', systemDate: new java.sql.Date(new Date().getTime())]
+     *
+     *     assert generator.toJson(input) == '{"lastUpdated":"2014"}'
+     * 
+ * + * @since 2.5 + */ + class Options { + + protected static final String JSON_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ"; + protected static final Locale JSON_DATE_FORMAT_LOCALE = Locale.US; + protected static final String DEFAULT_TIMEZONE = "GMT"; + + protected boolean excludeNulls; + protected boolean disableUnicodeEscaping; + protected String dateFormat = JSON_DATE_FORMAT; + protected Locale dateLocale = JSON_DATE_FORMAT_LOCALE; + protected TimeZone timezone = TimeZone.getTimeZone(DEFAULT_TIMEZONE); + protected final Set converters = new LinkedHashSet(); + protected final Set excludedFieldNames = new HashSet(); + protected final Set> excludedFieldTypes = new HashSet>(); + + public Options() {} + + /** + * Do not serialize {@code null} values. + * + * @return a reference to this {@code Options} instance + */ + public Options excludeNulls() { + excludeNulls = true; + return this; + } + + /** + * Disables the escaping of Unicode characters in JSON String values. + * + * @return a reference to this {@code Options} instance + */ + public Options disableUnicodeEscaping() { + disableUnicodeEscaping = true; + return this; + } + + /** + * Sets the date format that will be used to serialize {@code Date} objects. + * This must be a valid pattern for {@link java.text.SimpleDateFormat} and the + * date formatter will be constructed with the default locale of {@link Locale#US}. + * + * @param format date format pattern used to serialize dates + * @return a reference to this {@code Options} instance + * @exception NullPointerException if the given pattern is null + * @exception IllegalArgumentException if the given pattern is invalid + */ + public Options dateFormat(String format) { + return dateFormat(format, JSON_DATE_FORMAT_LOCALE); + } + + /** + * Sets the date format that will be used to serialize {@code Date} objects. + * This must be a valid pattern for {@link java.text.SimpleDateFormat}. + * + * @param format date format pattern used to serialize dates + * @param locale the locale whose date format symbols will be used + * @return a reference to this {@code Options} instance + * @exception IllegalArgumentException if the given pattern is invalid + */ + public Options dateFormat(String format, Locale locale) { + // validate date format pattern + new SimpleDateFormat(format, locale); + dateFormat = format; + dateLocale = locale; + return this; + } + + /** + * Sets the time zone that will be used to serialize dates. + * + * @param timezone used to serialize dates + * @return a reference to this {@code Options} instance + * @exception NullPointerException if the given timezone is null + */ + public Options timezone(String timezone) { + this.timezone = TimeZone.getTimeZone(timezone); + return this; + } + + /** + * Registers a closure that will be called when the specified type or subtype + * is serialized. + * + *

The closure must accept either 1 or 2 parameters. The first parameter + * is required and will be instance of the {@code type} for which the closure + * is registered. The second optional parameter should be of type {@code String} + * and, if available, will be passed the name of the key associated with this + * value if serializing a JSON Object. This parameter will be {@code null} when + * serializing a JSON Array or when there is no way to determine the name of the key. + * + *

The return value from the closure must be a valid JSON value. The result + * of the closure will be written to the internal buffer directly and no quoting, + * escaping or other manipulation will be done to the resulting output. + * + *

+ * Example: + *


+         *     def generator = new groovy.json.JsonGenerator.Options()
+         *                         .addConverter(URL) { URL u ->
+         *                             "\"${u.getHost()}\""
+         *                         }
+         *                         .build()
+         *
+         *     def input = [domain: new URL('http://groovy-lang.org/json.html#_parser_variants')]
+         *
+         *     assert generator.toJson(input) == '{"domain":"groovy-lang.org"}'
+         * 
+ * + *

If two or more closures are registered for the exact same type the last + * closure based on the order they were specified will be used. When serializing an + * object its type is compared to the list of registered types in the order the were + * given and the closure for the first suitable type will be called. Therefore, it is + * important to register more specific types first. + * + * @param type the type to convert + * @param closure called when the registered type or any type assignable to the given + * type is encountered + * @param the type this converter is registered to handle + * @return a reference to this {@code Options} instance + * @exception NullPointerException if the given type or closure is null + * @exception IllegalArgumentException if the given closure does not accept + * a parameter of the given type + */ + public Options addConverter(Class type, + @ClosureParams(value=FromString.class, options={"T","T,String"}) + Closure closure) + { + Converter converter = new DefaultJsonGenerator.ClosureConverter(type, closure); + if (converters.contains(converter)) { + converters.remove(converter); + } + converters.add(converter); + return this; + } + + /** + * Excludes from the output any fields that match the specified names. + * + * @param fieldNames name of the field to exclude from the output + * @return a reference to this {@code Options} instance + */ + public Options excludeFieldsByName(CharSequence... fieldNames) { + return excludeFieldsByName(Arrays.asList(fieldNames)); + } + + /** + * Excludes from the output any fields that match the specified names. + * + * @param fieldNames collection of names to exclude from the output + * @return a reference to this {@code Options} instance + */ + public Options excludeFieldsByName(Iterable fieldNames) { + for (CharSequence cs : fieldNames) { + if (cs != null) { + excludedFieldNames.add(cs.toString()); + } + } + return this; + } + + /** + * Excludes from the output any fields whose type is the same or is + * assignable to any of the given types. + * + * @param types excluded from the output + * @return a reference to this {@code Options} instance + */ + public Options excludeFieldsByType(Class... types) { + return excludeFieldsByType(Arrays.asList(types)); + } + + /** + * Excludes from the output any fields whose type is the same or is + * assignable to any of the given types. + * + * @param types collection of types to exclude from the output + * @return a reference to this {@code Options} instance + */ + public Options excludeFieldsByType(Iterable> types) { + for (Class c : types) { + if (c != null) { + excludedFieldTypes.add(c); + } + } + return this; + } + + /** + * Creates a {@link JsonGenerator} that is based on the current options. + * + * @return a fully configured {@link JsonGenerator} + */ + public JsonGenerator build() { + return new DefaultJsonGenerator(this); + } + } + +} diff --git a/subprojects/groovy-json/src/main/java/groovy/json/JsonOutput.java b/subprojects/groovy-json/src/main/java/groovy/json/JsonOutput.java index 322e9f122d..aa95b0783c 100644 --- a/subprojects/groovy-json/src/main/java/groovy/json/JsonOutput.java +++ b/subprojects/groovy-json/src/main/java/groovy/json/JsonOutput.java @@ -22,19 +22,17 @@ import groovy.json.internal.Chr; import groovy.lang.Closure; import groovy.util.Expando; -import org.codehaus.groovy.runtime.DefaultGroovyMethods; -import java.io.File; import java.io.StringReader; -import java.math.BigDecimal; -import java.math.BigInteger; import java.net.URL; -import java.text.SimpleDateFormat; import java.util.*; /** * Class responsible for the actual String serialization of the possible values of a JSON structure. * This class can also be used as a category, so as to add toJson() methods to various types. + *

+ * This class does not provide the ability to customize the resulting output. A {@link JsonGenerator} + * can be used if the ability to alter the resulting output is required. * * @author Guillaume Laforge * @author Roshan Dawrani @@ -42,6 +40,7 @@ * @author Rick Hightower * @author Graeme Rocher * + * @see JsonGenerator * @since 1.8.0 */ public class JsonOutput { @@ -56,20 +55,18 @@ public class JsonOutput { static final char NEW_LINE = '\n'; static final char QUOTE = '"'; - private static final char[] EMPTY_STRING_CHARS = Chr.array(QUOTE, QUOTE); + static final char[] EMPTY_STRING_CHARS = Chr.array(QUOTE, QUOTE); + static final char[] EMPTY_MAP_CHARS = {OPEN_BRACE, CLOSE_BRACE}; + static final char[] EMPTY_LIST_CHARS = {OPEN_BRACKET, CLOSE_BRACKET}; - private static final String NULL_VALUE = "null"; - private static final String JSON_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ"; - private static final String DEFAULT_TIMEZONE = "GMT"; + /* package-private for use in builders */ + static final JsonGenerator DEFAULT_GENERATOR = new DefaultJsonGenerator(new JsonGenerator.Options()); /** * @return "true" or "false" for a boolean value */ public static String toJson(Boolean bool) { - CharBuf buffer = CharBuf.create(4); - writeObject(bool, buffer); // checking null inside - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(bool); } /** @@ -77,39 +74,21 @@ public static String toJson(Boolean bool) { * @throws JsonException if the number is infinite or not a number. */ public static String toJson(Number n) { - if (n == null) { - return NULL_VALUE; - } - - CharBuf buffer = CharBuf.create(3); - Class numberClass = n.getClass(); - writeNumber(numberClass, n, buffer); - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(n); } /** * @return a JSON string representation of the character */ public static String toJson(Character c) { - CharBuf buffer = CharBuf.create(3); - writeObject(c, buffer); // checking null inside - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(c); } /** * @return a properly encoded string with escape sequences */ public static String toJson(String s) { - if (s == null) { - return NULL_VALUE; - } - - CharBuf buffer = CharBuf.create(s.length() + 2); - writeCharSequence(s, buffer); - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(s); } /** @@ -119,14 +98,7 @@ public static String toJson(String s) { * @return a formatted date in the form of a string */ public static String toJson(Date date) { - if (date == null) { - return NULL_VALUE; - } - - CharBuf buffer = CharBuf.create(26); - writeDate(date, buffer); - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(date); } /** @@ -136,62 +108,35 @@ public static String toJson(Date date) { * @return a formatted date in the form of a string */ public static String toJson(Calendar cal) { - if (cal == null) { - return NULL_VALUE; - } - - CharBuf buffer = CharBuf.create(26); - writeDate(cal.getTime(), buffer); - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(cal); } /** * @return the string representation of an uuid */ public static String toJson(UUID uuid) { - CharBuf buffer = CharBuf.create(64); - writeObject(uuid, buffer); // checking null inside - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(uuid); } /** * @return the string representation of the URL */ public static String toJson(URL url) { - CharBuf buffer = CharBuf.create(64); - writeObject(url, buffer); // checking null inside - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(url); } /** * @return an object representation of a closure */ public static String toJson(Closure closure) { - if (closure == null) { - return NULL_VALUE; - } - - CharBuf buffer = CharBuf.create(255); - writeMap(JsonDelegate.cloneDelegateAndGetContent(closure), buffer); - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(closure); } /** * @return an object representation of an Expando */ public static String toJson(Expando expando) { - if (expando == null) { - return NULL_VALUE; - } - - CharBuf buffer = CharBuf.create(255); - writeMap(expando.getProperties(), buffer); - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(expando); } /** @@ -199,289 +144,14 @@ public static String toJson(Expando expando) { * or representation for other object. */ public static String toJson(Object object) { - CharBuf buffer = CharBuf.create(255); - writeObject(object, buffer); // checking null inside - - return buffer.toString(); + return DEFAULT_GENERATOR.toJson(object); } /** * @return a JSON object representation for a map */ public static String toJson(Map m) { - if (m == null) { - return NULL_VALUE; - } - - CharBuf buffer = CharBuf.create(255); - writeMap(m, buffer); - - return buffer.toString(); - } - - /** - * Serializes Number value and writes it into specified buffer. - */ - private static void writeNumber(Class numberClass, Number value, CharBuf buffer) { - if (numberClass == Integer.class) { - buffer.addInt((Integer) value); - } else if (numberClass == Long.class) { - buffer.addLong((Long) value); - } else if (numberClass == BigInteger.class) { - buffer.addBigInteger((BigInteger) value); - } else if (numberClass == BigDecimal.class) { - buffer.addBigDecimal((BigDecimal) value); - } else if (numberClass == Double.class) { - Double doubleValue = (Double) value; - if (doubleValue.isInfinite()) { - throw new JsonException("Number " + value + " can't be serialized as JSON: infinite are not allowed in JSON."); - } - if (doubleValue.isNaN()) { - throw new JsonException("Number " + value + " can't be serialized as JSON: NaN are not allowed in JSON."); - } - - buffer.addDouble(doubleValue); - } else if (numberClass == Float.class) { - Float floatValue = (Float) value; - if (floatValue.isInfinite()) { - throw new JsonException("Number " + value + " can't be serialized as JSON: infinite are not allowed in JSON."); - } - if (floatValue.isNaN()) { - throw new JsonException("Number " + value + " can't be serialized as JSON: NaN are not allowed in JSON."); - } - - buffer.addFloat(floatValue); - } else if (numberClass == Byte.class) { - buffer.addByte((Byte) value); - } else if (numberClass == Short.class) { - buffer.addShort((Short) value); - } else { // Handle other Number implementations - buffer.addString(value.toString()); - } - } - - /** - * Serializes object and writes it into specified buffer. - */ - private static void writeObject(Object object, CharBuf buffer) { - if (object == null) { - buffer.addNull(); - } else { - Class objectClass = object.getClass(); - - if (CharSequence.class.isAssignableFrom(objectClass)) { // Handle String, StringBuilder, GString and other CharSequence implementations - writeCharSequence((CharSequence) object, buffer); - } else if (objectClass == Boolean.class) { - buffer.addBoolean((Boolean) object); - } else if (Number.class.isAssignableFrom(objectClass)) { - writeNumber(objectClass, (Number) object, buffer); - } else if (Date.class.isAssignableFrom(objectClass)) { - writeDate((Date) object, buffer); - } else if (Calendar.class.isAssignableFrom(objectClass)) { - writeDate(((Calendar) object).getTime(), buffer); - } else if (Map.class.isAssignableFrom(objectClass)) { - writeMap((Map) object, buffer); - } else if (Iterable.class.isAssignableFrom(objectClass)) { - writeIterator(((Iterable) object).iterator(), buffer); - } else if (Iterator.class.isAssignableFrom(objectClass)) { - writeIterator((Iterator) object, buffer); - } else if (objectClass == Character.class) { - buffer.addJsonEscapedString(Chr.array((Character) object)); - } else if (objectClass == URL.class) { - buffer.addJsonEscapedString(object.toString()); - } else if (objectClass == UUID.class) { - buffer.addQuoted(object.toString()); - } else if (objectClass == JsonUnescaped.class) { - buffer.add(object.toString()); - } else if (Closure.class.isAssignableFrom(objectClass)) { - writeMap(JsonDelegate.cloneDelegateAndGetContent((Closure) object), buffer); - } else if (Expando.class.isAssignableFrom(objectClass)) { - writeMap(((Expando) object).getProperties(), buffer); - } else if (Enumeration.class.isAssignableFrom(objectClass)) { - List list = Collections.list((Enumeration) object); - writeIterator(list.iterator(), buffer); - } else if (objectClass.isArray()) { - writeArray(objectClass, object, buffer); - } else if (Enum.class.isAssignableFrom(objectClass)) { - buffer.addQuoted(((Enum) object).name()); - }else if (File.class.isAssignableFrom(objectClass)){ - Map properties = getObjectProperties(object); - //Clean up all recursive references to File objects - Iterator> iterator = properties.entrySet().iterator(); - while(iterator.hasNext()){ - Map.Entry entry = iterator.next(); - if(entry.getValue() instanceof File){ - iterator.remove(); - } - } - - writeMap(properties, buffer); - } else { - Map properties = getObjectProperties(object); - writeMap(properties, buffer); - } - } - } - - private static Map getObjectProperties(Object object) { - Map properties = DefaultGroovyMethods.getProperties(object); - properties.remove("class"); - properties.remove("declaringClass"); - properties.remove("metaClass"); - return properties; - } - - - /** - * Serializes any char sequence and writes it into specified buffer. - */ - private static void writeCharSequence(CharSequence seq, CharBuf buffer) { - if (seq.length() > 0) { - buffer.addJsonEscapedString(seq.toString()); - } else { - buffer.addChars(EMPTY_STRING_CHARS); - } - } - - /** - * Serializes date and writes it into specified buffer. - */ - private static void writeDate(Date date, CharBuf buffer) { - SimpleDateFormat formatter = new SimpleDateFormat(JSON_DATE_FORMAT, Locale.US); - formatter.setTimeZone(TimeZone.getTimeZone(DEFAULT_TIMEZONE)); - buffer.addQuoted(formatter.format(date)); - } - - /** - * Serializes array and writes it into specified buffer. - */ - private static void writeArray(Class arrayClass, Object array, CharBuf buffer) { - buffer.addChar(OPEN_BRACKET); - if (Object[].class.isAssignableFrom(arrayClass)) { - Object[] objArray = (Object[]) array; - if (objArray.length > 0) { - writeObject(objArray[0], buffer); - for (int i = 1; i < objArray.length; i++) { - buffer.addChar(COMMA); - writeObject(objArray[i], buffer); - } - } - } else if (int[].class.isAssignableFrom(arrayClass)) { - int[] intArray = (int[]) array; - if (intArray.length > 0) { - buffer.addInt(intArray[0]); - for (int i = 1; i < intArray.length; i++) { - buffer.addChar(COMMA).addInt(intArray[i]); - } - } - } else if (long[].class.isAssignableFrom(arrayClass)) { - long[] longArray = (long[]) array; - if (longArray.length > 0) { - buffer.addLong(longArray[0]); - for (int i = 1; i < longArray.length; i++) { - buffer.addChar(COMMA).addLong(longArray[i]); - } - } - } else if (boolean[].class.isAssignableFrom(arrayClass)) { - boolean[] booleanArray = (boolean[]) array; - if (booleanArray.length > 0) { - buffer.addBoolean(booleanArray[0]); - for (int i = 1; i < booleanArray.length; i++) { - buffer.addChar(COMMA).addBoolean(booleanArray[i]); - } - } - } else if (char[].class.isAssignableFrom(arrayClass)) { - char[] charArray = (char[]) array; - if (charArray.length > 0) { - buffer.addJsonEscapedString(Chr.array(charArray[0])); - for (int i = 1; i < charArray.length; i++) { - buffer.addChar(COMMA).addJsonEscapedString(Chr.array(charArray[i])); - } - } - } else if (double[].class.isAssignableFrom(arrayClass)) { - double[] doubleArray = (double[]) array; - if (doubleArray.length > 0) { - buffer.addDouble(doubleArray[0]); - for (int i = 1; i < doubleArray.length; i++) { - buffer.addChar(COMMA).addDouble(doubleArray[i]); - } - } - } else if (float[].class.isAssignableFrom(arrayClass)) { - float[] floatArray = (float[]) array; - if (floatArray.length > 0) { - buffer.addFloat(floatArray[0]); - for (int i = 1; i < floatArray.length; i++) { - buffer.addChar(COMMA).addFloat(floatArray[i]); - } - } - } else if (byte[].class.isAssignableFrom(arrayClass)) { - byte[] byteArray = (byte[]) array; - if (byteArray.length > 0) { - buffer.addByte(byteArray[0]); - for (int i = 1; i < byteArray.length; i++) { - buffer.addChar(COMMA).addByte(byteArray[i]); - } - } - } else if (short[].class.isAssignableFrom(arrayClass)) { - short[] shortArray = (short[]) array; - if (shortArray.length > 0) { - buffer.addShort(shortArray[0]); - for (int i = 1; i < shortArray.length; i++) { - buffer.addChar(COMMA).addShort(shortArray[i]); - } - } - } - buffer.addChar(CLOSE_BRACKET); - } - - private static final char[] EMPTY_MAP_CHARS = {OPEN_BRACE, CLOSE_BRACE}; - - /** - * Serializes map and writes it into specified buffer. - */ - private static void writeMap(Map map, CharBuf buffer) { - if (!map.isEmpty()) { - buffer.addChar(OPEN_BRACE); - boolean firstItem = true; - for (Map.Entry entry : map.entrySet()) { - if (entry.getKey() == null) { - throw new IllegalArgumentException("Maps with null keys can\'t be converted to JSON"); - } - - if (!firstItem) { - buffer.addChar(COMMA); - } else { - firstItem = false; - } - - buffer.addJsonFieldName(entry.getKey().toString()); - writeObject(entry.getValue(), buffer); - } - buffer.addChar(CLOSE_BRACE); - } else { - buffer.addChars(EMPTY_MAP_CHARS); - } - } - - private static final char[] EMPTY_LIST_CHARS = {OPEN_BRACKET, CLOSE_BRACKET}; - - /** - * Serializes iterator and writes it into specified buffer. - */ - private static void writeIterator(Iterator iterator, CharBuf buffer) { - if (iterator.hasNext()) { - buffer.addChar(OPEN_BRACKET); - Object it = iterator.next(); - writeObject(it, buffer); - while (iterator.hasNext()) { - it = iterator.next(); - buffer.addChar(COMMA); - writeObject(it, buffer); - } - buffer.addChar(CLOSE_BRACKET); - } else { - buffer.addChars(EMPTY_LIST_CHARS); - } + return DEFAULT_GENERATOR.toJson(m); } /** diff --git a/subprojects/groovy-json/src/main/java/groovy/json/StreamingJsonBuilder.java b/subprojects/groovy-json/src/main/java/groovy/json/StreamingJsonBuilder.java index e52986fc13..69d5173ccd 100644 --- a/subprojects/groovy-json/src/main/java/groovy/json/StreamingJsonBuilder.java +++ b/subprojects/groovy-json/src/main/java/groovy/json/StreamingJsonBuilder.java @@ -73,6 +73,7 @@ public class StreamingJsonBuilder extends GroovyObjectSupport { private static final String COLON_WITH_OPEN_BRACE = ":{"; private final Writer writer; + private final JsonGenerator generator; /** * Instantiates a JSON builder. @@ -81,6 +82,19 @@ public class StreamingJsonBuilder extends GroovyObjectSupport { */ public StreamingJsonBuilder(Writer writer) { this.writer = writer; + generator = JsonOutput.DEFAULT_GENERATOR; + } + + /** + * Instantiates a JSON builder with the given generator. + * + * @param writer A writer to which Json will be written + * @param generator used to generate the output + * @since 2.5 + */ + public StreamingJsonBuilder(Writer writer, JsonGenerator generator) { + this.writer = writer; + this.generator = generator; } /** @@ -88,11 +102,27 @@ public StreamingJsonBuilder(Writer writer) { * * @param writer A writer to which Json will be written * @param content a pre-existing data structure, default to null + * @throws IOException */ public StreamingJsonBuilder(Writer writer, Object content) throws IOException { - this(writer); + this(writer, content, JsonOutput.DEFAULT_GENERATOR); + } + + /** + * Instantiates a JSON builder, possibly with some existing data structure and + * the given generator. + * + * @param writer A writer to which Json will be written + * @param content a pre-existing data structure, default to null + * @param generator used to generate the output + * @throws IOException + * @since 2.5 + */ + public StreamingJsonBuilder(Writer writer, Object content, JsonGenerator generator) throws IOException { + this.writer = writer; + this.generator = generator; if (content != null) { - writer.write(JsonOutput.toJson(content)); + writer.write(generator.toJson(content)); } } @@ -113,7 +143,7 @@ public StreamingJsonBuilder(Writer writer, Object content) throws IOException { * @return a map of key / value pairs */ public Object call(Map m) throws IOException { - writer.write(JsonOutput.toJson(m)); + writer.write(generator.toJson(m)); return m; } @@ -133,7 +163,7 @@ public Object call(Map m) throws IOException { * @throws IOException */ public void call(String name) throws IOException { - writer.write(JsonOutput.toJson(Collections.singletonMap(name, Collections.emptyMap()))); + writer.write(generator.toJson(Collections.singletonMap(name, Collections.emptyMap()))); } /** @@ -154,7 +184,7 @@ public void call(String name) throws IOException { * @return a list of values */ public Object call(List l) throws IOException { - writer.write(JsonOutput.toJson(l)); + writer.write(generator.toJson(l)); return l; } @@ -204,7 +234,7 @@ public Object call(Object... args) throws IOException { * @param c a closure used to convert the objects of coll */ public Object call(Iterable coll, @DelegatesTo(StreamingJsonDelegate.class) Closure c) throws IOException { - return StreamingJsonDelegate.writeCollectionWithClosure(writer, coll, c); + return StreamingJsonDelegate.writeCollectionWithClosure(writer, coll, c, generator); } /** @@ -234,7 +264,7 @@ public Object call(Collection coll, @DelegatesTo(StreamingJsonDelegate.class) Cl */ public Object call(@DelegatesTo(StreamingJsonDelegate.class) Closure c) throws IOException { writer.write(JsonOutput.OPEN_BRACE); - StreamingJsonDelegate.cloneDelegateAndGetContent(writer, c); + StreamingJsonDelegate.cloneDelegateAndGetContent(writer, c, true, generator); writer.write(JsonOutput.CLOSE_BRACE); return null; @@ -261,7 +291,7 @@ public Object call(@DelegatesTo(StreamingJsonDelegate.class) Closure c) throws I */ public void call(String name, @DelegatesTo(StreamingJsonDelegate.class) Closure c) throws IOException { writer.write(JsonOutput.OPEN_BRACE); - writer.write(JsonOutput.toJson(name)); + writer.write(generator.toJson(name)); writer.write(JsonOutput.COLON); call(c); writer.write(JsonOutput.CLOSE_BRACE); @@ -292,7 +322,7 @@ public void call(String name, @DelegatesTo(StreamingJsonDelegate.class) Closure */ public void call(String name, Iterable coll, @DelegatesTo(StreamingJsonDelegate.class) Closure c) throws IOException { writer.write(JsonOutput.OPEN_BRACE); - writer.write(JsonOutput.toJson(name)); + writer.write(generator.toJson(name)); writer.write(JsonOutput.COLON); call(coll, c); writer.write(JsonOutput.CLOSE_BRACE); @@ -329,7 +359,7 @@ public void call(String name, Collection coll, @DelegatesTo(StreamingJsonDelegat */ public void call(String name, Map map, @DelegatesTo(StreamingJsonDelegate.class) Closure callable) throws IOException { writer.write(JsonOutput.OPEN_BRACE); - writer.write(JsonOutput.toJson(name)); + writer.write(generator.toJson(name)); writer.write(COLON_WITH_OPEN_BRACE); boolean first = true; for (Object it : map.entrySet()) { @@ -340,11 +370,19 @@ public void call(String name, Map map, @DelegatesTo(StreamingJsonDelegate.class) } Map.Entry entry = (Map.Entry) it; - writer.write(JsonOutput.toJson(entry.getKey())); + String key = entry.getKey().toString(); + if (generator.isExcludingFieldsNamed(key)) { + continue; + } + Object value = entry.getValue(); + if (generator.isExcludingValues(value)) { + return; + } + writer.write(generator.toJson(key)); writer.write(JsonOutput.COLON); - writer.write(JsonOutput.toJson(entry.getValue())); + writer.write(generator.toJson(value)); } - StreamingJsonDelegate.cloneDelegateAndGetContent(writer, callable, map.size() == 0); + StreamingJsonDelegate.cloneDelegateAndGetContent(writer, callable, map.size() == 0, generator); writer.write(DOUBLE_CLOSE_BRACKET); } @@ -479,10 +517,16 @@ public static class StreamingJsonDelegate extends GroovyObjectSupport { protected boolean first; protected State state; + private final JsonGenerator generator; public StreamingJsonDelegate(Writer w, boolean first) { + this(w, first, null); + } + + StreamingJsonDelegate(Writer w, boolean first, JsonGenerator generator) { this.writer = w; this.first = first; + this.generator = (generator != null) ? generator : JsonOutput.DEFAULT_GENERATOR; } /** @@ -548,6 +592,9 @@ else if(obj.getClass().isArray()) { * @throws IOException */ public void call(String name, List list) throws IOException { + if (generator.isExcludingFieldsNamed(name)) { + return; + } writeName(name); writeArray(list); } @@ -559,6 +606,9 @@ public void call(String name, List list) throws IOException { * @throws IOException */ public void call(String name, Object...array) throws IOException { + if (generator.isExcludingFieldsNamed(name)) { + return; + } writeName(name); writeArray(Arrays.asList(array)); } @@ -589,6 +639,9 @@ public void call(String name, Object...array) throws IOException { * @param c a closure used to convert the objects of coll */ public void call(String name, Iterable coll, @DelegatesTo(StreamingJsonDelegate.class) Closure c) throws IOException { + if (generator.isExcludingFieldsNamed(name)) { + return; + } writeName(name); writeObjects(coll, c); } @@ -608,6 +661,9 @@ public void call(String name, Collection coll, @DelegatesTo(StreamingJsonDelegat * @throws IOException */ public void call(String name, Object value) throws IOException { + if (generator.isExcludingFieldsNamed(name) || generator.isExcludingValues(value)) { + return; + } writeName(name); writeValue(value); } @@ -620,9 +676,12 @@ public void call(String name, Object value) throws IOException { * @throws IOException */ public void call(String name, Object value, @DelegatesTo(StreamingJsonDelegate.class) Closure callable) throws IOException { + if (generator.isExcludingFieldsNamed(name)) { + return; + } writeName(name); verifyValue(); - writeObject(writer, value, callable); + writeObject(writer, value, callable, generator); } /** * Writes the name and another JSON object @@ -632,10 +691,13 @@ public void call(String name, Object value, @DelegatesTo(StreamingJsonDelegate.c * @throws IOException */ public void call(String name,@DelegatesTo(StreamingJsonDelegate.class) Closure value) throws IOException { + if (generator.isExcludingFieldsNamed(name)) { + return; + } writeName(name); verifyValue(); writer.write(JsonOutput.OPEN_BRACE); - StreamingJsonDelegate.cloneDelegateAndGetContent(writer, value); + StreamingJsonDelegate.cloneDelegateAndGetContent(writer, value, true, generator); writer.write(JsonOutput.CLOSE_BRACE); } @@ -647,6 +709,9 @@ public void call(String name,@DelegatesTo(StreamingJsonDelegate.class) Closure v * @throws IOException */ public void call(String name, JsonOutput.JsonUnescaped json) throws IOException { + if (generator.isExcludingFieldsNamed(name)) { + return; + } writeName(name); verifyValue(); writer.write(json.toString()); @@ -672,7 +737,7 @@ public void call(String name, Writable json) throws IOException { private void writeObjects(Iterable coll, @DelegatesTo(StreamingJsonDelegate.class) Closure c) throws IOException { verifyValue(); - writeCollectionWithClosure(writer, coll, c); + writeCollectionWithClosure(writer, coll, c, generator); } protected void verifyValue() { @@ -686,6 +751,9 @@ protected void verifyValue() { protected void writeName(String name) throws IOException { + if (generator.isExcludingFieldsNamed(name)) { + return; + } if(state == State.NAME) { throw new IllegalStateException("Cannot write a name when a name has just been written. Write a value first!"); } @@ -697,18 +765,21 @@ protected void writeName(String name) throws IOException { } else { first = false; } - writer.write(JsonOutput.toJson(name)); + writer.write(generator.toJson(name)); writer.write(JsonOutput.COLON); } protected void writeValue(Object value) throws IOException { + if (generator.isExcludingValues(value)) { + return; + } verifyValue(); - writer.write(JsonOutput.toJson(value)); + writer.write(generator.toJson(value)); } protected void writeArray(List list) throws IOException { verifyValue(); - writer.write(JsonOutput.toJson(list)); + writer.write(generator.toJson(list)); } public static boolean isCollectionWithClosure(Object[] args) { @@ -716,10 +787,11 @@ public static boolean isCollectionWithClosure(Object[] args) { } public static Object writeCollectionWithClosure(Writer writer, Collection coll, @DelegatesTo(StreamingJsonDelegate.class) Closure closure) throws IOException { - return writeCollectionWithClosure(writer, (Iterable)coll, closure); + return writeCollectionWithClosure(writer, (Iterable)coll, closure, JsonOutput.DEFAULT_GENERATOR); } - public static Object writeCollectionWithClosure(Writer writer, Iterable coll, @DelegatesTo(StreamingJsonDelegate.class) Closure closure) throws IOException { + private static Object writeCollectionWithClosure(Writer writer, Iterable coll, @DelegatesTo(StreamingJsonDelegate.class) Closure closure, JsonGenerator generator) + throws IOException { writer.write(JsonOutput.OPEN_BRACKET); boolean first = true; for (Object it : coll) { @@ -729,16 +801,16 @@ public static Object writeCollectionWithClosure(Writer writer, Iterable coll, @D first = false; } - writeObject(writer, it, closure); + writeObject(writer, it, closure, generator); } writer.write(JsonOutput.CLOSE_BRACKET); return writer; } - private static void writeObject(Writer writer, Object object, Closure closure) throws IOException { + private static void writeObject(Writer writer, Object object, Closure closure, JsonGenerator generator) throws IOException { writer.write(JsonOutput.OPEN_BRACE); - curryDelegateAndGetContent(writer, closure, object); + curryDelegateAndGetContent(writer, closure, object, true, generator); writer.write(JsonOutput.CLOSE_BRACE); } @@ -748,7 +820,11 @@ public static void cloneDelegateAndGetContent(Writer w, @DelegatesTo(StreamingJs } public static void cloneDelegateAndGetContent(Writer w, @DelegatesTo(StreamingJsonDelegate.class) Closure c, boolean first) { - StreamingJsonDelegate delegate = new StreamingJsonDelegate(w, first); + cloneDelegateAndGetContent(w, c, first, JsonOutput.DEFAULT_GENERATOR); + } + + private static void cloneDelegateAndGetContent(Writer w, @DelegatesTo(StreamingJsonDelegate.class) Closure c, boolean first, JsonGenerator generator) { + StreamingJsonDelegate delegate = new StreamingJsonDelegate(w, first, generator); Closure cloned = (Closure) c.clone(); cloned.setDelegate(delegate); cloned.setResolveStrategy(Closure.DELEGATE_FIRST); @@ -760,7 +836,11 @@ public static void curryDelegateAndGetContent(Writer w, @DelegatesTo(StreamingJs } public static void curryDelegateAndGetContent(Writer w, @DelegatesTo(StreamingJsonDelegate.class) Closure c, Object o, boolean first) { - StreamingJsonDelegate delegate = new StreamingJsonDelegate(w, first); + curryDelegateAndGetContent(w, c, o, first, JsonOutput.DEFAULT_GENERATOR); + } + + private static void curryDelegateAndGetContent(Writer w, @DelegatesTo(StreamingJsonDelegate.class) Closure c, Object o, boolean first, JsonGenerator generator) { + StreamingJsonDelegate delegate = new StreamingJsonDelegate(w, first, generator); Closure curried = c.curry(o); curried.setDelegate(delegate); curried.setResolveStrategy(Closure.DELEGATE_FIRST); @@ -772,5 +852,3 @@ private enum State { } } } - - diff --git a/subprojects/groovy-json/src/main/java/groovy/json/internal/CharBuf.java b/subprojects/groovy-json/src/main/java/groovy/json/internal/CharBuf.java index feaa6147c1..18f5d6a9d4 100644 --- a/subprojects/groovy-json/src/main/java/groovy/json/internal/CharBuf.java +++ b/subprojects/groovy-json/src/main/java/groovy/json/internal/CharBuf.java @@ -341,32 +341,34 @@ public final CharBuf addQuoted(char[] chars) { } public final CharBuf addJsonEscapedString(String jsonString) { + return addJsonEscapedString(jsonString, false); + } + + public final CharBuf addJsonEscapedString(String jsonString, boolean disableUnicodeEscaping) { char[] charArray = FastStringUtils.toCharArray(jsonString); - return addJsonEscapedString(charArray); + return addJsonEscapedString(charArray, disableUnicodeEscaping); } - private static boolean hasAnyJSONControlOrUnicodeChars(int c) { - /* Anything less than space is a control character. */ - if (c < 30) { + private static boolean shouldEscape(int c, boolean disableUnicodeEscaping) { + if (c < 32) { /* less than space is a control char */ return true; - /* 34 is double quote. */ - } else if (c == 34) { + } else if (c == 34) { /* double quote */ return true; - } else if (c == 92) { + } else if (c == 92) { /* backslash */ return true; - } else if (c < ' ' || c > 126) { + } else if (!disableUnicodeEscaping && c > 126) { /* non-ascii char range */ return true; } return false; } - private static boolean hasAnyJSONControlChars(final char[] charArray) { + private static boolean hasAnyJSONControlChars(final char[] charArray, boolean disableUnicodeEscaping) { int index = 0; char c; while (true) { c = charArray[index]; - if (hasAnyJSONControlOrUnicodeChars(c)) { + if (shouldEscape(c, disableUnicodeEscaping)) { return true; } if (++index >= charArray.length) return false; @@ -374,9 +376,13 @@ private static boolean hasAnyJSONControlChars(final char[] charArray) { } public final CharBuf addJsonEscapedString(final char[] charArray) { + return addJsonEscapedString(charArray, false); + } + + public final CharBuf addJsonEscapedString(final char[] charArray, boolean disableUnicodeEscaping) { if (charArray.length == 0) return this; - if (hasAnyJSONControlChars(charArray)) { - return doAddJsonEscapedString(charArray); + if (hasAnyJSONControlChars(charArray, disableUnicodeEscaping)) { + return doAddJsonEscapedString(charArray, disableUnicodeEscaping); } else { return this.addQuoted(charArray); } @@ -386,7 +392,7 @@ public final CharBuf addJsonEscapedString(final char[] charArray) { final byte[] charTo = new byte[2]; - private CharBuf doAddJsonEscapedString(char[] charArray) { + private CharBuf doAddJsonEscapedString(char[] charArray, boolean disableUnicodeEscaping) { char[] _buffer = buffer; int _location = this.location; @@ -410,7 +416,7 @@ private CharBuf doAddJsonEscapedString(char[] charArray) { while (true) { char c = charArray[index]; - if (hasAnyJSONControlOrUnicodeChars(c)) { + if (shouldEscape(c, disableUnicodeEscaping)) { /* We are covering our bet with a safety net. otherwise we would have to have 5x buffer allocated for control chars */ @@ -514,14 +520,22 @@ private CharBuf doAddJsonEscapedString(char[] charArray) { } public final CharBuf addJsonFieldName(String str) { - return addJsonFieldName(FastStringUtils.toCharArray(str)); + return addJsonFieldName(str, false); + } + + public final CharBuf addJsonFieldName(String str, boolean disableUnicodeEscaping) { + return addJsonFieldName(FastStringUtils.toCharArray(str), disableUnicodeEscaping); } private static final char[] EMPTY_STRING_CHARS = Chr.array('"', '"'); public final CharBuf addJsonFieldName(char[] chars) { + return addJsonFieldName(chars, false); + } + + public final CharBuf addJsonFieldName(char[] chars, boolean disableUnicodeEscaping) { if (chars.length > 0) { - addJsonEscapedString(chars); + addJsonEscapedString(chars, disableUnicodeEscaping); } else { addChars(EMPTY_STRING_CHARS); } @@ -671,7 +685,16 @@ public final void addNull() { } public void removeLastChar() { - location--; + if (location > 0) { + location--; + } + } + + public void removeLastChar(char expect) { + if (location == 0 || buffer[location-1] != expect) { + return; + } + removeLastChar(); } private Cache bigDCache; diff --git a/subprojects/groovy-json/src/spec/doc/json-builder.adoc b/subprojects/groovy-json/src/spec/doc/json-builder.adoc index dcf21d41c6..28ffd581c6 100644 --- a/subprojects/groovy-json/src/spec/doc/json-builder.adoc +++ b/subprojects/groovy-json/src/spec/doc/json-builder.adoc @@ -40,4 +40,11 @@ We use https://github.com/lukas-krecan/JsonUnit[JsonUnit] to check that the buil [source,groovy] ---- include::{rootProjectDir}/subprojects/groovy-json/src/spec/test/json/JsonBuilderTest.groovy[tags=json_assert,indent=0] ----- \ No newline at end of file +---- + +If you need to customize the generated output you can pass a `JsonGenerator` instance when creating a `JsonBuilder`: + +[source,groovy] +---- +include::{rootProjectDir}/subprojects/groovy-json/src/spec/test/json/JsonBuilderTest.groovy[tags=json_builder_generator,indent=0] +---- diff --git a/subprojects/groovy-json/src/spec/doc/json-userguide.adoc b/subprojects/groovy-json/src/spec/doc/json-userguide.adoc index 55575680f7..683e403410 100644 --- a/subprojects/groovy-json/src/spec/doc/json-userguide.adoc +++ b/subprojects/groovy-json/src/spec/doc/json-userguide.adoc @@ -159,7 +159,7 @@ include::{rootProjectDir}/subprojects/groovy-json/src/spec/test/json/JsonTest.gr <>, being a JSON parser. `JsonOutput` comes with overloaded, static `toJson` methods. Each `toJson` implementation takes a different parameter type. -The static method can either be used directly or by importing the methods with a static import statement. +The static methods can either be used directly or by importing the methods with a static import statement. The result of a `toJson` call is a `String` containing the JSON code. @@ -176,6 +176,30 @@ has support for serialising POGOs, that is, plain-old Groovy objects. include::{rootProjectDir}/subprojects/groovy-json/src/spec/test/json/JsonTest.groovy[tags=json_output_pogo,indent=0] ---- +=== Customizing Output + +If you need control over the serialized output you can use a `JsonGenerator`. The `JsonGenerator.Options` builder +can be used to create a customized generator. One or more options can be set on this builder in order to alter +the resulting output. When you are done setting the options simply call the `build()` method in order to get a fully +configured instance that will generate output based on the options selected. + +[source,groovy] +---- +include::{rootProjectDir}/subprojects/groovy-json/src/spec/test/json/JsonTest.groovy[tags=json_output_generator,indent=0] +---- + +A closure can be used to transform a type into a valid JSON value. These closure converters are registered +for a given type and will be called any time that type or a subtype is encountered. The first parameter to the +closure is an object matching the type for which the converter is registered and this parameter is required. +The closure may take an optional second `String` parameter and this will be set to the key name if one is available. + +[source,groovy] +---- +include::{rootProjectDir}/subprojects/groovy-json/src/spec/test/json/JsonTest.groovy[tags=json_output_converter,indent=0] +---- + +==== Formatted Output + As we saw in previous examples, the JSON output is not pretty printed per default. However, the `prettyPrint` method in `JsonOutput` comes to rescue for this task. @@ -187,6 +211,8 @@ include::{rootProjectDir}/subprojects/groovy-json/src/spec/test/json/JsonTest.gr `prettyPrint` takes a `String` as single parameter; therefore, it can be applied on arbitrary JSON `String` instances, not only the result of `JsonOutput.toJson`. +=== Builders + Another way to create JSON from Groovy is to use `JsonBuilder` or `StreamingJsonBuilder`. Both builders provide a DSL which allows to formulate an object graph which is then converted to JSON. diff --git a/subprojects/groovy-json/src/spec/doc/streaming-jason-builder.adoc b/subprojects/groovy-json/src/spec/doc/streaming-jason-builder.adoc index 296794e7d4..98d3e59303 100644 --- a/subprojects/groovy-json/src/spec/doc/streaming-jason-builder.adoc +++ b/subprojects/groovy-json/src/spec/doc/streaming-jason-builder.adoc @@ -44,4 +44,11 @@ We use https://github.com/lukas-krecan/JsonUnit[JsonUnit] to check the expected [source,groovy] ---- include::{rootProjectDir}/subprojects/groovy-json/src/spec/test/json/StreamingJsonBuilderTest.groovy[tags=json_assert,indent=0] ----- \ No newline at end of file +---- + +If you need to customize the generated output you can pass a `JsonGenerator` instance when creating a `StreamingJsonBuilder`: + +[source,groovy] +---- +include::{rootProjectDir}/subprojects/groovy-json/src/spec/test/json/StreamingJsonBuilderTest.groovy[tags=streaming_json_builder_generator,indent=0] +---- diff --git a/subprojects/groovy-json/src/spec/test/json/JsonBuilderTest.groovy b/subprojects/groovy-json/src/spec/test/json/JsonBuilderTest.groovy index 87ab5f13dd..d4a15769ea 100644 --- a/subprojects/groovy-json/src/spec/test/json/JsonBuilderTest.groovy +++ b/subprojects/groovy-json/src/spec/test/json/JsonBuilderTest.groovy @@ -69,4 +69,36 @@ class JsonBuilderTest extends GroovyTestCase { // end::json_assert[] """ } + + void testJsonBuilderWithGenerator() { + assertScript """ + // tag::json_builder_generator[] + import groovy.json.* + + def generator = new JsonGenerator.Options() + .excludeNulls() + .excludeFieldsByName('make', 'country', 'record') + .excludeFieldsByType(Number) + .addConverter(URL) { url -> '"http://groovy-lang.org"' } + .build() + + JsonBuilder builder = new JsonBuilder(generator) + builder.records { + car { + name 'HSV Maloo' + make 'Holden' + year 2006 + country 'Australia' + homepage new URL('http://example.org') + record { + type 'speed' + description 'production pickup truck with speed of 271kph' + } + } + } + + assert builder.toString() == '{"records":{"car":{"name":"HSV Maloo","homepage":"http://groovy-lang.org"}}}' + // end::json_builder_generator[] + """ + } } diff --git a/subprojects/groovy-json/src/spec/test/json/JsonTest.groovy b/subprojects/groovy-json/src/spec/test/json/JsonTest.groovy index b32093953b..28067a6789 100644 --- a/subprojects/groovy-json/src/spec/test/json/JsonTest.groovy +++ b/subprojects/groovy-json/src/spec/test/json/JsonTest.groovy @@ -94,6 +94,74 @@ class JsonTest extends GroovyTestCase { ''' } + void testJsonOutputWithGenerator() { + assertScript ''' + import groovy.json.* + + // tag::json_output_generator[] + class Person { + String name + String title + int age + String password + Date dob + URL favoriteUrl + } + + Person person = new Person(name: 'John', title: null, age: 21, password: 'secret', + dob: Date.parse('yyyy-MM-dd', '1984-12-15'), + favoriteUrl: new URL('http://groovy-lang.org/')) + + def generator = new JsonGenerator.Options() + .excludeNulls() + .dateFormat('MM@dd@yyyy') + .excludeFieldsByName('age', 'password') + .excludeFieldsByType(URL) + .build() + + assert generator.toJson(person) == '{"dob":"12@15@1984","name":"John"}' + // end::json_output_generator[] + ''' + } + + void testJsonOutputConverter() { + assertScript ''' + import groovy.json.* + import static groovy.test.GroovyAssert.shouldFail + + // tag::json_output_converter[] + class Person { + String name + URL favoriteUrl + } + + Person person = new Person(name: 'John', favoriteUrl: new URL('http://groovy-lang.org/json.html#_jsonoutput')) + + def generator = new JsonGenerator.Options() + .addConverter(URL) { URL u, String key -> + if (key == 'favoriteUrl') { + '"' + u.getHost() + '"' + } else { + JsonOutput.toJson(u) + } + } + .build() + + assert generator.toJson(person) == '{"favoriteUrl":"groovy-lang.org","name":"John"}' + + // No key available when generating a JSON Array + def list = [new URL('http://groovy-lang.org/json.html#_jsonoutput')] + assert generator.toJson(list) == '["http://groovy-lang.org/json.html#_jsonoutput"]' + + // First parameter to the converter must match the type for which it is registered + shouldFail(IllegalArgumentException) { + new JsonGenerator.Options() + .addConverter(Date) { Calendar cal -> } + } + // end::json_output_converter[] + ''' + } + void testPrettyPrint() { // tag::pretty_print[] def json = JsonOutput.toJson([name: 'John Doe', age: 42]) diff --git a/subprojects/groovy-json/src/spec/test/json/StreamingJsonBuilderTest.groovy b/subprojects/groovy-json/src/spec/test/json/StreamingJsonBuilderTest.groovy index c9bb0fe166..7deb2ae6bf 100644 --- a/subprojects/groovy-json/src/spec/test/json/StreamingJsonBuilderTest.groovy +++ b/subprojects/groovy-json/src/spec/test/json/StreamingJsonBuilderTest.groovy @@ -18,8 +18,6 @@ */ package json -import groovy.util.GroovyTestCase - class StreamingJsonBuilderTest extends GroovyTestCase { void testStreamingJsonBuilder() { @@ -72,4 +70,37 @@ class StreamingJsonBuilderTest extends GroovyTestCase { // end::json_assert[] """ } + + void testStreamingJsonBuilderWithGenerator() { + assertScript ''' + import groovy.json.* + // tag::streaming_json_builder_generator[] + def generator = new JsonGenerator.Options() + .excludeNulls() + .excludeFieldsByName('make', 'country', 'record') + .excludeFieldsByType(Number) + .addConverter(URL) { url -> '"http://groovy-lang.org"' } + .build() + + StringWriter writer = new StringWriter() + StreamingJsonBuilder builder = new StreamingJsonBuilder(writer, generator) + + builder.records { + car { + name 'HSV Maloo' + make 'Holden' + year 2006 + country 'Australia' + homepage new URL('http://example.org') + record { + type 'speed' + description 'production pickup truck with speed of 271kph' + } + } + } + + assert writer.toString() == '{"records":{"car":{"name":"HSV Maloo","homepage":"http://groovy-lang.org"}}}' + // end::streaming_json_builder_generator[] + ''' + } } \ No newline at end of file diff --git a/subprojects/groovy-json/src/test/groovy/groovy/json/CharBufTest.groovy b/subprojects/groovy-json/src/test/groovy/groovy/json/CharBufTest.groovy index d19718e19e..8915f5593e 100644 --- a/subprojects/groovy-json/src/test/groovy/groovy/json/CharBufTest.groovy +++ b/subprojects/groovy-json/src/test/groovy/groovy/json/CharBufTest.groovy @@ -63,6 +63,17 @@ class CharBufTest extends GroovyTestCase { assert str == '" \\\\ "' } + void testDisableUnicodeEscaping() { + String str = CharBuf.create(0).addJsonEscapedString("Éric").toString() + assert str == '"\\u00c9ric"' + + str = CharBuf.create(0).addJsonEscapedString("Éric", false).toString() + assert str == '"\\u00c9ric"' + + str = CharBuf.create(0).addJsonEscapedString("Éric", true).toString() + assert str == '"Éric"' + } + /** * https://issues.apache.org/jira/browse/GROOVY-6937 * https://issues.apache.org/jira/browse/GROOVY-6852 @@ -84,4 +95,28 @@ class CharBufTest extends GroovyTestCase { result = new JsonBuilder(obj).toString() assert result == /["${'\\u20ac' * 20_000}"]/ } + + void testRemoveLastChar() { + CharBuf buffer + + buffer = CharBuf.create(8).add('value1,') + buffer.removeLastChar() + assert buffer.toString() == 'value1' + + buffer = CharBuf.create(4) + buffer.removeLastChar() + assert buffer.toString() == '' + + buffer = CharBuf.create(8).add('[]') + buffer.removeLastChar((char)',') + assert buffer.toString() == '[]' + + buffer = CharBuf.create(8).add('[val,') + buffer.removeLastChar((char)',') + assert buffer.toString() == '[val' + + buffer = CharBuf.create(32).add('[one,two,three,four,') + buffer.removeLastChar((char)',') + assert buffer.toString() == '[one,two,three,four' + } } diff --git a/subprojects/groovy-json/src/test/groovy/groovy/json/CustomJsonGeneratorTest.groovy b/subprojects/groovy-json/src/test/groovy/groovy/json/CustomJsonGeneratorTest.groovy new file mode 100644 index 0000000000..38a73fa236 --- /dev/null +++ b/subprojects/groovy-json/src/test/groovy/groovy/json/CustomJsonGeneratorTest.groovy @@ -0,0 +1,89 @@ +/* + * 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 groovy.json + +import groovy.json.JsonGenerator.Converter +import groovy.json.JsonGenerator.Options +import groovy.json.internal.CharBuf + +/** + * Tests extensibility of JsonGenerator and associated classes + */ +class CustomJsonGeneratorTest extends GroovyTestCase { + + void testCustomGenerator() { + def generator = new CustomJsonOptions() + .excludeNulls() + .lowerCaseFieldNames() + .addCustomConverter(new CustomJsonConverter()) + .build() + + assert generator.toJson(['one', null, 'two', null]) == '["one","two"]' + assert generator.toJson(['Foo':'test1', 'BAR':'test2']) == '{"foo":"test1","bar":"test2"}' + assert generator.toJson(['foo': new CustomFoo()]) == '{"foo":"CustomFoo from CustomJsonConverter"}' + } + + static class CustomJsonOptions extends Options { + boolean lowerCaseFieldNames + CustomJsonOptions lowerCaseFieldNames() { + lowerCaseFieldNames = true + return this + } + CustomJsonOptions addCustomConverter(Converter converter) { + converters.add(converter) + return this + } + @Override + CustomJsonGenerator build() { + return new CustomJsonGenerator(this) + } + } + + static class CustomJsonGenerator extends DefaultJsonGenerator { + boolean lowerCaseFieldNames + CustomJsonGenerator(CustomJsonOptions opts) { + super(opts) + lowerCaseFieldNames = opts.lowerCaseFieldNames + } + @Override + protected void writeMapEntry(String key, Object value, CharBuf buffer) { + String newKey = (lowerCaseFieldNames) ? key.toLowerCase() : key + super.writeMapEntry(newKey, value, buffer) + } + } + + static class CustomJsonConverter implements Converter { + @Override + boolean handles(Class type) { + return CustomFoo.isAssignableFrom(type) + } + + @Override + CharSequence convert(Object value) { + return convert(value, null) + } + + @Override + CharSequence convert(Object value, String key) { + return '"CustomFoo from CustomJsonConverter"' + } + } + + static class CustomFoo {} +} diff --git a/subprojects/groovy-json/src/test/groovy/groovy/json/DefaultJsonGeneratorTest.groovy b/subprojects/groovy-json/src/test/groovy/groovy/json/DefaultJsonGeneratorTest.groovy new file mode 100644 index 0000000000..167ce312b2 --- /dev/null +++ b/subprojects/groovy-json/src/test/groovy/groovy/json/DefaultJsonGeneratorTest.groovy @@ -0,0 +1,283 @@ +/* + * 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 groovy.json + +class DefaultJsonGeneratorTest extends GroovyTestCase { + + void testExcludesNullValues() { + def generator = new JsonGenerator.Options() + .excludeNulls() + .build() + + def json = generator.toJson(new JsonObject(name: 'test', properties: null)) + assert json == '{"name":"test"}' + + json = generator.toJson([field1: null, field2: "test"]) + assert json == '{"field2":"test"}' + + assert generator.toJson([null]) == '[]' + assert generator.toJson(['a','b','c','d', null]) == '["a","b","c","d"]' + assert generator.toJson(['a', null, null, null, null]) == '["a"]' + assert generator.toJson(['a', null, null, null, 'e']) == '["a","e"]' + + def jsonArray = ["foo", null, "bar"] + def jsonExpected = '["foo","bar"]' + assert generator.toJson(jsonArray) == jsonExpected + assert generator.toJson(jsonArray as Object[]) == jsonExpected + assert generator.toJson(jsonArray.iterator()) == jsonExpected + assert generator.toJson((Iterable)jsonArray) == jsonExpected + + assert generator.toJson((Boolean)null) == '' + assert generator.toJson((Number)null) == '' + assert generator.toJson((Character)null) == '' + assert generator.toJson((String)null) == '' + assert generator.toJson((Date)null) == '' + assert generator.toJson((Calendar)null) == '' + assert generator.toJson((UUID)null) == '' + assert generator.toJson((Closure)null) == '' + assert generator.toJson((Expando)null) == '' + assert generator.toJson((Object)null) == '' + assert generator.toJson((Map)null) == '' + } + + void testCustomDateFormat() { + def generator = new JsonGenerator.Options() + .dateFormat('yyyy-MM') + .build() + + Date aDate = Date.parse('yyyy-MM-dd', '2016-07-04') + assert generator.toJson(aDate) == '"2016-07"' + + def jsonObject = new JsonObject(name: 'test', properties: [startDate: aDate]) + def json = generator.toJson(jsonObject) + assert json.contains('{"startDate":"2016-07"}') + + def jsonArray = ["foo", aDate, "bar"] + def jsonExpected = '["foo","2016-07","bar"]' + assert generator.toJson(jsonArray) == jsonExpected + assert generator.toJson(jsonArray as Object[]) == jsonExpected + assert generator.toJson(jsonArray.iterator()) == jsonExpected + assert generator.toJson((Iterable)jsonArray) == jsonExpected + } + + void testDateFormatBadInput() { + shouldFail(NullPointerException) { + new JsonGenerator.Options().dateFormat(null) + } + shouldFail(IllegalArgumentException) { + new JsonGenerator.Options().dateFormat('abcde') + } + shouldFail(NullPointerException) { + new JsonGenerator.Options().timezone(null) + } + } + + void testConverters() { + def generator = new JsonGenerator.Options() + .addConverter(JsonCyclicReference) { object, key -> + return '"JsonCyclicReference causes a stackoverflow"' + } + .addConverter(Date) { object -> + return '"4 score and 7 years ago"' + } + .addConverter(Calendar) { object -> + return '"22 days ago"' + } + .build() + + assert generator.toJson(new Date()) == '"4 score and 7 years ago"' + + def ref = new JsonBar('bar', new Date()) + def json = generator.toJson(ref) + assert json.contains('"lastVisit":"4 score and 7 years ago"') + assert json.contains('"cycle":"JsonCyclicReference causes a stackoverflow"') + + def jsonArray = ["foo", new JsonCyclicReference(), "bar", new Date()] + def jsonExpected = '["foo","JsonCyclicReference causes a stackoverflow","bar","4 score and 7 years ago"]' + assert generator.toJson(jsonArray) == jsonExpected + assert generator.toJson(jsonArray as Object[]) == jsonExpected + assert generator.toJson(jsonArray.iterator()) == jsonExpected + assert generator.toJson((Iterable)jsonArray) == jsonExpected + + assert generator.toJson([timeline: Calendar.getInstance()]) == '{"timeline":"22 days ago"}' + } + + void testConverterAddedLastTakesPrecedence() { + def options = new JsonGenerator.Options() + def c1 = { 'c1' } + def c2 = { 'c2' } + options.addConverter(URL, {}) + options.addConverter(Date, c1) + options.addConverter(Calendar, {}) + options.addConverter(Date, c2) + options.addConverter(java.sql.Date, {}) + + assert options.@converters.size() == 4 + assert options.@converters[2].convert(null) == 'c2' + assert !options.@converters.find { it.convert(null) == 'c1' } + } + + void testConvertersBadInput() { + shouldFail(NullPointerException) { + new JsonGenerator.Options().addConverter(null, null) + } + shouldFail(NullPointerException) { + new JsonGenerator.Options().addConverter(Date, null) + } + shouldFail(IllegalArgumentException) { + new JsonGenerator.Options().addConverter(Date, {-> 'no args closure'}) + } + shouldFail(IllegalArgumentException) { + new JsonGenerator.Options().addConverter(Date, { UUID obj -> 'mis-matched types'}) + } + shouldFail(IllegalArgumentException) { + new JsonGenerator.Options().addConverter(Date, { Date obj, UUID cs -> 'mis-matched types'}) + } + } + + void testExcludesFieldsByName() { + def generator = new JsonGenerator.Options() + .excludeFieldsByName('name') + .build() + + def ref = new JsonObject(name: 'Jason', properties: ['foo': 'bar']) + def json = generator.toJson(ref) + assert json == '{"properties":{"foo":"bar"}}' + + def jsonArray = ["foo", ["bar":"test","name":"Jane"], "baz"] + def jsonExpected = '["foo",{"bar":"test"},"baz"]' + assert generator.toJson(jsonArray) == jsonExpected + assert generator.toJson(jsonArray as Object[]) == jsonExpected + assert generator.toJson(jsonArray.iterator()) == jsonExpected + assert generator.toJson((Iterable)jsonArray) == jsonExpected + + def excludeList = ['foo', 'bar', "${'zoo'}"] + generator = new JsonGenerator.Options() + .excludeFieldsByName(excludeList) + .build() + + json = generator.toJson([foo: 'one', bar: 'two', baz: 'three', zoo: 'four']) + assert json == '{"baz":"three"}' + } + + void testExcludeFieldsByNameBadInput() { + shouldFail(NullPointerException) { + new JsonGenerator.Options().excludeFieldsByName(null) + } + } + + void testExcludeFieldsByNameShouldIgnoreNulls() { + def opts = new JsonGenerator.Options() + .excludeFieldsByName('foo', null, "${'bar'}") + .excludeFieldsByName([new StringBuilder('one'), null, 'two']) + + assert opts.@excludedFieldNames.size() == 4 + assert !opts.@excludedFieldNames.contains(null) + } + + void testExcludesFieldsByType() { + def generator = new JsonGenerator.Options() + .excludeFieldsByType(Date) + .build() + + def ref = [name: 'Jason', dob: new Date(), location: 'Los Angeles'] + assert generator.toJson(ref) == '{"name":"Jason","location":"Los Angeles"}' + + def jsonArray = ["foo", "bar", new Date()] + def jsonExpected = '["foo","bar"]' + assert generator.toJson(jsonArray) == jsonExpected + assert generator.toJson(jsonArray as Object[]) == jsonExpected + assert generator.toJson(jsonArray.iterator()) == jsonExpected + assert generator.toJson((Iterable)jsonArray) == jsonExpected + + generator = new JsonGenerator.Options() + .excludeFieldsByType(Integer) + .excludeFieldsByType(Boolean) + .excludeFieldsByType(Character) + .excludeFieldsByType(Calendar) + .excludeFieldsByType(UUID) + .excludeFieldsByType(URL) + .excludeFieldsByType(Closure) + .excludeFieldsByType(Expando) + .excludeFieldsByType(TreeMap) + .excludeFieldsByType(Date) + .build() + + assert generator.toJson(Integer.valueOf(7)) == '' + assert generator.toJson(Boolean.TRUE) == '' + assert generator.toJson((Character)'c') == '' + assert generator.toJson(Calendar.getInstance()) == '' + assert generator.toJson(UUID.randomUUID()) == '' + assert generator.toJson(new URL('http://groovy-lang.org')) == '' + assert generator.toJson({ url new URL('http://groovy-lang.org') }) == '' + assert generator.toJson(new Expando()) == '' + assert generator.toJson(new TreeMap()) == '' + assert generator.toJson(new java.sql.Date(new Date().getTime())) == '' + + def excludeList = [URL, Date] + generator = new JsonGenerator.Options() + .excludeFieldsByType(excludeList) + .build() + + def json = generator.toJson([foo: new Date(), bar: 'two', baz: new URL('http://groovy-lang.org')]) + assert json == '{"bar":"two"}' + } + + void testExcludeFieldsByTypeBadInput() { + shouldFail(NullPointerException) { + new JsonGenerator.Options().excludeFieldsByType(null) + } + } + + void testExcludeFieldsByTypeShouldIgnoreNulls() { + def opts = new JsonGenerator.Options() + .excludeFieldsByType(Date, null, URL) + .excludeFieldsByType([Calendar, null, TreeMap]) + + assert opts.@excludedFieldTypes.size() == 4 + assert !opts.@excludedFieldTypes.contains(null) + } + + void testDisableUnicodeEscaping() { + def json = new JsonGenerator.Options() + .disableUnicodeEscaping() + .build() + + String unicodeString = 'ΚΡΕΩΠΟΛΕΙΟ' + assert json.toJson([unicodeString]) == """["${unicodeString}"]""" + + assert json.toJson(['KÉY':'VALUE']) == '{"KÉY":"VALUE"}' + } + +} + +class JsonBar { + String favoriteDrink + Date lastVisit + JsonCyclicReference cycle = new JsonCyclicReference() + JsonBar(String favoriteDrink, Date lastVisit) { + this.favoriteDrink = favoriteDrink + this.lastVisit = lastVisit + } +} + +class JsonCyclicReference { + static final DEFAULT = new JsonCyclicReference() + JsonCyclicReference() { } +} diff --git a/subprojects/groovy-json/src/test/groovy/groovy/json/JsonBuilderTest.groovy b/subprojects/groovy-json/src/test/groovy/groovy/json/JsonBuilderTest.groovy index ec643f0563..79c88e3291 100644 --- a/subprojects/groovy-json/src/test/groovy/groovy/json/JsonBuilderTest.groovy +++ b/subprojects/groovy-json/src/test/groovy/groovy/json/JsonBuilderTest.groovy @@ -402,4 +402,31 @@ class JsonBuilderTest extends GroovyTestCase { assert new JsonBuilder({'\1' 0}).toString() == '{"\\u0001":0}' assert new JsonBuilder({'\u0002' 0}).toString() == '{"\\u0002":0}' } + + void testWithGenerator() { + def generator = new JsonGenerator.Options() + .excludeNulls() + .dateFormat('yyyyMM') + .excludeFieldsByName('secretKey', 'creditCardNumber') + .excludeFieldsByType(URL) + .addConverter(java.util.concurrent.atomic.AtomicBoolean) { ab -> ab.toString() } + .build() + + def json = new JsonBuilder(generator) + + json.payload { + id 'YT-1234' + location null + secretKey 'J79-A25' + creditCardNumber '123-444-789-2233' + site new URL('http://groovy-lang.org') + isActive new java.util.concurrent.atomic.AtomicBoolean(true) + } + + assert json.toString() == '{"payload":{"id":"YT-1234","isActive":true}}' + + json = new JsonBuilder(['foo', null, 'bar', new URL('http://groovy-lang.org')], generator) + assert json.toString() == '["foo","bar"]' + } + } diff --git a/subprojects/groovy-json/src/test/groovy/groovy/json/StreamingJsonBuilderTest.groovy b/subprojects/groovy-json/src/test/groovy/groovy/json/StreamingJsonBuilderTest.groovy index 25ac0ae649..a3cf4d8dff 100644 --- a/subprojects/groovy-json/src/test/groovy/groovy/json/StreamingJsonBuilderTest.groovy +++ b/subprojects/groovy-json/src/test/groovy/groovy/json/StreamingJsonBuilderTest.groovy @@ -496,4 +496,54 @@ class StreamingJsonBuilderTest extends GroovyTestCase { } } } + + void testWithGenerator() { + def generator = new JsonGenerator.Options() + .excludeNulls() + .dateFormat('yyyyMM') + .excludeFieldsByName('secretKey', 'creditCardNumber') + .excludeFieldsByType(URL) + .addConverter(java.util.concurrent.atomic.AtomicBoolean) { ab -> ab.toString() } + .build() + + new StringWriter().with { w -> + def builder = new StreamingJsonBuilder(w, generator) + + builder.payload { + id 'YT-1234' + location null + secretKey 'J79-A25' + creditCardNumber '123-444-789-2233' + site new URL('http://groovy-lang.org') + isActive new java.util.concurrent.atomic.AtomicBoolean(true) + } + + assert w.toString() == '{"payload":{"id":"YT-1234","isActive":true}}' + } + } + + @CompileStatic + void testWithGeneratorCompileStatic() { + def generator = new JsonGenerator.Options() + .excludeNulls() + .dateFormat('yyyyMM') + .excludeFieldsByName('secretKey', 'creditCardNumber') + .excludeFieldsByType(URL) + .addConverter(java.util.concurrent.atomic.AtomicBoolean) { ab -> ab.toString() } + .build() + + new StringWriter().with { w -> + def builder = new StreamingJsonBuilder(w, generator) + builder.call('payload') { + call 'id', 'YT-1234' + call 'location', (String)null + call 'secretKey', 'J79-A25' + call 'creditCardNumber', '123-444-789-2233' + call 'site', new URL('http://groovy-lang.org') + call 'isActive', new java.util.concurrent.atomic.AtomicBoolean(true) + } + + assert w.toString() == '{"payload":{"id":"YT-1234","isActive":true}}' + } + } }