Skip to content

issue #288 Allow links on particular rels to be displayed as an array even if there is only one link #295

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import org.springframework.hateoas.core.EvoInflectorRelProvider;
import org.springframework.hateoas.hal.CurieProvider;
import org.springframework.hateoas.hal.HalLinkDiscoverer;
import org.springframework.hateoas.hal.HalCollectionRels;
import org.springframework.hateoas.hal.Jackson2HalModule;
import org.springframework.hateoas.mvc.TypeConstrainedMappingJackson2HttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
Expand Down Expand Up @@ -288,14 +289,18 @@ private List<HttpMessageConverter<?>> potentiallyRegisterModule(List<HttpMessage
}

CurieProvider curieProvider = getCurieProvider(beanFactory);
HalCollectionRels halCollectionRels = getHalMultipleLinkRels(beanFactory);
RelProvider relProvider = beanFactory.getBean(DELEGATING_REL_PROVIDER_BEAN_NAME, RelProvider.class);
ObjectMapper halObjectMapper = beanFactory.getBean(HAL_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class);
MessageSourceAccessor linkRelationMessageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME,
MessageSourceAccessor.class);

halObjectMapper.registerModule(new Jackson2HalModule());
halObjectMapper.setHandlerInstantiator(
new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider, linkRelationMessageSource));
new Jackson2HalModule.HalHandlerInstantiator(
relProvider, curieProvider, halCollectionRels, linkRelationMessageSource
)
);

MappingJackson2HttpMessageConverter halConverter = new TypeConstrainedMappingJackson2HttpMessageConverter(
ResourceSupport.class);
Expand All @@ -316,6 +321,14 @@ private static CurieProvider getCurieProvider(BeanFactory factory) {
return null;
}
}

