From d152bcb7d1a973d855254178ac06264cab457a78 Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Wed, 20 Feb 2013 14:38:56 +0100 Subject: [PATCH] #54 - Added support for RFC5988 link headers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced new Links container element to group a list of links and allow parsing an RFC5988 compatible String representation of links into it. The parsing mechanism is currently regarding the rel attribute only. Added valueOf(…) factory methods to allow Spring MVC to pick the types up out of the box. --- .../org/springframework/hateoas/Link.java | 69 +++++++- .../org/springframework/hateoas/Links.java | 163 ++++++++++++++++++ .../springframework/hateoas/LinkUnitTest.java | 31 +++- .../hateoas/LinksUnitTest.java | 63 +++++++ 4 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/springframework/hateoas/Links.java create mode 100644 src/test/java/org/springframework/hateoas/LinksUnitTest.java diff --git a/src/main/java/org/springframework/hateoas/Link.java b/src/main/java/org/springframework/hateoas/Link.java index d77ba757..6cd925ec 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 the original author or authors. + * Copyright 2012-2013 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. @@ -16,11 +16,17 @@ package org.springframework.hateoas; import java.io.Serializable; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.xml.bind.annotation.XmlAttribute; import javax.xml.bind.annotation.XmlType; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * Value object for links. @@ -115,7 +121,8 @@ public class Link implements Serializable { return this.href.equals(that.href) && this.rel.equals(that.rel); } - /* (non-Javadoc) + /* + * (non-Javadoc) * @see java.lang.Object#hashCode() */ @Override @@ -133,6 +140,62 @@ public class Link implements Serializable { */ @Override public String toString() { - return String.format("{ rel : %s, href : %s }", rel, href); + return String.format("<%s>;rel=\"%s\"", href, rel); + } + + /** + * Factory method to easily create {@link Link} instances from RFC-5988 compatible {@link String} representations of a + * link. Will return {@literal null} if an empty or {@literal null} {@link String} is given. + * + * @param element an RFC-5899 compatible representation of a link. + * @throws IllegalArgumentException if a non-empty {@link String} was given that does not adhere to RFC-5899. + * @throws IllegalArgumentException if no {@code rel} attribute could be found. + * @return + */ + public static Link valueOf(String element) { + + if (!StringUtils.hasText(element)) { + return null; + } + + Pattern uriAndAttributes = Pattern.compile("<(.*)>;(.*)"); + Matcher matcher = uriAndAttributes.matcher(element); + + if (matcher.find()) { + + Map attributes = getAttributeMap(matcher.group(2)); + + if (!attributes.containsKey("rel")) { + throw new IllegalArgumentException("Link does not provide a rel attribute!"); + } + + return new Link(matcher.group(1), attributes.get("rel")); + + } else { + throw new IllegalArgumentException(String.format("Given link header %s is not RFC5988 compliant!", element)); + } + } + + /** + * Parses the links attributes from the given source {@link String}. + * + * @param source + * @return + */ + private static Map getAttributeMap(String source) { + + if (!StringUtils.hasText(source)) { + return Collections.emptyMap(); + } + + 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)); + } + + return attributes; } } diff --git a/src/main/java/org/springframework/hateoas/Links.java b/src/main/java/org/springframework/hateoas/Links.java new file mode 100644 index 00000000..51a27c07 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/Links.java @@ -0,0 +1,163 @@ +/* + * Copyright 2013 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.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import org.springframework.util.StringUtils; + +/** + * Value object to represent a list of {@link Link}s. + * + * @author Oliver Gierke + */ +public class Links implements Iterable { + + static final Links NO_LINKS = new Links(Collections. emptyList()); + + private final List links; + + /** + * Creates a new {@link Links} instance from the given {@link Link}s. + * + * @param links + */ + public Links(List links) { + this.links = links == null ? Collections. emptyList() : Collections.unmodifiableList(links); + } + + /** + * Creates a new {@link Links} instance from the given {@link Link}s. + * + * @param links + */ + public Links(Link... links) { + this(Arrays.asList(links)); + } + + /** + * Returns the {@link Link} with the given rel. + * + * @param rel the relation type to lookup a link for. + * @return the {@link Link} with the given rel or {@literal null} if none found. + */ + public Link getLink(String rel) { + + for (Link link : links) { + if (link.getRel().equals(rel)) { + return link; + } + } + + return null; + } + + /** + * Returns all {@link Links} with the given relation type. + * + * @return the links + */ + public List getLinks(String rel) { + + List result = new ArrayList(); + + for (Link link : links) { + if (link.getRel().endsWith(rel)) { + result.add(link); + } + } + + return result; + } + + /** + * Creates a {@link Links} instance from the given RFC5988-compatible link format. + * + * @param source a comma separated list of {@link Link} representations. + * @return the {@link Links} represented by the given {@link String}. + */ + public static Links valueOf(String source) { + + if (!StringUtils.hasText(source)) { + return NO_LINKS; + } + + String[] elements = source.split(","); + List links = new ArrayList(elements.length); + + for (String element : elements) { + + Link link = Link.valueOf(element); + + if (link != null) { + links.add(link); + } + } + + return new Links(links); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return StringUtils.collectionToCommaDelimitedString(links); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return links.iterator(); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object arg0) { + + if (!(arg0 instanceof Links)) { + return false; + } + + Links that = (Links) arg0; + + return this.links.equals(that.links); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + + int result = 17; + result += 31 * links.hashCode(); + + return result; + } +} diff --git a/src/test/java/org/springframework/hateoas/LinkUnitTest.java b/src/test/java/org/springframework/hateoas/LinkUnitTest.java index 4a4d8a5a..1adfc2d0 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 the original author or authors. + * Copyright 2012-2013 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. @@ -91,4 +91,33 @@ public class LinkUnitTest { public void differentTypeDoesNotEqual() { assertThat(new Link("foo"), is(not((Object) new ResourceSupport()))); } + + @Test + public void returnsNullForNullOrEmptyLink() { + + assertThat(Link.valueOf(null), is(nullValue())); + assertThat(Link.valueOf(""), is(nullValue())); + } + + @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"))); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsMissingRelAttribute() { + Link.valueOf(");title=\"title\""); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsLinkWithoutAttributesAtAll() { + Link.valueOf(");title=\"title\""); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNonRFC5988String() { + Link.valueOf("foo"); + } } diff --git a/src/test/java/org/springframework/hateoas/LinksUnitTest.java b/src/test/java/org/springframework/hateoas/LinksUnitTest.java new file mode 100644 index 00000000..7c6edadd --- /dev/null +++ b/src/test/java/org/springframework/hateoas/LinksUnitTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2013 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 static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import java.util.Arrays; + +import org.junit.Test; +import org.springframework.util.StringUtils; + +/** + * Unit test for {@link Links}. + * + * @author Oliver Gierke + */ +public class LinksUnitTest { + + static final String FIRST = ";rel=\"foo\""; + static final String SECOND = ";rel=\"bar\""; + + static final String LINKS = StringUtils.collectionToCommaDelimitedString(Arrays.asList(FIRST, SECOND)); + + static final Links reference = new Links(new Link("/something", "foo"), new Link("/somethingElse", "bar")); + + @Test + public void parsesLinkHeaderLinks() { + + assertThat(Links.valueOf(LINKS), is(reference)); + assertThat(reference.toString(), is(LINKS)); + } + + @Test + public void skipsEmptyLinkElements() { + assertThat(Links.valueOf(LINKS + ",,,"), is(reference)); + } + + @Test + public void returnsNullForNullOrEmptySource() { + + assertThat(Links.valueOf(null), is(Links.NO_LINKS)); + assertThat(Links.valueOf(""), is(Links.NO_LINKS)); + } + + @Test + public void getSingleLinkByRel() { + assertThat(reference.getLink("bar"), is(new Link("/somethingElse", "bar"))); + } +}