Skip to content

Commit 8669bd3

Browse files
committed
Allows links under a single rel to be represented as a multiple (i.e., serialized to an array) even if there is only one.
1 parent 3ca6fa5 commit 8669bd3

File tree

5 files changed

+106
-33
lines changed

5 files changed

+106
-33
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2012 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.hateoas;
17+
18+
import java.lang.annotation.Documented;
19+
import java.lang.annotation.ElementType;
20+
import java.lang.annotation.Inherited;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
import org.springframework.hateoas.core.ControllerEntityLinks;
26+
27+
/**
28+
* Annotation to demarcate controllers that expose URI templates of a structure according to
29+
* {@link ControllerEntityLinks}.
30+
*
31+
* @author Vivin Paliath
32+
*/
33+
@Inherited
34+
@Documented
35+
@Retention(RetentionPolicy.RUNTIME)
36+
@Target(ElementType.TYPE)
37+
public @interface ForceMultipleLinksOnRels {
38+
public String[] value();
39+
}

src/main/java/org/springframework/hateoas/Link.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public class Link implements Serializable {
5555
@XmlAttribute private String rel;
5656
@XmlAttribute private String href;
5757
@XmlTransient @JsonIgnore private UriTemplate template;
58+
@XmlTransient @JsonIgnore private Class<? extends ResourceSupport> owningResource;
5859

5960
/**
6061
* Creates a new link to the given URI with the self rel.
@@ -194,10 +195,18 @@ private UriTemplate getUriTemplate() {
194195
return template;
195196
}
196197

197-
/*
198-
* (non-Javadoc)
199-
* @see java.lang.Object#equals(java.lang.Object)
200-
*/
198+
public Class<? extends ResourceSupport> getOwningResource() {
199+
return this.owningResource;
200+
}
201+
202+
void setOwningResource(Class<? extends ResourceSupport> owningResource) {
203+
this.owningResource = owningResource;
204+
}
205+
206+
/*
207+
* (non-Javadoc)
208+
* @see java.lang.Object#equals(java.lang.Object)
209+
*/
201210
@Override
202211
public boolean equals(Object obj) {
203212

src/main/java/org/springframework/hateoas/ResourceSupport.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public Link getId() {
5353
*/
5454
public void add(Link link) {
5555
Assert.notNull(link, "Link must not be null!");
56+
link.setOwningResource(this.getClass());
5657
this.links.add(link);
5758
}
5859

@@ -64,6 +65,7 @@ public void add(Link link) {
6465
public void add(Iterable<Link> links) {
6566
Assert.notNull(links, "Given links must not be null!");
6667
for (Link candidate : links) {
68+
candidate.setOwningResource(this.getClass());
6769
add(candidate);
6870
}
6971
}

src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,11 @@
1717

1818
import java.io.IOException;
1919
import java.lang.reflect.Type;
20-
import java.util.ArrayList;
21-
import java.util.Collection;
22-
import java.util.HashMap;
23-
import java.util.Iterator;
24-
import java.util.LinkedHashMap;
25-
import java.util.List;
26-
import java.util.Map;
20+
import java.util.*;
2721

2822
import org.springframework.beans.BeanUtils;
29-
import org.springframework.hateoas.Link;
30-
import org.springframework.hateoas.Links;
31-
import org.springframework.hateoas.RelProvider;
32-
import org.springframework.hateoas.Resource;
33-
import org.springframework.hateoas.ResourceSupport;
34-
import org.springframework.hateoas.Resources;
23+
import org.springframework.core.annotation.AnnotationUtils;
24+
import org.springframework.hateoas.*;
3525
import org.springframework.util.Assert;
3626

3727
import com.fasterxml.jackson.core.JsonGenerationException;
@@ -130,15 +120,29 @@ public HalLinkListSerializer(BeanProperty property, CurieProvider curieProvider)
130120
public void serialize(List<Link> value, JsonGenerator jgen, SerializerProvider provider) throws IOException,
131121
JsonGenerationException {
132122

123+
124+
// keeps track of any rels that need their links to be forced as multiple (i.e., serialized to array)
125+
// regardless of cardinality
126+
List<String> forcedMultipleLinkRels = new ArrayList<String>();
127+
if(!value.isEmpty()) {
128+
Class<? extends ResourceSupport> owningResource = value.get(0).getOwningResource();
129+
ForceMultipleLinksOnRels annotation = AnnotationUtils.findAnnotation(owningResource, ForceMultipleLinksOnRels.class);
130+
if(annotation != null) {
131+
forcedMultipleLinkRels.addAll(Arrays.asList(annotation.value()));
132+
}
133+
}
134+
133135
// sort links according to their relation
134136
Map<String, List<Object>> sortedLinks = new LinkedHashMap<String, List<Object>>();
137+
135138
List<Link> links = new ArrayList<Link>();
136139

137140
boolean prefixingRequired = curieProvider != null;
138141
boolean curiedLinkPresent = false;
139142

140143
for (Link link : value) {
141144

145+
142146
String rel = prefixingRequired ? curieProvider.getNamespacedRelFrom(link) : link.getRel();
143147

144148
if (!link.getRel().equals(rel)) {
@@ -167,7 +171,7 @@ public void serialize(List<Link> value, JsonGenerator jgen, SerializerProvider p
167171
JavaType mapType = typeFactory.constructMapType(HashMap.class, keyType, valueType);
168172

169173
MapSerializer serializer = MapSerializer.construct(new String[] {}, mapType, true, null,
170-
provider.findKeySerializer(keyType, null), new OptionalListJackson2Serializer(property), null);
174+
provider.findKeySerializer(keyType, null), new OptionalListJackson2Serializer(property, forcedMultipleLinkRels), null);
171175

172176
serializer.serialize(sortedLinks, jgen, provider);
173177
}
@@ -320,6 +324,7 @@ public static class OptionalListJackson2Serializer extends ContainerSerializer<O
320324

321325
private final BeanProperty property;
322326
private final Map<Class<?>, JsonSerializer<Object>> serializers;
327+
private final List<String> forcedMultipleLinkRels;
323328

324329
public OptionalListJackson2Serializer() {
325330
this(null);
@@ -331,10 +336,24 @@ public OptionalListJackson2Serializer() {
331336
* @param property
332337
*/
333338
public OptionalListJackson2Serializer(BeanProperty property) {
339+
this(property, new ArrayList<String>());
340+
}
341+
342+
/**
343+
* Private constructor that creates a new instance using the given {@link BeanProperty}
344+
* and a list of rels whose links need to be forced into an array representation regardless of
345+
* cardinality.
346+
*
347+
* @param property
348+
* @param forcedMultipleLinkRels
349+
*/
350+
private OptionalListJackson2Serializer(BeanProperty property, List<String> forcedMultipleLinkRels) {
334351

335352
super(List.class, false);
353+
336354
this.property = property;
337355
this.serializers = new HashMap<Class<?>, JsonSerializer<Object>>();
356+
this.forcedMultipleLinkRels = forcedMultipleLinkRels;
338357
}
339358

340359
/*
@@ -360,14 +379,13 @@ public void serialize(Object value, JsonGenerator jgen, SerializerProvider provi
360379
return;
361380
}
362381

363-
if (list.size() == 1) {
364-
serializeContents(list.iterator(), jgen, provider);
365-
return;
382+
if(list.size() > 1 || ((list.get(0) instanceof Link) && forcedMultipleLinkRels.contains(((Link) list.get(0)).getRel()))) {
383+
jgen.writeStartArray();
384+
serializeContents(list.iterator(), jgen, provider);
385+
jgen.writeEndArray();
386+
} else {
387+
serializeContents(list.iterator(), jgen, provider);
366388
}
367-
368-
jgen.writeStartArray();
369-
serializeContents(list.iterator(), jgen, provider);
370-
jgen.writeEndArray();
371389
}
372390

373391
private void serializeContents(Iterator<?> value, JsonGenerator jgen, SerializerProvider provider)

src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,8 @@
2626

2727
import org.junit.Before;
2828
import org.junit.Test;
29-
import org.springframework.hateoas.AbstractJackson2MarshallingIntegrationTest;
30-
import org.springframework.hateoas.Link;
31-
import org.springframework.hateoas.Links;
32-
import org.springframework.hateoas.PagedResources;
29+
import org.springframework.hateoas.*;
3330
import org.springframework.hateoas.PagedResources.PageMetadata;
34-
import org.springframework.hateoas.Resource;
35-
import org.springframework.hateoas.ResourceSupport;
36-
import org.springframework.hateoas.Resources;
37-
import org.springframework.hateoas.UriTemplate;
3831
import org.springframework.hateoas.core.AnnotationRelProvider;
3932
import org.springframework.hateoas.hal.Jackson2HalModule.HalHandlerInstantiator;
4033

@@ -49,6 +42,7 @@
4942
public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationTest {
5043

5144
static final String SINGLE_LINK_REFERENCE = "{\"_links\":{\"self\":{\"href\":\"localhost\"}}}";
45+
static final String SINGLE_LINK_REFERENCE_AS_MULTIPLE = "{\"_links\":{\"multiple\":[{\"href\":\"localhost\"}]}}";
5246
static final String LIST_LINK_REFERENCE = "{\"_links\":{\"self\":[{\"href\":\"localhost\"},{\"href\":\"localhost2\"}]}}";
5347

5448
static final String SIMPLE_EMBEDDED_RESOURCE_REFERENCE = "{\"_links\":{\"self\":{\"href\":\"localhost\"}},\"_embedded\":{\"content\":[\"first\",\"second\"]}}";
@@ -95,6 +89,13 @@ public void deserializeSingleLink() throws Exception {
9589
assertThat(read(SINGLE_LINK_REFERENCE, ResourceSupport.class), is(expected));
9690
}
9791

92+
@Test
93+
public void rendersSingleLinkAsArrayWhenForced() throws Exception {
94+
ResourceWithForcedMultipleLink expected = new ResourceWithForcedMultipleLink();
95+
expected.add(new Link("localhost").withRel("multiple"));
96+
assertThat(read(SINGLE_LINK_REFERENCE_AS_MULTIPLE, ResourceWithForcedMultipleLink.class), is(expected));
97+
}
98+
9899
/**
99100
* @see #29
100101
*/
@@ -384,4 +385,8 @@ private static ObjectMapper getCuriedObjectMapper(CurieProvider provider) {
384385

385386
return mapper;
386387
}
388+
389+
@ForceMultipleLinksOnRels({"multiple"})
390+
private static class ResourceWithForcedMultipleLink extends ResourceSupport {
391+
}
387392
}

0 commit comments

Comments
 (0)