Skip to content

Commit 3ab9dea

Browse files
committed
HTM-1965: Implement zipped shapefile extract
1 parent 5bc588f commit 3ab9dea

11 files changed

Lines changed: 677 additions & 72 deletions

pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,10 @@ SPDX-License-Identifier: MIT
347347
<groupId>org.geotools</groupId>
348348
<artifactId>gt-referencing</artifactId>
349349
</dependency>
350+
<dependency>
351+
<groupId>org.geotools</groupId>
352+
<artifactId>gt-shapefile</artifactId>
353+
</dependency>
350354
<dependency>
351355
<groupId>org.geotools</groupId>
352356
<artifactId>gt-wfs-ng</artifactId>

src/main/java/org/tailormap/api/controller/LayerExtractController.java

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.geotools.api.filter.sort.SortOrder;
2929
import org.geotools.filter.text.cql2.CQLException;
3030
import org.geotools.filter.text.ecql.ECQL;
31+
import org.jspecify.annotations.Nullable;
3132
import org.slf4j.Logger;
3233
import org.slf4j.LoggerFactory;
3334
import org.springframework.beans.factory.annotation.Value;
@@ -198,8 +199,18 @@ public ResponseEntity<?> extract(
198199
attributes.add(sourceFT.getDefaultGeometryAttribute());
199200
}
200201

