From 9e5ce59731904ce8c961674bf7a552faaec32694 Mon Sep 17 00:00:00 2001 From: Jeff Stano Date: Tue, 26 Aug 2014 09:52:17 -0600 Subject: [PATCH] #235: added support for RFC 5988 Target Attributes to the Link class --- .../org/springframework/hateoas/Link.java | 186 +++++++++++++++++- .../springframework/hateoas/LinkUnitTest.java | 46 +++++ .../hal/Jackson2HalIntegrationTest.java | 14 ++ 3 files changed, 240 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/springframework/hateoas/Link.java b/src/main/java/org/springframework/hateoas/Link.java index 95457ae1c..8d014574e 100755 --- a/src/main/java/org/springframework/hateoas/Link.java +++ b/src/main/java/org/springframework/hateoas/Link.java @@ -16,10 +16,13 @@ package org.springframework.hateoas; import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -27,6 +30,8 @@ import javax.xml.bind.annotation.XmlTransient; import javax.xml.bind.annotation.XmlType; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonInclude; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -55,6 +60,7 @@ public class Link implements Serializable { @XmlAttribute private String rel; @XmlAttribute private String href; @XmlTransient @JsonIgnore private UriTemplate template; + private Map attributes = new TreeMap(); /** * Creates a new link to the given URI with the self rel. @@ -99,6 +105,17 @@ protected Link() { } + /** + * Copy constructor needed for the various with methods. + */ + private Link(Link linkToCopy) { + + this.template = linkToCopy.template; + this.href = linkToCopy.href; + this.rel = linkToCopy.rel; + this.attributes = linkToCopy.attributes; + } + /** * Returns the actual URI the link is pointing to. * @@ -117,6 +134,18 @@ public String getRel() { return rel; } + /** + * Returns the attributes of the link. + * + * @return + */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonAnyGetter + public Map getAttributes() { + + return Collections.unmodifiableMap(attributes); + } + /** * Returns a {@link Link} pointing to the same URI but with the given relation. * @@ -136,6 +165,83 @@ public Link withSelfRel() { return withRel(Link.REL_SELF); } + /** + * Returns a {@link Link} pointing to the same URI but with the specified anchor value. + * + * @param anchor + * @return + */ + public Link withAnchor(String anchor) { + + Link link = new Link(this); + link.setAttributeValue("anchor", anchor); + return link; + } + + /** + * Returns a {@link Link} pointing to the same URI but with the specified hreflang value. + * + * @param hreflang + * @return + */ + public Link withHreflang(String hreflang) { + + return withAttribute("hreflang", hreflang); + } + + /** + * Returns a {@link Link} pointing to the same URI but with the specified media value. + * + * @param media + * @return + */ + public Link withMedia(String media) { + + Link link = new Link(this); + link.setAttributeValue("media", media); + return link; + } + + /** + * Returns a {@link Link} pointing to the same URI but with the specified title value. + * + * @param title + * @return + */ + public Link withTitle(String title) { + + Link link = new Link(this); + link.setAttributeValue("title", title); + return link; + } + + /** + * Returns a {@link Link} pointing to the same URI but with the specified type value. + * + * @param type + * @return + */ + public Link withType(String type) { + + Link link = new Link(this); + link.setAttributeValue("type", type); + return link; + } + + /** + * Returns a {@link Link} pointing to the same URI but with the specified attribute + * + * @param key + * @param value + * @return + */ + public Link withAttribute(String key, String value) { + + Link link = new Link(this); + link.setAttributeValue(key, value); + return link; + } + /** * Returns the variable names contained in the template. * @@ -233,7 +339,59 @@ public int hashCode() { */ @Override public String toString() { - return String.format("<%s>;rel=\"%s\"", href, rel); + + StringBuilder str = new StringBuilder(); + + str.append("<"); + str.append(href); + str.append(">"); + + if (rel != null) { + str.append(";rel=\""); + str.append(rel); + str.append("\""); + } + + for (String key : attributes.keySet()) { + Object value = attributes.get(key); + + if (value instanceof Collection) { + for (String item : (Collection)value) { + str.append(";"); + str.append(key); + str.append("=\""); + str.append(item); + str.append("\""); + } + } + else { + str.append(";"); + str.append(key); + str.append("=\""); + str.append(value); + str.append("\""); + } + } + + return str.toString(); + } + + private void setAttributeValue(String key, String value) { + + Object currentValue = attributes.get(key); + + if (currentValue instanceof Collection) { + ((Collection)currentValue).add(value); + } + else if (currentValue instanceof String) { + Collection values = new ArrayList(); + attributes.put(key, values); + values.add(currentValue.toString()); + values.add(value); + } + else { + attributes.put(key, value); + } } /** @@ -262,7 +420,15 @@ public static Link valueOf(String element) { throw new IllegalArgumentException("Link does not provide a rel attribute!"); } - return new Link(matcher.group(1), attributes.get("rel")); + Link link = new Link(matcher.group(1), attributes.get("rel")); + + for (String key : attributes.keySet()) { + if (!key.equalsIgnoreCase("rel")) { + link = link.withAttribute(key, attributes.get(key)); + } + } + + return link; } else { throw new IllegalArgumentException(String.format("Given link header %s is not RFC5988 compliant!", element)); @@ -282,11 +448,19 @@ private static Map getAttributeMap(String source) { } Map attributes = new HashMap(); - Pattern keyAndValue = Pattern.compile("(\\w+)=\\\"(\\p{Alnum}*)\""); - Matcher matcher = keyAndValue.matcher(source); - while (matcher.find()) { - attributes.put(matcher.group(1), matcher.group(2)); + Pattern attributesPattern = Pattern.compile("\\w+=\\\"[\\s\\p{Alnum}]*\"*"); + Pattern keyAndValuePattern = Pattern.compile("(\\w+)=\\\"([\\s\\p{Alnum}]*)\""); + + Matcher attributesMatcher = attributesPattern.matcher(source); + + while (attributesMatcher.find()) { + String group = attributesMatcher.group(); + Matcher keyValueMatcher = keyAndValuePattern.matcher(group); + + if (keyValueMatcher.find()) { + attributes.put(keyValueMatcher.group(1), keyValueMatcher.group(2)); + } } return attributes; diff --git a/src/test/java/org/springframework/hateoas/LinkUnitTest.java b/src/test/java/org/springframework/hateoas/LinkUnitTest.java index 99abac041..0af33e5e3 100644 --- a/src/test/java/org/springframework/hateoas/LinkUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinkUnitTest.java @@ -44,6 +44,26 @@ public void createsLinkFromRelAndHref() { assertThat(link.getRel(), is(Link.REL_SELF)); } + @Test + public void createsLinkFromRelAndHrefWithParameters() { + + Link link = new Link("foo", Link.REL_SELF) + .withAnchor("anchor") + .withHreflang("hreflang") + .withMedia("media") + .withTitle("title") + .withType("type") + .withAttribute("name", "name"); + assertThat(link.getHref(), is("foo")); + assertThat(link.getRel(), is(Link.REL_SELF)); + assertThat(link.getAttributes().get("anchor").toString(), is("anchor")); + assertThat(link.getAttributes().get("hreflang").toString(), is("hreflang")); + assertThat(link.getAttributes().get("media").toString(), is("media")); + assertThat(link.getAttributes().get("title").toString(), is("title")); + assertThat(link.getAttributes().get("type").toString(), is("type")); + assertThat(link.getAttributes().get("name").toString(), is("name")); + } + @Test(expected = IllegalArgumentException.class) public void rejectsNullHref() { new Link(null); @@ -108,6 +128,32 @@ public void parsesRFC5988HeaderIntoLink() { assertThat(Link.valueOf(";rel=\"foo\""), is(new Link("/something", "foo"))); assertThat(Link.valueOf(";rel=\"foo\";title=\"Some title\""), is(new Link("/something", "foo"))); + assertThat(Link.valueOf(";title=\"Some title\";rel=\"foo\""), is(new Link("/something", "foo"))); + assertThat(Link.valueOf(";rel=\"foo\";title=\"Some title\"").getAttributes().get("title").toString(), is("Some title")); + } + + @Test + public void testToStringWithNoAttributes() { + + Link link = new Link("/foo", Link.REL_SELF); + + assertThat(link.toString(), is(";rel=\"self\"")); + } + + @Test + public void testToStringWithAllAttributes() { + + Link link = new Link("/foo", Link.REL_SELF) + .withAnchor("anchor") + .withHreflang("hreflang") + .withMedia("media") + .withTitle("title") + .withType("type") + .withAttribute("name", "name") + .withAttribute("custom", "custom1") + .withAttribute("custom", "custom2"); + + assertThat(link.toString(), is(";rel=\"self\";anchor=\"anchor\";custom=\"custom1\";custom=\"custom2\";hreflang=\"hreflang\";media=\"media\";name=\"name\";title=\"title\";type=\"type\"")); } @Test(expected = IllegalArgumentException.class) diff --git a/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java b/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java index af459a2ef..df8cc619b 100644 --- a/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java @@ -51,6 +51,8 @@ public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingInteg static final String SINGLE_LINK_REFERENCE = "{\"_links\":{\"self\":{\"href\":\"localhost\"}}}"; static final String LIST_LINK_REFERENCE = "{\"_links\":{\"self\":[{\"href\":\"localhost\"},{\"href\":\"localhost2\"}]}}"; + static final String SINGLE_LINK_WITH_ATTRIBUTES_REFERENCE = "{\"_links\":{\"self\":{\"href\":\"localhost\",\"hreflang\":[\"lang1\",\"lang2\"],\"title\":\"The Title\"}}}"; + static final String SIMPLE_EMBEDDED_RESOURCE_REFERENCE = "{\"_links\":{\"self\":{\"href\":\"localhost\"}},\"_embedded\":{\"content\":[\"first\",\"second\"]}}"; static final String SINGLE_EMBEDDED_RESOURCE_REFERENCE = "{\"_links\":{\"self\":{\"href\":\"localhost\"}},\"_embedded\":{\"content\":[{\"text\":\"test1\",\"number\":1,\"_links\":{\"self\":{\"href\":\"localhost\"}}}]}}"; static final String LIST_EMBEDDED_RESOURCE_REFERENCE = "{\"_links\":{\"self\":{\"href\":\"localhost\"}},\"_embedded\":{\"content\":[{\"text\":\"test1\",\"number\":1,\"_links\":{\"self\":{\"href\":\"localhost\"}}},{\"text\":\"test2\",\"number\":2,\"_links\":{\"self\":{\"href\":\"localhost\"}}}]}}"; @@ -95,6 +97,18 @@ public void deserializeSingleLink() throws Exception { assertThat(read(SINGLE_LINK_REFERENCE, ResourceSupport.class), is(expected)); } + @Test + public void rendersSingleLinkWithAttributesAsObject() throws Exception { + + ResourceSupport resourceSupport = new ResourceSupport(); + resourceSupport.add(new Link("localhost") + .withTitle("The Title") + .withHreflang("lang1") + .withHreflang("lang2")); + + assertThat(write(resourceSupport), is(SINGLE_LINK_WITH_ATTRIBUTES_REFERENCE)); + } + /** * @see #29 */