diff --git a/src/main/java/org/springframework/hateoas/hal/HalConfiguration.java b/src/main/java/org/springframework/hateoas/hal/HalConfiguration.java index 846c4b60..2cfa2745 100644 --- a/src/main/java/org/springframework/hateoas/hal/HalConfiguration.java +++ b/src/main/java/org/springframework/hateoas/hal/HalConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,21 +18,98 @@ package org.springframework.hateoas.hal; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.NoArgsConstructor; import lombok.experimental.Wither; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; + import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkRelation; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; +import org.springframework.util.PathMatcher; /** + * HAL specific configuration. + * * @author Greg Turnquist - * @author Oliver Gierke + * @author Oliver Drotbohm */ -@NoArgsConstructor @AllArgsConstructor(access = AccessLevel.PRIVATE) public class HalConfiguration { - private @Wither @Getter RenderSingleLinks renderSingleLinks = RenderSingleLinks.AS_SINGLE; + private static final PathMatcher MATCHER = new AntPathMatcher(); + /** + * Configures how to render links in case there is exactly one defined for a given link relation in general. By + * default, this single link will be rendered as nested document. + */ + private final @Wither @Getter RenderSingleLinks renderSingleLinks; + private final @Wither(AccessLevel.PRIVATE) Map singleLinksPerPattern; + + /** + * Creates a new default {@link HalConfiguration} rendering single links as immediate sub-document. + */ + public HalConfiguration() { + + this.renderSingleLinks = RenderSingleLinks.AS_SINGLE; + this.singleLinksPerPattern = new LinkedHashMap<>(); + } + + /** + * Configures how to render a single link for a given particular {@link LinkRelation}. This will override what has + * been configured via {@link #withRenderSingleLinks(RenderSingleLinks)} for that particular link relation. + * + * @param relation must not be {@literal null}. + * @param renderSingleLinks must not be {@literal null}. + * @return + */ + public HalConfiguration withRenderSingleLinksFor(LinkRelation relation, RenderSingleLinks renderSingleLinks) { + + Assert.notNull(relation, "Link relation must not be null!"); + Assert.notNull(renderSingleLinks, "RenderSingleLinks must not be null!"); + + return withRenderSingleLinksFor(relation.value(), renderSingleLinks); + } + + /** + * Configures how to render a single link for the given link relation pattern, i.e. this can be either a fixed link + * relation (like {@code search}), take wildcards to e.g. match links of a given curie (like {@code acme:*}) or even + * complete URIs (like {@code http://api.acme.com/foo/**}). + * + * @param pattern must not be {@literal null}. + * @param renderSingleLinks must not be {@literal null}. + * @return @see PathMatcher + */ + public HalConfiguration withRenderSingleLinksFor(String pattern, RenderSingleLinks renderSingleLinks) { + + Map map = new LinkedHashMap<>(singleLinksPerPattern); + map.put(pattern, renderSingleLinks); + + return withSingleLinksPerPattern(map); + } + + /** + * Returns which render mode to use to render a single link for the given {@link LinkRelation}. + * + * @param relation must not be {@literal null}. + * @return + */ + RenderSingleLinks getSingleLinkRenderModeFor(LinkRelation relation) { + + return singleLinksPerPattern.entrySet().stream() // + .filter(entry -> MATCHER.match(entry.getKey(), relation.value())) // + .map(Entry::getValue) // + .findFirst() // + .orElse(renderSingleLinks); + } + + /** + * Configuration option how to render single links of a given {@link LinkRelation}. + * + * @author Oliver Drotbohm + */ public enum RenderSingleLinks { /** diff --git a/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java b/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java index a1ff290d..f022848d 100644 --- a/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java +++ b/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java @@ -21,7 +21,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; -import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -183,7 +182,7 @@ public class Jackson2HalModule extends SimpleModule { if (!skipCuries && prefixingRequired && curiedLinkPresent) { ArrayList curies = new ArrayList<>(); - curies.add(curieProvider.getCurieInformation(Links.of(links))); + curies.addAll(curieProvider.getCurieInformation(Links.of(links))); sortedLinks.put(HalLinkRelation.CURIES, curies); } @@ -420,40 +419,24 @@ public class Jackson2HalModule extends SimpleModule { return; } - if (list.size() == 1 && this.halConfiguration.getRenderSingleLinks() == RenderSingleLinks.AS_SINGLE) { - serializeContents(list.iterator(), jgen, provider); + Object firstElement = list.get(0); + + if (!HalLink.class.isInstance(firstElement)) { + serializeContents(list, jgen, provider); return; } - jgen.writeStartArray(); - serializeContents(list.iterator(), jgen, provider); - jgen.writeEndArray(); - } + HalLink halLink = HalLink.class.cast(firstElement); - private void serializeContents(Iterator value, JsonGenerator jgen, SerializerProvider provider) - throws IOException { + if (list.size() == 1 + && halConfiguration.getSingleLinkRenderModeFor(halLink.getLink().getRel()).equals(RenderSingleLinks.AS_SINGLE)) { - while (value.hasNext()) { - Object elem = value.next(); - if (elem == null) { - provider.defaultSerializeNull(jgen); - } else { - getOrLookupSerializerFor(elem.getClass(), provider).serialize(elem, jgen, provider); - } - } - } + serializeContents(halLink, jgen, provider); - private JsonSerializer getOrLookupSerializerFor(Class type, SerializerProvider provider) - throws JsonMappingException { - - JsonSerializer serializer = serializers.get(type); - - if (serializer == null) { - serializer = provider.findValueSerializer(type, property); - serializers.put(type, serializer); + return; } - return serializer; + serializeContents(list, jgen, provider); } /* @@ -504,6 +487,24 @@ public class Jackson2HalModule extends SimpleModule { throws JsonMappingException { return new OptionalListJackson2Serializer(property, halConfiguration); } + + private void serializeContents(Object value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + getOrLookupSerializerFor(value, provider).serialize(value, jgen, provider); + } + + private JsonSerializer getOrLookupSerializerFor(Object value, SerializerProvider provider) + throws JsonMappingException { + + Class type = value.getClass(); + JsonSerializer serializer = serializers.get(type); + + if (serializer == null) { + serializer = provider.findValueSerializer(type, property); + serializers.put(type, serializer); + } + + return serializer; + } } public static class HalLinkListDeserializer extends ContainerDeserializerBase> { diff --git a/src/test/java/org/springframework/hateoas/hal/HalConfigurationUnitTest.java b/src/test/java/org/springframework/hateoas/hal/HalConfigurationUnitTest.java new file mode 100644 index 00000000..e103e42a --- /dev/null +++ b/src/test/java/org/springframework/hateoas/hal/HalConfigurationUnitTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.hal; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.Test; +import org.springframework.hateoas.LinkRelation; +import org.springframework.hateoas.hal.HalConfiguration.RenderSingleLinks; + +/** + * Unit tests for {@link HalConfiguration}. + * + * @author Oliver Drotbohm + * @soundtrack Port Cities - Montreal (Single) + */ +public class HalConfigurationUnitTest { + + @Test // #811 + public void registersSimpleArrayLinksPattern() { + + HalConfiguration configuration = new HalConfiguration().withRenderSingleLinksFor("foo", RenderSingleLinks.AS_ARRAY); + + assertThat(configuration.getSingleLinkRenderModeFor(LinkRelation.of("foo"))).isEqualTo(RenderSingleLinks.AS_ARRAY); + assertThat(configuration.getSingleLinkRenderModeFor(LinkRelation.of("bar"))).isEqualTo(RenderSingleLinks.AS_SINGLE); + } + + @Test // #811 + public void registersWildcardedArrayLinksPattern() { + + HalConfiguration configuration = new HalConfiguration().withRenderSingleLinksFor("foo*", + RenderSingleLinks.AS_ARRAY); + + assertThat(configuration.getSingleLinkRenderModeFor(LinkRelation.of("foo"))).isEqualTo(RenderSingleLinks.AS_ARRAY); + assertThat(configuration.getSingleLinkRenderModeFor(LinkRelation.of("foobar"))) + .isEqualTo(RenderSingleLinks.AS_ARRAY); + assertThat(configuration.getSingleLinkRenderModeFor(LinkRelation.of("bar"))).isEqualTo(RenderSingleLinks.AS_SINGLE); + } + + @Test // #811 + public void registersWildcardedArrayLinksPatternForUri() { + + HalConfiguration configuration = new HalConfiguration().withRenderSingleLinksFor("http://somehost/foo/**", + RenderSingleLinks.AS_ARRAY); + + assertThat(configuration.getSingleLinkRenderModeFor(LinkRelation.of("http://somehost/foo"))) + .isEqualTo(RenderSingleLinks.AS_ARRAY); + assertThat(configuration.getSingleLinkRenderModeFor(LinkRelation.of("http://somehost/foo/bar"))) + .isEqualTo(RenderSingleLinks.AS_ARRAY); + assertThat(configuration.getSingleLinkRenderModeFor(LinkRelation.of("http://somehost/foo/bar/foobar"))) + .isEqualTo(RenderSingleLinks.AS_ARRAY); + assertThat(configuration.getSingleLinkRenderModeFor(LinkRelation.of("http://somehost/bar"))) + .isEqualTo(RenderSingleLinks.AS_SINGLE); + } +} diff --git a/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java b/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java index 8642c59a..ccab245d 100755 --- a/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java @@ -475,6 +475,19 @@ public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingInteg assertThat(deserialized).isEqualTo(original); } + @Test // #811 + public void rendersSpecificRelWithSingleLinkAsArrayIfConfigured() throws Exception { + + mapper.setHandlerInstantiator(new HalHandlerInstantiator(new AnnotationRelProvider(), null, null, + new HalConfiguration().withRenderSingleLinksFor("foo", RenderSingleLinks.AS_ARRAY))); + + ResourceSupport resource = new ResourceSupport(); + resource.add(new Link("/some-href", "foo")); + + assertThat(mapper.writeValueAsString(resource)) // + .isEqualTo("{\"_links\":{\"foo\":[{\"href\":\"/some-href\"}]}}"); + } + private static void verifyResolvedTitle(String resourceBundleKey) throws Exception { LocaleContextHolder.setLocale(Locale.US);