201-
if (outputFormat == ExtractOutputFormat.XLSX) {
202-
validateExcelLimits(sourceFT, attributes, filter);
202+
// check if filter has valid syntax (it could still be invalid wrt feature type)
203+
Filter parsedCQL = null;
204+
try {
205+
if (!StringUtils.isBlank(filter)) {
206+
parsedCQL = ECQL.toFilter(filter);
207+
}
208+
} catch (CQLException e) {
209+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid filter");
210+
}
211+
212+
if (ExtractOutputFormat.XLSX.equals(outputFormat)) {
213+
validateExcelLimits(sourceFT, attributes, parsedCQL);
203214
}
204215

205216
SortOrder sortingOrder = SortOrder.ASCENDING;
@@ -213,7 +224,7 @@ public ResponseEntity<?> extract(
213224

214225
//noinspection JvmTaintAnalysis Not a Path Traversal Sink because the clientId is validated
215226
this.createLayerExtractService.createLayerExtract(
216-
clientId, sourceFT, attributes, filter, sortBy, sortingOrder, outputFormat, outputFileName);
227+
clientId, sourceFT, attributes, parsedCQL, sortBy, sortingOrder, outputFormat, outputFileName);
217228

218229
//noinspection JvmTaintAnalysis Not an XSS sink because the response is a json message
219230
return ResponseEntity.accepted()
@@ -227,9 +238,9 @@ public ResponseEntity<?> extract(
227238
*
228239
* @param featureType requested FT
229240
* @param attributes requested attributes
230-
* @param filterCQL requested filter
241+
* @param filter requested filter
231242
*/
232-
private void validateExcelLimits(TMFeatureType featureType, Set<String> attributes, String filterCQL) {
243+
private void validateExcelLimits(TMFeatureType featureType, Set<String> attributes, @Nullable Filter filter) {
233244
if (attributes.size() > ExcelDataStore.getMaxColumns()) {
234245
throw new ResponseStatusException(
235246
HttpStatus.BAD_REQUEST,
@@ -245,8 +256,7 @@ private void validateExcelLimits(TMFeatureType featureType, Set<String> attribut
245256
q.setPropertyNames(attributes.toArray(new String[0]));
246257
}
247258

248-
if (!StringUtils.isBlank(filterCQL)) {
249-
Filter filter = ECQL.toFilter(filterCQL);
259+
if (filter != null) {
250260
q.setFilter(filter);
251261
}
252262
final int featCount = inputFeatureSource.getCount(q);
@@ -255,10 +265,12 @@ private void validateExcelLimits(TMFeatureType featureType, Set<String> attribut
255265
HttpStatus.BAD_REQUEST,
256266
"Excel format does not support more than " + ExcelDataStore.getMaxRows() + " rows");
257267
}
258-
} catch (CQLException | IOException e) {
268+
} catch (IOException e) {
259269
throw new ResponseStatusException(
260270
HttpStatus.INTERNAL_SERVER_ERROR,
261271
"Failed to count all features for Excel extract: " + e.getMessage());
272+
} catch (IllegalArgumentException e) {
273+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid filter");
262274
} finally {
263275
if (inputFeatureSource != null) {
264276
inputFeatureSource.getDataStore().dispose();
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (C) 2026 B3Partners B.V.
3+
*
4+
* SPDX-License-Identifier: MIT
5+
*/
6+
package org.tailormap.api.geotools.collection;
7+
8+
import java.util.function.IntConsumer;
9+
import org.geotools.data.simple.SimpleFeatureCollection;
10+
import org.geotools.data.simple.SimpleFeatureIterator;
11+
import org.geotools.feature.collection.DecoratingSimpleFeatureCollection;
12+
import org.jspecify.annotations.Nullable;
13+
14+
/**
15+
* A decorating feature collection that will pass a callback to the iterator to report the number of features provided.
16+
*/
17+
public class ProgressReportingFeatureCollection extends DecoratingSimpleFeatureCollection {
18+
private final int progressInterval;
19+
private final IntConsumer progressCallback;
20+
21+
/**
22+
* Creates a new {@code ProgressReportingFeatureCollection} that wraps the given delegate and reports progress at
23+
* the specified interval.
24+
*
25+
* @param delegate the underlying {@link SimpleFeatureCollection} to decorate
26+
* @param progressInterval the number of features between each progress callback invocation; must be greater than
27+
* {@code 0}
28+
* @param progressCallback a callback that receives the current feature count at each interval; may be {@code null}
29+
*/
30+
public ProgressReportingFeatureCollection(
31+
SimpleFeatureCollection delegate, int progressInterval, @Nullable IntConsumer progressCallback) {
32+
super(delegate);
33+
if (progressInterval <= 0) {
34+
throw new IllegalArgumentException("progressInterval must be greater than 0");
35+
}
36+
this.delegate = delegate;
37+
this.progressInterval = progressInterval;
38+
this.progressCallback = progressCallback;
39+
}
40+
41+
@Override
42+
public SimpleFeatureIterator features() {
43+
return new ProgressReportingFeatureIterator(delegate.features(), progressInterval, progressCallback);
44+
}
45+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (C) 2026 B3Partners B.V.
3+
*
4+
* SPDX-License-Identifier: MIT
5+
*/
6+
package org.tailormap.api.geotools.collection;
7+
8+
import java.util.concurrent.atomic.AtomicInteger;
9+
import java.util.function.IntConsumer;
10+
import org.geotools.api.feature.simple.SimpleFeature;
11+
import org.geotools.data.simple.SimpleFeatureIterator;
12+
import org.geotools.feature.collection.DecoratingSimpleFeatureIterator;
13+
import org.jspecify.annotations.Nullable;
14+
15+
/** A decorating feature iterator that will call a callback after a specified number of features are handled. */
16+
public class ProgressReportingFeatureIterator extends DecoratingSimpleFeatureIterator {
17+
18+
private final AtomicInteger count = new AtomicInteger(0);
19+
private final int progressInterval;
20+
private final IntConsumer progressCallback;
21+
private SimpleFeatureIterator iterator;
22+
23+
/**
24+
* Creates an iterator that reports progress after every configured number of processed features.
25+
*
26+
* @param iterator the wrapped feature iterator, must not be {@code null}
27+
* @param progressInterval the number of processed features between progress updates, must be greater than {@code 0}
28+
* @param progressCallback the callback that receives the current processed feature count; may be {@code null}
29+
* @throws IllegalArgumentException if {@code progressInterval <= 0}
30+
*/
31+
public ProgressReportingFeatureIterator(
32+
SimpleFeatureIterator iterator, int progressInterval, @Nullable IntConsumer progressCallback) {
33+
super(iterator);
34+
if (progressInterval <= 0) {
35+
throw new IllegalArgumentException("progressInterval must be greater than 0");
36+
}
37+
this.iterator = iterator;
38+
this.progressInterval = progressInterval;
39+
this.progressCallback = progressCallback;
40+
}
41+
42+
@Override
43+
public SimpleFeature next() {
44+
if (count.incrementAndGet() % progressInterval == 0) {
45+
if (progressCallback != null) {
46+
progressCallback.accept(count.get());
47+
}
48+
}
49+
return iterator.next();
50+
}
51+
52+
@Override
53+
public boolean hasNext() {
54+
return iterator.hasNext();
55+
}
56+
57+
@Override
58+
public void close() {
59+
iterator.close();
60+
iterator = null;
61+
count.set(0);
62+
}
63+
}

0 commit comments

Comments
 (0)