private static HalCollectionRels getHalMultipleLinkRels(BeanFactory factory) {
try {
return factory.getBean(HalCollectionRels.class);
} catch(NoSuchBeanDefinitionException e) {
return null;
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public class EmbeddedWrappers {

/**
* Creates a new {@link EmbeddedWrappers}.
*
*
* @param preferCollections whether wrappers for single elements should rather treat the value as collection.
*/
public EmbeddedWrappers(boolean preferCollections) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.springframework.hateoas.hal;


import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

/**
* Class to maintain relations that must always be serialized to an array regardless of cardinality.
*
* In HAL, if there is only one element behind a rel, it can be serialized directly into a JSON object; otherwise
* the elements behind the rel are serialized into a JSON array of JSON objects.
*
* However, the HAL specification also says that the API designer can mandate that certain rels will always be
* represented as an array regardless of the cardinality of elements behind that rel. This class helps you do that
* If a rel is specified here, elements behind that rel will always be serialized into an array wherever it is
* used.
*
* @author Vivin Paliath
*/
public class HalCollectionRels {

private final Set<String> rels;

public HalCollectionRels(String... rels) {
this.rels = new HashSet<String>(Arrays.asList(rels));
}

public boolean containsRel(String rel) {
return rels.contains(rel);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,20 +120,22 @@ public static class HalLinkListSerializer extends ContainerSerializer<List<Link>
private final CurieProvider curieProvider;
private final EmbeddedMapper mapper;
private final MessageSourceAccessor messageSource;
private final HalCollectionRels halCollectionRels;

public HalLinkListSerializer(CurieProvider curieProvider, EmbeddedMapper mapper,
MessageSourceAccessor messageSource) {
this(null, curieProvider, mapper, messageSource);
HalCollectionRels halCollectionRels, MessageSourceAccessor messageSource) {
this(null, curieProvider, mapper, halCollectionRels, messageSource);
}

public HalLinkListSerializer(BeanProperty property, CurieProvider curieProvider, EmbeddedMapper mapper,
MessageSourceAccessor messageSource) {
HalCollectionRels halCollectionRels, MessageSourceAccessor messageSource) {

super(List.class, false);
this.property = property;
this.curieProvider = curieProvider;
this.mapper = mapper;
this.messageSource = messageSource;
this.halCollectionRels = halCollectionRels;
}

/*
Expand Down Expand Up @@ -195,7 +197,7 @@ public void serialize(List<Link> value, JsonGenerator jgen, SerializerProvider p
JavaType mapType = typeFactory.constructMapType(HashMap.class, keyType, valueType);

MapSerializer serializer = MapSerializer.construct(new String[] {}, mapType, true, null,
provider.findKeySerializer(keyType, null), new OptionalListJackson2Serializer(property), null);
provider.findKeySerializer(keyType, null), new OptionalListJackson2Serializer(property, halCollectionRels), null);

serializer.serialize(sortedLinks, jgen, provider);
}
Expand Down Expand Up @@ -244,7 +246,7 @@ private String getTitle(String localRel) {
@Override
public JsonSerializer<?> createContextual(SerializerProvider provider, BeanProperty property)
throws JsonMappingException {
return new HalLinkListSerializer(property, curieProvider, mapper, messageSource);
return new HalLinkListSerializer(property, curieProvider, mapper, halCollectionRels, messageSource);
}

/*
Expand Down Expand Up @@ -394,21 +396,24 @@ public static class OptionalListJackson2Serializer extends ContainerSerializer<O

private final BeanProperty property;
private final Map<Class<?>, JsonSerializer<Object>> serializers;
private final HalCollectionRels halCollectionRels;

public OptionalListJackson2Serializer() {
this(null);
this(null, new HalCollectionRels());
}

/**
* Creates a new {@link OptionalListJackson2Serializer} using the given {@link BeanProperty}.
*
* Creates a new {@link OptionalListJackson2Serializer} using the given {@link BeanProperty} and
* {@link HalCollectionRels}.
*
* @param property
*/
public OptionalListJackson2Serializer(BeanProperty property) {
public OptionalListJackson2Serializer(BeanProperty property, HalCollectionRels halCollectionRels) {

super(List.class, false);
this.property = property;
this.serializers = new HashMap<Class<?>, JsonSerializer<Object>>();
this.halCollectionRels = halCollectionRels;
}

/*
Expand All @@ -435,6 +440,18 @@ public void serialize(Object value, JsonGenerator jgen, SerializerProvider provi
}

if (list.size() == 1) {
Object element = list.get(0);
if(element instanceof HalLink) {
HalLink link = (HalLink) element;
if(halCollectionRels.containsRel(link.getLink().getRel())) {
jgen.writeStartArray();
serializeContents(list.iterator(), jgen, provider);
jgen.writeEndArray();

return;
}
}

serializeContents(list.iterator(), jgen, provider);
return;
}
Expand Down Expand Up @@ -523,7 +540,7 @@ public boolean isEmpty(SerializerProvider provider, Object value) {
@Override
public JsonSerializer<?> createContextual(SerializerProvider provider, BeanProperty property)
throws JsonMappingException {
return new OptionalListJackson2Serializer(property);
return new OptionalListJackson2Serializer(property, halCollectionRels);
}
}

Expand Down Expand Up @@ -679,18 +696,23 @@ public static class HalHandlerInstantiator extends HandlerInstantiator {

public HalHandlerInstantiator(RelProvider resolver, CurieProvider curieProvider,
MessageSourceAccessor messageSource) {
this(resolver, curieProvider, messageSource, true);
this(resolver, curieProvider, new HalCollectionRels(), messageSource, true);
}

public HalHandlerInstantiator(RelProvider resolver, CurieProvider curieProvider,
HalCollectionRels halCollectionRels, MessageSourceAccessor messageSource) {
this(resolver, curieProvider, halCollectionRels, messageSource, true);
}

public HalHandlerInstantiator(RelProvider resolver, CurieProvider curieProvider,
MessageSourceAccessor messageSource, boolean enforceEmbeddedCollections) {
HalCollectionRels halCollectionRels, MessageSourceAccessor messageSource, boolean enforceEmbeddedCollections) {

EmbeddedMapper mapper = new EmbeddedMapper(resolver, curieProvider, enforceEmbeddedCollections);

Assert.notNull(resolver, "RelProvider must not be null!");
this.instanceMap.put(HalResourcesSerializer.class, new HalResourcesSerializer(mapper));
this.instanceMap.put(HalLinkListSerializer.class,
new HalLinkListSerializer(curieProvider, mapper, messageSource));
new HalLinkListSerializer(curieProvider, mapper, halCollectionRels, messageSource));
}

private Object findInstance(Class<?> type) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.Collections;

import org.junit.Test;
import org.springframework.hateoas.hal.HalCollectionRels;

/**
* Unit tests for {@link EmbeddedWrappers}.
Expand All @@ -30,7 +31,17 @@
*/
public class EmbeddedWrappersUnitTest {

EmbeddedWrappers wrappers = new EmbeddedWrappers(false);
private static EmbeddedWrappers wrappers() {
return new EmbeddedWrappers(false);
}

private static EmbeddedWrappers collectionPreferredWrappers() {
return new EmbeddedWrappers(true);
}

private static EmbeddedWrappers collectionRelsWrappers() {
return new EmbeddedWrappers(false);
}

/**
* @see #286
Expand All @@ -39,7 +50,7 @@ public class EmbeddedWrappersUnitTest {
@SuppressWarnings("rawtypes")
public void createsWrapperForEmptyCollection() {

EmbeddedWrapper wrapper = wrappers.emptyCollectionOf(String.class);
EmbeddedWrapper wrapper = wrappers().emptyCollectionOf(String.class);

assertEmptyCollectionValue(wrapper);
assertThat(wrapper.getRel(), is(nullValue()));
Expand All @@ -52,7 +63,7 @@ public void createsWrapperForEmptyCollection() {
@Test
public void createsWrapperForEmptyCollectionAndExplicitRel() {

EmbeddedWrapper wrapper = wrappers.wrap(Collections.emptySet(), "rel");
EmbeddedWrapper wrapper = wrappers().wrap(Collections.emptySet(), "rel");

assertEmptyCollectionValue(wrapper);
assertThat(wrapper.getRel(), is("rel"));
Expand All @@ -64,7 +75,7 @@ public void createsWrapperForEmptyCollectionAndExplicitRel() {
*/
@Test(expected = IllegalArgumentException.class)
public void rejectsEmptyCollectionWithoutExplicitRel() {
wrappers.wrap(Collections.emptySet());
wrappers().wrap(Collections.emptySet());
}

private static void assertEmptyCollectionValue(EmbeddedWrapper wrapper) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 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
* 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,
Expand Down Expand Up @@ -41,6 +41,7 @@
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.UriTemplate;
import org.springframework.hateoas.core.AnnotationRelProvider;
import org.springframework.hateoas.core.EmbeddedWrapper;
import org.springframework.hateoas.core.EmbeddedWrappers;
import org.springframework.hateoas.hal.Jackson2HalModule.HalHandlerInstantiator;

Expand All @@ -55,10 +56,12 @@
public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationTest {

static final String SINGLE_LINK_REFERENCE = "{\"_links\":{\"self\":{\"href\":\"localhost\"}}}";
static final String SINGLE_LINK_ARRAY_REFERENCE = "{\"_links\":{\"multiple\":[{\"href\":\"localhost\"}]}}";
static final String LIST_LINK_REFERENCE = "{\"_links\":{\"self\":[{\"href\":\"localhost\"},{\"href\":\"localhost2\"}]}}";

static final String SIMPLE_EMBEDDED_RESOURCE_REFERENCE = "{\"_embedded\":{\"content\":[\"first\",\"second\"]},\"_links\":{\"self\":{\"href\":\"localhost\"}}}";
static final String SINGLE_EMBEDDED_RESOURCE_REFERENCE = "{\"_embedded\":{\"content\":[{\"text\":\"test1\",\"number\":1,\"_links\":{\"self\":{\"href\":\"localhost\"}}}]},\"_links\":{\"self\":{\"href\":\"localhost\"}}}";
static final String SINGLE_EMBEDDED_RESOURCE_ARRAY_REFERENCE = "{\"_embedded\":{\"multiple\":[{\"text\":\"test1\",\"number\":1}}}";
static final String LIST_EMBEDDED_RESOURCE_REFERENCE = "{\"_embedded\":{\"content\":[{\"text\":\"test1\",\"number\":1,\"_links\":{\"self\":{\"href\":\"localhost\"}}},{\"text\":\"test2\",\"number\":2,\"_links\":{\"self\":{\"href\":\"localhost\"}}}]},\"_links\":{\"self\":{\"href\":\"localhost\"}}}";

static final String ANNOTATED_EMBEDDED_RESOURCE_REFERENCE = "{\"_embedded\":{\"pojos\":[{\"text\":\"test1\",\"number\":1,\"_links\":{\"self\":{\"href\":\"localhost\"}}}]},\"_links\":{\"self\":{\"href\":\"localhost\"}}}";
Expand Down Expand Up @@ -116,6 +119,16 @@ public void rendersMultipleLinkAsArray() throws Exception {
assertThat(write(resourceSupport), is(LIST_LINK_REFERENCE));
}

@Test
public void rendersSingleLinkAsArrayWhenConfigured() throws Exception {
mapper.setHandlerInstantiator(new HalHandlerInstantiator(new AnnotationRelProvider(), null, new HalCollectionRels("multiple"), null));

ResourceSupport resourceSupport = new ResourceSupport();
resourceSupport.add(new Link("localhost").withRel("multiple"));

assertThat(write(resourceSupport), is(SINGLE_LINK_ARRAY_REFERENCE));
}

@Test
public void deserializeMultipleLinks() throws Exception {

Expand Down