Serializer.java

package com.github.celldynamics.quimp;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Reader;
import java.io.StringReader;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.github.celldynamics.quimp.filesystem.IQuimpSerialize;
import com.github.celldynamics.quimp.filesystem.versions.IQconfOlderConverter;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;

/**
 * Support saving and loading wrapped class to/from JSON file or string.
 * 
 * <p>The Serializer class wraps provided objects and converts it to Gson together with itself.
 * Serializer adds fields like wrapped class name and versioning data (@link {@link QuimpVersion})
 * to JSON.
 * 
 * <p>Restored object is constructed using its constructor. If JSON file does not contain variable
 * available in class being restored, it will have the value assigned in constructor or null. GSon
 * overrides variables after they have been created in normal process of object building. Check
 * {@link #fromReader(Reader)} for details.
 * 
 * <p>This serializer accepts only classes derived from IQuimpSerialize interface. Saved class is
 * packed in top level structure that contains version of software and wrapped class name. Exemplary
 * use case: SerializerTest#testLoad_1()
 * 
 * <p>There is option to skip call afterSerialzie() or beforeSerialzie() method on class restoring
 * or saving To do so set {@link #doAfterSerialize} to false or {@link #doBeforeSerialize}
 * 
 * <p>Serializer supports <tt>Since, Until</tt> tags from GSon library. User can write his own
 * converters executed if specified condition is met. Serializer compares version of callee tool
 * (provided in Serializer constructor) with trigger version returned by converter
 * {@link IQconfOlderConverter} and executes conversion provided by it.
 * <p>
 * <b>Important</b>: Until and Since tags are resolved using version of QCONF provided from callee
 * on json saving or by version read from QCONF file on its loading. Version read from JSON is also
 * used to decide whether apply converter or not but on load only.
 * </p>
 * 
 * @author p.baniukiewicz
 * @param <T> class type to be serialised
 * @see <a href=
 *      "link">http://stackoverflow.com/questions/14139437/java-type-generic-as-argument-for-gson</a>
 * @see com.github.celldynamics.quimp.Serializer#registerInstanceCreator(Class, Object)
 * @see #registerConverter(IQconfOlderConverter)
 */
public class Serializer<T extends IQuimpSerialize> implements ParameterizedType {

  /**
   * The Constant LOGGER.
   */
  static final Logger LOGGER = LoggerFactory.getLogger(Serializer.class.getName());

  /**
   * The gson builder.
   */
  public transient GsonBuilder gsonBuilder;
  private transient Type type;

  /**
   * Indicates if afterSerialze should be called.
   */
  protected transient boolean doAfterSerialize;
  /**
   * Indicates if {@link IQuimpSerialize#beforeSerialize()} should be called.
   * 
   * <p>Rather for tests as {@link IQuimpSerialize#beforeSerialize()} is always required.
   */
  protected transient boolean doBeforeSerialize;

  /**
   * Name of wrapped class, decoded from object.
   */
  public String className;

  /**
   * Version and other information passed to serializer. Since(17.0202)
   */
  public QuimpVersion timeStamp;

  /**
   * Date when file has been created.
   */
  public String createdOn;

  /**
   * Wrapped object being serialized.
   */
  public T obj;

  /**
   * Version stored in QCONF file loaded by Serialiser.
   * 
   * <p>If class is serialised (saved) it contains version provided with constructor. This version
   * is
   * provided to GSon on loading json
   */
  private transient Double qconfVersionToLoad;

  /**
   * Version provided form callee.
   * 
   * <p>This version is provided to GSon on saving json.
   */
  private transient Double qconfVersionToSave;

  /**
   * List of format converters called on every load when certain condition is met.
   */
  private transient ArrayList<IQconfOlderConverter<T>> converters = new ArrayList<>();

  /**
   * Default constructor used for restoring object.
   * 
   * <p>Template T can not be restored during runtime thus the type of wrapped object is not known
   * for GSon. This is why this type must be passed explicitly to Serializer.
   * 
   * @param type class type
   * @param version Version of framework this class is called from.
   */
  public Serializer(final Type type, final QuimpVersion version) {
    doAfterSerialize = true; // by default use afterSerialize methods to restore object state
    doBeforeSerialize = true;
    gsonBuilder = new GsonBuilder();
    obj = null;
    this.timeStamp = version;
    this.type = type;
    // fill date of creation
    createdOn = getCurrentDate();
  }

  /**
   * Constructor used for saving wrapped class.
   * 
   * @param obj Object being saved
   * @param version Version of framework this class is called from.
   */
  public Serializer(final T obj, final QuimpVersion version) {
    doAfterSerialize = true; // by default use afterSerialize methods to restore object state
    doBeforeSerialize = true;
    gsonBuilder = new GsonBuilder();
    this.type = obj.getClass();
    this.obj = obj;
    className = obj.getClass().getSimpleName();
    this.timeStamp = version;
    // set it as callee version if we will save json (json version is read on load only)
    this.qconfVersionToSave = convertStringVersion(version.getVersion());
    // fill date of creation
    createdOn = getCurrentDate();
  }

  /**
   * Save wrapped object passed in constructor as JSON file.
   * 
   * <p>Calls {@link IQuimpSerialize#beforeSerialize()} before save.
   * 
   * @param filename Name of file
   * @throws FileNotFoundException if problem with saving
   * @see com.github.celldynamics.quimp.Serializer#setPretty()
   * @see com.github.celldynamics.quimp.Serializer#Serializer(IQuimpSerialize, QuimpVersion)
   * @see com.github.celldynamics.quimp.Serializer#toString()
   */
  public void save(final String filename) throws FileNotFoundException {
    String str;
    str = toString(); // produce json
    LOGGER.debug("Saving at: " + filename);
    PrintWriter f;
    f = new PrintWriter(new File(filename));
    f.print(str);
    f.close();
  }

  /**
   * Load GSon file.
   * 
   * @param filename to load
   * @return Serializer object
   * @throws IOException when file can not be found
   * @throws JsonSyntaxException on wrong syntax
   * @throws JsonIOException on wrong syntax
   * @throws Exception any other case
   * @see #load(File)
   */
  public Serializer<T> load(final String filename)
          throws IOException, JsonSyntaxException, JsonIOException, Exception {
    File file = new File(filename);
    return load(file);
  }

  /**
   * Load wrapped object from JSON file.
   * 
   * <p>Calls {@link IQuimpSerialize#afterSerialize()} after load. The general steps taken on GSon
   * load are as follows:
   * 
   * <p><img src="doc-files/Serializer_1_UML.png"/>
   * </p>
   * 
   * @param filename to load
   * @return Serialiser object
   * @throws IOException when file can not be found
   * @throws JsonSyntaxException on wrong syntax
   * @throws JsonIOException on wrong syntax
   * @throws Exception any other case
   * @see #fromReader(Reader)
   */
  public Serializer<T> load(final File filename)
          throws IOException, JsonSyntaxException, JsonIOException, Exception {
    LOGGER.debug("Loading from: " + filename.getPath());
    // gather version from JSON
    FileReader vr = new FileReader(filename);
    qconfVersionToLoad = getQconfVersion(vr);
    vr.close(); // on duplicate to avoid problems with moving pointer

    FileReader f = new FileReader(filename);
    return fromReader(f);
  }

  /**
   * Restore wrapped object from JSON string.
   * 
   * @param json string with json
   * @return Serialise object
   * @throws JsonSyntaxException on wrong syntax
   * @throws JsonIOException on wrong syntax
   * @throws Exception any other case
   * @see #fromReader(Reader)
   */
  public Serializer<T> fromString(final String json)
          throws JsonSyntaxException, JsonIOException, Exception {
    LOGGER.debug("Reading from string");
    // gather version from JSON
    Reader vr = new StringReader(json);
    qconfVersionToLoad = getQconfVersion(vr);
    vr.close(); // on duplicate to avoid problems with moving pointer

    Reader reader = new StringReader(json);
    return fromReader(reader);
  }

  /**
   * Restore wrapped object from JSON string.
   * 
   * @param reader reader that provides JSon string
   * @see #load(File)
   * 
   * @return New instance of loaded object packed in Serializer class. returned instance has
   *         proper (no nulls or empty strings) fields: className, createdOn, version
   *         (and its subfields, obj)
   * @throws Exception from afterSerialize() method (specific to wrapped object)
   * @throws IOException when file can not be read
   * @throws JsonSyntaxException on bad file or when class has not been restored correctly
   * @throws JsonIOException This exception is raised when Gson was unable to read an input stream
   *         or write to on
   */
  public Serializer<T> fromReader(final Reader reader)
          throws JsonSyntaxException, JsonIOException, Exception {

    // warn user if newer config is load to older QuimP
    if (qconfVersionToLoad > convertStringVersion(timeStamp.getVersion())) {
      LOGGER.info("You are trying to load config file which is in newer version"
              + " than software you are using. (" + qconfVersionToLoad + " vs "
              + convertStringVersion(timeStamp.getVersion()) + ")");
    }

    // set version to load (read from file)
    gsonBuilder.setVersion(qconfVersionToLoad);

    Gson gson = gsonBuilder.create();
    Serializer<T> localref;
    localref = gson.fromJson(reader, this);
    verify(localref); // verification of correctness and conversion to current format

    if (doAfterSerialize) {
      localref.obj.afterSerialize();
    }
    return localref;
  }

