Skip to content

Commit

Permalink
Fix OGC API shapefile download
Browse files Browse the repository at this point in the history
Shapefile download links resulted in a 500 http error code.
Reason being the GeoTools Shapefile DataStore will always set the
default geometry attribute as the first one in the FeatureTypes it
creates, hence simple writing of original feature to target feature
failed.
  • Loading branch information
groldan committed Jun 21, 2024
1 parent 02f9dda commit 1f3128b
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
import java.util.zip.ZipOutputStream;

import org.apache.commons.io.FilenameUtils;
import org.geotools.api.data.FeatureWriter;
import org.geotools.api.data.Transaction;
import org.geotools.api.feature.simple.SimpleFeature;
import org.geotools.api.feature.simple.SimpleFeatureType;
import org.geotools.api.feature.type.AttributeDescriptor;
import org.geotools.api.feature.type.GeometryDescriptor;
import org.geotools.data.shapefile.ShapefileDataStore;
import org.geotools.data.simple.SimpleFeatureCollection;
import org.geotools.data.simple.SimpleFeatureIterator;
Expand Down Expand Up @@ -75,14 +76,14 @@ public ShapefileFeatureCollectionHttpMessageConverter() {
Path tmpDir = createTmpDir();
try {
Path shp = createShapefile(tmpDir, message);
Path shpZip = createShapeZipfile(shp);
Path shpZip = createZipfile(shp);
writeContent(new FileSystemResource(shpZip), outputMessage);
} finally {
FileSystemUtils.deleteRecursively(tmpDir);
}
}

