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/configuration/WfsServerConfig.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/configuration/WfsServerConfig.java
new file mode 100644
index 00000000..9cf2627a
--- /dev/null
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/configuration/WfsServerConfig.java
@@ -0,0 +1,62 @@
+package au.org.aodn.ogcapi.server.core.configuration;
+
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+@Configuration
+public class WfsServerConfig {
+ 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 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();
+ 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 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/core/exception/DownloadableFieldsNotFoundException.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/DownloadableFieldsNotFoundException.java
new file mode 100644
index 00000000..293f7e7d
--- /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..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
@@ -40,6 +40,32 @@ 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(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)
public ResponseEntity handleGlobalException(Exception ex, WebRequest request) {
ErrorResponse errorResponse = ErrorResponse
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/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/dto/wfs/WfsDescribeFeatureTypeResponse.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/dto/wfs/WfsDescribeFeatureTypeResponse.java
new file mode 100644
index 00000000..8e8c60ed
--- /dev/null
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/dto/wfs/WfsDescribeFeatureTypeResponse.java
@@ -0,0 +1,61 @@
+package au.org.aodn.ogcapi.server.core.model.dto.wfs;
+
+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/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/core/model/wfs/DownloadableFieldModel.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/DownloadableFieldModel.java
new file mode 100644
index 00000000..63cb0cd1
--- /dev/null
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/wfs/DownloadableFieldModel.java
@@ -0,0 +1,16 @@
+package au.org.aodn.ogcapi.server.core.model.wfs;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class DownloadableFieldModel {
+ @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/core/service/wfs/DownloadableFieldsService.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsService.java
new file mode 100644
index 00000000..8230485f
--- /dev/null
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsService.java
@@ -0,0 +1,159 @@
+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.core.configuration.WfsServerConfig;
+import au.org.aodn.ogcapi.server.core.model.wfs.DownloadableFieldModel;
+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;
+import org.springframework.cache.annotation.Cacheable;
+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;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@Slf4j
+@Service
+public class DownloadableFieldsService {
+
+ @Autowired
+ private RestTemplate restTemplate;
+
+ @Autowired
+ private WfsServerConfig wfsServerConfig;
+
+ 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
+ */
+ @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);
+
+ if (fields.isEmpty()) {
+ throw new DownloadableFieldsNotFoundException(
+ String.format("No downloadable fields found for typeName '%s' from WFS server '%s'", typeName, wfsUrl)
+ );
+ }
+
+ 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(
+ 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) {
+ // SSRF protection: Only use pre-approved server URLs
+ String validatedServerUrl = wfsServerConfig.validateAndGetApprovedServerUrl(wfsUrl);
+
+ try {
+ URI uri = UriComponentsBuilder.fromUriString(validatedServerUrl)
+ .queryParam("service", "WFS")
+ .queryParam("version", "1.0.0")
+ .queryParam("request", "DescribeFeatureType")
+ .queryParam("typeName", typeName)
+ .build()
+ .toUri();
+
+ 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 (UnauthorizedServerException e) {
+ throw e;
+ } 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) {
+ 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<>();
+ }
+
+ /**
+ * Create a downloadable field based on the element type
+ */
+ private DownloadableFieldModel createDownloadableField(WfsDescribeFeatureTypeResponse.Element element) {
+ String elementType = element.getType();
+ if (elementType == null) {
+ return null;
+ }
+
+ DownloadableFieldModel field = new DownloadableFieldModel();
+ field.setLabel(element.getName());
+ field.setName(element.getName());
+
+ return switch (elementType) {
+ case "gml:GeometryPropertyType" -> {
+ field.setType("geometrypropertytype");
+ yield field;
+ }
+ case "xsd:dateTime" -> {
+ field.setType("dateTime");
+ yield field;
+ }
+ case "xsd:date" -> {
+ field.setType("date");
+ yield field;
+ }
+ case "xsd:time" -> {
+ field.setType("time");
+ 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 094d5a6c..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
@@ -3,10 +3,10 @@
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.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;
@@ -16,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;
@@ -31,9 +32,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);
@@ -47,21 +45,31 @@ 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.",
+ 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(
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.",
@@ -78,56 +86,60 @@ 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) {
+ @ParameterObject @Valid FeatureRequest request) {
+ 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 (request.getServerUrl() == null || request.getLayerName() == null) {
+ return ResponseEntity.badRequest().build();
+ }
+ return featuresService.getDownloadableFields(request.getServerUrl(), request.getLayerName());
+ case summary:
+ String filter = null;
+ if (request.getDatetime() != null) {
+ filter = OGCApiService.processDatetimeParameter(CQLFields.temporal.name(), request.getDatetime(), filter);
+ }
- try {
- FeatureId fid = FeatureId.valueOf(FeatureId.class, featureId);
+ if (request.getBbox() != null) {
+ filter = OGCApiService.processBBoxParameter(CQLFields.geometry.name(), request.getBbox(), filter);
+ }
- 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,
+ request.getProperties(),
+ 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();
}
}
/**
- *
* @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/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..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,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.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;
@@ -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,15 @@ 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/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..e0d8f9ee
--- /dev/null
+++ b/server/src/test/java/au/org/aodn/ogcapi/server/features/DownloadableFieldsServiceTest.java
@@ -0,0 +1,205 @@
+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.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;
+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;
+
+ @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";
+
+ // 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));
+
+ List result = downloadableFieldsService.getDownloadableFields(AUTHORIZED_SERVER, "test:layer");
+
+ assertNotNull(result);
+ assertEquals(2, result.size());
+
+ // Check geometry field
+ DownloadableFieldModel 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
+ DownloadableFieldModel 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.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));
+
+ 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.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")
+ );
+
+ assertTrue(exception.getMessage().contains("not authorized"));
+ assertTrue(exception.getMessage().contains(UNAUTHORIZED_SERVER));
+ }
+
+ @Test
+ public void testGetDownloadableFieldsWfsError() {
+ 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));
+
+ 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.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"));
+
+ DownloadableFieldsNotFoundException exception = assertThrows(
+ DownloadableFieldsNotFoundException.class,
+ () -> downloadableFieldsService.getDownloadableFields(AUTHORIZED_SERVER, "test:layer")
+ );
+
+ assertTrue(exception.getMessage().contains("No downloadable fields found"));
+ }
+
+ @Test
+ public void testRestApiDownloadableFieldsMissingServerUrl() {
+ FeatureRequest request = createDownloadableFieldsRequest(null, "test:layer");
+
+ ResponseEntity> response = restApi.getFeature(
+ "test-collection",
+ "downloadableFields",
+ request
+ );
+
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing serverUrl");
+ }
+
+ @Test
+ public void testRestApiDownloadableFieldsMissingLayerName() {
+ FeatureRequest request = createDownloadableFieldsRequest("https://test.com/wfs", null);
+
+ ResponseEntity> response = restApi.getFeature(
+ "test-collection",
+ "downloadableFields",
+ request
+ );
+
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode(), "Should return 400 for missing layerName");
+ }
+}