/* Copyright (c) 2001 - 2007 TOPP - www.openplans.org. All rights reserved.
* This code is licensed under the GPL 2.0 license, availible at the root
* application directory.
*/
package org.vfny.geoserver.wms.responses;
import java.io.IOException;
import java.io.OutputStream;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.geoserver.config.GeoServer;
import org.geoserver.config.ServiceInfo;
import org.geoserver.data.util.CoverageUtils;
import org.geoserver.platform.ServiceException;
import org.geoserver.wms.MapLayerInfo;
import org.geoserver.wms.WMSExtensions;
import org.geotools.coverage.grid.io.AbstractGridCoverage2DReader;
import org.geotools.data.DefaultQuery;
import org.geotools.data.FeatureSource;
import org.geotools.data.Query;
import org.geotools.data.QueryCapabilities;
import org.geotools.factory.CommonFactoryFinder;
import org.geotools.map.DefaultMapLayer;
import org.geotools.map.FeatureSourceMapLayer;
import org.geotools.map.MapLayer;
import org.geotools.referencing.crs.DefaultGeographicCRS;
import org.geotools.resources.coverage.FeatureUtilities;
import org.geotools.styling.FeatureTypeConstraint;
import org.geotools.styling.Style;
import org.opengis.feature.Feature;
import org.opengis.feature.simple.SimpleFeature;
import org.opengis.feature.simple.SimpleFeatureType;
import org.opengis.feature.type.FeatureType;
import org.opengis.filter.Filter;
import org.opengis.filter.FilterFactory;
import org.opengis.parameter.ParameterNotFoundException;
import org.opengis.parameter.ParameterValue;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.vfny.geoserver.Request;
import org.vfny.geoserver.Response;
import org.vfny.geoserver.wms.GetMapProducer;
import org.vfny.geoserver.wms.RasterMapProducer;
import org.vfny.geoserver.wms.WMSMapContext;
import org.vfny.geoserver.wms.WmsException;
import org.vfny.geoserver.wms.requests.GetMapRequest;
import org.vfny.geoserver.wms.responses.map.metatile.MetatileMapProducer;
import com.vividsolutions.jts.geom.Envelope;
/**
* A GetMapResponse object is responsible of generating a map based on a GetMap
* request. The way the map is generated is independent of this class, wich will
* use a delegate object based on the output format requested
*
* @author Gabriel Roldan, Axios Engineering
* @author Simone Giannecchini - GeoSolutions SAS
* @version $Id$
*/
public class GetMapResponse implements Response {
/** DOCUMENT ME! */
static final Logger LOGGER = org.geotools.util.logging.Logging
.getLogger(GetMapResponse.class.getPackage().getName());
private static FilterFactory filterFac = CommonFactoryFinder.getFilterFactory(null);
/**
* The map producer that will be used for the production of a map in the
* requested format.
*/
private GetMapProducer delegate;
/**
* The map context
*/
private WMSMapContext map;
/**
* custom response headers
*/
private HashMap responseHeaders;
String headerContentDisposition;
private Collection<GetMapProducer> availableProducers;
/**
* Creates a new GetMapResponse object.
*
* @param availableProducers
* the list of available map producers where to get one to handle the request
* format at {@link #execute(Request)}
*/
public GetMapResponse(Collection<GetMapProducer> availableProducers) {
if(availableProducers == null){
throw new NullPointerException("availableProducers");
}
if(availableProducers.size() == 0){
throw new IllegalArgumentException("No available map producers provided");
}
this.availableProducers = new ArrayList<GetMapProducer>(availableProducers);
responseHeaders = null;
}
/**
* Returns any extra headers that this service might want to set in the HTTP
* response object.
*
*/
public HashMap getResponseHeaders() {
return responseHeaders == null? null : new HashMap(responseHeaders);
}
/**
* Implements the map production logic for a WMS GetMap request, delegating
* the encoding to the appropriate output format to a {@link GetMapProducer}
* appropriate for the required format.
*
* <p>
* Preconditions:
* <ul>
* <li>request.getLayers().length > 0
* <li>request.getStyles().length == request.getLayers().length
* </ul>
* </p>
*
* @param req
* a {@link GetMapRequest}
*
* @throws ServiceException
* if an error occurs creating the map from the provided request
*
* TODO: This method have become a 300+ lines monster, refactore it to
* private methods from which names one can inferr what's going on... but
* get a decent test coverage on it first as to avoid regressions as much as
* possible
*/
@SuppressWarnings("unchecked")
public void execute(Request req) throws ServiceException {
final GetMapRequest request = (GetMapRequest) req;
assertMandatory(request);
final String outputFormat = request.getFormat();
this.delegate = getDelegate(outputFormat);
// JD:make instance variable in order to release resources later
// final WMSMapContext map = new WMSMapContext();
map = new WMSMapContext(request);
this.delegate.setMapContext(map);
final Envelope env = request.getBbox();
// enable on the fly meta tiling if request looks like a tiled one
if (MetatileMapProducer.isRequestTiled(request, delegate)) {
if (LOGGER.isLoggable(Level.FINER)) {
LOGGER.finer("Tiled request detected, activating on the fly meta tiler");
}
this.delegate = new MetatileMapProducer(request, (RasterMapProducer) delegate);
this.delegate.setMapContext(map);
}
final MapLayerInfo[] layers = request.getLayers();
final Style[] styles = request.getStyles().toArray(new Style[] {});
final Filter[] filters = buildLayersFilters(request.getFilter(), layers);
// DJB DONE: replace by setAreaOfInterest(Envelope,
// CoordinateReferenceSystem)
// with the user supplied SRS parameter
// if there's a crs in the request, use that. If not, assume its 4326
final CoordinateReferenceSystem mapcrs = request.getCrs();
// DJB: added this to be nicer about the "NONE" srs.
if (mapcrs != null) {
map.setAreaOfInterest(env, mapcrs);
} else {
map.setAreaOfInterest(env, DefaultGeographicCRS.WGS84);
}
map.setMapWidth(request.getWidth());
map.setMapHeight(request.getHeight());
map.setBgColor(request.getBgColor());
map.setTransparent(request.isTransparent());
map.setBuffer(request.getBuffer());
map.setPaletteInverter(request.getPalette());
// //
//
// Check to see if we really have something to display. Sometimes width
// or height or both are non positivie or the requested area is null.
//
// ///
if ((request.getWidth() <= 0) || (request.getHeight() <= 0)
|| (map.getAreaOfInterest().getLength(0) <= 0)
|| (map.getAreaOfInterest().getLength(1) <= 0)) {
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER
.fine("We are not going to render anything because either the area is null or the dimensions are not positive.");
}
return;
}
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER.fine("setting up map");
}
try { // mapcontext can leak memory -- we make sure we done (see
// finally block)
// track the external caching strategy for any map layers
boolean cachingPossible = "GET".equals(request.getHttpServletRequest().getMethod());
final String featureVersion = request.getFeatureVersion();
int maxAge = Integer.MAX_VALUE;
for (int i = 0; i < layers.length; i++) {
final Style layerStyle = styles[i];
final Filter layerFilter = filters[i];
final MapLayer layer;
if (layers[i].getType() == MapLayerInfo.TYPE_REMOTE_VECTOR) {
cachingPossible = false;
final FeatureSource<SimpleFeatureType, SimpleFeature> source = layers[i]
.getRemoteFeatureSource();
layer = new DefaultMapLayer(source, layerStyle);
layer.setTitle(layers[i].getRemoteFeatureSource().getSchema().getTypeName());
final DefaultQuery definitionQuery = new DefaultQuery(source.getSchema()
.getTypeName());
definitionQuery.setFilter(layerFilter);
definitionQuery.setVersion(featureVersion);
int maxFeatures = request.getMaxFeatures() != null ? request.getMaxFeatures()
: Integer.MAX_VALUE;
definitionQuery.setMaxFeatures(maxFeatures);
layer.setQuery(definitionQuery);
map.addLayer(layer);
} else if (layers[i].getType() == MapLayerInfo.TYPE_VECTOR) {
if (cachingPossible) {
if (layers[i].isCachingEnabled()) {
int nma = layers[i].getCacheMaxAge();
// suppose the map contains multiple cachable
// layers...we can only cache the combined map for
// the
// time specified by the shortest-cached layer.
if (nma < maxAge) {
maxAge = nma;
}
} else {
// if one layer isn't cachable, then we can't cache
// any of them. Disable caching.
cachingPossible = false;
}
}
FeatureSource<? extends FeatureType, ? extends Feature> source;
// /////////////////////////////////////////////////////////
//
// Adding a feature layer
//
// /////////////////////////////////////////////////////////
try {
source = layers[i].getFeatureSource(true);
// NOTE for the feature. Here there was some code that
// sounded like:
// * get the bounding box from feature source
// * eventually reproject it to the actual CRS used for
// map
// * if no intersection, don't bother adding the feature
// source to the map
// This is not an optimization, on the contrary,
// computing the bbox may be
// very expensive depending on the data size. Using
// sigma.openplans.org data
// and a tiled client like OpenLayers, it dragged the
// server to his knees
// and the client simply timed out
} catch (IOException exp) {
if (LOGGER.isLoggable(Level.SEVERE)) {
LOGGER.log(Level.SEVERE, new StringBuffer("Getting feature source: ")
.append(exp.getMessage()).toString(), exp);
}
throw new WmsException("Internal error", "", exp);
}
layer = new FeatureSourceMapLayer(source, layerStyle);
layer.setTitle(layers[i].getFeature().getName());
final DefaultQuery definitionQuery = new DefaultQuery(source.getSchema()
.getName().getLocalPart());
definitionQuery.setVersion(featureVersion);
definitionQuery.setFilter(layerFilter);
// check for startIndex + offset
final Integer startIndex = request.getStartIndex();
if (startIndex != null) {
QueryCapabilities queryCapabilities = source.getQueryCapabilities();
if (queryCapabilities.isOffsetSupported()) {
// fsource is required to support
// SortBy.NATURAL_ORDER so we don't bother checking
definitionQuery.setStartIndex(startIndex);
} else {
// source = new PagingFeatureSource(source,
// request.getStartIndex(), limit);
throw new WmsException("startIndex is not supported for the "
+ layers[i].getName() + " layer");
}
}
int maxFeatures = request.getMaxFeatures() != null ? request.getMaxFeatures()
: Integer.MAX_VALUE;
definitionQuery.setMaxFeatures(maxFeatures);
layer.setQuery(definitionQuery);
map.addLayer(layer);
} else if (layers[i].getType() == MapLayerInfo.TYPE_RASTER) {
// /////////////////////////////////////////////////////////
//
// Adding a coverage layer
//
// /////////////////////////////////////////////////////////
AbstractGridCoverage2DReader reader;
reader = (AbstractGridCoverage2DReader) layers[i].getCoverageReader();
if (reader != null) {
// /////////////////////////////////////////////////////////
//
// Setting coverage reading params.
//
// /////////////////////////////////////////////////////////
/*
* Test if the parameter "TIME" is present in the WMS
* request, and by the way in the reading parameters. If
* it is the case, one can adds it to the request. If an
* exception is thrown, we have nothing to do.
*/
try {
ParameterValue time = reader.getFormat().getReadParameters().parameter(
"TIME");
if (time != null && request.getTime() != null) {
time.setValue(request.getTime());
}
} catch (ParameterNotFoundException p) {
}
// uncomment when the DIM_RANGE vendor parameter will be
// enabled
// try {
// ParameterValue dimRange =
// reader.getFormat().getReadParameters()
// .parameter("DIM_RANGE");
// if (dimRange != null && request.getDimRange() !=
// null) {
// dimRange.setValue(request.getDimRange());
// }
// } catch (ParameterNotFoundException p) {
// }
try {
ParameterValue elevation = reader.getFormat().getReadParameters()
.parameter("ELEVATION");
if (elevation != null && request.getElevation() != null) {
elevation.setValue(request.getElevation().intValue());
}
} catch (ParameterNotFoundException p) {
//ignore?
}
try {
final ParameterValueGroup params = reader.getFormat()
.getReadParameters();
layer = new DefaultMapLayer(FeatureUtilities.wrapGridCoverageReader(
reader, CoverageUtils.getParameters(params, layers[i]
.getCoverage().getParameters())), layerStyle);
layer.setTitle(layers[i].getCoverage().getName());
layer.setQuery(Query.ALL);
map.addLayer(layer);
} catch (IllegalArgumentException e) {
if (LOGGER.isLoggable(Level.SEVERE)) {
LOGGER.log(Level.SEVERE, new StringBuffer(
"Wrapping GC in feature source: ").append(
e.getLocalizedMessage()).toString(), e);
}
throw new WmsException(
null,
new StringBuffer(
"Internal error : unable to get reader for this coverage layer ")
.append(layers[i].toString()).toString());
}
} else {
throw new WmsException(null, new StringBuffer(
"Internal error : unable to get reader for this coverage layer ")
.append(layers[i].toString()).toString());
}
}
}
// /////////////////////////////////////////////////////////
//
// Producing the map in the requested format.
//
// /////////////////////////////////////////////////////////
this.delegate.produceMap();
if (cachingPossible) {
if(responseHeaders == null){
responseHeaders = new HashMap();
}
responseHeaders.put("Cache-Control", "max-age=" + maxAge + ", must-revalidate");
Date expires = new Date();
expires.setTime(expires.getTime() + maxAge * 1000);
DateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
format.setTimeZone(TimeZone.getTimeZone("GMT"));
responseHeaders.put("Expires", format.format(expires));
}
final String contentDisposition = this.delegate.getContentDisposition();
if (contentDisposition != null) {
this.headerContentDisposition = contentDisposition;
}
} catch (Exception e) {
clearMapContext();
throw new WmsException(e, "Internal error ", "");
}
}
/**
* Asserts the mandatory GetMap parameters have been provided.
* <p>
* With the exception of the SRS and STYLES parameters, for which default values are assigned.
* </p>
*
* @param request
* @throws ServiceException
* if any mandatory parameter has not been set on the request
*/
private void assertMandatory(GetMapRequest request) throws ServiceException {
if (0 >= request.getWidth() || 0 >= request.getHeight()) {
throw new ServiceException("Missing or invalid requested map size. Parameters"
+ " WIDTH and HEIGHT shall be present and be integers > 0. Got " + "WIDTH="
+ request.getWidth() + ", HEIGHT=" + request.getHeight(),
"MissingOrInvalidParameter");
}
if (request.getLayers().length == 0) {
throw new ServiceException("No layers have been requested", "LayerNotDefined");
}
if (request.getStyles().size() == 0) {
throw new ServiceException("No styles have been requested", "StyleNotDefined");
}
if (request.getFormat() == null) {
throw new ServiceException("No output map format requested", "InvalidFormat");
}
// DJB: the WMS spec says that the request must not be 0 area
// if it is, throw a service exception!
final Envelope env = request.getBbox();
if (env == null) {
throw new WmsException("GetMap requests must include a BBOX parameter.", "MissingBBox");
}
if (env.isNull() || (env.getWidth() <= 0) || (env.getHeight() <= 0)) {
throw new WmsException(new StringBuffer("The request bounding box has zero area: ")
.append(env).toString(), "InvalidBBox");
}
}
/**
* Returns the list of filters resulting of comining the layers definition
* filters with the per layer filters made by the user.
* <p>
* If <code>requestFilters != null</code>, it shall contain the same
* number of elements than <code>layers</code>, as filters are requested
* one per layer.
* </p>
*
* @param requestFilters
* the list of filters sent by the user, or <code>null</code>
* @param layers
* the layers requested in the GetMap request, where to get the
* per layer definition filters from.
* @return a list of filters, one per layer, resulting of anding the user
* requested filter and the layer definition filter
*/
private Filter[] buildLayersFilters(List<Filter> requestFilters, MapLayerInfo[] layers) {
final int nLayers = layers.length;
if (requestFilters == null || requestFilters.size() == 0) {
requestFilters = Collections.nCopies(layers.length, (Filter) Filter.INCLUDE);
} else if (requestFilters.size() != nLayers) {
throw new IllegalArgumentException(
"requested filters and number of layers do not match");
}
Filter[] combinedList = new Filter[nLayers];
Filter layerDefinitionFilter;
Filter userRequestedFilter;
Filter combined;
MapLayerInfo layer;
for (int i = 0; i < nLayers; i++) {
layer = layers[i];
userRequestedFilter = requestFilters.get(i);
if (layer.getType() == MapLayerInfo.TYPE_REMOTE_VECTOR) {
combinedList[i] = userRequestedFilter;
} else if (layer.getType() != MapLayerInfo.TYPE_RASTER) {
layerDefinitionFilter = layer.getFeature().getFilter();
// heck, how I wish we use the null objects more
if (layerDefinitionFilter == null) {
layerDefinitionFilter = Filter.INCLUDE;
}
combined = filterFac.and(layerDefinitionFilter, userRequestedFilter);
FeatureTypeConstraint[] featureTypeConstraints = layer.getLayerFeatureConstraints();
if (featureTypeConstraints != null) {
List<Filter> filters = new ArrayList<Filter>();
for (int j=0;j<featureTypeConstraints.length; j++) {
FeatureTypeConstraint featureTypeConstraint = featureTypeConstraints[j];
filters.add(featureTypeConstraint.getFilter());
};
combined = filterFac.and(combined, filterFac.and(filters));
}
combinedList[i] = combined;
}
}
return combinedList;
}
/**
* asks the internal GetMapDelegate for the MIME type of the map that it
* will generate or is ready to, and returns it
*
* @param gs
* DOCUMENT ME!
*
* @return the MIME type of the map generated or ready to generate
*
* @throws IllegalStateException
* if a GetMapDelegate is not setted yet
* @see Response#getContentType(GeoServer)
*/
public String getContentType(GeoServer gs) throws IllegalStateException {
if (this.delegate == null) {
throw new IllegalStateException("No request has been processed");
}
return this.delegate.getContentType();
}
/**
* DOCUMENT ME!
*
* @return DOCUMENT ME!
*/
public String getContentEncoding() {
if (LOGGER.isLoggable(Level.FINER)) {
LOGGER.finer("returning content encoding null");
}
return null;
}
/**
* if a GetMapDelegate is set, calls it's abort method. Elsewere do nothing.
*
* @see Response#abort(ServiceInfo)
*/
public void abort(ServiceInfo gs) {
if (this.delegate != null) {
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER.fine("asking delegate for aborting the process");
}
this.delegate.abort();
}
}
/**
* delegates the writing and encoding of the results of the request to the
* <code>GetMapDelegate</code> wich is actually processing it, and has
* been obtained when <code>execute(Request)</code> was called
*
* @param out
* the output to where the map must be written
*
* @throws ServiceException
* if the delegate throws a ServiceException inside its
* <code>writeTo(OuptutStream)</code>, mostly due to
* @throws IOException
* if the delegate throws an IOException inside its
* <code>writeTo(OuptutStream)</code>, mostly due to
* @throws IllegalStateException
* if this method is called before <code>execute(Request)</code>
* has succeed
*/
public void writeTo(OutputStream out) throws ServiceException, IOException {
try { // mapcontext can leak memory -- we make sure we done (see
// finally block)
if (this.delegate == null) {
throw new IllegalStateException(
"No GetMapDelegate is setted, make sure you have called execute and it has succeed");
}
if (LOGGER.isLoggable(Level.FINER)) {
LOGGER.finer(new StringBuffer("asking delegate for write to ").append(out)
.toString());
}
this.delegate.writeTo(out);
} finally {
clearMapContext();
}
}
/**
* Clearing the map context is paramount, otherwise we end up with a memory
* leak
*/
void clearMapContext() {
try {
if (map != null && map.getLayerCount() > 0)
map.clearLayerList();
} catch (Exception e) // we dont want to propogate a new error
{
if (LOGGER.isLoggable(Level.SEVERE)) {
LOGGER.log(Level.SEVERE, new StringBuffer("Getting feature source: ").append(
e.getMessage()).toString(), e);
}
}
}
/**
* Finds out a {@link GetMapProducer} specialized in generating the
* requested map format, registered in the spring context.
*
* @param outputFormat
* a request parameter object wich holds the processed request
* objects, such as layers, bbox, outpu format, etc.
*
* @return A specialization of <code>GetMapDelegate</code> wich can
* produce the requested output map format
*
* @throws WmsException
* if no specialization is configured for the output format
* specified in <code>request</code> or if it can't be
* instantiated
*/
private GetMapProducer getDelegate(final String outputFormat) throws WmsException {
final GetMapProducer producer = WMSExtensions.findMapProducer(outputFormat,
availableProducers);
if (producer == null) {
WmsException e = new WmsException("There is no support for creating maps in "
+ outputFormat + " format", "InvalidFormat");
e.setCode("InvalidFormat");
throw e;
}
// Tell the producer which of its output format names the
// request
// was made for, the producer may need it for its logic.
// For example, result is different if requested image/png8
// instead of image/png
// I'm not so glad with this, but found a lot of producers were
// made that way
producer.setOutputFormat(outputFormat);
return producer;
}
public String getContentDisposition() {
return headerContentDisposition;
}
@Override
protected void finalize() throws Throwable {
clearMapContext();
}
/**
* This is package visible only to allow getting to the delegate from inside
* unit tests
*
* @return
*/
GetMapProducer getDelegate() {
return delegate;
}
}