/**
* Copyright 2010-2012 Ralph Schaer <ralphschaer@gmail.com>
*
* 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 ch.ralscha.extdirectspring.generator;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.IOException;
import java.lang.ref.SoftReference;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.util.DigestUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.ReflectionUtils.FieldCallback;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.support.RequestContextUtils;
import ch.ralscha.extdirectspring.controller.RouterController;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.core.JsonGenerationException;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Generator for creating ExtJS and Touch Model objects (JS code) based on a
* provided class or {@link ModelBean}.
*
* @author Ralph Schaer
*/
public abstract class ModelGenerator {
private static final Map<JsCacheKey, SoftReference<String>> jsCache = new ConcurrentHashMap<JsCacheKey, SoftReference<String>>();
private static final Map<String, SoftReference<ModelBean>> modelCache = new ConcurrentHashMap<String, SoftReference<ModelBean>>();
/**
* Instrospects the provided class, creates a model object (JS code) and
* writes it into the response. Creates compressed JS code.
*
* @param request the http servlet request
* @param response the http servlet response
* @param clazz class that the generator should introspect
* @param format specifies which code (ExtJS or Touch) the generator should
* create.
* @throws IOException
*
* @see #writeModel(HttpServletRequest, HttpServletResponse, Class,
* OutputFormat, boolean)
*/
public static void writeModel(HttpServletRequest request, HttpServletResponse response, Class<?> clazz,
OutputFormat format) throws IOException {
writeModel(request, response, clazz, format, false);
}
/**
* Instrospects the provided class, creates a model object (JS code) and
* writes it into the response.
*
* @param request the http servlet request
* @param response the http servlet response
* @param clazz class that the generator should introspect
* @param format specifies which code (ExtJS or Touch) the generator should
* create
* @param debug if true the generator creates the output in pretty format,
* false the output is compressed
* @throws IOException
*/
public static void writeModel(HttpServletRequest request, HttpServletResponse response, Class<?> clazz,
OutputFormat format, boolean debug) throws IOException {
ModelBean model = createModel(clazz);
writeModel(request, response, model, format, debug);
}
/**
* Creates a model object (JS code) based on the provided {@link ModelBean}
* and writes it into the response. Creates compressed JS code.
*
* @param request the http servlet request
* @param response the http servlet response
* @param model {@link ModelBean} describing the model to be generated
* @param format specifies which code (ExtJS or Touch) the generator should
* create.
* @throws IOException
*/
public static void writeModel(HttpServletRequest request, HttpServletResponse response, ModelBean model,
OutputFormat format) throws IOException {
writeModel(request, response, model, format, false);
}
/**
* Creates a model object (JS code) based on the provided ModelBean and
* writes it into the response.
*
* @param request the http servlet request
* @param response the http servlet response
* @param model {@link ModelBean} describing the model to be generated
* @param format specifies which code (ExtJS or Touch) the generator should
* create.
* @param debug if true the generator creates the output in pretty format,
* false the output is compressed
* @throws IOException
*/
public static void writeModel(HttpServletRequest request, HttpServletResponse response, ModelBean model,
OutputFormat format, boolean debug) throws IOException {
RouterController routerController = RequestContextUtils.getWebApplicationContext(request).getBean(
RouterController.class);
byte[] data = generateJavascript(model, format, debug).getBytes(RouterController.UTF8_CHARSET);
String ifNoneMatch = request.getHeader("If-None-Match");
String etag = "\"0" + DigestUtils.md5DigestAsHex(data) + "\"";
if (etag.equals(ifNoneMatch)) {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
return;
}
response.setContentType(routerController.getConfiguration().getJsContentType());
response.setCharacterEncoding(RouterController.UTF8_CHARSET.name());
response.setContentLength(data.length);
response.setHeader("ETag", etag);
@SuppressWarnings("resource")
ServletOutputStream out = response.getOutputStream();
out.write(data);
out.flush();
}
/**
* Instrospects the provided class and creates a {@link ModelBean} instance.
* A program could customize this and call
* {@link #generateJavascript(ModelBean, OutputFormat, boolean)} or
* {@link #writeModel(HttpServletRequest, HttpServletResponse, ModelBean, OutputFormat)}
* to create JS code.
*
* @param clazz the model will be created based on this class.
* @return a instance of {@link ModelBean} that describes the provided class
* and can be used for Javascript generation.
*/
public static ModelBean createModel(Class<?> clazz) {
SoftReference<ModelBean> modelReference = modelCache.get(clazz.getName());
if (modelReference != null && modelReference.get() != null) {
return modelReference.get();
}
Model modelAnnotation = clazz.getAnnotation(Model.class);
final ModelBean model = new ModelBean();
if (modelAnnotation != null && StringUtils.hasText(modelAnnotation.value())) {
model.setName(modelAnnotation.value());
} else {
model.setName(clazz.getName());
}
if (modelAnnotation != null) {
model.setIdProperty(modelAnnotation.idProperty());
model.setPaging(modelAnnotation.paging());
if (StringUtils.hasText(modelAnnotation.createMethod())) {
model.setCreateMethod(modelAnnotation.createMethod());
}
if (StringUtils.hasText(modelAnnotation.readMethod())) {
model.setReadMethod(modelAnnotation.readMethod());
}
if (StringUtils.hasText(modelAnnotation.updateMethod())) {
model.setUpdateMethod(modelAnnotation.updateMethod());
}
if (StringUtils.hasText(modelAnnotation.destroyMethod())) {
model.setDestroyMethod(modelAnnotation.destroyMethod());
}
}
final Set<String> hasReadMethod = new HashSet<String>();
BeanInfo bi;
try {
bi = Introspector.getBeanInfo(clazz);
} catch (IntrospectionException e) {
throw new RuntimeException(e);
}
for (PropertyDescriptor pd : bi.getPropertyDescriptors()) {
if (pd.getReadMethod() != null && pd.getReadMethod().getAnnotation(JsonIgnore.class) == null) {
hasReadMethod.add(pd.getName());
}
}
final List<ModelFieldBean> modelFields = new ArrayList<ModelFieldBean>();
ReflectionUtils.doWithFields(clazz, new FieldCallback() {
private final Set<String> fields = new HashSet<String>();
@Override
public void doWith(Field field) throws IllegalArgumentException, IllegalAccessException {
if ((Modifier.isPublic(field.getModifiers()) || hasReadMethod.contains(field.getName()))
&& field.getAnnotation(JsonIgnore.class) == null) {
if (fields.contains(field.getName())) {
// ignore superclass declarations of fields already
// found in a subclass
} else {
fields.add(field.getName());
Class<?> javaType = field.getType();
ModelType modelType = null;
for (ModelType mt : ModelType.values()) {
if (mt.supports(javaType)) {
modelType = mt;
break;
}
}
ModelField modelFieldAnnotation = field.getAnnotation(ModelField.class);
if (modelFieldAnnotation != null) {
String name;
if (StringUtils.hasText(modelFieldAnnotation.value())) {
name = modelFieldAnnotation.value();
} else {
name = field.getName();
}
ModelType type;
if (modelFieldAnnotation.type() != ModelType.AUTO) {
type = modelFieldAnnotation.type();
} else {
if (modelType != null) {
type = modelType;
} else {
type = ModelType.AUTO;
}
}
ModelFieldBean modelFieldBean = new ModelFieldBean(name, type);
if (StringUtils.hasText(modelFieldAnnotation.dateFormat()) && type == ModelType.DATE) {
modelFieldBean.setDateFormat(modelFieldAnnotation.dateFormat());
}
if (StringUtils.hasText(modelFieldAnnotation.defaultValue())) {
if (type == ModelType.BOOLEAN) {
modelFieldBean.setDefaultValue(Boolean.parseBoolean(modelFieldAnnotation
.defaultValue()));
} else if (type == ModelType.INTEGER) {
modelFieldBean.setDefaultValue(Long.valueOf(modelFieldAnnotation.defaultValue()));
} else if (type == ModelType.FLOAT) {
modelFieldBean.setDefaultValue(Double.valueOf(modelFieldAnnotation.defaultValue()));
} else {
modelFieldBean.setDefaultValue(modelFieldAnnotation.defaultValue());
}
}
if (modelFieldAnnotation.useNull()
&& (type == ModelType.INTEGER || type == ModelType.FLOAT
|| type == ModelType.STRING || type == ModelType.BOOLEAN)) {
modelFieldBean.setUseNull(true);
}
modelFields.add(modelFieldBean);
} else {
if (modelType != null) {
modelFields.add(new ModelFieldBean(field.getName(), modelType));
}
}
}
}
}
});
model.addFields(modelFields);
modelCache.put(clazz.getName(), new SoftReference<ModelBean>(model));
return model;
}
/**
* Instrospects the provided class, creates a model object (JS code) and
* returns it.
*
* @param clazz class that the generator should introspect
* @param format specifies which code (ExtJS or Touch) the generator should
* create
* @param debug if true the generator creates the output in pretty format,
* false the output is compressed
* @return the generated model object (JS code)
*/
public static String generateJavascript(Class<?> clazz, OutputFormat format, boolean debug) {
ModelBean model = createModel(clazz);
return generateJavascript(model, format, debug);
}
/**
* Creates JS code based on the provided {@link ModelBean} in the specified
* {@link OutputFormat}. Code can be generated in pretty or compressed
* format.
*
* @param model generate code based on this {@link ModelBean}
* @param format specifies which code (ExtJS or Touch) the generator should
* create
* @param debug if true the generator creates the output in pretty format,
* false the output is compressed
* @return the generated model object (JS code)
*/
public static String generateJavascript(ModelBean model, OutputFormat format, boolean debug) {
JsCacheKey key = new JsCacheKey(model, format, debug);
SoftReference<String> jsReference = jsCache.get(key);
if (jsReference != null && jsReference.get() != null) {
return jsReference.get();
}
ObjectMapper mapper = new ObjectMapper();
mapper.configure(JsonGenerator.Feature.QUOTE_FIELD_NAMES, false);
Map<String, Object> modelObject = new LinkedHashMap<String, Object>();
modelObject.put("extend", "Ext.data.Model");
Map<String, Object> configObject = new LinkedHashMap<String, Object>();
if (StringUtils.hasText(model.getIdProperty()) && !model.getIdProperty().equals("id")) {
configObject.put("idProperty", model.getIdProperty());
}
configObject.put("fields", model.getFields().values());
Map<String, Object> proxyObject = new LinkedHashMap<String, Object>();
proxyObject.put("type", "direct");
Map<String, Object> apiObject = new LinkedHashMap<String, Object>();
if (StringUtils.hasText(model.getReadMethod()) && !StringUtils.hasText(model.getCreateMethod())
&& !StringUtils.hasText(model.getUpdateMethod()) && !StringUtils.hasText(model.getDestroyMethod())) {
proxyObject.put("directFn", model.getReadMethod());
} else {
if (StringUtils.hasText(model.getReadMethod())) {
apiObject.put("read", model.getReadMethod());
}
if (StringUtils.hasText(model.getCreateMethod())) {
apiObject.put("create", model.getCreateMethod());
}
if (StringUtils.hasText(model.getUpdateMethod())) {
apiObject.put("update", model.getUpdateMethod());
}
if (StringUtils.hasText(model.getDestroyMethod())) {
apiObject.put("destroy", model.getDestroyMethod());
}
if (!apiObject.isEmpty()) {
proxyObject.put("api", apiObject);
}
}
if (model.isPaging()) {
Map<String, Object> readerObject = new LinkedHashMap<String, Object>();
readerObject.put("root", "records");
proxyObject.put("reader", readerObject);
}
if (!apiObject.isEmpty() || proxyObject.containsKey("directFn")) {
configObject.put("proxy", proxyObject);
}
if (format == OutputFormat.EXTJS4) {
modelObject.putAll(configObject);
} else {
modelObject.put("config", configObject);
}
StringBuilder sb = new StringBuilder();
sb.append("Ext.define('").append(model.getName()).append("',");
if (debug) {
sb.append("\n");
}
String configObjectString;
try {
if (debug) {
configObjectString = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(modelObject);
} else {
configObjectString = mapper.writeValueAsString(modelObject);
}
} catch (JsonGenerationException e) {
throw new RuntimeException(e);
} catch (JsonMappingException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
configObjectString = configObjectString.replace("\"", "'");
configObjectString = configObjectString.replaceAll("directFn( ?: ?)'([^']+)'", "directFn$1$2");
configObjectString = configObjectString.replaceAll("read( ?: ?)'([^']+)'", "read$1$2");
configObjectString = configObjectString.replaceAll("create( ?: ?)'([^']+)'", "create$1$2");
configObjectString = configObjectString.replaceAll("update( ?: ?)'([^']+)'", "update$1$2");
configObjectString = configObjectString.replaceAll("destroy( ?: ?)'([^']+)'", "destroy$1$2");
sb.append(configObjectString);
sb.append(");");
String result = sb.toString();
jsCache.put(key, new SoftReference<String>(result));
return result;
}
private static class JsCacheKey {
private final ModelBean modelBean;
private final OutputFormat format;
private final boolean debug;
JsCacheKey(ModelBean modelBean, OutputFormat format, boolean debug) {
this.modelBean = modelBean;
this.format = format;
this.debug = debug;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + (debug ? 1231 : 1237);
result = prime * result + ((format == null) ? 0 : format.hashCode());
result = prime * result + ((modelBean == null) ? 0 : modelBean.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
JsCacheKey other = (JsCacheKey) obj;
if (debug != other.debug) {
return false;
}
if (format != other.format) {
return false;
}
if (modelBean == null) {
if (other.modelBean != null) {
return false;
}
} else if (!modelBean.equals(other.modelBean)) {
return false;
}
return true;
}
}
}