  /**
   * Perform basic verification of loaded file.
   * 
   * <p>It verifies rather on general level for fields added by Serializer itself. More detailed
   * verification related to serialized class should be performed after full restoration of
   * wrapped object.
   * 
   * @param localref object to verify
   * @throws JsonSyntaxException on bad file or when class has not been restored correctly
   */
  private void verify(Serializer<T> localref) throws JsonSyntaxException {
    // basic verification of loaded file, check whether some fields have reasonable values
    try {
      if (localref == null || localref.obj == null || localref.className.isEmpty()
              || localref.createdOn.isEmpty()) {
        throw new JsonSyntaxException("Can not map loaded gson to class. Is it proper file?");
      }
      convert(localref);
    } catch (NullPointerException | IllegalArgumentException | QuimpException np) {
      throw new JsonSyntaxException("Can not map loaded gson to class. Is it proper file?", np);
    }
  }

  /**
   * This method is called on load and goes through registered converters executing them.
   * 
   * <p>Perform conversions from older version to current (newer).
   * 
   * @param localref restored object
   * @throws QuimpException on problems with conversion
   * @see #registerConverter(IQconfOlderConverter)
   */
  private void convert(Serializer<T> localref) throws QuimpException {
    if (converters.isEmpty()) {
      return; // no converters registered
    }

    for (IQconfOlderConverter<T> converter : converters) {
      // compare version loaded from file. If read version from file is smaller than returned
      // by converter - execute conversion
      if (converter.executeForLowerThan() > qconfVersionToLoad) {
        converter.upgradeFromOld(localref);
      }
    }
  }

  /**
   * This method register format converter on list of converters.
   * 
   * <p>Registered converters are called on every object deserialisation in order that they were
   * registered. Converter is run when version of tool is higher than version of converter.
   * 
   * @param converter converter
   * @see IQconfOlderConverter
   * @see #convert(Serializer)
   */
  public void registerConverter(IQconfOlderConverter<T> converter) {
    converters.add(converter);
  }

  /**
   * Convert wrapped class to JSON representation together with Serializer wrapper
   * 
   * <p>Calls com.github.celldynamics.quimp.IQuimpSerialize.beforeSerialize() before conversion
   * 
   * @return JSON string
   * @see com.github.celldynamics.quimp.Serializer#setPretty()
   */
  @Override
  public String toString() {
    // set version to save (read from calee)
    gsonBuilder.setVersion(qconfVersionToSave);
    Gson gson = gsonBuilder.create();
    if (obj != null && doBeforeSerialize == true) {
      obj.beforeSerialize();
    }
    return gson.toJson(this);
  }

  /**
   * Get current date included in saved file.
   * 
   * @return Formatted string with date.
   */
  public String getCurrentDate() {
    Date dateNow = new Date();
    SimpleDateFormat df = new SimpleDateFormat("E yyyy.MM.dd 'at' HH:mm:ss a zzz");
    return df.format(dateNow);
  }

  /**
   * Dump object.
   *
   * @param obj object to dump (must be packed in Serializer already)
   * @param filename filename
   * @param savePretty true if use pretty format
   * @throws FileNotFoundException on saving problem
   * @see #jsonDump(Object, String, boolean)
   * @deprecated It does not support GSon versioning
   */
  @Deprecated
  static void jsonDump(final Object obj, final String filename, boolean savePretty)
          throws FileNotFoundException {
    File file = new File(filename);
    Serializer.jsonDump(obj, file, savePretty);
  }

