From fc6af19ae639887e7d4d37c3414c07ee33f15aad Mon Sep 17 00:00:00 2001 From: JohnNiang Date: Mon, 1 Jul 2024 16:41:27 +0800 Subject: [PATCH] Expose search service for plugin Signed-off-by: JohnNiang --- .../run/halo/app/search/SearchService.java | 21 ++++ ...efaultPluginApplicationContextFactory.java | 6 + .../app/plugin/PluginAutoConfiguration.java | 2 +- .../run/halo/app/search/IndexEndpoint.java | 25 +---- .../halo/app/search/SearchServiceImpl.java | 36 ++++++ ...ltPluginApplicationContextFactoryTest.java | 47 ++++++++ .../halo/app/search/IndexEndpointTest.java | 48 ++------ .../app/search/SearchServiceImplTest.java | 106 ++++++++++++++++++ 8 files changed, 232 insertions(+), 59 deletions(-) create mode 100644 api/src/main/java/run/halo/app/search/SearchService.java create mode 100644 application/src/main/java/run/halo/app/search/SearchServiceImpl.java create mode 100644 application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java create mode 100644 application/src/test/java/run/halo/app/search/SearchServiceImplTest.java diff --git a/api/src/main/java/run/halo/app/search/SearchService.java b/api/src/main/java/run/halo/app/search/SearchService.java new file mode 100644 index 0000000000..8d1758839c --- /dev/null +++ b/api/src/main/java/run/halo/app/search/SearchService.java @@ -0,0 +1,21 @@ +package run.halo.app.search; + +import reactor.core.publisher.Mono; + +/** + * Search service is used to search content. + * + * @author johnniang + * @since 2.17.0 + */ +public interface SearchService { + + /** + * Perform search. + * + * @param option search option must not be null + * @return search result + */ + Mono search(SearchOption option); + +} diff --git a/application/src/main/java/run/halo/app/plugin/DefaultPluginApplicationContextFactory.java b/application/src/main/java/run/halo/app/plugin/DefaultPluginApplicationContextFactory.java index 0d29e3290d..2896c57713 100644 --- a/application/src/main/java/run/halo/app/plugin/DefaultPluginApplicationContextFactory.java +++ b/application/src/main/java/run/halo/app/plugin/DefaultPluginApplicationContextFactory.java @@ -38,6 +38,7 @@ import run.halo.app.plugin.event.SpringPluginStartedEvent; import run.halo.app.plugin.event.SpringPluginStoppedEvent; import run.halo.app.plugin.event.SpringPluginStoppingEvent; +import run.halo.app.search.SearchService; import run.halo.app.theme.DefaultTemplateNameResolver; import run.halo.app.theme.ViewNameResolver; import run.halo.app.theme.finders.FinderRegistry; @@ -136,6 +137,11 @@ public ApplicationContext create(String pluginId) { ); }); + rootContext.getBeanProvider(SearchService.class) + .ifUnique(searchService -> + beanFactory.registerSingleton("searchService", searchService) + ); + sw.stop(); sw.start("LoadComponents"); diff --git a/application/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java b/application/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java index f94e9e97ef..2fec70b5fc 100644 --- a/application/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java +++ b/application/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java @@ -43,7 +43,7 @@ public PluginRequestMappingHandlerMapping pluginRequestMappingHandlerMapping( } @Bean - public PluginManager pluginManager(ApplicationContext context, + public SpringPluginManager pluginManager(ApplicationContext context, SystemVersionSupplier systemVersionSupplier, PluginProperties pluginProperties) { return new HaloPluginManager(context, pluginProperties, systemVersionSupplier); diff --git a/application/src/main/java/run/halo/app/search/IndexEndpoint.java b/application/src/main/java/run/halo/app/search/IndexEndpoint.java index 5571a4d14b..69dc51052a 100644 --- a/application/src/main/java/run/halo/app/search/IndexEndpoint.java +++ b/application/src/main/java/run/halo/app/search/IndexEndpoint.java @@ -6,17 +6,13 @@ import java.util.List; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.stereotype.Component; -import org.springframework.validation.Validator; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; -import run.halo.app.infra.exception.RequestBodyValidationException; -import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.search.post.PostHaloDocumentsProvider; @Component @@ -24,13 +20,10 @@ public class IndexEndpoint implements CustomEndpoint { private static final String API_VERSION = "api.halo.run/v1alpha1"; - private final ExtensionGetter extensionGetter; + private final SearchService searchService; - private final Validator validator; - - public IndexEndpoint(ExtensionGetter extensionGetter, Validator validator) { - this.extensionGetter = extensionGetter; - this.validator = validator; + public IndexEndpoint(SearchService searchService) { + this.searchService = searchService; } @Override @@ -92,17 +85,7 @@ private Mono performSearch(SearchOption option) { option.setFilterExposed(true); option.setFilterPublished(true); option.setFilterRecycled(false); - // validate the option - var errors = validator.validateObject(option); - if (errors.hasErrors()) { - return Mono.error(new RequestBodyValidationException(errors)); - } - return extensionGetter.getEnabledExtension(SearchEngine.class) - .filter(SearchEngine::available) - .switchIfEmpty(Mono.error(SearchEngineUnavailableException::new)) - .flatMap(searchEngine -> Mono.fromSupplier(() -> - searchEngine.search(option) - ).subscribeOn(Schedulers.boundedElastic())); + return searchService.search(option); } @Override diff --git a/application/src/main/java/run/halo/app/search/SearchServiceImpl.java b/application/src/main/java/run/halo/app/search/SearchServiceImpl.java new file mode 100644 index 0000000000..0e229bf154 --- /dev/null +++ b/application/src/main/java/run/halo/app/search/SearchServiceImpl.java @@ -0,0 +1,36 @@ +package run.halo.app.search; + +import org.springframework.stereotype.Service; +import org.springframework.validation.Validator; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import run.halo.app.infra.exception.RequestBodyValidationException; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +@Service +public class SearchServiceImpl implements SearchService { + + private final Validator validator; + + private final ExtensionGetter extensionGetter; + + public SearchServiceImpl(Validator validator, ExtensionGetter extensionGetter) { + this.validator = validator; + this.extensionGetter = extensionGetter; + } + + @Override + public Mono search(SearchOption option) { + // validate the option + var errors = validator.validateObject(option); + if (errors.hasErrors()) { + return Mono.error(new RequestBodyValidationException(errors)); + } + return extensionGetter.getEnabledExtension(SearchEngine.class) + .filter(SearchEngine::available) + .switchIfEmpty(Mono.error(SearchEngineUnavailableException::new)) + .flatMap(searchEngine -> Mono.fromSupplier(() -> + searchEngine.search(option) + ).subscribeOn(Schedulers.boundedElastic())); + } +} diff --git a/application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java b/application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java new file mode 100644 index 0000000000..74e56a65a0 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java @@ -0,0 +1,47 @@ +package run.halo.app.plugin; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.pf4j.PluginWrapper; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import run.halo.app.search.SearchService; + +@SpringBootTest +class DefaultPluginApplicationContextFactoryTest { + + @SpyBean + SpringPluginManager pluginManager; + + DefaultPluginApplicationContextFactory factory; + + @BeforeEach + void setUp() { + factory = new DefaultPluginApplicationContextFactory((SpringPluginManager) pluginManager); + } + + @Test + void shouldCreateCorrectly() { + var pw = mock(PluginWrapper.class); + when(pw.getPluginClassLoader()).thenReturn(this.getClass().getClassLoader()); + var sp = mock(SpringPlugin.class); + var pluginContext = new PluginContext.PluginContextBuilder() + .name("fake-plugin") + .version("1.0.0") + .build(); + when(sp.getPluginContext()).thenReturn(pluginContext); + when(pw.getPlugin()).thenReturn(sp); + when(pluginManager.getPlugin("fake-plugin")).thenReturn(pw); + var context = factory.create("fake-plugin"); + + assertInstanceOf(PluginApplicationContext.class, context); + assertNotNull(context.getBeanProvider(SearchService.class).getIfUnique()); + // TODO Add more assertions here. + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/search/IndexEndpointTest.java b/application/src/test/java/run/halo/app/search/IndexEndpointTest.java index 2e36310071..e77e190f8e 100644 --- a/application/src/test/java/run/halo/app/search/IndexEndpointTest.java +++ b/application/src/test/java/run/halo/app/search/IndexEndpointTest.java @@ -17,20 +17,16 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.validation.Errors; -import org.springframework.validation.Validator; import org.springframework.web.reactive.function.server.HandlerStrategies; import org.springframework.web.server.handler.ResponseStatusExceptionHandler; import reactor.core.publisher.Mono; -import run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.app.infra.exception.RequestBodyValidationException; @ExtendWith(MockitoExtension.class) class IndexEndpointTest { @Mock - ExtensionGetter extensionGetter; - - @Mock - Validator validator; + SearchService searchService; @InjectMocks IndexEndpoint endpoint; @@ -57,8 +53,8 @@ void shouldResponseBadRequestIfNotRequestBody() { void shouldResponseBadRequestIfRequestBodyValidationFailed() { var option = new SearchOption(); var errors = mock(Errors.class); - when(errors.hasErrors()).thenReturn(true); - when(validator.validateObject(any(SearchOption.class))).thenReturn(errors); + when(searchService.search(any(SearchOption.class))) + .thenReturn(Mono.error(new RequestBodyValidationException(errors))); client.post().uri("/indices/-/search") .bodyValue(option) @@ -70,17 +66,8 @@ void shouldResponseBadRequestIfRequestBodyValidationFailed() { void shouldSearchCorrectly() { var option = new SearchOption(); option.setKeyword("halo"); - - var errors = mock(Errors.class); - when(errors.hasErrors()).thenReturn(false); - when(validator.validateObject(any(SearchOption.class))).thenReturn(errors); - - var searchEngine = mock(SearchEngine.class); - when(searchEngine.available()).thenReturn(true); var searchResult = new SearchResult(); - when(searchEngine.search(any(SearchOption.class))).thenReturn(searchResult); - when(extensionGetter.getEnabledExtension(SearchEngine.class)) - .thenReturn(Mono.just(searchEngine)); + when(searchService.search(any(SearchOption.class))).thenReturn(Mono.just(searchResult)); client.post().uri("/indices/-/search") .bodyValue(option) @@ -89,7 +76,7 @@ void shouldSearchCorrectly() { .expectBody(SearchResult.class) .isEqualTo(searchResult); - verify(searchEngine).search(assertArg(o -> { + verify(searchService).search(assertArg(o -> { assertEquals("halo", o.getKeyword()); // make sure the filters are overwritten assertTrue(o.getFilterExposed()); @@ -101,15 +88,8 @@ void shouldSearchCorrectly() { @Test void shouldBeCompatibleWithOldSearchApi() { var searchResult = new SearchResult(); - var searchEngine = mock(SearchEngine.class); - when(searchEngine.available()).thenReturn(true); - when(searchEngine.search(any(SearchOption.class))).thenReturn(searchResult); - when(extensionGetter.getEnabledExtension(SearchEngine.class)) - .thenReturn(Mono.just(searchEngine)); - - var errors = mock(Errors.class); - when(errors.hasErrors()).thenReturn(false); - when(validator.validateObject(any(SearchOption.class))).thenReturn(errors); + when(searchService.search(any(SearchOption.class))) + .thenReturn(Mono.just(searchResult)); client.get().uri(uriBuilder -> uriBuilder.path("/indices/post") .queryParam("keyword", "halo") @@ -119,7 +99,7 @@ void shouldBeCompatibleWithOldSearchApi() { .expectBody(SearchResult.class) .isEqualTo(searchResult); - verify(searchEngine).search(assertArg(o -> { + verify(searchService).search(assertArg(o -> { assertEquals("halo", o.getKeyword()); // make sure the filters are overwritten assertTrue(o.getFilterExposed()); @@ -130,14 +110,8 @@ void shouldBeCompatibleWithOldSearchApi() { @Test void shouldFailWhenSearchEngineIsUnavailable() { - var searchEngine = mock(SearchEngine.class); - when(searchEngine.available()).thenReturn(false); - when(extensionGetter.getEnabledExtension(SearchEngine.class)) - .thenReturn(Mono.just(searchEngine)); - - var errors = mock(Errors.class); - when(errors.hasErrors()).thenReturn(false); - when(validator.validateObject(any(SearchOption.class))).thenReturn(errors); + when(searchService.search(any(SearchOption.class))) + .thenReturn(Mono.error(new SearchEngineUnavailableException())); client.post().uri("/indices/-/search") .bodyValue(new SearchOption()) diff --git a/application/src/test/java/run/halo/app/search/SearchServiceImplTest.java b/application/src/test/java/run/halo/app/search/SearchServiceImplTest.java new file mode 100644 index 0000000000..53325fac45 --- /dev/null +++ b/application/src/test/java/run/halo/app/search/SearchServiceImplTest.java @@ -0,0 +1,106 @@ +package run.halo.app.search; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.infra.exception.RequestBodyValidationException; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +@ExtendWith(MockitoExtension.class) +class SearchServiceImplTest { + + @Mock + Validator validator; + + @Mock + ExtensionGetter extensionGetter; + + @InjectMocks + SearchServiceImpl searchService; + + @Test + void shouldThrowValidationErrorIfOptionIsInvalid() { + var option = new SearchOption(); + option.setKeyword("halo"); + + var errors = mock(Errors.class); + when(errors.hasErrors()).thenReturn(true); + when(validator.validateObject(option)).thenReturn(errors); + + searchService.search(option) + .as(StepVerifier::create) + .expectError(RequestBodyValidationException.class) + .verify(); + } + + @Test + void shouldThrowSearchEngineUnavailableExceptionIfNoSearchEngineFound() { + var option = new SearchOption(); + option.setKeyword("halo"); + + var errors = mock(Errors.class); + when(errors.hasErrors()).thenReturn(false); + when(validator.validateObject(option)).thenReturn(errors); + + when(extensionGetter.getEnabledExtension(SearchEngine.class)).thenReturn(Mono.empty()); + + searchService.search(option) + .as(StepVerifier::create) + .expectError(SearchEngineUnavailableException.class) + .verify(); + } + + @Test + void shouldThrowSearchEngineUnavailableExceptionIfNoSearchEngineAvailable() { + var option = new SearchOption(); + option.setKeyword("halo"); + + var errors = mock(Errors.class); + when(errors.hasErrors()).thenReturn(false); + when(validator.validateObject(option)).thenReturn(errors); + + when(extensionGetter.getEnabledExtension(SearchEngine.class)) + .thenAnswer(invocation -> Mono.fromSupplier(() -> { + var searchEngine = mock(SearchEngine.class); + when(searchEngine.available()).thenReturn(false); + return searchEngine; + })); + + searchService.search(option) + .as(StepVerifier::create) + .expectError(SearchEngineUnavailableException.class); + } + + @Test + void shouldSearch() { + var option = new SearchOption(); + option.setKeyword("halo"); + + var errors = mock(Errors.class); + when(errors.hasErrors()).thenReturn(false); + when(validator.validateObject(option)).thenReturn(errors); + + var searchResult = mock(SearchResult.class); + when(extensionGetter.getEnabledExtension(SearchEngine.class)) + .thenAnswer(invocation -> Mono.fromSupplier(() -> { + var searchEngine = mock(SearchEngine.class); + when(searchEngine.available()).thenReturn(true); + when(searchEngine.search(option)).thenReturn(searchResult); + return searchEngine; + })); + + searchService.search(option) + .as(StepVerifier::create) + .expectNext(searchResult) + .verifyComplete(); + } +}