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
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@
<dependency>
<groupId>au.org.aodn</groupId>
<artifactId>stacmodel</artifactId>
<version>0.0.63</version>
<version>0.0.66</version>
</dependency>
</dependencies>
</dependencyManagement>
Expand Down
4 changes: 0 additions & 4 deletions server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,6 @@
<groupId>au.org.aodn</groupId>
<artifactId>stacmodel</artifactId>
</dependency>
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>jackson-databind-nullable</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package au.org.aodn.ogcapi.server.common;


import au.org.aodn.ogcapi.server.core.configuration.OgcApiProperties;
import au.org.aodn.ogcapi.server.core.model.enumeration.CQLCrsType;
import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.v3.oas.annotations.Parameter;
Expand Down Expand Up @@ -28,6 +29,9 @@ public class RestAdminApi {
@Autowired
protected RestAdminService restAdminService;

@Autowired
protected OgcApiProperties ogcApiProperties;

/**
* Explain the detail relevance score of a search query
* Internal debugging/troubleshooting usage only
Expand All @@ -51,7 +55,7 @@ public ResponseEntity<JsonNode> getExplainByParameters(
@Parameter(in = ParameterIn.QUERY, description = "Response format, simple for the flattened score breakdown, anything else returns the full elastic search explanation")
@RequestParam(value = "format", required = false) String format
) throws Exception {
if (!restAdminService.isElasticsearchExplainEnabled()) {
if (!ogcApiProperties.debug().elasticsearchExplainEnabled()) {
//return 404 NotFound error if elasticsearch-explain-enabled is set as false
return ResponseEntity.notFound().build();
}
Expand Down Expand Up @@ -88,7 +92,7 @@ public ResponseEntity<JsonNode> getExplainByParametersUuid(
@Parameter(in = ParameterIn.QUERY, description = "Filter language")
@RequestParam(value = "filter-lang", required = false, defaultValue = "cql-text") String filterLang
) throws Exception {
if (!restAdminService.isElasticsearchExplainEnabled()) {
if (!ogcApiProperties.debug().elasticsearchExplainEnabled()) {
//return 404 NotFound error if elasticsearch-explain-enabled is set as false
return ResponseEntity.notFound().build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,17 @@
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@Slf4j
public class RestAdminService {
@Value("${ogcapi.debug.elasticsearch-explain-enabled:false}")
protected boolean elasticsearchExplainEnabled;

@Autowired
protected Search searchService;

/**
* Value defined in application-*.yml, set as true for dev, edge, staging, production and test.
* The default is false, so any environment that does not set it explicitly has explain disabled.
*/
public boolean isElasticsearchExplainEnabled() {
return elasticsearchExplainEnabled;
}

public JsonNode explainByParameters(
List<String> q,
String filter,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
IndexerProperties.class,
GNProperties.class,
DasProperties.class,
BatchJobProperties.class
BatchJobProperties.class,
OgcApiProperties.class
})
public class Config {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

@Component
public class CustomWebMvcConfigurer implements WebMvcConfigurer {

/**
* In springboot, parameter and path variable conversion isn't done via @JsonCreator but Converter, here
* we define additional generic converter for the Enum types.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package au.org.aodn.ogcapi.server.core.configuration;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

import java.util.List;
import java.util.Map;

@ConfigurationProperties(prefix = "ogcapi")
public record OgcApiProperties(
@DefaultValue Debug debug,
@DefaultValue HttpCache httpCache
) {
public record Debug(
@DefaultValue("false") boolean elasticsearchExplainEnabled
) {
}
Comment on lines +10 to +17

public record HttpCache(
@DefaultValue("false") boolean enabled,
List<Mapping> mappings
) {
public HttpCache {
if (mappings == null) {
mappings = List.of();
}
}
}

public record Mapping(
String path,
int maxAgeHours,
Map<String, String> expectedParams
) {
public Mapping {
if (expectedParams == null) {
expectedParams = Map.of();
}
}

/**
* True when the request path equals {@code path} and the query string contains
* exactly the keys and decoded values in {@code expectedParams} (no extras).
*/
public boolean matches(String requestPath, Map<String, String[]> queryParams) {
if (path == null || !path.equals(requestPath)) {
return false;
}
Map<String, String[]> params = queryParams == null ? Map.of() : queryParams;
if (expectedParams.size() != params.size()) {
return false;
}
for (Map.Entry<String, String> expected : expectedParams.entrySet()) {
String[] values = params.get(expected.getKey());
if (values == null || values.length != 1 || !expected.getValue().equals(values[0])) {
return false;
}
}
return true;
}

public String cacheControlHeader() {
return "public, max-age=" + Math.max(0, maxAgeHours) * 3600L;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package au.org.aodn.ogcapi.server.core.http;

import au.org.aodn.ogcapi.server.core.configuration.OgcApiProperties;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.io.PrintWriter;

/**
* Sets {@code Cache-Control} only when HTTP cache is enabled and the request path plus
* query parameters match a mapping exactly. Unmatched requests get no cache header.
* <p>
* Implemented as a filter (not a {@code HandlerInterceptor}) so the header is applied
* before the response body is written. {@code postHandle} runs after {@code @ResponseBody}
* conversion, by which time compression can already have committed the response.
*/
@Component
public class HttpCacheControlFilter extends OncePerRequestFilter {

private final OgcApiProperties ogcApiProperties;

public HttpCacheControlFilter(OgcApiProperties ogcApiProperties) {
this.ogcApiProperties = ogcApiProperties;
}

@Override
protected void doFilterInternal(
@Nullable HttpServletRequest request,
@Nullable HttpServletResponse response,
@Nullable FilterChain filterChain) throws ServletException, IOException {

OgcApiProperties.HttpCache httpCache = ogcApiProperties.httpCache();
if (httpCache == null || !httpCache.enabled() || (request != null && !HttpMethod.GET.matches(request.getMethod()))) {
if (filterChain != null) {
filterChain.doFilter(request, response);
}
return;
}

if (filterChain != null) {
CacheControlResponseWrapper wrapped = new CacheControlResponseWrapper(request, response, httpCache);
filterChain.doFilter(request, wrapped);
wrapped.applyIfEligible();
}
Comment on lines +43 to +55
}

static final class CacheControlResponseWrapper extends HttpServletResponseWrapper {

private final HttpServletRequest request;
private final OgcApiProperties.HttpCache httpCache;
private boolean applied;

CacheControlResponseWrapper(
HttpServletRequest request,
HttpServletResponse response,
OgcApiProperties.HttpCache httpCache) {
super(response);
this.request = request;
this.httpCache = httpCache;
}

void applyIfEligible() {
if (applied) {
return;
}
applied = true;
if (getStatus() != HttpStatus.OK.value()) {
return;
}
if (containsHeader(HttpHeaders.CACHE_CONTROL)) {
return;
}
String path = request.getRequestURI();
for (OgcApiProperties.Mapping mapping : httpCache.mappings()) {
if (mapping.matches(path, request.getParameterMap())) {
setHeader(HttpHeaders.CACHE_CONTROL, mapping.cacheControlHeader());
return;
}
}
}

@Override
public ServletOutputStream getOutputStream() throws IOException {
applyIfEligible();
return super.getOutputStream();
}

@Override
public PrintWriter getWriter() throws IOException {
applyIfEligible();
return super.getWriter();
}

@Override
public void flushBuffer() throws IOException {
applyIfEligible();
super.flushBuffer();
}
}
}
9 changes: 9 additions & 0 deletions server/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ server:
ogcapi:
debug:
elasticsearch-explain-enabled: false
http-cache:
enabled: true
mappings:
- path: "/api/v1/ogc/collections"
max-age-hours: 24
expected-params:
properties: "id,temporal"
filter: "temporal after 1970-01-01T00:00:00Z" # Decoded space format
sortby: "id"

elasticsearch:
index:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -805,4 +805,26 @@ public void verifyQueryByIdWorks() throws IOException {
assertEquals(1, Objects.requireNonNull(collections.getBody()).getCollections().size(), "hit 1");
assertEquals("516811d7-cd1e-207a-e0440003ba8c79dd", Objects.requireNonNull(collections.getBody()).getCollections().get(0).getId(), "id correct");
}
/**
* A config set for this particular api call, we need to set the cache control header to signal cloud-front caching
* @throws IOException Not expected
*/
Comment on lines +808 to +811
@Test
public void verifyCacheHeaderSetForQuery() throws IOException {
super.insertJsonToElasticRecordIndex(
"073fde5a-bff3-1c1f-e053-08114f8c5588.json",
"5c418118-2581-4936-b6fd-d6bedfe74f62.json",
"19da2ce7-138f-4427-89de-a50c724f5f54.json",
"516811d7-cd1e-207a-e0440003ba8c79dd.json",
"35234913-aa3c-48ec-b9a4-77f822f66ef8.json" // This one have cloud optimized index, that is assets.summary value
);

ResponseEntity<Collections> collections = testRestTemplate.exchange(
getBasePath() + "/collections?properties=id,temporal&filter=temporal after 1970-01-01T00:00:00Z&sortby=id",
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {});

assertNotNull(collections.getHeaders().getFirst("Cache-Control"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.client.Request;
import org.elasticsearch.client.Response;
import org.elasticsearch.client.RestClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
Expand All @@ -19,12 +23,16 @@
import org.testcontainers.elasticsearch.ElasticsearchContainer;
import org.testcontainers.utility.DockerImageName;

import java.io.IOException;

/**
* We use test container with docker image throughout the testing.
*/
@Configuration
public class ElasticSearchTestConfig {

private static final Logger log = LoggerFactory.getLogger(ElasticSearchTestConfig.class);

@Lazy
@Autowired
protected ElasticsearchContainer container;
Expand Down Expand Up @@ -53,6 +61,7 @@ public ElasticsearchContainer createElasticDockerTestContainer(
.allowInsecure();

ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE)
.withEnv("xpack.license.self_generated.type", "trial")
.waitingFor(httpsWaitStrategy);

container.start();
Expand Down Expand Up @@ -83,7 +92,24 @@ public RestClientTransport testRestClientTransport() {
})
.build();

startTrialLicense(client);

// Create the transport with a Jackson mapper
return new RestClientTransport(client, new JacksonJsonpMapper());
}

/**
* Testcontainers ships a basic licence. {@code semantic_text} needs the {@code inference}
* feature, which a 30-day self-generated trial enables. The container is discarded after tests.
*/
private static void startTrialLicense(RestClient client) {
try {
Request request = new Request("POST", "/_license/start_trial");
request.addParameter("acknowledge", "true");
Response response = client.performRequest(request);
log.info("Elasticsearch trial licence start returned {}", response.getStatusLine());
} catch (IOException e) {
log.warn("Could not start Elasticsearch trial licence (may already be trial): {}", e.getMessage());
}
}
}
Loading
Loading