diff --git a/NEWS.md b/NEWS.md index b167fe04a..c7394c4c7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -19,6 +19,7 @@ * Remove ability to match on LCCN searches without a prefix ([MSEARCH-752](https://folio-org.atlassian.net/browse/MSEARCH-752)) * Search consolidated items/holdings data in consortium ([MSEARCH-759](https://folio-org.atlassian.net/browse/MSEARCH-759)) * Create bibframe index and process bibframe events ([MSEARCH-781](https://folio-org.atlassian.net/browse/MSEARCH-781)) +* Create bibframe authority index and process bibframe authority events ([MSEARCH-784](https://folio-org.atlassian.net/browse/MSEARCH-784)) * Allow Unified List of Inventory Locations in a Consortium to be fetched by member tenants ([MSEARCH-660](https://folio-org.atlassian.net/browse/MSEARCH-660)) * Implement Indexing of Campuses from Kafka ([MSEARCH-770](https://issues.folio.org/browse/MSEARCH-770)) * Extend response with additional Location fields for Inventory Locations in a Consortium endpoint ([MSEARCH-775](https://folio-org.atlassian.net/browse/MSEARCH-775)) diff --git a/README.md b/README.md index adde835c5..341031d53 100644 --- a/README.md +++ b/README.md @@ -414,14 +414,15 @@ Consortium feature on module enable is defined by 'centralTenantId' tenant param ### Search API -| METHOD | URL | DESCRIPTION | -|:-------|:------------------------------|:-------------------------------------------------------------------------------------| -| GET | `/search/instances` | Search by instances and to this instance items and holding-records | -| GET | `/search/authorities` | Search by authority records | -| GET | `/search/bibframe` | Search linked data graph resource descriptions | -| GET | `/search/{recordType}/facets` | Get facets where recordType could be: instances, authorities, contributors, subjects | -| GET | ~~`/search/instances/ids`~~ | (DEPRECATED) Stream instance ids as JSON or plain text | -| GET | ~~`/search/holdings/ids`~~ | (DEPRECATED) Stream holding record ids as JSON or plain text | +| METHOD | URL | DESCRIPTION | +|:-------|:------------------------------------------|:-------------------------------------------------------------------------------------| +| GET | `/search/instances` | Search by instances and to this instance items and holding-records | +| GET | `/search/authorities` | Search by authority records | +| GET | `/search/bibframe` | Search linked data graph resource descriptions | +| GET | `/search/bibframe/authorities` | Search linked data graph authority resource descriptions | +| GET | `/search/{recordType}/facets` | Get facets where recordType could be: instances, authorities, contributors, subjects | +| GET | ~~`/search/instances/ids`~~ | (DEPRECATED) Stream instance ids as JSON or plain text | +| GET | ~~`/search/holdings/ids`~~ | (DEPRECATED) Stream holding record ids as JSON or plain text | #### Searching and filtering diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index e741c054a..6b25a485a 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -111,6 +111,15 @@ "search.bibframe.collection.get" ] }, + { + "methods": [ + "GET" + ], + "pathPattern": "/search/bibframe/authorities", + "permissionsRequired": [ + "search.bibframe.authority.collection.get" + ] + }, { "methods": [ "GET" diff --git a/src/main/java/org/folio/search/controller/SearchController.java b/src/main/java/org/folio/search/controller/SearchController.java index 7813c6165..a8c9b8ac2 100644 --- a/src/main/java/org/folio/search/controller/SearchController.java +++ b/src/main/java/org/folio/search/controller/SearchController.java @@ -4,6 +4,8 @@ import org.folio.search.domain.dto.Authority; import org.folio.search.domain.dto.AuthoritySearchResult; import org.folio.search.domain.dto.Bibframe; +import org.folio.search.domain.dto.BibframeAuthority; +import org.folio.search.domain.dto.BibframeSearchAuthorityResult; import org.folio.search.domain.dto.BibframeSearchResult; import org.folio.search.domain.dto.Instance; import org.folio.search.domain.dto.InstanceSearchResult; @@ -65,6 +67,23 @@ public ResponseEntity searchBibframe(String tenant, String ); } + @Override + public ResponseEntity searchBibframeAuthorities(String tenant, + String query, + Integer limit, + Integer offset) { + var searchRequest = CqlSearchRequest.of( + BibframeAuthority.class, tenant, query, limit, offset, true); + var result = searchService.search(searchRequest); + return ResponseEntity.ok(new BibframeSearchAuthorityResult() + .searchQuery(query) + .content(result.getRecords()) + .pageNumber(divPlusOneIfRemainder(offset, limit)) + .totalPages(divPlusOneIfRemainder(result.getTotalRecords(), limit)) + .totalRecords(result.getTotalRecords()) + ); + } + private int divPlusOneIfRemainder(int one, int two) { var modulo = one % two; return one / two + (modulo > 0 ? 1 : 0); diff --git a/src/main/java/org/folio/search/integration/KafkaMessageListener.java b/src/main/java/org/folio/search/integration/KafkaMessageListener.java index f580bdd84..d33016a18 100644 --- a/src/main/java/org/folio/search/integration/KafkaMessageListener.java +++ b/src/main/java/org/folio/search/integration/KafkaMessageListener.java @@ -10,7 +10,6 @@ import static org.folio.search.utils.SearchConverterUtils.getEventPayload; import static org.folio.search.utils.SearchConverterUtils.getResourceEventId; import static org.folio.search.utils.SearchConverterUtils.getResourceSource; -import static org.folio.search.utils.SearchUtils.BIBFRAME_RESOURCE; import static org.folio.search.utils.SearchUtils.ID_FIELD; import static org.folio.search.utils.SearchUtils.INSTANCE_ID_FIELD; import static org.folio.search.utils.SearchUtils.INSTANCE_RESOURCE; @@ -206,7 +205,7 @@ public void handleBibframeEvents(List> con log.info("Processing bibframe events from Kafka [number of events: {}]", consumerRecords.size()); var batch = consumerRecords.stream() .map(ConsumerRecord::value) - .map(bibframe -> bibframe.resourceName(BIBFRAME_RESOURCE).id(getResourceEventId(bibframe))) + .map(bibframe -> bibframe.id(getResourceEventId(bibframe))) .toList(); indexResources(batch, resourceService::indexResources); diff --git a/src/main/java/org/folio/search/integration/interceptor/ResourceEventBatchInterceptor.java b/src/main/java/org/folio/search/integration/interceptor/ResourceEventBatchInterceptor.java index a13ce8bc1..38ac4d886 100644 --- a/src/main/java/org/folio/search/integration/interceptor/ResourceEventBatchInterceptor.java +++ b/src/main/java/org/folio/search/integration/interceptor/ResourceEventBatchInterceptor.java @@ -25,7 +25,9 @@ public class ResourceEventBatchInterceptor implements BatchInterceptor> { + + private final LccnNormalizer lccnNormalizer; + + @Override + public Set getFieldValue(BibframeAuthority bibframe) { + return Optional.of(bibframe) + .map(BibframeAuthority::getIdentifiers) + .orElseGet(Collections::emptyList) + .stream() + .filter(i -> LCCN.equals(i.getType())) + .map(BibframeAuthorityIdentifiersInner::getValue) + .filter(Objects::nonNull) + .map(lccnNormalizer) + .flatMap(Optional::stream) + .collect(toCollection(LinkedHashSet::new)); + } +} diff --git a/src/main/java/org/folio/search/utils/SearchUtils.java b/src/main/java/org/folio/search/utils/SearchUtils.java index 5cb7b910e..b6a02a406 100644 --- a/src/main/java/org/folio/search/utils/SearchUtils.java +++ b/src/main/java/org/folio/search/utils/SearchUtils.java @@ -39,6 +39,7 @@ public class SearchUtils { public static final String LOCATION_RESOURCE = "location"; public static final String CLASSIFICATION_TYPE_RESOURCE = "classification-type"; public static final String BIBFRAME_RESOURCE = "bibframe"; + public static final String BIBFRAME_AUTHORITY_RESOURCE = "bibframe-authority"; public static final String CAMPUS_RESOURCE = "campus"; public static final String INSTITUTION_RESOURCE = "institution"; public static final String LIBRARY_RESOURCE = "library"; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 57d7e803b..5671b3487 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -137,7 +137,7 @@ folio: group-id: ${folio.environment}-mod-search-location-type-group bibframe: concurrency: ${KAFKA_BIBFRAME_CONCURRENCY:1} - topic-pattern: (${folio.environment}\.)(.*\.)search\.bibframe + topic-pattern: (${folio.environment}\.)(.*\.)search\.(bibframe|bibframe-authorities) group-id: ${folio.environment}-mod-search-bibframe-group okapiUrl: ${okapi.url} logging: diff --git a/src/main/resources/elasticsearch/index/bibframe-authority.json b/src/main/resources/elasticsearch/index/bibframe-authority.json new file mode 100644 index 000000000..137a49b25 --- /dev/null +++ b/src/main/resources/elasticsearch/index/bibframe-authority.json @@ -0,0 +1,68 @@ +{ + "index": { + "number_of_shards": 4, + "number_of_replicas": 2, + "refresh_interval": "1s", + "codec": "best_compression", + "mapping.total_fields.limit": 1000 + }, + "analysis": { + "filter": { + "folio_word_delimiter_graph": { + "type": "word_delimiter_graph", + "catenate_words": true + } + }, + "normalizer": { + "keyword_lowercase": { + "filter": [ + "lowercase", + "trim" + ], + "type": "custom" + }, + "keyword_uppercase": { + "filter": [ + "uppercase", + "trim" + ], + "type": "custom" + }, + "keyword_trimmed": { + "filter": [ + "trim" + ], + "type": "custom" + } + }, + "analyzer": { + "source_analyzer": { + "tokenizer": "icu_tokenizer", + "filter": [ + "folio_word_delimiter_graph", + "icu_folding" + ], + "char_filter": [ + "and_char_replacement" + ], + "type": "custom" + }, + "whitespace_lowercase_analyzer": { + "tokenizer": "whitespace", + "filter": [ + "lowercase", + "icu_folding" + ], + "type": "custom" + } + }, + "tokenizers": { }, + "char_filter": { + "and_char_replacement": { + "type": "pattern_replace", + "pattern": " & ", + "replacement": " and " + } + } + } +} diff --git a/src/main/resources/model/bibframe_authority.json b/src/main/resources/model/bibframe_authority.json new file mode 100644 index 000000000..10eb0bd15 --- /dev/null +++ b/src/main/resources/model/bibframe_authority.json @@ -0,0 +1,35 @@ +{ + "name": "bibframe-authority", + "eventBodyJavaClass": "org.folio.search.domain.dto.BibframeAuthority", + "reindexSupported": false, + "fields": { + "id": { + "index": "keyword" + }, + "label": { + "index": "standard" + }, + "type": { + "index": "keyword" + }, + "identifiers": { + "type": "object", + "properties": { + "value": { + "index": "whitespace" + }, + "type": { + "index": "whitespace" + } + } + } + }, + "searchFields": { + "lccn": { + "type": "search", + "index": "keyword", + "processor": "bibframeAuthorityLccnProcessor" + } + }, + "indexMappings": { } +} diff --git a/src/main/resources/swagger.api/examples/result/bibframeSearchAuthorityResult.yaml b/src/main/resources/swagger.api/examples/result/bibframeSearchAuthorityResult.yaml new file mode 100644 index 000000000..a35956d7b --- /dev/null +++ b/src/main/resources/swagger.api/examples/result/bibframeSearchAuthorityResult.yaml @@ -0,0 +1,12 @@ +value: + searchQuery: "query string" + content: + - id: "1" + label: "Label Value" + type: "Person" + identifiers: + - value: "sh85121033" + type: "LCCN" + pageNumber: 0 + totalPages: 3 + totalRecords: 27 diff --git a/src/main/resources/swagger.api/mod-search.yaml b/src/main/resources/swagger.api/mod-search.yaml index 71390f7e3..85d1a2267 100644 --- a/src/main/resources/swagger.api/mod-search.yaml +++ b/src/main/resources/swagger.api/mod-search.yaml @@ -118,6 +118,9 @@ paths: /search/bibframe: $ref: 'paths/search-bibframe/search-bibframe.yaml' + /search/bibframe/authorities: + $ref: 'paths/search-bibframe/search-bibframe-authorities.yaml' + /browse/call-numbers/instances: $ref: 'paths/browse-call-numbers/browse-call-numbers-instances.yaml' diff --git a/src/main/resources/swagger.api/paths/search-bibframe/search-bibframe-authorities.yaml b/src/main/resources/swagger.api/paths/search-bibframe/search-bibframe-authorities.yaml new file mode 100644 index 000000000..3035a98b6 --- /dev/null +++ b/src/main/resources/swagger.api/paths/search-bibframe/search-bibframe-authorities.yaml @@ -0,0 +1,25 @@ +get: + operationId: searchBibframeAuthorities + summary: Search Bibframe Authorities + description: Get a list of bibframe authorities records for CQL query + tags: + - search + parameters: + - $ref: '../../parameters/x-okapi-tenant-header.yaml' + - $ref: '../../parameters/cql-query.yaml' + - $ref: '../../parameters/bibframe-limit-param.yaml' + - $ref: '../../parameters/offset-param.yaml' + responses: + '200': + description: 'Bibframe authorities search result' + content: + application/json: + schema: + $ref: '../../schemas/response/bibframeSearchAuthorityResult.yaml' + examples: + searchResult: + $ref: '../../examples/result/bibframeSearchAuthorityResult.yaml' + '400': + $ref: '../../responses/badRequestResponse.yaml' + '500': + $ref: '../../responses/internalServerErrorResponse.yaml' diff --git a/src/main/resources/swagger.api/schemas/dto/bibframe/bibframeAuthority.yaml b/src/main/resources/swagger.api/schemas/dto/bibframe/bibframeAuthority.yaml new file mode 100644 index 000000000..8e39d0e11 --- /dev/null +++ b/src/main/resources/swagger.api/schemas/dto/bibframe/bibframeAuthority.yaml @@ -0,0 +1,25 @@ +description: "Bibframe authority search dto, contains Authority and Identifiers" +type: "object" +properties: + id: + description: "The Linked Data ID of an Authority" + type: "string" + label: + description: "Value of Label" + type: "string" + type: + type: "string" + identifiers: + type: "array" + description: "Authority identifier array" + items: + properties: + value: + type: "string" + description: "Value of Identifier" + type: + type: "string" + enum: + - "LCCN" +required: + - "id" diff --git a/src/main/resources/swagger.api/schemas/response/bibframeSearchAuthorityResult.yaml b/src/main/resources/swagger.api/schemas/response/bibframeSearchAuthorityResult.yaml new file mode 100644 index 000000000..dbb62830d --- /dev/null +++ b/src/main/resources/swagger.api/schemas/response/bibframeSearchAuthorityResult.yaml @@ -0,0 +1,20 @@ +description: "Bibframe authority search result response" +type: "object" +properties: + searchQuery: + type: "string" + description: "Initial search query" + content: + type: "array" + description: "List of bibframe authority records found" + items: + $ref: "../../schemas/dto/bibframe/bibframeAuthority.yaml" + pageNumber: + type: "integer" + description: "Current results page number, 0 by default" + totalPages: + type: "integer" + description: "Total pages count" + totalRecords: + type: "integer" + description: "Total results count" diff --git a/src/test/java/org/folio/search/controller/SearchBibframeAuthorityIT.java b/src/test/java/org/folio/search/controller/SearchBibframeAuthorityIT.java new file mode 100644 index 000000000..d220dd276 --- /dev/null +++ b/src/test/java/org/folio/search/controller/SearchBibframeAuthorityIT.java @@ -0,0 +1,58 @@ +package org.folio.search.controller; + +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +import org.folio.search.domain.dto.BibframeAuthority; +import org.folio.search.sample.SampleBibframe; +import org.folio.search.support.base.BaseIntegrationTest; +import org.folio.spring.testing.type.IntegrationTest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +@IntegrationTest +class SearchBibframeAuthorityIT extends BaseIntegrationTest { + + @BeforeAll + static void prepare() { + setUpTenant(BibframeAuthority.class, 2, + SampleBibframe.getBibframeAuthorityConceptSampleAsMap(), SampleBibframe.getBibframeAuthorityPersonSampleAsMap() + ); + } + + @AfterAll + static void cleanUp() { + removeTenant(); + } + + @DisplayName("search by authority bibframe (all authorities are found)") + @ParameterizedTest(name = "[{0}] {2}") + @CsvSource({ + " 1, 2, label any \"*\"", + " 2, 2, label any \"*-*\"", + " 3, 2, label any \"*-label\"", + " 4, 2, label <> \"lab\"", + " 5, 1, label any \"concept-*\"", + " 6, 1, label = \"concept-label\"", + " 7, 1, label any \"person-*\"", + " 8, 1, label = \"person-label\"", + " 9, 1, type = \"PERSON\"", + "10, 1, type = \"CONCEPT\"", + "11, 1, lccn = \"sh9876543210\"", + "12, 0, lccn = \"s h9876543210\"", + "13, 1, lccn = \"sh0123456789\"", + "14, 0, lccn = \"sh 0123456789\"", + "15, 1, lccn = \"n0123456789\"", + "16, 0, lccn = \"n 0123456789\"", + "17, 2, lccn any \"*0123456789\"", + "18, 1, lccn any \"*0123456789\" AND type = \"PERSON\"", + "19, 1, lccn any \"*0123456789\" AND type = \"CONCEPT\"" + }) + void searchByBibframe_parameterized_singleResult(int index, int size, String query) throws Throwable { + doSearchByBibframeAuthority(query) + .andExpect(jsonPath("$.totalRecords", is(size))); + } +} diff --git a/src/test/java/org/folio/search/controller/SearchControllerTest.java b/src/test/java/org/folio/search/controller/SearchControllerTest.java index 792566f89..264b9856f 100644 --- a/src/test/java/org/folio/search/controller/SearchControllerTest.java +++ b/src/test/java/org/folio/search/controller/SearchControllerTest.java @@ -1,7 +1,6 @@ package org.folio.search.controller; import static java.util.Collections.emptyList; -import static org.folio.search.support.base.ApiEndpoints.authoritySearchPath; import static org.folio.search.utils.TestConstants.INDEX_NAME; import static org.folio.search.utils.TestConstants.TENANT_ID; import static org.folio.search.utils.TestUtils.randomId; @@ -15,7 +14,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.stream.Stream; import org.folio.search.domain.dto.Authority; +import org.folio.search.domain.dto.Bibframe; +import org.folio.search.domain.dto.BibframeAuthority; import org.folio.search.domain.dto.Instance; import org.folio.search.exception.SearchOperationException; import org.folio.search.exception.SearchServiceException; @@ -24,7 +26,11 @@ import org.folio.spring.integration.XOkapiHeaders; import org.folio.spring.testing.type.UnitTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.opensearch.OpenSearchException; import org.opensearch.core.index.Index; import org.springframework.beans.factory.annotation.Autowired; @@ -51,54 +57,46 @@ public void setUp() { .thenReturn(TENANT_ID); } - @Test - void search_positive_authorities() throws Exception { - var cqlQuery = "cql.allRecords=1"; - var expectedSearchRequest = searchServiceRequest(Authority.class, cqlQuery); - - when(searchService.search(expectedSearchRequest)).thenReturn(searchResult()); - - var requestBuilder = get(authoritySearchPath()) - .queryParam("query", cqlQuery) - .queryParam("limit", "100") - .contentType(APPLICATION_JSON) - .header(XOkapiHeaders.TENANT, TENANT_ID); - - mockMvc.perform(requestBuilder) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.totalRecords", is(0))) - .andExpect(jsonPath("$.authorities", is(emptyList()))); - } - - @Test - void search_positive() throws Exception { + @ParameterizedTest + @MethodSource("provideSearchPaths") + void search_positive(Class requestClass, + String searchPass, + boolean expandAll, + int limit, + String jsonDataPath) throws Exception { var cqlQuery = "title all \"test-query\""; - - when(searchService.search(searchServiceRequest(Instance.class, cqlQuery))) + var expectedSearchRequest = searchServiceRequest(requestClass, TENANT_ID, cqlQuery, expandAll, limit); + when(searchService.search(expectedSearchRequest)) .thenReturn(searchResult()); - var requestBuilder = get("/search/instances") + var requestBuilder = get(searchPass) .queryParam("query", cqlQuery) - .queryParam("limit", "100") + .queryParam("limit", String.valueOf(limit)) .contentType(APPLICATION_JSON) .header(XOkapiHeaders.TENANT, TENANT_ID); mockMvc.perform(requestBuilder) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalRecords", is(0))) - .andExpect(jsonPath("$.instances", is(emptyList()))); + .andExpect(jsonPath(jsonDataPath, is(emptyList()))); } - @Test - void search_offset_limit_10k() throws Exception { + @ParameterizedTest + @ValueSource(strings = { + "/search/instances", + "/search/authorities", + "/search/bibframe", + "/search/bibframe/authorities", + }) + void search_offset_limit_10k(String searchPath) throws Exception { var cqlQuery = "title all \"test-query\""; when(searchService.search(searchServiceRequest(Instance.class, cqlQuery))) .thenReturn(searchResult()); - var requestBuilder = get("/search/instances") + var requestBuilder = get(searchPath) .queryParam("query", cqlQuery) .queryParam("limit", "100") .queryParam("offset", "10000") @@ -109,18 +107,22 @@ void search_offset_limit_10k() throws Exception { .andExpect(status().isBadRequest()); } - @Test - void search_negative_indexNotFound() throws Exception { + @ParameterizedTest + @MethodSource("provideSearchPaths") + void search_negative_indexNotFound(Class requestClass, + String searchPass, + boolean expandAll, + int limit) throws Exception { var cqlQuery = "title all \"test-query\""; var openSearchException = new OpenSearchException("Elasticsearch exception [" + "type=index_not_found_exception, " + "reason=no such index [instance_test-tenant]]"); openSearchException.setIndex(new Index(INDEX_NAME, randomId())); - - when(searchService.search(searchServiceRequest(Instance.class, cqlQuery))).thenThrow( + var expectedSearchRequest = searchServiceRequest(requestClass, TENANT_ID, cqlQuery, expandAll, limit); + when(searchService.search(expectedSearchRequest)).thenThrow( new SearchOperationException("error", openSearchException)); - var requestBuilder = get("/search/instances") + var requestBuilder = get(searchPass) .queryParam("query", cqlQuery) .contentType(APPLICATION_JSON) .header(XOkapiHeaders.TENANT, TENANT_ID); @@ -133,9 +135,16 @@ void search_negative_indexNotFound() throws Exception { .andExpect(jsonPath("$.errors[0].code", is("elasticsearch_error"))); } - @Test - void search_negative_invalidLimitParameter() throws Exception { - var requestBuilder = get("/search/instances") + @ParameterizedTest + @CsvSource(value = { + "Instances , 500 , /search/instances", + "Authorities , 500 , /search/authorities", + "Bibframe , 100 , /search/bibframe", + "BibframeAuthorities , 100 , /search/bibframe/authorities", + }) + void search_negative_invalidLimitParameter(String classMessagePart, int limit, String searchPass) throws Exception { + var expectedMessage = String.format("search%s.limit must be less than or equal to %s", classMessagePart, limit); + var requestBuilder = get(searchPass) .queryParam("query", "title all \"test-query\"") .queryParam("limit", "100000") .contentType(APPLICATION_JSON) @@ -144,19 +153,23 @@ void search_negative_invalidLimitParameter() throws Exception { mockMvc.perform(requestBuilder) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.total_records", is(1))) - .andExpect(jsonPath("$.errors[0].message", is("searchInstances.limit must be less than or equal to 500"))) + .andExpect(jsonPath("$.errors[0].message", is(expectedMessage))) .andExpect(jsonPath("$.errors[0].type", is("ConstraintViolationException"))) .andExpect(jsonPath("$.errors[0].code", is("validation_error"))); } - @Test - void search_negative_invalidCqlQuery() throws Exception { + @ParameterizedTest + @MethodSource("provideSearchPaths") + void search_negative_invalidCqlQuery(Class requestClass, + String searchPass, + boolean expandAll, + int limit) throws Exception { var cqlQuery = "title all \"test-query\" and"; - var expectedSearchRequest = searchServiceRequest(Instance.class, cqlQuery); + var expectedSearchRequest = searchServiceRequest(requestClass, TENANT_ID, cqlQuery, expandAll, limit); var exceptionMessage = String.format("Failed to parse CQL query [query: '%s']", cqlQuery); when(searchService.search(expectedSearchRequest)).thenThrow(new SearchServiceException(exceptionMessage)); - var requestBuilder = get("/search/instances") + var requestBuilder = get(searchPass) .queryParam("query", cqlQuery) .contentType(APPLICATION_JSON) .header(XOkapiHeaders.TENANT, TENANT_ID); @@ -169,15 +182,19 @@ void search_negative_invalidCqlQuery() throws Exception { .andExpect(jsonPath("$.errors[0].code", is("service_error"))); } - @Test - void search_negative_unsupportedCqlQueryModifier() throws Exception { + @ParameterizedTest + @MethodSource("provideSearchPaths") + void search_negative_unsupportedCqlQueryModifier(Class requestClass, + String searchPass, + boolean expandAll, + int limit) throws Exception { var cqlQuery = "title all \"test-query\" and"; - var expectedSearchRequest = searchServiceRequest(Instance.class, cqlQuery); + var expectedSearchRequest = searchServiceRequest(requestClass, TENANT_ID, cqlQuery, expandAll, limit); var exceptionMessage = "Failed to parse CQL query. Comparator 'within' is not supported."; when(searchService.search(expectedSearchRequest)).thenThrow( new UnsupportedOperationException(exceptionMessage)); - var requestBuilder = get("/search/instances") + var requestBuilder = get(searchPass) .queryParam("query", cqlQuery) .contentType(APPLICATION_JSON) .header(XOkapiHeaders.TENANT, TENANT_ID); @@ -190,4 +207,12 @@ void search_negative_unsupportedCqlQueryModifier() throws Exception { .andExpect(jsonPath("$.errors[0].code", is("service_error"))); } + private static Stream provideSearchPaths() { + return Stream.of( + Arguments.of(Instance.class, "/search/instances", false, 100, "$.instances"), + Arguments.of(Authority.class, "/search/authorities", false, 100, "$.authorities"), + Arguments.of(Bibframe.class, "/search/bibframe", true, 10, "$.content"), + Arguments.of(BibframeAuthority.class, "/search/bibframe/authorities", true, 10, "$.content") + ); + } } diff --git a/src/test/java/org/folio/search/integration/KafkaMessageListenerTest.java b/src/test/java/org/folio/search/integration/KafkaMessageListenerTest.java index e19a87d6f..af2ba5f01 100644 --- a/src/test/java/org/folio/search/integration/KafkaMessageListenerTest.java +++ b/src/test/java/org/folio/search/integration/KafkaMessageListenerTest.java @@ -8,11 +8,15 @@ import static org.folio.search.domain.dto.ResourceEventType.REINDEX; import static org.folio.search.domain.dto.ResourceEventType.UPDATE; import static org.folio.search.utils.SearchUtils.AUTHORITY_RESOURCE; +import static org.folio.search.utils.SearchUtils.BIBFRAME_AUTHORITY_RESOURCE; +import static org.folio.search.utils.SearchUtils.BIBFRAME_RESOURCE; import static org.folio.search.utils.SearchUtils.CONTRIBUTOR_RESOURCE; import static org.folio.search.utils.SearchUtils.INSTANCE_RESOURCE; import static org.folio.search.utils.TestConstants.INVENTORY_INSTANCE_TOPIC; import static org.folio.search.utils.TestConstants.RESOURCE_ID; import static org.folio.search.utils.TestConstants.TENANT_ID; +import static org.folio.search.utils.TestConstants.bibframeAuthorityTopic; +import static org.folio.search.utils.TestConstants.bibframeTopic; import static org.folio.search.utils.TestConstants.consortiumInstanceTopic; import static org.folio.search.utils.TestConstants.inventoryAuthorityTopic; import static org.folio.search.utils.TestConstants.inventoryBoundWithTopic; @@ -42,6 +46,8 @@ import java.util.function.BiConsumer; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.folio.search.domain.dto.Authority; +import org.folio.search.domain.dto.Bibframe; +import org.folio.search.domain.dto.BibframeAuthority; import org.folio.search.domain.dto.ResourceEvent; import org.folio.search.domain.dto.ResourceEventType; import org.folio.search.model.event.ConsortiumInstanceEvent; @@ -285,6 +291,64 @@ void handleConsortiumInstanceEvents_negative() { any(), any()); } + @Test + void handleBibframeEvent_positive() { + var payload = toMap(new Bibframe().id(RESOURCE_ID)); + + messageListener.handleBibframeEvents(List.of(new ConsumerRecord<>( + bibframeTopic(TENANT_ID), 0, 0, RESOURCE_ID, resourceEvent(null, BIBFRAME_RESOURCE, CREATE, payload, null)))); + + var expectedEvents = singletonList(resourceEvent(RESOURCE_ID, BIBFRAME_RESOURCE, CREATE, payload, null)); + verify(resourceService).indexResources(expectedEvents); + verify(batchProcessor).consumeBatchWithFallback(eq(expectedEvents), eq(KAFKA_RETRY_TEMPLATE_NAME), any(), any()); + } + + @Test + void handleBibframeEvent_negative_logFailedEvent() { + var payload = toMap(new Bibframe().id(RESOURCE_ID)); + var expectedEvents = List.of(resourceEvent(RESOURCE_ID, BIBFRAME_RESOURCE, UPDATE, payload, null)); + + doAnswer(inv -> { + inv.>getArgument(3).accept(expectedEvents.get(0), new Exception("error")); + return null; + }).when(batchProcessor).consumeBatchWithFallback(eq(expectedEvents), eq(KAFKA_RETRY_TEMPLATE_NAME), any(), any()); + + messageListener.handleBibframeEvents(List.of(new ConsumerRecord<>( + bibframeTopic(TENANT_ID), 0, 0, RESOURCE_ID, resourceEvent(null, BIBFRAME_RESOURCE, UPDATE, payload, null)))); + + verify(batchProcessor).consumeBatchWithFallback(eq(expectedEvents), eq(KAFKA_RETRY_TEMPLATE_NAME), any(), any()); + } + + @Test + void handleBibframeAuthorityEvent_positive() { + var payload = toMap(new BibframeAuthority().id(RESOURCE_ID)); + + messageListener.handleBibframeEvents(List.of(new ConsumerRecord<>( + bibframeAuthorityTopic(TENANT_ID), 0, 0, RESOURCE_ID, + resourceEvent(null, BIBFRAME_AUTHORITY_RESOURCE, CREATE, payload, null)))); + + var expectedEvents = singletonList(resourceEvent(RESOURCE_ID, BIBFRAME_AUTHORITY_RESOURCE, CREATE, payload, null)); + verify(resourceService).indexResources(expectedEvents); + verify(batchProcessor).consumeBatchWithFallback(eq(expectedEvents), eq(KAFKA_RETRY_TEMPLATE_NAME), any(), any()); + } + + @Test + void handleBibframeAuthorityEvent_negative_logFailedEvent() { + var payload = toMap(new BibframeAuthority().id(RESOURCE_ID)); + var expectedEvents = List.of(resourceEvent(RESOURCE_ID, BIBFRAME_AUTHORITY_RESOURCE, UPDATE, payload, null)); + + doAnswer(inv -> { + inv.>getArgument(3).accept(expectedEvents.get(0), new Exception("error")); + return null; + }).when(batchProcessor).consumeBatchWithFallback(eq(expectedEvents), eq(KAFKA_RETRY_TEMPLATE_NAME), any(), any()); + + messageListener.handleBibframeEvents(List.of(new ConsumerRecord<>( + bibframeAuthorityTopic(TENANT_ID), 0, 0, RESOURCE_ID, + resourceEvent(null, BIBFRAME_AUTHORITY_RESOURCE, UPDATE, payload, null)))); + + verify(batchProcessor).consumeBatchWithFallback(eq(expectedEvents), eq(KAFKA_RETRY_TEMPLATE_NAME), any(), any()); + } + @Test void handleClassificationTypeEvent_positive_filterOnlyDeleteEvents() { var deleteEvent = resourceEvent(RESOURCE_ID, ResourceType.CLASSIFICATION_TYPE.getValue(), DELETE, null, emptyMap()); diff --git a/src/test/java/org/folio/search/sample/SampleBibframe.java b/src/test/java/org/folio/search/sample/SampleBibframe.java index 56962f4d0..0fc0355b6 100644 --- a/src/test/java/org/folio/search/sample/SampleBibframe.java +++ b/src/test/java/org/folio/search/sample/SampleBibframe.java @@ -16,6 +16,12 @@ public class SampleBibframe { private static final Map BIBFRAME_2_AS_MAP = readJsonFromFile("/samples/bibframe/bibframe2.json", MAP_TYPE_REFERENCE); + private static final Map BIBFRAME_AUTHORITY_CONCEPT_AS_MAP = + readJsonFromFile("/samples/bibframe/authority_concept.json", MAP_TYPE_REFERENCE); + + private static final Map BIBFRAME_AUTHORITY_PERSON_AS_MAP = + readJsonFromFile("/samples/bibframe/authority_person.json", MAP_TYPE_REFERENCE); + public static Map getBibframeSampleAsMap() { return BIBFRAME_AS_MAP; } @@ -23,4 +29,12 @@ public static Map getBibframeSampleAsMap() { public static Map getBibframe2SampleAsMap() { return BIBFRAME_2_AS_MAP; } + + public static Map getBibframeAuthorityConceptSampleAsMap() { + return BIBFRAME_AUTHORITY_CONCEPT_AS_MAP; + } + + public static Map getBibframeAuthorityPersonSampleAsMap() { + return BIBFRAME_AUTHORITY_PERSON_AS_MAP; + } } diff --git a/src/test/java/org/folio/search/support/base/ApiEndpoints.java b/src/test/java/org/folio/search/support/base/ApiEndpoints.java index 5f8c14f3d..b3fe38d00 100644 --- a/src/test/java/org/folio/search/support/base/ApiEndpoints.java +++ b/src/test/java/org/folio/search/support/base/ApiEndpoints.java @@ -81,6 +81,10 @@ public static String bibframeSearchPath() { return "/search/bibframe"; } + public static String bibframeAuthoritySearchPath() { + return "/search/bibframe/authorities"; + } + public static String authorityBrowsePath() { return "/browse/authorities"; } diff --git a/src/test/java/org/folio/search/support/base/BaseIntegrationTest.java b/src/test/java/org/folio/search/support/base/BaseIntegrationTest.java index 96c644e06..5059a9888 100644 --- a/src/test/java/org/folio/search/support/base/BaseIntegrationTest.java +++ b/src/test/java/org/folio/search/support/base/BaseIntegrationTest.java @@ -5,10 +5,12 @@ import static org.awaitility.Durations.TWO_HUNDRED_MILLISECONDS; import static org.awaitility.Durations.TWO_MINUTES; import static org.folio.search.support.base.ApiEndpoints.authoritySearchPath; +import static org.folio.search.support.base.ApiEndpoints.bibframeAuthoritySearchPath; import static org.folio.search.support.base.ApiEndpoints.bibframeSearchPath; import static org.folio.search.support.base.ApiEndpoints.instanceSearchPath; import static org.folio.search.utils.SearchUtils.getIndexName; import static org.folio.search.utils.TestConstants.TENANT_ID; +import static org.folio.search.utils.TestConstants.bibframeAuthorityTopic; import static org.folio.search.utils.TestConstants.bibframeTopic; import static org.folio.search.utils.TestConstants.inventoryAuthorityTopic; import static org.folio.search.utils.TestUtils.asJsonString; @@ -40,6 +42,7 @@ import lombok.extern.log4j.Log4j2; import org.folio.search.domain.dto.Authority; import org.folio.search.domain.dto.Bibframe; +import org.folio.search.domain.dto.BibframeAuthority; import org.folio.search.domain.dto.FeatureConfig; import org.folio.search.domain.dto.Instance; import org.folio.search.domain.dto.ResourceEvent; @@ -164,6 +167,11 @@ protected static ResultActions doSearchByBibframe(String query) { return doSearch(bibframeSearchPath(), TENANT_ID, query, null, null, null); } + @SneakyThrows + protected static ResultActions doSearchByBibframeAuthority(String query) { + return doSearch(bibframeAuthoritySearchPath(), TENANT_ID, query, null, null, null); + } + @SneakyThrows protected static ResultActions attemptSearchByAuthorities(String query) { return attemptSearch(authoritySearchPath(), TENANT_ID, query, null, null, null); @@ -290,6 +298,11 @@ protected static void setUpTenant(Class type, String tenant, Runnable postIni setUpTenant(tenant, bibframeSearchPath(), postInitAction, asList(records), expectedCount, bibframe -> kafkaTemplate.send(bibframeTopic(tenant), resourceEvent(null, null, bibframe))); } + + if (type.equals(BibframeAuthority.class)) { + setUpTenant(tenant, bibframeAuthoritySearchPath(), postInitAction, asList(records), expectedCount, + bibAuth -> kafkaTemplate.send(bibframeAuthorityTopic(tenant), resourceEvent(null, null, bibAuth))); + } } @SneakyThrows diff --git a/src/test/java/org/folio/search/utils/TestConstants.java b/src/test/java/org/folio/search/utils/TestConstants.java index 6e7b827e0..a792bbce6 100644 --- a/src/test/java/org/folio/search/utils/TestConstants.java +++ b/src/test/java/org/folio/search/utils/TestConstants.java @@ -38,6 +38,7 @@ public class TestConstants { public static final String INVENTORY_CLASSIFICATION_TYPE_TOPIC = "inventory.classification-type"; public static final String CONSORTIUM_INSTANCE_TOPIC = "search.consortium.instance"; public static final String BIBFRAME_TOPIC = "search.bibframe"; + public static final String BIBFRAME_AUTHORITY_TOPIC = "search.bibframe-authorities"; public static final String CAMPUS_TOPIC = "inventory.campus"; public static final String INSTITUTION_TOPIC = "inventory.institution"; public static final String LIBRARY_TOPIC = "inventory.library"; @@ -127,6 +128,10 @@ public static String bibframeTopic(String tenantId) { return getTopicName(tenantId, BIBFRAME_TOPIC); } + public static String bibframeAuthorityTopic(String tenantId) { + return getTopicName(tenantId, BIBFRAME_AUTHORITY_TOPIC); + } + public static String inventoryCampusTopic(String tenantId) { return getTopicName(tenantId, CAMPUS_TOPIC); } diff --git a/src/test/java/org/folio/search/utils/TestUtils.java b/src/test/java/org/folio/search/utils/TestUtils.java index fcb82fa6e..fb5e20ba1 100644 --- a/src/test/java/org/folio/search/utils/TestUtils.java +++ b/src/test/java/org/folio/search/utils/TestUtils.java @@ -182,7 +182,12 @@ public static CqlSearchRequest searchServiceRequest(Class resourceClas public static CqlSearchRequest searchServiceRequest(Class resourceClass, String tenantId, String query, boolean expandAll) { - return CqlSearchRequest.of(resourceClass, tenantId, query, 100, 0, expandAll); + return searchServiceRequest(resourceClass, tenantId, query, expandAll, 100); + } + + public static CqlSearchRequest searchServiceRequest(Class resourceClass, String tenantId, String query, + boolean expandAll, int limit) { + return CqlSearchRequest.of(resourceClass, tenantId, query, limit, 0, expandAll); } public static CqlFacetRequest defaultFacetServiceRequest(String resource, String query, String... facets) { diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3ec4ad8e7..486a6c3aa 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -113,6 +113,9 @@ folio: - name: search.bibframe numPartitions: 1 replicationFactor: 1 + - name: search.bibframe-authorities + numPartitions: 1 + replicationFactor: 1 - name: inventory.campus numPartitions: 1 replicationFactor: 1 @@ -152,8 +155,8 @@ folio: topic-pattern: (${folio.environment}\.)(.*\.)inventory\.(location|campus|institution|library) group-id: ${folio.environment}-mod-search-location-type-group bibframe: - concurrency: ${KAFKA_BIBFRAME_CONCURRENCY:1} - topic-pattern: ${KAFKA_BIBFRAME_CONSUMER_PATTERN:(${folio.environment}\.)(.*\.)search\.bibframe} + concurrency: 1 + topic-pattern: (${folio.environment}\.)(.*\.)search\.(bibframe|bibframe-authorities) group-id: ${folio.environment}-mod-search-bibframe-group okapiUrl: ${okapi.url} logging: diff --git a/src/test/resources/samples/bibframe/authority_concept.json b/src/test/resources/samples/bibframe/authority_concept.json new file mode 100644 index 000000000..04205f469 --- /dev/null +++ b/src/test/resources/samples/bibframe/authority_concept.json @@ -0,0 +1,15 @@ +{ + "id": "123456654321", + "label": "concept-label", + "type": "CONCEPT", + "identifiers": [ + { + "value": "sh 0123456789", + "type": "LCCN" + }, + { + "value": "sh 9876543210", + "type": "LCCN" + } + ] +} diff --git a/src/test/resources/samples/bibframe/authority_person.json b/src/test/resources/samples/bibframe/authority_person.json new file mode 100644 index 000000000..ceb3e408a --- /dev/null +++ b/src/test/resources/samples/bibframe/authority_person.json @@ -0,0 +1,11 @@ +{ + "id": "123456654322", + "label": "person-label", + "type": "PERSON", + "identifiers": [ + { + "value": "n 0123456789", + "type": "LCCN" + } + ] +}