diff --git a/pom.xml b/pom.xml index 3d8cd95d..e0b02816 100644 --- a/pom.xml +++ b/pom.xml @@ -163,6 +163,10 @@ io.quarkus quarkus-hibernate-validator + + io.quarkus + quarkus-cache + io.quarkiverse.helm quarkus-helm diff --git a/src/main/java/io/quarkus/search/app/QuarkusVersions.java b/src/main/java/io/quarkus/search/app/QuarkusVersions.java index a42ee06e..551ac322 100644 --- a/src/main/java/io/quarkus/search/app/QuarkusVersions.java +++ b/src/main/java/io/quarkus/search/app/QuarkusVersions.java @@ -1,5 +1,7 @@ package io.quarkus.search.app; +import java.util.Comparator; + public final class QuarkusVersions { private QuarkusVersions() { } @@ -8,4 +10,24 @@ private QuarkusVersions() { public static final String MAIN = "main"; public static final String V3_2 = "3.2"; + public static final Comparator COMPARATOR = new Comparator() { + @Override + public int compare(String left, String right) { + if (left.equals(right)) { + return 0; + } else if (left.equals(MAIN)) { + return 1; + } else if (right.equals(MAIN)) { + return -1; + } else if (left.equals(LATEST)) { + // "latest" actually means "latest non-snapshot", so it's older than main. + return 1; + } else if (right.equals(LATEST)) { + return -1; + } else { + return left.compareTo(right); + } + } + }; + } diff --git a/src/main/java/io/quarkus/search/app/ReferenceService.java b/src/main/java/io/quarkus/search/app/ReferenceService.java new file mode 100644 index 00000000..a54659f8 --- /dev/null +++ b/src/main/java/io/quarkus/search/app/ReferenceService.java @@ -0,0 +1,82 @@ +package io.quarkus.search.app; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import io.quarkus.search.app.cache.MethodNameCacheKeyGenerator; +import io.quarkus.search.app.entity.Guide; +import io.quarkus.search.app.entity.Language; + +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheName; +import io.quarkus.cache.CacheResult; + +import org.hibernate.search.engine.search.aggregation.AggregationKey; +import org.hibernate.search.mapper.orm.session.SearchSession; + +import org.eclipse.microprofile.openapi.annotations.Operation; + +@ApplicationScoped +@Path("/") +@Transactional +@org.jboss.resteasy.reactive.Cache(maxAge = 120) +public class ReferenceService { + + private static final String REFERENCE_CACHE = "reference-cache"; + + @CacheName(REFERENCE_CACHE) + Cache cache; + + @Inject + SearchSession session; + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List available versions") + @Path("/versions") + @CacheResult(cacheName = REFERENCE_CACHE, keyGenerator = MethodNameCacheKeyGenerator.class) + public List versions() { + return listAllValues("quarkusVersion", QuarkusVersions.COMPARATOR.reversed()); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List available languages") + @Path("/languages") + @CacheResult(cacheName = REFERENCE_CACHE, keyGenerator = MethodNameCacheKeyGenerator.class) + public List languages() { + return Arrays.stream(Language.values()).map(lang -> lang.code).toList(); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List available categories") + @Path("/categories") + @CacheResult(cacheName = REFERENCE_CACHE, keyGenerator = MethodNameCacheKeyGenerator.class) + public List categories() { + return listAllValues("categories", Comparator.naturalOrder()); + } + + public void invalidateCaches() { + cache.invalidateAll(); + } + + private List listAllValues(String fieldName, Comparator comparator) { + var aggKey = AggregationKey.> of("versions"); + var result = session.search(Guide.class) + .where(f -> f.matchAll()) + .aggregation(aggKey, f -> f.terms().field(fieldName, String.class)) + .fetch(0); + return result.aggregation(aggKey).keySet().stream().sorted(comparator).toList(); + } +} diff --git a/src/main/java/io/quarkus/search/app/cache/MethodNameCacheKeyGenerator.java b/src/main/java/io/quarkus/search/app/cache/MethodNameCacheKeyGenerator.java new file mode 100644 index 00000000..b877949c --- /dev/null +++ b/src/main/java/io/quarkus/search/app/cache/MethodNameCacheKeyGenerator.java @@ -0,0 +1,12 @@ +package io.quarkus.search.app.cache; + +import java.lang.reflect.Method; + +import io.quarkus.cache.CacheKeyGenerator; + +public class MethodNameCacheKeyGenerator implements CacheKeyGenerator { + @Override + public Object generate(Method method, Object... methodParams) { + return method.getName(); + } +} diff --git a/src/main/java/io/quarkus/search/app/entity/Guide.java b/src/main/java/io/quarkus/search/app/entity/Guide.java index c76af8b5..178bf3fc 100644 --- a/src/main/java/io/quarkus/search/app/entity/Guide.java +++ b/src/main/java/io/quarkus/search/app/entity/Guide.java @@ -28,7 +28,6 @@ import org.hibernate.search.engine.backend.types.Sortable; import org.hibernate.search.engine.backend.types.TermVector; import org.hibernate.search.mapper.pojo.automaticindexing.ReindexOnUpdate; -import org.hibernate.search.mapper.pojo.bridge.builtin.annotation.AlternativeDiscriminator; import org.hibernate.search.mapper.pojo.bridge.mapping.annotation.RoutingBinderRef; import org.hibernate.search.mapper.pojo.bridge.mapping.annotation.ValueBridgeRef; import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed; @@ -42,10 +41,11 @@ public class Guide { @JavaType(URIType.class) public URI url; - @AlternativeDiscriminator @Enumerated(EnumType.STRING) + @KeywordField(searchable = Searchable.NO, aggregable = Aggregable.YES) public Language language; + @KeywordField(searchable = Searchable.NO, aggregable = Aggregable.YES) public String quarkusVersion; @KeywordField @@ -76,7 +76,7 @@ public class Guide { @IndexingDependency(reindexOnUpdate = ReindexOnUpdate.NO) public I18nData htmlFullContentProvider = new I18nData<>(); - @KeywordField(name = "categories") + @KeywordField(name = "categories", aggregable = Aggregable.YES) public Set categories = Set.of(); @I18nFullTextField(name = "topics", analyzerPrefix = AnalysisConfigurer.DEFAULT, searchAnalyzerPrefix = AnalysisConfigurer.DEFAULT_SEARCH) diff --git a/src/main/java/io/quarkus/search/app/indexing/IndexingService.java b/src/main/java/io/quarkus/search/app/indexing/IndexingService.java index fdde15ec..926d240c 100644 --- a/src/main/java/io/quarkus/search/app/indexing/IndexingService.java +++ b/src/main/java/io/quarkus/search/app/indexing/IndexingService.java @@ -16,6 +16,7 @@ import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; +import io.quarkus.search.app.ReferenceService; import io.quarkus.search.app.fetching.FetchingService; import io.quarkus.search.app.quarkusio.QuarkusIO; import io.quarkus.search.app.util.SimpleExecutor; @@ -57,6 +58,9 @@ public class IndexingService { @Inject IndexingConfig indexingConfig; + @Inject + ReferenceService referenceService; + private final AtomicBoolean reindexingInProgress = new AtomicBoolean(); void registerManagementRoutes(@Observes ManagementInterface mi) { @@ -237,6 +241,7 @@ private void indexAll(FailureCollector failureCollector) { searchMapping.scope(Object.class).workspace().refresh(); rollover.commit(); + referenceService.invalidateCaches(); Log.info("Indexing success"); } catch (RuntimeException | IOException e) { throw new IllegalStateException("Failed to index data: " + e.getMessage(), e); diff --git a/src/test/java/io/quarkus/search/app/ReferenceServiceTest.java b/src/test/java/io/quarkus/search/app/ReferenceServiceTest.java new file mode 100644 index 00000000..0219eca2 --- /dev/null +++ b/src/test/java/io/quarkus/search/app/ReferenceServiceTest.java @@ -0,0 +1,67 @@ +package io.quarkus.search.app; + +import static io.restassured.RestAssured.when; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import io.quarkus.search.app.testsupport.QuarkusIOSample; +import io.quarkus.search.app.testsupport.SetupUtil; + +import io.quarkus.test.common.http.TestHTTPEndpoint; +import io.quarkus.test.junit.QuarkusTest; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.filter.log.LogDetail; + +@QuarkusTest +@TestHTTPEndpoint(SearchService.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@QuarkusIOSample.Setup +class ReferenceServiceTest { + private static final TypeRef> LIST_OF_STRINGS = new TypeRef<>() { + }; + + private List get(String referenceName) { + return when().get("/" + referenceName) + .then() + .statusCode(200) + .extract().body().as(LIST_OF_STRINGS); + } + + @BeforeAll + void setup() { + SetupUtil.waitForIndexing(getClass()); + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(LogDetail.BODY); + } + + @Test + void versions() { + assertThat(get("versions")).containsExactly("main", "latest", "3.2"); + } + + @Test + void languages() { + assertThat(get("languages")).containsExactly("en", "es", "pt", "cn", "ja"); + } + + @Test + void categories() { + assertThat(get("categories")).containsExactly( + "alt-languages", + "architecture", + "cloud", + "compatibility", + "core", + "data", + "miscellaneous", + "security", + "web", + "writing-extensions"); + } +}