/* (c) 2017 Open Source Geospatial Foundation - all rights reserved
* This code is licensed under the GPL 2.0 license, available at the root
* application directory.
*/
package org.geoserver.rest.catalog;
import java.io.IOException;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.geoserver.catalog.AttributeTypeInfo;
import org.geoserver.catalog.Catalog;
import org.geoserver.catalog.CatalogBuilder;
import org.geoserver.catalog.CoverageInfo;
import org.geoserver.catalog.CoverageStoreInfo;
import org.geoserver.catalog.WorkspaceInfo;
import org.geoserver.catalog.impl.AttributeTypeInfoImpl;
import org.geoserver.config.util.XStreamPersister;
import org.geoserver.feature.RetypingFeatureCollection;
import org.geoserver.rest.ResourceNotFoundException;
import org.geoserver.rest.RestBaseController;
import org.geoserver.rest.RestException;
import org.geoserver.rest.converters.XStreamMessageConverter;
import org.geoserver.rest.util.MediaTypeExtensions;
import org.geoserver.rest.wrapper.RestWrapper;
import org.geotools.coverage.grid.io.GranuleSource;
import org.geotools.coverage.grid.io.GranuleStore;
import org.geotools.coverage.grid.io.StructuredGridCoverage2DReader;
import org.geotools.data.Query;
import org.geotools.data.simple.SimpleFeatureCollection;
import org.geotools.factory.CommonFactoryFinder;
import org.geotools.feature.FeatureTypes;
import org.geotools.filter.text.cql2.CQLException;
import org.geotools.filter.text.ecql.ECQL;
import org.geotools.util.logging.Logging;
import org.opengis.feature.simple.SimpleFeatureType;
import org.opengis.feature.type.AttributeDescriptor;
import org.opengis.filter.Filter;
import org.opengis.filter.FilterFactory2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.converters.MarshallingContext;
import com.thoughtworks.xstream.converters.reflection.ReflectionConverter;
import com.thoughtworks.xstream.io.HierarchicalStreamWriter;
/**
* Structured coverage controller, allows to visit and query granules
*/
@RestController
@ControllerAdvice
@RequestMapping(path = RestBaseController.ROOT_PATH
+ "/workspaces/{workspaceName}/coveragestores/{storeName}/coverages/{coverageName}/index")
public class StructuredCoverageController extends AbstractCatalogController {
private static final Logger LOGGER = Logging.getLogger(StructuredCoverageController.class);
static final FilterFactory2 FF = CommonFactoryFinder.getFilterFactory2();
/**
* Just holds a list of attributes
*
* @author Andrea Aime - GeoSolutions
*/
static class IndexSchema {
List<AttributeTypeInfo> attributes;
public IndexSchema(List<AttributeTypeInfo> attributes) {
this.attributes = attributes;
}
}
@Autowired
public StructuredCoverageController(@Qualifier("catalog") Catalog catalog) {
super(catalog);
}
@GetMapping(produces = {
MediaType.APPLICATION_XML_VALUE,
MediaType.APPLICATION_JSON_VALUE,
MediaTypeExtensions.TEXT_JSON_VALUE})
public RestWrapper<IndexSchema> indexGet(
@PathVariable String workspaceName,
@PathVariable String storeName,
@PathVariable String coverageName) throws IOException {
GranuleSource source = getGranuleSource(workspaceName, storeName, coverageName);
SimpleFeatureType schema = source.getSchema();
List<AttributeTypeInfo> attributes = new CatalogBuilder(catalog).getAttributes(schema,
null);
IndexSchema indexSchema = new IndexSchema(attributes);
return wrapObject(indexSchema, IndexSchema.class);
}
@GetMapping(path = "/granules", produces = {
MediaType.APPLICATION_XML_VALUE,
MediaType.TEXT_XML_VALUE,
MediaType.APPLICATION_JSON_VALUE,
MediaTypeExtensions.TEXT_JSON_VALUE})
@ResponseBody
public SimpleFeatureCollection granulesGet(
@PathVariable String workspaceName,
@PathVariable String storeName,
@PathVariable String coverageName,
@RequestParam(name = "filter", required = false) String filter,
@RequestParam(name = "offset", required = false) Integer offset,
@RequestParam(name = "limit", required = false) Integer limit) throws IOException {
GranuleSource source = getGranuleSource(workspaceName, storeName, coverageName);
Query q = toQuery(filter, offset, limit);
LOGGER.log(Level.SEVERE, "Still need to parse the filters");
return forceNonNullNamespace(source.getGranules(q));
}
@DeleteMapping(path = "/granules")
@ResponseBody
public void granulesDelete(@PathVariable String workspaceName,
@PathVariable String storeName,
@PathVariable String coverageName,
@RequestParam(name = "filter", required = false) String filter,
@RequestParam(name = "offset", required = false) Integer offset,
@RequestParam(name = "limit", required = false) Integer limit) throws IOException {
GranuleStore store = getGranuleStore(workspaceName, storeName, coverageName);
Query q = toQuery(filter, offset, limit);
LOGGER.log(Level.SEVERE, "Still need to parse the filters");
store.removeGranules(q.getFilter());
}
/*
* Note, the .+ regular expression allows granuleId to contain dots instead of having them
* interpreted as format extension
*/
@GetMapping(path = "/granules/{granuleId:.+}", produces = {
MediaType.APPLICATION_XML_VALUE,
MediaType.APPLICATION_JSON_VALUE })
@ResponseBody
public FormatCollectionWrapper granuleGet(
@PathVariable String workspaceName,
@PathVariable String storeName,
@PathVariable String coverageName,
@PathVariable String granuleId) throws IOException {
GranuleSource source = getGranuleSource(workspaceName, storeName, coverageName);
Filter filter = getGranuleIdFilter(granuleId);
Query q = new Query(null, filter);
SimpleFeatureCollection granules = source.getGranules(q);
if (granules.isEmpty()) {
throw new ResourceNotFoundException(
"Could not find a granule with id " + granuleId + " in coverage " + coverageName);
}
SimpleFeatureCollection collection = forceNonNullNamespace(granules);
// and now for the fun part, figure out the extension if it was there, and force
// the right output format... ugly as hell, but we could not find a better solution
// regexes with positive and negative lookaheads were tried
if(granuleId.endsWith(".json")) {
return new FormatCollectionWrapper.JSONCollectionWrapper(collection);
} else {
return new FormatCollectionWrapper.XMLCollectionWrapper(collection);
}
}
private Filter getGranuleIdFilter(String granuleId) {
if(granuleId.endsWith(".xml")) {
granuleId = granuleId.substring(0, granuleId.length() - 4);
} else if(granuleId.endsWith(".json")) {
granuleId = granuleId.substring(0, granuleId.length() - 5);
}
return FF.id(FF.featureId(granuleId));
}
/*
* Note, the .+ regular expression allows granuleId to contain dots instead of having them
* interpreted as format extension
*/
@DeleteMapping(path = {"/granules/{granuleId:.+}", "/granules/{granuleId:.+}/{format}"})
@ResponseBody
public void granuleDelete(@PathVariable(name = "workspaceName") String workspaceName,
@PathVariable String storeName,
@PathVariable String coverageName,
@PathVariable String granuleId,
@PathVariable(name = "format", required = false) String gsConfigFormat) throws IOException {
// gsConfigForma allows for weird calls gsconfig does, like granules/granule.id/.json
GranuleStore store = getGranuleStore(workspaceName, storeName, coverageName);
Filter filter = getGranuleIdFilter(granuleId);
store.removeGranules(filter);
}
@Override
public boolean supports(MethodParameter methodParameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
return IndexSchema.class.isAssignableFrom(methodParameter.getParameterType());
}
@Override
public void configurePersister(XStreamPersister persister, XStreamMessageConverter converter) {
XStream xstream = persister.getXStream();
xstream.alias("Schema", IndexSchema.class);
xstream.alias("Attribute", AttributeTypeInfo.class);
xstream.omitField(AttributeTypeInfoImpl.class, "featureType");
xstream.omitField(AttributeTypeInfoImpl.class, "metadata");
ReflectionConverter rc = new ReflectionConverter(xstream.getMapper(),
xstream.getReflectionProvider()) {
@Override
public boolean canConvert(Class type) {
return type.equals(IndexSchema.class);
}
@Override
public void marshal(Object original, HierarchicalStreamWriter writer,
MarshallingContext context) {
super.marshal(original, writer, context);
converter.encodeLink("granules", writer);
}
};
xstream.registerConverter(rc);
}
private GranuleSource getGranuleSource(String workspaceName, String storeName,
String coverageName) throws IOException {
CoverageInfo coverage = getExistingStructuredCoverage(workspaceName, storeName,
coverageName);
StructuredGridCoverage2DReader reader = (StructuredGridCoverage2DReader) coverage
.getGridCoverageReader(null, null);
String nativeCoverageName = getNativeCoverageName(coverage, reader);
return reader.getGranules(nativeCoverageName, true);
}
private GranuleStore getGranuleStore(String workspaceName, String storeName,
String coverageName) throws IOException {
CoverageInfo coverage = getExistingStructuredCoverage(workspaceName, storeName,
coverageName);
StructuredGridCoverage2DReader reader = (StructuredGridCoverage2DReader) coverage
.getGridCoverageReader(null, null);
if (reader.isReadOnly()) {
throw new RestException("Coverage " + coverage.prefixedName() + " is read ony",
HttpStatus.METHOD_NOT_ALLOWED);
}
String nativeCoverageName = getNativeCoverageName(coverage, reader);
return (GranuleStore) reader.getGranules(nativeCoverageName, false);
}
private String getNativeCoverageName(CoverageInfo coverage,
StructuredGridCoverage2DReader reader) throws IOException {
String nativeCoverageName = coverage.getNativeCoverageName();
if (nativeCoverageName == null) {
if (reader.getGridCoverageNames().length > 1) {
throw new IllegalStateException("The grid coverage configuration for "
+ coverage.getName()
+ " does not specify a native coverage name, yet the reader provides more than one coverage. "
+ "Please assign a native coverage name (the GUI does so automatically)");
} else {
nativeCoverageName = reader.getGridCoverageNames()[0];
}
}
return nativeCoverageName;
}
private SimpleFeatureCollection forceNonNullNamespace(SimpleFeatureCollection features)
throws IOException {
SimpleFeatureType sourceSchema = features.getSchema();
if (sourceSchema.getName().getNamespaceURI() == null) {
try {
String targetNs = "http://www.geoserver.org/rest/granules";
AttributeDescriptor[] attributes = sourceSchema
.getAttributeDescriptors().toArray(new AttributeDescriptor[sourceSchema
.getAttributeDescriptors().size()]);
SimpleFeatureType targetSchema = FeatureTypes.newFeatureType(attributes,
sourceSchema.getName().getLocalPart(), new URI(targetNs));
return new RetypingFeatureCollection(features,
targetSchema);
} catch (Exception e) {
throw new IOException(
"Failed to retype the granules feature schema, in order to force "
+ "it having a non null namespace",
e);
}
} else {
return features;
}
}
private CoverageInfo getExistingStructuredCoverage(String workspaceName, String storeName,
String coverageName) {
WorkspaceInfo ws = catalog.getWorkspaceByName(workspaceName);
if (ws == null) {
throw new ResourceNotFoundException("No such workspace : " + workspaceName);
}
CoverageStoreInfo store = catalog.getCoverageStoreByName(ws, storeName);
if (store == null) {
throw new ResourceNotFoundException("No such coverage store: " + storeName);
}
Optional<CoverageInfo> optCoverage = catalog.getCoveragesByStore(store).stream()
.filter(si -> coverageName.equals(si.getName())).findFirst();
if (!optCoverage.isPresent()) {
throw new ResourceNotFoundException("No such coverage in store: " + coverageName);
}
return optCoverage.get();
}
private Query toQuery(String filter, Integer offset, Integer limit) {
// build the query
Query q = new Query(Query.ALL);
// ... filter
if (filter != null) {
try {
Filter ogcFilter = ECQL.toFilter(filter);
q.setFilter(ogcFilter);
} catch (CQLException e) {
throw new RestException("Invalid cql syntax: " + e.getMessage(),
HttpStatus.BAD_REQUEST);
}
}
// ... offset
if (offset != null) {
if (offset < 0) {
throw new RestException("Invalid offset value: " + offset, HttpStatus.BAD_REQUEST);
}
q.setStartIndex(offset);
}
if (limit != null) {
if (limit <= 0) {
throw new RestException("Invalid limit value: " + offset, HttpStatus.BAD_REQUEST);
}
q.setMaxFeatures(limit);
}
return q;
}
}