diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/ogc/wms/LayerInfo.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/ogc/wms/LayerInfo.java index 40a38aae..da536f64 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/ogc/wms/LayerInfo.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/ogc/wms/LayerInfo.java @@ -49,5 +49,14 @@ public class LayerInfo { @JacksonXmlProperty(localName = "Style") private Style style; + /** + * Nested child layers. WMS GetCapabilities layers are hierarchical: a layer + * can contain sub-layers, which can contain further sub-layers. This field + * lets Jackson deserialize that nesting so the full tree can be traversed. + */ + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "Layer") + private List layers; + protected NcWmsLayerInfo ncWmsLayerInfo; } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/CacheWarm.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/CacheWarm.java index 4e052ffa..1b4f4e97 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/CacheWarm.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/CacheWarm.java @@ -37,7 +37,8 @@ public class CacheWarm { "https://www.cmar.csiro.au/geoserver/nerp/wms", "https://www.cmar.csiro.au/geoserver/AusSeabed/wms", "https://geoserver.apps.aims.gov.au/aims/wms", - "https://geoserver.apps.aims.gov.au/reefcloud/wms" + "https://geoserver.apps.aims.gov.au/reefcloud/wms", + "https://maps.eatlas.org.au/maps/wms" ); // WFS server URLs for cache warming - using /ows endpoint which supports both WMS and WFS diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java index f7f694d0..a706d17d 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java @@ -787,10 +787,16 @@ public List fetchCapabilitiesLayersByUrl(String wmsServerUrl) { && capabilitiesResponse.getCapability().getRootLayer() != null && capabilitiesResponse.getCapability().getRootLayer().getLayers() != null) { - List layers = capabilitiesResponse.getCapability() + List rootLayers = capabilitiesResponse.getCapability() .getRootLayer() .getLayers(); + // WMS layers are hierarchical - flatten the tree and keep only + // layers that have a (container layers without a name are + // not individually requestable). + List layers = new ArrayList<>(); + flattenLayers(rootLayers, layers); + log.info("Fetched and cached {} get-capabilities layers for {}", layers.size(), wmsServerUrl); return layers; } @@ -803,6 +809,29 @@ public List fetchCapabilitiesLayersByUrl(String wmsServerUrl) { return Collections.emptyList(); } + /** + * Recursively flatten the hierarchical WMS layer tree into a flat list, + * keeping only layers that have a non-blank {@code } (named layers are + * the ones individually requestable; unnamed layers are grouping containers). + * + * @param layers - Layers at the current level (may be null) + * @param out - Accumulator receiving all named layers in the tree + */ + protected void flattenLayers(List layers, List out) { + if (layers == null) { + return; + } + for (LayerInfo layer : layers) { + if (layer == null) { + continue; + } + if (layer.getName() != null && !layer.getName().isBlank()) { + out.add(layer); + } + flattenLayers(layer.getLayers(), out); + } + } + /** * Get all WMS links from a collection. * @@ -843,6 +872,20 @@ protected List filterLayers(String collectionId, FeatureRequest reque if (request != null && request.getLayerName() != null && !request.getLayerName().isEmpty()) { log.debug("=== Starting to match {} layers by layername {} ===", layers.size(), request.getLayerName()); String requestLayerName = request.getLayerName(); + + // Prefer exact matches (full name incl. namespace prefix). Only if there + // is no exact match do we fall back to the rough/namespace-insensitive match. + List exactMatches = layers.stream() + .filter(layer -> + exactMatch(requestLayerName, layer.getName()) || + exactMatch(requestLayerName, layer.getTitle())) + .toList(); + if (!exactMatches.isEmpty()) { + log.debug("Found {} exact match(es) for layername '{}', ignoring partial matches", + exactMatches.size(), requestLayerName); + return exactMatches; + } + return layers.stream() .filter(layer -> roughlyMatch(requestLayerName, layer.getName()) || @@ -859,25 +902,28 @@ protected List filterLayers(String collectionId, FeatureRequest reque boolean matched = false; for (LinkModel wmsLink : wmsLinks) { - // Primary match: link.title matches layer.name OR layer.title + // Primary match: link.title matches layer.name OR layer.title. + // Prefer an exact match (full name incl. namespace prefix) so that a + // title like 'ea:GBR_GBRMPA_GBR-dry-reef' matches only the 'ea:' + // layer and not 'ea-be:GBR_GBRMPA_GBR-dry-reef'. if (wmsLink.getTitle() != null) { - if (roughlyMatch(wmsLink.getTitle(), layer.getName()) || - roughlyMatch(wmsLink.getTitle(), layer.getTitle())) { - log.debug(" ✓ Primary match found - WMS title '{}' matches layer '{}'", + if (exactMatch(wmsLink.getTitle(), layer.getName()) || + exactMatch(wmsLink.getTitle(), layer.getTitle())) { + log.debug(" ✓ Primary exact match found - WMS title '{}' matches layer '{}'", wmsLink.getTitle(), layer.getName()); matched = true; - break; // This will skip the next if block + break; } } // Sometimes the wms link.title is wrong but the link.url contains all information - // Fallback match: extract layername from link URI + // Fallback match: extract layername from link URI (exact) if (wmsLink.getHref() != null) { Optional layername = extractLayernameOrTypenameFromUrl(wmsLink.getHref()); if (layername.isPresent()) { - if (roughlyMatch(layername.get(), layer.getName()) || - roughlyMatch(layername.get(), layer.getTitle())) { - log.debug(" ✓ Fallback match found - layername '{}' matches layer '{}'", + if (exactMatch(layername.get(), layer.getName()) || + exactMatch(layername.get(), layer.getTitle())) { + log.debug(" ✓ Fallback exact match found - layername '{}' matches layer '{}'", layername.get(), layer.getName()); matched = true; break; @@ -891,6 +937,45 @@ protected List filterLayers(String collectionId, FeatureRequest reque } } + // Only if no layer matched exactly do we fall back to the rough + // (namespace-insensitive) matching, to avoid returning layers from a + // different workspace when an exact match exists. + if (filteredLayers.isEmpty()) { + log.debug("=== No exact match; falling back to rough matching for {} layers ===", layers.size()); + for (LayerInfo layer : layers) { + boolean matched = false; + + for (LinkModel wmsLink : wmsLinks) { + if (wmsLink.getTitle() != null) { + if (roughlyMatch(wmsLink.getTitle(), layer.getName()) || + roughlyMatch(wmsLink.getTitle(), layer.getTitle())) { + log.debug(" ✓ Primary rough match found - WMS title '{}' matches layer '{}'", + wmsLink.getTitle(), layer.getName()); + matched = true; + break; + } + } + + if (wmsLink.getHref() != null) { + Optional layername = extractLayernameOrTypenameFromUrl(wmsLink.getHref()); + if (layername.isPresent()) { + if (roughlyMatch(layername.get(), layer.getName()) || + roughlyMatch(layername.get(), layer.getTitle())) { + log.debug(" ✓ Fallback rough match found - layername '{}' matches layer '{}'", + layername.get(), layer.getName()); + matched = true; + break; + } + } + } + } + + if (matched) { + filteredLayers.add(layer); + } + } + } + log.info("Filtered {} wms layers out of {} based on WMS link matching", filteredLayers.size(), layers.size()); return filteredLayers; @@ -934,7 +1019,7 @@ protected Optional fetchNCWMSMetadata(String mapServerUrl, Strin * Sometimes the URL provided by WMS link is not optimal, for example * ... * will result in timeout due to too big query, if layername inside request have format xxx:yyyy then - * we can use xxx as the workspace name and rewrite the URL to https://www.cmar.csiro.au/geoserver/xxx/wms + * we can use xxx as the workspace name and rewrite the URL to ... * * @param collectionId - The uuid * @param request - The request param (can be null) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/GeoserverUtils.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/GeoserverUtils.java index 30cac27e..bebde730 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/GeoserverUtils.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/GeoserverUtils.java @@ -63,6 +63,22 @@ public static boolean roughlyMatch(String text1, String text2) { return normalized1.equals(normalized2); } + /** + * Strict match utility that compares the full names including any namespace + * prefix. Unlike {@link #roughlyMatch(String, String)}, this does NOT strip + * the namespace prefix, so "ea:GBR" matches "ea:GBR" but not "ea-be:GBR". + * + * @param text1 - First text to compare + * @param text2 - Second text to compare + * @return true if the two texts are equal + */ + public static boolean exactMatch(String text1, String text2) { + if (text1 == null || text2 == null) { + return false; + } + return text1.equals(text2); + } + /** * Extract typename from WFS or WMS URL query parameters. * For WFS URLs, looks for typeName/TYPENAME/typename parameter.