CachingXmlDataStore.java

/*******************************************************************************
 * Copyright 2012 André Rouél
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *   http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 ******************************************************************************/
package net.sf.uadetector.datastore;

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.Charset;

import javax.annotation.Nonnull;

import net.sf.qualitycheck.Check;
import net.sf.qualitycheck.exception.IllegalStateOfArgumentException;
import net.sf.uadetector.datareader.DataReader;
import net.sf.uadetector.datareader.XmlDataReader;
import net.sf.uadetector.internal.data.Data;
import net.sf.uadetector.internal.util.FileUtil;
import net.sf.uadetector.internal.util.UrlUtil;

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

/**
 * Implementation of a {@link DataStore} which is able to recover <em>UAS data</em> in XML format from a cache file. If
 * the cache file is empty, the data will be read from the given data URL.<br>
 * <br>
 * You can also update the data of the store at any time if you trigger {@link CachingXmlDataStore#refresh()}.
 * 
 * @author André Rouél
 */
public final class CachingXmlDataStore extends AbstractRefreshableDataStore {

	/**
	 * Internal data store which will be used to load previously saved <em>UAS data</em> from a cache file.
	 */
	private static class CacheFileDataStore extends AbstractDataStore {
		protected CacheFileDataStore(final Data data, final DataReader reader, final URL dataUrl, final Charset charset) {
			super(data, reader, dataUrl, dataUrl, charset);
		}
	}

	/**
	 * The default temporary-file directory
	 */
	private static final String CACHE_DIR = System.getProperty("java.io.tmpdir");

	/**
	 * Corresponding default logger of this class
	 */
	private static final Logger LOG = LoggerFactory.getLogger(CachingXmlDataStore.class);

	/**
	 * Message for the log if the cache file is filled
	 */
	private static final String MSG_CACHE_FILE_IS_EMPTY = "The cache file is empty. The given UAS data source will be imported.";

	/**
	 * Message for the log if the cache file is empty
	 */
	private static final String MSG_CACHE_FILE_IS_FILLED = "The cache file is filled and will be imported.";

	/**
	 * Message if the cache file contains unexpected data and must be deleted manually
	 */
	private static final String MSG_CACHE_FILE_IS_DAMAGED = "The cache file '%s' is damaged and must be removed manually.";

	/**
	 * Message if the cache file contains unexpected data and has been removed
	 */
	private static final String MSG_CACHE_FILE_IS_DAMAGED_AND_DELETED = "The cache file '%s' is damaged and has been deleted.";

	/**
	 * The prefix string to be used in generating the cache file's name; must be at least three characters long
	 */
	private static final String PREFIX = "uas";

	/**
	 * The suffix string to be used in generating the cache file's name; may be {@code null}, in which case the suffix "
	 * {@code .tmp}" will be used
	 */
	private static final String SUFFIX = ".xml";

	/**
	 * Constructs a new instance of {@code CachingXmlDataStore} with the given arguments. The given {@code cacheFile}
	 * can be empty or filled with previously cached data in XML format. The file must be writable otherwise an
	 * exception will be thrown.
	 * 
	 * @param fallback
	 *            <em>UAS data</em> as fallback in case the data on the specified resource can not be read correctly
	 * @return new instance of {@link CachingXmlDataStore}
	 * @throws net.sf.qualitycheck.exception.IllegalNullArgumentException
	 *             if one of the given arguments is {@code null}
	 * @throws net.sf.qualitycheck.exception.IllegalStateOfArgumentException
	 *             if the given cache file can not be read
	 * @throws net.sf.qualitycheck.exception.IllegalStateOfArgumentException
	 *             if no URL can be resolved to the given given file
	 */
	@Nonnull
	public static CachingXmlDataStore createCachingXmlDataStore(@Nonnull final DataStore fallback) {
		return createCachingXmlDataStore(findOrCreateCacheFile(), fallback);
	}

