From 8ce69d9848fb11afd1c4cdce7fe09990d5db2c0f Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Thu, 5 Aug 2021 15:12:48 +0200 Subject: [PATCH] Allow enrichment of embedded repositories on search hits (#1760) * Introduce RepositoryCoordinates RepositoryCoordinates will be used for the enrichment of the embedded repositories of search result hits. This is required, because if we used the normal repository for the enrichment, we would get a lot of unrelated enrichers would be applied. * Add builder method to HalEnricherContext With the new builder method it is possible to add an object to the context with an interface as key. * Add enricher support for embedded repository by applying enricher for RepositoryCoordinates * Use embedded repository for avatars --- .../api/v2/resources/HalAppenderMapper.java | 13 +++-- .../api/v2/resources/HalEnricherContext.java | 48 +++++++++++++++-- .../java/sonia/scm/repository/Repository.java | 5 +- .../scm/repository/RepositoryCoordinates.java | 54 +++++++++++++++++++ scm-ui/ui-types/src/Search.ts | 9 +++- .../ui-webapp/src/containers/OmniSearch.tsx | 16 ++---- scm-ui/ui-webapp/src/search/RepositoryHit.tsx | 14 ++--- .../api/v2/resources/QueryResultMapper.java | 17 ++++-- .../api/v2/resources/SearchResourceTest.java | 52 ++++++++++++++++-- 9 files changed, 185 insertions(+), 43 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/repository/RepositoryCoordinates.java diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppenderMapper.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppenderMapper.java index fb56621f66..4022a72e38 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppenderMapper.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppenderMapper.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.google.common.annotations.VisibleForTesting; @@ -49,11 +49,14 @@ public class HalAppenderMapper { } HalEnricherContext context = HalEnricherContext.of(ctx); + applyEnrichers(context, appender, source.getClass()); + } + } - Iterable enrichers = registry.allByType(source.getClass()); - for (HalEnricher enricher : enrichers) { - enricher.enrich(context, appender); - } + protected void applyEnrichers(HalEnricherContext context, HalAppender appender, Class type) { + Iterable enrichers = registry.allByType(type); + for (HalEnricher enricher : enrichers) { + enricher.enrich(context, appender); } } diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricherContext.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricherContext.java index a7c13ff5f7..5c08ac2fc5 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricherContext.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricherContext.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.google.common.collect.ImmutableMap; @@ -39,9 +39,9 @@ import java.util.Optional; */ public final class HalEnricherContext { - private final Map instanceMap; + private final Map, Object> instanceMap; - private HalEnricherContext(Map instanceMap) { + private HalEnricherContext(Map,Object> instanceMap) { this.instanceMap = instanceMap; } @@ -53,13 +53,22 @@ public final class HalEnricherContext { * @return context of given entries */ public static HalEnricherContext of(Object... instances) { - ImmutableMap.Builder builder = ImmutableMap.builder(); + ImmutableMap.Builder, Object> builder = ImmutableMap.builder(); for (Object instance : instances) { builder.put(instance.getClass(), instance); } return new HalEnricherContext(builder.build()); } + /** + * Return builder for {@link HalEnricherContext}. + * @return builder + * @since 2.23.0 + */ + public static Builder builder() { + return new Builder(); + } + /** * Returns the registered object from the context. The method will return an empty optional, if no object with the * given type was registered. @@ -93,4 +102,35 @@ public final class HalEnricherContext { } } + /** + * Builder for {@link HalEnricherContext}. + * + * @since 2.23.0 + */ + public static class Builder { + + private final ImmutableMap.Builder, Object> mapBuilder = ImmutableMap.builder(); + + /** + * Add an entry with the given type to the context. + * @param type type of the object + * @param object object + * @param type of object + * @return {@code this} + */ + public Builder put(Class type, T object) { + mapBuilder.put(type, object); + return this; + } + + /** + * Returns the {@link HalEnricherContext}. + * @return context + */ + public HalEnricherContext build() { + return new HalEnricherContext(mapBuilder.build()); + } + + } + } diff --git a/scm-core/src/main/java/sonia/scm/repository/Repository.java b/scm-core/src/main/java/sonia/scm/repository/Repository.java index e5d9b7efd3..c0ae338670 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Repository.java +++ b/scm-core/src/main/java/sonia/scm/repository/Repository.java @@ -64,7 +64,7 @@ import java.util.Set; @Guard(guard = RepositoryPermissionGuard.class) } ) -public class Repository extends BasicPropertiesAware implements ModelObject, PermissionObject { +public class Repository extends BasicPropertiesAware implements ModelObject, PermissionObject, RepositoryCoordinates { private static final long serialVersionUID = 3486560714961909711L; @@ -190,11 +190,12 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per return lastModified; } - + @Override public String getName() { return name; } + @Override public String getNamespace() { return namespace; } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryCoordinates.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryCoordinates.java new file mode 100644 index 0000000000..d726d687e1 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryCoordinates.java @@ -0,0 +1,54 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import sonia.scm.TypedObject; + +/** + * Coordinates to identify a repository. + * + * @since 2.23.0 + */ +public interface RepositoryCoordinates extends TypedObject { + + /** + * Returns the internal id of the repository. + * @return internal id + */ + String getId(); + + /** + * Returns the namespace of the repository. + * + * @return namespace + */ + String getNamespace(); + + /** + * Returns the name of the repository. + * @return name + */ + String getName(); +} diff --git a/scm-ui/ui-types/src/Search.ts b/scm-ui/ui-types/src/Search.ts index 332addbe2f..016235e434 100644 --- a/scm-ui/ui-types/src/Search.ts +++ b/scm-ui/ui-types/src/Search.ts @@ -22,7 +22,8 @@ * SOFTWARE. */ -import { HalRepresentation, PagedCollection } from "./hal"; +import { HalRepresentationWithEmbedded, PagedCollection } from "./hal"; +import { Repository } from "./Repositories"; export type ValueHitField = { highlighted: false; @@ -36,7 +37,11 @@ export type HighlightedHitField = { export type HitField = ValueHitField | HighlightedHitField; -export type Hit = HalRepresentation & { +export type EmbeddedRepository = { + repository?: Repository; +}; + +export type Hit = HalRepresentationWithEmbedded & { score: number; fields: { [name: string]: HitField }; }; diff --git a/scm-ui/ui-webapp/src/containers/OmniSearch.tsx b/scm-ui/ui-webapp/src/containers/OmniSearch.tsx index a4627b2689..c4491ebac8 100644 --- a/scm-ui/ui-webapp/src/containers/OmniSearch.tsx +++ b/scm-ui/ui-webapp/src/containers/OmniSearch.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ import React, { FC, KeyboardEvent as ReactKeyboardEvent, MouseEvent, useCallback, useState, useEffect } from "react"; -import { Hit, Links, Repository, ValueHitField } from "@scm-manager/ui-types"; +import { Hit, Links, ValueHitField } from "@scm-manager/ui-types"; import styled from "styled-components"; import { BackendError, useSearch } from "@scm-manager/ui-api"; import classNames from "classnames"; @@ -130,18 +130,11 @@ const AvatarSection: FC = ({ hit }) => { const name = useStringHitFieldValue(hit, "name"); const type = useStringHitFieldValue(hit, "type"); - if (!namespace || !name || !type) { + const repository = hit._embedded.repository; + if (!namespace || !name || !type || !repository) { return null; } - const repository: Repository = { - namespace, - name, - type, - _links: {}, - _embedded: hit._embedded, - }; - return ( @@ -278,7 +271,8 @@ const useShowResultsOnFocus = () => { }; const onKeyUp = (e: KeyboardEvent) => { - if (e.which === 9) { // tab + if (e.which === 9) { + // tab const element = document.activeElement; if (!element || !isOnmiSearchElement(element)) { close(); diff --git a/scm-ui/ui-webapp/src/search/RepositoryHit.tsx b/scm-ui/ui-webapp/src/search/RepositoryHit.tsx index 322d620c64..8d30a36ed6 100644 --- a/scm-ui/ui-webapp/src/search/RepositoryHit.tsx +++ b/scm-ui/ui-webapp/src/search/RepositoryHit.tsx @@ -23,7 +23,6 @@ */ import React, { FC } from "react"; -import { Repository } from "@scm-manager/ui-types"; import { Link } from "react-router-dom"; import { useDateHitFieldValue, @@ -43,18 +42,13 @@ const RepositoryHit: FC = ({ hit }) => { const creationDate = useDateHitFieldValue(hit, "creationDate"); const date = lastModified || creationDate; - if (!namespace || !name || !type) { + // the embedded repository is only a subset of the repository (RepositoryCoordinates), + // so we should use the fields to get more information + const repository = hit._embedded.repository; + if (!namespace || !name || !type || !repository) { return null; } - const repository: Repository = { - namespace, - name, - type, - _links: {}, - _embedded: hit._embedded, - }; - return ( 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 b006c6c540..2d63991f1e 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 @@ -38,6 +38,7 @@ import org.mapstruct.Mapper; import org.mapstruct.MappingTarget; import org.mapstruct.ObjectFactory; import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryCoordinates; import sonia.scm.repository.RepositoryManager; import sonia.scm.search.Hit; import sonia.scm.search.QueryResult; @@ -87,8 +88,16 @@ public abstract class QueryResultMapper extends HalAppenderMapper { @Nonnull @ObjectFactory EmbeddedRepositoryDto createDto(Repository repository) { - String self = resourceLinks.repository().self(repository.getNamespace(), repository.getName()); - return new EmbeddedRepositoryDto(linkingTo().self(self).build()); + Links.Builder links = linkingTo(); + links.self(resourceLinks.repository().self(repository.getNamespace(), repository.getName())); + Embedded.Builder embedded = Embedded.embeddedBuilder(); + + HalEnricherContext context = HalEnricherContext.builder() + .put(RepositoryCoordinates.class, repository) + .build(); + + applyEnrichers(context, new EdisonHalAppender(links, embedded), RepositoryCoordinates.class); + return new EmbeddedRepositoryDto(links.build(), embedded.build()); } @Nonnull @@ -164,8 +173,8 @@ public abstract class QueryResultMapper extends HalAppenderMapper { private String namespace; private String name; private String type; - public EmbeddedRepositoryDto(Links links) { - super(links); + public EmbeddedRepositoryDto(Links links, Embedded embedded) { + super(links, embedded); } } } 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 cc53e09202..debbd97cf9 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 @@ -38,6 +38,7 @@ import org.mockito.Answers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryCoordinates; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryTestData; import sonia.scm.search.Hit; @@ -63,7 +64,6 @@ 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) @@ -106,7 +106,7 @@ class SearchResourceTest { JsonMockHttpResponse response = search("Hello"); JsonNode sample = response.getContentAsJson().get("_embedded").get("sample"); - assertThat(sample.get("type").asText()).isEqualTo("java.lang.String"); + assertThat(sample.get("value").asText()).isEqualTo("java.lang.String"); } @Test @@ -122,7 +122,35 @@ class SearchResourceTest { JsonNode sample = response.getContentAsJson() .get("_embedded").get("hits").get(0) .get("_embedded").get("sample"); - assertThat(sample.get("type").asText()).isEqualTo("java.lang.String"); + assertThat(sample.get("value").asText()).isEqualTo("java.lang.String"); + } + + @Test + void shouldEnrichRepository() throws UnsupportedEncodingException, URISyntaxException { + when(enricherRegistry.allByType(QueryResult.class)) + .thenReturn(Collections.emptySet()); + when(enricherRegistry.allByType(Hit.class)) + .thenReturn(Collections.singleton(new SampleEnricher())); + + when(enricherRegistry.allByType(RepositoryCoordinates.class)) + .thenReturn(Collections.singleton(new RepositoryEnricher())); + + Repository heartOfGold = RepositoryTestData.createHeartOfGold("hg"); + 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 sample = response.getContentAsJson() + .get("_embedded").get("hits").get(0) + .get("_embedded").get("repository") + .get("_embedded").get("sample"); + + assertThat(sample.get("value").asText()).isEqualTo("42"); } @Nested @@ -132,6 +160,7 @@ class SearchResourceTest { void setUpEnricherRegistry() { when(enricherRegistry.allByType(QueryResult.class)).thenReturn(Collections.emptySet()); lenient().when(enricherRegistry.allByType(Hit.class)).thenReturn(Collections.emptySet()); + lenient().when(enricherRegistry.allByType(RepositoryCoordinates.class)).thenReturn(Collections.emptySet()); } @Test @@ -305,7 +334,20 @@ class SearchResourceTest { @Getter @Setter public static class SampleEmbedded extends HalRepresentation { - private Class type; + private String value; + } + + private static class RepositoryEnricher implements HalEnricher { + + @Override + public void enrich(HalEnricherContext context, HalAppender appender) { + RepositoryCoordinates repositoryCoordinates = context.oneRequireByType(RepositoryCoordinates.class); + + SampleEmbedded embedded = new SampleEmbedded(); + embedded.setValue(repositoryCoordinates.getId()); + + appender.appendEmbedded("sample", embedded); + } } private static class SampleEnricher implements HalEnricher { @@ -314,7 +356,7 @@ class SearchResourceTest { QueryResult result = context.oneRequireByType(QueryResult.class); SampleEmbedded embedded = new SampleEmbedded(); - embedded.setType(result.getType()); + embedded.setValue(result.getType().getName()); appender.appendEmbedded("sample", embedded); }