diff --git a/pom.xml b/pom.xml
index 55585f8..15985d7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -138,6 +138,13 @@
${lucene.version}
+
+
+ me.xdrop
+ fuzzywuzzy
+ 1.4.0
+
+
org.postgresql
diff --git a/src/main/kotlin/no/nb/bikube/core/controller/CoreController.kt b/src/main/kotlin/no/nb/bikube/core/controller/CoreController.kt
index a05ada4..7439762 100644
--- a/src/main/kotlin/no/nb/bikube/core/controller/CoreController.kt
+++ b/src/main/kotlin/no/nb/bikube/core/controller/CoreController.kt
@@ -18,6 +18,7 @@ import no.nb.bikube.core.exception.NotSupportedException
import no.nb.bikube.core.model.CatalogueRecord
import no.nb.bikube.core.model.Item
import no.nb.bikube.core.model.Title
+import no.nb.bikube.core.service.SearchFilterService
import no.nb.bikube.newspaper.service.NewspaperService
import no.nb.bikube.newspaper.service.TitleIndexService
import org.springframework.http.MediaType
@@ -36,7 +37,8 @@ import java.time.LocalDate
@RequestMapping("")
class CoreController (
private val newspaperService: NewspaperService,
- private val titleIndexService: TitleIndexService
+ private val titleIndexService: TitleIndexService,
+ private val searchFilterService: SearchFilterService
){
companion object {
const val DATE_REGEX = "^(17|18|19|20)\\d{2}(-)?(0[1-9]|1[0-2])(-)?(0[1-9]|[12][0-9]|3[01])$"
@@ -88,12 +90,22 @@ class CoreController (
ApiResponse(responseCode = "500", description = "Server error", content = [Content()])
])
fun searchTitle(
+ @Parameter(description = "Search term")
@RequestParam searchTerm: String,
- @RequestParam materialType: MaterialType
+ @Parameter(description = "Material type of the titles to search for")
+ @RequestParam materialType: MaterialType,
+ @Parameter(description = "Date in ISO-8601 format (YYYY-MM-DD). " +
+ "If provided, only titles with start and end date that includes this date will be returned.")
+ @RequestParam date: LocalDate? = null,
+ @Parameter(description = "Whether or not to select only the best match for search term")
+ @RequestParam selectBestMatch: Boolean = false
): ResponseEntity> {
if (searchTerm.isEmpty()) throw BadRequestBodyException("Search term cannot be empty.")
return when(materialTypeToCatalogueName(materialType)) {
- CatalogueName.COLLECTIONS -> ResponseEntity.ok(titleIndexService.searchTitle(searchTerm))
+ CatalogueName.COLLECTIONS -> {
+ val searchResult = titleIndexService.searchTitle(searchTerm)
+ ResponseEntity.ok(searchFilterService.filterSearchResults(searchResult, searchTerm, date, selectBestMatch))
+ }
else -> throw NotSupportedException("Material type $materialType is not supported.")
}
}
diff --git a/src/main/kotlin/no/nb/bikube/core/service/SearchFilterService.kt b/src/main/kotlin/no/nb/bikube/core/service/SearchFilterService.kt
new file mode 100644
index 0000000..8277a48
--- /dev/null
+++ b/src/main/kotlin/no/nb/bikube/core/service/SearchFilterService.kt
@@ -0,0 +1,31 @@
+package no.nb.bikube.core.service
+
+import me.xdrop.fuzzywuzzy.FuzzySearch
+import no.nb.bikube.core.model.Title
+import no.nb.bikube.core.util.logger
+import org.springframework.stereotype.Service
+import java.time.LocalDate
+
+@Service
+class SearchFilterService {
+ fun filterSearchResults(searchResult: List, searchTerm: String, date: LocalDate?, selectBestMatch: Boolean?): List {
+ val filteredResult = date?.let {
+ searchResult.filter { title ->
+ (title.startDate == null || !it.isBefore(title.startDate)) &&
+ (title.endDate == null || !it.isAfter(title.endDate))
+ }
+ } ?: searchResult
+
+ val scores = FuzzySearch.extractAll(searchTerm, filteredResult.map { it.name })
+ logger().debug("Fuzzy search scores {}", scores)
+
+ val bestMatch = scores.maxByOrNull { it.score }
+ logger().debug("Fuzzy search best match {}", bestMatch)
+
+ return if (selectBestMatch == true && bestMatch != null && bestMatch.score > 50) {
+ listOf(filteredResult[bestMatch.index])
+ } else {
+ filteredResult
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerIntegrationTest.kt b/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerIntegrationTest.kt
index 8a99a29..2ce1152 100644
--- a/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerIntegrationTest.kt
+++ b/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerIntegrationTest.kt
@@ -8,6 +8,7 @@ import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.col
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockItemA
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockManifestationA
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockTitleA
+import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockTitleB
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsPartsObjectMockItemA
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsPartsObjectMockManifestationA
import no.nb.bikube.catalogue.collections.enum.CollectionsFormat
@@ -30,9 +31,11 @@ import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec
import org.springframework.test.web.reactive.server.expectBody
import org.springframework.test.web.reactive.server.returnResult
+import org.springframework.web.util.UriBuilder
import reactor.core.publisher.Mono
import reactor.kotlin.test.test
import java.time.Duration
+import java.time.LocalDate
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@@ -89,13 +92,21 @@ class CoreControllerIntegrationTest (
.exchange()
}
- private fun searchTitle(search: String, materialType: MaterialType): ResponseSpec {
+ private fun searchTitle(search: String, materialType: MaterialType, date: LocalDate? = null, selectBestMatch: Boolean? = null): ResponseSpec {
return webClient
.get()
.uri { uri ->
uri.pathSegment("title", "search")
.queryParam("searchTerm", search)
.queryParam("materialType", materialType)
+ .apply {
+ if (date != null) {
+ queryParam("date", date)
+ }
+ if (selectBestMatch != null) {
+ queryParam("selectBestMatch", selectBestMatch)
+ }
+ }
.build()
}
.exchange()
@@ -322,6 +333,29 @@ class CoreControllerIntegrationTest (
.verifyComplete()
}
+ @Test
+ fun `get-title-search endpoint should return best match if query parameter is present`() {
+ searchTitle("Bikubeavisen", MaterialType.NEWSPAPER, selectBestMatch = true)
+ .returnResult()
+ .responseBody
+ .test()
+ .expectSubscription()
+ .expectNext(mapCollectionsObjectToGenericTitle(collectionsModelMockTitleA.getFirstObject()))
+ .verifyComplete()
+ }
+
+ @Test
+ fun `get-title-search endpoint should filter by date if query parameter is present`() {
+ searchTitle("Bikubeavisen", MaterialType.NEWSPAPER, date = LocalDate.parse("2021-01-01"))
+ .returnResult()
+ .responseBody
+ .test()
+ .expectSubscription()
+ .expectNext(mapCollectionsObjectToGenericTitle(collectionsModelMockTitleA.getFirstObject()))
+ .expectNext(mapCollectionsObjectToGenericTitle(collectionsModelMockTitleB.getFirstObject()))
+ .verifyComplete()
+ }
+
@Test
fun `search-item endpoint should return 200 OK with all items`() {
webClient
diff --git a/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerTest.kt b/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerTest.kt
index b2d08bc..5043749 100644
--- a/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerTest.kt
+++ b/src/test/kotlin/no/nb/bikube/core/controller/CoreControllerTest.kt
@@ -8,6 +8,7 @@ import no.nb.bikube.core.enum.MaterialType
import no.nb.bikube.core.exception.BadRequestBodyException
import no.nb.bikube.core.exception.NotSupportedException
import no.nb.bikube.core.model.Title
+import no.nb.bikube.core.service.SearchFilterService
import no.nb.bikube.newspaper.NewspaperMockData.Companion.newspaperItemMockA
import no.nb.bikube.newspaper.NewspaperMockData.Companion.newspaperTitleMockA
import no.nb.bikube.newspaper.NewspaperMockData.Companion.newspaperTitleMockB
@@ -35,6 +36,9 @@ class CoreControllerTest {
@MockkBean
private lateinit var titleIndexService: TitleIndexService
+ @MockkBean
+ private lateinit var searchFilterService: SearchFilterService
+
@Test
fun `get single item for newspaper should return item in body`() {
every { newspaperService.getSingleItem(any()) } returns Mono.just(newspaperItemMockA.copy())
@@ -97,6 +101,10 @@ class CoreControllerTest {
newspaperTitleMockA.copy(), newspaperTitleMockB.copy()
)
+ every { searchFilterService.filterSearchResults(any(), any(), any(), any()) } returns listOf(
+ newspaperTitleMockA.copy(), newspaperTitleMockB.copy()
+ )
+
Assertions.assertEquals(
coreController.searchTitle("Avis", MaterialType.NEWSPAPER).body!!,
listOf(
@@ -117,6 +125,7 @@ class CoreControllerTest {
@Test
fun `search title should call on titleIndexService function when materialType is NEWSPAPER`() {
every { titleIndexService.searchTitle(any()) } returns emptyList()
+ every { searchFilterService.filterSearchResults(any(), any(), any(), any()) } returns emptyList()
Assertions.assertEquals(
coreController.searchTitle("Avis", MaterialType.NEWSPAPER).body!!,
@@ -126,6 +135,24 @@ class CoreControllerTest {
verify(exactly = 1) { titleIndexService.searchTitle(any()) }
}
+ @Test
+ fun `search title should call on searchFilterService function when materialType is NEWSPAPER`() {
+ every { titleIndexService.searchTitle(any()) } returns listOf(
+ newspaperTitleMockA.copy(), newspaperTitleMockB.copy()
+ )
+ every { searchFilterService.filterSearchResults(any(), any(), any(), any()) } returns listOf(
+ newspaperTitleMockA.copy(), newspaperTitleMockB.copy()
+ )
+
+ Assertions.assertEquals(
+ coreController.searchTitle("Avis", MaterialType.NEWSPAPER).body!!,
+ listOf(
+ newspaperTitleMockA.copy(), newspaperTitleMockB.copy()
+ )
+ )
+ verify(exactly = 1) { searchFilterService.filterSearchResults(any(), any(), any(), any()) }
+ }
+
@Test
fun `search item should return a list of items matching criteria`() {
every { newspaperService.getItemsByTitleAndDate(any(), any(), any()) } returns Flux.just(
diff --git a/src/test/kotlin/no/nb/bikube/core/service/SearchFilterServiceTest.kt b/src/test/kotlin/no/nb/bikube/core/service/SearchFilterServiceTest.kt
new file mode 100644
index 0000000..d3ceb81
--- /dev/null
+++ b/src/test/kotlin/no/nb/bikube/core/service/SearchFilterServiceTest.kt
@@ -0,0 +1,87 @@
+package no.nb.bikube.core.service
+
+import no.nb.bikube.core.enum.MaterialType
+import no.nb.bikube.core.model.Title
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.context.SpringBootTest
+import org.springframework.test.context.ActiveProfiles
+import java.time.LocalDate
+
+@SpringBootTest
+@ActiveProfiles("test")
+class SearchFilterServiceTest {
+ @Autowired
+ private lateinit var searchFilterService: SearchFilterService
+
+ private val titleA: Title = Title(
+ name = "A-Avisen",
+ startDate = LocalDate.parse("1900-01-01"),
+ endDate = LocalDate.parse("1925-12-31"),
+ catalogueId = "1",
+ publisher = null,
+ publisherPlace = null,
+ language = null,
+ materialType = MaterialType.NEWSPAPER.norwegian
+ )
+
+ private val titleB = Title(
+ name = "B-Avisen",
+ startDate = LocalDate.parse("1950-01-01"),
+ endDate = LocalDate.parse("1999-12-31"),
+ catalogueId = "2",
+ publisher = null,
+ publisherPlace = null,
+ language = null,
+ materialType = MaterialType.NEWSPAPER.norwegian
+ )
+
+ private val titleC = Title(
+ name = "C-Avisen",
+ startDate = LocalDate.parse("1998-01-01"),
+ endDate = null,
+ catalogueId = "3",
+ publisher = null,
+ publisherPlace = null,
+ language = null,
+ materialType = MaterialType.NEWSPAPER.norwegian
+ )
+
+ private val results: List = listOf(titleA, titleB, titleC)
+
+ @Test
+ fun `filterSearchResults should filter by date`() {
+ val date1 = LocalDate.parse("1990-01-01")
+ val filteredResults1 = searchFilterService.filterSearchResults(results, "", date1, false)
+ assert(filteredResults1.size == 1)
+ assert(filteredResults1.contains(titleB))
+
+ val date2 = LocalDate.parse("1950-01-01")
+ val filteredResults2 = searchFilterService.filterSearchResults(results, "", date2, true)
+ assert(filteredResults2.size == 1)
+ assert(filteredResults2.contains(titleB))
+
+ val date3 = LocalDate.parse("1999-01-01")
+ val filteredResults3 = searchFilterService.filterSearchResults(results, "", date3, false)
+ assert(filteredResults3.size == 2)
+ assert(filteredResults3.contains(titleB))
+ assert(filteredResults3.contains(titleC))
+ }
+
+ @Test
+ fun `filterSearchResults should select best match`() {
+ val searchTerm = "B-Avisen"
+ val filteredResults = searchFilterService.filterSearchResults(results, searchTerm, null, true)
+ assert(filteredResults.size == 1)
+ assert(filteredResults.contains(titleB))
+ }
+
+ @Test
+ fun `filterSearchResults should return original results list if best match has too low score`() {
+ val searchTerm = "En helt annen tittel" // Gives 45 score
+ val filteredResults = searchFilterService.filterSearchResults(results, searchTerm, null, true)
+ assert(filteredResults.size == 3)
+ assert(filteredResults.containsAll(listOf(titleA, titleB, titleC)))
+ }
+
+}
\ No newline at end of file