org.springframework.hateoas.client.Traverson Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of spring-hateoas Show documentation
Show all versions of spring-hateoas Show documentation
Library to support implementing representations for
hyper-text driven REST web services.
/*
* Copyright 2013-2024 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
*
* https://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.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.UriTemplate;
import org.springframework.hateoas.client.Rels.Rel;
import org.springframework.hateoas.mediatype.hal.HalLinkDiscoverer;
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.lang.Nullable;
import org.springframework.plugin.core.PluginRegistry;
import org.springframework.util.Assert;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
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
* @author Dietrich Schulten
* @author Greg Turnquist
* @author Tom Bunting
* @author Manish Misra
* @author Michael Wirth
* @since 0.11
*/
public class Traverson {
private static final TraversonDefaults DEFAULTS;
static {
List ALL_DEFAULTS = SpringFactoriesLoader.loadFactories(TraversonDefaults.class,
Traverson.class.getClassLoader());
Assert.isTrue(ALL_DEFAULTS.size() == 1,
() -> String.format("Expected to find only one TraversonDefaults instance, but found: %s", //
ALL_DEFAULTS.stream() //
.map(Object::getClass) //
.map(Class::getName) //
.collect(Collectors.joining(", "))));
DEFAULTS = ALL_DEFAULTS.get(0);
}
private final URI baseUri;
private final List mediaTypes;
private RestOperations operations;
private LinkDiscoverers discoverers;
/**
* 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 mediaTypes must not be {@literal null} or empty.
*/
public Traverson(URI baseUri, MediaType... mediaTypes) {
this(baseUri, Arrays.asList(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 mediaTypes must not be {@literal null} or empty.
*/
public Traverson(URI baseUri, List mediaTypes) {
Assert.notNull(baseUri, "Base URI must not be null!");
Assert.notEmpty(mediaTypes, "At least one media type must be given!");
this.mediaTypes = mediaTypes;
this.baseUri = baseUri;
setLinkDiscoverers(DEFAULTS.getLinkDiscoverers(mediaTypes));
setRestOperations(createDefaultTemplate(this.mediaTypes));
}
/**
* Returns all {@link HttpMessageConverter}s that will be registered for the given {@link MediaType}s by default.
*
* @param mediaTypes must not be {@literal null}.
* @return
*/
public static List> getDefaultMessageConverters(MediaType... mediaTypes) {
return DEFAULTS.getHttpMessageConverters(Arrays.asList(mediaTypes));
}
private static RestOperations createDefaultTemplate(List mediaTypes) {
RestTemplate template = new RestTemplate();
template.setMessageConverters(DEFAULTS.getHttpMessageConverters(mediaTypes));
return template;
}
/**
* Configures the {@link RestOperations} to use. If {@literal null} is provided a default {@link RestTemplate} will be
* used.
*
* @param operations
* @return
*/
public Traverson setRestOperations(@Nullable RestOperations operations) {
this.operations = operations == null //
? createDefaultTemplate(this.mediaTypes) //
: operations;
return this;
}
/**
* Sets the {@link LinkDiscoverers} to use. By default a single {@link HalLinkDiscoverer} is registered. If
* {@literal null} is provided the default is reapplied.
*
* @param discoverer can be {@literal null}.
* @return
*/
public Traverson setLinkDiscoverers(@Nullable List extends LinkDiscoverer> discoverer) {
List extends LinkDiscoverer> defaultedDiscoverers = discoverer == null //
? DEFAULTS.getLinkDiscoverers(mediaTypes) //
: discoverer;
this.discoverers = new LinkDiscoverers(PluginRegistry.of(defaultedDiscoverers));
return this;
}
/**
* 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);
}
/**
* 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();
toSend.putAll(headers);
if (headers.getAccept().isEmpty()) {
toSend.setAccept(mediaTypes);
}
return new HttpEntity(toSend);
}
/**
* Builder API to customize traversals.
*
* @author Oliver Gierke
*/
public class TraversalBuilder {
private static final String MEDIA_TYPE_HEADER_NOT_FOUND = "Response for request to %s did not expose a content type! Unable to identify links!";
private static final String LINK_NOT_FOUND = "Expected to find link with rel '%s' in response %s!";
private final List rels = new ArrayList<>();
private Map templateParameters = new HashMap<>();
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
*/
public TraversalBuilder follow(String... rels) {
Assert.notNull(rels, "Rels must not be null!");
Arrays.stream(rels) //
.map(Hop::rel) //
.forEach(this.rels::add);
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
* @see Hop#rel(String)
*/
public TraversalBuilder follow(Hop hop) {
Assert.notNull(hop, "Hop must not be null!");
this.rels.add(hop);
return this;
}
/**
* Adds the given operations parameters to the traversal. If a link discovered by the traversal is templated, the
* given parameters will be used to expand the operations into a resolvable URI.
*
* @param parameters can be {@literal null}.
* @return
*/
public TraversalBuilder withTemplateParameters(Map parameters) {
Assert.notNull(parameters, "Parameters must not be null!");
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) {
Assert.notNull(headers, "Headers must not be null!");
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
*/
@Nullable
public T toObject(Class type) {
Assert.notNull(type, "Target type must not be null!");
URIAndHeaders uriAndHeaders = traverseToExpandedFinalUrl();
HttpEntity> requestEntity = prepareRequest(mergeHeaders(this.headers, uriAndHeaders.getHttpHeaders()));
return operations.exchange(uriAndHeaders.getUri(), GET, requestEntity, type).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
*/
@Nullable
public T toObject(ParameterizedTypeReference type) {
Assert.notNull(type, "Target type must not be null!");
URIAndHeaders uriAndHeaders = traverseToExpandedFinalUrl();
HttpEntity> requestEntity = prepareRequest(mergeHeaders(this.headers, uriAndHeaders.getHttpHeaders()));
return operations.exchange(uriAndHeaders.getUri(), GET, requestEntity, type).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 toObject(String jsonPath) {
Assert.hasText(jsonPath, "JSON path must not be null or empty!");
URIAndHeaders uriAndHeaders = traverseToExpandedFinalUrl();
HttpEntity> requestEntity = prepareRequest(mergeHeaders(this.headers, uriAndHeaders.getHttpHeaders()));
String forObject = operations.exchange(uriAndHeaders.getUri(), GET, requestEntity, String.class).getBody();
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 ResponseEntity toEntity(Class type) {
Assert.notNull(type, "Target type must not be null!");
URIAndHeaders uriAndHeaders = traverseToExpandedFinalUrl();
HttpEntity> requestEntity = prepareRequest(mergeHeaders(this.headers, uriAndHeaders.getHttpHeaders()));
return operations.exchange(uriAndHeaders.getUri(), GET, requestEntity, type);
}
/**
* Returns the {@link Link} found for the last rel in the rels configured to follow. Will expand the final
* {@link Link} using the
*
* @return
* @see #withTemplateParameters(Map)
* @since 0.15
*/
public Link asLink() {
return traverseToLink(true);
}
/**
* Returns the templated {@link Link} found for the last relation in the rels configured to follow.
*
* @return
* @since 0.17
*/
public Link asTemplatedLink() {
return traverseToLink(false);
}
private Link traverseToLink(boolean expandFinalUrl) {
Assert.isTrue(rels.size() > 0, "At least one rel needs to be provided!");
return Link.of(expandFinalUrl ? traverseToExpandedFinalUrl().getUri().toString() : traverseToFinalUrl().getUri(),
rels.get(rels.size() - 1).getRel());
}
private UriStringAndHeaders traverseToFinalUrl() {
UriStringAndHeaders uriAndHeaders = getAndFindLinkWithRel(baseUri.toString(), rels.iterator(), HttpHeaders.EMPTY);
return new UriStringAndHeaders(UriTemplate.of(uriAndHeaders.getUri()).toString(), uriAndHeaders.getHttpHeaders());
}
private URIAndHeaders traverseToExpandedFinalUrl() {
UriStringAndHeaders uriAndHeaders = getAndFindLinkWithRel(baseUri.toString(), rels.iterator(), HttpHeaders.EMPTY);
return new URIAndHeaders(UriTemplate.of(uriAndHeaders.getUri()).expand(templateParameters),
uriAndHeaders.getHttpHeaders());
}
private UriStringAndHeaders getAndFindLinkWithRel(String uri, Iterator rels, HttpHeaders extraHeaders) {
if (!rels.hasNext()) {
return new UriStringAndHeaders(uri, extraHeaders);
}
HttpEntity> request = prepareRequest(mergeHeaders(this.headers, extraHeaders));
URI target = UriTemplate.of(uri).expand();
ResponseEntity responseEntity = operations.exchange(target, GET, request, String.class);
MediaType contentType = responseEntity.getHeaders().getContentType();
if (contentType == null) {
throw new IllegalStateException(String.format(MEDIA_TYPE_HEADER_NOT_FOUND, target));
}
String responseBody = responseEntity.getBody();
Hop thisHop = rels.next();
Rel rel = Rels.getRelFor(thisHop.getRel(), discoverers);
Link link = rel.findInResponse(responseBody == null ? "" : responseBody, contentType) //
.orElseThrow(() -> new IllegalStateException(String.format(LINK_NOT_FOUND, rel, responseBody)));
String linkTarget = thisHop.hasParameters() //
? link.expand(thisHop.getMergedParameters(templateParameters)).getHref() //
: link.getHref();
return getAndFindLinkWithRel(linkTarget, rels, thisHop.getHeaders());
}
/**
* Combine two sets of {@link HttpHeaders} into one.
*
* @param headersA
* @param headersB
* @return
*/
private HttpHeaders mergeHeaders(HttpHeaders headersA, HttpHeaders headersB) {
HttpHeaders mergedHeaders = new HttpHeaders();
mergedHeaders.addAll(headersA);
mergedHeaders.addAll(headersB);
return mergedHeaders;
}
}
/**
* Temporary container for a string-base {@literal URI} and {@link HttpHeaders}.
*/
private static final class UriStringAndHeaders {
private final String uri;
private final HttpHeaders httpHeaders;
UriStringAndHeaders(String uri, HttpHeaders httpHeaders) {
this.uri = uri;
this.httpHeaders = httpHeaders;
}
String getUri() {
return this.uri;
}
HttpHeaders getHttpHeaders() {
return this.httpHeaders;
}
@Override
public boolean equals(@Nullable Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
UriStringAndHeaders that = (UriStringAndHeaders) o;
return Objects.equals(this.uri, that.uri) && Objects.equals(this.httpHeaders, that.httpHeaders);
}
@Override
public int hashCode() {
return Objects.hash(this.uri, this.httpHeaders);
}
@Override
public String toString() {
return "Traverson.UriStringAndHeaders(uri=" + this.uri + ", httpHeaders=" + this.httpHeaders + ")";
}
}
/**
* Temporary container for a {@link URI}-based {@literal URI} and {@link HttpHeaders}.
*/
private static final class URIAndHeaders {
private final URI uri;
private final HttpHeaders httpHeaders;
URIAndHeaders(URI uri, HttpHeaders httpHeaders) {
this.uri = uri;
this.httpHeaders = httpHeaders;
}
URI getUri() {
return this.uri;
}
HttpHeaders getHttpHeaders() {
return this.httpHeaders;
}
@Override
public boolean equals(@Nullable Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
URIAndHeaders that = (URIAndHeaders) o;
return Objects.equals(this.uri, that.uri) && Objects.equals(this.httpHeaders, that.httpHeaders);
}
@Override
public int hashCode() {
return Objects.hash(this.uri, this.httpHeaders);
}
@Override
public String toString() {
return "Traverson.URIAndHeaders(uri=" + this.uri + ", httpHeaders=" + this.httpHeaders + ")";
}
}
}