private Path createShapeZipfile(Path shp) throws IOException {
private Path createZipfile(Path shp) throws IOException {
String baseName = FilenameUtils.getBaseName(shp.getFileName().toString());
Path dbf = shp.getParent().resolve(baseName + ".dbf");
Path shx = shp.getParent().resolve(baseName + ".shx");
Expand Down Expand Up @@ -113,26 +114,20 @@ private void zipEncode(ZipOutputStream zout, Path file) throws IOException {
}

private Path createShapefile(Path dir, FeatureCollection message) throws IOException {
final SimpleFeatureCollection origContents = message.getOriginalContents().orElseThrow();
if (null == origContents.getSchema().getGeometryDescriptor()) {
throw new IllegalArgumentException(
"Collection %s does not have a geometry attribute, can't encode as Shapefile"
.formatted(origContents.getSchema().getTypeName()));
}

final SimpleFeatureType featureType = resolveFeatureType(message);
final String typeName = featureType.getTypeName();
final Path shp = dir.resolve(typeName + ".shp");

ShapefileDataStore ds = createDataStore(shp);
final SimpleFeatureCollection origContents = message.getOriginalContents().orElseThrow();
try (SimpleFeatureIterator orig = origContents.features()) {
ds.createSchema(featureType);
try (FeatureWriter<SimpleFeatureType, SimpleFeature> featureWriter = ds
.getFeatureWriterAppend(Transaction.AUTO_COMMIT)) {
try (var featureWriter = ds.getFeatureWriterAppend(Transaction.AUTO_COMMIT)) {

while (orig.hasNext()) {
SimpleFeature from = orig.next();
SimpleFeature to = featureWriter.next();
to.setAttributes(from.getAttributes());
setAttributes(from, to);
featureWriter.write();
}
}
Expand All @@ -142,7 +137,27 @@ private Path createShapefile(Path dir, FeatureCollection message) throws IOExcep
return shp;
}

private ShapefileDataStore createDataStore(Path shp) throws MalformedURLException {
private void setAttributes(SimpleFeature from, SimpleFeature to) {
SimpleFeatureType fromType = from.getFeatureType();

// ShapefileDataStore does not respect the default geometry attribute index nor
// its name (sets it to the_geom)
GeometryDescriptor geometryDescriptor = fromType.getGeometryDescriptor();
assert to.getFeatureType().getDescriptor(0) == to.getFeatureType().getGeometryDescriptor();
to.setDefaultGeometry(from.getDefaultGeometry());

for (int fromIndex = 0; fromIndex < fromType.getAttributeCount(); fromIndex++) {
AttributeDescriptor descriptor = fromType.getDescriptor(fromIndex);
if (geometryDescriptor == descriptor) {
continue;
}
int toIndex = fromIndex + 1;
Object value = from.getAttribute(fromIndex);
to.setAttribute(toIndex, value);
}
}

static ShapefileDataStore createDataStore(Path shp) throws MalformedURLException {
// avoid searching for other extensions when missing.
boolean skipScan = true;
ShapefileDataStore ds = new ShapefileDataStore(shp.toUri().toURL(), skipScan);
Expand All @@ -155,6 +170,12 @@ private ShapefileDataStore createDataStore(Path shp) throws MalformedURLExceptio

private SimpleFeatureType resolveFeatureType(FeatureCollection message) {
SimpleFeatureCollection orig = message.getOriginalContents().orElseThrow();
if (null == orig.getSchema().getGeometryDescriptor()) {
throw new IllegalArgumentException(
"Collection %s does not have a geometry attribute, can't encode as Shapefile"
.formatted(orig.getSchema().getTypeName()));
}

return orig.getSchema();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ public FeatureCollection query(@NonNull DataQuery query) {
*/
private void ensureSchemaIsInSync(Query gtQuery) {
Query noopQuery = new Query(gtQuery);
noopQuery.setSortBy((SortBy[]) null);
noopQuery.setMaxFeatures(0);
SimpleFeatureCollection fc = query(noopQuery);
try (SimpleFeatureIterator it = fc.features()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ logging:
com.camptocamp.opendata.ogc.features.repository: info
com.camptocamp.opendata.producer.geotools: info
com.camptocamp.opendata.jackson.geojson: info
org.geotools.data.shapefile.dbf: error

---
spring:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.camptocamp.opendata.ogc.features.http.codec.json;

import com.camptocamp.opendata.ogc.features.model.Collection;
import com.camptocamp.opendata.ogc.features.model.FeatureCollection;
import com.camptocamp.opendata.ogc.features.model.GeoToolsFeatureCollection;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;

import java.util.List;

import org.geotools.api.feature.simple.SimpleFeatureType;
import org.geotools.data.simple.SimpleFeatureCollection;
import org.geotools.feature.DefaultFeatureCollection;
Expand All @@ -12,11 +14,9 @@
import org.locationtech.jts.geom.Point;
import org.springframework.mock.http.MockHttpOutputMessage;

import java.awt.*;
import java.util.List;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import com.camptocamp.opendata.ogc.features.model.Collection;
import com.camptocamp.opendata.ogc.features.model.FeatureCollection;
import com.camptocamp.opendata.ogc.features.model.GeoToolsFeatureCollection;

class SimpleJsonFeatureCollectionHttpMessageConverterTest {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package com.camptocamp.opendata.ogc.features.http.codec.shp;

import static org.assertj.core.api.Assertions.assertThat;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import org.apache.commons.io.FileUtils;
import org.geotools.api.feature.simple.SimpleFeature;
import org.geotools.api.feature.simple.SimpleFeatureType;
import org.geotools.api.feature.type.AttributeDescriptor;
import org.geotools.api.feature.type.AttributeType;
import org.geotools.api.feature.type.GeometryDescriptor;
import org.geotools.data.collection.ListFeatureCollection;
import org.geotools.data.memory.MemoryFeatureCollection;
import org.geotools.data.shapefile.ShapefileDataStore;
import org.geotools.data.shapefile.ShapefileDirectoryFactory;
import org.geotools.data.simple.SimpleFeatureCollection;
import org.geotools.data.store.ContentFeatureCollection;
import org.geotools.feature.DefaultFeatureCollection;
import org.geotools.feature.simple.SimpleFeatureBuilder;
import org.geotools.feature.simple.SimpleFeatureTypeBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.io.WKTReader;
import org.springframework.mock.http.MockHttpOutputMessage;

import com.camptocamp.opendata.ogc.features.model.Collection;
import com.camptocamp.opendata.ogc.features.model.FeatureCollection;
import com.camptocamp.opendata.ogc.features.model.GeoToolsFeatureCollection;

import lombok.SneakyThrows;

class ShapefileFeatureCollectionHttpMessageConverterTest {

@TempDir
Path unzipFolder;

ShapefileFeatureCollectionHttpMessageConverter converter;
MockHttpOutputMessage output;

@BeforeEach
void setup() {
converter = new ShapefileFeatureCollectionHttpMessageConverter();
output = new MockHttpOutputMessage();
}

@Test
void testSimpleCollection() throws Exception {

FeatureCollection collection = featureCollection();

Type unused = null;
converter.writeInternal(collection, unused, output);

SimpleFeatureCollection expected = collection.getOriginalContents().orElseThrow();
SimpleFeatureCollection actual = extractShapefile(expected.getSchema().getTypeName(), output.getBodyAsBytes());

assertThat(actual.size()).isEqualTo(expected.size());
assertFeatureCollectionContents(expected, actual, "date");
}

private void assertFeatureCollectionContents(SimpleFeatureCollection expected, SimpleFeatureCollection actual,
String sortProperty) {
List<SimpleFeature> fExpected = sortedContents(expected, sortProperty);
List<SimpleFeature> fActual = sortedContents(actual, sortProperty);
assertThat(fActual).hasSameSizeAs(fExpected);
for (int i = 0; i < fExpected.size(); i++) {
assertFeatureContents(fExpected.get(i), fActual.get(i));
}
}

private void assertFeatureContents(SimpleFeature expected, SimpleFeature actual) {
SimpleFeatureType schema = actual.getFeatureType();
GeometryDescriptor geometryDescriptor = schema.getGeometryDescriptor();
assertThat(actual.getDefaultGeometry()).isEqualTo(expected.getDefaultGeometry());
for (AttributeDescriptor att : schema.getAttributeDescriptors()) {
if (att == geometryDescriptor)
continue;
String attName = att.getLocalName();
Object ev = expected.getAttribute(attName);
Object av = actual.getAttribute(attName);
assertThat(av).isEqualTo(ev);
}

}

@SneakyThrows(IOException.class)
private List<SimpleFeature> sortedContents(SimpleFeatureCollection collection, String sortProperty) {

@SuppressWarnings("unchecked")
Comparator<SimpleFeature> comparator = Comparator
.comparing(f -> (Comparable<Object>) ((SimpleFeature) f).getAttribute(sortProperty));

comparator = Comparator.nullsFirst(comparator);

return new ListFeatureCollection(collection).stream().sorted(comparator).toList();
}

@SneakyThrows(IOException.class)
private SimpleFeatureCollection extractShapefile(String typeName, byte[] shapeZip) {
ShapefileDataStore result = unzipAndGetShapefile(typeName, shapeZip);
try {
ContentFeatureCollection actual = result.getFeatureSource().getFeatures();
return new ListFeatureCollection(actual);
} finally {
result.dispose();
}
}

@SneakyThrows(IOException.class)
private ShapefileDataStore unzipAndGetShapefile(String typeName, byte[] shapeZip) {
Set<Path> unzipped = unzip(shapeZip);

Set<Path> expectedFiles = Stream.of("%s.shp", "%s.dbf", "%s.prj", "%s.shx").map(s -> s.formatted(typeName))
.map(unzipFolder::resolve).collect(Collectors.toSet());

assertThat(unzipped).isEqualTo(expectedFiles);

Path shp = unzipFolder.resolve("%s.shp".formatted(typeName));
return ShapefileFeatureCollectionHttpMessageConverter.createDataStore(shp);
}

private Set<Path> unzip(byte[] shapeZip) throws IOException {
ZipInputStream zipin = new ZipInputStream(new ByteArrayInputStream(shapeZip));
ZipEntry entry;
Set<Path> files = new TreeSet<>();
while (null != (entry = zipin.getNextEntry())) {
Path file = unzipFolder.resolve(entry.getName());
save(file, zipin.readAllBytes());
files.add(file);
}
return files;
}

private void save(Path file, byte[] contents) throws IOException {
Files.copy(new ByteArrayInputStream(contents), file);
}

private SimpleFeatureCollection features() {
SimpleFeatureType schema = featureType();
DefaultFeatureCollection col = new DefaultFeatureCollection("col", schema);

// ~ 2024-05-02 GMT+02
java.util.Date date = new java.util.Date(1714646315000L);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
col.add(feature(schema, "123", //
"string val", //
1000, //
date, geom("POINT(0 89)")));
return col;
}

private SimpleFeature feature(SimpleFeatureType schema, String id, Object... values) {
SimpleFeatureBuilder fb = new SimpleFeatureBuilder(featureType());
for (int i = 0; i < values.length; i++) {
fb.set(i, values[i]);
}
return fb.buildFeature(id);
}

private SimpleFeatureType featureType() {
SimpleFeatureTypeBuilder builder = new SimpleFeatureTypeBuilder();
builder.setName("test");
builder.setNamespaceURI("http://test");
builder.setSRS("EPSG:4326");
builder.add("string", String.class);
builder.add("int", Integer.class);
builder.add("date", java.util.Date.class);
builder.add("pointProperty", Point.class);

return builder.buildFeatureType();
}

private FeatureCollection featureCollection() {
Collection col = new Collection("col-id", List.of());
SimpleFeatureCollection features = features();
GeoToolsFeatureCollection collection = new GeoToolsFeatureCollection(col, features);
collection.setNumberMatched(1L);
collection.setNumberReturned(1L);

return collection;
}

@SneakyThrows
private Geometry geom(String wkt) {
return new WKTReader().read(wkt);
}
}

0 comments on commit 1f3128b

Please sign in to comment.