mirror of https://github.com/halo-dev/halo
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
parent
2aaf64aa34
commit
4ea4bdf8b5
|
@ -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);
|
||||
|
||||
}
|
|
@ -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");
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
}
|
||||
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue