diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java index d7f299d989..89cc893131 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java @@ -3,7 +3,7 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.HalRepresentation; import org.mapstruct.Mapping; -public abstract class BaseMapper implements InstantAttributeMapper { +public abstract class BaseMapper extends LinkAppenderMapper implements InstantAttributeMapper { @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract D map(T modelObject); diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/Index.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/Index.java new file mode 100644 index 0000000000..bf20f26a7a --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/Index.java @@ -0,0 +1,10 @@ +package sonia.scm.api.v2.resources; + +/** + * The {@link Index} object can be used to register a {@link LinkEnricher} for the index resource. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public final class Index { +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java new file mode 100644 index 0000000000..dbf1ff3ff6 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java @@ -0,0 +1,18 @@ +package sonia.scm.api.v2.resources; + +/** + * The {@link LinkAppender} can be used within an {@link LinkEnricher} to append hateoas links to a json response. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public interface LinkAppender { + + /** + * Appends one link to the json response. + * + * @param rel name of relation + * @param href link uri + */ + void appendOne(String rel, String href); +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppenderMapper.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppenderMapper.java new file mode 100644 index 0000000000..7843491b71 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppenderMapper.java @@ -0,0 +1,36 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.annotations.VisibleForTesting; + +import javax.inject.Inject; + +public class LinkAppenderMapper { + + @Inject + private LinkEnricherRegistry registry; + + @VisibleForTesting + void setRegistry(LinkEnricherRegistry registry) { + this.registry = registry; + } + + protected void appendLinks(LinkAppender appender, Object source, Object... contextEntries) { + // null check is only their to not break existing tests + if (registry != null) { + + Object[] ctx = new Object[contextEntries.length + 1]; + ctx[0] = source; + for (int i = 0; i < contextEntries.length; i++) { + ctx[i + 1] = contextEntries[i]; + } + + LinkEnricherContext context = LinkEnricherContext.of(ctx); + + Iterable enrichers = registry.allByType(source.getClass()); + for (LinkEnricher enricher : enrichers) { + enricher.enrich(context, appender); + } + } + } + +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java new file mode 100644 index 0000000000..b9cc3c5059 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java @@ -0,0 +1,19 @@ +package sonia.scm.api.v2.resources; + +/** + * A {@link LinkEnricher} can be used to append hateoas links to a specific json response. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@FunctionalInterface +public interface LinkEnricher { + + /** + * Enriches the response with hateoas links. + * + * @param context contains the source for the json mapping and related objects + * @param appender can be used to append links to the json response + */ + void enrich(LinkEnricherContext context, LinkAppender appender); +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java new file mode 100644 index 0000000000..6f6b4ca8f8 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java @@ -0,0 +1,67 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.ImmutableMap; + +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; + +/** + * Context object for the {@link LinkEnricher}. The context holds the source object for the json and all related + * objects, which can be useful for the link creation. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public final class LinkEnricherContext { + + private final Map instanceMap; + + private LinkEnricherContext(Map instanceMap) { + this.instanceMap = instanceMap; + } + + /** + * Creates a context with the given entries + * + * @param instances entries of the context + * + * @return context of given entries + */ + public static LinkEnricherContext of(Object... instances) { + ImmutableMap.Builder builder = ImmutableMap.builder(); + for (Object instance : instances) { + builder.put(instance.getClass(), instance); + } + return new LinkEnricherContext(builder.build()); + } + + /** + * Returns the registered object from the context. The method will return an empty optional, if no object with the + * given type was registered. + * + * @param type type of instance + * @param type of instance + * @return optional instance + */ + public Optional oneByType(Class type) { + Object instance = instanceMap.get(type); + if (instance != null) { + return Optional.of(type.cast(instance)); + } + return Optional.empty(); + } + + /** + * Returns the registered object from the context, but throws an {@link NoSuchElementException} if the type was not + * registered. + * + * @param type type of instance + * @param type of instance + * @return instance + */ + public T oneRequireByType(Class type) { + return oneByType(type).get(); + } + +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherRegistry.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherRegistry.java new file mode 100644 index 0000000000..cd95a62ec3 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherRegistry.java @@ -0,0 +1,40 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import sonia.scm.plugin.Extension; + +import javax.inject.Singleton; + +/** + * The {@link LinkEnricherRegistry} is responsible for binding {@link LinkEnricher} instances to their source types. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@Extension +@Singleton +public final class LinkEnricherRegistry { + + private final Multimap enrichers = HashMultimap.create(); + + /** + * Registers a new {@link LinkEnricher} for the given source type. + * + * @param sourceType type of json mapping source + * @param enricher link enricher instance + */ + public void register(Class sourceType, LinkEnricher enricher) { + enrichers.put(sourceType, enricher); + } + + /** + * Returns all registered {@link LinkEnricher} for the given type. + * + * @param sourceType type of json mapping source + * @return all registered enrichers + */ + public Iterable allByType(Class sourceType) { + return enrichers.get(sourceType); + } +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/Me.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/Me.java new file mode 100644 index 0000000000..f8f82804a6 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/Me.java @@ -0,0 +1,10 @@ +package sonia.scm.api.v2.resources; + +/** + * The {@link Me} object can be used to register a {@link LinkEnricher} for the me resource. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public final class Me { +} diff --git a/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkAppenderMapperTest.java b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkAppenderMapperTest.java new file mode 100644 index 0000000000..557eac2020 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkAppenderMapperTest.java @@ -0,0 +1,74 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class LinkAppenderMapperTest { + + @Mock + private LinkAppender appender; + + private LinkEnricherRegistry registry; + private LinkAppenderMapper mapper; + + @BeforeEach + void beforeEach() { + registry = new LinkEnricherRegistry(); + mapper = new LinkAppenderMapper(); + mapper.setRegistry(registry); + } + + @Test + void shouldAppendSimpleLink() { + registry.register(String.class, (ctx, appender) -> appender.appendOne("42", "https://hitchhiker.com")); + + mapper.appendLinks(appender, "hello"); + + verify(appender).appendOne("42", "https://hitchhiker.com"); + } + + @Test + void shouldCallMultipleEnrichers() { + registry.register(String.class, (ctx, appender) -> appender.appendOne("42", "https://hitchhiker.com")); + registry.register(String.class, (ctx, appender) -> appender.appendOne("21", "https://scm.hitchhiker.com")); + + mapper.appendLinks(appender, "hello"); + + verify(appender).appendOne("42", "https://hitchhiker.com"); + verify(appender).appendOne("21", "https://scm.hitchhiker.com"); + } + + @Test + void shouldAppendLinkByUsingSourceFromContext() { + registry.register(String.class, (ctx, appender) -> { + Optional rel = ctx.oneByType(String.class); + appender.appendOne(rel.get(), "https://hitchhiker.com"); + }); + + mapper.appendLinks(appender, "42"); + + verify(appender).appendOne("42", "https://hitchhiker.com"); + } + + @Test + void shouldAppendLinkByUsingMultipleContextEntries() { + registry.register(Integer.class, (ctx, appender) -> { + Optional rel = ctx.oneByType(Integer.class); + Optional href = ctx.oneByType(String.class); + appender.appendOne(String.valueOf(rel.get()), href.get()); + }); + + mapper.appendLinks(appender, Integer.valueOf(42), "https://hitchhiker.com"); + + verify(appender).appendOne("42", "https://hitchhiker.com"); + } + +} diff --git a/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherContextTest.java b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherContextTest.java new file mode 100644 index 0000000000..6eb7bb4c84 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherContextTest.java @@ -0,0 +1,44 @@ +package sonia.scm.api.v2.resources; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import java.util.NoSuchElementException; + +class LinkEnricherContextTest { + + @Test + void shouldCreateContextFromSingleObject() { + LinkEnricherContext context = LinkEnricherContext.of("hello"); + assertThat(context.oneByType(String.class)).contains("hello"); + } + + @Test + void shouldCreateContextFromMultipleObjects() { + LinkEnricherContext context = LinkEnricherContext.of("hello", Integer.valueOf(42), Long.valueOf(21L)); + assertThat(context.oneByType(String.class)).contains("hello"); + assertThat(context.oneByType(Integer.class)).contains(42); + assertThat(context.oneByType(Long.class)).contains(21L); + } + + @Test + void shouldReturnEmptyOptionalForUnknownTypes() { + LinkEnricherContext context = LinkEnricherContext.of(); + assertThat(context.oneByType(String.class)).isNotPresent(); + } + + @Test + void shouldReturnRequiredObject() { + LinkEnricherContext context = LinkEnricherContext.of("hello"); + assertThat(context.oneRequireByType(String.class)).isEqualTo("hello"); + } + + @Test + void shouldThrowAnNoSuchElementExceptionForUnknownTypes() { + LinkEnricherContext context = LinkEnricherContext.of(); + assertThrows(NoSuchElementException.class, () -> context.oneRequireByType(String.class)); + } + +} diff --git a/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherRegistryTest.java b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherRegistryTest.java new file mode 100644 index 0000000000..07441003d7 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/api/v2/resources/LinkEnricherRegistryTest.java @@ -0,0 +1,60 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LinkEnricherRegistryTest { + + private LinkEnricherRegistry registry; + + @BeforeEach + void setUpObjectUnderTest() { + registry = new LinkEnricherRegistry(); + } + + @Test + void shouldRegisterTheEnricher() { + SampleLinkEnricher enricher = new SampleLinkEnricher(); + registry.register(String.class, enricher); + + Iterable enrichers = registry.allByType(String.class); + assertThat(enrichers).containsOnly(enricher); + } + + @Test + void shouldRegisterMultipleEnrichers() { + SampleLinkEnricher one = new SampleLinkEnricher(); + registry.register(String.class, one); + + SampleLinkEnricher two = new SampleLinkEnricher(); + registry.register(String.class, two); + + Iterable enrichers = registry.allByType(String.class); + assertThat(enrichers).containsOnly(one, two); + } + + @Test + void shouldRegisterEnrichersForDifferentTypes() { + SampleLinkEnricher one = new SampleLinkEnricher(); + registry.register(String.class, one); + + SampleLinkEnricher two = new SampleLinkEnricher(); + registry.register(Integer.class, two); + + Iterable enrichers = registry.allByType(String.class); + assertThat(enrichers).containsOnly(one); + + enrichers = registry.allByType(Integer.class); + assertThat(enrichers).containsOnly(two); + } + + private static class SampleLinkEnricher implements LinkEnricher { + @Override + public void enrich(LinkEnricherContext context, LinkAppender appender) { + + } + } + +}