diff --git a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java index b272a706b..abde7b084 100644 --- a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java @@ -763,7 +763,10 @@ public ResponseEntity search( String sortBy, @RequestParam(defaultValue = "false") @Parameter(description = "Whether to include information on all available versions for each returned entry") - boolean includeAllVersions + boolean includeAllVersions, + @RequestParam(required = false) + @Parameter(description = "Filter to only return web-only extensions (extensionKind=web)", schema = @Schema(type = "boolean")) + Boolean webOnly ) { if (size < 0) { var json = SearchResultJson.error(negativeSizeMessage()); @@ -774,7 +777,7 @@ public ResponseEntity search( return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } - var options = new ISearchService.Options(query, category, targetPlatform, size, offset, sortOrder, sortBy, includeAllVersions, null); + var options = new ISearchService.Options(query, category, targetPlatform, size, offset, sortOrder, sortBy, includeAllVersions, null, null, webOnly); var resultOffset = 0; var resultSize = 0; var resultExtensions = new ArrayList(size); diff --git a/server/src/main/java/org/eclipse/openvsx/entities/Extension.java b/server/src/main/java/org/eclipse/openvsx/entities/Extension.java index 4cee20496..4ea51106b 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Extension.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Extension.java @@ -82,6 +82,7 @@ public ExtensionSearch toSearch(ExtensionVersion latest, List targetPlat search.setTimestamp(latest.getTimestamp().toEpochSecond(ZoneOffset.UTC)); search.setCategories(latest.getCategories()); search.setTags(latest.getTags()); + search.setExtensionKind(latest.getExtensionKind()); return search; } diff --git a/server/src/main/java/org/eclipse/openvsx/search/DatabaseSearchService.java b/server/src/main/java/org/eclipse/openvsx/search/DatabaseSearchService.java index da489b711..4271f8dea 100644 --- a/server/src/main/java/org/eclipse/openvsx/search/DatabaseSearchService.java +++ b/server/src/main/java/org/eclipse/openvsx/search/DatabaseSearchService.java @@ -63,6 +63,7 @@ public SearchResult search(ISearchService.Options options) { matchingExtensions = excludeByTargetPlatform(options, matchingExtensions); matchingExtensions = excludeByCategory(options, matchingExtensions); matchingExtensions = excludeByQueryString(options, matchingExtensions); + matchingExtensions = filterByWebOnly(options, matchingExtensions); var sortedExtensions = sortExtensions(options, matchingExtensions); var totalHits = sortedExtensions.size(); @@ -159,6 +160,17 @@ private Streamable excludeByNamespace(Options options, Streamable !namespacesToExclude.contains(extension.getNamespace().getName())); } + private Streamable filterByWebOnly(Options options, Streamable matchingExtensions) { + if(options.webOnly() == null || !options.webOnly()) { + return matchingExtensions; + } + + return matchingExtensions.filter(extension -> { + var latest = repositories.findLatestVersion(extension, null, false, true); + return latest.getExtensionKind() != null && latest.getExtensionKind().contains("web"); + }); + } + @Override @CacheEvict(value = CACHE_DATABASE_SEARCH, allEntries = true) public void updateSearchIndex(boolean clear) { diff --git a/server/src/main/java/org/eclipse/openvsx/search/ElasticSearchService.java b/server/src/main/java/org/eclipse/openvsx/search/ElasticSearchService.java index 7d783674d..9f1eacd48 100644 --- a/server/src/main/java/org/eclipse/openvsx/search/ElasticSearchService.java +++ b/server/src/main/java/org/eclipse/openvsx/search/ElasticSearchService.java @@ -319,6 +319,10 @@ private ObjectBuilder createSearchQuery(BoolQuery.Builder boolQuery, boolQuery.mustNot(QueryBuilders.term(builder -> builder.field("namespace.keyword").value(namespaceToExclude))); } } + if (options.webOnly() != null && options.webOnly()) { + // Filter by web-only extensions + boolQuery.must(QueryBuilders.matchPhrase(builder -> builder.field("extensionKind").query("web"))); + } return boolQuery; } diff --git a/server/src/main/java/org/eclipse/openvsx/search/ExtensionSearch.java b/server/src/main/java/org/eclipse/openvsx/search/ExtensionSearch.java index a03ceaf66..e882495f6 100644 --- a/server/src/main/java/org/eclipse/openvsx/search/ExtensionSearch.java +++ b/server/src/main/java/org/eclipse/openvsx/search/ExtensionSearch.java @@ -54,6 +54,8 @@ public class ExtensionSearch { private List tags; + private List extensionKind; + public long getId() { return id; } @@ -159,6 +161,14 @@ public void setTags(List tags) { this.tags = tags; } + public List getExtensionKind() { + return extensionKind; + } + + public void setExtensionKind(List extensionKind) { + this.extensionKind = extensionKind; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -176,14 +186,15 @@ public boolean equals(Object o) { && Objects.equals(description, that.description) && Objects.equals(rating, that.rating) && Objects.equals(categories, that.categories) - && Objects.equals(tags, that.tags); + && Objects.equals(tags, that.tags) + && Objects.equals(extensionKind, that.extensionKind); } @Override public int hashCode() { return Objects.hash( id, relevance, name, namespace, extensionId, targetPlatforms, displayName, description, timestamp, - rating, downloadCount, categories, tags + rating, downloadCount, categories, tags, extensionKind ); } } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/search/ISearchService.java b/server/src/main/java/org/eclipse/openvsx/search/ISearchService.java index df2969d43..34c3b7417 100644 --- a/server/src/main/java/org/eclipse/openvsx/search/ISearchService.java +++ b/server/src/main/java/org/eclipse/openvsx/search/ISearchService.java @@ -73,7 +73,8 @@ record Options( String sortBy, boolean includeAllVersions, String[] namespacesToExclude, - String namespace + String namespace, + Boolean webOnly ) { private static final Pattern PUBLISHER_PATTERN = Pattern.compile("^@?publisher:| @?publisher:"); @@ -88,7 +89,38 @@ public Options( boolean includeAllVersions, String[] namespacesToExclude ) { - String namespace = null; + this(queryString, category, targetPlatform, requestedSize, requestedOffset, sortOrder, sortBy, includeAllVersions, namespacesToExclude, null, null); + } + + public Options( + String queryString, + String category, + String targetPlatform, + int requestedSize, + int requestedOffset, + String sortOrder, + String sortBy, + boolean includeAllVersions, + String[] namespacesToExclude, + String namespace + ) { + this(queryString, category, targetPlatform, requestedSize, requestedOffset, sortOrder, sortBy, includeAllVersions, namespacesToExclude, namespace, null); + } + + public Options( + String queryString, + String category, + String targetPlatform, + int requestedSize, + int requestedOffset, + String sortOrder, + String sortBy, + boolean includeAllVersions, + String[] namespacesToExclude, + String namespace, + Boolean webOnly + ) { + String extractedNamespace = null; if(queryString != null) { var matcher = PUBLISHER_PATTERN.matcher(queryString); var results = matcher.results().toList(); @@ -101,7 +133,7 @@ public Options( if(publisherEndIndex == -1) { publisherEndIndex = queryString.length(); } - namespace = queryString.substring(first.end(), publisherEndIndex); + extractedNamespace = queryString.substring(first.end(), publisherEndIndex); var newQuery = ""; if(publisherStartIndex > 0) { newQuery += queryString.substring(0, publisherStartIndex); @@ -114,18 +146,17 @@ public Options( } } - this( - queryString, - category, - targetPlatform, - requestedSize, - requestedOffset, - sortOrder, - sortBy, - includeAllVersions, - namespacesToExclude, - namespace - ); + this.queryString = queryString; + this.category = category; + this.targetPlatform = targetPlatform; + this.requestedSize = requestedSize; + this.requestedOffset = requestedOffset; + this.sortOrder = sortOrder; + this.sortBy = sortBy; + this.includeAllVersions = includeAllVersions; + this.namespacesToExclude = namespacesToExclude; + this.namespace = namespace != null ? namespace : extractedNamespace; + this.webOnly = webOnly; } @@ -143,12 +174,13 @@ public boolean equals(Object o) { && Objects.equals(sortOrder, options.sortOrder) && Objects.equals(sortBy, options.sortBy) && Arrays.equals(namespacesToExclude, options.namespacesToExclude) - && Objects.equals(namespace, options.namespace); + && Objects.equals(namespace, options.namespace) + && Objects.equals(webOnly, options.webOnly); } @Override public int hashCode() { - int result = Objects.hash(queryString, category, targetPlatform, requestedSize, requestedOffset, sortOrder, sortBy, includeAllVersions, namespace); + int result = Objects.hash(queryString, category, targetPlatform, requestedSize, requestedOffset, sortOrder, sortBy, includeAllVersions, namespace, webOnly); result = 31 * result + Arrays.hashCode(namespacesToExclude); return result; } diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 19d565c16..3b40d1a52 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -705,6 +705,62 @@ void testSearchInactive() throws Exception { .andExpect(content().string("{\"offset\":0,\"totalSize\":1,\"extensions\":[]}")); } + @Test + void testSearchWebOnly() throws Exception { + var extVersions = mockSearch(); + extVersions.forEach(extVersion -> Mockito.when(repositories.findLatestVersion(extVersion.getExtension(), null, false, true)).thenReturn(extVersion)); + Mockito.when(repositories.findLatestVersions(extVersions.stream().map(ExtensionVersion::getExtension).map(Extension::getId).toList())) + .thenReturn(extVersions); + + var searchOptions = new ISearchService.Options("foo", null, null, 10, 0, "desc", SortBy.RELEVANCE, false, null, null, true); + var searchResult = new SearchResult(1, List.of(new ExtensionSearch())); + searchResult.getHits().get(0).setId(1); + Mockito.when(search.search(searchOptions)) + .thenReturn(searchResult); + + mockMvc.perform(get("/api/-/search?query={query}&size={size}&offset={offset}&webOnly={webOnly}", "foo", "10", "0", "true")) + .andExpect(status().isOk()) + .andExpect(content().json(searchJson(s -> { + s.setOffset(0); + s.setTotalSize(1); + var e1 = new SearchEntryJson(); + e1.setNamespace("foo"); + e1.setName("bar"); + e1.setVersion("1.0.0"); + e1.setTimestamp("2000-01-01T10:00Z"); + e1.setDisplayName("Foo Bar"); + s.getExtensions().add(e1); + }))); + } + + @Test + void testSearchWebOnlyFalse() throws Exception { + var extVersions = mockSearch(); + extVersions.forEach(extVersion -> Mockito.when(repositories.findLatestVersion(extVersion.getExtension(), null, false, true)).thenReturn(extVersion)); + Mockito.when(repositories.findLatestVersions(extVersions.stream().map(ExtensionVersion::getExtension).map(Extension::getId).toList())) + .thenReturn(extVersions); + + var searchOptions = new ISearchService.Options("foo", null, null, 10, 0, "desc", SortBy.RELEVANCE, false, null, null, false); + var searchResult = new SearchResult(1, List.of(new ExtensionSearch())); + searchResult.getHits().get(0).setId(1); + Mockito.when(search.search(searchOptions)) + .thenReturn(searchResult); + + mockMvc.perform(get("/api/-/search?query={query}&size={size}&offset={offset}&webOnly={webOnly}", "foo", "10", "0", "false")) + .andExpect(status().isOk()) + .andExpect(content().json(searchJson(s -> { + s.setOffset(0); + s.setTotalSize(1); + var e1 = new SearchEntryJson(); + e1.setNamespace("foo"); + e1.setName("bar"); + e1.setVersion("1.0.0"); + e1.setTimestamp("2000-01-01T10:00Z"); + e1.setDisplayName("Foo Bar"); + s.getExtensions().add(e1); + }))); + } + @Test void testGetQueryExtensionName() throws Exception { mockExtensionVersion(); diff --git a/server/src/test/java/org/eclipse/openvsx/search/DatabaseSearchServiceTest.java b/server/src/test/java/org/eclipse/openvsx/search/DatabaseSearchServiceTest.java index 41b5b544a..38e6ef04b 100644 --- a/server/src/test/java/org/eclipse/openvsx/search/DatabaseSearchServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/search/DatabaseSearchServiceTest.java @@ -327,6 +327,51 @@ void testSortByRating() { assertThat(getIdFromExtensionHits(hits, 3)).isEqualTo(getIdFromExtensionName("java")); } + @Test + void testWebOnlyFilter() { + var ext1 = mockExtension("web-ext", 4.0, 1, 0, "test", List.of("Other"), List.of("web")); + var ext2 = mockExtension("desktop-ext", 4.0, 1, 0, "test", List.of("Other"), List.of("ui", "workspace")); + var ext3 = mockExtension("hybrid-ext", 4.0, 1, 0, "test", List.of("Other"), List.of("web", "ui")); + var ext4 = mockExtension("no-kind-ext", 4.0, 1, 0, "test", List.of("Other"), null); + Mockito.when(repositories.findAllActiveExtensions()).thenReturn(Streamable.of(List.of(ext1, ext2, ext3, ext4))); + + var searchOptionsAll = searchOptions(null, null, 50, 0, null, null, null); + var resultAll = search.search(searchOptionsAll); + assertThat(resultAll.getTotalHits()).isEqualTo(4); + + var searchOptionsWebOnly = searchOptions(null, null, 50, 0, null, null, true); + var resultWebOnly = search.search(searchOptionsWebOnly); + assertThat(resultWebOnly.getTotalHits()).isEqualTo(2); + + var hits = resultWebOnly.getHits(); + assertThat(getIdFromExtensionHits(hits, 0)).isEqualTo(getIdFromExtensionName("web-ext")); + assertThat(getIdFromExtensionHits(hits, 1)).isEqualTo(getIdFromExtensionName("hybrid-ext")); + } + + @Test + void testWebOnlyFilterWithQuery() { + var ext1 = mockExtension("web-javascript", 4.0, 1, 0, "test", List.of("Other"), List.of("web")); + var ext2 = mockExtension("desktop-javascript", 4.0, 1, 0, "test", List.of("Other"), List.of("ui")); + var ext3 = mockExtension("web-typescript", 4.0, 1, 0, "test", List.of("Other"), List.of("web")); + Mockito.when(repositories.findAllActiveExtensions()).thenReturn(Streamable.of(List.of(ext1, ext2, ext3))); + + var searchOptions = searchOptions("javascript", null, 50, 0, null, null, true); + var result = search.search(searchOptions); + assertThat(result.getTotalHits()).isEqualTo(1); + assertThat(getIdFromExtensionHits(result.getHits(), 0)).isEqualTo(getIdFromExtensionName("web-javascript")); + } + + @Test + void testWebOnlyFilterFalse() { + var ext1 = mockExtension("web-ext", 4.0, 1, 0, "test", List.of("Other"), List.of("web")); + var ext2 = mockExtension("desktop-ext", 4.0, 1, 0, "test", List.of("Other"), List.of("ui")); + Mockito.when(repositories.findAllActiveExtensions()).thenReturn(Streamable.of(List.of(ext1, ext2))); + + var searchOptions = searchOptions(null, null, 50, 0, null, null, false); + var result = search.search(searchOptions); + assertThat(result.getTotalHits()).isEqualTo(2); + } + // ---------- UTILITY ----------// private ISearchService.Options searchOptions( @@ -336,6 +381,18 @@ private ISearchService.Options searchOptions( Integer requestedOffset, String sortOrder, String sortBy + ) { + return searchOptions(queryString, category, requestedSize, requestedOffset, sortOrder, sortBy, null); + } + + private ISearchService.Options searchOptions( + String queryString, + String category, + Integer requestedSize, + Integer requestedOffset, + String sortOrder, + String sortBy, + Boolean webOnly ) { if(requestedSize == null) { requestedSize = 18; @@ -356,7 +413,9 @@ private ISearchService.Options searchOptions( sortOrder, sortBy, false, - null + null, + null, + webOnly ); } @@ -370,6 +429,11 @@ long getIdFromExtensionName(String extensionName) { private Extension mockExtension(String name, double averageRating, long ratingCount, int downloadCount, String namespaceName, List categories) { + return mockExtension(name, averageRating, ratingCount, downloadCount, namespaceName, categories, null); + } + + private Extension mockExtension(String name, double averageRating, long ratingCount, int downloadCount, + String namespaceName, List categories, List extensionKind) { var extension = new Extension(); extension.setName(name); extension.setId(name.hashCode()); @@ -384,6 +448,7 @@ private Extension mockExtension(String name, double averageRating, long ratingCo var extVer = new ExtensionVersion(); extVer.setTargetPlatform(TargetPlatform.NAME_UNIVERSAL); extVer.setCategories(categories); + extVer.setExtensionKind(extensionKind); extVer.setTimestamp(LocalDateTime.parse("2021-10-01T00:00")); extVer.setActive(true); extVer.setExtension(extension);