/*
* =============================================================================
*
* Copyright (c) 2011-2016, The THYMELEAF team (http://www.thymeleaf.org)
*
* 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 org.thymeleaf.standard.serializer;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.IOException;
import java.io.Writer;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.text.DateFormat;
import java.text.FieldPosition;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.SerializableString;
import com.fasterxml.jackson.core.io.CharacterEscapes;
import com.fasterxml.jackson.core.io.SerializedString;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.thymeleaf.exceptions.ConfigurationException;
import org.thymeleaf.exceptions.TemplateProcessingException;
import org.thymeleaf.util.ClassLoaderUtils;
import org.thymeleaf.util.DateUtils;
import org.unbescape.json.JsonEscape;
import org.unbescape.json.JsonEscapeLevel;
import org.unbescape.json.JsonEscapeType;
/**
* <p>
* Default implementation of the {@link IStandardJavaScriptSerializer}.
* </p>
* <p>
* This implementation will delegate serialization to the
* <a href="http://wiki.fasterxml.com/JacksonHome">Jackson JSON processor library</a> if it is found in the
* classpath. If not, it will default to a custom implementation that produces similar results (but is less
* flexible).
* </p>
* <p>
* If a Thymeleaf application uses JavaScript template processing in a significant amount of templates or
* situations, the use of Jackson (2.6+) is recommended.
* </p>
* <p>
* Note that, even if Jackson is present in the classpath, its usage can be prevented by means of the
* <tt>useJacksonIfAvailable</tt> constructor flag.
* </p>
*
* @author Daniel Fernández
*
* @since 3.0.0
*
*/
public final class StandardJavaScriptSerializer implements IStandardJavaScriptSerializer {
private static final Logger logger = LoggerFactory.getLogger(StandardJavaScriptSerializer.class);
private final IStandardJavaScriptSerializer delegate;
private String computeJacksonPackageNameIfPresent() {
// We will try to know whether Jackson is present in a way that is as resilient as possible with
// dependency package renaming, so we will return the package name.
try {
final Class<?> objectMapperClass = ObjectMapper.class;
final String objectMapperPackageName = objectMapperClass.getPackage().getName();
return objectMapperPackageName.substring(0, objectMapperPackageName.length() - ".databind".length());
} catch (final Throwable ignored) {
// Nothing bad - simply Jackson is not in the classpath
return null;
}
}
public StandardJavaScriptSerializer(final boolean useJacksonIfAvailable) {
super();
IStandardJavaScriptSerializer newDelegate = null;
final String jacksonPrefix = (useJacksonIfAvailable? computeJacksonPackageNameIfPresent() : null);
if (jacksonPrefix != null) {
try {
newDelegate = new JacksonStandardJavaScriptSerializer(jacksonPrefix);
} catch (final Exception e) {
handleErrorLoggingOnJacksonInitialization(e);
} catch (final NoSuchMethodError e) {
handleErrorLoggingOnJacksonInitialization(e);
}
}
if (newDelegate == null) {
// Jackson could not be used, so we will use a default StandardJavaScriptSerializer (non-Jackson)
newDelegate = new DefaultStandardJavaScriptSerializer();
}
this.delegate = newDelegate;
}
public void serializeValue(final Object object, final Writer writer) {
this.delegate.serializeValue(object, writer);
}
private static final class JacksonStandardJavaScriptSerializer implements IStandardJavaScriptSerializer {
private final ObjectMapper mapper;
JacksonStandardJavaScriptSerializer(final String jacksonPrefix) {
super();
this.mapper = new ObjectMapper();
this.mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
this.mapper.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
this.mapper.enable(JsonGenerator.Feature.ESCAPE_NON_ASCII);
this.mapper.getFactory().setCharacterEscapes(new JacksonThymeleafCharacterEscapes());
this.mapper.setDateFormat(new JacksonThymeleafISO8601DateFormat());
/*
* Now try to (conditionally) initialize support for Jackson serialization of JSR310 (java.time) objects,
* by making use of the 'jackson-datatype-jsr310' optional dependency.
*/
final Class<?> javaTimeModuleClass =
ClassLoaderUtils.findClass(jacksonPrefix + ".datatype.jsr310.JavaTimeModule");
if (javaTimeModuleClass != null) {
// JSR310 support for Jackson is present in classpath
try {
this.mapper.registerModule((Module)javaTimeModuleClass.newInstance());
this.mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
} catch (final InstantiationException e) {
throw new ConfigurationException("Exception while trying to initialize JSR310 support for Jackson", e);
} catch (final IllegalAccessException e) {
throw new ConfigurationException("Exception while trying to initialize JSR310 support for Jackson", e);
}
}
}
public void serializeValue(final Object object, final Writer writer) {
try {
this.mapper.writeValue(writer, object);
} catch (final IOException e) {
throw new TemplateProcessingException(
"An exception was raised while trying to serialize object to JavaScript using Jackson", e);
}
}
}
/*
* This DateFormat implementation replaces the standard Jackson date serialization mechanism for ISO6801 dates,
* with the aim of making Jackson output dates in a way that is at the same time ECMAScript-valid and also
* as compatible with non-Jackson JavaScript serialization infrastructure in Thymeleaf as possible. For this:
*
* * The default Jackson behaviour of outputting all dates as GMT is disabled.
* * The default Jackson format adding timezone as '+0800' is modified, as ECMAScript requires '+08:00'
*
* On the latter point, see https://github.com/FasterXML/jackson-databind/issues/1020
*/
private static final class JacksonThymeleafISO8601DateFormat extends DateFormat {
/*
* This SimpleDateFormat defines an almost-ISO8601 formatter.
*
* The correct ISO8601 format would be "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", but the "X" pattern (which outputs the
* timezone as "+02:00" or "Z" instead of "+0200") was not added until Java SE 7. So the use of this
* SimpleDateFormat object requires additional post-processing.
*
* SimpleDateFormat objects are NOT thread-safe, but it is here being used from another DateFormat
* implementation, so we must suppose that it is the use of this DateFormat wrapper that will be
* adequately synchronized by Jackson.
*/
private SimpleDateFormat dateFormat;
JacksonThymeleafISO8601DateFormat() {
super();
this.dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZZZ");
setCalendar(this.dateFormat.getCalendar());
setNumberFormat(this.dateFormat.getNumberFormat());
}
@Override
public StringBuffer format(final Date date, final StringBuffer toAppendTo, final FieldPosition fieldPosition) {
final StringBuffer formatted = this.dateFormat.format(date, toAppendTo, fieldPosition);
formatted.insert(26, ':');
return formatted;
}
@Override
public Date parse(final String source, final ParsePosition pos) {
throw new UnsupportedOperationException(
"JacksonThymeleafISO8601DateFormat should never be asked for a 'parse' operation");
}
}
/*
* This CharacterEscapes implementation makes sure that the slash ('/') and ampersand ('&') characters
* are also escaped, which is not standard Jackson behaviour.
*
* Escaping '/' covers against the possible premature closing of <script> tags inside inlined JavaScript
* literals, thus preventing code injection in templates being processed by browsers as HTML.
*
* Escaping '&' covers against the injection of XHTML-escaped code which might prematurely close the
* inlined JavaScript literals and even the container <script> tags in templates being processed
* by browsers as XHTML.
*
* Note that, unfortunately Jackson's escape customization mechanism offers no way to only escape '/' when it
* is preceded by '<', so that only '</' is escaped. Therefore, all '/' need to be escaped. Which is a
* difference with the default Unbescape-based mechanism.
*/
private static final class JacksonThymeleafCharacterEscapes extends CharacterEscapes {
private static final int[] CHARACTER_ESCAPES;
private static final SerializableString SLASH_ESCAPE;
private static final SerializableString AMPERSAND_ESCAPE;
static {
CHARACTER_ESCAPES = CharacterEscapes.standardAsciiEscapesForJSON();
CHARACTER_ESCAPES['/'] = CharacterEscapes.ESCAPE_CUSTOM;
CHARACTER_ESCAPES['&'] = CharacterEscapes.ESCAPE_CUSTOM;
SLASH_ESCAPE = new SerializedString("\\/");
AMPERSAND_ESCAPE = new SerializedString("\\u0026");
}
JacksonThymeleafCharacterEscapes() {
super();
}
@Override
public int[] getEscapeCodesForAscii() {
return CHARACTER_ESCAPES;
}
@Override
public SerializableString getEscapeSequence(final int ch) {
if (ch == '/') {
return SLASH_ESCAPE;
}
if (ch == '&') {
return AMPERSAND_ESCAPE;
}
return null;
}
}
private static final class DefaultStandardJavaScriptSerializer implements IStandardJavaScriptSerializer {
public void serializeValue(final Object object, final Writer writer) {
try {
writeValue(writer, object);
} catch (final IOException e) {
throw new TemplateProcessingException(
"An exception was raised while trying to serialize object to JavaScript using the default serializer", e);
}
}
private static void writeValue(final Writer writer, final Object object) throws IOException {
if (object == null) {
writeNull(writer);
return;
}
if (object instanceof CharSequence) {
writeString(writer, object.toString());
return;
}
if (object instanceof Character) {
writeString(writer, object.toString());
return;
}
if (object instanceof Number) {
writeNumber(writer, (Number) object);
return;
}
if (object instanceof Boolean) {
writeBoolean(writer, (Boolean) object);
return;
}
if (object instanceof Date) {
writeDate(writer, (Date) object);
return;
}
if (object instanceof Calendar) {
writeDate(writer, ((Calendar) object).getTime());
return;
}
if (object.getClass().isArray()) {
writeArray(writer, object);
return;
}
if (object instanceof Collection<?>) {
writeCollection(writer, (Collection<?>) object);
return;
}
if (object instanceof Map<?,?>) {
writeMap(writer, (Map<?, ?>) object);
return;
}
if (object instanceof Enum<?>) {
writeEnum(writer, object);
return;
}
writeObject(writer, object);
}
private static void writeNull(final Writer writer) throws IOException {
writer.write("null");
}
private static void writeString(final Writer writer, final String str) throws IOException {
/*
* Note we will be using JSON escape instead of JavaScript escape. They are basically (99%) interchangeable
* once we have established that our literals use double-quotes (") and not single-quotes, and this allows us
* to avoid escaping single-quotes and therefore be more consistent with Jackson-based serialization, which
* is obviously JSON-based.
*/
writer.write('"');
writer.write(JsonEscape.escapeJson(str, JsonEscapeType.SINGLE_ESCAPE_CHARS_DEFAULT_TO_UHEXA, JsonEscapeLevel.LEVEL_2_ALL_NON_ASCII_PLUS_BASIC_ESCAPE_SET));
writer.write('"');
}
private static void writeNumber(final Writer writer, final Number number) throws IOException {
writer.write(number.toString());
}
private static void writeBoolean(final Writer writer, final Boolean bool) throws IOException {
writer.write(bool.toString());
}
private static void writeDate(final Writer writer, final Date date) throws IOException {
writer.write('"');
writer.write(DateUtils.formatISO(date));
writer.write('"');
}
private static void writeArray(final Writer writer, final Object arrayObj) throws IOException {
writer.write('[');
if (arrayObj instanceof Object[]) {
final Object[] array = (Object[]) arrayObj;
boolean first = true;
for (final Object element: array) {
if (first) {
first = false;
} else {
writer.write(',');
}
writeValue(writer, element);
}
} else if (arrayObj instanceof boolean[]) {
final boolean[] array = (boolean[]) arrayObj;
boolean first = true;
for (final boolean element: array) {
if (first) {
first = false;
} else {
writer.write(',');
}
writeValue(writer, Boolean.valueOf(element));
}
} else if (arrayObj instanceof byte[]) {
final byte[] array = (byte[]) arrayObj;
boolean first = true;
for (final byte element: array) {
if (first) {
first = false;
} else {
writer.write(',');
}
writeValue(writer, Byte.valueOf(element));
}
} else if (arrayObj instanceof short[]) {
final short[] array = (short[]) arrayObj;
boolean first = true;
for (final short element: array) {
if (first) {
first = false;
} else {
writer.write(',');
}
writeValue(writer, Short.valueOf(element));
}
} else if (arrayObj instanceof int[]) {
final int[] array = (int[]) arrayObj;
boolean first = true;
for (final int element: array) {
if (first) {
first = false;
} else {
writer.write(',');
}
writeValue(writer, Integer.valueOf(element));
}
} else if (arrayObj instanceof long[]) {
final long[] array = (long[]) arrayObj;
boolean first = true;
for (final long element: array) {
if (first) {
first = false;
} else {
writer.write(',');
}
writeValue(writer, Long.valueOf(element));
}
} else if (arrayObj instanceof float[]) {
final float[] array = (float[]) arrayObj;
boolean first = true;
for (final float element: array) {
if (first) {
first = false;
} else {
writer.write(',');
}
writeValue(writer, Float.valueOf(element));
}
} else if (arrayObj instanceof double[]) {
final double[] array = (double[]) arrayObj;
boolean first = true;
for (final double element: array) {
if (first) {
first = false;
} else {
writer.write(',');
}
writeValue(writer, Double.valueOf(element));
}
} else {
throw new IllegalArgumentException("Cannot write value \"" + arrayObj + "\" of class " + arrayObj.getClass().getName() + " as an array");
}
writer.write(']');
}
private static void writeCollection(final Writer writer, final Collection<?> collection) throws IOException {
writer.write('[');
boolean first = true;
for (final Object element: collection) {
if (first) {
first = false;
} else {
writer.write(',');
}
writeValue(writer, element);
}
writer.write(']');
}
private static void writeMap(final Writer writer, final Map<?,?> map) throws IOException {
writer.write('{');
boolean first = true;
for (final Map.Entry<?,?> entry: map.entrySet()) {
if (first) {
first = false;
} else {
writer.write(',');
}
writeKeyValue(writer, entry.getKey(), entry.getValue());
}
writer.write('}');
}
private static void writeKeyValue(final Writer writer, final Object key, final Object value) throws IOException {
writeValue(writer, key);
writer.write(':');
writeValue(writer, value);
}
private static void writeObject(final Writer writer, final Object object) throws IOException {
try {
final PropertyDescriptor[] descriptors =
Introspector.getBeanInfo(object.getClass()).getPropertyDescriptors();
final Map<String,Object> properties = new LinkedHashMap<String, Object>(descriptors.length + 1, 1.0f);
for (final PropertyDescriptor descriptor : descriptors) {
final Method readMethod = descriptor.getReadMethod();
if (readMethod != null) {
final String name = descriptor.getName();
if (!"class".equals(name.toLowerCase())) {
final Object value = readMethod.invoke(object);
properties.put(name, value);
}
}
}
writeMap(writer, properties);
} catch (final IllegalAccessException e) {
throw new IllegalArgumentException("Could not perform introspection on object of class " + object.getClass().getName(), e);
} catch (final InvocationTargetException e) {
throw new IllegalArgumentException("Could not perform introspection on object of class " + object.getClass().getName(), e);
} catch (final IntrospectionException e) {
throw new IllegalArgumentException("Could not perform introspection on object of class " + object.getClass().getName(), e);
}
}
private static void writeEnum(final Writer writer, final Object object) throws IOException {
final Enum<?> enumObject = (Enum<?>) object;
writeString(writer, enumObject.toString());
}
}
private void handleErrorLoggingOnJacksonInitialization(Throwable e) {
final String warningMessage =
"[THYMELEAF] Could not initialize Jackson-based serializer even if the Jackson library was " +
"detected to be present at the classpath. Please make sure you are adding the " +
"jackson-databind module to your classpath, and that version is >= 2.5.0. " +
"THYMELEAF INITIALIZATION WILL CONTINUE, but Jackson will not be used for JavaScript " +
"serialization.";
if (logger.isDebugEnabled()) {
logger.warn(warningMessage, e);
} else {
// We will avoid an ugly exception trace appearing through the logs as WARN unless DEBUG is enabled
logger.warn(
warningMessage + " Set the log to DEBUG to see a complete exception trace. Exception " +
"message is: " + e.getMessage());
}
}
}