#131 - Add Traverson hypermedia client API.
Introduce Traverson as means to traverse hypermedia based APIs by providing a set of relation names to discover and follow to access the final representation. Traverson allows to specify a parameter map that will be used to potentially expand the URIs discovered. We currently support LinkDiscoverer based relation discovery, which means that relations in HAL representations can be found by simply stating their names. Alternatively JSON path expressions can be used (starting with a $).
This commit is contained in:
134
src/main/java/org/springframework/hateoas/client/Rels.java
Normal file
134
src/main/java/org/springframework/hateoas/client/Rels.java
Normal file
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2013-2014 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 org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.LinkDiscoverer;
|
||||
import org.springframework.hateoas.LinkDiscoverers;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
|
||||
/**
|
||||
* Helper class to find {@link Link} instances in representations.
|
||||
*
|
||||
* @author Oliver Gierke
|
||||
* @since 0.11
|
||||
*/
|
||||
class Rels {
|
||||
|
||||
/**
|
||||
* Creates a new {@link Rel} for the given relation name and {@link LinkDiscoverers}.
|
||||
*
|
||||
* @param rel must not be {@literal null} or empty.
|
||||
* @param discoverers must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public static Rel getRelFor(String rel, LinkDiscoverers discoverers) {
|
||||
|
||||
Assert.hasText(rel, "Relation name must not be null!");
|
||||
Assert.notNull(discoverers, "LinkDiscoverers must not be null!");
|
||||
|
||||
if (rel.startsWith("$")) {
|
||||
return new JsonPathRel(rel);
|
||||
}
|
||||
|
||||
return new LinkDiscovererRel(rel, discoverers);
|
||||
}
|
||||
|
||||
public interface Rel {
|
||||
|
||||
/**
|
||||
* Returns the link contained in the given representation of the given {@link MediaType}.
|
||||
*
|
||||
* @param representation
|
||||
* @param mediaType
|
||||
* @return
|
||||
*/
|
||||
Link findInResponse(String representation, MediaType mediaType);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link Rel} to using a {@link LinkDiscoverer} based on the given {@link MediaType}.
|
||||
*
|
||||
* @author Oliver Gierke
|
||||
*/
|
||||
private static class LinkDiscovererRel implements Rel {
|
||||
|
||||
private final String rel;
|
||||
private final LinkDiscoverers discoverers;
|
||||
|
||||
/**
|
||||
* Creates a new {@link LinkDiscovererRel} for the given relation name and {@link LinkDiscoverers}.
|
||||
*
|
||||
* @param rel must not be {@literal null} or empty.
|
||||
* @param discoverers must not be {@literal null}.
|
||||
*/
|
||||
private LinkDiscovererRel(String rel, LinkDiscoverers discoverers) {
|
||||
|
||||
Assert.hasText(rel, "Rel must not be null or empty!");
|
||||
Assert.notNull(discoverers, "LinkDiscoverers must not be null!");
|
||||
|
||||
this.rel = rel;
|
||||
this.discoverers = discoverers;
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.client.Rels.Rel#findInResponse(java.lang.String, org.springframework.http.MediaType)
|
||||
*/
|
||||
@Override
|
||||
public Link findInResponse(String response, MediaType mediaType) {
|
||||
return discoverers.getLinkDiscovererFor(mediaType).findLinkWithRel(rel, response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A relation that's being looked up by a JSONPath expression.
|
||||
*
|
||||
* @author Oliver Gierke
|
||||
*/
|
||||
private static class JsonPathRel implements Rel {
|
||||
|
||||
private final String jsonPath;
|
||||
private final String rel;
|
||||
|
||||
/**
|
||||
* Creates a new {@link JsonPathRel} for the given JSON path.
|
||||
*
|
||||
* @param jsonPath must not be {@literal null} or empty.
|
||||
*/
|
||||
private JsonPathRel(String jsonPath) {
|
||||
|
||||
Assert.hasText(jsonPath, "JSON path must not be null or empty!");
|
||||
|
||||
this.jsonPath = jsonPath;
|
||||
|
||||
String lastSegment = jsonPath.substring(jsonPath.lastIndexOf('.'));
|
||||
this.rel = lastSegment.contains("[") ? lastSegment.substring(0, lastSegment.indexOf("[")) : lastSegment;
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.client.Rels.Rel#findInResponse(java.lang.String, org.springframework.http.MediaType)
|
||||
*/
|
||||
@Override
|
||||
public Link findInResponse(String representation, MediaType mediaType) {
|
||||
return new Link(JsonPath.<Object> read(representation, jsonPath).toString(), rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
272
src/main/java/org/springframework/hateoas/client/Traverson.java
Normal file
272
src/main/java/org/springframework/hateoas/client/Traverson.java
Normal file
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright 2013-2014 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 static org.springframework.http.HttpMethod.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.LinkDiscoverer;
|
||||
import org.springframework.hateoas.LinkDiscoverers;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.UriTemplate;
|
||||
import org.springframework.hateoas.client.Rels.Rel;
|
||||
import org.springframework.hateoas.hal.HalLinkDiscoverer;
|
||||
import org.springframework.hateoas.hal.Jackson2HalModule;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.plugin.core.OrderAwarePluginRegistry;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
|
||||
/**
|
||||
* Component to ease traversing hypermedia APIs by following links with relation types. Highly inspired by the equally
|
||||
* named JavaScript library.
|
||||
*
|
||||
* @see https://github.com/basti1302/traverson
|
||||
* @author Oliver Gierke
|
||||
* @since 0.11
|
||||
*/
|
||||
public class Traverson {
|
||||
|
||||
private final URI baseUri;
|
||||
private final RestTemplate template;
|
||||
private final LinkDiscoverers discoverers;
|
||||
private final List<MediaType> mediaTypes;
|
||||
|
||||
/**
|
||||
* Creates a new {@link Traverson} interacting with the given base URI and using the given {@link MediaType}s to
|
||||
* interact with the service.
|
||||
*
|
||||
* @param baseUri must not be {@literal null}.
|
||||
* @param mediaType must not be {@literal null} or empty.
|
||||
*/
|
||||
public Traverson(URI baseUri, MediaType... mediaTypes) {
|
||||
|
||||
Assert.notNull(baseUri, "Base URI must not be null!");
|
||||
Assert.notEmpty(mediaTypes, "At least one media must be given!");
|
||||
|
||||
this.mediaTypes = Arrays.asList(mediaTypes);
|
||||
this.template = prepareTemplate(this.mediaTypes);
|
||||
|
||||
this.baseUri = baseUri;
|
||||
|
||||
LinkDiscoverer discoverer = new HalLinkDiscoverer();
|
||||
this.discoverers = new LinkDiscoverers(OrderAwarePluginRegistry.create(Arrays.asList(discoverer)));
|
||||
}
|
||||
|
||||
private final RestTemplate prepareTemplate(List<MediaType> mediaTypes) {
|
||||
|
||||
List<HttpMessageConverter<?>> converters = new ArrayList<HttpMessageConverter<?>>();
|
||||
converters.add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
|
||||
|
||||
if (mediaTypes.contains(MediaTypes.HAL_JSON)) {
|
||||
converters.add(getHalConverter());
|
||||
}
|
||||
|
||||
RestTemplate template = new RestTemplate();
|
||||
template.setMessageConverters(converters);
|
||||
return template;
|
||||
}
|
||||
|
||||
private final HttpMessageConverter<?> getHalConverter() {
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.registerModule(new Jackson2HalModule());
|
||||
|
||||
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
|
||||
|
||||
converter.setObjectMapper(mapper);
|
||||
converter.setSupportedMediaTypes(Arrays.asList(MediaTypes.HAL_JSON));
|
||||
|
||||
return converter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a {@link TraversalBuilder} to follow the given rels.
|
||||
*
|
||||
* @param rels must not be {@literal null} or empty.
|
||||
* @return
|
||||
* @see TraversalBuilder
|
||||
*/
|
||||
public TraversalBuilder follow(String... rels) {
|
||||
return new TraversalBuilder().follow(rels);
|
||||
}
|
||||
|
||||
private HttpEntity<?> prepareRequest(HttpHeaders headers) {
|
||||
|
||||
HttpHeaders toSent = new HttpHeaders();
|
||||
toSent.putAll(headers);
|
||||
|
||||
if (headers.getAccept().isEmpty()) {
|
||||
toSent.setAccept(mediaTypes);
|
||||
}
|
||||
|
||||
return new HttpEntity<Void>(headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder API to customize traversals.
|
||||
*
|
||||
* @author Oliver Gierke
|
||||
*/
|
||||
public class TraversalBuilder {
|
||||
|
||||
private List<String> rels = new ArrayList<String>();
|
||||
private Map<String, Object> templateParameters = new HashMap<String, Object>();
|
||||
private HttpHeaders headers = new HttpHeaders();
|
||||
|
||||
private TraversalBuilder() {}
|
||||
|
||||
/**
|
||||
* Follows the given rels one by one, which means a request per rel to discover the next resource with the rel in
|
||||
* line.
|
||||
*
|
||||
* @param rels must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
private TraversalBuilder follow(String... rels) {
|
||||
|
||||
Assert.notNull(rels, "Rels must not be null!");
|
||||
|
||||
this.rels.addAll(Arrays.asList(rels));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given template parameters to the traversal. If a link discovered by the traversal is templated, the
|
||||
* given parameters will be used to expand the template into a resolvable URI.
|
||||
*
|
||||
* @param parameters can be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public TraversalBuilder withTemplateParameters(Map<String, Object> parameters) {
|
||||
|
||||
this.templateParameters = parameters;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link HttpHeaders} that shall be used for the requests of the traversal.
|
||||
*
|
||||
* @param headers can be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public TraversalBuilder withHeaders(HttpHeaders headers) {
|
||||
|
||||
this.headers = headers;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the traversal and marshals the final response into an object of the given type.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public <T> T toObject(Class<T> type) {
|
||||
|
||||
Assert.notNull(type, "Target type must not be null!");
|
||||
return template.exchange(traverseToFinalUrl(), GET, prepareRequest(headers), type, templateParameters).getBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the traversal and marshals the final response into an object of the given
|
||||
* {@link ParameterizedTypeReference}.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public <T> T toObject(ParameterizedTypeReference<T> type) {
|
||||
|
||||
Assert.notNull(type, "Target type must not be null!");
|
||||
return template.exchange(traverseToFinalUrl(), GET, prepareRequest(headers), type, templateParameters).getBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the traversal and returns the result of the given JSON Path expression evaluated against the final
|
||||
* representation.
|
||||
*
|
||||
* @param jsonPath must not be {@literal null} or empty.
|
||||
* @return
|
||||
*/
|
||||
public <T> T toObject(String jsonPath) {
|
||||
|
||||
Assert.hasText(jsonPath, "JSON path must not be null or empty!");
|
||||
|
||||
String forObject = template.getForObject(traverseToFinalUrl(), String.class, templateParameters);
|
||||
return JsonPath.read(forObject, jsonPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw {@link ResponseEntity} with the representation unmarshalled into an instance of the given type.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public <T> ResponseEntity<T> toEntity(Class<T> type) {
|
||||
|
||||
Assert.notNull(type, "Target type must not be null!");
|
||||
return template.getForEntity(traverseToFinalUrl(), type, templateParameters);
|
||||
}
|
||||
|
||||
private String traverseToFinalUrl() {
|
||||
return getAndFindLinkWithRel(baseUri.toString(), rels.iterator());
|
||||
}
|
||||
|
||||
private String getAndFindLinkWithRel(String uri, Iterator<String> rels) {
|
||||
|
||||
if (!rels.hasNext()) {
|
||||
return uri;
|
||||
}
|
||||
|
||||
HttpEntity<?> request = prepareRequest(headers);
|
||||
UriTemplate uriTemplate = new UriTemplate(uri);
|
||||
|
||||
ResponseEntity<String> responseEntity = template.exchange(uriTemplate.expand(templateParameters), GET, request,
|
||||
String.class);
|
||||
MediaType contentType = responseEntity.getHeaders().getContentType();
|
||||
String responseBody = responseEntity.getBody();
|
||||
|
||||
Rel rel = Rels.getRelFor(rels.next(), discoverers);
|
||||
Link link = rel.findInResponse(responseBody, contentType);
|
||||
|
||||
if (link == null) {
|
||||
throw new IllegalStateException(String.format("Expected to find link with rel '%s' in response %s!", rel,
|
||||
responseBody));
|
||||
}
|
||||
|
||||
return getAndFindLinkWithRel(link.getHref(), rels);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user