From 10ba4273442e28fa2831ecd22494e9fc808a1a1a Mon Sep 17 00:00:00 2001 From: leslieduan Date: Wed, 12 Aug 2026 11:50:09 +1000 Subject: [PATCH 1/3] Add DAS tiler point lookup and batched multi-collection products endpoint --- .../core/service/das/DasTilerService.java | 48 ++- .../aodn/ogcapi/server/tile/RestExtApi.java | 335 ++++++++++++++---- server/src/main/resources/application.yaml | 2 +- .../core/service/das/DasTilerServiceTest.java | 119 +++++++ .../ogcapi/server/tile/RestExtApiTest.java | 173 ++++++++- 5 files changed, 594 insertions(+), 83 deletions(-) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerService.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerService.java index d357d79c..13fabb63 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerService.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.net.SocketTimeoutException; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -121,6 +122,24 @@ public DasTileResult getDataTile(String productId, String date, int lod, int x, return exchangeForImage(builder, params, "image/png"); } + /** + * Fetches the decoded value(s) at a single lat/lon point for a product on a date. Unlike the + * tile route this returns the already-decoded value(s) as JSON, not value-encoded pixels. + */ + public DasJsonResult getPoint(String productId, String date, double lat, double lon) { + UriComponentsBuilder builder = UriComponentsBuilder + .fromUriString(dasProperties.host() + DATA_TILES_BASE + "/{product}/{date}/point") + .queryParam("lat", "{lat}") + .queryParam("lon", "{lon}"); + Map params = new HashMap<>(); + params.put("product", productId); + params.put("date", date); + params.put("lat", lat); + params.put("lon", lon); + + return exchangeForJson(builder, params); + } + /** * Fetches the per-date data-tile manifest (decode ranges, bounds, LOD geometry) needed to * decode the data tiles. Unlike the plain JSON getters this expands the product id (which @@ -153,15 +172,14 @@ public List getProducts() { } } - public JsonNode getManifest() { - String url = dasProperties.host() + VISUAL_TILES_BASE + "/manifest"; - try { - return httpClient.getForObject(url, JsonNode.class); - } catch (HttpStatusCodeException e) { - throw mapUpstreamError(e); - } catch (ResourceAccessException e) { - throw mapNetworkError(e); - } + /** + * Fetches the product/date manifest, forwarding its {@code Cache-Control} rather than this + * service inventing a freshness of its own. + */ + public DasJsonResult getManifest() { + UriComponentsBuilder builder = UriComponentsBuilder + .fromUriString(dasProperties.host() + VISUAL_TILES_BASE + "/manifest"); + return exchangeForJson(builder, Map.of()); } public JsonNode getColormaps() { @@ -203,9 +221,19 @@ public DasTileResult getLegend(String name, String rescale, Integer width, Integ } public List productsForCollection(String collectionId) { + return productsForCollections(List.of(collectionId)); + } + + /** + * Filters products down to the given collection ids ({@code metadata_uuid}). A null or empty + * id collection is unfiltered — every product across every collection is returned. + */ + public List productsForCollections(Collection collectionIds) { + boolean unfiltered = collectionIds == null || collectionIds.isEmpty(); List result = new ArrayList<>(); for (JsonNode product : getProducts()) { - if (collectionId.equals(product.path("metadata_uuid").asText(null))) { + String uuid = product.path("metadata_uuid").asText(null); + if (unfiltered || (uuid != null && collectionIds.contains(uuid))) { result.add(product); } } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java index 807abe53..b668df84 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java @@ -111,83 +111,179 @@ public ResponseEntity getCollectionProducts( example = "0c9eb39c-9cbe-4c6a-8a10-5867087e703a") @PathVariable String collectionId) { List products = dasTilerService.productsForCollection(collectionId); - JsonNode manifest = dasTilerService.getManifest(); - JsonNode manifestProducts = manifest != null ? manifest.path("products") : null; + DasTilerService.DasJsonResult manifest = dasTilerService.getManifest(); + JsonNode manifestProducts = manifest.body() != null ? manifest.body().path("products") : null; ArrayNode result = mapper.createArrayNode(); for (JsonNode product : products) { - String id = product.path("id").asText(); - JsonNode variable = product.path("variable"); - int variableCount = variable.isArray() ? variable.size() : 1; + result.add(buildProductEntry(product, collectionId, manifestProducts)); + } - ObjectNode entry = mapper.createObjectNode(); - entry.put("id", id); - entry.set("variable", variable); - - // tile_types is a capability list: what THIS service can serve today, not a property of - // the data. Visual capability now comes from DAS, which knows whether a variable is - // actually renderable — arity cannot tell a colourisable scalar from one the renderer - // has no sensible colouring for. The arity rule survives only as a fallback for a DAS - // old enough to have no `visual` field, which the OGC-first deployment order requires. - // Data tiles still follow arity: the shader packs one or two channels, and DAS config - // validation guarantees nothing longer reaches here. - boolean canVisual = product.has("visual") - ? product.path("visual").asBoolean() - : variableCount == 1; - boolean canData = variableCount == 1 || variableCount == 2; - ArrayNode tileTypes = mapper.createArrayNode(); - if (canVisual) { - tileTypes.add("visual"); - } - if (canData) { - tileTypes.add("data"); - } - entry.set("tile_types", tileTypes); - - JsonNode availability = manifestProducts != null ? manifestProducts.path(id) : null; - entry.set("available_dates", availability != null && !availability.isMissingNode() - ? availability.path("available_dates") : mapper.createArrayNode()); - entry.set("full_date_range", availability != null && !availability.isMissingNode() - ? availability.path("full_date_range") : mapper.createObjectNode()); - - // The tile routes take dataset and variable separately, so split the product id on its - // first ':'. That split is the only place the id is treated as anything but opaque. The - // variable half of a two-variable product contains '+' (e.g. ucur+vcur), which URLEncoder - // renders as %2B — without that a query string would decode it back to a space. - int sep = id.indexOf(':'); - String datasetPart = sep >= 0 ? id.substring(0, sep) : id; - String variablePart = sep >= 0 ? id.substring(sep + 1) : ""; - String encodedDataset = URLEncoder.encode(datasetPart, StandardCharsets.UTF_8); - String encodedVariable = URLEncoder.encode(variablePart, StandardCharsets.UTF_8); - - if (canVisual) { - entry.put("visual_tile_url_template", - "/api/v1/ogc/collections/" + collectionId + "/map/tiles/WebMercatorQuad/{z}/{x}/{y}" - + "?dataset=" + encodedDataset + "&variable=" + encodedVariable - + "&datetime={datetime}&f=png"); - entry.put("legend_url", "/api/v1/ogc/ext/tiles/colormaps/{colormap}/legend"); - } - - if (canData) { - entry.put("data_tile_url_template", - "/api/v1/ogc/ext/tiles/collections/" + collectionId + "/data_tiles/{lod}/{x}/{y}" - + "?dataset=" + encodedDataset + "&variable=" + encodedVariable - + "&datetime={datetime}"); - entry.put("data_manifest_url_template", - "/api/v1/ogc/ext/tiles/collections/" + collectionId + "/data_tiles/manifest" - + "?dataset=" + encodedDataset + "&variable=" + encodedVariable - + "&datetime={datetime}"); - } + ObjectNode body = mapper.createObjectNode(); + body.set("products", result); + + ResponseEntity.BodyBuilder response = ResponseEntity.ok(); + if (manifest.cacheControl() != null) { + response.header(HttpHeaders.CACHE_CONTROL, manifest.cacheControl()); + } + return response.body(body); + } + + /** + * Builds one product entry of the shape shared by {@link #getCollectionProducts} and + * {@link #getProductsForCollections}: capability list, availability from the manifest, and + * ready-to-use URL templates scoped to {@code collectionId}. + */ + private ObjectNode buildProductEntry(JsonNode product, String collectionId, JsonNode manifestProducts) { + String id = product.path("id").asText(); + JsonNode variable = product.path("variable"); + int variableCount = variable.isArray() ? variable.size() : 1; + + ObjectNode entry = mapper.createObjectNode(); + entry.put("id", id); + entry.set("variable", variable); + + // tile_types is a capability list: what THIS service can serve today, not a property of + // the data. Visual capability now comes from DAS, which knows whether a variable is + // actually renderable — arity cannot tell a colourisable scalar from one the renderer + // has no sensible colouring for. The arity rule survives only as a fallback for a DAS + // old enough to have no `visual` field, which the OGC-first deployment order requires. + // Data tiles still follow arity: the shader packs one or two channels, and DAS config + // validation guarantees nothing longer reaches here. + boolean canVisual = product.has("visual") + ? product.path("visual").asBoolean() + : variableCount == 1; + boolean canData = variableCount == 1 || variableCount == 2; + ArrayNode tileTypes = mapper.createArrayNode(); + if (canVisual) { + tileTypes.add("visual"); + } + if (canData) { + tileTypes.add("data"); + } + entry.set("tile_types", tileTypes); + + JsonNode availability = manifestProducts != null ? manifestProducts.path(id) : null; + entry.set("available_dates", availability != null && !availability.isMissingNode() + ? availability.path("available_dates") : mapper.createArrayNode()); + entry.set("full_date_range", availability != null && !availability.isMissingNode() + ? availability.path("full_date_range") : mapper.createObjectNode()); + + // The tile routes take dataset and variable separately, so split the product id on its + // first ':'. That split is the only place the id is treated as anything but opaque. The + // variable half of a two-variable product contains '+' (e.g. ucur+vcur), which URLEncoder + // renders as %2B — without that a query string would decode it back to a space. + int sep = id.indexOf(':'); + String datasetPart = sep >= 0 ? id.substring(0, sep) : id; + String variablePart = sep >= 0 ? id.substring(sep + 1) : ""; + String encodedDataset = URLEncoder.encode(datasetPart, StandardCharsets.UTF_8); + String encodedVariable = URLEncoder.encode(variablePart, StandardCharsets.UTF_8); + + if (canVisual) { + entry.put("visual_tile_url_template", + "/api/v1/ogc/collections/" + collectionId + "/map/tiles/WebMercatorQuad/{z}/{x}/{y}" + + "?dataset=" + encodedDataset + "&variable=" + encodedVariable + + "&datetime={datetime}&f=png"); + entry.put("legend_url", "/api/v1/ogc/ext/tiles/colormaps/{colormap}/legend"); + } + + if (canData) { + entry.put("data_tile_url_template", + "/api/v1/ogc/ext/tiles/collections/" + collectionId + "/data_tiles/{lod}/{x}/{y}" + + "?dataset=" + encodedDataset + "&variable=" + encodedVariable + + "&datetime={datetime}"); + entry.put("data_manifest_url_template", + "/api/v1/ogc/ext/tiles/collections/" + collectionId + "/data_tiles/manifest" + + "?dataset=" + encodedDataset + "&variable=" + encodedVariable + + "&datetime={datetime}"); + } + + return entry; + } + + @Operation( + summary = "List the renderable tiler products of several collections, or all of them", + description = "Batched form of `GET /collections/{collectionId}/products`: pass one or more " + + "`collectionId` query params to fetch several collections in a single round trip, or " + + "omit it entirely to list every tiler product across every collection. Since the result " + + "can span more than one collection, each entry additionally carries the `collectionId` " + + "it belongs to — otherwise the entry shape is identical to the single-collection route." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "The matching products; an empty array if none match.", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "products": [ + { + "collectionId": "0c9eb39c-9cbe-4c6a-8a10-5867087e703a", + "id": "model_sea_level_anomaly_gridded_realtime:gsla", + "variable": "GSLA", + "tile_types": ["visual", "data"], + "available_dates": ["2024-01-01", "2024-01-02"], + "full_date_range": {"start": "2020-01-01", "end": "2024-01-02"}, + "visual_tile_url_template": "/api/v1/ogc/collections/0c9eb39c-9cbe-4c6a-8a10-5867087e703a/map/tiles/WebMercatorQuad/{z}/{x}/{y}?dataset=model_sea_level_anomaly_gridded_realtime&variable=gsla&datetime={datetime}&f=png", + "legend_url": "/api/v1/ogc/ext/tiles/colormaps/{colormap}/legend", + "data_tile_url_template": "/api/v1/ogc/ext/tiles/collections/0c9eb39c-9cbe-4c6a-8a10-5867087e703a/data_tiles/{lod}/{x}/{y}?dataset=model_sea_level_anomaly_gridded_realtime&variable=gsla&datetime={datetime}", + "data_manifest_url_template": "/api/v1/ogc/ext/tiles/collections/0c9eb39c-9cbe-4c6a-8a10-5867087e703a/data_tiles/manifest?dataset=model_sea_level_anomaly_gridded_realtime&variable=gsla&datetime={datetime}" + }, + { + "collectionId": "1a2b3c4d-0000-1111-2222-333344445555", + "id": "satellite_austemp_heatwave_14day:mcs_category", + "variable": "MCS_category", + "tile_types": ["data"], + "available_dates": ["2026-02-14"], + "full_date_range": {"start": "2020-01-01", "end": "2026-02-14"}, + "data_tile_url_template": "/api/v1/ogc/ext/tiles/collections/1a2b3c4d-0000-1111-2222-333344445555/data_tiles/{lod}/{x}/{y}?dataset=satellite_austemp_heatwave_14day&variable=mcs_category&datetime={datetime}", + "data_manifest_url_template": "/api/v1/ogc/ext/tiles/collections/1a2b3c4d-0000-1111-2222-333344445555/data_tiles/manifest?dataset=satellite_austemp_heatwave_14day&variable=mcs_category&datetime={datetime}" + } + ] + }"""))), + @ApiResponse(responseCode = "429", description = "Upstream rate limit reached.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "502", description = "DAS unreachable, errored, or rejected this service's API key.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "503", description = "DAS is still warming up.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "504", description = "DAS did not respond in time.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class)))}) + @GetMapping("/collections/products") + public ResponseEntity getProductsForCollections( + @Parameter(in = ParameterIn.QUERY, + description = "Collection identifier(s) (metadata record UUID) to include. Repeat for " + + "multiple collections (`collectionId=a&collectionId=b`) or omit entirely to list " + + "every collection's products.", + example = "0c9eb39c-9cbe-4c6a-8a10-5867087e703a") + @RequestParam(required = false) List collectionId) { + + List products = (collectionId == null || collectionId.isEmpty()) + ? dasTilerService.getProducts() + : dasTilerService.productsForCollections(collectionId); + DasTilerService.DasJsonResult manifest = dasTilerService.getManifest(); + JsonNode manifestProducts = manifest.body() != null ? manifest.body().path("products") : null; + ArrayNode result = mapper.createArrayNode(); + for (JsonNode product : products) { + String productCollectionId = product.path("metadata_uuid").asText(null); + ObjectNode entry = mapper.createObjectNode(); + entry.put("collectionId", productCollectionId); + entry.setAll(buildProductEntry(product, productCollectionId, manifestProducts)); result.add(entry); } ObjectNode body = mapper.createObjectNode(); body.set("products", result); - return ResponseEntity.ok() - .header(HttpHeaders.CACHE_CONTROL, "public, max-age=300, must-revalidate") - .body(body); + ResponseEntity.BodyBuilder response = ResponseEntity.ok(); + if (manifest.cacheControl() != null) { + response.header(HttpHeaders.CACHE_CONTROL, manifest.cacheControl()); + } + return response.body(body); } @Operation( @@ -309,6 +405,104 @@ public ResponseEntity getCollectionDataTile( return response.body(tile.body()); } + @Operation( + summary = "Retrieve the decoded value(s) at a point for a product", + description = "Returns the already-decoded value(s) at a single lat/lon point — unlike the data-tile " + + "route, this is **not** value-encoded pixels, it's the plain decoded value(s) as JSON. " + + "Useful for point queries (e.g. a click on the map) without fetching and decoding a whole " + + "tile.\n\n" + + "Valid `dataset`, `variable` and `datetime` values come from " + + "`GET /api/v1/ogc/ext/tiles/collections/{collectionId}/products`.", + tags = {"Data Tiles"}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "The decoded value(s) at the point.", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "lat": -44.27813720703125, + "lon": 132.0092315673828, + "variables": { + "MCS_category": { + "value": 0.0, + "units": null + } + } + }"""))), + @ApiResponse(responseCode = "400", description = "`dataset` or `variable` missing, `variable` " + + "containing a space (an unencoded `+`), `datetime` not `YYYY-MM-DD`, or `lat`/`lon` " + + "missing or out of range.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "DAS reported an unknown product " + + "(`{dataset}:{variable}`), an unavailable date, or a point outside the product's coverage. " + + "Forwarded from DAS, which owns the product catalogue.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "422", description = "DAS could not process the request (e.g. a " + + "malformed date).", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "429", description = "Upstream rate limit reached.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "502", description = "The tile service is unavailable. The cause is " + + "deliberately not described and is logged server-side instead.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "503", description = "DAS is still warming up.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "504", description = "DAS did not respond in time.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class)))}) + @GetMapping("/collections/{collectionId}/data_tiles/point") + public ResponseEntity getCollectionDataPoint( + @Parameter(in = ParameterIn.PATH, required = true, + description = "The metadata record UUID.", + example = "0c9eb39c-9cbe-4c6a-8a10-5867087e703a") + @PathVariable String collectionId, + + @Parameter(in = ParameterIn.QUERY, required = true, + description = "Dataset name — the `{dataset}` half of a DAS product id. Must be one of the " + + "collection's data assets.", + example = "model_sea_level_anomaly_gridded_realtime") + @RequestParam(required = false) String dataset, + + @Parameter(in = ParameterIn.QUERY, required = true, + description = "Variable name — the `{variable}` half of a DAS product id. A two-variable " + + "value such as `ucur+vcur` must have its `+` percent-encoded as `%2B`.", + example = "gsla") + @RequestParam(required = false) String variable, + + @Parameter(in = ParameterIn.QUERY, required = true, + description = "Date to decode, strict `YYYY-MM-DD`. Must be one of the product's " + + "`available_dates`.", + example = "2024-01-01") + @RequestParam(required = false) String datetime, + + @Parameter(in = ParameterIn.QUERY, required = true, + description = "Latitude of the point, in degrees.", + schema = @Schema(type = "number", minimum = "-90", maximum = "90"), example = "-44.27") + @RequestParam(required = false) Double lat, + + @Parameter(in = ParameterIn.QUERY, required = true, + description = "Longitude of the point, in degrees.", + schema = @Schema(type = "number", minimum = "-180", maximum = "180"), example = "132.00") + @RequestParam(required = false) Double lon) { + + validateProductParams(dataset, variable, datetime); + validateLatLon(lat, lon); + + String product = dataset + ":" + variable; + DasTilerService.DasJsonResult point = dasTilerService.getPoint(product, datetime, lat, lon); + + ResponseEntity.BodyBuilder response = ResponseEntity.ok(); + if (point.cacheControl() != null) { + response.header(HttpHeaders.CACHE_CONTROL, point.cacheControl()); + } + return response.body(point.body()); + } + @Operation( summary = "Retrieve the data-tile decode manifest for a product", description = "Returns the per-date manifest a client must fetch **before** requesting data " + @@ -406,6 +600,15 @@ private void validateProductParams(String dataset, String variable, String datet } } + private void validateLatLon(Double lat, Double lon) { + if (lat == null || lat < -90 || lat > 90) { + throw new InvalidParameterException("lat is required and must be between -90 and 90"); + } + if (lon == null || lon < -180 || lon > 180) { + throw new InvalidParameterException("lon is required and must be between -180 and 180"); + } + } + @Operation( summary = "List the available colormaps", description = "Names accepted by the tile route's `colormap` parameter and the legend endpoint. " + diff --git a/server/src/main/resources/application.yaml b/server/src/main/resources/application.yaml index d63fd46c..7c05b062 100644 --- a/server/src/main/resources/application.yaml +++ b/server/src/main/resources/application.yaml @@ -468,7 +468,7 @@ springdoc: path: /api/v1/ogc/api-docs/v3 data-access-service: - host: http://localhost:5000 + host: http://localhost:8000 secret: 123 connect-timeout: 5s read-timeout: 30s diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerServiceTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerServiceTest.java index f9ef856c..6eef32ad 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerServiceTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerServiceTest.java @@ -188,6 +188,62 @@ public void testGetDataTileNotFoundMirrored() { assertEquals("LOD 9 not in grid", ex.getMessage()); } + // --- Point: decoded value(s) at a lat/lon, JSON body with query params --- + + @Test + public void testGetPointSendsProductAndDateAsPathVariablesWithLatLonQuery() { + ObjectNode pointBody = new ObjectMapper().createObjectNode(); + pointBody.put("lat", -44.27813720703125); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable"); + when(httpClient.getForEntity(anyString(), eq(JsonNode.class), anyMap())) + .thenReturn(new ResponseEntity<>(pointBody, headers, HttpStatus.OK)); + + service.getPoint(PRODUCT_ID, "2024-01-01", -44.27, 132.00); + + CapturedRequest captured = captureJsonRequest(); + assertTrue(captured.url.contains("/data_tiles/{product}/{date}/point"), + "point must expand product/date as path variables, got: " + captured.url); + assertTrue(captured.url.contains("lat={lat}") && captured.url.contains("lon={lon}"), + "lat/lon must be query params, got: " + captured.url); + assertEquals(PRODUCT_ID, captured.params.get("product"), "product id with ':' must be a raw path variable"); + assertEquals("2024-01-01", captured.params.get("date")); + assertEquals(-44.27, captured.params.get("lat")); + assertEquals(132.00, captured.params.get("lon")); + } + + @Test + public void testGetPointForwardsBodyAndCacheControl() { + ObjectNode pointBody = new ObjectMapper().createObjectNode(); + pointBody.put("lat", -44.27813720703125); + pointBody.put("lon", 132.0092315673828); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable"); + when(httpClient.getForEntity(anyString(), eq(JsonNode.class), anyMap())) + .thenReturn(new ResponseEntity<>(pointBody, headers, HttpStatus.OK)); + + DasTilerService.DasJsonResult result = service.getPoint(PRODUCT_ID, "2024-01-01", -44.27, 132.00); + + assertEquals(pointBody, result.body()); + assertEquals("public, max-age=31536000, immutable", result.cacheControl()); + } + + @Test + public void testGetPointNotFoundMirrored() { + when(httpClient.getForEntity(anyString(), eq(JsonNode.class), anyMap())) + .thenThrow(HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, + "{\"detail\":\"point outside coverage\"}".getBytes(), null)); + + DasUpstreamException ex = assertThrows(DasUpstreamException.class, + () -> service.getPoint(PRODUCT_ID, "2024-01-01", -89.0, 0.0)); + + assertEquals(HttpStatus.NOT_FOUND, ex.getStatus()); + assertEquals("point outside coverage", ex.getMessage()); + } + // --- Data manifest: JSON body, but (unlike the plain getters) forwards Cache-Control --- @Test @@ -353,6 +409,39 @@ public void testOtherNetworkFailureMappedTo502() { assertEquals(HttpStatus.BAD_GATEWAY, ex.getStatus()); } + // --- Product/date manifest (visual_tiles): JSON body, forwards whatever Cache-Control DAS sent --- + + @Test + public void testGetManifestBuildsUrlAndForwardsCacheControl() { + ObjectNode manifestBody = new ObjectMapper().createObjectNode(); + manifestBody.putObject("products"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.CACHE_CONTROL, "public, max-age=60"); + when(httpClient.getForEntity(anyString(), eq(JsonNode.class), anyMap())) + .thenReturn(new ResponseEntity<>(manifestBody, headers, HttpStatus.OK)); + + DasTilerService.DasJsonResult result = service.getManifest(); + + CapturedRequest captured = captureJsonRequest(); + assertTrue(captured.url.contains("/visual_tiles/manifest"), "got: " + captured.url); + assertEquals(manifestBody, result.body()); + // Whatever freshness DAS decided must ride through as-is — this service must not invent + // its own Cache-Control for a response it doesn't own the freshness of. + assertEquals("public, max-age=60", result.cacheControl()); + } + + @Test + public void testGetManifestServerErrorMappedTo502() { + when(httpClient.getForEntity(anyString(), eq(JsonNode.class), anyMap())) + .thenThrow(HttpServerErrorException.create( + HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", HttpHeaders.EMPTY, new byte[0], null)); + + DasUpstreamException ex = assertThrows(DasUpstreamException.class, () -> service.getManifest()); + + assertEquals(HttpStatus.BAD_GATEWAY, ex.getStatus()); + } + @Test public void testProductsForCollectionFiltersByMetadataUuid() { ObjectMapper mapper = new ObjectMapper(); @@ -368,6 +457,36 @@ public void testProductsForCollectionFiltersByMetadataUuid() { assertTrue(service.productsForCollection("unknown-uuid").isEmpty()); } + @Test + public void testProductsForCollectionsFiltersByGivenIds() { + ObjectMapper mapper = new ObjectMapper(); + JsonNode products = mapper.createArrayNode() + .add(mapper.createObjectNode().put("id", "p1").put("metadata_uuid", "uuid-a")) + .add(mapper.createObjectNode().put("id", "p2").put("metadata_uuid", "uuid-b")) + .add(mapper.createObjectNode().put("id", "p3").put("metadata_uuid", "uuid-c")); + when(httpClient.getForObject(anyString(), eq(JsonNode.class))) + .thenReturn(products); + + List result = service.productsForCollections(List.of("uuid-a", "uuid-c")); + + assertEquals(2, result.size()); + assertEquals("p1", result.get(0).get("id").asText()); + assertEquals("p3", result.get(1).get("id").asText()); + } + + @Test + public void testProductsForCollectionsWithNullOrEmptyIdsReturnsEverything() { + ObjectMapper mapper = new ObjectMapper(); + JsonNode products = mapper.createArrayNode() + .add(mapper.createObjectNode().put("id", "p1").put("metadata_uuid", "uuid-a")) + .add(mapper.createObjectNode().put("id", "p2").put("metadata_uuid", "uuid-b")); + when(httpClient.getForObject(anyString(), eq(JsonNode.class))) + .thenReturn(products); + + assertEquals(2, service.productsForCollections(null).size()); + assertEquals(2, service.productsForCollections(List.of()).size()); + } + private record CapturedRequest(String url, Map params) { } } diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java index de1b1aa1..6e75f526 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.List; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; @@ -73,7 +74,7 @@ private JsonNode scalarProductWithVisual(String id, String metadataUuid, String return node; } - private ObjectNode manifestWith(String productId) { + private DasTilerService.DasJsonResult manifestWith(String productId) { ObjectNode manifest = mapper.createObjectNode(); ObjectNode products = mapper.createObjectNode(); ObjectNode availability = mapper.createObjectNode(); @@ -84,7 +85,11 @@ private ObjectNode manifestWith(String productId) { availability.set("full_date_range", range); products.set(productId, availability); manifest.set("products", products); - return manifest; + return new DasTilerService.DasJsonResult(manifest, "public, max-age=31536000, immutable"); + } + + private DasTilerService.DasJsonResult emptyManifest() { + return new DasTilerService.DasJsonResult(mapper.createObjectNode(), "public, max-age=31536000, immutable"); } @Test @@ -149,7 +154,7 @@ public void verifyScalarProductAdvertisesVisualAndDataTileTypes() { @Test public void verifyCollectionProductsEmptyWhenNoneMatch() { when(dasTilerService.productsForCollection("uuid-none")).thenReturn(List.of()); - when(dasTilerService.getManifest()).thenReturn(mapper.createObjectNode()); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); ResponseEntity response = testRestTemplate.getForEntity( getExternalBasePath() + "/tiles/collections/uuid-none/products", JsonNode.class @@ -164,7 +169,7 @@ public void verifyMultiVariableProductAdvertisesDataTileTypeOnly() { when(dasTilerService.productsForCollection("uuid-a")).thenReturn( List.of(multiVariableProduct("model_currents:ucur+vcur", "uuid-a", List.of("UCUR", "VCUR"))) ); - when(dasTilerService.getManifest()).thenReturn(mapper.createObjectNode()); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); ResponseEntity response = testRestTemplate.getForEntity( getExternalBasePath() + "/tiles/collections/uuid-a/products", JsonNode.class @@ -235,7 +240,7 @@ public void verifyPairIsDataOnlyEvenWhenDasReportsVisualExplicitly() { JsonNode product = multiVariableProduct("model_currents:ucur+vcur", "uuid-a", List.of("UCUR", "VCUR")); ((ObjectNode) product).put("visual", false); when(dasTilerService.productsForCollection("uuid-a")).thenReturn(List.of(product)); - when(dasTilerService.getManifest()).thenReturn(mapper.createObjectNode()); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); JsonNode entry = getProducts("uuid-a").get(0); @@ -274,7 +279,7 @@ public void verifyDataCapabilityStillFollowsArityNotTheVisualField() { when(dasTilerService.productsForCollection("uuid-a")).thenReturn( List.of(scalarProductWithVisual("model_sla:wdir", "uuid-a", "WDIR", false)) ); - when(dasTilerService.getManifest()).thenReturn(mapper.createObjectNode()); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); JsonNode entry = getProducts("uuid-a").get(0); @@ -296,6 +301,77 @@ private List tileTypesOf(JsonNode entry) { return types; } + // --- Batched products route: multiple collectionIds, or every collection when omitted --- + + @Test + public void verifyProductsForCollectionsWithNoIdsListsEveryCollection() { + JsonNode productA = singleVariableProduct("model_sla:gsla", "uuid-a", "GSLA"); + JsonNode productB = singleVariableProduct("satellite_austemp_heatwave_14day:mcs_category", "uuid-b", "MCS_category"); + when(dasTilerService.getProducts()).thenReturn(List.of(productA, productB)); + when(dasTilerService.getManifest()).thenReturn(manifestWith("model_sla:gsla")); + + ResponseEntity response = testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/products", JsonNode.class + ); + + Assertions.assertEquals(HttpStatus.OK, response.getStatusCode()); + JsonNode products = response.getBody().get("products"); + Assertions.assertEquals(2, products.size()); + Assertions.assertEquals("uuid-a", products.get(0).get("collectionId").asText()); + Assertions.assertEquals("uuid-b", products.get(1).get("collectionId").asText()); + verify(dasTilerService, never()).productsForCollections(org.mockito.ArgumentMatchers.any()); + } + + @Test + public void verifyProductsForCollectionsFiltersByGivenIds() { + JsonNode productA = singleVariableProduct("model_sla:gsla", "uuid-a", "GSLA"); + JsonNode productB = singleVariableProduct("model_currents:ucur", "uuid-b", "UCUR"); + when(dasTilerService.productsForCollections(List.of("uuid-a", "uuid-b"))) + .thenReturn(List.of(productA, productB)); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); + + ResponseEntity response = testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/products?collectionId=uuid-a&collectionId=uuid-b", + JsonNode.class + ); + + Assertions.assertEquals(HttpStatus.OK, response.getStatusCode()); + JsonNode products = response.getBody().get("products"); + Assertions.assertEquals(2, products.size()); + verify(dasTilerService).productsForCollections(List.of("uuid-a", "uuid-b")); + verify(dasTilerService, never()).getProducts(); + } + + @Test + public void verifyProductsForCollectionsEntryHasOwnUrlTemplatesAndCollectionId() { + JsonNode product = singleVariableProduct("satellite_austemp_heatwave_14day:mcs_category", "uuid-b", "MCS_category"); + when(dasTilerService.productsForCollections(List.of("uuid-b"))).thenReturn(List.of(product)); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); + + ResponseEntity response = testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/products?collectionId=uuid-b", JsonNode.class + ); + + JsonNode entry = response.getBody().get("products").get(0); + Assertions.assertEquals("uuid-b", entry.get("collectionId").asText()); + String dataTemplate = entry.get("data_tile_url_template").asText(); + Assertions.assertTrue(dataTemplate.contains("/collections/uuid-b/data_tiles/"), + "url template must be scoped to the product's own collection, got: " + dataTemplate); + } + + @Test + public void verifyProductsForCollectionsEmptyWhenNoneMatch() { + when(dasTilerService.productsForCollections(List.of("uuid-none"))).thenReturn(List.of()); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); + + ResponseEntity response = testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/products?collectionId=uuid-none", JsonNode.class + ); + + Assertions.assertEquals(HttpStatus.OK, response.getStatusCode()); + Assertions.assertEquals(0, response.getBody().get("products").size()); + } + // --- Data-tile route: value-encoded PNG passthrough, floor-only validation, forwarded DAS errors --- @Test @@ -422,6 +498,91 @@ public void verifyDataTileMirrorsUpstreamServiceUnavailable() { } } + // --- Point route: decoded value(s) at a lat/lon, JSON body with query params --- + + @Test + public void verifyDataPointReturnsJsonWithCacheControl() { + ObjectNode pointBody = mapper.createObjectNode(); + pointBody.put("lat", -44.27813720703125); + pointBody.put("lon", 132.0092315673828); + when(dasTilerService.getPoint("model_sla:gsla", "2024-01-01", -44.27, 132.00)) + .thenReturn(new DasTilerService.DasJsonResult(pointBody, "public, max-age=31536000, immutable")); + + ResponseEntity response = testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-01-01&lat=-44.27&lon=132.00", JsonNode.class); + + Assertions.assertEquals(HttpStatus.OK, response.getStatusCode()); + Assertions.assertTrue(response.getBody().has("lat")); + Assertions.assertEquals("public, max-age=31536000, immutable", response.getHeaders().getCacheControl()); + } + + @Test + public void verifyDataPointRejectsMissingOrMalformedParams() { + // missing dataset + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?variable=gsla&datetime=2024-01-01&lat=-44.27&lon=132.00", ErrorResponse.class).getStatusCode()); + // missing variable + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&datetime=2024-01-01&lat=-44.27&lon=132.00", ErrorResponse.class).getStatusCode()); + // datetime not YYYY-MM-DD + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-1-1&lat=-44.27&lon=132.00", ErrorResponse.class).getStatusCode()); + } + + @Test + public void verifyDataPointRejectsMissingOrOutOfRangeLatLon() { + // missing lat + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-01-01&lon=132.00", ErrorResponse.class).getStatusCode()); + // missing lon + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-01-01&lat=-44.27", ErrorResponse.class).getStatusCode()); + // lat out of range + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-01-01&lat=91&lon=132.00", ErrorResponse.class).getStatusCode()); + // lon out of range + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-01-01&lat=-44.27&lon=181", ErrorResponse.class).getStatusCode()); + verify(dasTilerService, never()).getPoint(anyString(), anyString(), anyDouble(), anyDouble()); + } + + @Test + public void verifyDataPointRejectsUnencodedPlusInVariable() { + // A raw '+' decodes to a space, so the product id would be 'model_sla:ucur vcur' — caught + // here rather than forwarded to DAS as an unresolvable id. + ResponseEntity response = testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=ucur+vcur&datetime=2024-01-01&lat=-44.27&lon=132.00", ErrorResponse.class); + + Assertions.assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + Assertions.assertTrue(response.getBody().getMessage().contains("%2B"), + "the message must name the fix, got: " + response.getBody().getMessage()); + verify(dasTilerService, never()).getPoint(anyString(), anyString(), anyDouble(), anyDouble()); + } + + @Test + public void verifyDataPointUnknownProductIsForwardedToDas() { + // DAS owns the product catalogue, so an unknown dataset is its answer to give. + when(dasTilerService.getPoint("wrong:gsla", "2024-01-01", -44.27, 132.00)) + .thenThrow(new DasUpstreamException(HttpStatus.NOT_FOUND, "Unknown product: wrong:gsla")); + + ResponseEntity response = testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=wrong&variable=gsla&datetime=2024-01-01&lat=-44.27&lon=132.00", ErrorResponse.class); + + Assertions.assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + Assertions.assertEquals("Unknown product: wrong:gsla", response.getBody().getMessage()); + verify(dasTilerService).getPoint("wrong:gsla", "2024-01-01", -44.27, 132.00); + } + @Test public void verifyDataManifestReturnsJsonWithCacheControl() { ObjectNode manifestBody = mapper.createObjectNode(); From 8df5d44ea3b76492ede5f99c9c9f2812e46ad6f4 Mon Sep 17 00:00:00 2001 From: leslieduan Date: Wed, 12 Aug 2026 11:51:20 +1000 Subject: [PATCH 2/3] das port back 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 7c05b062..d63fd46c 100644 --- a/server/src/main/resources/application.yaml +++ b/server/src/main/resources/application.yaml @@ -468,7 +468,7 @@ springdoc: path: /api/v1/ogc/api-docs/v3 data-access-service: - host: http://localhost:8000 + host: http://localhost:5000 secret: 123 connect-timeout: 5s read-timeout: 30s From edc837e32919758d4d75767ecb846ef01c56c333 Mon Sep 17 00:00:00 2001 From: leslieduan Date: Wed, 12 Aug 2026 15:22:51 +1000 Subject: [PATCH 3/3] remove get all prudcts for now --- .../aodn/ogcapi/server/tile/RestExtApi.java | 216 +++++------------- .../ogcapi/server/tile/RestExtApiTest.java | 71 ------ 2 files changed, 61 insertions(+), 226 deletions(-) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java index b668df84..fcfe9bca 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java @@ -116,163 +116,69 @@ public ResponseEntity getCollectionProducts( ArrayNode result = mapper.createArrayNode(); for (JsonNode product : products) { - result.add(buildProductEntry(product, collectionId, manifestProducts)); - } - - ObjectNode body = mapper.createObjectNode(); - body.set("products", result); - - ResponseEntity.BodyBuilder response = ResponseEntity.ok(); - if (manifest.cacheControl() != null) { - response.header(HttpHeaders.CACHE_CONTROL, manifest.cacheControl()); - } - return response.body(body); - } - - /** - * Builds one product entry of the shape shared by {@link #getCollectionProducts} and - * {@link #getProductsForCollections}: capability list, availability from the manifest, and - * ready-to-use URL templates scoped to {@code collectionId}. - */ - private ObjectNode buildProductEntry(JsonNode product, String collectionId, JsonNode manifestProducts) { - String id = product.path("id").asText(); - JsonNode variable = product.path("variable"); - int variableCount = variable.isArray() ? variable.size() : 1; - - ObjectNode entry = mapper.createObjectNode(); - entry.put("id", id); - entry.set("variable", variable); - - // tile_types is a capability list: what THIS service can serve today, not a property of - // the data. Visual capability now comes from DAS, which knows whether a variable is - // actually renderable — arity cannot tell a colourisable scalar from one the renderer - // has no sensible colouring for. The arity rule survives only as a fallback for a DAS - // old enough to have no `visual` field, which the OGC-first deployment order requires. - // Data tiles still follow arity: the shader packs one or two channels, and DAS config - // validation guarantees nothing longer reaches here. - boolean canVisual = product.has("visual") - ? product.path("visual").asBoolean() - : variableCount == 1; - boolean canData = variableCount == 1 || variableCount == 2; - ArrayNode tileTypes = mapper.createArrayNode(); - if (canVisual) { - tileTypes.add("visual"); - } - if (canData) { - tileTypes.add("data"); - } - entry.set("tile_types", tileTypes); - - JsonNode availability = manifestProducts != null ? manifestProducts.path(id) : null; - entry.set("available_dates", availability != null && !availability.isMissingNode() - ? availability.path("available_dates") : mapper.createArrayNode()); - entry.set("full_date_range", availability != null && !availability.isMissingNode() - ? availability.path("full_date_range") : mapper.createObjectNode()); - - // The tile routes take dataset and variable separately, so split the product id on its - // first ':'. That split is the only place the id is treated as anything but opaque. The - // variable half of a two-variable product contains '+' (e.g. ucur+vcur), which URLEncoder - // renders as %2B — without that a query string would decode it back to a space. - int sep = id.indexOf(':'); - String datasetPart = sep >= 0 ? id.substring(0, sep) : id; - String variablePart = sep >= 0 ? id.substring(sep + 1) : ""; - String encodedDataset = URLEncoder.encode(datasetPart, StandardCharsets.UTF_8); - String encodedVariable = URLEncoder.encode(variablePart, StandardCharsets.UTF_8); - - if (canVisual) { - entry.put("visual_tile_url_template", - "/api/v1/ogc/collections/" + collectionId + "/map/tiles/WebMercatorQuad/{z}/{x}/{y}" - + "?dataset=" + encodedDataset + "&variable=" + encodedVariable - + "&datetime={datetime}&f=png"); - entry.put("legend_url", "/api/v1/ogc/ext/tiles/colormaps/{colormap}/legend"); - } - - if (canData) { - entry.put("data_tile_url_template", - "/api/v1/ogc/ext/tiles/collections/" + collectionId + "/data_tiles/{lod}/{x}/{y}" - + "?dataset=" + encodedDataset + "&variable=" + encodedVariable - + "&datetime={datetime}"); - entry.put("data_manifest_url_template", - "/api/v1/ogc/ext/tiles/collections/" + collectionId + "/data_tiles/manifest" - + "?dataset=" + encodedDataset + "&variable=" + encodedVariable - + "&datetime={datetime}"); - } - - return entry; - } - - @Operation( - summary = "List the renderable tiler products of several collections, or all of them", - description = "Batched form of `GET /collections/{collectionId}/products`: pass one or more " + - "`collectionId` query params to fetch several collections in a single round trip, or " + - "omit it entirely to list every tiler product across every collection. Since the result " + - "can span more than one collection, each entry additionally carries the `collectionId` " + - "it belongs to — otherwise the entry shape is identical to the single-collection route." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", - description = "The matching products; an empty array if none match.", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "products": [ - { - "collectionId": "0c9eb39c-9cbe-4c6a-8a10-5867087e703a", - "id": "model_sea_level_anomaly_gridded_realtime:gsla", - "variable": "GSLA", - "tile_types": ["visual", "data"], - "available_dates": ["2024-01-01", "2024-01-02"], - "full_date_range": {"start": "2020-01-01", "end": "2024-01-02"}, - "visual_tile_url_template": "/api/v1/ogc/collections/0c9eb39c-9cbe-4c6a-8a10-5867087e703a/map/tiles/WebMercatorQuad/{z}/{x}/{y}?dataset=model_sea_level_anomaly_gridded_realtime&variable=gsla&datetime={datetime}&f=png", - "legend_url": "/api/v1/ogc/ext/tiles/colormaps/{colormap}/legend", - "data_tile_url_template": "/api/v1/ogc/ext/tiles/collections/0c9eb39c-9cbe-4c6a-8a10-5867087e703a/data_tiles/{lod}/{x}/{y}?dataset=model_sea_level_anomaly_gridded_realtime&variable=gsla&datetime={datetime}", - "data_manifest_url_template": "/api/v1/ogc/ext/tiles/collections/0c9eb39c-9cbe-4c6a-8a10-5867087e703a/data_tiles/manifest?dataset=model_sea_level_anomaly_gridded_realtime&variable=gsla&datetime={datetime}" - }, - { - "collectionId": "1a2b3c4d-0000-1111-2222-333344445555", - "id": "satellite_austemp_heatwave_14day:mcs_category", - "variable": "MCS_category", - "tile_types": ["data"], - "available_dates": ["2026-02-14"], - "full_date_range": {"start": "2020-01-01", "end": "2026-02-14"}, - "data_tile_url_template": "/api/v1/ogc/ext/tiles/collections/1a2b3c4d-0000-1111-2222-333344445555/data_tiles/{lod}/{x}/{y}?dataset=satellite_austemp_heatwave_14day&variable=mcs_category&datetime={datetime}", - "data_manifest_url_template": "/api/v1/ogc/ext/tiles/collections/1a2b3c4d-0000-1111-2222-333344445555/data_tiles/manifest?dataset=satellite_austemp_heatwave_14day&variable=mcs_category&datetime={datetime}" - } - ] - }"""))), - @ApiResponse(responseCode = "429", description = "Upstream rate limit reached.", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "502", description = "DAS unreachable, errored, or rejected this service's API key.", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "503", description = "DAS is still warming up.", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "504", description = "DAS did not respond in time.", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class)))}) - @GetMapping("/collections/products") - public ResponseEntity getProductsForCollections( - @Parameter(in = ParameterIn.QUERY, - description = "Collection identifier(s) (metadata record UUID) to include. Repeat for " + - "multiple collections (`collectionId=a&collectionId=b`) or omit entirely to list " + - "every collection's products.", - example = "0c9eb39c-9cbe-4c6a-8a10-5867087e703a") - @RequestParam(required = false) List collectionId) { - - List products = (collectionId == null || collectionId.isEmpty()) - ? dasTilerService.getProducts() - : dasTilerService.productsForCollections(collectionId); - DasTilerService.DasJsonResult manifest = dasTilerService.getManifest(); - JsonNode manifestProducts = manifest.body() != null ? manifest.body().path("products") : null; + String id = product.path("id").asText(); + JsonNode variable = product.path("variable"); + int variableCount = variable.isArray() ? variable.size() : 1; - ArrayNode result = mapper.createArrayNode(); - for (JsonNode product : products) { - String productCollectionId = product.path("metadata_uuid").asText(null); ObjectNode entry = mapper.createObjectNode(); - entry.put("collectionId", productCollectionId); - entry.setAll(buildProductEntry(product, productCollectionId, manifestProducts)); + entry.put("id", id); + entry.set("variable", variable); + + // tile_types is a capability list: what THIS service can serve today, not a property of + // the data. Visual capability now comes from DAS, which knows whether a variable is + // actually renderable — arity cannot tell a colourisable scalar from one the renderer + // has no sensible colouring for. The arity rule survives only as a fallback for a DAS + // old enough to have no `visual` field, which the OGC-first deployment order requires. + // Data tiles still follow arity: the shader packs one or two channels, and DAS config + // validation guarantees nothing longer reaches here. + boolean canVisual = product.has("visual") + ? product.path("visual").asBoolean() + : variableCount == 1; + boolean canData = variableCount == 1 || variableCount == 2; + ArrayNode tileTypes = mapper.createArrayNode(); + if (canVisual) { + tileTypes.add("visual"); + } + if (canData) { + tileTypes.add("data"); + } + entry.set("tile_types", tileTypes); + + JsonNode availability = manifestProducts != null ? manifestProducts.path(id) : null; + entry.set("available_dates", availability != null && !availability.isMissingNode() + ? availability.path("available_dates") : mapper.createArrayNode()); + entry.set("full_date_range", availability != null && !availability.isMissingNode() + ? availability.path("full_date_range") : mapper.createObjectNode()); + + // The tile routes take dataset and variable separately, so split the product id on its + // first ':'. That split is the only place the id is treated as anything but opaque. The + // variable half of a two-variable product contains '+' (e.g. ucur+vcur), which URLEncoder + // renders as %2B — without that a query string would decode it back to a space. + int sep = id.indexOf(':'); + String datasetPart = sep >= 0 ? id.substring(0, sep) : id; + String variablePart = sep >= 0 ? id.substring(sep + 1) : ""; + String encodedDataset = URLEncoder.encode(datasetPart, StandardCharsets.UTF_8); + String encodedVariable = URLEncoder.encode(variablePart, StandardCharsets.UTF_8); + + if (canVisual) { + entry.put("visual_tile_url_template", + "/api/v1/ogc/collections/" + collectionId + "/map/tiles/WebMercatorQuad/{z}/{x}/{y}" + + "?dataset=" + encodedDataset + "&variable=" + encodedVariable + + "&datetime={datetime}&f=png"); + entry.put("legend_url", "/api/v1/ogc/ext/tiles/colormaps/{colormap}/legend"); + } + + if (canData) { + entry.put("data_tile_url_template", + "/api/v1/ogc/ext/tiles/collections/" + collectionId + "/data_tiles/{lod}/{x}/{y}" + + "?dataset=" + encodedDataset + "&variable=" + encodedVariable + + "&datetime={datetime}"); + entry.put("data_manifest_url_template", + "/api/v1/ogc/ext/tiles/collections/" + collectionId + "/data_tiles/manifest" + + "?dataset=" + encodedDataset + "&variable=" + encodedVariable + + "&datetime={datetime}"); + } + result.add(entry); } diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java index 6e75f526..558db85c 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java @@ -301,77 +301,6 @@ private List tileTypesOf(JsonNode entry) { return types; } - // --- Batched products route: multiple collectionIds, or every collection when omitted --- - - @Test - public void verifyProductsForCollectionsWithNoIdsListsEveryCollection() { - JsonNode productA = singleVariableProduct("model_sla:gsla", "uuid-a", "GSLA"); - JsonNode productB = singleVariableProduct("satellite_austemp_heatwave_14day:mcs_category", "uuid-b", "MCS_category"); - when(dasTilerService.getProducts()).thenReturn(List.of(productA, productB)); - when(dasTilerService.getManifest()).thenReturn(manifestWith("model_sla:gsla")); - - ResponseEntity response = testRestTemplate.getForEntity( - getExternalBasePath() + "/tiles/collections/products", JsonNode.class - ); - - Assertions.assertEquals(HttpStatus.OK, response.getStatusCode()); - JsonNode products = response.getBody().get("products"); - Assertions.assertEquals(2, products.size()); - Assertions.assertEquals("uuid-a", products.get(0).get("collectionId").asText()); - Assertions.assertEquals("uuid-b", products.get(1).get("collectionId").asText()); - verify(dasTilerService, never()).productsForCollections(org.mockito.ArgumentMatchers.any()); - } - - @Test - public void verifyProductsForCollectionsFiltersByGivenIds() { - JsonNode productA = singleVariableProduct("model_sla:gsla", "uuid-a", "GSLA"); - JsonNode productB = singleVariableProduct("model_currents:ucur", "uuid-b", "UCUR"); - when(dasTilerService.productsForCollections(List.of("uuid-a", "uuid-b"))) - .thenReturn(List.of(productA, productB)); - when(dasTilerService.getManifest()).thenReturn(emptyManifest()); - - ResponseEntity response = testRestTemplate.getForEntity( - getExternalBasePath() + "/tiles/collections/products?collectionId=uuid-a&collectionId=uuid-b", - JsonNode.class - ); - - Assertions.assertEquals(HttpStatus.OK, response.getStatusCode()); - JsonNode products = response.getBody().get("products"); - Assertions.assertEquals(2, products.size()); - verify(dasTilerService).productsForCollections(List.of("uuid-a", "uuid-b")); - verify(dasTilerService, never()).getProducts(); - } - - @Test - public void verifyProductsForCollectionsEntryHasOwnUrlTemplatesAndCollectionId() { - JsonNode product = singleVariableProduct("satellite_austemp_heatwave_14day:mcs_category", "uuid-b", "MCS_category"); - when(dasTilerService.productsForCollections(List.of("uuid-b"))).thenReturn(List.of(product)); - when(dasTilerService.getManifest()).thenReturn(emptyManifest()); - - ResponseEntity response = testRestTemplate.getForEntity( - getExternalBasePath() + "/tiles/collections/products?collectionId=uuid-b", JsonNode.class - ); - - JsonNode entry = response.getBody().get("products").get(0); - Assertions.assertEquals("uuid-b", entry.get("collectionId").asText()); - String dataTemplate = entry.get("data_tile_url_template").asText(); - Assertions.assertTrue(dataTemplate.contains("/collections/uuid-b/data_tiles/"), - "url template must be scoped to the product's own collection, got: " + dataTemplate); - } - - @Test - public void verifyProductsForCollectionsEmptyWhenNoneMatch() { - when(dasTilerService.productsForCollections(List.of("uuid-none"))).thenReturn(List.of()); - when(dasTilerService.getManifest()).thenReturn(emptyManifest()); - - ResponseEntity response = testRestTemplate.getForEntity( - getExternalBasePath() + "/tiles/collections/products?collectionId=uuid-none", JsonNode.class - ); - - Assertions.assertEquals(HttpStatus.OK, response.getStatusCode()); - Assertions.assertEquals(0, response.getBody().get("products").size()); - } - // --- Data-tile route: value-encoded PNG passthrough, floor-only validation, forwarded DAS errors --- @Test