From 891fd90f5aba80c4150c99291a6f8274a08669a9 Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Sun, 26 Mar 2017 12:16:01 -0500 Subject: [PATCH] #567 - Add extra attributes to Link and use for HAL mediatype. Adds many additional attributes defined in RFC5988 and verifies they work properly in the neutral representation of Link while also being rendered properly in the HAL module. Related tickets: #100, #417, #235 Previous pull requests: #240, #238, #223, #79 --- .../org/springframework/hateoas/Link.java | 143 +++++++++--------- .../org/springframework/hateoas/Links.java | 5 +- .../hateoas/hal/LinkMixin.java | 39 ++++- ...actJackson2MarshallingIntegrationTest.java | 18 +++ .../springframework/hateoas/LinkUnitTest.java | 32 +++- .../hateoas/LinksUnitTest.java | 19 ++- .../hal/DefaultCurieProviderUnitTest.java | 16 +- .../hal/Jackson2HalIntegrationTest.java | 34 ++++- 8 files changed, 219 insertions(+), 87 deletions(-) diff --git a/src/main/java/org/springframework/hateoas/Link.java b/src/main/java/org/springframework/hateoas/Link.java index 44d69fa4..97e3c014 100755 --- a/src/main/java/org/springframework/hateoas/Link.java +++ b/src/main/java/org/springframework/hateoas/Link.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2016 the original author or authors. + * Copyright 2012-2017 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. @@ -15,6 +15,13 @@ */ package org.springframework.hateoas; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.Wither; + import java.io.Serializable; import java.util.Collections; import java.util.HashMap; @@ -37,9 +44,14 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; * Value object for links. * * @author Oliver Gierke + * @author Greg Turnquist */ @XmlType(name = "link", namespace = Link.ATOM_NAMESPACE) @JsonIgnoreProperties("templated") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PACKAGE) +@Getter +@EqualsAndHashCode(of = {"rel", "href", "hreflang", "media", "title", "deprecation"}) public class Link implements Serializable { private static final long serialVersionUID = -9037755944661782121L; @@ -53,8 +65,13 @@ public class Link implements Serializable { public static final String REL_NEXT = "next"; public static final String REL_LAST = "last"; - @XmlAttribute private String rel; - @XmlAttribute private String href; + @XmlAttribute @Wither private String rel; + @XmlAttribute @Wither private String href; + @XmlAttribute @Wither private String hreflang; + @XmlAttribute @Wither private String media; + @XmlAttribute @Wither private String title; + @XmlAttribute @Wither private String type; + @XmlAttribute @Wither private String deprecation; @XmlTransient @JsonIgnore private UriTemplate template; /** @@ -93,41 +110,6 @@ public class Link implements Serializable { this.rel = rel; } - /** - * Empty constructor required by the marshalling framework. - */ - protected Link() { - - } - - /** - * Returns the actual URI the link is pointing to. - * - * @return - */ - public String getHref() { - return href; - } - - /** - * Returns the rel of the link. - * - * @return - */ - public String getRel() { - return rel; - } - - /** - * Returns a {@link Link} pointing to the same URI but with the given relation. - * - * @param rel must not be {@literal null} or empty. - * @return - */ - public Link withRel(String rel) { - return new Link(href, rel); - } - /** * Returns a {@link Link} pointing to the same URI but with the {@code self} relation. * @@ -195,46 +177,35 @@ public class Link implements Serializable { return template; } - /* - * (non-Javadoc) - * @see java.lang.Object#equals(java.lang.Object) - */ - @Override - public boolean equals(Object obj) { - - if (this == obj) { - return true; - } - - if (!(obj instanceof Link)) { - return false; - } - - Link that = (Link) obj; - - return this.href.equals(that.href) && this.rel.equals(that.rel); - } - - /* - * (non-Javadoc) - * @see java.lang.Object#hashCode() - */ - @Override - public int hashCode() { - - int result = 17; - result += 31 * href.hashCode(); - result += 31 * rel.hashCode(); - return result; - } - - /* + /* * (non-Javadoc) * @see java.lang.Object#toString() */ @Override public String toString() { - return String.format("<%s>;rel=\"%s\"", href, rel); + String linkString = String.format("<%s>;rel=\"%s\"", href, rel); + + if (hreflang != null) { + linkString += ";hreflang=\"" + hreflang + "\""; + } + + if (media != null) { + linkString += ";media=\"" + media + "\""; + } + + if (title != null) { + linkString += ";title=\"" + title + "\""; + } + + if (type != null) { + linkString += ";type=\"" + type + "\""; + } + + if (deprecation != null) { + linkString += ";deprecation=\"" + deprecation + "\""; + } + + return linkString; } /** @@ -263,7 +234,29 @@ public class Link implements Serializable { 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")); + + if (attributes.containsKey("hreflang")) { + link = link.withHreflang(attributes.get("hreflang")); + } + + if (attributes.containsKey("media")) { + link = link.withMedia(attributes.get("media")); + } + + if (attributes.containsKey("title")) { + link = link.withTitle(attributes.get("title")); + } + + if (attributes.containsKey("type")) { + link = link.withType(attributes.get("type")); + } + + if (attributes.containsKey("deprecation")) { + link = link.withDeprecation(attributes.get("deprecation")); + } + + return link; } else { throw new IllegalArgumentException(String.format("Given link header %s is not RFC5988 compliant!", element)); @@ -283,7 +276,7 @@ public class Link implements Serializable { } Map attributes = new HashMap(); - Pattern keyAndValue = Pattern.compile("(\\w+)=\"(\\p{Lower}[\\p{Lower}\\p{Digit}\\.\\-]*|" + URI_PATTERN + ")\""); + Pattern keyAndValue = Pattern.compile("(\\w+)=\"(\\p{Lower}[\\p{Lower}\\p{Digit}\\.\\-\\s]*|" + URI_PATTERN + ")\""); Matcher matcher = keyAndValue.matcher(source); while (matcher.find()) { diff --git a/src/main/java/org/springframework/hateoas/Links.java b/src/main/java/org/springframework/hateoas/Links.java index df459f82..89fdae93 100644 --- a/src/main/java/org/springframework/hateoas/Links.java +++ b/src/main/java/org/springframework/hateoas/Links.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2016 the original author or authors. + * Copyright 2013-2017 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. @@ -29,10 +29,11 @@ import org.springframework.util.StringUtils; * Value object to represent a list of {@link Link}s. * * @author Oliver Gierke + * @author Greg Turnquist */ public class Links implements Iterable { - private static final Pattern LINK_HEADER_PATTERN = Pattern.compile("(<[^>]*>;rel=\"[^\"]*\")"); + private static final Pattern LINK_HEADER_PATTERN = Pattern.compile("(<[^>]*>(;\\w+=\"[^\"]*\")+)"); static final Links NO_LINKS = new Links(Collections. emptyList()); diff --git a/src/main/java/org/springframework/hateoas/hal/LinkMixin.java b/src/main/java/org/springframework/hateoas/hal/LinkMixin.java index 53f796eb..b7b9293c 100644 --- a/src/main/java/org/springframework/hateoas/hal/LinkMixin.java +++ b/src/main/java/org/springframework/hateoas/hal/LinkMixin.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2014 the original author or authors. + * Copyright 2012-2017 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. @@ -28,13 +28,46 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; * * @author Alexander Baetz * @author Oliver Gierke + * @author Greg Turnquist */ -@JsonIgnoreProperties(value = "rel") +@JsonIgnoreProperties({"rel", "media"}) abstract class LinkMixin extends Link { private static final long serialVersionUID = 4720588561299667409L; - /* + /* + * (non-Javadoc) + * @see org.springframework.hateoas.Link#getHreflang() + */ + @Override + @JsonInclude(Include.NON_NULL) + public abstract String getHreflang(); + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.Link#getTitle() + */ + @Override + @JsonInclude(Include.NON_NULL) + public abstract String getTitle(); + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.Link#getType() + */ + @Override + @JsonInclude(Include.NON_NULL) + public abstract String getType(); + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.Link#getDeprecation() + */ + @Override + @JsonInclude(Include.NON_NULL) + public abstract String getDeprecation(); + + /* * (non-Javadoc) * @see org.springframework.hateoas.Link#isTemplate() */ diff --git a/src/test/java/org/springframework/hateoas/AbstractJackson2MarshallingIntegrationTest.java b/src/test/java/org/springframework/hateoas/AbstractJackson2MarshallingIntegrationTest.java index df0133d6..49723e6a 100644 --- a/src/test/java/org/springframework/hateoas/AbstractJackson2MarshallingIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/AbstractJackson2MarshallingIntegrationTest.java @@ -1,3 +1,18 @@ +/* + * Copyright 2012-2017 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; import java.io.StringWriter; @@ -5,6 +20,7 @@ import java.io.Writer; import org.junit.Before; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; /** @@ -12,6 +28,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; * * @author Oliver Gierke * @author Jon Brisbin + * @author Greg Turnquist */ public abstract class AbstractJackson2MarshallingIntegrationTest { @@ -20,6 +37,7 @@ public abstract class AbstractJackson2MarshallingIntegrationTest { @Before public void setUp() { mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); } protected String write(Object object) throws Exception { diff --git a/src/test/java/org/springframework/hateoas/LinkUnitTest.java b/src/test/java/org/springframework/hateoas/LinkUnitTest.java index 727822f2..03c1efff 100644 --- a/src/test/java/org/springframework/hateoas/LinkUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinkUnitTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2016 the original author or authors. + * Copyright 2012-2017 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. @@ -28,6 +28,7 @@ import org.junit.Test; * Unit tests for {@link Link}. * * @author Oliver Gierke + * @author Greg Turnquist */ public class LinkUnitTest { @@ -103,21 +104,46 @@ public class LinkUnitTest { assertThat(Link.valueOf(""), is(nullValue())); } + /** + * @see #54 + * @see #100 + */ @Test 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(";rel=\"self\";hreflang=\"en\";media=\"pdf\";title=\"pdf customer copy\";type=\"portable document\";deprecation=\"http://example.com/customers/deprecated\""), + is(new Link("/customer/1") + .withHreflang("en") + .withMedia("pdf") + .withTitle("pdf customer copy") + .withType("portable document") + .withDeprecation("http://example.com/customers/deprecated"))); + } + + /** + * @see #100 + */ + @Test + public void ignoresUnrecognizedAttributes() { + Link link = Link.valueOf(";rel=\"foo\";unknown=\"should fail\""); + + assertThat(link.getHref(), is("/something")); + assertThat(link.getRel(), is("foo")); } @Test(expected = IllegalArgumentException.class) public void rejectsMissingRelAttribute() { - Link.valueOf(");title=\"title\""); + Link.valueOf(";title=\"title\""); } @Test(expected = IllegalArgumentException.class) public void rejectsLinkWithoutAttributesAtAll() { - Link.valueOf(");title=\"title\""); + + Link link = Link.valueOf(""); + + System.out.println(link); } @Test(expected = IllegalArgumentException.class) diff --git a/src/test/java/org/springframework/hateoas/LinksUnitTest.java b/src/test/java/org/springframework/hateoas/LinksUnitTest.java index b16004af..a9dba464 100644 --- a/src/test/java/org/springframework/hateoas/LinksUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinksUnitTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2016 the original author or authors. + * Copyright 2013-2017 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. @@ -27,6 +27,7 @@ import org.springframework.util.StringUtils; * Unit test for {@link Links}. * * @author Oliver Gierke + * @author Greg Turnquist */ public class LinksUnitTest { @@ -36,18 +37,27 @@ public class LinksUnitTest { static final String LINKS = StringUtils.collectionToCommaDelimitedString(Arrays.asList(FIRST, SECOND)); - static final Links reference = new Links(new Link("/something", "foo"), new Link("/somethingElse", "bar")); + static final String THIRD = ";rel=\"foo\";hreflang=\"en\""; + static final String FOURTH = ";rel=\"bar\";hreflang=\"de\""; + static final String LINKS2 = StringUtils.collectionToCommaDelimitedString(Arrays.asList(THIRD, FOURTH)); + + static final Links reference = new Links(new Link("/something", "foo"), new Link("/somethingElse", "bar")); + static final Links reference2 = new Links(new Link("/something", "foo").withHreflang("en"), new Link("/somethingElse", "bar").withHreflang("de")); + @Test public void parsesLinkHeaderLinks() { assertThat(Links.valueOf(LINKS), is(reference)); + assertThat(Links.valueOf(LINKS2), is(reference2)); assertThat(reference.toString(), is(LINKS)); + assertThat(reference2.toString(), is(LINKS2)); } @Test public void skipsEmptyLinkElements() { assertThat(Links.valueOf(LINKS + ",,,"), is(reference)); + assertThat(Links.valueOf(LINKS2 + ",,,"), is(reference2)); } @Test @@ -57,9 +67,14 @@ public class LinksUnitTest { assertThat(Links.valueOf(""), is(Links.NO_LINKS)); } + /** + * @see #54 + * @see #100 + */ @Test public void getSingleLinkByRel() { assertThat(reference.getLink("bar"), is(new Link("/somethingElse", "bar"))); + assertThat(reference2.getLink("bar"), is(new Link("/somethingElse", "bar").withHreflang("de"))); } /** diff --git a/src/test/java/org/springframework/hateoas/hal/DefaultCurieProviderUnitTest.java b/src/test/java/org/springframework/hateoas/hal/DefaultCurieProviderUnitTest.java index 5e20279d..ed9327f9 100644 --- a/src/test/java/org/springframework/hateoas/hal/DefaultCurieProviderUnitTest.java +++ b/src/test/java/org/springframework/hateoas/hal/DefaultCurieProviderUnitTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2016 the original author or authors. + * Copyright 2013-2017 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. @@ -35,6 +35,7 @@ import org.springframework.web.context.request.ServletRequestAttributes; * Unit tests for {@link DefaultCurieProvider}. * * @author Oliver Gierke + * @author Greg Turnquist */ public class DefaultCurieProviderUnitTest { @@ -82,6 +83,19 @@ public class DefaultCurieProviderUnitTest { assertThat(provider.getNamespacedRelFrom(new Link("http://amazon.com", "custom:rel")), is("custom:rel")); } + /** + * @see #100 + */ + @Test + public void prefixesNormalRelsThatHaveExtraRFC5988Attributes() { + assertThat(provider.getNamespacedRelFrom(new Link("http://amazon.com", "custom:rel") + .withHreflang("en") + .withTitle("the title") + .withMedia("the media") + .withType("the type") + .withDeprecation("http://example.com/custom/deprecated")), is("custom:rel")); + } + /** * @see #229 */ diff --git a/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java b/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java index df858c8d..25a4faa0 100644 --- a/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2015 the original author or authors. + * Copyright 2012-2017 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. @@ -27,6 +27,7 @@ import java.util.Locale; import org.junit.Before; import org.junit.Test; + import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.context.support.MessageSourceAccessor; @@ -51,6 +52,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; * * @author Alexander Baetz * @author Oliver Gierke + * @author Greg Turnquist */ public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationTest { @@ -77,6 +79,9 @@ public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingInteg static final String LINK_WITH_TITLE = "{\"_links\":{\"ns:foobar\":{\"href\":\"target\",\"title\":\"Foobar's title!\"}}}"; + static final String SINGLE_WITH_ONE_EXTRA_ATTRIBUTES = "{\"_links\":{\"self\":{\"href\":\"localhost\",\"title\":\"the title\"}}}"; + static final String SINGLE_WITH_ALL_EXTRA_ATTRIBUTES = "{\"_links\":{\"self\":{\"href\":\"localhost\",\"hreflang\":\"en\",\"title\":\"the title\",\"type\":\"the type\",\"deprecation\":\"/customers/deprecated\"}}}"; + @Before public void setUpModule() { @@ -96,6 +101,33 @@ public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingInteg assertThat(write(resourceSupport), is(SINGLE_LINK_REFERENCE)); } + /** + * @see #100 + */ + @Test + public void rendersAllExtraRFC5988Attributes() throws Exception { + + ResourceSupport resourceSupport = new ResourceSupport(); + resourceSupport.add(new Link("localhost", "self") + .withHreflang("en") + .withTitle("the title") + .withType("the type") + .withMedia("the media") + .withDeprecation("/customers/deprecated")); + + assertThat(write(resourceSupport), is(SINGLE_WITH_ALL_EXTRA_ATTRIBUTES)); + } + + @Test + public void rendersWithOneExtraRFC5988Attribute() throws Exception { + + ResourceSupport resourceSupport = new ResourceSupport(); + resourceSupport.add(new Link("localhost", "self") + .withTitle("the title")); + + assertThat(write(resourceSupport), is(SINGLE_WITH_ONE_EXTRA_ATTRIBUTES)); + } + @Test public void deserializeSingleLink() throws Exception { ResourceSupport expected = new ResourceSupport();