#346 - Enhance Traverson.follow() to customize individual hops.

Introduced a dedicated value object to abstract a Hop within a relation traversal. The Hop consists of a relation name and optional local template parameters that are applied to this particular hop when traversing it.

Changed the implementation of Traverson to work with Hops instead of plain rels and merges the global template parameters with the hop-local ones before expanding the templates found.

Original pull request: #350.
This commit is contained in:
Greg Turnquist
2015-05-20 12:31:46 -05:00
committed by Oliver Gierke
parent 96166f9ede
commit 2c1797d9b7
12 changed files with 403 additions and 14 deletions

View File

@@ -297,6 +297,7 @@
<allow-uri-read>true</allow-uri-read>
<toclevels>3</toclevels>
<numbered>true</numbered>
<baseDir>${project.basedir}</baseDir>
</attributes>
</configuration>

View File

@@ -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

View File

@@ -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<String, String> params = new HashMap<String, String>();
/**
* 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<String, String> 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<String, Object> getMergedParameteres(Map<String, Object> globalParameters) {
Map<String, Object> mergedParameters = new HashMap<String, Object>();
mergedParameters.putAll(globalParameters);
mergedParameters.putAll(this.params);
return mergedParameters;
}
}

View File

@@ -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;
}
}
/**

View File

@@ -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<String> rels = new ArrayList<String>();
private List<Hop> rels = new ArrayList<Hop>();
private Map<String, Object> templateParameters = new HashMap<String, Object>();
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<String> rels) {
private String getAndFindLinkWithRel(String uri, Iterator<Hop> rels) {
if (!rels.hasNext()) {
return uri;
}
HttpEntity<?> request = prepareRequest(headers);
UriTemplate uriTemplate = new UriTemplate(uri);
ResponseEntity<String> responseEntity = operations.exchange(uriTemplate.expand(templateParameters), GET, request,
String.class);
ResponseEntity<String> 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);
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<Link, Link> baseResources = new LinkedMultiValueMap<Link, Link>();
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() {

View File

@@ -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<Resource<Actor>> typeReference = new ParameterizedTypeReference<Resource<Actor>>() {};
Resource<Actor> 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<Resource<Item>> resourceParameterizedTypeReference =
new ParameterizedTypeReference<Resource<Item>>() {};
Resource<Item> 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<Resource<Item>> resourceParameterizedTypeReference =
new ParameterizedTypeReference<Resource<Item>>() {};
Map<String, String> params = new HashMap<String, String>();
params.put("projection", "noImages");
Resource<Item> 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<String, Object> params = new HashMap<String, Object>();
params.put("projection", "thisShouldGetOverwrittenByLocalHop");
ParameterizedTypeReference<Resource<Item>> resourceParameterizedTypeReference =
new ParameterizedTypeReference<Resource<Item>>() {};
Resource<Item> 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> actor = new Resource<Actor>(new Actor("Keanu Reaves"));

View File

@@ -0,0 +1,9 @@
{
"description" : "cat",
"_links" : {
"self" : {
"href" : "%s/springagram/items/1{?projection}",
"templated" : true
}
}
}

View File

@@ -0,0 +1,10 @@
{
"image" : "%s/springagram/file/cat",
"description" : "cat",
"_links" : {
"self" : {
"href" : "%s/springagram/items/1{?projection}",
"templated" : true
}
}
}

View File

@@ -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
}
}
} ]
}
}

View File

@@ -0,0 +1,11 @@
{
"_links" : {
"items" : {
"href" : "%s/springagram/items{?projection}",
"templated" : true
},
"profile" : {
"href" : "%s/springagram/alps"
}
}
}