Expose search service for plugin (#6239)

#### What type of PR is this?

/kind feature
/kind api-change
/area core
/area plugin
/milestone 2.17.0

#### What this PR does / why we need it:

This PR creates a SearchService and makes it invokable for plugins.

#### Special notes for your reviewer:

1. Create a plugin
2. Publish all publication into Maven local repository by executing `./gradlew publishAllPublicationsToMavenLocalRepository`
3. Use `2.17.0-SNAPSHOT` as dependency version and refresh dependencies
4. Try to use the SearchService to search something.

#### Does this PR introduce a user-facing change?

```release-note
为插件提供全文搜索服务
```
pull/6191/head^2
John Niang 2024-07-01 17:11:17 +08:00 committed by GitHub
parent 2aaf64aa34
commit 4ea4bdf8b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 232 additions and 59 deletions

View File

@ -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<SearchResult> search(SearchOption option);
}

View File

@ -38,6 +38,7 @@ import run.halo.app.plugin.event.HaloPluginStoppedEvent;
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 class DefaultPluginApplicationContextFactory implements PluginApplication
);
});
rootContext.getBeanProvider(SearchService.class)
.ifUnique(searchService ->
beanFactory.registerSingleton("searchService", searchService)
);
sw.stop();
sw.start("LoadComponents");

View File

@ -43,7 +43,7 @@ public class PluginAutoConfiguration {
}
@Bean
public PluginManager pluginManager(ApplicationContext context,
public SpringPluginManager pluginManager(ApplicationContext context,
SystemVersionSupplier systemVersionSupplier,
PluginProperties pluginProperties) {
return new HaloPluginManager(context, pluginProperties, systemVersionSupplier);

View File

@ -6,17 +6,13 @@ import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuil
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 @@ public class IndexEndpoint implements CustomEndpoint {
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

View File

@ -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<SearchResult> 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()));
}
}

View File

@ -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.
}
}

View File

@ -17,20 +17,16 @@ import org.mockito.Mock;
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 @@ class IndexEndpointTest {
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 @@ class IndexEndpointTest {
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 @@ class IndexEndpointTest {
.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 @@ class IndexEndpointTest {
@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 @@ class IndexEndpointTest {
.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 @@ class IndexEndpointTest {
@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())

View File

@ -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();
}
}