Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Object> 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
Expand Down Expand Up @@ -153,15 +172,14 @@ public List<JsonNode> 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() {
Expand Down Expand Up @@ -203,9 +221,19 @@ public DasTileResult getLegend(String name, String rescale, Integer width, Integ
}

public List<JsonNode> 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<JsonNode> productsForCollections(Collection<String> collectionIds) {
boolean unfiltered = collectionIds == null || collectionIds.isEmpty();
List<JsonNode> 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);
}
}
Expand Down
119 changes: 114 additions & 5 deletions server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ public ResponseEntity<JsonNode> getCollectionProducts(
example = "0c9eb39c-9cbe-4c6a-8a10-5867087e703a")
@PathVariable String collectionId) {
List<JsonNode> 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) {
Expand Down Expand Up @@ -185,9 +185,11 @@ public ResponseEntity<JsonNode> getCollectionProducts(
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(
Expand Down Expand Up @@ -309,6 +311,104 @@ public ResponseEntity<byte[]> 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<JsonNode> 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 " +
Expand Down Expand Up @@ -406,6 +506,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. " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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<JsonNode> 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<String, Object> params) {
}
}
Loading
Loading