	/**
	 * Constructs a new instance of {@code CachingXmlDataStore} with the given arguments. The given {@code cacheFile}
	 * can be empty or filled with previously cached data in XML format. The file must be writable otherwise an
	 * exception will be thrown.
	 * 
	 * @param cacheFile
	 *            file with cached <em>UAS data</em> in XML format or empty file
	 * @param fallback
	 *            <em>UAS data</em> as fallback in case the data on the specified resource can not be read correctly
	 * @return new instance of {@link CachingXmlDataStore}
	 * @throws net.sf.qualitycheck.exception.IllegalNullArgumentException
	 *             if one of the given arguments is {@code null}
	 * @throws net.sf.qualitycheck.exception.IllegalStateOfArgumentException
	 *             if the given cache file can not be read
	 * @throws net.sf.qualitycheck.exception.IllegalStateOfArgumentException
	 *             if no URL can be resolved to the given given file
	 */
	@Nonnull
	public static CachingXmlDataStore createCachingXmlDataStore(@Nonnull final File cacheFile, @Nonnull final DataStore fallback) {
		return createCachingXmlDataStore(cacheFile, UrlUtil.build(DEFAULT_DATA_URL), UrlUtil.build(DEFAULT_VERSION_URL), DEFAULT_CHARSET,
				fallback);
	}

	/**
	 * Constructs a new instance of {@code CachingXmlDataStore} with the given arguments. The given {@code cacheFile}
	 * can be empty or filled with previously cached data in XML format. The file must be writable otherwise an
	 * exception will be thrown.
	 * 
	 * @param cacheFile
	 *            file with cached <em>UAS data</em> in XML format or empty file
	 * @param dataUrl
	 *            URL to <em>UAS data</em>
	 * @param versionUrl
	 *            URL to version information about the given <em>UAS data</em>
	 * @param charset
	 *            the character set in which the data should be read
	 * @param fallback
	 *            <em>UAS data</em> as fallback in case the data on the specified resource can not be read correctly
	 * @return new instance of {@link CachingXmlDataStore}
	 * @throws net.sf.qualitycheck.exception.IllegalNullArgumentException
	 *             if one of the given arguments is {@code null}
	 * @throws net.sf.qualitycheck.exception.IllegalNullArgumentException
	 *             if the given cache file can not be read
	 * @throws net.sf.qualitycheck.exception.IllegalStateOfArgumentException
	 *             if no URL can be resolved to the given given file
	 */
	@Nonnull
	public static CachingXmlDataStore createCachingXmlDataStore(@Nonnull final File cacheFile, @Nonnull final URL dataUrl,
			@Nonnull final URL versionUrl, @Nonnull final Charset charset, @Nonnull final DataStore fallback) {
		Check.notNull(cacheFile, "cacheFile");
		Check.notNull(charset, "charset");
		Check.notNull(dataUrl, "dataUrl");
		Check.notNull(fallback, "fallback");
		Check.notNull(versionUrl, "versionUrl");

		final DataReader reader = new XmlDataReader();
		final DataStore fallbackDataStore = readCacheFileAsFallback(reader, cacheFile, charset, fallback);
		return new CachingXmlDataStore(reader, dataUrl, versionUrl, charset, cacheFile, fallbackDataStore);
	}

	/**
	 * Constructs a new instance of {@code CachingXmlDataStore} with the given arguments. The file used to cache the
	 * read in <em>UAS data</em> will be called from {@link CachingXmlDataStore#findOrCreateCacheFile()}. This file may
	 * be empty or filled with previously cached data in XML format. The file must be writable otherwise an exception
	 * will be thrown.
	 * 
	 * @param dataUrl
	 *            URL to <em>UAS data</em>
	 * @param versionUrl
	 *            URL to version information about the given <em>UAS data</em>
	 * @param charset
	 *            the character set in which the data should be read
	 * @param fallback
	 *            <em>UAS data</em> as fallback in case the data on the specified resource can not be read correctly
	 * @return new instance of {@link CachingXmlDataStore}
	 * @throws net.sf.qualitycheck.exception.IllegalNullArgumentException
	 *             if one of the given arguments is {@code null}
	 * @throws net.sf.qualitycheck.exception.IllegalStateOfArgumentException
	 *             if the given cache file can not be read
	 */
	@Nonnull
	public static CachingXmlDataStore createCachingXmlDataStore(@Nonnull final URL dataUrl, @Nonnull final URL versionUrl,
			@Nonnull final Charset charset, @Nonnull final DataStore fallback) {
		return createCachingXmlDataStore(findOrCreateCacheFile(), dataUrl, versionUrl, charset, fallback);
	}

