diff --git a/pom.xml b/pom.xml index 8dcc2de5..0571be5f 100644 --- a/pom.xml +++ b/pom.xml @@ -297,6 +297,7 @@ true 3 true + ${project.basedir} diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index abfb1f31..5a73e433 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -1,5 +1,5 @@ = Spring HATEOAS - Reference Documentation -Oliver Gierke; +Oliver Gierke, Greg Turnquist; :revnumber: {version} :revdate: {localdate} :toc: @@ -355,7 +355,7 @@ Since the purpose of the `CurieProvider` API is to allow for automatic curie cre [[client.traverson]] === Traverson -As of version 0.11 Spring HATEOAS provides an API for client side service traversal inspired by the [Traverson](https://blog.codecentric.de/en/2013/11/traverson/) JavaScript library. +As of version 0.11 Spring HATEOAS provides an API for client side service traversal inspired by the https://blog.codecentric.de/en/2013/11/traverson/[Traverson JavaScript library]. [source, java] ---- @@ -372,6 +372,26 @@ You set up a `Traverson` instance by pointing it to a REST server and configure The sample then hands a parameter map into the execution. The parameters will be used to expand URIs found during the traversal that are templated. The traversal is concluded by accessing the representation of the final traversal. In the case of the sample we evaluate a JSONPath expression to access the actor's name. +The example listed above is the simplest version of traversal, where the rels are strings, and at each hop, the same template parameters are applied. + +There are more options to customize template parameters at each level. + +[source,java,indent=0] +---- +include::{baseDir}/src/test/java/org/springframework/hateoas/client/TraversonTests.java[tag=hop-with-param] +---- + +The `.withParam(key, value)` make it simple to add URI Template variables. + +[source,java,indent=0] +---- +include::{baseDir}/src/test/java/org/springframework/hateoas/client/TraversonTests.java[tag=hop-put] +---- + +It's also possible to load an entire `Map` of parameters via `.withParams(Map)`. + +NOTE: `follow()` is chainable, meaning you can string together multiple hops as shown above. You can either put multiple, simple string-based rels (`follow("items", "item")`) or a single hop with specific parameters. + [[client.link-discoverer]] === LinkDiscoverers diff --git a/src/main/java/org/springframework/hateoas/client/Hop.java b/src/main/java/org/springframework/hateoas/client/Hop.java new file mode 100644 index 00000000..c587ac66 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/client/Hop.java @@ -0,0 +1,77 @@ +/* + * Copyright 2015 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.client; + +import java.util.HashMap; +import java.util.Map; + +import lombok.Value; + +/** + * Container for cuztomizations to a single traverson "hop" + * + * @author Greg Turnquist + */ +@Value(staticConstructor="rel") +public class Hop { + + /** + * Name of this hop. + */ + private final String rel; + + /** + * Collection of URI Template parameters. + */ + private final Map params = new HashMap(); + + /** + * Add one parameter to the map of parameters. + * + * @param name + * @param value + * @return fluent DSL (Lombok @Wither didn't seem to work) + */ + public Hop withParam(String name, String value) { + this.params.put(name, value); + return this; + } + + /** + * Add a collection of parameters (does NOT clear out existing ones). + * + * @param newParams + * @return fluent DSL (Lombok @Wither didn't seem to work) + */ + public Hop withParams(Map newParams) { + this.params.putAll(newParams); + return this; + } + + /** + * Create a new {@link Map} starting with the supplied template parameters. Then add the ones for this hop. This + * allows a local hop to override global parameters. + * + * @param globalParameters + * @return a merged map of URI Template parameters + */ + public Map getMergedParameteres(Map globalParameters) { + Map mergedParameters = new HashMap(); + mergedParameters.putAll(globalParameters); + mergedParameters.putAll(this.params); + return mergedParameters; + } +} diff --git a/src/main/java/org/springframework/hateoas/client/Rels.java b/src/main/java/org/springframework/hateoas/client/Rels.java index 82c45b82..74b4ff5e 100644 --- a/src/main/java/org/springframework/hateoas/client/Rels.java +++ b/src/main/java/org/springframework/hateoas/client/Rels.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2014 the original author or authors. + * Copyright 2013-2015 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 com.jayway.jsonpath.JsonPath; * Helper class to find {@link Link} instances in representations. * * @author Oliver Gierke + * @author Greg Turnquist * @since 0.11 */ class Rels { @@ -103,6 +104,16 @@ class Rels { return discoverer.findLinkWithRel(rel, response); } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return this.rel; + } + } /** diff --git a/src/main/java/org/springframework/hateoas/client/Traverson.java b/src/main/java/org/springframework/hateoas/client/Traverson.java index 95a979f8..02939a34 100644 --- a/src/main/java/org/springframework/hateoas/client/Traverson.java +++ b/src/main/java/org/springframework/hateoas/client/Traverson.java @@ -58,6 +58,7 @@ import com.jayway.jsonpath.JsonPath; * @see https://github.com/basti1302/traverson * @author Oliver Gierke * @author Dietrich Schulten + * @author Greg Turnquist * @since 0.11 */ public class Traverson { @@ -201,6 +202,16 @@ public class Traverson { return new TraversalBuilder().follow(rels); } + /** + * Sets up a {@link TraversalBuilder} for a single rel with customized details. + * + * @param hop must not be {@literal null} + * @return + */ + public TraversalBuilder follow(Hop hop) { + return new TraversalBuilder().follow(hop); + } + private HttpEntity prepareRequest(HttpHeaders headers) { HttpHeaders toSend = new HttpHeaders(); @@ -220,7 +231,7 @@ public class Traverson { */ public class TraversalBuilder { - private List rels = new ArrayList(); + private List rels = new ArrayList(); private Map templateParameters = new HashMap(); private HttpHeaders headers = new HttpHeaders(); @@ -233,11 +244,27 @@ public class Traverson { * @param rels must not be {@literal null}. * @return */ - private TraversalBuilder follow(String... rels) { + public TraversalBuilder follow(String... rels) { Assert.notNull(rels, "Rels must not be null!"); - this.rels.addAll(Arrays.asList(rels)); + for (String rel : rels) { + this.rels.add(Hop.rel(rel)); + } + return this; + } + + /** + * Follows the given rels one by one, which means a request per rel to discover the next resource with the rel in + * line. + * + * @param hop must not be {@literal null} + * @return + */ + public TraversalBuilder follow(Hop hop) { + + Assert.notNull(hop, "Hop must not be null!"); + this.rels.add(hop); return this; } @@ -344,7 +371,7 @@ public class Traverson { private Link traverseToLink(boolean expandFinalUrl) { Assert.isTrue(rels.size() > 0, "At least one rel needs to be provided!"); - return new Link(traverseToFinalUrl(expandFinalUrl), rels.get(rels.size() - 1)); + return new Link(traverseToFinalUrl(expandFinalUrl), rels.get(rels.size() - 1).getRel()); } private String traverseToFinalUrl(boolean expandFinalUrl) { @@ -354,21 +381,21 @@ public class Traverson { return expandFinalUrl ? uriTemplate.expand(templateParameters).toString() : uriTemplate.toString(); } - private String getAndFindLinkWithRel(String uri, Iterator rels) { + private String getAndFindLinkWithRel(String uri, Iterator rels) { if (!rels.hasNext()) { return uri; } HttpEntity request = prepareRequest(headers); - UriTemplate uriTemplate = new UriTemplate(uri); - ResponseEntity responseEntity = operations.exchange(uriTemplate.expand(templateParameters), GET, request, - String.class); + ResponseEntity responseEntity = operations.exchange(uri, GET, request, String.class); MediaType contentType = responseEntity.getHeaders().getContentType(); String responseBody = responseEntity.getBody(); - Rel rel = Rels.getRelFor(rels.next(), discoverers); + Hop thisHop = rels.next(); + + Rel rel = Rels.getRelFor(thisHop.getRel(), discoverers); Link link = rel.findInResponse(responseBody, contentType); if (link == null) { @@ -376,7 +403,14 @@ public class Traverson { responseBody)); } - return getAndFindLinkWithRel(link.getHref(), rels); + /** + * Don't expand if the parameters are empty + */ + if (thisHop.getParams().isEmpty()) { + return getAndFindLinkWithRel(link.getHref(), rels); + } else { + return getAndFindLinkWithRel(link.expand(thisHop.getMergedParameteres(templateParameters)).getHref(), rels); + } } } } diff --git a/src/test/java/org/springframework/hateoas/client/Item.java b/src/test/java/org/springframework/hateoas/client/Item.java new file mode 100644 index 00000000..99165d5d --- /dev/null +++ b/src/test/java/org/springframework/hateoas/client/Item.java @@ -0,0 +1,31 @@ +/* + * Copyright 2015 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.client; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) +public class Item { + + String image; + String description; + + Item(@JsonProperty("image") String image, @JsonProperty("description") String description) { + this.image = image; + this.description = description; + } +} diff --git a/src/test/java/org/springframework/hateoas/client/Server.java b/src/test/java/org/springframework/hateoas/client/Server.java index aca407cf..b6f99158 100644 --- a/src/test/java/org/springframework/hateoas/client/Server.java +++ b/src/test/java/org/springframework/hateoas/client/Server.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2014 the original author or authors. + * Copyright 2013-2015 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,6 +16,7 @@ package org.springframework.hateoas.client; import static net.jadler.Jadler.*; +import static org.hamcrest.Matchers.*; import java.io.Closeable; import java.io.IOException; @@ -23,6 +24,8 @@ import java.nio.charset.Charset; import java.util.Collections; import java.util.UUID; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.ResourceLoader; import org.springframework.hateoas.Link; import org.springframework.hateoas.MediaTypes; import org.springframework.hateoas.RelProvider; @@ -33,6 +36,7 @@ import org.springframework.hateoas.hal.Jackson2HalModule; import org.springframework.http.MediaType; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StreamUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -41,6 +45,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; * Helper class for integration tests. * * @author Oliver Gierke + * @author Greg Turnquist */ public class Server implements Closeable { @@ -48,6 +53,7 @@ public class Server implements Closeable { private final RelProvider relProvider; private final MultiValueMap baseResources = new LinkedMultiValueMap(); + private final ResourceLoader resourceLoader = new DefaultResourceLoader(); public Server() { @@ -89,6 +95,58 @@ public class Server implements Closeable { respond(). // withBody("{ \"_links\" : { \"self\" : { \"href\" : \"/{?template}\" }}}"). // withContentType(MediaTypes.HAL_JSON.toString()); + + // Sample traversal of HAL docs based on Spring-a-Gram showcase + org.springframework.core.io.Resource springagramRoot = resourceLoader.getResource("classpath:springagram-root.json"); + org.springframework.core.io.Resource springagramItems = resourceLoader.getResource("classpath:springagram-items.json"); + org.springframework.core.io.Resource springagramItem = resourceLoader.getResource("classpath:springagram-item.json"); + org.springframework.core.io.Resource springagramItemWithoutImage = resourceLoader.getResource("classpath:springagram-item-without-image.json"); + + String springagramRootTemplate; + String springagramItemsTemplate; + String springagramItemTemplate; + String springagramItemWithoutImageTemplate; + + try { + springagramRootTemplate = StreamUtils.copyToString(springagramRoot.getInputStream(), Charset.forName("UTF-8")); + springagramItemsTemplate = StreamUtils.copyToString(springagramItems.getInputStream(), Charset.forName("UTF-8")); + springagramItemTemplate = StreamUtils.copyToString(springagramItem.getInputStream(), Charset.forName("UTF-8")); + springagramItemWithoutImageTemplate = StreamUtils.copyToString(springagramItemWithoutImage.getInputStream(), + Charset.forName("UTF-8")); + } catch (IOException e) { + throw new RuntimeException(e); + } + + String springagramRootHalDocument = String.format(springagramRootTemplate, rootResource(), rootResource()); + String springagramItemsHalDocument = String.format(springagramItemsTemplate, rootResource(), rootResource(), rootResource()); + String springagramItemHalDocument = String.format(springagramItemTemplate, rootResource(), rootResource()); + String springagramItemWithoutImageHalDocument = String.format(springagramItemWithoutImageTemplate, rootResource()); + + onRequest(). // + havingPathEqualTo("/springagram"). // + respond(). // + withBody(springagramRootHalDocument). // + withContentType(MediaTypes.HAL_JSON.toString()); + + onRequest(). // + havingPathEqualTo("/springagram/items"). // + havingQueryString(equalTo("projection=noImages")). // + respond(). // + withBody(springagramItemsHalDocument). // + withContentType(MediaTypes.HAL_JSON.toString()); + + onRequest(). // + havingPathEqualTo("/springagram/items/1"). // + respond(). // + withBody(springagramItemHalDocument). // + withContentType(MediaTypes.HAL_JSON.toString()); + + onRequest(). // + havingPathEqualTo("/springagram/items/1"). // + havingQueryString(equalTo("projection=noImages")). // + respond(). // + withBody(springagramItemWithoutImageHalDocument). // + withContentType(MediaTypes.HAL_JSON.toString()); } public String rootResource() { diff --git a/src/test/java/org/springframework/hateoas/client/TraversonTests.java b/src/test/java/org/springframework/hateoas/client/TraversonTests.java index 00c4ab27..19bfee46 100644 --- a/src/test/java/org/springframework/hateoas/client/TraversonTests.java +++ b/src/test/java/org/springframework/hateoas/client/TraversonTests.java @@ -18,16 +18,20 @@ package org.springframework.hateoas.client; import static net.jadler.Jadler.*; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; +import static org.springframework.hateoas.client.Hop.*; import java.io.IOException; import java.net.URI; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.After; import org.junit.Before; import org.junit.Test; + import org.springframework.core.ParameterizedTypeReference; import org.springframework.hateoas.Link; import org.springframework.hateoas.MediaTypes; @@ -49,6 +53,7 @@ import org.springframework.web.client.RestTemplate; * Integration tests for {@link Traverson}. * * @author Oliver Gierke + * @author Greg Turnquist * @since 0.11 */ public class TraversonTests { @@ -260,6 +265,101 @@ public class TraversonTests { assertThat(converters.get(0), is(instanceOf(StringHttpMessageConverter.class))); } + /** + * @see #346 + */ + @Test + public void chainMultipleFollowOperations() { + + ParameterizedTypeReference> typeReference = new ParameterizedTypeReference>() {}; + Resource result = traverson.follow("movies") + .follow("movie") + .follow("actor").toObject(typeReference); + + assertThat(result.getContent().name, is("Keanu Reaves")); + } + + /** + * @see #346 + */ + @Test + public void allowAlteringTheDetailsOfASingleHop() { + + this.traverson = new Traverson(URI.create(server.rootResource() + "/springagram"), MediaTypes.HAL_JSON); + + // tag::hop-with-param[] + ParameterizedTypeReference> resourceParameterizedTypeReference = + new ParameterizedTypeReference>() {}; + + Resource itemResource = traverson + .follow(rel("items").withParam("projection", "noImages")) + .follow("$._embedded.items[0]._links.self.href") + .toObject(resourceParameterizedTypeReference); + // end::hop-with-param[] + + assertThat(itemResource.hasLink("self"), is(true)); + assertThat(itemResource.getLink("self").expand().getHref(), equalTo(server.rootResource() + "/springagram/items/1")); + + final Item item = itemResource.getContent(); + assertThat(item.image, equalTo(server.rootResource() + "/springagram/file/cat")); + assertThat(item.description, equalTo("cat")); + } + + /** + * @see #346 + */ + @Test + public void allowAlteringTheDetailsOfASingleHopByMapOperations() { + + this.traverson = new Traverson(URI.create(server.rootResource() + "/springagram"), MediaTypes.HAL_JSON); + + // tag::hop-put[] + ParameterizedTypeReference> resourceParameterizedTypeReference = + new ParameterizedTypeReference>() {}; + + Map params = new HashMap(); + params.put("projection", "noImages"); + + Resource itemResource = traverson + .follow(rel("items").withParams(params)) + .follow("$._embedded.items[0]._links.self.href") + .toObject(resourceParameterizedTypeReference); + // end::hop-put[] + + assertThat(itemResource.hasLink("self"), is(true)); + assertThat(itemResource.getLink("self").expand().getHref(), equalTo(server.rootResource() + "/springagram/items/1")); + + final Item item = itemResource.getContent(); + assertThat(item.image, equalTo(server.rootResource() + "/springagram/file/cat")); + assertThat(item.description, equalTo("cat")); + } + + /** + * @see #346 + */ + @Test + public void allowGlobalsToImpactSingleHops() { + + this.traverson = new Traverson(URI.create(server.rootResource() + "/springagram"), MediaTypes.HAL_JSON); + + Map params = new HashMap(); + params.put("projection", "thisShouldGetOverwrittenByLocalHop"); + + ParameterizedTypeReference> resourceParameterizedTypeReference = + new ParameterizedTypeReference>() {}; + Resource itemResource = traverson.follow(rel("items").withParam("projection", "noImages")) + .follow("$._embedded.items[0]._links.self.href") // retrieve first Item in the collection + .withTemplateParameters(params) + .toObject(resourceParameterizedTypeReference); + + assertThat(itemResource.hasLink("self"), is(true)); + assertThat(itemResource.getLink("self").expand().getHref(), equalTo(server.rootResource() + "/springagram/items/1")); + + final Item item = itemResource.getContent(); + assertThat(item.image, equalTo(server.rootResource() + "/springagram/file/cat")); + assertThat(item.description, equalTo("cat")); + } + private void setUpActors() { Resource actor = new Resource(new Actor("Keanu Reaves")); diff --git a/src/test/resources/springagram-item-without-image.json b/src/test/resources/springagram-item-without-image.json new file mode 100644 index 00000000..b7803097 --- /dev/null +++ b/src/test/resources/springagram-item-without-image.json @@ -0,0 +1,9 @@ +{ + "description" : "cat", + "_links" : { + "self" : { + "href" : "%s/springagram/items/1{?projection}", + "templated" : true + } + } +} diff --git a/src/test/resources/springagram-item.json b/src/test/resources/springagram-item.json new file mode 100644 index 00000000..51f7b6cb --- /dev/null +++ b/src/test/resources/springagram-item.json @@ -0,0 +1,10 @@ +{ + "image" : "%s/springagram/file/cat", + "description" : "cat", + "_links" : { + "self" : { + "href" : "%s/springagram/items/1{?projection}", + "templated" : true + } + } +} diff --git a/src/test/resources/springagram-items.json b/src/test/resources/springagram-items.json new file mode 100644 index 00000000..1ddc91ea --- /dev/null +++ b/src/test/resources/springagram-items.json @@ -0,0 +1,27 @@ +{ + "_links" : { + "self" : { + "href" : "%s/springagram/items{?projection}", + "templated" : true + } + }, + "_embedded" : { + "items" : [ { + "description" : "cat", + "_links" : { + "self" : { + "href" : "%s/springagram/items/1{?projection}", + "templated" : true + } + } + }, { + "description" : "caterpillar", + "_links" : { + "self" : { + "href" : "%s/springagram/items/2{?projection}", + "templated" : true + } + } + } ] + } +} diff --git a/src/test/resources/springagram-root.json b/src/test/resources/springagram-root.json new file mode 100644 index 00000000..745e289b --- /dev/null +++ b/src/test/resources/springagram-root.json @@ -0,0 +1,11 @@ +{ + "_links" : { + "items" : { + "href" : "%s/springagram/items{?projection}", + "templated" : true + }, + "profile" : { + "href" : "%s/springagram/alps" + } + } +}