diff --git a/scm-annotations/src/main/java/sonia/scm/api/v2/resources/Enrich.java b/scm-annotations/src/main/java/sonia/scm/api/v2/resources/Enrich.java new file mode 100644 index 0000000000..a1269dfc00 --- /dev/null +++ b/scm-annotations/src/main/java/sonia/scm/api/v2/resources/Enrich.java @@ -0,0 +1,26 @@ +package sonia.scm.api.v2.resources; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify the source of an enricher. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Enrich { + + /** + * Source mapping class. + * + * @return source mapping class + */ + Class value(); +} 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..d3864dc798 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkAppender.java @@ -0,0 +1,47 @@ +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); + + /** + * Returns a builder which is able to append an array of links to the resource. + * + * @param rel name of link relation + * @return multi link builder + */ + LinkArrayBuilder arrayBuilder(String rel); + + + /** + * Builder for link arrays. + */ + interface LinkArrayBuilder { + + /** + * Append an link to the array. + * + * @param name name of link + * @param href link target + * @return {@code this} + */ + LinkArrayBuilder append(String name, String href); + + /** + * Builds the array and appends the it to the json response. + */ + void build(); + } +} 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..c16d6f6482 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricher.java @@ -0,0 +1,26 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.plugin.ExtensionPoint; + +/** + * A {@link LinkEnricher} can be used to append hateoas links to a specific json response. + * To register an enricher use the {@link Enrich} annotation or the {@link LinkEnricherRegistry} which is available + * via injection. + * + * Warning: enrichers are always registered as singletons. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@ExtensionPoint +@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..2808a923e9 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LinkEnricherContext.java @@ -0,0 +1,72 @@ +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) { + Optional instance = oneByType(type); + if (instance.isPresent()) { + return instance.get(); + } else { + throw new NoSuchElementException("No instance for given type present"); + } + } + +} 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/main/java/sonia/scm/security/TokenClaimsValidator.java b/scm-core/src/main/java/sonia/scm/security/AccessTokenValidator.java similarity index 82% rename from scm-core/src/main/java/sonia/scm/security/TokenClaimsValidator.java rename to scm-core/src/main/java/sonia/scm/security/AccessTokenValidator.java index 4389e7bfb7..24a92929f9 100644 --- a/scm-core/src/main/java/sonia/scm/security/TokenClaimsValidator.java +++ b/scm-core/src/main/java/sonia/scm/security/AccessTokenValidator.java @@ -30,26 +30,25 @@ */ package sonia.scm.security; -import java.util.Map; import sonia.scm.plugin.ExtensionPoint; /** - * Validates the claims of a jwt token. The validator is called durring authentication - * with a jwt token. + * Validates an {@link AccessToken}. The validator is called during authentication + * with an {@link AccessToken}. * * @author Sebastian Sdorra * @since 2.0.0 */ @ExtensionPoint -public interface TokenClaimsValidator { +public interface AccessTokenValidator { /** - * Returns {@code true} if the claims is valid. If the token is not valid and the + * Returns {@code true} if the {@link AccessToken} is valid. If the token is not valid and the * method returns {@code false}, the authentication is treated as failed. * - * @param claims token claims + * @param token the access token to verify * - * @return {@code true} if the claims is valid + * @return {@code true} if the token is valid */ - boolean validate(Map claims); + boolean validate(AccessToken token); } 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) { + + } + } + +} diff --git a/scm-ui-components/packages/ui-components/src/config/Configuration.js b/scm-ui-components/packages/ui-components/src/config/Configuration.js index 07b68f39a6..0eb6f6ffc2 100644 --- a/scm-ui-components/packages/ui-components/src/config/Configuration.js +++ b/scm-ui-components/packages/ui-components/src/config/Configuration.js @@ -2,12 +2,7 @@ import React from "react"; import { translate } from "react-i18next"; import type { Links } from "@scm-manager/ui-types"; -import { - apiClient, - SubmitButton, - Loading, - ErrorNotification -} from "../"; +import { apiClient, SubmitButton, Loading, ErrorNotification } from "../"; type RenderProps = { readOnly: boolean, @@ -20,10 +15,10 @@ type Props = { render: (props: RenderProps) => any, // ??? // context props - t: (string) => string + t: string => string }; -type ConfigurationType = { +type ConfigurationType = { _links: Links } & Object; @@ -32,6 +27,7 @@ type State = { fetching: boolean, modifying: boolean, contentType?: string, + configChanged: boolean, configuration?: ConfigurationType, modifiedConfiguration?: ConfigurationType, @@ -43,12 +39,12 @@ type State = { * synchronizing the configuration with the backend. */ class Configuration extends React.Component { - constructor(props: Props) { super(props); this.state = { fetching: true, modifying: false, + configChanged: false, valid: false }; } @@ -56,7 +52,8 @@ class Configuration extends React.Component { componentDidMount() { const { link } = this.props; - apiClient.get(link) + apiClient + .get(link) .then(this.captureContentType) .then(response => response.json()) .then(this.loadConfig) @@ -119,19 +116,39 @@ class Configuration extends React.Component { this.setState({ modifying: true }); - const {modifiedConfiguration} = this.state; + const { modifiedConfiguration } = this.state; - apiClient.put(this.getModificationUrl(), modifiedConfiguration, this.getContentType()) - .then(() => this.setState({ modifying: false })) + apiClient + .put( + this.getModificationUrl(), + modifiedConfiguration, + this.getContentType() + ) + .then(() => this.setState({ modifying: false, configChanged: true, valid: false })) .catch(this.handleError); }; + renderConfigChangedNotification = () => { + if (this.state.configChanged) { + return ( +
+
+ ); + } + return null; + }; + render() { const { t } = this.props; const { fetching, error, configuration, modifying, valid } = this.state; if (error) { - return ; + return ; } else if (fetching || !configuration) { return ; } else { @@ -144,19 +161,21 @@ class Configuration extends React.Component { }; return ( -
- { this.props.render(renderProps) } -
- - + <> + {this.renderConfigChangedNotification()} +
+ {this.props.render(renderProps)} +
+ + + ); } } - } export default translate("config")(Configuration); diff --git a/scm-ui-components/packages/ui-components/src/forms/DropDown.js b/scm-ui-components/packages/ui-components/src/forms/DropDown.js index 5098a901f3..62a7f1ebe1 100644 --- a/scm-ui-components/packages/ui-components/src/forms/DropDown.js +++ b/scm-ui-components/packages/ui-components/src/forms/DropDown.js @@ -7,17 +7,19 @@ type Props = { options: string[], optionSelected: string => void, preselectedOption?: string, - className: any + className: any, + disabled?: boolean }; class DropDown extends React.Component { render() { - const { options, preselectedOption, className } = this.props; + const { options, preselectedOption, className, disabled } = this.props; return (