	/**
	 * Removes the given cache file because it contains damaged content.
	 * 
	 * @param cacheFile
	 *            cache file to delete
	 */
	private static void deleteCacheFile(final File cacheFile) {
		try {
			if (cacheFile.delete()) {
				LOG.warn(String.format(MSG_CACHE_FILE_IS_DAMAGED_AND_DELETED, cacheFile.getPath()));
			} else {
				LOG.warn(String.format(MSG_CACHE_FILE_IS_DAMAGED, cacheFile.getPath()));
			}
		} catch (final Exception e) {
			LOG.warn(String.format(MSG_CACHE_FILE_IS_DAMAGED, cacheFile.getPath()));
		}
	}

	/**
	 * Gets the cache file for <em>UAS data</em> in the default temporary-file directory. If no cache file exists, a new
	 * empty file in the default temporary-file directory will be created, using the default prefix and suffix to
	 * generate its name.
	 * 
	 * @return file to cache read in <em>UAS data</em>
	 * @throws net.sf.qualitycheck.exception.IllegalStateOfArgumentException
	 *             if the cache file can not be created
	 */
	@Nonnull
	public static File findOrCreateCacheFile() {
		final File file = new File(CACHE_DIR, PREFIX + SUFFIX);
		if (!file.exists()) {
			try {
				file.createNewFile();
			} catch (final IOException e) {
				throw new IllegalStateOfArgumentException("Can not create a cache file.", e);
			}
		}
		return file;
	}

	/**
	 * Checks if the given file is empty.
	 * 
	 * @param file
	 *            the file that could be empty
	 * @return {@code true} when the file is accessible and empty otherwise {@code false}
	 * @throws net.sf.qualitycheck.exception.IllegalStateOfArgumentException
	 *             if an I/O error occurs
	 */
	private static boolean isEmpty(@Nonnull final File file, @Nonnull final Charset charset) {
		try {
			return FileUtil.isEmpty(file, charset);
		} catch (final IOException e) {
			throw new IllegalStateOfArgumentException("The given file could not be read.", e);
		}
	}

	/**
	 * Tries to read the content of specified cache file and returns them as fallback data store. If the cache file
	 * contains unexpected data the given fallback data store will be returned instead.
	 * 
	 * @param reader
	 *            data reader to read the given {@code dataUrl}
	 * @param cacheFile
	 *            file with cached <em>UAS data</em> in XML format or empty file
	 * @param versionUrl
	 *            URL to version information about the given <em>UAS data</em>
	 * @param charset
	 *            the character set in which the data should be read
	 * @param fallback
	 *            <em>UAS data</em> as fallback in case the data on the specified resource can not be read correctly
	 * @return a fallback data store
	 */
	private static DataStore readCacheFileAsFallback(@Nonnull final DataReader reader, @Nonnull final File cacheFile,
			@Nonnull final Charset charset, @Nonnull final DataStore fallback) {
		DataStore fallbackDataStore;
		if (!isEmpty(cacheFile, charset)) {
			final URL cacheFileUrl = UrlUtil.toUrl(cacheFile);
			try {
				fallbackDataStore = new CacheFileDataStore(reader.read(cacheFileUrl, charset), reader, cacheFileUrl, charset);
				LOG.debug(MSG_CACHE_FILE_IS_FILLED);
			} catch (final RuntimeException e) {
				fallbackDataStore = fallback;
				deleteCacheFile(cacheFile);
			}
		} else {
			fallbackDataStore = fallback;
			LOG.debug(MSG_CACHE_FILE_IS_EMPTY);
		}
		return fallbackDataStore;
	}

	/**
	 * Constructs an {@code CachingXmlDataStore} with the given arguments.
	 * 
	 * @param data
	 *            first <em>UAS data</em> which will be available in the store
	 * @param reader
	 *            data reader to read the given {@code dataUrl}
	 * @param dataUrl
	 *            URL to <em>UAS data</em>
	 * @param versionUrl
	 *            URL to version information about the given <em>UAS data</em>
	 * @param charset
	 *            the character set in which the data should be read
	 * @param cacheFile
	 *            file with cached <em>UAS data</em> in XML format or an empty file
	 * @throws net.sf.qualitycheck.exception.IllegalNullArgumentException
	 *             if one of the given arguments is {@code null}
	 */
	private CachingXmlDataStore(@Nonnull final DataReader reader, @Nonnull final URL dataUrl, @Nonnull final URL versionUrl,
			@Nonnull final Charset charset, @Nonnull final File cacheFile, @Nonnull final DataStore fallback) {
		super(reader, dataUrl, versionUrl, charset, fallback);
		setUpdateOperation(new UpdateOperationWithCacheFileTask(this, cacheFile));
	}

}