diff --git a/gradle/changelog/hit_embedded_repository.yaml b/gradle/changelog/hit_embedded_repository.yaml new file mode 100644 index 0000000000..d96445af22 --- /dev/null +++ b/gradle/changelog/hit_embedded_repository.yaml @@ -0,0 +1,2 @@ +- type: Added + description: Embedded repository in search result hit ([#1756](https://github.com/scm-manager/scm-manager/pull/1756)) diff --git a/scm-core/src/main/java/sonia/scm/search/Hit.java b/scm-core/src/main/java/sonia/scm/search/Hit.java index b19a69951c..6779fff141 100644 --- a/scm-core/src/main/java/sonia/scm/search/Hit.java +++ b/scm-core/src/main/java/sonia/scm/search/Hit.java @@ -31,6 +31,7 @@ import lombok.Getter; import lombok.Value; import java.util.Map; +import java.util.Optional; /** * Represents an object which matched the search query. @@ -46,6 +47,12 @@ public class Hit { */ String id; + /** + * Repository associated with the hit. + * @since 2.23.0 + */ + String repositoryId; + /** * The score describes how good the match was. */ @@ -57,6 +64,17 @@ public class Hit { */ Map fields; + /** + * Returns optional id of a repository which associated with the hit + * or empty if the hit is not associated with any repository. + * + * @return optional repository id + * @since 2.23.0 + */ + public Optional getRepositoryId() { + return Optional.ofNullable(repositoryId); + } + /** * Base class of hit field types. */ diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/QueryResultMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/QueryResultMapper.java index fa3a1e088c..b006c6c540 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/QueryResultMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/QueryResultMapper.java @@ -25,20 +25,27 @@ package sonia.scm.api.v2.resources; import com.damnhandy.uri.template.UriTemplate; +import com.google.common.annotations.VisibleForTesting; import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import de.otto.edison.hal.paging.NumberedPaging; import de.otto.edison.hal.paging.PagingRel; +import lombok.Data; import org.mapstruct.AfterMapping; import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.MappingTarget; import org.mapstruct.ObjectFactory; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; import sonia.scm.search.Hit; import sonia.scm.search.QueryResult; import sonia.scm.web.EdisonHalAppender; import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.inject.Inject; import java.util.EnumSet; import java.util.List; import java.util.stream.Collectors; @@ -50,8 +57,26 @@ import static de.otto.edison.hal.paging.NumberedPaging.zeroBasedNumberedPaging; @Mapper public abstract class QueryResultMapper extends HalAppenderMapper { + @Inject + private RepositoryManager repositoryManager; + + @Inject + private ResourceLinks resourceLinks; + + @VisibleForTesting + void setRepositoryManager(RepositoryManager repositoryManager) { + this.repositoryManager = repositoryManager; + } + + @VisibleForTesting + void setResourceLinks(ResourceLinks resourceLinks) { + this.resourceLinks = resourceLinks; + } + public abstract QueryResultDto map(@Context SearchParameters params, QueryResult result); + public abstract EmbeddedRepositoryDto map(Repository repository); + @AfterMapping void setPageValues(@MappingTarget QueryResultDto dto, QueryResult result, @Context SearchParameters params) { int totalHits = (int) result.getTotalHits(); @@ -59,6 +84,13 @@ public abstract class QueryResultMapper extends HalAppenderMapper { dto.setPage(params.getPage()); } + @Nonnull + @ObjectFactory + EmbeddedRepositoryDto createDto(Repository repository) { + String self = resourceLinks.repository().self(repository.getNamespace(), repository.getName()); + return new EmbeddedRepositoryDto(linkingTo().self(self).build()); + } + @Nonnull @ObjectFactory QueryResultDto createDto(@Context SearchParameters params, QueryResult result) { @@ -103,11 +135,20 @@ public abstract class QueryResultMapper extends HalAppenderMapper { protected HitDto createHitDto(@Context QueryResult queryResult, Hit hit) { Links.Builder links = linkingTo(); Embedded.Builder embedded = Embedded.embeddedBuilder(); - + hit.getRepositoryId().map(this::repository).ifPresent(r -> embedded.with("repository", r)); applyEnrichers(new EdisonHalAppender(links, embedded), hit, queryResult); return new HitDto(links.build(), embedded.build()); } + @Nullable + private HalRepresentation repository(String id) { + Repository repository = repositoryManager.get(id); + if (repository != null) { + return map(repository); + } + return null; + } + private int computePageTotal(int totalHits, int pageSize) { if (totalHits % pageSize > 0) { return totalHits / pageSize + 1; @@ -117,4 +158,14 @@ public abstract class QueryResultMapper extends HalAppenderMapper { } protected abstract HitDto map(@Context QueryResult queryResult, Hit hit); + + @Data + public static class EmbeddedRepositoryDto extends HalRepresentation { + private String namespace; + private String name; + private String type; + public EmbeddedRepositoryDto(Links links) { + super(links); + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java b/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java index 197b21812f..48de427369 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java @@ -80,7 +80,7 @@ public class QueryResultFactory { for (SearchableField field : searchableType.getFields()) { field(document, field).ifPresent(f -> fields.put(field.getName(), f)); } - return new Hit(document.get(FieldNames.ID), scoreDoc.score, fields); + return new Hit(document.get(FieldNames.ID), document.get(FieldNames.REPOSITORY), scoreDoc.score, fields); } private Optional field(Document document, SearchableField field) throws IOException, InvalidTokenOffsetsException { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java index b00abcef66..32b6e97946 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java @@ -37,6 +37,9 @@ import org.mapstruct.factory.Mappers; import org.mockito.Answers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryTestData; import sonia.scm.search.Hit; import sonia.scm.search.IndexNames; import sonia.scm.search.QueryCountResult; @@ -50,6 +53,7 @@ import javax.annotation.Nonnull; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.UnsupportedEncodingException; +import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; import java.util.ArrayList; @@ -60,6 +64,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -68,6 +73,9 @@ class SearchResourceTest { @Mock(answer = Answers.RETURNS_DEEP_STUBS) private SearchEngine searchEngine; + @Mock + private RepositoryManager repositoryManager; + private RestDispatcher dispatcher; @Mock @@ -75,7 +83,13 @@ class SearchResourceTest { @BeforeEach void setUpDispatcher() { + ScmPathInfoStore scmPathInfoStore = new ScmPathInfoStore(); + scmPathInfoStore.set(() -> URI.create("/")); + QueryResultMapper mapper = Mappers.getMapper(QueryResultMapper.class); + mapper.setRepositoryManager(repositoryManager); + mapper.setResourceLinks(new ResourceLinks(scmPathInfoStore)); + mapper.setRegistry(enricherRegistry); SearchResource resource = new SearchResource( searchEngine, mapper @@ -202,6 +216,27 @@ class SearchResourceTest { assertThat(hits.size()).isZero(); } + @Test + void shouldReturnEmbeddedRepository() throws UnsupportedEncodingException, URISyntaxException { + Repository heartOfGold = RepositoryTestData.createHeartOfGold("git"); + heartOfGold.setId("42"); + when(repositoryManager.get("42")).thenReturn(heartOfGold); + + Hit hit = new Hit("21", "42", 21f, Collections.emptyMap()); + QueryResult result = new QueryResult(1L, String.class, Collections.singletonList(hit)); + + mockQueryResult("hello", result); + JsonMockHttpResponse response = search("hello"); + + JsonNode hitNode = response.getContentAsJson().get("_embedded").get("hits").get(0); + JsonNode repositoryNode = hitNode.get("_embedded").get("repository"); + + assertThat(repositoryNode.get("namespace").asText()).isEqualTo(heartOfGold.getNamespace()); + assertThat(repositoryNode.get("name").asText()).isEqualTo(heartOfGold.getName()); + assertThat(repositoryNode.get("type").asText()).isEqualTo(heartOfGold.getType()); + assertThat(repositoryNode.get("_links").get("self").get("href").asText()).isEqualTo("/v2/repositories/hitchhiker/HeartOfGold"); + } + } private void assertLink(JsonNode links, String self, String s) { @@ -219,7 +254,7 @@ class SearchResourceTest { @Nonnull private Hit hit(int i, Object[] values) { Map fields = fields(values[i]); - return new Hit("" + i, values.length - i, fields); + return new Hit("" + i, null, values.length - i, fields); } @Nonnull diff --git a/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java b/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java index 8f690d72ee..d1608759e7 100644 --- a/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java @@ -549,6 +549,30 @@ class LuceneQueryBuilderTest { assertThat(fields.get("instantValue").get("value").asText()).isEqualTo(now.toString()); } + @Test + void shouldReturnEmptyRepository() throws IOException { + try (IndexWriter writer = writer()) { + writer.addDocument(simpleDoc("Trillian")); + } + + QueryResult result = query(Simple.class, "Trillian"); + Hit hit = result.getHits().get(0); + assertThat(hit.getRepositoryId()).isEmpty(); + } + + @Test + void shouldReturnRepositoryAsPartOfHit() throws IOException { + try (IndexWriter writer = writer()) { + Document document = simpleDoc("Trillian"); + document.add(new StoredField(FieldNames.REPOSITORY, "4211")); + writer.addDocument(document); + } + + QueryResult result = query(Simple.class, "Trillian"); + Hit hit = result.getHits().get(0); + assertThat(hit.getRepositoryId()).contains("4211"); + } + private QueryResult query(Class type, String queryString) throws IOException { return query(type, queryString, null, null); }