Skip to content

Commit 457f20b

Browse files
committed
use maxRecordCount 1 to get total features
1 parent eeb30e8 commit 457f20b

2 files changed

Lines changed: 65 additions & 97 deletions

File tree

server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wfs/DownloadWfsDataService.java

Lines changed: 30 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,17 @@
33
import au.org.aodn.ogcapi.server.core.configuration.CacheConfig;
44
import au.org.aodn.ogcapi.server.core.model.ogc.FeatureRequest;
55
import au.org.aodn.ogcapi.server.core.util.DatetimeUtils;
6+
import com.fasterxml.jackson.databind.JsonNode;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
68
import lombok.extern.slf4j.Slf4j;
7-
import net.opengis.ows10.ExceptionReportType;
8-
import net.opengis.wfs.FeatureCollectionType;
9-
import org.geotools.wfs.v1_1.WFSConfiguration;
10-
import org.geotools.xsd.Parser;
119
import org.springframework.cache.annotation.Cacheable;
1210
import org.springframework.http.*;
1311
import org.springframework.web.client.RestTemplate;
1412
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
15-
import org.xml.sax.SAXException;
1613

17-
import javax.xml.parsers.ParserConfigurationException;
1814
import java.io.ByteArrayOutputStream;
1915
import java.io.IOException;
2016
import java.io.InputStream;
21-
import java.io.StringReader;
2217
import java.math.BigInteger;
2318
import java.util.*;
2419
import java.util.concurrent.atomic.AtomicBoolean;
@@ -29,7 +24,7 @@ public class DownloadWfsDataService {
2924
protected final RestTemplate restTemplate;
3025
protected final HttpEntity<?> pretendUserEntity;
3126
protected final int chunkSize;
32-
protected static final WFSConfiguration WFS_CONFIG = new WFSConfiguration();
27+
protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
3328
protected static final int SAMPLES_SIZE = 500; // A not too small sample for download size estimation
3429

3530
public DownloadWfsDataService(
@@ -43,6 +38,7 @@ public DownloadWfsDataService(
4338
this.chunkSize = chunkSize;
4439
this.restTemplate = restTemplate;
4540
}
41+
4642
/**
4743
* Does collection lookup, WFS validation, field retrieval, and URL building
4844
*/
@@ -88,10 +84,12 @@ public String prepareWfsRequestUrl(
8884
throw new IllegalArgumentException("No WFS server URL found for the given UUID and layer name");
8985
}
9086
}
87+
9188
/**
9289
* We just need to estimate the download size, the way we do it is issue two query:
9390
* a. Issue a query and get the number or record hit
9491
* b. Issue a query with data download but then limit the records size, and do a liner interpolation
92+
*
9593
* @return The estimated file size
9694
*/
9795
@Cacheable(CacheConfig.DOWNLOADABLE_SIZE)
@@ -104,45 +102,40 @@ public BigInteger estimateDownloadSize(
104102
List<String> fields,
105103
String outputFormat) throws IllegalArgumentException {
106104

107-
// Just get number of record, the reply will always in XML
108-
String wfsRequestUrl = prepareWfsRequestUrl(
109-
uuid, startDate, endDate, multiPolygon, fields, layerName, "", -1L, true
105+
// Get total feature count via GeoJSON response (totalFeatures field is correctly populated
106+
// even with spatial CQL filters, unlike resultType=hits which ignores INTERSECTS)
107+
String countUrl = prepareWfsRequestUrl(
108+
uuid, startDate, endDate, multiPolygon, fields, layerName, "application/json", 1L, false
110109
);
111110

112-
ResponseEntity<String> response = restTemplate.exchange(wfsRequestUrl, HttpMethod.GET, pretendUserEntity, String.class);
113-
114-
if(response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
115-
Parser parser = new Parser(WFS_CONFIG);
116-
parser.setValidating(false);
117-
parser.setFailOnValidationError(false);
111+
ResponseEntity<String> countResponse = restTemplate.exchange(countUrl, HttpMethod.GET, pretendUserEntity, String.class);
118112

113+
if (countResponse.getStatusCode().is2xxSuccessful() && countResponse.getBody() != null) {
119114
try {
120-
Object o = parser.parse(new StringReader(response.getBody()));
121-
if(o instanceof FeatureCollectionType hits) {
122-
BigInteger featureCount = hits.getNumberOfFeatures();
123-
124-
log.debug("Total record hits {}", featureCount);
125-
// Now we need to do another query where we limited the record count to something small
126-
wfsRequestUrl = prepareWfsRequestUrl(
127-
uuid, startDate, endDate, multiPolygon, fields, layerName, outputFormat, SAMPLES_SIZE, false
128-
);
129-
ResponseEntity<byte[]> bytes = restTemplate.exchange(wfsRequestUrl, HttpMethod.GET, pretendUserEntity, byte[].class);
130-
if(bytes.getStatusCode().is2xxSuccessful() && bytes.getBody() != null) {
131-
return featureCount
132-
.multiply(BigInteger.valueOf(bytes.getBody().length))
133-
.divide(BigInteger.valueOf(SAMPLES_SIZE));
134-
}
115+
JsonNode root = OBJECT_MAPPER.readTree(countResponse.getBody());
116+
if (!root.has("totalFeatures")) {
117+
throw new RuntimeException("GeoServer GeoJSON response missing totalFeatures field");
135118
}
136-
else if(o instanceof ExceptionReportType report) {
137-
throw new IllegalArgumentException(String.join(",", report.getException().stream().map(ex -> ex.getExceptionText().toString()).toList()));
119+
BigInteger featureCount = BigInteger.valueOf(root.get("totalFeatures").asLong());
120+
log.debug("Total record hits {}", featureCount);
121+
122+
// Download a small sample to measure bytes per record in the requested output format
123+
String sampleUrl = prepareWfsRequestUrl(
124+
uuid, startDate, endDate, multiPolygon, fields, layerName, outputFormat, SAMPLES_SIZE, false
125+
);
126+
ResponseEntity<byte[]> bytes = restTemplate.exchange(sampleUrl, HttpMethod.GET, pretendUserEntity, byte[].class);
127+
if (bytes.getStatusCode().is2xxSuccessful() && bytes.getBody() != null) {
128+
return featureCount
129+
.multiply(BigInteger.valueOf(bytes.getBody().length))
130+
.divide(BigInteger.valueOf(SAMPLES_SIZE));
138131
}
139-
}
140-
catch(IOException | SAXException | ParserConfigurationException e) {
141-
log.error("Fail to convert wfs hits result", e);
132+
} catch (IOException e) {
133+
log.error("Fail to get feature count for estimate", e);
142134
}
143135
}
144136
return null;
145137
}
138+
146139
/**
147140
* Execute WFS request with SSE support
148141
*/

server/src/test/java/au/org/aodn/ogcapi/server/core/service/geoserver/wfs/DownloadWfsDataServiceTest.java

Lines changed: 35 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -315,31 +315,17 @@ void shouldReturnEstimatedSizeWhenBothRequestsSucceed() {
315315
List<String> fields = List.of("name", "area");
316316
String format = "application/json";
317317

318-
// 1. Hits response (XML)
319-
String hitsXml = """
320-
<?xml version="1.0" encoding="UTF-8"?>
321-
<wfs:FeatureCollection
322-
xmlns:xs="http://www.w3.org/2001/XMLSchema"
323-
xmlns:wfs="http://www.opengis.net/wfs"
324-
xmlns:gml="http://www.opengis.net/gml"
325-
xmlns:ogc="http://www.opengis.net/ogc"
326-
xmlns:ows="http://www.opengis.net/ows"
327-
xmlns:xlink="http://www.w3.org/1999/xlink"
328-
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
329-
numberOfFeatures="227193"
330-
timeStamp="2026-03-01T22:28:56.206Z"
331-
xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd"
332-
/>
333-
""";
334-
ResponseEntity<String> hitsResponse = new ResponseEntity<>(hitsXml, HttpStatus.OK);
335-
336-
// 2. Sample response (small payload)
318+
// 1. Count response: GeoJSON with totalFeatures (1 record requested, but totalFeatures = full count)
319+
String countJson = "{\"totalFeatures\": 227193, \"features\": []}";
320+
ResponseEntity<String> countResponse = new ResponseEntity<>(countJson, HttpStatus.OK);
321+
322+
// 2. Sample response (small payload in requested format)
337323
byte[] sampleBytes = "fake data".getBytes();
338324
ResponseEntity<byte[]> sampleResponse = new ResponseEntity<>(sampleBytes, HttpStatus.OK);
339325

340-
doReturn(hitsResponse)
326+
doReturn(countResponse)
341327
.when(restTemplate).exchange(
342-
argThat((String url) -> url != null && url.contains("resultType=hits")),
328+
argThat((String url) -> url != null && url.contains("maxFeatures=1")),
343329
eq(HttpMethod.GET),
344330
any(HttpEntity.class),
345331
eq(String.class));
@@ -364,53 +350,48 @@ void shouldReturnEstimatedSizeWhenBothRequestsSucceed() {
364350
BigInteger size = downloadWfsDataService.estimateDownloadSize(
365351
uuid, layer, start, end, multiPolygon, fields, format);
366352

367-
// Should have call with resultType=hits to get number of record
353+
// Should call with maxFeatures=1 to get totalFeatures count via GeoJSON
368354
verify(restTemplate).exchange(
369-
eq("https://dummy.com/wfs?VERSION=1.1.0&typeName=water_bodies&SERVICE=WFS&REQUEST=GetFeature&resultType=hits&propertyName=name,area&cql_filter=((time DURING 2024-01-01T00:00:00Z/2024-12-31T23:59:59Z))"), // or contains(...)
355+
argThat((String url) -> url != null && url.contains("maxFeatures=1") && url.contains("outputFormat=application")),
370356
eq(HttpMethod.GET),
371357
any(),
372-
eq(String.class) // or byte[].class etc.
358+
eq(String.class)
373359
);
374360

375-
// Should also call with maxFeatures
361+
// Should also call with maxFeatures=500 to sample bytes for size interpolation
376362
verify(restTemplate).exchange(
377-
eq("https://dummy.com/wfs?REQUEST=GetFeature&propertyName=name,area&VERSION=1.0.0&typeName=water_bodies&SERVICE=WFS&outputFormat=application/json&maxFeatures=500&cql_filter=((time DURING 2024-01-01T00:00:00Z/2024-12-31T23:59:59Z))"), // or contains(...)
363+
argThat((String url) -> url != null && url.contains("maxFeatures=" + DownloadWfsDataService.SAMPLES_SIZE)),
378364
eq(HttpMethod.GET),
379365
any(),
380-
eq(byte[].class) // or byte[].class etc.
366+
eq(byte[].class)
381367
);
382368

383-
// numberOfFeatures="227193"
369+
// totalFeatures=227193, sampleBytes=9 bytes, SAMPLES_SIZE=500
384370
long expected = 227193L * sampleBytes.length / DownloadWfsDataService.SAMPLES_SIZE;
385371
assertEquals(BigInteger.valueOf(expected), size, "Size match");
386372
}
387373
/**
388-
* Expect illegal exception when param is wrong
374+
* Expect RuntimeException when GeoServer returns JSON without the totalFeatures field
375+
* (e.g. GeoServer returned an error JSON or unexpected structure)
389376
*/
390377
@Test
391-
void throwExceptionWhenHitsRequestFails() {
378+
void throwsExceptionWhenCountResponseMissesTotalFeatures() {
392379

393380
String uuid = "lyr-123";
394381
String layer = "imos:aatams_sattag_dm_profile_map1";
395382
String start = "2024-01-01";
396383
String end = "2024-12-31";
397-
Object multiPolygon = new Object(); // or real geometry
384+
Object multiPolygon = new Object();
398385
List<String> fields = List.of("name", "area");
399386
String format = "application/json";
400387

401-
// 1. Hits response (XML), indicate error
402-
String hitsXml = """
403-
<?xml version="1.0" encoding="UTF-8"?>
404-
<ows:ExceptionReport xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:ows="http://www.opengis.net/ows" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.0.0" xsi:schemaLocation="http://www.opengis.net/ows https://geoserver-123.aodn.org.au/geoserver/schemas/ows/1.0.0/owsExceptionReport.xsd">
405-
<ows:Exception exceptionCode="InvalidParameterValue" locator="typeName">
406-
<ows:ExceptionText>Feature type imos:aatams_sattag_dm_profile_map1 unknown</ows:ExceptionText>
407-
</ows:Exception>
408-
</ows:ExceptionReport>
409-
""";
410-
ResponseEntity<String> hitsResponse = new ResponseEntity<>(hitsXml, HttpStatus.OK);
411-
doReturn(hitsResponse)
388+
// GeoServer returns JSON but without the totalFeatures field
389+
String countJson = "{\"error\": \"Feature type unknown\"}";
390+
ResponseEntity<String> countResponse = new ResponseEntity<>(countJson, HttpStatus.OK);
391+
392+
doReturn(countResponse)
412393
.when(restTemplate).exchange(
413-
argThat((String url) -> url != null && url.contains("resultType=hits")),
394+
argThat((String url) -> url != null && url.contains("maxFeatures=1")),
414395
eq(HttpMethod.GET),
415396
any(HttpEntity.class),
416397
eq(String.class));
@@ -427,10 +408,10 @@ void throwExceptionWhenHitsRequestFails() {
427408
doReturn(fs)
428409
.when(wfsServer).getDownloadableFields(eq(uuid), any(WfsServer.WfsFeatureRequest.class));
429410

430-
assertThrows(IllegalArgumentException.class,
411+
assertThrows(RuntimeException.class,
431412
() -> downloadWfsDataService.estimateDownloadSize(
432-
uuid, layer, start, end, multiPolygon, fields, format)
433-
);
413+
uuid, layer, start, end, multiPolygon, fields, format),
414+
"Should throw RuntimeException when totalFeatures is missing from count response");
434415
}
435416

436417
@Test
@@ -439,23 +420,17 @@ void returnsNullWhenParserThrowsException() {
439420
String layer = "imos:aatams_sattag_dm_profile_map1";
440421
String start = "2024-01-01";
441422
String end = "2024-12-31";
442-
Object multiPolygon = new Object(); // or real geometry
423+
Object multiPolygon = new Object();
443424
List<String> fields = List.of("name", "area");
444425
String format = "application/json";
445426

446-
// 1. A syntax error XML, should not see this but just incase
447-
String hitsXml = """
448-
<?xml version="1.0" encoding="UTF-8"?>
449-
<ows:ExceptionReport23433 xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:ows="http://www.opengis.net/ows" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.0.0" xsi:schemaLocation="http://www.opengis.net/ows https://geoserver-123.aodn.org.au/geoserver/schemas/ows/1.0.0/owsExceptionReport.xsd">
450-
<ows:Exception exceptionCode="InvalidParameterValue" locator="typeName">
451-
<ows:ExceptionText>Feature type imos:aatams_sattag_dm_profile_map1 unknown</ows:ExceptionText>
452-
</ows:Exception>
453-
</ows:ExceptionReport>
454-
""";
455-
ResponseEntity<String> hitsResponse = new ResponseEntity<>(hitsXml, HttpStatus.OK);
456-
doReturn(hitsResponse)
427+
// GeoServer returns malformed JSON — Jackson will throw an IOException, expect null back
428+
String malformedJson = "not-valid-json{{{{";
429+
ResponseEntity<String> countResponse = new ResponseEntity<>(malformedJson, HttpStatus.OK);
430+
431+
doReturn(countResponse)
457432
.when(restTemplate).exchange(
458-
argThat((String url) -> url != null && url.contains("resultType=hits")),
433+
argThat((String url) -> url != null && url.contains("maxFeatures=1")),
459434
eq(HttpMethod.GET),
460435
any(HttpEntity.class),
461436
eq(String.class));
@@ -475,6 +450,6 @@ void returnsNullWhenParserThrowsException() {
475450
BigInteger size = downloadWfsDataService.estimateDownloadSize(
476451
uuid, layer, start, end, multiPolygon, fields, format);
477452

478-
assertNull(size, "Size should be null");
453+
assertNull(size, "Size should be null when JSON parsing fails");
479454
}
480455
}

0 commit comments

Comments
 (0)