mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-18 03:01:05 +01:00
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
This commit is contained in:
@@ -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<HalEnricher> enrichers = registry.allByType(source.getClass());
|
||||
for (HalEnricher enricher : enrichers) {
|
||||
enricher.enrich(context, appender);
|
||||
}
|
||||
protected void applyEnrichers(HalEnricherContext context, HalAppender appender, Class<?> type) {
|
||||
Iterable<HalEnricher> enrichers = registry.allByType(type);
|
||||
for (HalEnricher enricher : enrichers) {
|
||||
enricher.enrich(context, appender);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Class, Object> instanceMap;
|
||||
private final Map<Class<?>, Object> instanceMap;
|
||||
|
||||
private HalEnricherContext(Map<Class,Object> instanceMap) {
|
||||
private HalEnricherContext(Map<Class<?>,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<Class, Object> builder = ImmutableMap.builder();
|
||||
ImmutableMap.Builder<Class<?>, 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<Class<?>, Object> mapBuilder = ImmutableMap.builder();
|
||||
|
||||
/**
|
||||
* Add an entry with the given type to the context.
|
||||
* @param type type of the object
|
||||
* @param object object
|
||||
* @param <T> type of object
|
||||
* @return {@code this}
|
||||
*/
|
||||
public <T> Builder put(Class<? super T> type, T object) {
|
||||
mapBuilder.put(type, object);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link HalEnricherContext}.
|
||||
* @return context
|
||||
*/
|
||||
public HalEnricherContext build() {
|
||||
return new HalEnricherContext(mapBuilder.build());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<EmbeddedRepository> & {
|
||||
score: number;
|
||||
fields: { [name: string]: HitField };
|
||||
};
|
||||
|
||||
@@ -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<HitProps> = ({ 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 (
|
||||
<span className="mr-2">
|
||||
<RepositoryAvatar repository={repository} size={24} />
|
||||
@@ -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();
|
||||
|
||||
@@ -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<HitProps> = ({ 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 (
|
||||
<Hit>
|
||||
<Hit.Left>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user