  /**
   * Performs pure dump of provided object without packing it into super class
   * 
   * <p><b>Warning</b>
   * 
   * <p>This method does not call beforeSerialize(). It must be called explicitly before dumping.
   * Can be used for saving already packed objects
   * 
   * @param obj to dump
   * @param filename to be saved under
   * @param savePretty if \a true use pretty format
   * @throws FileNotFoundException when file can not be created
   * @deprecated It does not support GSon versioning
   */
  @Deprecated
  static void jsonDump(final Object obj, final File filename, boolean savePretty)
          throws FileNotFoundException {
    GsonBuilder gsonBuilder = new GsonBuilder();
    if (savePretty) {
      gsonBuilder.setPrettyPrinting();
    }
    Gson gson = gsonBuilder.create();
    if (obj != null) {
      String str = gson.toJson(obj);
      PrintWriter f;
      f = new PrintWriter(filename);
      f.print(str);
      f.close();
    }
  }

  /**
   * Sets pretty JSON formatting on save operation.
   * 
   * @see com.github.celldynamics.quimp.Serializer#toString()
   * @see com.github.celldynamics.quimp.Serializer#save(String)
   */
  public void setPretty() {
    gsonBuilder.setPrettyPrinting();
  }

  /**
   * Read QuimP version from QCONF file.
   * 
   * <p>It does not deserialize JSON, just plain string reading from file.
   * 
   * @param reader reader that delivers string
   * @return Version string encoded as double. Any -SNAPSHOT suffix is removed. Return 0.0 on
   *         error.
   * @throws JsonSyntaxException on version read error
   */
  public Double getQconfVersion(Reader reader) {
    // key to look for
    final String versionKey = "\"version\"";
    char[] buf = new char[256];
    try {
      reader.read(buf);
    } catch (IOException e) {
      throw new JsonSyntaxException("JSON string can not be read", e);
    }
    String sbuf = new String(buf);
    LOGGER.trace("Header: " + sbuf);
    int pos = sbuf.indexOf(versionKey);
    if (pos < 0) {
      throw new JsonSyntaxException("JSON file does not contain version tag");
    }
    pos = sbuf.indexOf("\"", pos + versionKey.length());
    int pos2 = sbuf.indexOf("\"", pos + 1);
    String version = sbuf.substring(pos + 1, pos2);
    Double ret = convertStringVersion(version);
    return ret;
  }

  /**
   * Convert string in format a.b.c-SNAPSHOT to double a.bc
   * 
   * @param ver String version to convert
   * @return Double representation of version string
   * @throws JsonSyntaxException on wrong conversions (due to e.g. wrong wile read or bad
   *         structure)
   */
  private Double convertStringVersion(String ver) {
    String ret;
    try {
      // remove "" and other stuff
      ret = ver.replaceAll("([ \",]|-SNAPSHOT)", "");
      int dotcount = ret.length() - ret.replace(".", "").length();
      if (dotcount > 2) {
        throw new JsonSyntaxException("Format of version string must follow rule major.minor.inc");
      }
      if (dotcount == 2) {
        int seconddotpos = ret.lastIndexOf('.');
        ret = ret.substring(0, seconddotpos) + ret.substring(seconddotpos + 1);
      }
      return new Double(ret);
    } catch (NumberFormatException ex) {
      throw new JsonSyntaxException("Version string could not be converted to number", ex);
    }
  }

  /**
   * Register constructor for wrapped class.
   * 
   * <p>It may be necessary during loading JSON file if wrapped class needs some parameters to
   * restore its state on com.github.celldynamics.quimp.IQuimpSerialize.afterSerialize() call and
   * those
   * parameters are passed in constructor.
   * 
   * @param type Type of class
   * @param typeAdapter Wrapped object builder that implements InstanceCreator interface.
   * @see com.github.celldynamics.quimp.filesystem.IQuimpSerialize#afterSerialize()
   * @see <a href=
   *      "GSon doc">https://github.com/google/gson/blob/master/UserGuide.md#TOC-InstanceCreator-for-a-Parameterized-Type</a>
   */
  public void registerInstanceCreator(Class<T> type, Object typeAdapter) {
    gsonBuilder.registerTypeAdapter(type, typeAdapter);
  }

  /*
   * (non-Javadoc)
   * 
   * @see java.lang.reflect.ParameterizedType#getActualTypeArguments()
   */
  @Override
  public Type[] getActualTypeArguments() {
    return new Type[] { type };
  }

  /*
   * (non-Javadoc)
   * 
   * @see java.lang.reflect.ParameterizedType#getRawType()
   */
  @Override
  public Type getRawType() {
    return Serializer.class;
  }

  /*
   * (non-Javadoc)
   * 
   * @see java.lang.reflect.ParameterizedType#getOwnerType()
   */
  @Override
  public Type getOwnerType() {
    return null;
  }

}