From aea4c4c91f4bbb37049124133f4465078a02c769 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Thu, 21 Feb 2019 16:34:07 +0100 Subject: [PATCH] #811 - Added ability to override single link render mode per link relation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HalConfiguration now exposes a withSingleLinkRenderModeFor(…) taking a path pattern to be rendered in the also given RenderSingleLinks mode. It takes patterns as link relations are either plain strings or valid URIs. Simplified HAL link list rendering in Jackson2HalModule avoiding double nesting of collections before rendering. --- .../hateoas/hal/HalConfiguration.java | 87 +++++++++++++++++-- .../hateoas/hal/Jackson2HalModule.java | 57 ++++++------ .../hateoas/hal/HalConfigurationUnitTest.java | 68 +++++++++++++++ .../hal/Jackson2HalIntegrationTest.java | 13 +++ 4 files changed, 192 insertions(+), 33 deletions(-) create mode 100644 src/test/java/org/springframework/hateoas/hal/HalConfigurationUnitTest.java 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);