/*
* Copyright (c) 2014 Oculus Info Inc.
* http://www.oculusinfo.com/
*
* Released under the MIT License.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.oculusinfo.tile.rest.layer;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import com.oculusinfo.binning.io.PyramidIO;
import com.oculusinfo.binning.io.PyramidIOFactory;
import com.oculusinfo.binning.metadata.PyramidMetaData;
import com.oculusinfo.binning.util.JsonUtilities;
import com.oculusinfo.factory.ConfigurableFactory;
import com.oculusinfo.factory.ConfigurationException;
import com.oculusinfo.factory.providers.FactoryProvider;
import com.oculusinfo.tile.init.providers.CachingLayerConfigurationProvider;
import com.oculusinfo.tile.rendering.LayerConfiguration;
import com.oculusinfo.tile.rest.config.ConfigException;
import com.oculusinfo.tile.rest.config.ConfigService;
import com.oculusinfo.tile.rest.tile.caching.CachingPyramidIO.LayerDataChangedListener;
import org.apache.commons.io.filefilter.WildcardFileFilter;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.net.URI;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
@Singleton
public class LayerServiceImpl implements LayerService {
private static final Logger LOGGER = LoggerFactory.getLogger(LayerServiceImpl.class);
private List< JSONObject > _layers;
private Map< String, JSONObject > _layersById;
private Map< String, JSONObject > _layersBySha;
private Map< String, JSONObject > _metaDataCache;
private FactoryProvider< LayerConfiguration > _layerConfigurationProvider;
private final ConfigService _configService;
@Inject
public LayerServiceImpl( @Named("com.oculusinfo.tile.layer.config") String layerConfigurationLocation,
FactoryProvider<LayerConfiguration> layerConfigProvider,
ConfigService configService) {
_layers = new ArrayList<>();
_layersById = new HashMap<>();
_layersBySha = new HashMap<>();
_metaDataCache = new HashMap<>();
_layerConfigurationProvider = layerConfigProvider;
_configService = configService;
if (layerConfigProvider instanceof CachingLayerConfigurationProvider) {
CachingLayerConfigurationProvider caching = (CachingLayerConfigurationProvider)layerConfigProvider;
caching.addLayerListener( new LayerDataChangedListener() {
public void onLayerDataChanged( String layerId ) {
_metaDataCache.remove( layerId );
}
} );
}
readConfigFiles( getConfigurationFiles( layerConfigurationLocation ) );
}
@Override
public List< JSONObject > getLayerJSONs() {
return _layers;
}
@Override
public JSONObject getLayerJSON( String layerId ) {
return _layersById.get( layerId );
}
@Override
public List< String > getLayerIds() {
List< String > layers = new ArrayList<>();
try {
for ( JSONObject layerConfig : _layers ) {
layers.add( layerConfig.getString( LayerConfiguration.LAYER_ID.getName() ) );
}
} catch ( Exception e ) {
e.printStackTrace();
}
return layers;
}
@Override
public PyramidMetaData getMetaData( String layerId ) {
try {
LayerConfiguration config = getLayerConfiguration( layerId, null );
String dataId = config.getPropertyValue(LayerConfiguration.DATA_ID);
if ( dataId == null ) {
LOGGER.error( "Couldn't determine data id for layer: "+layerId+" , please ensure the layer config file is correct." );
return null;
}
PyramidIO pyramidIO = config.produce( PyramidIO.class );
if ( pyramidIO == null ) {
LOGGER.error( "Couldn't produce the pyramid io instance for layer: "+layerId+" , this is most likely due to either:\n" +
"\t1) Missing or incorrectly configured 'data' node. Please confirm 'data.id' and 'pyramidio.*' are correct.\n" +
"\t2) The layer files are unavailable. Please confirm that the database is available, or the files are in the correct directory Default='res://'." );
return null;
}
return getCachedMetaData( layerId, dataId, pyramidIO );
} catch (ConfigurationException e) {
LOGGER.error( "Couldn't determine pyramid I/O method for {}", layerId, e );
return null;
}
}
private PyramidMetaData getCachedMetaData( String layerId, String dataId, PyramidIO pyramidIO ) {
try {
JSONObject metadata = _metaDataCache.get( layerId );
if ( metadata == null ) {
String s = pyramidIO.readMetaData( dataId );
if ( s == null ) {
metadata = new JSONObject();
} else {
metadata = new JSONObject( s );
}
_metaDataCache.put( layerId, metadata );
}
return new PyramidMetaData( metadata );
} catch (JSONException e) {
LOGGER.error("Metadata file for layer is missing or corrupt: {}", layerId, e);
} catch (IOException e) {
LOGGER.error("Couldn't read metadata: {}", layerId, e);
}
return null;
}
/**
* Wraps the options and query {@link JSONObject}s together into a new object.
*/
private JSONObject mergeQueryConfigOptions(JSONObject options, JSONObject query) {
JSONObject result = JsonUtilities.deepClone( options );
try {
// all client configurable properties exist under an unseen 'public' node,
// create this node before overlay query parameters onto server config
if ( query != null ) {
JSONObject publicNode = new JSONObject();
publicNode.put( "public", query );
result = JsonUtilities.overlayInPlace( result, publicNode );
}
} catch (Exception e) {
LOGGER.error("Couldn't merge query options with main options.", e);
}
return result;
}
@Override
public LayerConfiguration getLayerConfiguration( String layerId, JSONObject requestParams ) {
try {
// first check if the query parameters contains a SHA-256 hash. If so
// load the configured JSONObject. Otherwise take the server default.
JSONObject layerConfig;
if ( requestParams != null && requestParams.has("state") ) {
layerConfig = _layersBySha.get( requestParams.getString("state") );
} else {
layerConfig = _layersById.get( layerId );
}
// create layer configuration factory
ConfigurableFactory<? extends LayerConfiguration> factory = _layerConfigurationProvider.createFactory( null, new ArrayList<String>() );
// override the server configuration with supplied query parameters, this simply overlays
// the query parameter JSON over the server default JSON, then sets the factory upp
// to build our layer configuration object.
factory.readConfiguration( mergeQueryConfigOptions( layerConfig, requestParams ) );
// produce the layer configuration
LayerConfiguration config = factory.produce( LayerConfiguration.class );
JSONObject initJSON = config.getProducer( PyramidIO.class ).getPropertyValue( PyramidIOFactory.INITIALIZATION_DATA );
if ( initJSON != null ) {
String dataId = config.getPropertyValue(LayerConfiguration.DATA_ID);
int width = config.getPropertyValue(LayerConfiguration.OUTPUT_WIDTH);
int height = config.getPropertyValue(LayerConfiguration.OUTPUT_HEIGHT);
Properties initProps = JsonUtilities.jsonObjToProperties(initJSON);
// initialize the PyramidIO for reading
PyramidIO pyramidIO = config.produce( PyramidIO.class );
pyramidIO.initializeForRead( dataId, width, height, initProps);
}
return config;
} catch ( Exception e ) {
LOGGER.warn("Error configuring rendering for", e);
return null;
}
}
@Override
public String saveLayerState( String layerId, JSONObject overrideConfiguration ) throws Exception {
try {
// use the layer config to produce the string rather than the config json itself,
// this ensures that ALL configurable properties are used in sha generation, rather
// than those only specified in the JSON
LayerConfiguration config = getLayerConfiguration( layerId, overrideConfiguration );
// get SHA-256 hash of state
String shaHex = config.generateSHA256();
// store the config under the SHA-256
_layersBySha.put( shaHex, mergeQueryConfigOptions( _layersById.get( layerId ), overrideConfiguration ) );
return shaHex;
} catch ( Exception e ) {
LOGGER.warn("Error registering configuration to SHA");
throw e;
}
}
@Override
public JSONObject getLayerStates( String layerId ) {
JSONObject states = new JSONObject();
try {
// add default
states.put( "default", getLayerConfiguration( layerId, null )
.getExplicitConfiguration()
.getJSONObject("public") ); // only return public node
// add saved
for ( Map.Entry<String, JSONObject> entry : _layersBySha.entrySet() ) {
String key = entry.getKey();
JSONObject value = entry.getValue();
states.put( key, getLayerConfiguration( layerId, value.getJSONObject("public") )
.getExplicitConfiguration()
.getJSONObject("public") ); // only return public node
}
} catch ( Exception e ) {
e.printStackTrace();
}
return states;
}
@Override
public JSONObject getLayerState( String layerId, String stateId ) {
try {
JSONObject layer = _layersBySha.get( stateId );
if ( layer == null ) {
return null;
}
return getLayerConfiguration( layerId, layer.getJSONObject("public") )
.getExplicitConfiguration()
.getJSONObject("public"); // only return public node
} catch ( Exception e ) {
e.printStackTrace();
}
return null;
}
private File[] getConfigurationFiles (String location) {
try {
// Find our configuration file.
URI path;
if (location.startsWith("res://")) {
location = location.substring(6);
path = LayerServiceImpl.class.getResource(location).toURI();
} else {
path = new File(location).toURI();
}
File configRoot = new File(path);
if (!configRoot.exists())
throw new Exception(location+" doesn't exist");
if (configRoot.isDirectory()) {
return configRoot.listFiles();
} else {
return new File[] {configRoot};
}
} catch (Exception e) {
LOGGER.warn("Can't find configuration file {}", location, e);
return new File[0];
}
}
private void readConfigFiles( File[] files ) {
for (File file: files) {
try {
String fileContent = _configService.replaceProperties(file);
JSONArray contents = new JSONArray( new JSONTokener(fileContent) );
for ( int i=0; i<contents.length(); i++ ) {
if( contents.get(i) instanceof JSONObject ) {
JSONObject layerJSON = contents.getJSONObject(i);
layerJSON = addLayerContext(layerJSON);
_layersById.put( layerJSON.getString( LayerConfiguration.LAYER_ID.getName() ), layerJSON );
_layers.add( layerJSON );
}
}
} catch (JSONException e) {
LOGGER.error("Layer configuration file {} was not valid JSON.", file, e);
} catch (ConfigException e) {
LOGGER.error("Unable to replace properties in layer file {}.", file, e);
}
}
}
protected JSONObject addLayerContext(JSONObject layer) throws JSONException {
JSONObject publicObj = layer.optJSONObject("public");
if (publicObj != null) {
switch (publicObj.optString("domain")) {
case "kml":
layer = addKMLLayerContext(layer);
break;
}
}
return layer;
}
protected JSONObject addKMLLayerContext(JSONObject kmlLayer) {
try {
JSONObject publicObj = kmlLayer.optJSONObject("public");
JSONArray kmlSets = publicObj.getJSONArray("kml");
long minTime = Long.MAX_VALUE;
long maxTime = Long.MIN_VALUE;
for (int i = 0; i < kmlSets.length(); i++) {
JSONObject kmlSet = kmlSets.getJSONObject(i);
String resourceDir = kmlSet.optString("dir", null);
if (resourceDir != null) {
// Scan this directory for kml files
File dir = new File(getClass().getClassLoader().getResource(resourceDir).getFile());
FileFilter filter = new WildcardFileFilter(kmlSet.getString("fileTemplate"));
File[] kmlFiles = dir.listFiles(filter);
JSONArray files = new JSONArray();
for (int j = 0; j < kmlFiles.length; j++) {
File kmlFile = kmlFiles[j];
// Extract month and year from file name. For now always expect file to be named
// MM-yyyy* Force timezon to avoid offsetting to previous month
String dateText = kmlFile.getName().substring(0,7);
DateFormat format = new SimpleDateFormat("dd-MM-yyyy", Locale.CANADA);
format.setTimeZone(TimeZone.getTimeZone("Canada/Eastern"));
long dateTime = format.parse("01-" + dateText).getTime();
JSONObject urlObject = new JSONObject();
urlObject.put("fileName", kmlFile.getName());
urlObject.put("date", dateTime);
files.put(urlObject);
if (dateTime < minTime) {
minTime = dateTime;
}
if (dateTime > maxTime) {
maxTime = dateTime;
}
}
// Set URL to JSONArray of URL date pairs
kmlSet.put("files", files);
}
}
// Update the layer meta information
JSONObject metaL1 = publicObj.optJSONObject("meta");
if (metaL1 == null) {
metaL1 = new JSONObject();
publicObj.put("meta", metaL1);
}
JSONObject metaL2 = metaL1.optJSONObject("meta");
if (metaL2 == null) {
metaL2 = new JSONObject();
metaL1.put("meta", metaL2);
}
metaL2.put("rangeMax", maxTime);
metaL2.put("rangeMin", minTime);
} catch (Exception e) {
LOGGER.error("There was a problem reading kml files", e);
}
return kmlLayer;
}
}