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); }