Skip to content

Commit

Permalink
Merge pull request #36 from georchestra/GSMEL-530
Browse files Browse the repository at this point in the history
Fix OGC API shapefile download
  • Loading branch information
pmauduit authored Jun 24, 2024
2 parents 02f9dda + 1f3128b commit 50ba799
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 50ba799

Please sign in to comment.