From fc48b3a538ebf417824135a41b7c626c6f4e402f Mon Sep 17 00:00:00 2001 From: leslieduan Date: Tue, 16 Jun 2026 17:02:59 +1000 Subject: [PATCH 1/5] feature: add moorings --- .../core/model/enumeration/FeatureId.java | 11 +- .../server/core/model/ogc/FeatureRequest.java | 3 + .../server/core/service/DasService.java | 67 +++++----- .../server/core/util/DatetimeUtils.java | 21 +++ .../aodn/ogcapi/server/features/RestApi.java | 23 ++-- .../ogcapi/server/features/RestServices.java | 124 ++++++++++++------ server/src/main/resources/application.yaml | 2 +- 7 files changed, 163 insertions(+), 88 deletions(-) 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 d82754fc..258d85b7 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 @@ -5,10 +5,13 @@ public enum FeatureId { wfs_fields("wfs_fields"), // Query field based on pure wfs and given layer wfs_field_value("wfs_field_value"), wms_fields("wms_fields"), // Query field based on value from wms describe layer query - wave_buoy_first_data_available("wave_buoy_first_data_available"), - wave_buoy_latest_date("wave_buoy_latest_date"), - wave_buoy_timeseries("wave_buoy_timeseries"), - wave_buoy_all("wave_buoy_all"), + //new + wave_buoys_between_dates("wave_buoys_between_dates"), + wave_buoys_latest_available_date("wave_buoys_latest_available_date"), + wave_buoy_details_between_dates("wave_buoy_details_between_dates"), + moorings_between_dates("moorings_between_dates"), + moorings_latest_available_date("moorings_latest_available_date"), + mooring_details_between_dates("mooring_details_between_dates"), wms_map_tile("wms_map_tile"), wms_map_feature("wms_map_feature"), wms_layers("wms_layers"), // Get all available layers from WMS GetCapabilities diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/ogc/FeatureRequest.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/ogc/FeatureRequest.java index 8e469c1a..27963a77 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/ogc/FeatureRequest.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/ogc/FeatureRequest.java @@ -87,6 +87,9 @@ public String toString() { @Schema(description = "Wave buoy name") private String waveBuoy; + @Schema(description = "Mooring name") + private String mooring; + @Schema(description = "Enable or disable geoserver whitelist") @Builder.Default private Boolean enableGeoServerWhiteList = Boolean.TRUE; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/DasService.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/DasService.java index 38676654..c9e777bd 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/DasService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/DasService.java @@ -12,7 +12,6 @@ import jakarta.annotation.PostConstruct; -import java.net.URLEncoder; import java.util.HashMap; import java.util.Map; @@ -35,20 +34,34 @@ public void init() { httpEntity = new HttpEntity<>(headers); } - public byte[] getWaveBuoys(String from, String to){ - String waveBuoysUrlTemplate = UriComponentsBuilder.fromUriString(dasConfig.host + "/api/v1/das/data/feature-collection/wave-buoy") - .queryParam("start_date","{start_date}") - .queryParam("end_date","{end_date}") - .encode() - .toUriString(); - Map params = new HashMap<>(); - params.put("start_date", from); - params.put("end_date",to); + /** + * GET a feature-collection from the DAS, optionally bounded by start/end date. Only the date + * query params that are non-null are added, so a null value is never passed to URI template + * expansion (which would throw). Any path variables in {@code path} are supplied via + * {@code pathVariables}. + */ + private byte[] getFeatureCollection(String path, String start, String end, Map pathVariables) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(dasConfig.host + path); + Map params = new HashMap<>(pathVariables); + + if (start != null) { + builder.queryParam("start_date", "{start_date}"); + params.put("start_date", start); + } + if (end != null) { + builder.queryParam("end_date", "{end_date}"); + params.put("end_date", end); + } + + String url = builder.encode().toUriString(); + return httpClient.exchange(url, HttpMethod.GET, httpEntity, byte[].class, params).getBody(); + } - return httpClient.exchange(waveBuoysUrlTemplate, HttpMethod.GET,httpEntity,byte[].class,params).getBody(); + public byte[] getWaveBuoysBetweenDates(String start, String end) { + return getFeatureCollection("/api/v1/das/data/feature-collection/wave-buoy", start, end, Map.of()); } - public byte[] getWaveBuoysLatestDate(){ + public byte[] getWaveBuoysLatestAvailableDate() { String waveBuoysUrlTemplate = UriComponentsBuilder.fromUriString(dasConfig.host + "/api/v1/das/data/feature-collection/wave-buoy/latest") .encode() .toUriString(); @@ -56,33 +69,23 @@ public byte[] getWaveBuoysLatestDate(){ return httpClient.exchange(waveBuoysUrlTemplate, HttpMethod.GET,httpEntity,byte[].class).getBody(); } - public byte[] getWaveBuoyData(String from, String to, String buoy){ - String encodedBuoy = URLEncoder.encode(buoy, java.nio.charset.StandardCharsets.UTF_8); - - String waveBuoyDataUrlTemplate = UriComponentsBuilder.fromUriString(dasConfig.host + "/api/v1/das/data/feature-collection/wave-buoy/" + encodedBuoy) - .queryParam("start_date","{start_date}") - .queryParam("end_date","{end_date}") - .encode() - .toUriString(); - Map params = new HashMap<>(); - params.put("start_date", from); - params.put("end_date",to); + public byte[] getWaveBuoyDetailsBetweenDates(String startDateTime, String endDateTime, String buoy) { + return getFeatureCollection("/api/v1/das/data/feature-collection/wave-buoy/{buoy}", startDateTime, endDateTime, Map.of("buoy", buoy)); + } - return httpClient.exchange(waveBuoyDataUrlTemplate, HttpMethod.GET,httpEntity,byte[].class,params).getBody(); + public byte[] getMooringsBetweenDates(String start, String end) { + return getFeatureCollection("/api/v1/das/data/feature-collection/mooring", start, end, Map.of()); } - public byte[] getLatestWaveBuoySites(){ - String waveBuoysUrlTemplate = UriComponentsBuilder.fromUriString(dasConfig.host + "/api/v1/das/data/feature-collection/wave-buoy/all") + public byte[] getMooringsLatestAvailableDate() { + String mooringsUrlTemplate = UriComponentsBuilder.fromUriString(dasConfig.host + "/api/v1/das/data/feature-collection/mooring/latest") .encode() .toUriString(); - return httpClient.exchange(waveBuoysUrlTemplate, HttpMethod.GET,httpEntity,byte[].class).getBody(); + return httpClient.exchange(mooringsUrlTemplate, HttpMethod.GET,httpEntity,byte[].class).getBody(); } - public boolean isCollectionSupported(String collectionId){ - final String waveBuoyRealtimeCollectionID = "b299cdcd-3dee-48aa-abdd-e0fcdbb9cadc"; - return waveBuoyRealtimeCollectionID.contentEquals(collectionId); + public byte[] getMooringDetailsBetweenDates(String startDateTime, String endDateTime, String mooring) { + return getFeatureCollection("/api/v1/das/data/feature-collection/mooring/{mooring}", startDateTime, endDateTime, Map.of("mooring", mooring)); } - - } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java index 7c449e3a..aad1e1c1 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java @@ -62,4 +62,25 @@ public static String validateAndFormatDate(String dateInput, boolean isStartDate throw new IllegalArgumentException("Date must be in MM-YYYY or YYYY-MM-DD format: " + dateInput); } } + + /** + * Parse an ISO-8601 date-time and verify it is in UTC (offset 'Z'). + * + * @param dateTime ISO-8601 date-time string, e.g. "2026-06-16T00:00:00Z" + * @return the parsed date-time + * @throws IllegalArgumentException if the value is not a valid ISO-8601 date-time or is not in UTC + */ + public static java.time.OffsetDateTime parseUtcDateTime(String dateTime) { + java.time.OffsetDateTime parsed; + try { + parsed = java.time.OffsetDateTime.parse(dateTime); + } catch (java.time.format.DateTimeParseException e) { + throw new IllegalArgumentException("Date-time must be a valid ISO-8601 UTC value (e.g. '2026-06-16T00:00:00Z'): " + dateTime); + } + + if (!parsed.getOffset().equals(java.time.ZoneOffset.UTC)) { + throw new IllegalArgumentException("Date-time must be in UTC (offset 'Z'): " + dateTime); + } + return parsed; + } } 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 86af810c..a14ab07f 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 @@ -115,17 +115,24 @@ public ResponseEntity getFeature( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } - case wave_buoy_first_data_available -> { - return featuresService.getWaveBuoys(collectionId, request.getDatetime()); + // new + case wave_buoys_between_dates -> { + return featuresService.getWaveBuoysBetweenDates( request.getStartDateTime(), request.getEndDateTime()); } - case wave_buoy_latest_date -> { - return featuresService.getWaveBuoysLatestDate(collectionId); + case wave_buoys_latest_available_date -> { + return featuresService.getWaveBuoysLatestAvailableDate(); } - case wave_buoy_timeseries -> { - return featuresService.getWaveBuoyData(collectionId, request.getDatetime(), request.getWaveBuoy()); + case wave_buoy_details_between_dates -> { + return featuresService.getWaveBuoyDetailsBetweenDates(request.getStartDateTime(), request.getEndDateTime(), request.getWaveBuoy()); } - case wave_buoy_all -> { - return featuresService.getLatestWaveBuoySites(collectionId); + case moorings_between_dates -> { + return featuresService.getMooringsBetweenDates(request.getStartDateTime(), request.getEndDateTime()); + } + case moorings_latest_available_date -> { + return featuresService.getMooringsLatestAvailableDate(); + } + case mooring_details_between_dates -> { + return featuresService.getMooringDetailsBetweenDates(request.getStartDateTime(), request.getEndDateTime(), request.getMooring()); } case wfs_fields -> { return featuresService.getWfsFields(collectionId, request); 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 6ca11c74..9dcb7e39 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 @@ -16,6 +16,7 @@ import au.org.aodn.ogcapi.server.core.service.geoserver.wms.WmsDefaultParam; import au.org.aodn.ogcapi.server.core.service.geoserver.wms.WmsServer; import au.org.aodn.ogcapi.server.core.util.CommonUtils; +import au.org.aodn.ogcapi.server.core.util.DatetimeUtils; import com.fasterxml.jackson.core.JsonProcessingException; import lombok.extern.slf4j.Slf4j; import org.geotools.feature.FeatureCollection; @@ -224,44 +225,78 @@ public ResponseEntity getWfsLayers(String collectionId, FeatureRequest reques } } + /** - * @param collectionID - uuid - * @param from - - * @return - + * Validate that startDateTime/endDateTime (when provided) are UTC ISO-8601 values and that the + * range is ordered. + * + * @return a bad-request response describing the problem, or empty if the range is valid */ - public ResponseEntity getWaveBuoys(String collectionID, String from) { - if (!dasService.isCollectionSupported(collectionID)) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + private Optional> validateUtcDateRange(String startDateTime, String endDateTime) { + java.time.OffsetDateTime start; + java.time.OffsetDateTime end; + try { + start = startDateTime == null ? null : DatetimeUtils.parseUtcDateTime(startDateTime); + end = endDateTime == null ? null : DatetimeUtils.parseUtcDateTime(endDateTime); + } catch (IllegalArgumentException e) { + return Optional.of(ResponseEntity.badRequest().body(e.getMessage())); } - if (from == null) { - return ResponseEntity.badRequest().body("Parameter 'datetime' is required and must be in 'from/to' format"); + + if (start != null && end != null && start.isAfter(end)) { + return Optional.of(ResponseEntity.badRequest().body("Parameter 'startDateTime' must not be after 'endDateTime'")); + } + return Optional.empty(); + } + + public ResponseEntity getWaveBuoysBetweenDates(String startDateTime, String endDateTime) { + log.info(startDateTime, endDateTime); + Optional> validationError = validateUtcDateRange(startDateTime, endDateTime); + if (validationError.isPresent()) { + return validationError.get(); } - java.time.ZonedDateTime fromDateTime = java.time.ZonedDateTime.parse(from); - String to = fromDateTime.plusDays(1) - .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS'Z'")); try { return ResponseEntity .ok() .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) - .body(dasService.getWaveBuoys(from, to)); + .body(dasService.getWaveBuoysBetweenDates(startDateTime, endDateTime)); } catch (Exception e) { log.error("Error fetching wave buoys data: {}", e.getMessage()); return ResponseEntity.internalServerError().build(); } + } - public ResponseEntity getWaveBuoysLatestDate(String collectionID) { - if (!dasService.isCollectionSupported(collectionID)) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + + public ResponseEntity getWaveBuoyDetailsBetweenDates(String startDateTime, String endDateTime, String buoy) { + Optional> validationError = validateUtcDateRange(startDateTime, endDateTime); + if (validationError.isPresent()) { + return validationError.get(); + } + if (buoy == null) { + return ResponseEntity.badRequest().body("Parameter 'waveBuoy' is required"); } try { return ResponseEntity .ok() .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) - .body(dasService.getWaveBuoysLatestDate()); + .body(dasService.getWaveBuoyDetailsBetweenDates(startDateTime, endDateTime, buoy)); + + } catch (Exception e) { + log.error("Error fetching wave buoy historical data: {}", e.getMessage()); + return ResponseEntity.internalServerError().build(); + } + } + + + public ResponseEntity getWaveBuoysLatestAvailableDate() { + try { + return ResponseEntity + .ok() + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .body(dasService.getWaveBuoysLatestAvailableDate()); } catch (Exception e) { log.error("Error fetching wave buoys latest date: {}", e.getMessage()); @@ -269,55 +304,58 @@ public ResponseEntity getWaveBuoysLatestDate(String collectionID) { } } - public ResponseEntity getWaveBuoyData(String collectionID, String datetime, String buoy) { - if (!dasService.isCollectionSupported(collectionID)) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + public ResponseEntity getMooringsBetweenDates(String startDateTime, String endDateTime) { + Optional> validationError = validateUtcDateRange(startDateTime, endDateTime); + if (validationError.isPresent()) { + return validationError.get(); } - if (datetime == null) { - return ResponseEntity.badRequest().body("Parameter 'datetime' is required and must be in 'from/to' format"); + + try { + return ResponseEntity + .ok() + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .body(dasService.getMooringsBetweenDates(startDateTime, endDateTime)); + + } catch (Exception e) { + log.error("Error fetching moorings data: {}", e.getMessage()); + return ResponseEntity.internalServerError().build(); } - if (!datetime.contains("/")) { - return ResponseEntity.badRequest().body("Parameter 'datetime' must be in 'from/to' format"); + } + + public ResponseEntity getMooringDetailsBetweenDates(String startDateTime, String endDateTime, String mooring) { + Optional> validationError = validateUtcDateRange(startDateTime, endDateTime); + if (validationError.isPresent()) { + return validationError.get(); } - if (buoy == null) { - return ResponseEntity.badRequest().body("Parameter 'waveBuoy' is required"); + if (mooring == null) { + return ResponseEntity.badRequest().body("Parameter 'mooring' is required"); } try { - String[] parts = datetime.split("/", 2); - String from = parts[0]; - String to = parts[1]; - return ResponseEntity .ok() .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) - .body(dasService.getWaveBuoyData(from, to, buoy)); + .body(dasService.getMooringDetailsBetweenDates(startDateTime, endDateTime, mooring)); } catch (Exception e) { - log.error("Error fetching wave buoy historical data: {}", e.getMessage()); + log.error("Error fetching mooring historical data: {}", e.getMessage()); return ResponseEntity.internalServerError().build(); } } - /** - * This is to get all buoy sites with their latest available observation - * - * @param collectionID - uuid - * @return - - */ - public ResponseEntity getLatestWaveBuoySites(String collectionID) { - if (!dasService.isCollectionSupported(collectionID)) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); - } + public ResponseEntity getMooringsLatestAvailableDate() { try { return ResponseEntity .ok() .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) - .body(dasService.getLatestWaveBuoySites()); + .body(dasService.getMooringsLatestAvailableDate()); } catch (Exception e) { - log.error("Error fetching wave buoy all unique sites date: {}", e.getMessage()); + log.error("Error fetching moorings latest date: {}", e.getMessage()); return ResponseEntity.internalServerError().build(); } } } + + + diff --git a/server/src/main/resources/application.yaml b/server/src/main/resources/application.yaml index 55c56178..999cfbc4 100644 --- a/server/src/main/resources/application.yaml +++ b/server/src/main/resources/application.yaml @@ -454,7 +454,7 @@ springdoc: path: /api/v1/ogc/api-docs/v3 data-access-service: - host: http://localhost:5000 + host: http://localhost:5001 secret: 123 management: From 2bb45f908c146ecdd5340d295b55c9b3dba310b0 Mon Sep 17 00:00:00 2001 From: leslieduan Date: Tue, 16 Jun 2026 17:17:37 +1000 Subject: [PATCH 2/5] test: add testing --- .../core/model/enumeration/FeatureId.java | 1 - .../aodn/ogcapi/server/features/RestApi.java | 1 - .../server/core/service/DasServiceTest.java | 125 +++++++++++ .../server/core/util/DatetimeUtilsTest.java | 35 ++++ .../server/features/RestServicesTest.java | 197 +++++++++++++++--- 5 files changed, 330 insertions(+), 29 deletions(-) create mode 100644 server/src/test/java/au/org/aodn/ogcapi/server/core/service/DasServiceTest.java 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 258d85b7..73192a0b 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 @@ -5,7 +5,6 @@ public enum FeatureId { wfs_fields("wfs_fields"), // Query field based on pure wfs and given layer wfs_field_value("wfs_field_value"), wms_fields("wms_fields"), // Query field based on value from wms describe layer query - //new wave_buoys_between_dates("wave_buoys_between_dates"), wave_buoys_latest_available_date("wave_buoys_latest_available_date"), wave_buoy_details_between_dates("wave_buoy_details_between_dates"), 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 a14ab07f..975b5cc5 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 @@ -115,7 +115,6 @@ public ResponseEntity getFeature( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } - // new case wave_buoys_between_dates -> { return featuresService.getWaveBuoysBetweenDates( request.getStartDateTime(), request.getEndDateTime()); } diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/DasServiceTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/DasServiceTest.java new file mode 100644 index 00000000..f37254d9 --- /dev/null +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/DasServiceTest.java @@ -0,0 +1,125 @@ +package au.org.aodn.ogcapi.server.core.service; + +import au.org.aodn.ogcapi.server.core.configuration.DASConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for DasService URL building. Verifies that null date params are omitted (so they are + * never passed to URI template expansion) and that buoy/mooring identifiers are sent as path + * variables for single, correct encoding. + */ +public class DasServiceTest { + + private static final String HOST = "http://localhost:5001"; + + private RestTemplate httpClient; + private DasService dasService; + + @BeforeEach + public void setUp() { + httpClient = mock(RestTemplate.class); + + DASConfig config = new DASConfig(); + config.host = HOST; + config.secret = "test-secret"; + + dasService = new DasService(); + dasService.dasConfig = config; + dasService.httpClient = httpClient; + dasService.init(); + + when(httpClient.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class), anyMap())) + .thenReturn(ResponseEntity.ok("ok".getBytes())); + when(httpClient.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class))) + .thenReturn(ResponseEntity.ok("ok".getBytes())); + } + + @SuppressWarnings("unchecked") + private CapturedRequest captureMapRequest() { + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); + verify(httpClient).exchange(urlCaptor.capture(), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class), mapCaptor.capture()); + return new CapturedRequest(urlCaptor.getValue(), mapCaptor.getValue()); + } + + @Test + public void testBetweenDatesIncludesBothDateParams() { + dasService.getWaveBuoysBetweenDates("2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"); + + CapturedRequest captured = captureMapRequest(); + assertTrue(captured.url.startsWith(HOST + "/api/v1/das/data/feature-collection/wave-buoy")); + assertTrue(captured.url.contains("start_date={start_date}")); + assertTrue(captured.url.contains("end_date={end_date}")); + assertEquals("2024-01-01T00:00:00Z", captured.params.get("start_date")); + assertEquals("2024-01-02T00:00:00Z", captured.params.get("end_date")); + } + + @Test + public void testBetweenDatesOmitsNullStart() { + dasService.getWaveBuoysBetweenDates(null, "2024-01-02T00:00:00Z"); + + CapturedRequest captured = captureMapRequest(); + assertFalse(captured.url.contains("start_date"), "null start_date must not appear in URL"); + assertFalse(captured.params.containsKey("start_date"), "null start_date must not be a uri variable"); + assertTrue(captured.url.contains("end_date={end_date}")); + assertEquals("2024-01-02T00:00:00Z", captured.params.get("end_date")); + } + + @Test + public void testBetweenDatesOmitsBothNullDates() { + dasService.getWaveBuoysBetweenDates(null, null); + + CapturedRequest captured = captureMapRequest(); + // No query string at all when both dates are absent + assertFalse(captured.url.contains("?"), "no query params expected, got: " + captured.url); + assertTrue(captured.params.isEmpty()); + } + + @Test + public void testBuoyDetailsSendsRawBuoyAsPathVariable() { + // A buoy name with a space must be passed raw; RestTemplate encodes the path variable once. + dasService.getWaveBuoyDetailsBetweenDates("2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "Apollo Bay"); + + CapturedRequest captured = captureMapRequest(); + assertTrue(captured.url.contains("/wave-buoy/{buoy}"), "buoy must be a path variable, got: " + captured.url); + assertEquals("Apollo Bay", captured.params.get("buoy")); + } + + @Test + public void testMooringDetailsSendsRawMooringAsPathVariable() { + dasService.getMooringDetailsBetweenDates("2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "Mooring 1"); + + CapturedRequest captured = captureMapRequest(); + assertTrue(captured.url.contains("/mooring/{mooring}"), "mooring must be a path variable, got: " + captured.url); + assertEquals("Mooring 1", captured.params.get("mooring")); + } + + @Test + public void testLatestAvailableDateUsesNoUriVariables() { + dasService.getWaveBuoysLatestAvailableDate(); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(httpClient).exchange(urlCaptor.capture(), eq(HttpMethod.GET), any(HttpEntity.class), eq(byte[].class)); + assertEquals(HOST + "/api/v1/das/data/feature-collection/wave-buoy/latest", urlCaptor.getValue()); + } + + private record CapturedRequest(String url, Map params) { + } +} diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java index c72c0abb..c9418653 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java @@ -129,4 +129,39 @@ public void testValidateAndFormatDate_RandomString() { }); assertTrue(exception.getMessage().contains("Date must be in MM-YYYY or YYYY-MM-DD format")); } + + @Test + public void testParseUtcDateTime_ValidUtcZulu() { + java.time.OffsetDateTime result = DatetimeUtils.parseUtcDateTime("2026-06-16T00:00:00Z"); + assertEquals(java.time.ZoneOffset.UTC, result.getOffset()); + assertEquals(2026, result.getYear()); + } + + @Test + public void testParseUtcDateTime_ValidZeroOffsetIsUtc() { + // +00:00 is equivalent to UTC and should be accepted + java.time.OffsetDateTime result = DatetimeUtils.parseUtcDateTime("2026-06-16T10:30:00+00:00"); + assertEquals(java.time.ZoneOffset.UTC, result.getOffset()); + } + + @Test + public void testParseUtcDateTime_NonUtcOffsetRejected() { + Exception exception = assertThrows(IllegalArgumentException.class, () -> + DatetimeUtils.parseUtcDateTime("2026-06-16T10:30:00+10:00")); + assertTrue(exception.getMessage().contains("UTC")); + } + + @Test + public void testParseUtcDateTime_MalformedRejected() { + Exception exception = assertThrows(IllegalArgumentException.class, () -> + DatetimeUtils.parseUtcDateTime("not-a-date")); + assertTrue(exception.getMessage().contains("ISO-8601")); + } + + @Test + public void testParseUtcDateTime_DateOnlyRejected() { + // A bare date has no time/offset and is not a valid OffsetDateTime + assertThrows(IllegalArgumentException.class, () -> + DatetimeUtils.parseUtcDateTime("2026-06-16")); + } } diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/features/RestServicesTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/features/RestServicesTest.java index 4eb3aca8..415d1c4b 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/features/RestServicesTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/features/RestServicesTest.java @@ -48,8 +48,6 @@ public class RestServicesTest { private AutoCloseable closeableMock; - private static final String SUPPORTED_COLLECTION_ID = "b299cdcd-3dee-48aa-abdd-e0fcdbb9cadc"; - @BeforeEach public void setUp() { closeableMock = MockitoAnnotations.openMocks(this); @@ -60,64 +58,209 @@ void cleanUp() throws Exception { closeableMock.close(); } + private static final String VALID_START = "2024-01-01T00:00:00Z"; + private static final String VALID_END = "2024-01-02T00:00:00Z"; + + // ----- wave_buoys_between_dates ----- + + @Test + public void testGetWaveBuoysBetweenDatesSuccess() { + byte[] mockResponse = "{\"type\":\"FeatureCollection\"}".getBytes(); + when(dasService.getWaveBuoysBetweenDates(VALID_START, VALID_END)).thenReturn(mockResponse); + + ResponseEntity response = restServices.getWaveBuoysBetweenDates(VALID_START, VALID_END); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(mockResponse, response.getBody()); + } + + @Test + public void testGetWaveBuoysBetweenDatesNullDatesPassThrough() { + // Both dates null is allowed; the service is still called (with nulls) and the result returned + byte[] mockResponse = "{\"type\":\"FeatureCollection\"}".getBytes(); + when(dasService.getWaveBuoysBetweenDates(null, null)).thenReturn(mockResponse); + + ResponseEntity response = restServices.getWaveBuoysBetweenDates(null, null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(dasService).getWaveBuoysBetweenDates(null, null); + } + @Test - public void testGetWaveBuoysLatestDateSuccess() { + public void testGetWaveBuoysBetweenDatesNonUtcRejected() { + ResponseEntity response = restServices.getWaveBuoysBetweenDates("2024-01-01T00:00:00+10:00", VALID_END); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + verify(dasService, never()).getWaveBuoysBetweenDates(anyString(), anyString()); + } + + @Test + public void testGetWaveBuoysBetweenDatesMalformedRejected() { + ResponseEntity response = restServices.getWaveBuoysBetweenDates("not-a-date", VALID_END); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + verify(dasService, never()).getWaveBuoysBetweenDates(anyString(), anyString()); + } + + @Test + public void testGetWaveBuoysBetweenDatesStartAfterEndRejected() { + ResponseEntity response = restServices.getWaveBuoysBetweenDates(VALID_END, VALID_START); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + verify(dasService, never()).getWaveBuoysBetweenDates(anyString(), anyString()); + } + + @Test + public void testGetWaveBuoysBetweenDatesServiceError() { + when(dasService.getWaveBuoysBetweenDates(VALID_START, VALID_END)) + .thenThrow(new RuntimeException("Connection refused")); + + ResponseEntity response = restServices.getWaveBuoysBetweenDates(VALID_START, VALID_END); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + + // ----- wave_buoys_latest_available_date ----- + + @Test + public void testGetWaveBuoysLatestAvailableDateSuccess() { byte[] mockResponse = "{\"latest_date\":\"2024-01-01\"}".getBytes(); - when(dasService.isCollectionSupported(SUPPORTED_COLLECTION_ID)).thenReturn(true); - when(dasService.getWaveBuoysLatestDate()).thenReturn(mockResponse); + when(dasService.getWaveBuoysLatestAvailableDate()).thenReturn(mockResponse); - ResponseEntity response = restServices.getWaveBuoysLatestDate(SUPPORTED_COLLECTION_ID); + ResponseEntity response = restServices.getWaveBuoysLatestAvailableDate(); assertEquals(HttpStatus.OK, response.getStatusCode()); assertEquals(mockResponse, response.getBody()); } @Test - public void testGetWaveBuoysLatestDateUnsupportedCollection() { - when(dasService.isCollectionSupported("unsupported-id")).thenReturn(false); + public void testGetWaveBuoysLatestAvailableDateServiceError() { + when(dasService.getWaveBuoysLatestAvailableDate()).thenThrow(new RuntimeException("Connection refused")); - ResponseEntity response = restServices.getWaveBuoysLatestDate("unsupported-id"); + ResponseEntity response = restServices.getWaveBuoysLatestAvailableDate(); - assertEquals(HttpStatus.NOT_IMPLEMENTED, response.getStatusCode()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); } + // ----- wave_buoy_details_between_dates ----- + @Test - public void testGetWaveBuoysLatestDateServiceError() { - when(dasService.isCollectionSupported(SUPPORTED_COLLECTION_ID)).thenReturn(true); - when(dasService.getWaveBuoysLatestDate()).thenThrow(new RuntimeException("Connection refused")); + public void testGetWaveBuoyDetailsBetweenDatesSuccess() { + byte[] mockResponse = "{\"type\":\"FeatureCollection\"}".getBytes(); + when(dasService.getWaveBuoyDetailsBetweenDates(VALID_START, VALID_END, "BUOY-1")).thenReturn(mockResponse); + + ResponseEntity response = restServices.getWaveBuoyDetailsBetweenDates(VALID_START, VALID_END, "BUOY-1"); - ResponseEntity response = restServices.getWaveBuoysLatestDate(SUPPORTED_COLLECTION_ID); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(mockResponse, response.getBody()); + } + + @Test + public void testGetWaveBuoyDetailsBetweenDatesNullBuoyRejected() { + ResponseEntity response = restServices.getWaveBuoyDetailsBetweenDates(VALID_START, VALID_END, null); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + verify(dasService, never()).getWaveBuoyDetailsBetweenDates(anyString(), anyString(), any()); + } + + @Test + public void testGetWaveBuoyDetailsBetweenDatesInvalidDateRejected() { + ResponseEntity response = restServices.getWaveBuoyDetailsBetweenDates("not-a-date", VALID_END, "BUOY-1"); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + verify(dasService, never()).getWaveBuoyDetailsBetweenDates(anyString(), anyString(), any()); + } + + @Test + public void testGetWaveBuoyDetailsBetweenDatesServiceError() { + when(dasService.getWaveBuoyDetailsBetweenDates(VALID_START, VALID_END, "BUOY-1")) + .thenThrow(new RuntimeException("Connection refused")); + + ResponseEntity response = restServices.getWaveBuoyDetailsBetweenDates(VALID_START, VALID_END, "BUOY-1"); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + + // ----- moorings_between_dates ----- + + @Test + public void testGetMooringsBetweenDatesSuccess() { + byte[] mockResponse = "{\"type\":\"FeatureCollection\"}".getBytes(); + when(dasService.getMooringsBetweenDates(VALID_START, VALID_END)).thenReturn(mockResponse); + + ResponseEntity response = restServices.getMooringsBetweenDates(VALID_START, VALID_END); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(mockResponse, response.getBody()); + } + + @Test + public void testGetMooringsBetweenDatesStartAfterEndRejected() { + ResponseEntity response = restServices.getMooringsBetweenDates(VALID_END, VALID_START); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + verify(dasService, never()).getMooringsBetweenDates(anyString(), anyString()); + } + + @Test + public void testGetMooringsBetweenDatesServiceError() { + when(dasService.getMooringsBetweenDates(VALID_START, VALID_END)) + .thenThrow(new RuntimeException("Connection refused")); + + ResponseEntity response = restServices.getMooringsBetweenDates(VALID_START, VALID_END); assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); } + // ----- moorings_latest_available_date ----- + @Test - public void testGetLatestWaveBuoySitesSuccess() { - byte[] mockResponse = "[{\"site_code\":\"SITE1\"}]".getBytes(); - when(dasService.isCollectionSupported(SUPPORTED_COLLECTION_ID)).thenReturn(true); - when(dasService.getLatestWaveBuoySites()).thenReturn(mockResponse); + public void testGetMooringsLatestAvailableDateSuccess() { + byte[] mockResponse = "{\"latest_date\":\"2024-01-01\"}".getBytes(); + when(dasService.getMooringsLatestAvailableDate()).thenReturn(mockResponse); - ResponseEntity response = restServices.getLatestWaveBuoySites(SUPPORTED_COLLECTION_ID); + ResponseEntity response = restServices.getMooringsLatestAvailableDate(); assertEquals(HttpStatus.OK, response.getStatusCode()); assertEquals(mockResponse, response.getBody()); } @Test - public void testGetLatestWaveBuoySitesUnsupportedCollection() { - when(dasService.isCollectionSupported("unsupported-id")).thenReturn(false); + public void testGetMooringsLatestAvailableDateServiceError() { + when(dasService.getMooringsLatestAvailableDate()).thenThrow(new RuntimeException("Connection refused")); + + ResponseEntity response = restServices.getMooringsLatestAvailableDate(); - ResponseEntity response = restServices.getLatestWaveBuoySites("unsupported-id"); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + + // ----- mooring_details_between_dates ----- + + @Test + public void testGetMooringDetailsBetweenDatesSuccess() { + byte[] mockResponse = "{\"type\":\"FeatureCollection\"}".getBytes(); + when(dasService.getMooringDetailsBetweenDates(VALID_START, VALID_END, "MOORING-1")).thenReturn(mockResponse); + + ResponseEntity response = restServices.getMooringDetailsBetweenDates(VALID_START, VALID_END, "MOORING-1"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(mockResponse, response.getBody()); + } + + @Test + public void testGetMooringDetailsBetweenDatesNullMooringRejected() { + ResponseEntity response = restServices.getMooringDetailsBetweenDates(VALID_START, VALID_END, null); - assertEquals(HttpStatus.NOT_IMPLEMENTED, response.getStatusCode()); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + verify(dasService, never()).getMooringDetailsBetweenDates(anyString(), anyString(), any()); } @Test - public void testGetLatestWaveBuoySitesServiceError() { - when(dasService.isCollectionSupported(SUPPORTED_COLLECTION_ID)).thenReturn(true); - when(dasService.getLatestWaveBuoySites()).thenThrow(new RuntimeException("Connection refused")); + public void testGetMooringDetailsBetweenDatesServiceError() { + when(dasService.getMooringDetailsBetweenDates(VALID_START, VALID_END, "MOORING-1")) + .thenThrow(new RuntimeException("Connection refused")); - ResponseEntity response = restServices.getLatestWaveBuoySites(SUPPORTED_COLLECTION_ID); + ResponseEntity response = restServices.getMooringDetailsBetweenDates(VALID_START, VALID_END, "MOORING-1"); assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); } From 02b7af9397b8be99e36f916ed9135f57170188d7 Mon Sep 17 00:00:00 2001 From: leslieduan Date: Wed, 17 Jun 2026 12:05:56 +1000 Subject: [PATCH 3/5] docs: add comment --- .../ogcapi/server/features/RestServices.java | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) 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 9dcb7e39..c7a6b899 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 @@ -248,8 +248,12 @@ private Optional> validateUtcDateRange(String startDateTime, S return Optional.empty(); } + /** + * Returns wave buoy sites recorded within the given UTC date range. + * + * @param startDateTime,endDateTime must be UTC or null, since the underlying DAS service expects UTC only. + */ public ResponseEntity getWaveBuoysBetweenDates(String startDateTime, String endDateTime) { - log.info(startDateTime, endDateTime); Optional> validationError = validateUtcDateRange(startDateTime, endDateTime); if (validationError.isPresent()) { return validationError.get(); @@ -269,6 +273,12 @@ public ResponseEntity getWaveBuoysBetweenDates(String startDateTime, String e } + /** + * Returns detailed data for a single wave buoy within the given UTC date range. + * + * @param startDateTime,endDateTime must be UTC or null, since the underlying DAS service expects UTC only. + * @param buoy the wave buoy identifier; required. + */ public ResponseEntity getWaveBuoyDetailsBetweenDates(String startDateTime, String endDateTime, String buoy) { Optional> validationError = validateUtcDateRange(startDateTime, endDateTime); if (validationError.isPresent()) { @@ -291,6 +301,9 @@ public ResponseEntity getWaveBuoyDetailsBetweenDates(String startDateTime, St } + /** + * Returns the latest date for which wave buoy data is available. + */ public ResponseEntity getWaveBuoysLatestAvailableDate() { try { return ResponseEntity @@ -304,6 +317,11 @@ public ResponseEntity getWaveBuoysLatestAvailableDate() { } } + /** + * Returns mooring observations recorded within the given UTC date range. + * + * @param startDateTime,endDateTime must be UTC or null, since the underlying DAS service expects UTC only. + */ public ResponseEntity getMooringsBetweenDates(String startDateTime, String endDateTime) { Optional> validationError = validateUtcDateRange(startDateTime, endDateTime); if (validationError.isPresent()) { @@ -322,6 +340,12 @@ public ResponseEntity getMooringsBetweenDates(String startDateTime, String en } } + /** + * Returns detailed observations for a single mooring within the given UTC date range. + * + * @param startDateTime,endDateTime must be UTC or null, since the underlying DAS service expects UTC only. + * @param mooring the mooring identifier; required. + */ public ResponseEntity getMooringDetailsBetweenDates(String startDateTime, String endDateTime, String mooring) { Optional> validationError = validateUtcDateRange(startDateTime, endDateTime); if (validationError.isPresent()) { @@ -343,6 +367,9 @@ public ResponseEntity getMooringDetailsBetweenDates(String startDateTime, Str } } + /** + * Returns the latest date for which mooring data is available. + */ public ResponseEntity getMooringsLatestAvailableDate() { try { return ResponseEntity From 8b9564d074e8015109d9708ab94328157cfa673f Mon Sep 17 00:00:00 2001 From: leslieduan Date: Thu, 18 Jun 2026 17:06:47 +1000 Subject: [PATCH 4/5] fix: das port to 5000 --- server/src/main/resources/application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/resources/application.yaml b/server/src/main/resources/application.yaml index 999cfbc4..55c56178 100644 --- a/server/src/main/resources/application.yaml +++ b/server/src/main/resources/application.yaml @@ -454,7 +454,7 @@ springdoc: path: /api/v1/ogc/api-docs/v3 data-access-service: - host: http://localhost:5001 + host: http://localhost:5000 secret: 123 management: From 438c83b871f57debff0deb20b09ddd9289755e7d Mon Sep 17 00:00:00 2001 From: leslieduan Date: Thu, 18 Jun 2026 17:18:42 +1000 Subject: [PATCH 5/5] fix: format errors --- .../java/au/org/aodn/ogcapi/server/features/RestServices.java | 3 --- 1 file changed, 3 deletions(-) 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 c7a6b899..83e65dd1 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 @@ -383,6 +383,3 @@ public ResponseEntity getMooringsLatestAvailableDate() { } } } - - -