diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/common/RestExtApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/common/RestExtApi.java index 7bc28cf2..cfd7a345 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/common/RestExtApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/common/RestExtApi.java @@ -5,11 +5,16 @@ import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.NotImplementedException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.Cacheable; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -29,6 +34,9 @@ public class RestExtApi { @Autowired protected RestExtService restExtService; + @Value("${ogcapi.debug.elasticsearch-explain-enabled:false}") + protected boolean elasticsearchExplainEnabled; + /** * This call is cql aware, so if you provided the filter string, then the return value will be filtered by * the cql clause. @@ -47,6 +55,100 @@ public class RestExtApi { ) throws java.lang.Exception { return searchService.getAutocompleteSuggestions(input, cql, CQLCrsType.convertFromUrl("https://epsg.io/4326")); } + + @GetMapping(path="/explain") + public ResponseEntity getCollectionsExplain( + @Parameter(in = ParameterIn.QUERY, description = "Keyword search terms") + @Valid @RequestParam(value = "q", required = false) List q, + @Parameter(in = ParameterIn.QUERY, description = "Filter expression") + @RequestParam(value = "filter", required = false) String filter, + @Parameter(in = ParameterIn.QUERY, description = "Properties used by the production search request") + @Valid @RequestParam(value = "properties", required = false) List properties, + @Size(min = 1) + @Parameter(in = ParameterIn.QUERY, description = "Sort by a valid CQL property") + @Valid @RequestParam(value = "sortby", required = false, defaultValue = "-score,-rank") String sortBy, + @Parameter(in = ParameterIn.QUERY, description = "Coordinate system") + @RequestParam(value = "crs", required = false, defaultValue = "https://epsg.io/4326") String crs, + @Parameter(in = ParameterIn.QUERY, description = "Filter language") + @RequestParam(value = "filter-lang", required = false, defaultValue = "cql-text") String filterLang + ) throws Exception { + if (!elasticsearchExplainEnabled) { + return ResponseEntity.notFound().build(); + } + + boolean isCqlFilter = "cql-text".equals(filterLang); + CQLCrsType convertedCrs = CQLCrsType.convertFromUrl(crs); + + if (!isCqlFilter || convertedCrs != CQLCrsType.EPSG4326) { + List reasons = new ArrayList<>(); + + if (!isCqlFilter) { + reasons.add("Unknown filter language, support cql-text only"); + } + + if (convertedCrs != CQLCrsType.EPSG4326) { + reasons.add("Unknown crs, support EPSG4326 only"); + } + + throw new NotImplementedException(String.join(",", reasons)); + } + + return ResponseEntity.ok(searchService.explainByParameters( + q, + filter, + properties, + sortBy, + convertedCrs)); + } + + @GetMapping(path="/explain/{uuid}") + public ResponseEntity getCollectionExplain( + @Parameter(in = ParameterIn.PATH, description = "Elasticsearch document id") + @PathVariable String uuid, + @Parameter(in = ParameterIn.QUERY, description = "Keyword search terms") + @Valid @RequestParam(value = "q", required = false) List q, + @Parameter(in = ParameterIn.QUERY, description = "Filter expression") + @RequestParam(value = "filter", required = false) String filter, + @Parameter(in = ParameterIn.QUERY, description = "Properties used by the production search request") + @Valid @RequestParam(value = "properties", required = false) List properties, + @Size(min = 1) + @Parameter(in = ParameterIn.QUERY, description = "Sort by a valid CQL property") + @Valid @RequestParam(value = "sortby", required = false, defaultValue = "-score,-rank") String sortBy, + @Parameter(in = ParameterIn.QUERY, description = "Coordinate system") + @RequestParam(value = "crs", required = false, defaultValue = "https://epsg.io/4326") String crs, + @Parameter(in = ParameterIn.QUERY, description = "Filter language") + @RequestParam(value = "filter-lang", required = false, defaultValue = "cql-text") String filterLang + ) throws Exception { + if (!elasticsearchExplainEnabled) { + return ResponseEntity.notFound().build(); + } + + boolean isCqlFilter = "cql-text".equals(filterLang); + CQLCrsType convertedCrs = CQLCrsType.convertFromUrl(crs); + + if (!isCqlFilter || convertedCrs != CQLCrsType.EPSG4326) { + List reasons = new ArrayList<>(); + + if (!isCqlFilter) { + reasons.add("Unknown filter language, support cql-text only"); + } + + if (convertedCrs != CQLCrsType.EPSG4326) { + reasons.add("Unknown crs, support EPSG4326 only"); + } + + throw new NotImplementedException(String.join(",", reasons)); + } + + return ResponseEntity.ok(searchService.explainByUuid( + uuid, + q, + filter, + properties, + sortBy, + convertedCrs)); + } + /** * Value cached to avoid excessive load * @return - List of Json Vocabs diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java index b3d0e3ee..f7712640 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java @@ -9,6 +9,7 @@ import au.org.aodn.ogcapi.server.core.parser.elastic.QueryHandler; import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch._types.FieldValue; +import co.elastic.clients.elasticsearch._types.SortOptions; import co.elastic.clients.elasticsearch._types.query_dsl.*; import co.elastic.clients.elasticsearch.core.SearchMvtRequest; import co.elastic.clients.elasticsearch.core.SearchRequest; @@ -16,6 +17,7 @@ import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.elasticsearch.core.search_mvt.GridType; import co.elastic.clients.transport.endpoints.BinaryResponse; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.geotools.filter.text.commons.CompilerUtil; @@ -52,6 +54,16 @@ public class ElasticSearch extends ElasticSearchBase implements Search { @Value("${elasticsearch.search_after.split_regex:\\|\\|}") protected String searchAfterSplitRegex; + private record QueryComponents( + List queries, + List should, + List filters, + List searchAfter, + List sortOptions, + Double score, + Long maxSize) { + } + public ElasticSearch(ElasticsearchClient client, CacheNoLandGeometry cacheNoLandGeometry, ObjectMapper mapper, @@ -257,136 +269,206 @@ public ElasticSearchBase.SearchResult searchAllCollections( @Override public ElasticSearchBase.SearchResult searchByParameters(List keywords, String cql, List properties, String sortBy, CQLCrsType coor) throws CQLException { + boolean isEmptySearch = (keywords == null || keywords.isEmpty()) && cql == null; + QueryComponents components = buildQueryComponents(keywords, cql, sortBy, coor); + + return searchCollectionBy( + components.queries(), + components.should(), + components.filters(), + isEmptySearch ? null : properties, + components.searchAfter(), + components.sortOptions(), + components.score(), + components.maxSize() + ); + } + + @Override + public JsonNode explainByParameters( + List keywords, + String cql, + List properties, + String sortBy, + CQLCrsType coor) throws IOException, CQLException { + boolean isEmptySearch = (keywords == null || keywords.isEmpty()) && cql == null; + QueryComponents components = buildQueryComponents(keywords, cql, sortBy, coor); + + return explainCollectionBy(buildCollectionSearchRequestSupplier( + components.queries(), + components.should(), + components.filters(), + isEmptySearch ? null : properties, + components.searchAfter(), + components.sortOptions(), + components.score(), + components.maxSize())); + } + + @Override + public JsonNode explainByUuid( + String id, + List keywords, + String cql, + List properties, + String sortBy, + CQLCrsType coor) throws IOException, CQLException { + boolean isEmptySearch = (keywords == null || keywords.isEmpty()) && cql == null; + QueryComponents components = buildQueryComponents(keywords, cql, sortBy, coor); + + return explainCollectionById(id, buildCollectionSearchRequestSupplier( + components.queries(), + components.should(), + components.filters(), + isEmptySearch ? null : properties, + components.searchAfter(), + components.sortOptions(), + components.score(), + components.maxSize())); + } + + private QueryComponents buildQueryComponents( + List keywords, + String cql, + String sortBy, + CQLCrsType coor) throws CQLException { if((keywords == null || keywords.isEmpty()) && cql == null) { - return searchAllCollections(sortBy); + Query collectionQuery = MatchQuery.of(m -> m + .field(StacType.searchField) + .query(StacType.Collection.value))._toQuery(); + + return new QueryComponents( + List.of(collectionQuery), + null, + null, + null, + createSortOptions(sortBy, CQLFields.class), + null, + null); } - else { - - List should = null; - if(keywords != null && !keywords.isEmpty()) { - should = new ArrayList<>(); - - for (String t : keywords) { - // If user's input (keywords) starts and ends with quote ", and the text is not empty - // treat the user intend to search with the exact term, - // instead of searching in fuzzy fields i.e., fuzzy_title and fuzzy_desc, - // search in the original title and description fields - // other fields are searched with the same term regardless of exact match or not, as they do not use fuzzy matching. - boolean isExact = t.startsWith("\"") && t.endsWith("\"") && t.length() > 2; - // If search text with double quote, remove quotes, - // otherwise keeps same - String term = isExact ? t.substring(1, t.length() - 1) : t; - - if (isExact) { - // Match phrase in original title and description, not use fuzzy fields - should.add(CQLFields.title.getPropertyEqualToQuery(term)); - should.add(CQLFields.description.getPropertyEqualToQuery(term)); - } - else { - should.add(CQLFields.fuzzy_title.getPropertyEqualToQuery(term)); - should.add(CQLFields.fuzzy_desc.getPropertyEqualToQuery(term)); - } - should.add(CQLFields.parameter_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.organisation_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.platform_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.id.getPropertyEqualToQuery(term)); - // A request to not using acronym in title and description in metadata, hence these - // acronym moved to links, for example NRMN record is mentioned in the link title. - // This is a work-around to the requirement but still allow use of NRMN - // links_title_contains and credit_contains use match query by default, exact match is not applied here - // links_title_contains weighted lower as it may contain combined title+description content - should.add(BoolQuery.of(b -> b - .should(CQLFields.links_title_contains.getPropertyEqualToQuery(term)) - .boost(0.5f) // lower boost to reduce promotion of link-title-only matches - )._toQuery()); - should.add(CQLFields.credit_contains.getPropertyEqualToQuery(term)); + + List should = null; + if(keywords != null && !keywords.isEmpty()) { + should = new ArrayList<>(); + + for (String t : keywords) { + // If user's input (keywords) starts and ends with quote ", and the text is not empty + // treat the user intend to search with the exact term, + // instead of searching in fuzzy fields i.e., fuzzy_title and fuzzy_desc, + // search in the original title and description fields + // other fields are searched with the same term regardless of exact match or not, as they do not use fuzzy matching. + boolean isExact = t.startsWith("\"") && t.endsWith("\"") && t.length() > 2; + // If search text with double quote, remove quotes, + // otherwise keeps same + String term = isExact ? t.substring(1, t.length() - 1) : t; + + if (isExact) { + // Match phrase in original title and description, not use fuzzy fields + should.add(CQLFields.title.getPropertyEqualToQuery(term)); + should.add(CQLFields.description.getPropertyEqualToQuery(term)); + } + else { + should.add(CQLFields.fuzzy_title.getPropertyEqualToQuery(term)); + should.add(CQLFields.fuzzy_desc.getPropertyEqualToQuery(term)); } + should.add(CQLFields.parameter_vocabs.getPropertyEqualToQuery(term)); + should.add(CQLFields.organisation_vocabs.getPropertyEqualToQuery(term)); + should.add(CQLFields.platform_vocabs.getPropertyEqualToQuery(term)); + should.add(CQLFields.id.getPropertyEqualToQuery(term)); + // A request to not using acronym in title and description in metadata, hence these + // acronym moved to links, for example NRMN record is mentioned in the link title. + // This is a work-around to the requirement but still allow use of NRMN + // links_title_contains and credit_contains use match query by default, exact match is not applied here + // links_title_contains weighted lower as it may contain combined title+description content + should.add(BoolQuery.of(b -> b + .should(CQLFields.links_title_contains.getPropertyEqualToQuery(term)) + .boost(0.5f) // lower boost to reduce promotion of link-title-only matches + )._toQuery()); + should.add(CQLFields.credit_contains.getPropertyEqualToQuery(term)); } + } - List filters = new ArrayList<>(); + List filters = new ArrayList<>(); - CQLToElasticFilterFactory factory = new CQLToElasticFilterFactory<>(coor, CQLFields.class); - if(cql != null) { - try { - Filter filter = CompilerUtil.parseFilter(Language.ECQL, cql, factory); - if(filter instanceof QueryHandler handler) { - if(handler.getErrors() == null || handler.getErrors().isEmpty()) { - if(handler.getQuery() != null) { - // There is no error during parsing - filters = List.of(handler.getQuery()); - } - } - else { - throw new IllegalArgumentException( - "ECQL Parse Error", - handler.getErrors() - .stream() - .reduce(null, (e1, e2) -> { - if (e1 == null) return e2; - e1.addSuppressed(e2); - return e1; - })); + CQLToElasticFilterFactory factory = new CQLToElasticFilterFactory<>(coor, CQLFields.class); + if(cql != null) { + try { + Filter filter = CompilerUtil.parseFilter(Language.ECQL, cql, factory); + if(filter instanceof QueryHandler handler) { + if(handler.getErrors() == null || handler.getErrors().isEmpty()) { + if(handler.getQuery() != null) { + // There is no error during parsing + filters = List.of(handler.getQuery()); } } + else { + throw new IllegalArgumentException( + "ECQL Parse Error", + handler.getErrors() + .stream() + .reduce(null, (e1, e2) -> { + if (e1 == null) return e2; + e1.addSuppressed(e2); + return e1; + })); + } } - catch(CQLException ce) { - log.error("Error parsing ECQL", ce); - throw ce; - } - } - // Get the page size after parsing - Map setting = factory.getQuerySetting(); - Long maxSize = null; - try { - if(setting.get(CQLElasticSetting.page_size) != null && - !setting.get(CQLElasticSetting.page_size).isBlank()) { - maxSize = Long.parseLong(setting.get(CQLElasticSetting.page_size)); - } - } - catch(NumberFormatException pe) { - // Nothing to do as except null as default } - // Get the score after parsing - // TODO: !! It is not good to set score due to fact that the text search include match on filter - // in case of text where filter is the only match, the score will become null (only fuzzy match have score) - // then if you set a score, you have nothing match. In the future, this score should be removed if we - // do not encounter a good use case. !! - Double score = null; - try { - if (setting.get(CQLElasticSetting.score) != null && - !setting.get(CQLElasticSetting.score).isBlank()) { - score = Double.parseDouble(setting.get(CQLElasticSetting.score)); - } + catch(CQLException ce) { + log.error("Error parsing ECQL", ce); + throw ce; } - catch(Exception e) { - log.warn("Error parsing score assume null", e); - // OK to ignore as accept null as the value + } + // Get the page size after parsing + Map setting = factory.getQuerySetting(); + Long maxSize = null; + try { + if(setting.get(CQLElasticSetting.page_size) != null && + !setting.get(CQLElasticSetting.page_size).isBlank()) { + maxSize = Long.parseLong(setting.get(CQLElasticSetting.page_size)); } - // Get the search after - List searchAfter = null; - if (setting.get(CQLElasticSetting.search_after) != null && - !setting.get(CQLElasticSetting.search_after).isBlank()) { - // Convert the regex separate string to List - searchAfter = Arrays.stream(setting.get(CQLElasticSetting.search_after) - .split(searchAfterSplitRegex)) - .filter(v -> !v.isBlank()) - .map(String::trim) - .map(ElasticSearch::toFieldValue) - .toList(); + } + catch(NumberFormatException pe) { + // Nothing to do as except null as default + } + // Get the score after parsing + // TODO: !! It is not good to set score due to fact that the text search include match on filter + // in case of text where filter is the only match, the score will become null (only fuzzy match have score) + // then if you set a score, you have nothing match. In the future, this score should be removed if we + // do not encounter a good use case. !! + Double score = null; + try { + if (setting.get(CQLElasticSetting.score) != null && + !setting.get(CQLElasticSetting.score).isBlank()) { + score = Double.parseDouble(setting.get(CQLElasticSetting.score)); } - - return searchCollectionBy( - null, - should, - filters, - properties, - searchAfter, - createSortOptions(sortBy, CQLFields.class), - score, - maxSize - ); } + catch(Exception e) { + log.warn("Error parsing score assume null", e); + // OK to ignore as accept null as the value + } + // Get the search after + List searchAfter = null; + if (setting.get(CQLElasticSetting.search_after) != null && + !setting.get(CQLElasticSetting.search_after).isBlank()) { + // Convert the regex separate string to List + searchAfter = Arrays.stream(setting.get(CQLElasticSetting.search_after) + .split(searchAfterSplitRegex)) + .filter(v -> !v.isBlank()) + .map(String::trim) + .map(ElasticSearch::toFieldValue) + .toList(); + } + + return new QueryComponents( + null, + should, + filters, + searchAfter, + createSortOptions(sortBy, CQLFields.class), + score, + maxSize); } @Override diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBase.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBase.java index cdc11561..7b0d03ce 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBase.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBase.java @@ -14,13 +14,19 @@ import co.elastic.clients.elasticsearch._types.query_dsl.QueryBuilders; import co.elastic.clients.elasticsearch.core.CountRequest; import co.elastic.clients.elasticsearch.core.CountResponse; +import co.elastic.clients.elasticsearch.core.ExplainRequest; +import co.elastic.clients.elasticsearch.core.ExplainResponse; import co.elastic.clients.elasticsearch.core.SearchRequest; import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.elasticsearch.core.search.SourceConfig; +import co.elastic.clients.json.JsonpSerializable; +import co.elastic.clients.json.JsonpUtils; import co.elastic.clients.util.ObjectBuilder; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Getter; import lombok.Setter; @@ -142,24 +148,16 @@ protected SearchRequest buildSearchAsYouTypeRequest( .sort(so -> so.score(v -> v.order(SortOrder.Desc))) .build(); } - /** - * Core function to do the search, it used pageableSearch to make sure all records are return. - * - * @param queries - The query come from text query - * @param should - This query will use in the should block of elastic search - * @param filters - The Query coming from CQL parser - * @param properties - The fields you want to return in the search, you can search a field but not include in the return - * @return - The search result from Elastic query and format in StacCollectionModel - */ - protected SearchResult searchCollectionBy(final List queries, - final List should, - final List filters, - final List properties, - final List searchAfter, - final List sortOptions, - final Double score, - final Long maxSize) { - Supplier builderSupplier = () -> { + protected Supplier buildCollectionSearchRequestSupplier( + final List queries, + final List should, + final List filters, + final List properties, + final List searchAfter, + final List sortOptions, + final Double score, + final Long maxSize) { + return () -> { SearchRequest.Builder builder = new SearchRequest.Builder(); builder.index(indexName) // If user query request a page that is smaller then the internal default, then @@ -302,6 +300,100 @@ else if (should != null && !should.isEmpty()) { } return builder; }; + } + + protected JsonNode explainCollectionBy(Supplier requestSupplier) throws IOException { + SearchRequest request = requestSupplier.get() + .explain(true) + .trackTotalHits(t -> t.enabled(true)) + .source(s -> s.fetch(false)) + .build(); + + log.debug("Final elastic search explain payload {}", request); + SearchResponse response = esClient.search(request, ObjectNode.class); + + ObjectNode result = mapper.createObjectNode(); + result.set("request", toJsonNode(request)); + + ObjectNode total = result.putObject("total"); + if (response.hits().total() != null) { + total.put("value", response.hits().total().value()); + total.put("relation", response.hits().total().relation().jsonValue()); + } + + ArrayNode hits = result.putArray("hits"); + for (Hit hit : response.hits().hits()) { + ObjectNode item = hits.addObject(); + item.put("id", hit.id()); + + if (hit.score() != null) { + item.put("score", hit.score()); + } + else { + item.putNull("score"); + } + + if (hit.explanation() != null) { + item.set("explanation", toJsonNode(hit.explanation())); + } + else { + item.putNull("explanation"); + } + } + + return result; + } + + protected JsonNode explainCollectionById(String id, Supplier requestSupplier) throws IOException { + SearchRequest searchRequest = requestSupplier.get().build(); + ExplainRequest request = new ExplainRequest.Builder() + .index(indexName) + .id(id) + .query(searchRequest.query()) + .source(s -> s.fetch(false)) + .build(); + + log.debug("Final elastic search explain by id payload {}", request); + ExplainResponse response = esClient.explain(request, ObjectNode.class); + + ObjectNode result = mapper.createObjectNode(); + result.set("request", toJsonNode(request)); + result.set("response", toJsonNode(response)); + + return result; + } + + protected JsonNode toJsonNode(JsonpSerializable value) throws JsonProcessingException { + String json = JsonpUtils.toJsonString(value, esClient._transport().jsonpMapper()); + return mapper.readTree(json); + } + + /** + * Core function to do the search, it used pageableSearch to make sure all records are return. + * + * @param queries - The query come from text query + * @param should - This query will use in the should block of elastic search + * @param filters - The Query coming from CQL parser + * @param properties - The fields you want to return in the search, you can search a field but not include in the return + * @return - The search result from Elastic query and format in StacCollectionModel + */ + protected SearchResult searchCollectionBy(final List queries, + final List should, + final List filters, + final List properties, + final List searchAfter, + final List sortOptions, + final Double score, + final Long maxSize) { + Supplier builderSupplier = buildCollectionSearchRequestSupplier( + queries, + should, + filters, + properties, + searchAfter, + sortOptions, + score, + maxSize); try { log.info("Start search {} {}", ZonedDateTime.now(), Thread.currentThread().getName()); diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/Search.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/Search.java index e9f07379..99957d09 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/Search.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/Search.java @@ -5,6 +5,7 @@ import au.org.aodn.ogcapi.server.core.model.enumeration.CQLCrsType; import au.org.aodn.ogcapi.server.core.model.ogc.FeatureRequest; import co.elastic.clients.transport.endpoints.BinaryResponse; +import com.fasterxml.jackson.databind.JsonNode; import org.springframework.http.ResponseEntity; import java.io.IOException; @@ -28,6 +29,23 @@ ElasticSearchBase.SearchResult searchByParameters( CQLCrsType coor ) throws Exception; + JsonNode explainByParameters( + List targets, + String filter, + List properties, + String sortBy, + CQLCrsType coor + ) throws Exception; + + JsonNode explainByUuid( + String uuid, + List targets, + String filter, + List properties, + String sortBy, + CQLCrsType coor + ) throws Exception; + BinaryResponse searchCollectionVectorTile( List ids, Integer tileMatrix, diff --git a/server/src/main/resources/application-dev.yaml b/server/src/main/resources/application-dev.yaml index f25750bb..63749f0e 100644 --- a/server/src/main/resources/application-dev.yaml +++ b/server/src/main/resources/application-dev.yaml @@ -3,3 +3,7 @@ management: web: exposure: include: "health,info,env,beans,logfile" + +ogcapi: + debug: + elasticsearch-explain-enabled: true diff --git a/server/src/main/resources/application.yaml b/server/src/main/resources/application.yaml index 55c56178..8b40984a 100644 --- a/server/src/main/resources/application.yaml +++ b/server/src/main/resources/application.yaml @@ -5,6 +5,10 @@ server: mime-types: application/json,application/xml,text/html,text/xml,text/plain min-response-size: 1024 # Minimum response size in bytes to compress +ogcapi: + debug: + elasticsearch-explain-enabled: false + elasticsearch: index: name: dev_portal_records diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/common/RestExtApiTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/common/RestExtApiTest.java index a391cc05..ead1a0ce 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/common/RestExtApiTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/common/RestExtApiTest.java @@ -7,12 +7,19 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ActiveProfiles; +import org.springframework.util.ResourceUtils; +import org.springframework.web.util.UriComponentsBuilder; +import java.io.File; +import java.io.FileReader; import java.io.IOException; +import java.io.Reader; +import java.net.URI; import java.util.List; import java.util.Objects; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Arrays; +import java.util.stream.StreamSupport; import static org.junit.jupiter.api.Assertions.*; @@ -46,6 +53,93 @@ public void verifyClusterIsHealthy() throws IOException { ObjectMapper objectMapper = new ObjectMapper(); + @Test + public void explainEndpointMatchesNormalSearchOrdering() throws IOException { + insertRecordsWithExplicitIds( + "5c418118-2581-4936-b6fd-d6bedfe74f62.json", + "19da2ce7-138f-4427-89de-a50c724f5f54.json", + "516811d7-cd1e-207a-e0440003ba8c79dd.json", + "7709f541-fc0c-4318-b5b9-9053aa474e0e.json", + "bc55eff4-7596-3565-e044-00144fdd4fa6.json", + "bf287dfe-9ce4-4969-9c59-51c39ea4d011.json"); + + URI normalUri = UriComponentsBuilder + .fromUriString(getBasePath() + "/collections") + .queryParam("q", "dataset") + .queryParam("filter", "page_size=3") + .build() + .encode() + .toUri(); + URI explainUri = UriComponentsBuilder + .fromUriString(getExternalBasePath() + "/explain") + .queryParam("q", "dataset") + .queryParam("filter", "page_size=3") + .build() + .encode() + .toUri(); + + ResponseEntity normalResponse = testRestTemplate.getForEntity(normalUri, JsonNode.class); + ResponseEntity explainResponse = testRestTemplate.getForEntity(explainUri, JsonNode.class); + + assertTrue(normalResponse.getStatusCode().is2xxSuccessful()); + assertTrue(explainResponse.getStatusCode().is2xxSuccessful()); + + JsonNode normalBody = Objects.requireNonNull(normalResponse.getBody()); + JsonNode explainBody = Objects.requireNonNull(explainResponse.getBody()); + List normalIds = StreamSupport.stream(normalBody.path("collections").spliterator(), false) + .map(collection -> collection.path("id").asText()) + .toList(); + List explainIds = StreamSupport.stream(explainBody.path("hits").spliterator(), false) + .map(hit -> hit.path("id").asText()) + .toList(); + + assertEquals(3, explainBody.path("request").path("size").asInt()); + assertTrue(explainBody.path("request").path("query").has("script_score")); + assertFalse(explainBody.path("request").path("_source").asBoolean()); + assertEquals("eq", explainBody.path("total").path("relation").asText()); + assertEquals(normalIds, explainIds); + assertTrue(StreamSupport.stream(explainBody.path("hits").spliterator(), false) + .allMatch(hit -> hit.path("explanation").has("description"))); + } + + @Test + public void explainEndpointAppliesCqlFilter() throws IOException { + insertRecordsWithExplicitIds( + "516811d7-cd1e-207a-e0440003ba8c79dd.json", + "7709f541-fc0c-4318-b5b9-9053aa474e0e.json"); + + URI explainUri = UriComponentsBuilder + .fromUriString(getExternalBasePath() + "/explain") + .queryParam("filter", "parameter_vocabs='temperature'") + .build() + .encode() + .toUri(); + + ResponseEntity response = testRestTemplate.getForEntity(explainUri, JsonNode.class); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + JsonNode body = Objects.requireNonNull(response.getBody()); + assertEquals(1, body.path("total").path("value").asLong()); + assertEquals("7709f541-fc0c-4318-b5b9-9053aa474e0e", body.path("hits").path(0).path("id").asText()); + assertTrue(body.path("request").path("query").path("bool").path("filter").isArray()); + assertTrue(body.path("hits").path(0).path("explanation").has("details")); + } + + private void insertRecordsWithExplicitIds(String... filenames) throws IOException { + for (String filename : filenames) { + File file = ResourceUtils.getFile("classpath:databag/" + filename); + String id = filename.substring(0, filename.length() - ".json".length()); + + try (Reader reader = new FileReader(file)) { + client.index(i -> i + .index(record_index_name) + .id(id) + .withJson(reader)); + } + } + client.indices().refresh(); + } + /** * * @throws IOException - not expect to throw diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBaseTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBaseTest.java new file mode 100644 index 00000000..04556307 --- /dev/null +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBaseTest.java @@ -0,0 +1,144 @@ +package au.org.aodn.ogcapi.server.core.service; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.ExplainRequest; +import co.elastic.clients.elasticsearch.core.ExplainResponse; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.explain.Explanation; +import co.elastic.clients.elasticsearch.core.explain.ExplanationDetail; +import co.elastic.clients.elasticsearch.core.search.Hit; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.ElasticsearchTransport; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ElasticSearchBaseTest { + + @Test + void explainCollectionByPreservesSizeAndReturnsExplanation() throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + ElasticsearchClient client = mock(ElasticsearchClient.class); + ElasticsearchTransport transport = mock(ElasticsearchTransport.class); + when(client._transport()).thenReturn(transport); + when(transport.jsonpMapper()).thenReturn(new JacksonJsonpMapper(objectMapper)); + + Explanation explanation = Explanation.of(e -> e + .value(1.5f) + .description("test explanation") + .details(List.of())); + Hit hit = Hit.of(h -> h + .index("test-index") + .id("collection-id") + .score(1.5) + .explanation(explanation)); + SearchResponse response = SearchResponse.of(r -> r + .took(1) + .timedOut(false) + .shards(s -> s.total(1).successful(1).failed(0)) + .hits(h -> h + .total(t -> t.value(1).relation(co.elastic.clients.elasticsearch.core.search.TotalHitsRelation.Eq)) + .hits(hit))); + when(client.search(any(SearchRequest.class), eq(ObjectNode.class))).thenReturn(response); + + TestElasticSearchBase elasticSearch = new TestElasticSearchBase(client, objectMapper); + JsonNode result = elasticSearch.explain(() -> new SearchRequest.Builder() + .index("test-index") + .size(11) + .query(q -> q.matchAll(m -> m))); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(SearchRequest.class); + verify(client).search(requestCaptor.capture(), eq(ObjectNode.class)); + SearchRequest request = requestCaptor.getValue(); + + assertEquals(11, request.size()); + assertTrue(request.explain()); + assertTrue(request.trackTotalHits().isEnabled()); + assertTrue(request.trackTotalHits().enabled()); + assertNotNull(request.source()); + assertFalse(request.source().fetch()); + + assertEquals(11, result.path("request").path("size").asInt()); + assertEquals(1, result.path("total").path("value").asLong()); + assertEquals("eq", result.path("total").path("relation").asText()); + assertEquals("collection-id", result.path("hits").path(0).path("id").asText()); + assertEquals(1.5, result.path("hits").path(0).path("score").asDouble()); + assertEquals("test explanation", result.path("hits").path(0) + .path("explanation").path("description").asText()); + } + + @Test + void explainCollectionByIdUsesExplainApiWithBuiltQuery() throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + ElasticsearchClient client = mock(ElasticsearchClient.class); + ElasticsearchTransport transport = mock(ElasticsearchTransport.class); + when(client._transport()).thenReturn(transport); + when(transport.jsonpMapper()).thenReturn(new JacksonJsonpMapper(objectMapper)); + + ExplanationDetail explanation = ExplanationDetail.of(e -> e + .value(2.5f) + .description("id explanation") + .details(List.of())); + ExplainResponse response = ExplainResponse.of(r -> r + .index("test-index") + .id("collection-id") + .matched(true) + .explanation(explanation)); + when(client.explain(any(ExplainRequest.class), eq(ObjectNode.class))).thenReturn(response); + + TestElasticSearchBase elasticSearch = new TestElasticSearchBase(client, objectMapper); + elasticSearch.setIndexName("test-index"); + JsonNode result = elasticSearch.explainById("collection-id", () -> new SearchRequest.Builder() + .index("test-index") + .query(q -> q.matchAll(m -> m))); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ExplainRequest.class); + verify(client).explain(requestCaptor.capture(), eq(ObjectNode.class)); + ExplainRequest request = requestCaptor.getValue(); + + assertEquals("test-index", request.index()); + assertEquals("collection-id", request.id()); + assertTrue(request.query().isMatchAll()); + assertNotNull(request.source()); + assertFalse(request.source().fetch()); + + assertEquals("collection-id", result.path("request").path("id").asText()); + assertTrue(result.path("request").path("query").has("match_all")); + assertEquals("collection-id", result.path("response").path("_id").asText()); + assertTrue(result.path("response").path("matched").asBoolean()); + assertEquals("id explanation", result.path("response") + .path("explanation").path("description").asText()); + } + + private static class TestElasticSearchBase extends ElasticSearchBase { + private TestElasticSearchBase(ElasticsearchClient client, ObjectMapper objectMapper) { + setEsClient(client); + setMapper(objectMapper); + } + + private JsonNode explain(Supplier requestSupplier) throws IOException { + return explainCollectionBy(requestSupplier); + } + + private JsonNode explainById(String id, Supplier requestSupplier) throws IOException { + return explainCollectionById(id, requestSupplier); + } + } +} diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/service/ElasticSearchTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/service/ElasticSearchTest.java index 07998e0d..6a1de0d8 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/service/ElasticSearchTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/service/ElasticSearchTest.java @@ -4,17 +4,21 @@ import au.org.aodn.ogcapi.server.core.model.EsFeatureCollectionModel; import au.org.aodn.ogcapi.server.core.model.EsFeatureModel; import au.org.aodn.ogcapi.server.core.model.EsPolygonModel; -import au.org.aodn.ogcapi.server.core.model.enumeration.CQLFields; +import au.org.aodn.ogcapi.server.core.model.enumeration.CQLCrsType; import au.org.aodn.ogcapi.server.core.service.ElasticSearch; import au.org.aodn.ogcapi.server.core.service.ElasticSearchBase; import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.FieldValue; +import co.elastic.clients.elasticsearch._types.SortOptions; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; import co.elastic.clients.elasticsearch.core.SearchRequest; import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.elasticsearch.core.search.HitsMetadata; import co.elastic.clients.elasticsearch.core.search.TotalHits; -import co.elastic.clients.elasticsearch._types.query_dsl.*; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.openapitools.jackson.nullable.JsonNullable; @@ -26,6 +30,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -139,61 +144,195 @@ public void searchFeatureSummaryTest() throws IOException { } @Test - public void searchByParametersWithDoubleQuote() { - String keyword = "\"ocean temperature\""; - List keywords = List.of(keyword); - List should = new ArrayList<>(); - for (String t : keywords) { - boolean isExact = t.startsWith("\"") && t.endsWith("\"") && t.length() > 2; - String term = isExact ? t.substring(1, t.length() - 1) : t; - if (isExact) { - should.add(CQLFields.title.getPropertyEqualToQuery(term)); - should.add(CQLFields.description.getPropertyEqualToQuery(term)); - } else { - should.add(CQLFields.fuzzy_title.getPropertyEqualToQuery(term)); - should.add(CQLFields.fuzzy_desc.getPropertyEqualToQuery(term)); - } - should.add(CQLFields.parameter_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.organisation_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.platform_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.id.getPropertyEqualToQuery(term)); - should.add(BoolQuery.of(b -> b - .should(CQLFields.links_title_contains.getPropertyEqualToQuery(term)) - .boost(0.5f) // lower boost to reduce promotion of link-title-only matches - )._toQuery()); - should.add(CQLFields.credit_contains.getPropertyEqualToQuery(term)); - } - assertEquals(8, should.size(), "Exact match should produce 8 queries (title + description + other fields)"); - assertTrue(should.get(0).isMatchPhrase(), "Title query should be MatchPhraseQuery"); - assertTrue(should.get(1).isMatchPhrase(), "Description query should be MatchPhraseQuery"); + public void searchByParametersWithDoubleQuote() throws Exception { + CapturingElasticSearch capturingSearch = new CapturingElasticSearch(mockClient); + + capturingSearch.searchByParameters( + List.of("\"ocean temperature\""), + null, + null, + "-score,-rank", + CQLCrsType.EPSG4326); + + assertEquals(8, capturingSearch.should.size(), + "Exact match should produce 8 queries (title + description + other fields)"); + assertTrue(capturingSearch.should.get(0).isMatchPhrase(), "Title query should be MatchPhraseQuery"); + assertTrue(capturingSearch.should.get(1).isMatchPhrase(), "Description query should be MatchPhraseQuery"); + } + + @Test + public void searchByParametersWithoutDoubleQuote() throws Exception { + CapturingElasticSearch capturingSearch = new CapturingElasticSearch(mockClient); + + capturingSearch.searchByParameters( + List.of("ocean temperature"), + null, + null, + "-score,-rank", + CQLCrsType.EPSG4326); + + assertEquals(8, capturingSearch.should.size(), "Fuzzy match should produce 8 queries"); + assertTrue(capturingSearch.should.get(0).isMatch(), "fuzzy_title should be MatchQuery"); + } + + @Test + public void emptySearchByParametersMatchesSearchAllCollections() throws Exception { + CapturingElasticSearch capturingSearch = new CapturingElasticSearch(mockClient); + + capturingSearch.searchAllCollections("-score,-rank"); + SearchArguments searchAllArguments = capturingSearch.arguments; + + capturingSearch.searchByParameters( + null, + null, + List.of("title"), + "-score,-rank", + CQLCrsType.EPSG4326); + SearchArguments emptySearchArguments = capturingSearch.arguments; + + assertEquals(searchAllArguments.queries().toString(), emptySearchArguments.queries().toString()); + assertEquals(searchAllArguments.should(), emptySearchArguments.should()); + assertEquals(searchAllArguments.filters(), emptySearchArguments.filters()); + assertEquals(searchAllArguments.properties(), emptySearchArguments.properties()); + assertEquals(searchAllArguments.searchAfter(), emptySearchArguments.searchAfter()); + assertEquals(searchAllArguments.sortOptions().toString(), emptySearchArguments.sortOptions().toString()); + assertEquals(searchAllArguments.score(), emptySearchArguments.score()); + assertEquals(searchAllArguments.maxSize(), emptySearchArguments.maxSize()); + } + + @Test + public void explainByParametersUsesScriptScoreRequestForKeywords() throws Exception { + CapturingElasticSearch capturingSearch = new CapturingElasticSearch(mockClient); + + JsonNode result = capturingSearch.explainByParameters( + List.of("ocean temperature"), + null, + List.of("title"), + "-score,-rank", + CQLCrsType.EPSG4326); + + assertEquals("captured", result.path("status").asText()); + assertEquals(100, capturingSearch.explainRequest.size()); + assertTrue(capturingSearch.explainRequest.query().isScriptScore()); + assertEquals(8, capturingSearch.explainRequest.query().scriptScore() + .query().bool().should().size()); + assertNotNull(capturingSearch.explainRequest.source()); + assertTrue(capturingSearch.explainRequest.source().isFilter()); + assertFalse(capturingSearch.explainRequest.source().filter().includes().isEmpty()); } @Test - public void searchByParametersWithoutDoubleQuote() { - String keyword = "ocean temperature"; - List keywords = List.of(keyword); - List should = new ArrayList<>(); - for (String t : keywords) { - boolean isExact = t.startsWith("\"") && t.endsWith("\"") && t.length() > 2; - String term = isExact ? t.substring(1, t.length() - 1) : t; - if (isExact) { - should.add(CQLFields.title.getPropertyEqualToQuery(term)); - should.add(CQLFields.description.getPropertyEqualToQuery(term)); - } else { - should.add(CQLFields.fuzzy_title.getPropertyEqualToQuery(term)); - should.add(CQLFields.fuzzy_desc.getPropertyEqualToQuery(term)); - } - should.add(CQLFields.parameter_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.organisation_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.platform_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.id.getPropertyEqualToQuery(term)); - should.add(BoolQuery.of(b -> b - .should(CQLFields.links_title_contains.getPropertyEqualToQuery(term)) - .boost(0.5f) // lower boost to reduce promotion of link-title-only matches - )._toQuery()); - should.add(CQLFields.credit_contains.getPropertyEqualToQuery(term)); + public void explainByParametersUsesCollectionQueryForEmptySearch() throws Exception { + CapturingElasticSearch capturingSearch = new CapturingElasticSearch(mockClient); + + capturingSearch.explainByParameters( + null, + null, + List.of("title"), + "-score,-rank", + CQLCrsType.EPSG4326); + + SearchRequest request = capturingSearch.explainRequest; + assertEquals(100, request.size()); + assertTrue(request.query().isBool()); + assertEquals("type", request.query().bool().must().get(0).match().field()); + assertEquals("Collection", request.query().bool().must().get(0).match().query().stringValue()); + assertTrue(request.source().isFetch()); + assertTrue(request.source().fetch(), + "Empty parameter searches must preserve searchAllCollections source behavior"); + } + + @Test + public void normalAndExplainRequestsMatchForKeywordAndCqlSettings() throws Exception { + CapturingElasticSearch capturingSearch = new CapturingElasticSearch(mockClient); + List keywords = List.of("temperature"); + String cql = "parameter_vocabs='temperature' AND page_size=3 AND score>=1.3"; + + capturingSearch.searchByParameters( + keywords, + cql, + List.of("title"), + "-score,-rank", + CQLCrsType.EPSG4326); + SearchRequest normalRequest = capturingSearch.normalRequest; + + capturingSearch.explainByParameters( + keywords, + cql, + List.of("title"), + "-score,-rank", + CQLCrsType.EPSG4326); + SearchRequest explainRequest = capturingSearch.explainRequest; + + assertEquals(normalRequest.toString(), explainRequest.toString()); + assertEquals(3, explainRequest.size()); + assertEquals(1.3, explainRequest.minScore()); + assertTrue(explainRequest.query().isScriptScore()); + assertFalse(explainRequest.query().scriptScore().query().bool().filter().isEmpty()); + } + + private record SearchArguments( + List queries, + List should, + List filters, + List properties, + List searchAfter, + List sortOptions, + Double score, + Long maxSize) { + } + + private static class CapturingElasticSearch extends ElasticSearch { + private List should; + private SearchArguments arguments; + private SearchRequest normalRequest; + private SearchRequest explainRequest; + + private CapturingElasticSearch(ElasticsearchClient client) { + super(client, null, new ObjectMapper(), "test-index", 100, 10); + this.searchAfterSplitRegex = "\\|\\|"; + } + + @Override + protected SearchResult searchCollectionBy( + List queries, + List should, + List filters, + List properties, + List searchAfter, + List sortOptions, + Double score, + Long maxSize) { + this.should = should; + this.arguments = new SearchArguments( + queries, + should, + filters, + properties, + searchAfter, + sortOptions, + score, + maxSize); + this.normalRequest = buildCollectionSearchRequestSupplier( + queries, + should, + filters, + properties, + searchAfter, + sortOptions, + score, + maxSize).get().build(); + SearchResult result = new SearchResult<>(); + result.setCollections(List.of()); + return result; + } + + @Override + protected JsonNode explainCollectionBy(Supplier requestSupplier) { + this.explainRequest = requestSupplier.get().build(); + ObjectNode result = mapper.createObjectNode(); + result.put("status", "captured"); + return result; } - assertEquals(8, should.size(), "Fuzzy match should produce 8 queries"); - assertTrue(should.get(0).isMatch(), "fuzzy_title should be MatchQuery"); } } diff --git a/server/src/test/resources/application-test.yaml b/server/src/test/resources/application-test.yaml index fd525ac6..f4713627 100644 --- a/server/src/test/resources/application-test.yaml +++ b/server/src/test/resources/application-test.yaml @@ -1,6 +1,8 @@ ogcapi: docker: elasticVersion: "8.19.10" + debug: + elasticsearch-explain-enabled: true elasticsearch: index: