From 4da9aa5f0a2d99202398213cc43551361579d9df Mon Sep 17 00:00:00 2001 From: nekoLyn Date: Mon, 28 Jul 2025 15:31:12 +1000 Subject: [PATCH 1/9] add endpoint for getDownloadableFields --- server/pom.xml | 4 + .../DownloadableFieldsNotFoundException.java | 11 ++ .../exception/GlobalExceptionHandler.java | 14 ++ .../aodn/ogcapi/server/features/RestApi.java | 22 ++- .../ogcapi/server/features/RestServices.java | 17 ++ .../features/model/DownloadableField.java | 16 ++ .../model/WfsDescribeFeatureTypeResponse.java | 61 +++++++ .../service/DownloadableFieldsService.java | 154 ++++++++++++++++++ 8 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 server/src/main/java/au/org/aodn/ogcapi/server/core/exception/DownloadableFieldsNotFoundException.java create mode 100644 server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java create mode 100644 server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java create mode 100644 server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java diff --git a/server/pom.xml b/server/pom.xml index f7d55bf6..ea5d4ef7 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -105,6 +105,10 @@ com.fasterxml.jackson.core jackson-databind + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + org.geotools diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/DownloadableFieldsNotFoundException.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/DownloadableFieldsNotFoundException.java new file mode 100644 index 00000000..200eb054 --- /dev/null +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/DownloadableFieldsNotFoundException.java @@ -0,0 +1,11 @@ +package au.org.aodn.ogcapi.server.core.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class DownloadableFieldsNotFoundException extends RuntimeException { + public DownloadableFieldsNotFoundException(String message) { + super(message); + } +} diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/GlobalExceptionHandler.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/GlobalExceptionHandler.java index bb05cba0..376a877f 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/GlobalExceptionHandler.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/GlobalExceptionHandler.java @@ -40,6 +40,20 @@ public ResponseEntity handleCustomException(Exception ex, WebRequ return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); } + @ExceptionHandler(DownloadableFieldsNotFoundException.class) + public ResponseEntity handleDownloadableFieldsNotFoundException(DownloadableFieldsNotFoundException ex, WebRequest request) { + ErrorResponse errorResponse = ErrorResponse + .builder() + .timestamp(LocalDateTime.now()) + .message(ex.getMessage()) + .details(request.getDescription(false)) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); + } + + + @ExceptionHandler(Exception.class) public ResponseEntity handleGlobalException(Exception ex, WebRequest request) { ErrorResponse errorResponse = ErrorResponse diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java index 094d5a6c..031946df 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java @@ -7,6 +7,7 @@ import au.org.aodn.ogcapi.server.core.model.enumeration.CQLFields; import au.org.aodn.ogcapi.server.core.model.enumeration.FeatureId; import au.org.aodn.ogcapi.server.core.service.OGCApiService; +import au.org.aodn.ogcapi.server.features.model.DownloadableField; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -46,8 +47,10 @@ public ResponseEntity getFeature(String collectionId, String fea } @Operation( - summary = "fetch a single feature", - description = "Fetch the feature with id `featureId` in the feature collection with id `collectionId`. Use content negotiation to request HTML or GeoJSON.", + summary = "fetch a single feature or downloadable fields", + description = "Fetch the feature with id `featureId` in the feature collection with id `collectionId`. Use content negotiation to request HTML or GeoJSON. " + + "Special case: if featureId is 'downloadableFields', returns the downloadable fields for WFS subsetting. " + + "For downloadableFields, requires query parameters: serverUrl (WMS server URL) and layerName (layer name).", tags = {"Data"} ) @ApiResponses({@ApiResponse( @@ -87,7 +90,11 @@ ResponseEntity getFeature( @Parameter(in = ParameterIn.QUERY, description = "Only records that have a geometry that intersects the bounding box are selected. The bounding box is provided as four or six numbers, depending on whether the coordinate reference system includes a vertical axis (height or depth): * Lower left corner, coordinate axis 1 * Lower left corner, coordinate axis 2 * Minimum value, coordinate axis 3 (optional) * Upper right corner, coordinate axis 1 * Upper right corner, coordinate axis 2 * Maximum value, coordinate axis 3 (optional) The coordinate reference system of the values is WGS 84 long/lat (http://www.opengis.net/def/crs/OGC/1.3/CRS84) unless a different coordinate reference system is specified in the parameter `bbox-crs`. For WGS 84 longitude/latitude the values are in most cases the sequence of minimum longitude, minimum latitude, maximum longitude and maximum latitude. However, in cases where the box spans the antimeridian the first value (west-most box edge) is larger than the third value (east-most box edge). If the vertical axis is included, the third and the sixth number are the bottom and the top of the 3-dimensional bounding box. If a record has multiple spatial geometry properties, it is the decision of the server whether only a single spatial geometry property is used to determine the extent or all relevant geometries." ,schema=@Schema()) @Valid @RequestParam(value = "bbox", required = false) List bbox, @Parameter(in = ParameterIn.QUERY, description = "Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots. Examples: * A date-time: \"2018-02-12T23:20:50Z\" * A closed interval: \"2018-02-12T00:00:00Z/2018-03-18T12:31:12Z\" * Open intervals: \"2018-02-12T00:00:00Z/..\" or \"../2018-03-18T12:31:12Z\" Only records that have a temporal property that intersects the value of `datetime` are selected. It is left to the decision of the server whether only a single temporal property is used to determine the extent or all relevant temporal properties." ,schema=@Schema()) - @Valid @RequestParam(value = "datetime", required = false) String datetime) { + @Valid @RequestParam(value = "datetime", required = false) String datetime, + @Parameter(in = ParameterIn.QUERY, description = "WFS server URL (required when featureId is 'downloadableFields')" ,schema=@Schema()) + @Valid @RequestParam(value = "serverUrl", required = false) String serverUrl, + @Parameter(in = ParameterIn.QUERY, description = "WFS type name (required when featureId is 'downloadableFields')" ,schema=@Schema()) + @Valid @RequestParam(value = "layerName", required = false) String layerName) { String filter = null; if (datetime != null) { @@ -98,6 +105,15 @@ ResponseEntity getFeature( filter = OGCApiService.processBBoxParameter(CQLFields.geometry.name(), bbox, filter); } + // Handle special case for downloadableFields + if ("downloadableFields".equals(featureId)) { + if (serverUrl == null || layerName == null) { + return ResponseEntity.badRequest().build(); + } + + return featuresService.getDownloadableFields(serverUrl, layerName); + } + try { FeatureId fid = FeatureId.valueOf(FeatureId.class, featureId); diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java index f0e68ab1..65aff20e 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java @@ -5,6 +5,8 @@ import au.org.aodn.ogcapi.server.core.model.StacCollectionModel; import au.org.aodn.ogcapi.server.core.service.ElasticSearch; import au.org.aodn.ogcapi.server.core.service.OGCApiService; +import au.org.aodn.ogcapi.server.features.model.DownloadableField; +import au.org.aodn.ogcapi.server.features.service.DownloadableFieldsService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; @@ -20,6 +22,9 @@ public class RestServices extends OGCApiService { @Autowired protected StacToCollection StacToCollection; + @Autowired + protected DownloadableFieldsService downloadableFieldsService; + @Override public List getConformanceDeclaration() { return List.of("http://www.opengis.net/doc/IS/ogcapi-features-1/1.0.1"); @@ -40,4 +45,16 @@ public ResponseEntity getCollection(String id, String sortBy) throws return ResponseEntity.notFound().build(); } } + + /** + * Get downloadable fields for a layer + * + * @param wfsUrl The WFS server URL + * @param typeName The WFS type name + * @return List of downloadable fields + */ + public ResponseEntity> getDownloadableFields(String wfsUrl, String typeName) { + List fields = downloadableFieldsService.getDownloadableFields(wfsUrl, typeName); + return ResponseEntity.ok(fields); + } } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java new file mode 100644 index 00000000..c978a635 --- /dev/null +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java @@ -0,0 +1,16 @@ +package au.org.aodn.ogcapi.server.features.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class DownloadableField { + @JsonProperty("label") + private String label; + + @JsonProperty("type") + private String type; + + @JsonProperty("name") + private String name; +} diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java new file mode 100644 index 00000000..1789f17c --- /dev/null +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java @@ -0,0 +1,61 @@ +package au.org.aodn.ogcapi.server.features.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +@JacksonXmlRootElement(localName = "schema", namespace = "http://www.w3.org/2001/XMLSchema") +public class WfsDescribeFeatureTypeResponse { + + @JacksonXmlProperty(localName = "complexType") + @JacksonXmlElementWrapper(useWrapping = false) + private List complexTypes; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ComplexType { + @JacksonXmlProperty(isAttribute = true) + private String name; + + @JacksonXmlProperty(localName = "complexContent") + private ComplexContent complexContent; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ComplexContent { + @JacksonXmlProperty(localName = "extension") + private Extension extension; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Extension { + @JacksonXmlProperty(localName = "sequence") + private Sequence sequence; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Sequence { + @JacksonXmlProperty(localName = "element") + @JacksonXmlElementWrapper(useWrapping = false) + private List elements; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Element { + @JacksonXmlProperty(isAttribute = true) + private String name; + + @JacksonXmlProperty(isAttribute = true) + private String type; + } +} diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java new file mode 100644 index 00000000..c9bad5ac --- /dev/null +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java @@ -0,0 +1,154 @@ +package au.org.aodn.ogcapi.server.features.service; + +import au.org.aodn.ogcapi.server.core.exception.DownloadableFieldsNotFoundException; +import au.org.aodn.ogcapi.server.features.model.*; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +public class DownloadableFieldsService { + + @Autowired + private RestTemplate restTemplate; + + private final XmlMapper xmlMapper = new XmlMapper(); + + /** + * Get downloadable fields for a layer + * + * @param wfsUrl The WFS server URL + * @param typeName The WFS type name + * @return List of downloadable fields + */ + public List getDownloadableFields(String wfsUrl, String typeName) { + log.info("Getting downloadable fields for typeName: {} from WFS: {}", typeName, wfsUrl); + + try { + List fields = getFilterFieldsFromWfs(wfsUrl, typeName); + + if (fields.isEmpty()) { + throw new DownloadableFieldsNotFoundException( + String.format("No downloadable fields found for typeName '%s' from WFS server '%s'", typeName, wfsUrl) + ); + } + + return fields; + } catch (Exception e) { + log.error("Error getting downloadable fields for typeName: {} from WFS: {}", typeName, wfsUrl, e); + throw new DownloadableFieldsNotFoundException( + String.format("No downloadable fields found for typeName '%s' from WFS server '%s'", typeName, wfsUrl) + ); + } + } + + + + + + /** + * Get filter fields from WFS DescribeFeatureType + */ + private List getFilterFieldsFromWfs(String wfsUrl, String typeName) { + try { + URI uri = UriComponentsBuilder.fromUriString(wfsUrl) + .queryParam("service", "WFS") + .queryParam("version", "1.0.0") + .queryParam("request", "DescribeFeatureType") + .queryParam("typeName", typeName) + .build() + .toUri(); + + log.debug("WFS DescribeFeatureType request: {}", uri); + + ResponseEntity response = restTemplate.exchange(uri, HttpMethod.GET, null, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + WfsDescribeFeatureTypeResponse wfsResponse = xmlMapper.readValue(response.getBody(), WfsDescribeFeatureTypeResponse.class); + return convertWfsResponseToDownloadableFields(wfsResponse); + } else { + throw new DownloadableFieldsNotFoundException( + String.format("No downloadable fields found for typeName '%s' from WFS server '%s'", typeName, wfsUrl) + ); + } + + } catch (Exception e) { + log.error("Error calling WFS DescribeFeatureType for typeName: {}", typeName, e); + throw new DownloadableFieldsNotFoundException( + String.format("No downloadable fields found for typeName '%s' from WFS server '%s'", typeName, wfsUrl) + ); + } + } + + + + /** + * Convert WFS response to downloadable fields (geometry and date/time fields) + */ + private List convertWfsResponseToDownloadableFields(WfsDescribeFeatureTypeResponse wfsResponse) { + List fields = new ArrayList<>(); + + if (wfsResponse.getComplexTypes() != null) { + for (WfsDescribeFeatureTypeResponse.ComplexType complexType : wfsResponse.getComplexTypes()) { + if (complexType.getComplexContent() != null && + complexType.getComplexContent().getExtension() != null && + complexType.getComplexContent().getExtension().getSequence() != null) { + + List elements = + complexType.getComplexContent().getExtension().getSequence().getElements(); + + if (elements != null) { + for (WfsDescribeFeatureTypeResponse.Element element : elements) { + if (element.getName() != null && element.getType() != null) { + + // Add geometry fields + if ("gml:GeometryPropertyType".equals(element.getType())) { + DownloadableField geomField = new DownloadableField(); + geomField.setLabel(element.getName()); + geomField.setType("geometrypropertytype"); + geomField.setName(element.getName()); + fields.add(geomField); + } + + // Add date/time fields + else if ("xsd:dateTime".equals(element.getType())) { + DownloadableField timeField = new DownloadableField(); + timeField.setLabel(element.getName()); + timeField.setType("dateTime"); + timeField.setName(element.getName()); + fields.add(timeField); + } + else if ("xsd:date".equals(element.getType())) { + DownloadableField dateField = new DownloadableField(); + dateField.setLabel(element.getName()); + dateField.setType("date"); + dateField.setName(element.getName()); + fields.add(dateField); + } + else if ("xsd:time".equals(element.getType())) { + DownloadableField timeField = new DownloadableField(); + timeField.setLabel(element.getName()); + timeField.setType("time"); + timeField.setName(element.getName()); + fields.add(timeField); + } + } + } + } + } + } + } + + return fields; + } +} From 3a126d6c2134b51d9059d62cd6fffad88039bbc7 Mon Sep 17 00:00:00 2001 From: nekoLyn Date: Mon, 28 Jul 2025 16:19:19 +1000 Subject: [PATCH 2/9] add simple test --- .../aodn/ogcapi/server/features/RestApi.java | 6 +-- .../ogcapi/server/features/RestApiTest.java | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java index 031946df..50e8a5a5 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java @@ -47,10 +47,8 @@ public ResponseEntity getFeature(String collectionId, String fea } @Operation( - summary = "fetch a single feature or downloadable fields", - description = "Fetch the feature with id `featureId` in the feature collection with id `collectionId`. Use content negotiation to request HTML or GeoJSON. " + - "Special case: if featureId is 'downloadableFields', returns the downloadable fields for WFS subsetting. " + - "For downloadableFields, requires query parameters: serverUrl (WMS server URL) and layerName (layer name).", + summary = "fetch a single feature", + description = "Fetch the feature with id `featureId` in the feature collection with id `collectionId`. Use content negotiation to request HTML or GeoJSON. ", tags = {"Data"} ) @ApiResponses({@ApiResponse( diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java index 308b236a..0a0e806e 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java @@ -6,7 +6,9 @@ import au.org.aodn.ogcapi.features.model.PointGeoJSON; import au.org.aodn.ogcapi.server.BaseTestClass; import au.org.aodn.ogcapi.server.core.model.ExtendedCollections; +import au.org.aodn.ogcapi.server.core.model.ErrorResponse; import au.org.aodn.ogcapi.server.core.model.enumeration.FeatureProperty; +import au.org.aodn.ogcapi.server.features.model.DownloadableField; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; @@ -576,4 +578,45 @@ public void verifyAggregationFeatureSummaryWithPageCorrect() throws IOException )); assertEquals(featureGeoJSON2, sf.get(1), "featureGeoJSON2"); } + + @Test + public void testDownloadableFieldsNotFound() { + // Test with invalid layer name - should return 404 + ResponseEntity response = testRestTemplate.getForEntity( + getBasePath() + "/collections/test-collection/items/downloadableFields?serverUrl=https://invalid-server.com/wfs&layerName=invalid:layer", + ErrorResponse.class + ); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode(), "Should return 404 for invalid layer"); + assertNotNull(response.getBody(), "Error response body should not be null"); + assertTrue(response.getBody().getMessage().contains("No downloadable fields found"), + "Error message should indicate no fields found"); + } + + @Test + public void testDownloadableFieldsMissingParameters() { + // Test with missing serverUrl parameter - should return 400 + ResponseEntity response = testRestTemplate.getForEntity( + getBasePath() + "/collections/test-collection/items/downloadableFields?layerName=test:layer", + String.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing serverUrl"); + + // Test with missing layerName parameter - should return 400 + response = testRestTemplate.getForEntity( + getBasePath() + "/collections/test-collection/items/downloadableFields?serverUrl=https://test.com/wfs", + String.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing layerName"); + + // Test with missing both parameters - should return 400 + response = testRestTemplate.getForEntity( + getBasePath() + "/collections/test-collection/items/downloadableFields", + String.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing both parameters"); + } } From b841eedc6288a2c607ef0399c30a3a86775a3c58 Mon Sep 17 00:00:00 2001 From: nekoLyn Date: Mon, 28 Jul 2025 17:19:01 +1000 Subject: [PATCH 3/9] fix code format --- .../DownloadableFieldsNotFoundException.java | 2 +- .../aodn/ogcapi/server/features/RestApi.java | 2 +- .../ogcapi/server/features/RestServices.java | 3 +- .../features/model/DownloadableField.java | 6 ++-- .../model/WfsDescribeFeatureTypeResponse.java | 18 +++++----- .../service/DownloadableFieldsService.java | 34 +++++++++---------- .../ogcapi/server/features/RestApiTest.java | 4 +-- 7 files changed, 33 insertions(+), 36 deletions(-) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/DownloadableFieldsNotFoundException.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/DownloadableFieldsNotFoundException.java index 200eb054..293f7e7d 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/DownloadableFieldsNotFoundException.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/DownloadableFieldsNotFoundException.java @@ -8,4 +8,4 @@ public class DownloadableFieldsNotFoundException extends RuntimeException { public DownloadableFieldsNotFoundException(String message) { super(message); } -} +} diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java index 50e8a5a5..5ab01533 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java @@ -108,7 +108,7 @@ ResponseEntity getFeature( if (serverUrl == null || layerName == null) { return ResponseEntity.badRequest().build(); } - + return featuresService.getDownloadableFields(serverUrl, layerName); } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java index 65aff20e..50c8cca7 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java @@ -48,9 +48,8 @@ public ResponseEntity getCollection(String id, String sortBy) throws /** * Get downloadable fields for a layer - * * @param wfsUrl The WFS server URL - * @param typeName The WFS type name + * @param typeName The WFS type name * @return List of downloadable fields */ public ResponseEntity> getDownloadableFields(String wfsUrl, String typeName) { diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java index c978a635..7a22ae22 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java @@ -7,10 +7,10 @@ public class DownloadableField { @JsonProperty("label") private String label; - + @JsonProperty("type") private String type; - + @JsonProperty("name") private String name; -} +} diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java index 1789f17c..f368fd9f 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java @@ -12,35 +12,35 @@ @JsonIgnoreProperties(ignoreUnknown = true) @JacksonXmlRootElement(localName = "schema", namespace = "http://www.w3.org/2001/XMLSchema") public class WfsDescribeFeatureTypeResponse { - + @JacksonXmlProperty(localName = "complexType") @JacksonXmlElementWrapper(useWrapping = false) private List complexTypes; - + @Data @JsonIgnoreProperties(ignoreUnknown = true) public static class ComplexType { @JacksonXmlProperty(isAttribute = true) private String name; - + @JacksonXmlProperty(localName = "complexContent") private ComplexContent complexContent; } - + @Data @JsonIgnoreProperties(ignoreUnknown = true) public static class ComplexContent { @JacksonXmlProperty(localName = "extension") private Extension extension; } - + @Data @JsonIgnoreProperties(ignoreUnknown = true) public static class Extension { @JacksonXmlProperty(localName = "sequence") private Sequence sequence; } - + @Data @JsonIgnoreProperties(ignoreUnknown = true) public static class Sequence { @@ -48,14 +48,14 @@ public static class Sequence { @JacksonXmlElementWrapper(useWrapping = false) private List elements; } - + @Data @JsonIgnoreProperties(ignoreUnknown = true) public static class Element { @JacksonXmlProperty(isAttribute = true) private String name; - + @JacksonXmlProperty(isAttribute = true) private String type; } -} +} diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java index c9bad5ac..d434a4bf 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java @@ -26,23 +26,22 @@ public class DownloadableFieldsService { /** * Get downloadable fields for a layer - * * @param wfsUrl The WFS server URL * @param typeName The WFS type name * @return List of downloadable fields */ public List getDownloadableFields(String wfsUrl, String typeName) { log.info("Getting downloadable fields for typeName: {} from WFS: {}", typeName, wfsUrl); - + try { List fields = getFilterFieldsFromWfs(wfsUrl, typeName); - + if (fields.isEmpty()) { throw new DownloadableFieldsNotFoundException( String.format("No downloadable fields found for typeName '%s' from WFS server '%s'", typeName, wfsUrl) ); } - + return fields; } catch (Exception e) { log.error("Error getting downloadable fields for typeName: {} from WFS: {}", typeName, wfsUrl, e); @@ -68,11 +67,11 @@ private List getFilterFieldsFromWfs(String wfsUrl, String typ .queryParam("typeName", typeName) .build() .toUri(); - + log.debug("WFS DescribeFeatureType request: {}", uri); - + ResponseEntity response = restTemplate.exchange(uri, HttpMethod.GET, null, String.class); - + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { WfsDescribeFeatureTypeResponse wfsResponse = xmlMapper.readValue(response.getBody(), WfsDescribeFeatureTypeResponse.class); return convertWfsResponseToDownloadableFields(wfsResponse); @@ -81,7 +80,7 @@ private List getFilterFieldsFromWfs(String wfsUrl, String typ String.format("No downloadable fields found for typeName '%s' from WFS server '%s'", typeName, wfsUrl) ); } - + } catch (Exception e) { log.error("Error calling WFS DescribeFeatureType for typeName: {}", typeName, e); throw new DownloadableFieldsNotFoundException( @@ -97,20 +96,19 @@ private List getFilterFieldsFromWfs(String wfsUrl, String typ */ private List convertWfsResponseToDownloadableFields(WfsDescribeFeatureTypeResponse wfsResponse) { List fields = new ArrayList<>(); - + if (wfsResponse.getComplexTypes() != null) { for (WfsDescribeFeatureTypeResponse.ComplexType complexType : wfsResponse.getComplexTypes()) { - if (complexType.getComplexContent() != null && + if (complexType.getComplexContent() != null && complexType.getComplexContent().getExtension() != null && complexType.getComplexContent().getExtension().getSequence() != null) { - - List elements = - complexType.getComplexContent().getExtension().getSequence().getElements(); - + + List elements = complexType.getComplexContent().getExtension().getSequence().getElements(); + if (elements != null) { for (WfsDescribeFeatureTypeResponse.Element element : elements) { if (element.getName() != null && element.getType() != null) { - + // Add geometry fields if ("gml:GeometryPropertyType".equals(element.getType())) { DownloadableField geomField = new DownloadableField(); @@ -119,7 +117,7 @@ private List convertWfsResponseToDownloadableFields(WfsDescri geomField.setName(element.getName()); fields.add(geomField); } - + // Add date/time fields else if ("xsd:dateTime".equals(element.getType())) { DownloadableField timeField = new DownloadableField(); @@ -148,7 +146,7 @@ else if ("xsd:time".equals(element.getType())) { } } } - + return fields; } -} +} diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java index 0a0e806e..70c6f7c4 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java @@ -589,11 +589,11 @@ public void testDownloadableFieldsNotFound() { assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode(), "Should return 404 for invalid layer"); assertNotNull(response.getBody(), "Error response body should not be null"); - assertTrue(response.getBody().getMessage().contains("No downloadable fields found"), + assertTrue(response.getBody().getMessage().contains("No downloadable fields found"), "Error message should indicate no fields found"); } - @Test + @Test public void testDownloadableFieldsMissingParameters() { // Test with missing serverUrl parameter - should return 400 ResponseEntity response = testRestTemplate.getForEntity( From aab12a67c5cd76704b593d9da32d7bbca2077ee6 Mon Sep 17 00:00:00 2001 From: nekoLyn Date: Tue, 29 Jul 2025 10:24:22 +1000 Subject: [PATCH 4/9] add allowed wfs-server url list --- .../exception/GlobalExceptionHandler.java | 12 ++++++ .../UnauthorizedServerException.java | 11 +++++ .../features/config/WfsServerConfig.java | 40 +++++++++++++++++++ .../service/DownloadableFieldsService.java | 14 ++++++- .../ogcapi/server/features/RestApiTest.java | 14 +++++++ 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 server/src/main/java/au/org/aodn/ogcapi/server/core/exception/UnauthorizedServerException.java create mode 100644 server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/GlobalExceptionHandler.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/GlobalExceptionHandler.java index 376a877f..47bedba7 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/GlobalExceptionHandler.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/GlobalExceptionHandler.java @@ -52,6 +52,18 @@ public ResponseEntity handleDownloadableFieldsNotFoundException(D return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); } + @ExceptionHandler(UnauthorizedServerException.class) + public ResponseEntity handleUnauthorizedServerException(UnauthorizedServerException ex, WebRequest request) { + ErrorResponse errorResponse = ErrorResponse + .builder() + .timestamp(LocalDateTime.now()) + .message(ex.getMessage()) + .details(request.getDescription(false)) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN); + } + @ExceptionHandler(Exception.class) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/UnauthorizedServerException.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/UnauthorizedServerException.java new file mode 100644 index 00000000..fa3cf965 --- /dev/null +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/UnauthorizedServerException.java @@ -0,0 +1,11 @@ +package au.org.aodn.ogcapi.server.core.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.FORBIDDEN) +public class UnauthorizedServerException extends RuntimeException { + public UnauthorizedServerException(String message) { + super(message); + } +} diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java new file mode 100644 index 00000000..c790fb5f --- /dev/null +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java @@ -0,0 +1,40 @@ +package au.org.aodn.ogcapi.server.features.config; + +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class WfsServerConfig { + // Hardcoded wfs server URLs to avoid SSRF attack and ensure only known servers are used. + private final List urls = List.of( + "https://geoserver.imas.utas.edu.au/geoserver/wfs", + "https://geoserver-123.aodn.org.au/geoserver/wfs", + "https://www.cmar.csiro.au/geoserver/wfs", + "https://geoserver.apps.aims.gov.au/aims/wfs" + ); + + public List getUrls() { + return urls; + } + + public boolean isAllowed(String serverUrl) { + if (serverUrl == null) { + return false; + } + + // Normalize the URL by removing trailing slashes and query parameters + String normalizedUrl = serverUrl.trim(); + if (normalizedUrl.endsWith("/")) { + normalizedUrl = normalizedUrl.substring(0, normalizedUrl.length() - 1); + } + + // Remove query parameters if present + int queryIndex = normalizedUrl.indexOf('?'); + if (queryIndex != -1) { + normalizedUrl = normalizedUrl.substring(0, queryIndex); + } + + return urls.contains(normalizedUrl); + } +} diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java index d434a4bf..69241e83 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java @@ -1,6 +1,8 @@ package au.org.aodn.ogcapi.server.features.service; import au.org.aodn.ogcapi.server.core.exception.DownloadableFieldsNotFoundException; +import au.org.aodn.ogcapi.server.core.exception.UnauthorizedServerException; +import au.org.aodn.ogcapi.server.features.config.WfsServerConfig; import au.org.aodn.ogcapi.server.features.model.*; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import lombok.extern.slf4j.Slf4j; @@ -22,6 +24,9 @@ public class DownloadableFieldsService { @Autowired private RestTemplate restTemplate; + @Autowired + private WfsServerConfig wfsServerConfig; + private final XmlMapper xmlMapper = new XmlMapper(); /** @@ -30,9 +35,16 @@ public class DownloadableFieldsService { * @param typeName The WFS type name * @return List of downloadable fields */ - public List getDownloadableFields(String wfsUrl, String typeName) { + public List getDownloadableFields(String wfsUrl, String typeName) { log.info("Getting downloadable fields for typeName: {} from WFS: {}", typeName, wfsUrl); + // Validate WFS server URL against whitelist + if (!wfsServerConfig.isAllowed(wfsUrl)) { + throw new UnauthorizedServerException( + String.format("Access to WFS server '%s' is not authorized. Only approved servers are allowed.", wfsUrl) + ); + } + try { List fields = getFilterFieldsFromWfs(wfsUrl, typeName); diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java index 70c6f7c4..b2afc0e3 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java @@ -619,4 +619,18 @@ public void testDownloadableFieldsMissingParameters() { assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing both parameters"); } + + @Test + public void testDownloadableFieldsUnauthorizedServer() { + // Test with unauthorized server URL - should return 403 Forbidden + ResponseEntity response = testRestTemplate.getForEntity( + getBasePath() + "/collections/test-collection/items/downloadableFields?serverUrl=https://wrong-server.com/wfs&layerName=test:layer", + ErrorResponse.class + ); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode(), "Should return 403 for unauthorized server"); + assertNotNull(response.getBody(), "Error response body should not be null"); + assertTrue(response.getBody().getMessage().contains("not authorized"), + "Error message should indicate server is not authorized"); + } } From cba90d77c10dd749dbc07bb659aeac0a9bbb4635 Mon Sep 17 00:00:00 2001 From: nekoLyn Date: Tue, 29 Jul 2025 13:12:07 +1000 Subject: [PATCH 5/9] fix test and refactor code --- .../aodn/ogcapi/server/features/RestApi.java | 5 - .../features/config/WfsServerConfig.java | 4 - .../service/DownloadableFieldsService.java | 65 ++--- .../DownloadableFieldsServiceTest.java | 224 ++++++++++++++++++ .../ogcapi/server/features/RestApiTest.java | 57 ----- 5 files changed, 258 insertions(+), 97 deletions(-) create mode 100644 server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java index 5ab01533..bd4b6704 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java @@ -3,11 +3,9 @@ import au.org.aodn.ogcapi.features.api.CollectionsApi; import au.org.aodn.ogcapi.features.model.*; import au.org.aodn.ogcapi.features.model.Exception; -import au.org.aodn.ogcapi.server.core.mapper.StacToFeatureCollection; import au.org.aodn.ogcapi.server.core.model.enumeration.CQLFields; import au.org.aodn.ogcapi.server.core.model.enumeration.FeatureId; import au.org.aodn.ogcapi.server.core.service.OGCApiService; -import au.org.aodn.ogcapi.server.features.model.DownloadableField; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -32,9 +30,6 @@ public class RestApi implements CollectionsApi { @Autowired protected RestServices featuresService; - @Autowired - protected StacToFeatureCollection stacToFeatureCollection; - @Override public ResponseEntity describeCollection(String collectionId) { return featuresService.getCollection(collectionId, null); diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java index c790fb5f..508b9bae 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java @@ -14,10 +14,6 @@ public class WfsServerConfig { "https://geoserver.apps.aims.gov.au/aims/wfs" ); - public List getUrls() { - return urls; - } - public boolean isAllowed(String serverUrl) { if (serverUrl == null) { return false; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java index 69241e83..94c787aa 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java @@ -120,37 +120,9 @@ private List convertWfsResponseToDownloadableFields(WfsDescri if (elements != null) { for (WfsDescribeFeatureTypeResponse.Element element : elements) { if (element.getName() != null && element.getType() != null) { - - // Add geometry fields - if ("gml:GeometryPropertyType".equals(element.getType())) { - DownloadableField geomField = new DownloadableField(); - geomField.setLabel(element.getName()); - geomField.setType("geometrypropertytype"); - geomField.setName(element.getName()); - fields.add(geomField); - } - - // Add date/time fields - else if ("xsd:dateTime".equals(element.getType())) { - DownloadableField timeField = new DownloadableField(); - timeField.setLabel(element.getName()); - timeField.setType("dateTime"); - timeField.setName(element.getName()); - fields.add(timeField); - } - else if ("xsd:date".equals(element.getType())) { - DownloadableField dateField = new DownloadableField(); - dateField.setLabel(element.getName()); - dateField.setType("date"); - dateField.setName(element.getName()); - fields.add(dateField); - } - else if ("xsd:time".equals(element.getType())) { - DownloadableField timeField = new DownloadableField(); - timeField.setLabel(element.getName()); - timeField.setType("time"); - timeField.setName(element.getName()); - fields.add(timeField); + DownloadableField field = createDownloadableField(element); + if (field != null) { + fields.add(field); } } } @@ -161,4 +133,35 @@ else if ("xsd:time".equals(element.getType())) { return fields; } + + /** + * Create a downloadable field based on the element type + */ + private DownloadableField createDownloadableField(WfsDescribeFeatureTypeResponse.Element element) { + String elementType = element.getType(); + if (elementType == null) { + return null; + } + + DownloadableField field = new DownloadableField(); + field.setLabel(element.getName()); + field.setName(element.getName()); + + switch (elementType) { + case "gml:GeometryPropertyType": + field.setType("geometrypropertytype"); + return field; + case "xsd:dateTime": + field.setType("dateTime"); + return field; + case "xsd:date": + field.setType("date"); + return field; + case "xsd:time": + field.setType("time"); + return field; + default: + return null; // Ignore other types + } + } } diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java new file mode 100644 index 00000000..bdf72ac5 --- /dev/null +++ b/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java @@ -0,0 +1,224 @@ +package au.org.aodn.ogcapi.server.features; + +import au.org.aodn.ogcapi.server.core.exception.DownloadableFieldsNotFoundException; +import au.org.aodn.ogcapi.server.core.exception.UnauthorizedServerException; +import au.org.aodn.ogcapi.server.features.config.WfsServerConfig; +import au.org.aodn.ogcapi.server.features.model.DownloadableField; +import au.org.aodn.ogcapi.server.features.service.DownloadableFieldsService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DownloadableFieldsServiceTest { + + @Mock + private RestTemplate restTemplate; + + @Mock + private WfsServerConfig wfsServerConfig; + + @Mock + private RestServices restServices; + + @InjectMocks + private DownloadableFieldsService downloadableFieldsService; + + @InjectMocks + private RestApi restApi; + + private static final String AUTHORIZED_SERVER = "https://geoserver-123.aodn.org.au/geoserver/wfs"; + private static final String UNAUTHORIZED_SERVER = "https://unauthorized-server.com/wfs"; + + + @Test + public void testGetDownloadableFieldsSuccess() { + // Mock successful WFS response with geometry and datetime fields + String mockWfsResponse = """ + + + + + + + + + + + + + + + """; + when(wfsServerConfig.isAllowed(AUTHORIZED_SERVER)).thenReturn(true); + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) + .thenReturn(new ResponseEntity<>(mockWfsResponse, HttpStatus.OK)); + + List result = downloadableFieldsService.getDownloadableFields(AUTHORIZED_SERVER, "test:layer"); + + assertNotNull(result); + assertEquals(2, result.size()); + + // Check geometry field + DownloadableField geomField = result.stream() + .filter(f -> "geom".equals(f.getName())) + .findFirst() + .orElse(null); + assertNotNull(geomField); + assertEquals("geom", geomField.getLabel()); + assertEquals("geometrypropertytype", geomField.getType()); + + // Check datetime field + DownloadableField timeField = result.stream() + .filter(f -> "timestamp".equals(f.getName())) + .findFirst() + .orElse(null); + assertNotNull(timeField); + assertEquals("timestamp", timeField.getLabel()); + assertEquals("dateTime", timeField.getType()); + } + + @Test + public void testGetDownloadableFieldsEmptyResponse() { + // Mock WFS response with no geometry or datetime fields + String mockWfsResponse = """ + + + + + + + + + + + + + + """; + when(wfsServerConfig.isAllowed(AUTHORIZED_SERVER)).thenReturn(true); + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) + .thenReturn(new ResponseEntity<>(mockWfsResponse, HttpStatus.OK)); + + DownloadableFieldsNotFoundException exception = assertThrows( + DownloadableFieldsNotFoundException.class, + () -> downloadableFieldsService.getDownloadableFields(AUTHORIZED_SERVER, "test:layer") + ); + + assertTrue(exception.getMessage().contains("No downloadable fields found")); + assertTrue(exception.getMessage().contains("test:layer")); + } + + @Test + public void testGetDownloadableFieldsUnauthorizedServer() { + when(wfsServerConfig.isAllowed(UNAUTHORIZED_SERVER)).thenReturn(false); + UnauthorizedServerException exception = assertThrows( + UnauthorizedServerException.class, + () -> downloadableFieldsService.getDownloadableFields(UNAUTHORIZED_SERVER, "test:layer") + ); + + assertTrue(exception.getMessage().contains("not authorized")); + assertTrue(exception.getMessage().contains(UNAUTHORIZED_SERVER)); + } + + @Test + public void testGetDownloadableFieldsWfsError() { + when(wfsServerConfig.isAllowed(AUTHORIZED_SERVER)).thenReturn(true); + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) + .thenReturn(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + + DownloadableFieldsNotFoundException exception = assertThrows( + DownloadableFieldsNotFoundException.class, + () -> downloadableFieldsService.getDownloadableFields(AUTHORIZED_SERVER, "invalid:layer") + ); + + assertTrue(exception.getMessage().contains("No downloadable fields found")); + } + + @Test + public void testGetDownloadableFieldsNetworkError() { + when(wfsServerConfig.isAllowed(AUTHORIZED_SERVER)).thenReturn(true); + // Mock network error + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) + .thenThrow(new RuntimeException("Connection timeout")); + + DownloadableFieldsNotFoundException exception = assertThrows( + DownloadableFieldsNotFoundException.class, + () -> downloadableFieldsService.getDownloadableFields(AUTHORIZED_SERVER, "test:layer") + ); + + assertTrue(exception.getMessage().contains("No downloadable fields found")); + } + + @Test + public void testRestApiDownloadableFieldsMissingServerUrl() { + // Test with missing serverUrl parameter - should return 400 + ResponseEntity response = restApi.getFeature( + "test-collection", + "downloadableFields", + null, // properties + null, // bbox + null, // datetime + null, // serverUrl - missing + "test:layer" // layerName + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing serverUrl"); + } + + @Test + public void testRestApiDownloadableFieldsMissingLayerName() { + // Test with missing layerName parameter - should return 400 + ResponseEntity response = restApi.getFeature( + "test-collection", + "downloadableFields", + null, // properties + null, // bbox + null, // datetime + "https://test.com/wfs", // serverUrl + null // layerName - missing + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing layerName"); + } + + @Test + public void testRestApiDownloadableFieldsUnauthorizedServer() throws Exception { + // Mock the service to throw UnauthorizedServerException + when(restServices.getDownloadableFields(any(), any())) + .thenThrow(new UnauthorizedServerException("Access to WFS server 'https://unauthorized-server.com/wfs' is not authorized")); + + // This should propagate the exception (not catch it) + assertThrows(UnauthorizedServerException.class, () -> { + restApi.getFeature( + "test-collection", + "downloadableFields", + null, // properties + null, // bbox + null, // datetime + "https://unauthorized-server.com/wfs", // unauthorized serverUrl + "test:layer" // layerName + ); + }); + } +} diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java index b2afc0e3..308b236a 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/features/RestApiTest.java @@ -6,9 +6,7 @@ import au.org.aodn.ogcapi.features.model.PointGeoJSON; import au.org.aodn.ogcapi.server.BaseTestClass; import au.org.aodn.ogcapi.server.core.model.ExtendedCollections; -import au.org.aodn.ogcapi.server.core.model.ErrorResponse; import au.org.aodn.ogcapi.server.core.model.enumeration.FeatureProperty; -import au.org.aodn.ogcapi.server.features.model.DownloadableField; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; @@ -578,59 +576,4 @@ public void verifyAggregationFeatureSummaryWithPageCorrect() throws IOException )); assertEquals(featureGeoJSON2, sf.get(1), "featureGeoJSON2"); } - - @Test - public void testDownloadableFieldsNotFound() { - // Test with invalid layer name - should return 404 - ResponseEntity response = testRestTemplate.getForEntity( - getBasePath() + "/collections/test-collection/items/downloadableFields?serverUrl=https://invalid-server.com/wfs&layerName=invalid:layer", - ErrorResponse.class - ); - - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode(), "Should return 404 for invalid layer"); - assertNotNull(response.getBody(), "Error response body should not be null"); - assertTrue(response.getBody().getMessage().contains("No downloadable fields found"), - "Error message should indicate no fields found"); - } - - @Test - public void testDownloadableFieldsMissingParameters() { - // Test with missing serverUrl parameter - should return 400 - ResponseEntity response = testRestTemplate.getForEntity( - getBasePath() + "/collections/test-collection/items/downloadableFields?layerName=test:layer", - String.class - ); - - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing serverUrl"); - - // Test with missing layerName parameter - should return 400 - response = testRestTemplate.getForEntity( - getBasePath() + "/collections/test-collection/items/downloadableFields?serverUrl=https://test.com/wfs", - String.class - ); - - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing layerName"); - - // Test with missing both parameters - should return 400 - response = testRestTemplate.getForEntity( - getBasePath() + "/collections/test-collection/items/downloadableFields", - String.class - ); - - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing both parameters"); - } - - @Test - public void testDownloadableFieldsUnauthorizedServer() { - // Test with unauthorized server URL - should return 403 Forbidden - ResponseEntity response = testRestTemplate.getForEntity( - getBasePath() + "/collections/test-collection/items/downloadableFields?serverUrl=https://wrong-server.com/wfs&layerName=test:layer", - ErrorResponse.class - ); - - assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode(), "Should return 403 for unauthorized server"); - assertNotNull(response.getBody(), "Error response body should not be null"); - assertTrue(response.getBody().getMessage().contains("not authorized"), - "Error message should indicate server is not authorized"); - } } From e593571f53986394c5d45621f80846490916b7b9 Mon Sep 17 00:00:00 2001 From: nekoLyn Date: Tue, 29 Jul 2025 13:48:05 +1000 Subject: [PATCH 6/9] fix SSFR and refactor code --- .../service/DownloadableFieldsService.java | 63 ++++++++----------- 1 file changed, 25 insertions(+), 38 deletions(-) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java index 94c787aa..9058cce3 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java @@ -16,6 +16,9 @@ import java.net.URI; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; @Slf4j @Service @@ -38,13 +41,6 @@ public class DownloadableFieldsService { public List getDownloadableFields(String wfsUrl, String typeName) { log.info("Getting downloadable fields for typeName: {} from WFS: {}", typeName, wfsUrl); - // Validate WFS server URL against whitelist - if (!wfsServerConfig.isAllowed(wfsUrl)) { - throw new UnauthorizedServerException( - String.format("Access to WFS server '%s' is not authorized. Only approved servers are allowed.", wfsUrl) - ); - } - try { List fields = getFilterFieldsFromWfs(wfsUrl, typeName); @@ -65,13 +61,18 @@ public List getDownloadableFields(String wfsUrl, String typeN - - /** * Get filter fields from WFS DescribeFeatureType */ private List getFilterFieldsFromWfs(String wfsUrl, String typeName) { try { + // SSRF protection: validate URL before making HTTP request + if (!wfsServerConfig.isAllowed(wfsUrl)) { + throw new UnauthorizedServerException( + String.format("Access to WFS server '%s' is not authorized. Only approved servers are allowed.", wfsUrl) + ); + } + URI uri = UriComponentsBuilder.fromUriString(wfsUrl) .queryParam("service", "WFS") .queryParam("version", "1.0.0") @@ -80,8 +81,7 @@ private List getFilterFieldsFromWfs(String wfsUrl, String typ .build() .toUri(); - log.debug("WFS DescribeFeatureType request: {}", uri); - + // SSRF Protection: Only make request to pre-approved WFS servers ResponseEntity response = restTemplate.exchange(uri, HttpMethod.GET, null, String.class); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { @@ -92,7 +92,6 @@ private List getFilterFieldsFromWfs(String wfsUrl, String typ String.format("No downloadable fields found for typeName '%s' from WFS server '%s'", typeName, wfsUrl) ); } - } catch (Exception e) { log.error("Error calling WFS DescribeFeatureType for typeName: {}", typeName, e); throw new DownloadableFieldsNotFoundException( @@ -102,36 +101,24 @@ private List getFilterFieldsFromWfs(String wfsUrl, String typ } - /** * Convert WFS response to downloadable fields (geometry and date/time fields) */ private List convertWfsResponseToDownloadableFields(WfsDescribeFeatureTypeResponse wfsResponse) { - List fields = new ArrayList<>(); - - if (wfsResponse.getComplexTypes() != null) { - for (WfsDescribeFeatureTypeResponse.ComplexType complexType : wfsResponse.getComplexTypes()) { - if (complexType.getComplexContent() != null && - complexType.getComplexContent().getExtension() != null && - complexType.getComplexContent().getExtension().getSequence() != null) { - - List elements = complexType.getComplexContent().getExtension().getSequence().getElements(); - - if (elements != null) { - for (WfsDescribeFeatureTypeResponse.Element element : elements) { - if (element.getName() != null && element.getType() != null) { - DownloadableField field = createDownloadableField(element); - if (field != null) { - fields.add(field); - } - } - } - } - } - } - } - - return fields; + return wfsResponse.getComplexTypes() != null ? + wfsResponse.getComplexTypes().stream() + .filter(complexType -> complexType.getComplexContent() != null) + .filter(complexType -> complexType.getComplexContent().getExtension() != null) + .filter(complexType -> complexType.getComplexContent().getExtension().getSequence() != null) + .flatMap(complexType -> { + List elements = + complexType.getComplexContent().getExtension().getSequence().getElements(); + return elements != null ? elements.stream() : Stream.empty(); + }) + .filter(element -> element.getName() != null && element.getType() != null) + .map(this::createDownloadableField) + .filter(Objects::nonNull) + .collect(Collectors.toList()) : new ArrayList<>(); } /** From 3a6fc61060405e537907524db6aabf74e728a7bb Mon Sep 17 00:00:00 2001 From: nekoLyn Date: Tue, 29 Jul 2025 14:50:37 +1000 Subject: [PATCH 7/9] improve way to address SSFR --- .../features/config/WfsServerConfig.java | 30 ++++++++++++++-- .../service/DownloadableFieldsService.java | 22 ++++++------ .../DownloadableFieldsServiceTest.java | 34 ++++--------------- 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java index 508b9bae..b46e80d2 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java @@ -6,7 +6,6 @@ @Configuration public class WfsServerConfig { - // Hardcoded wfs server URLs to avoid SSRF attack and ensure only known servers are used. private final List urls = List.of( "https://geoserver.imas.utas.edu.au/geoserver/wfs", "https://geoserver-123.aodn.org.au/geoserver/wfs", @@ -18,6 +17,13 @@ public boolean isAllowed(String serverUrl) { if (serverUrl == null) { return false; } + return urls.contains(normalizeUrl(serverUrl)); + } + + public String normalizeUrl(String serverUrl) { + if (serverUrl == null) { + return null; + } // Normalize the URL by removing trailing slashes and query parameters String normalizedUrl = serverUrl.trim(); @@ -31,6 +37,26 @@ public boolean isAllowed(String serverUrl) { normalizedUrl = normalizedUrl.substring(0, queryIndex); } - return urls.contains(normalizedUrl); + return normalizedUrl; + } + + /** + * SSRF Protection: Validate user input against whitelist and return approved URL + * This ensures no user input is directly used in HTTP requests + */ + public String validateAndGetApprovedServerUrl(String userProvidedUrl) { + if (!isAllowed(userProvidedUrl)) { + throw new au.org.aodn.ogcapi.server.core.exception.UnauthorizedServerException( + String.format("Access to WFS server '%s' is not authorized. Only approved servers are allowed.", userProvidedUrl) + ); + } + + // Return the exact approved URL from our whitelist, not user input + return urls.stream() + .filter(approvedUrl -> normalizeUrl(userProvidedUrl).equals(normalizeUrl(approvedUrl))) + .findFirst() + .orElseThrow(() -> new au.org.aodn.ogcapi.server.core.exception.UnauthorizedServerException( + String.format("Access to WFS server '%s' is not authorized. Only approved servers are allowed.", userProvidedUrl) + )); } } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java index 9058cce3..7f344f62 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java @@ -51,6 +51,8 @@ public List getDownloadableFields(String wfsUrl, String typeN } return fields; + } catch (UnauthorizedServerException e) { + throw e; } catch (Exception e) { log.error("Error getting downloadable fields for typeName: {} from WFS: {}", typeName, wfsUrl, e); throw new DownloadableFieldsNotFoundException( @@ -61,19 +63,16 @@ public List getDownloadableFields(String wfsUrl, String typeN - /** + /** * Get filter fields from WFS DescribeFeatureType */ private List getFilterFieldsFromWfs(String wfsUrl, String typeName) { - try { - // SSRF protection: validate URL before making HTTP request - if (!wfsServerConfig.isAllowed(wfsUrl)) { - throw new UnauthorizedServerException( - String.format("Access to WFS server '%s' is not authorized. Only approved servers are allowed.", wfsUrl) - ); - } + // SSRF protection: Only use pre-approved server URLs - no user input in URL construction + String validatedServerUrl = wfsServerConfig.validateAndGetApprovedServerUrl(wfsUrl); - URI uri = UriComponentsBuilder.fromUriString(wfsUrl) + try { + // Build URI using only validated/approved server URL + URI uri = UriComponentsBuilder.fromUriString(validatedServerUrl) .queryParam("service", "WFS") .queryParam("version", "1.0.0") .queryParam("request", "DescribeFeatureType") @@ -81,7 +80,7 @@ private List getFilterFieldsFromWfs(String wfsUrl, String typ .build() .toUri(); - // SSRF Protection: Only make request to pre-approved WFS servers + // Safe HTTP request - URL is from approved whitelist only ResponseEntity response = restTemplate.exchange(uri, HttpMethod.GET, null, String.class); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { @@ -92,6 +91,9 @@ private List getFilterFieldsFromWfs(String wfsUrl, String typ String.format("No downloadable fields found for typeName '%s' from WFS server '%s'", typeName, wfsUrl) ); } + + } catch (UnauthorizedServerException e) { + throw e; } catch (Exception e) { log.error("Error calling WFS DescribeFeatureType for typeName: {}", typeName, e); throw new DownloadableFieldsNotFoundException( diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java index bdf72ac5..1f00770e 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java @@ -32,9 +32,6 @@ public class DownloadableFieldsServiceTest { @Mock private WfsServerConfig wfsServerConfig; - @Mock - private RestServices restServices; - @InjectMocks private DownloadableFieldsService downloadableFieldsService; @@ -67,7 +64,7 @@ public void testGetDownloadableFieldsSuccess() { """; - when(wfsServerConfig.isAllowed(AUTHORIZED_SERVER)).thenReturn(true); + when(wfsServerConfig.validateAndGetApprovedServerUrl(AUTHORIZED_SERVER)).thenReturn(AUTHORIZED_SERVER); when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) .thenReturn(new ResponseEntity<>(mockWfsResponse, HttpStatus.OK)); @@ -116,7 +113,7 @@ public void testGetDownloadableFieldsEmptyResponse() { """; - when(wfsServerConfig.isAllowed(AUTHORIZED_SERVER)).thenReturn(true); + when(wfsServerConfig.validateAndGetApprovedServerUrl(AUTHORIZED_SERVER)).thenReturn(AUTHORIZED_SERVER); when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) .thenReturn(new ResponseEntity<>(mockWfsResponse, HttpStatus.OK)); @@ -131,7 +128,8 @@ public void testGetDownloadableFieldsEmptyResponse() { @Test public void testGetDownloadableFieldsUnauthorizedServer() { - when(wfsServerConfig.isAllowed(UNAUTHORIZED_SERVER)).thenReturn(false); + when(wfsServerConfig.validateAndGetApprovedServerUrl(UNAUTHORIZED_SERVER)) + .thenThrow(new UnauthorizedServerException("Access to WFS server '" + UNAUTHORIZED_SERVER + "' is not authorized")); UnauthorizedServerException exception = assertThrows( UnauthorizedServerException.class, () -> downloadableFieldsService.getDownloadableFields(UNAUTHORIZED_SERVER, "test:layer") @@ -143,7 +141,7 @@ public void testGetDownloadableFieldsUnauthorizedServer() { @Test public void testGetDownloadableFieldsWfsError() { - when(wfsServerConfig.isAllowed(AUTHORIZED_SERVER)).thenReturn(true); + when(wfsServerConfig.validateAndGetApprovedServerUrl(AUTHORIZED_SERVER)).thenReturn(AUTHORIZED_SERVER); when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) .thenReturn(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); @@ -157,7 +155,7 @@ public void testGetDownloadableFieldsWfsError() { @Test public void testGetDownloadableFieldsNetworkError() { - when(wfsServerConfig.isAllowed(AUTHORIZED_SERVER)).thenReturn(true); + when(wfsServerConfig.validateAndGetApprovedServerUrl(AUTHORIZED_SERVER)).thenReturn(AUTHORIZED_SERVER); // Mock network error when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) .thenThrow(new RuntimeException("Connection timeout")); @@ -201,24 +199,4 @@ public void testRestApiDownloadableFieldsMissingLayerName() { assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing layerName"); } - - @Test - public void testRestApiDownloadableFieldsUnauthorizedServer() throws Exception { - // Mock the service to throw UnauthorizedServerException - when(restServices.getDownloadableFields(any(), any())) - .thenThrow(new UnauthorizedServerException("Access to WFS server 'https://unauthorized-server.com/wfs' is not authorized")); - - // This should propagate the exception (not catch it) - assertThrows(UnauthorizedServerException.class, () -> { - restApi.getFeature( - "test-collection", - "downloadableFields", - null, // properties - null, // bbox - null, // datetime - "https://unauthorized-server.com/wfs", // unauthorized serverUrl - "test:layer" // layerName - ); - }); - } } From 3340481298a77d915e8f6c3f863ff39f84e5b9cf Mon Sep 17 00:00:00 2001 From: nekoLyn Date: Wed, 30 Jul 2025 15:08:48 +1000 Subject: [PATCH 8/9] improve file structure and refactor code --- .../configuration}/WfsServerConfig.java | 2 +- .../core/model/enumeration/FeatureId.java | 3 +- .../model/wfs/DownloadableFieldModel.java} | 4 +- .../wfs}/WfsDescribeFeatureTypeResponse.java | 2 +- .../wfs}/DownloadableFieldsService.java | 57 ++++++++++--------- .../aodn/ogcapi/server/features/RestApi.java | 55 +++++++++--------- .../ogcapi/server/features/RestServices.java | 8 +-- .../DownloadableFieldsServiceTest.java | 12 ++-- 8 files changed, 73 insertions(+), 70 deletions(-) rename server/src/main/java/au/org/aodn/ogcapi/server/{features/config => core/configuration}/WfsServerConfig.java (97%) rename server/src/main/java/au/org/aodn/ogcapi/server/{features/model/DownloadableField.java => core/model/wfs/DownloadableFieldModel.java} (73%) rename server/src/main/java/au/org/aodn/ogcapi/server/{features/model => core/model/wfs}/WfsDescribeFeatureTypeResponse.java (97%) rename server/src/main/java/au/org/aodn/ogcapi/server/{features/service => core/service/wfs}/DownloadableFieldsService.java (77%) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/configuration/WfsServerConfig.java similarity index 97% rename from server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java rename to server/src/main/java/au/org/aodn/ogcapi/server/core/configuration/WfsServerConfig.java index b46e80d2..9cf2627a 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/config/WfsServerConfig.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/configuration/WfsServerConfig.java @@ -1,4 +1,4 @@ -package au.org.aodn.ogcapi.server.features.config; +package au.org.aodn.ogcapi.server.core.configuration; import org.springframework.context.annotation.Configuration; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/FeatureId.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/FeatureId.java index 0af82b41..948b8eb3 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/FeatureId.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/FeatureId.java @@ -1,7 +1,8 @@ package au.org.aodn.ogcapi.server.core.model.enumeration; public enum FeatureId { - summary("summary"); + summary("summary"), + downloadableFields("downloadableFields"); private final String featureId; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/DownloadableFieldModel.java similarity index 73% rename from server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java rename to server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/DownloadableFieldModel.java index 7a22ae22..63cb0cd1 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/model/DownloadableField.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/DownloadableFieldModel.java @@ -1,10 +1,10 @@ -package au.org.aodn.ogcapi.server.features.model; +package au.org.aodn.ogcapi.server.core.model.wfs; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data -public class DownloadableField { +public class DownloadableFieldModel { @JsonProperty("label") private String label; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/WfsDescribeFeatureTypeResponse.java similarity index 97% rename from server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java rename to server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/WfsDescribeFeatureTypeResponse.java index f368fd9f..bd90acda 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/model/WfsDescribeFeatureTypeResponse.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/WfsDescribeFeatureTypeResponse.java @@ -1,4 +1,4 @@ -package au.org.aodn.ogcapi.server.features.model; +package au.org.aodn.ogcapi.server.core.model.wfs; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsService.java similarity index 77% rename from server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java rename to server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsService.java index 7f344f62..a03b014c 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/service/DownloadableFieldsService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsService.java @@ -1,12 +1,14 @@ -package au.org.aodn.ogcapi.server.features.service; +package au.org.aodn.ogcapi.server.core.service.wfs; import au.org.aodn.ogcapi.server.core.exception.DownloadableFieldsNotFoundException; import au.org.aodn.ogcapi.server.core.exception.UnauthorizedServerException; -import au.org.aodn.ogcapi.server.features.config.WfsServerConfig; -import au.org.aodn.ogcapi.server.features.model.*; +import au.org.aodn.ogcapi.server.core.configuration.WfsServerConfig; +import au.org.aodn.ogcapi.server.core.model.wfs.DownloadableFieldModel; +import au.org.aodn.ogcapi.server.core.model.wfs.WfsDescribeFeatureTypeResponse; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; @@ -32,17 +34,18 @@ public class DownloadableFieldsService { private final XmlMapper xmlMapper = new XmlMapper(); - /** + /** * Get downloadable fields for a layer * @param wfsUrl The WFS server URL * @param typeName The WFS type name * @return List of downloadable fields */ - public List getDownloadableFields(String wfsUrl, String typeName) { + @Cacheable(value = "downloadableFields", key = "#wfsUrl + ':' + #typeName") + public List getDownloadableFields(String wfsUrl, String typeName) { log.info("Getting downloadable fields for typeName: {} from WFS: {}", typeName, wfsUrl); try { - List fields = getFilterFieldsFromWfs(wfsUrl, typeName); + List fields = getFilterFieldsFromWfs(wfsUrl, typeName); if (fields.isEmpty()) { throw new DownloadableFieldsNotFoundException( @@ -62,16 +65,14 @@ public List getDownloadableFields(String wfsUrl, String typeN } - - /** + /** * Get filter fields from WFS DescribeFeatureType */ - private List getFilterFieldsFromWfs(String wfsUrl, String typeName) { - // SSRF protection: Only use pre-approved server URLs - no user input in URL construction + private List getFilterFieldsFromWfs(String wfsUrl, String typeName) { + // SSRF protection: Only use pre-approved server URLs String validatedServerUrl = wfsServerConfig.validateAndGetApprovedServerUrl(wfsUrl); try { - // Build URI using only validated/approved server URL URI uri = UriComponentsBuilder.fromUriString(validatedServerUrl) .queryParam("service", "WFS") .queryParam("version", "1.0.0") @@ -80,7 +81,6 @@ private List getFilterFieldsFromWfs(String wfsUrl, String typ .build() .toUri(); - // Safe HTTP request - URL is from approved whitelist only ResponseEntity response = restTemplate.exchange(uri, HttpMethod.GET, null, String.class); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { @@ -106,7 +106,7 @@ private List getFilterFieldsFromWfs(String wfsUrl, String typ /** * Convert WFS response to downloadable fields (geometry and date/time fields) */ - private List convertWfsResponseToDownloadableFields(WfsDescribeFeatureTypeResponse wfsResponse) { + private List convertWfsResponseToDownloadableFields(WfsDescribeFeatureTypeResponse wfsResponse) { return wfsResponse.getComplexTypes() != null ? wfsResponse.getComplexTypes().stream() .filter(complexType -> complexType.getComplexContent() != null) @@ -126,31 +126,34 @@ private List convertWfsResponseToDownloadableFields(WfsDescri /** * Create a downloadable field based on the element type */ - private DownloadableField createDownloadableField(WfsDescribeFeatureTypeResponse.Element element) { + private DownloadableFieldModel createDownloadableField(WfsDescribeFeatureTypeResponse.Element element) { String elementType = element.getType(); if (elementType == null) { return null; } - DownloadableField field = new DownloadableField(); + DownloadableFieldModel field = new DownloadableFieldModel(); field.setLabel(element.getName()); field.setName(element.getName()); - switch (elementType) { - case "gml:GeometryPropertyType": + return switch (elementType) { + case "gml:GeometryPropertyType" -> { field.setType("geometrypropertytype"); - return field; - case "xsd:dateTime": + yield field; + } + case "xsd:dateTime" -> { field.setType("dateTime"); - return field; - case "xsd:date": + yield field; + } + case "xsd:date" -> { field.setType("date"); - return field; - case "xsd:time": + yield field; + } + case "xsd:time" -> { field.setType("time"); - return field; - default: - return null; // Ignore other types - } + yield field; + } + default -> null; // Ignore other types + }; } } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java index bd4b6704..211396a5 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java @@ -88,37 +88,36 @@ ResponseEntity getFeature( @Valid @RequestParam(value = "serverUrl", required = false) String serverUrl, @Parameter(in = ParameterIn.QUERY, description = "WFS type name (required when featureId is 'downloadableFields')" ,schema=@Schema()) @Valid @RequestParam(value = "layerName", required = false) String layerName) { + FeatureId fid = FeatureId.valueOf(FeatureId.class, featureId); - String filter = null; - if (datetime != null) { - filter = OGCApiService.processDatetimeParameter(CQLFields.temporal.name(), datetime, filter); - } - - if (bbox != null) { - filter = OGCApiService.processBBoxParameter(CQLFields.geometry.name(), bbox, filter); - } + switch (fid) { + case downloadableFields: + if (serverUrl == null || layerName == null) { + return ResponseEntity.badRequest().build(); + } + return featuresService.getDownloadableFields(serverUrl, layerName); + case summary: + String filter = null; + if (datetime != null) { + filter = OGCApiService.processDatetimeParameter(CQLFields.temporal.name(), datetime, filter); + } - // Handle special case for downloadableFields - if ("downloadableFields".equals(featureId)) { - if (serverUrl == null || layerName == null) { - return ResponseEntity.badRequest().build(); - } + if (bbox != null) { + filter = OGCApiService.processBBoxParameter(CQLFields.geometry.name(), bbox, filter); + } - return featuresService.getDownloadableFields(serverUrl, layerName); - } - - try { - FeatureId fid = FeatureId.valueOf(FeatureId.class, featureId); - - return featuresService.getFeature( - collectionId, - fid, - properties, - filter != null ? "filter=" + filter : null - ); - } - catch(java.lang.Exception e) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + try { + return featuresService.getFeature( + collectionId, + fid, + properties, + filter != null ? "filter=" + filter : null + ); + } catch (java.lang.Exception e) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + default: + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); } } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java index 50c8cca7..1be26013 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java @@ -5,8 +5,8 @@ import au.org.aodn.ogcapi.server.core.model.StacCollectionModel; import au.org.aodn.ogcapi.server.core.service.ElasticSearch; import au.org.aodn.ogcapi.server.core.service.OGCApiService; -import au.org.aodn.ogcapi.server.features.model.DownloadableField; -import au.org.aodn.ogcapi.server.features.service.DownloadableFieldsService; +import au.org.aodn.ogcapi.server.core.model.wfs.DownloadableFieldModel; +import au.org.aodn.ogcapi.server.core.service.wfs.DownloadableFieldsService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; @@ -52,8 +52,8 @@ public ResponseEntity getCollection(String id, String sortBy) throws * @param typeName The WFS type name * @return List of downloadable fields */ - public ResponseEntity> getDownloadableFields(String wfsUrl, String typeName) { - List fields = downloadableFieldsService.getDownloadableFields(wfsUrl, typeName); + public ResponseEntity> getDownloadableFields(String wfsUrl, String typeName) { + List fields = downloadableFieldsService.getDownloadableFields(wfsUrl, typeName); return ResponseEntity.ok(fields); } } diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java index 1f00770e..ac31d76b 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java @@ -2,9 +2,9 @@ import au.org.aodn.ogcapi.server.core.exception.DownloadableFieldsNotFoundException; import au.org.aodn.ogcapi.server.core.exception.UnauthorizedServerException; -import au.org.aodn.ogcapi.server.features.config.WfsServerConfig; -import au.org.aodn.ogcapi.server.features.model.DownloadableField; -import au.org.aodn.ogcapi.server.features.service.DownloadableFieldsService; +import au.org.aodn.ogcapi.server.core.configuration.WfsServerConfig; +import au.org.aodn.ogcapi.server.core.model.wfs.DownloadableFieldModel; +import au.org.aodn.ogcapi.server.core.service.wfs.DownloadableFieldsService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -68,13 +68,13 @@ public void testGetDownloadableFieldsSuccess() { when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) .thenReturn(new ResponseEntity<>(mockWfsResponse, HttpStatus.OK)); - List result = downloadableFieldsService.getDownloadableFields(AUTHORIZED_SERVER, "test:layer"); + List result = downloadableFieldsService.getDownloadableFields(AUTHORIZED_SERVER, "test:layer"); assertNotNull(result); assertEquals(2, result.size()); // Check geometry field - DownloadableField geomField = result.stream() + DownloadableFieldModel geomField = result.stream() .filter(f -> "geom".equals(f.getName())) .findFirst() .orElse(null); @@ -83,7 +83,7 @@ public void testGetDownloadableFieldsSuccess() { assertEquals("geometrypropertytype", geomField.getType()); // Check datetime field - DownloadableField timeField = result.stream() + DownloadableFieldModel timeField = result.stream() .filter(f -> "timestamp".equals(f.getName())) .findFirst() .orElse(null); From 47386feca1bc3161701e2d4a481c48521c613f9f Mon Sep 17 00:00:00 2001 From: nekoLyn Date: Thu, 31 Jul 2025 10:46:24 +1000 Subject: [PATCH 9/9] refactor getFeature parameter to use FeatureRequest obj --- .../core/model/dto/wfs/FeatureRequest.java | 28 ++++++ .../wfs/WfsDescribeFeatureTypeResponse.java | 2 +- .../wfs/DownloadableFieldsService.java | 2 +- .../aodn/ogcapi/server/features/RestApi.java | 64 ++++++------ .../DownloadableFieldsServiceTest.java | 97 ++++++++++--------- 5 files changed, 114 insertions(+), 79 deletions(-) create mode 100644 server/src/main/java/au/org/aodn/ogcapi/server/core/model/dto/wfs/FeatureRequest.java rename server/src/main/java/au/org/aodn/ogcapi/server/core/model/{ => dto}/wfs/WfsDescribeFeatureTypeResponse.java (97%) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/dto/wfs/FeatureRequest.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/dto/wfs/FeatureRequest.java new file mode 100644 index 00000000..373f04e6 --- /dev/null +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/dto/wfs/FeatureRequest.java @@ -0,0 +1,28 @@ +package au.org.aodn.ogcapi.server.core.model.dto.wfs; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +@Schema(description = "Query parameters for feature requests") +@Data +@Builder +public class FeatureRequest { + @Schema(description = "Property to be return") + private List properties; + + @Schema(description = "Only records that have a geometry that intersects the bounding box are selected. The bounding box is provided as four or six numbers, depending on whether the coordinate reference system includes a vertical axis (height or depth): * Lower left corner, coordinate axis 1 * Lower left corner, coordinate axis 2 * Minimum value, coordinate axis 3 (optional) * Upper right corner, coordinate axis 1 * Upper right corner, coordinate axis 2 * Maximum value, coordinate axis 3 (optional) The coordinate reference system of the values is WGS 84 long/lat (http://www.opengis.net/def/crs/OGC/1.3/CRS84) unless a different coordinate reference system is specified in the parameter `bbox-crs`. For WGS 84 longitude/latitude the values are in most cases the sequence of minimum longitude, minimum latitude, maximum longitude and maximum latitude. However, in cases where the box spans the antimeridian the first value (west-most box edge) is larger than the third value (east-most box edge). If the vertical axis is included, the third and the sixth number are the bottom and the top of the 3-dimensional bounding box. If a record has multiple spatial geometry properties, it is the decision of the server whether only a single spatial geometry property is used to determine the extent or all relevant geometries.") + private List bbox; + + @Schema(description = "Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots. Examples: * A date-time: \"2018-02-12T23:20:50Z\" * A closed interval: \"2018-02-12T00:00:00Z/2018-03-18T12:31:12Z\" * Open intervals: \"2018-02-12T00:00:00Z/..\" or \"../2018-03-18T12:31:12Z\" Only records that have a temporal property that intersects the value of `datetime` are selected. It is left to the decision of the server whether only a single temporal property is used to determine the extent or all relevant temporal properties.") + private String datetime; + + @Schema(description = "WFS server URL (required when featureId is 'downloadableFields')") + private String serverUrl; + + @Schema(description = "WFS type name (required when featureId is 'downloadableFields')") + private String layerName; +} diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/WfsDescribeFeatureTypeResponse.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/dto/wfs/WfsDescribeFeatureTypeResponse.java similarity index 97% rename from server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/WfsDescribeFeatureTypeResponse.java rename to server/src/main/java/au/org/aodn/ogcapi/server/core/model/dto/wfs/WfsDescribeFeatureTypeResponse.java index bd90acda..8e8c60ed 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/WfsDescribeFeatureTypeResponse.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/dto/wfs/WfsDescribeFeatureTypeResponse.java @@ -1,4 +1,4 @@ -package au.org.aodn.ogcapi.server.core.model.wfs; +package au.org.aodn.ogcapi.server.core.model.dto.wfs; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsService.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsService.java index a03b014c..8230485f 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsService.java @@ -4,7 +4,7 @@ import au.org.aodn.ogcapi.server.core.exception.UnauthorizedServerException; import au.org.aodn.ogcapi.server.core.configuration.WfsServerConfig; import au.org.aodn.ogcapi.server.core.model.wfs.DownloadableFieldModel; -import au.org.aodn.ogcapi.server.core.model.wfs.WfsDescribeFeatureTypeResponse; +import au.org.aodn.ogcapi.server.core.model.dto.wfs.WfsDescribeFeatureTypeResponse; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java index 211396a5..8a9ad605 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java @@ -6,6 +6,7 @@ import au.org.aodn.ogcapi.server.core.model.enumeration.CQLFields; import au.org.aodn.ogcapi.server.core.model.enumeration.FeatureId; import au.org.aodn.ogcapi.server.core.service.OGCApiService; +import au.org.aodn.ogcapi.server.core.model.dto.wfs.FeatureRequest; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -15,6 +16,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -48,16 +50,26 @@ public ResponseEntity getFeature(String collectionId, String fea ) @ApiResponses({@ApiResponse( responseCode = "200", - description = "fetch the feature with id `featureId` in the feature collection with id `collectionId`", - content = {@Content( - mediaType = "application/geo+json", - schema = @Schema( - implementation = FeatureGeoJSON.class + description = "Successfully retrieved feature data. Response type depends on featureId: for 'downloadableFields' returns list of available fields, for 'summary' returns GeoJSON feature.", + content = { + @Content( + mediaType = "application/geo+json", + schema = @Schema(implementation = FeatureGeoJSON.class) + ), + @Content( + mediaType = "application/json", + schema = @Schema(implementation = Object.class, description = "List of DownloadableFieldModel for downloadableFields requests") ) - )} + } + ), @ApiResponse( + responseCode = "400", + description = "Bad request. Missing required parameters (serverUrl or layerName) when featureId is 'downloadableFields'." + ), @ApiResponse( + responseCode = "403", + description = "Forbidden. Access to the specified WFS server is not authorized." ), @ApiResponse( responseCode = "404", - description = "The requested resource does not exist on the server. For example, a path parameter had an incorrect value." + description = "The requested resource does not exist on the server. For example, a path parameter had an incorrect value or no downloadable fields found." ), @ApiResponse( responseCode = "500", description = "A server error occurred.", @@ -74,43 +86,34 @@ public ResponseEntity getFeature(String collectionId, String fea method = {RequestMethod.GET} ) ResponseEntity getFeature( - @Parameter(in = ParameterIn.PATH,description = "local identifier of a collection",required = true,schema = @Schema) + @Parameter(in = ParameterIn.PATH, description = "local identifier of a collection", required = true, schema = @Schema) @PathVariable("collectionId") String collectionId, - @Parameter(in = ParameterIn.PATH,description = "local identifier of a feature",required = true,schema = @Schema) + @Parameter(in = ParameterIn.PATH, description = "local identifier of a feature", required = true, schema = @Schema) @PathVariable("featureId") String featureId, - @Parameter(in = ParameterIn.QUERY, description = "Property to be return" ,schema=@Schema()) - @Valid @RequestParam(value = "properties", required = false) List properties, - @Parameter(in = ParameterIn.QUERY, description = "Only records that have a geometry that intersects the bounding box are selected. The bounding box is provided as four or six numbers, depending on whether the coordinate reference system includes a vertical axis (height or depth): * Lower left corner, coordinate axis 1 * Lower left corner, coordinate axis 2 * Minimum value, coordinate axis 3 (optional) * Upper right corner, coordinate axis 1 * Upper right corner, coordinate axis 2 * Maximum value, coordinate axis 3 (optional) The coordinate reference system of the values is WGS 84 long/lat (http://www.opengis.net/def/crs/OGC/1.3/CRS84) unless a different coordinate reference system is specified in the parameter `bbox-crs`. For WGS 84 longitude/latitude the values are in most cases the sequence of minimum longitude, minimum latitude, maximum longitude and maximum latitude. However, in cases where the box spans the antimeridian the first value (west-most box edge) is larger than the third value (east-most box edge). If the vertical axis is included, the third and the sixth number are the bottom and the top of the 3-dimensional bounding box. If a record has multiple spatial geometry properties, it is the decision of the server whether only a single spatial geometry property is used to determine the extent or all relevant geometries." ,schema=@Schema()) - @Valid @RequestParam(value = "bbox", required = false) List bbox, - @Parameter(in = ParameterIn.QUERY, description = "Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots. Examples: * A date-time: \"2018-02-12T23:20:50Z\" * A closed interval: \"2018-02-12T00:00:00Z/2018-03-18T12:31:12Z\" * Open intervals: \"2018-02-12T00:00:00Z/..\" or \"../2018-03-18T12:31:12Z\" Only records that have a temporal property that intersects the value of `datetime` are selected. It is left to the decision of the server whether only a single temporal property is used to determine the extent or all relevant temporal properties." ,schema=@Schema()) - @Valid @RequestParam(value = "datetime", required = false) String datetime, - @Parameter(in = ParameterIn.QUERY, description = "WFS server URL (required when featureId is 'downloadableFields')" ,schema=@Schema()) - @Valid @RequestParam(value = "serverUrl", required = false) String serverUrl, - @Parameter(in = ParameterIn.QUERY, description = "WFS type name (required when featureId is 'downloadableFields')" ,schema=@Schema()) - @Valid @RequestParam(value = "layerName", required = false) String layerName) { + @ParameterObject @Valid FeatureRequest request) { FeatureId fid = FeatureId.valueOf(FeatureId.class, featureId); switch (fid) { case downloadableFields: - if (serverUrl == null || layerName == null) { + if (request.getServerUrl() == null || request.getLayerName() == null) { return ResponseEntity.badRequest().build(); } - return featuresService.getDownloadableFields(serverUrl, layerName); + return featuresService.getDownloadableFields(request.getServerUrl(), request.getLayerName()); case summary: String filter = null; - if (datetime != null) { - filter = OGCApiService.processDatetimeParameter(CQLFields.temporal.name(), datetime, filter); + if (request.getDatetime() != null) { + filter = OGCApiService.processDatetimeParameter(CQLFields.temporal.name(), request.getDatetime(), filter); } - if (bbox != null) { - filter = OGCApiService.processBBoxParameter(CQLFields.geometry.name(), bbox, filter); + if (request.getBbox() != null) { + filter = OGCApiService.processBBoxParameter(CQLFields.geometry.name(), request.getBbox(), filter); } try { return featuresService.getFeature( collectionId, fid, - properties, + request.getProperties(), filter != null ? "filter=" + filter : null ); } catch (java.lang.Exception e) { @@ -122,20 +125,21 @@ ResponseEntity getFeature( } /** - * * @param collectionId - The collection id - * @param limit - Limit of result return - * @param bbox - Bounding box that bounds the result set. In case of multiple bounding box, you need to issue multiple query - * @param datetime - Start/end time + * @param limit - Limit of result return + * @param bbox - Bounding box that bounds the result set. In case of multiple bounding box, you need to issue multiple query + * @param datetime - Start/end time * @return - The data that matches the filter criteria */ @Override public ResponseEntity getFeatures(String collectionId, Integer limit, List bbox, String datetime) { return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); } + /** * Hidden effectively disable this REST point because it is common in many places and * should not implement it here, @Hidden disable swagger doc too + * * @return - Not implemented */ @Hidden diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java index ac31d76b..e0d8f9ee 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java @@ -5,6 +5,7 @@ import au.org.aodn.ogcapi.server.core.configuration.WfsServerConfig; import au.org.aodn.ogcapi.server.core.model.wfs.DownloadableFieldModel; import au.org.aodn.ogcapi.server.core.service.wfs.DownloadableFieldsService; +import au.org.aodn.ogcapi.server.core.model.dto.wfs.FeatureRequest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -41,29 +42,37 @@ public class DownloadableFieldsServiceTest { private static final String AUTHORIZED_SERVER = "https://geoserver-123.aodn.org.au/geoserver/wfs"; private static final String UNAUTHORIZED_SERVER = "https://unauthorized-server.com/wfs"; + // Helper method to create FeatureRequest for testing + private FeatureRequest createDownloadableFieldsRequest(String serverUrl, String layerName) { + return FeatureRequest.builder() + .serverUrl(serverUrl) + .layerName(layerName) + .build(); + } + @Test public void testGetDownloadableFieldsSuccess() { // Mock successful WFS response with geometry and datetime fields String mockWfsResponse = """ - - - - - - - - - - - - - - - """; + + + + + + + + + + + + + + + """; when(wfsServerConfig.validateAndGetApprovedServerUrl(AUTHORIZED_SERVER)).thenReturn(AUTHORIZED_SERVER); when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) .thenReturn(new ResponseEntity<>(mockWfsResponse, HttpStatus.OK)); @@ -96,23 +105,23 @@ public void testGetDownloadableFieldsSuccess() { public void testGetDownloadableFieldsEmptyResponse() { // Mock WFS response with no geometry or datetime fields String mockWfsResponse = """ - - - - - - - - - - - - - - """; + + + + + + + + + + + + + + """; when(wfsServerConfig.validateAndGetApprovedServerUrl(AUTHORIZED_SERVER)).thenReturn(AUTHORIZED_SERVER); when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(), eq(String.class))) .thenReturn(new ResponseEntity<>(mockWfsResponse, HttpStatus.OK)); @@ -170,15 +179,12 @@ public void testGetDownloadableFieldsNetworkError() { @Test public void testRestApiDownloadableFieldsMissingServerUrl() { - // Test with missing serverUrl parameter - should return 400 + FeatureRequest request = createDownloadableFieldsRequest(null, "test:layer"); + ResponseEntity response = restApi.getFeature( "test-collection", "downloadableFields", - null, // properties - null, // bbox - null, // datetime - null, // serverUrl - missing - "test:layer" // layerName + request ); assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing serverUrl"); @@ -186,15 +192,12 @@ public void testRestApiDownloadableFieldsMissingServerUrl() { @Test public void testRestApiDownloadableFieldsMissingLayerName() { - // Test with missing layerName parameter - should return 400 + FeatureRequest request = createDownloadableFieldsRequest("https://test.com/wfs", null); + ResponseEntity response = restApi.getFeature( "test-collection", "downloadableFields", - null, // properties - null, // bbox - null, // datetime - "https://test.com/wfs", // serverUrl - null // layerName - missing + request ); assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing layerName");