diff --git a/pom.xml b/pom.xml index 40c5d423..f665bcd4 100644 --- a/pom.xml +++ b/pom.xml @@ -126,13 +126,6 @@ objenesis ${objenesis.version} - - - javax.servlet - servlet-api - 2.5 - provided - com.fasterxml.jackson.core @@ -230,6 +223,22 @@ 1.3 test + + + net.jadler + jadler-all + 1.1.0 + test + + + + + + javax.servlet + servlet-api + 2.5 + provided + diff --git a/readme.md b/readme.md index 6c8b3c22..806cd0b9 100644 --- a/readme.md +++ b/readme.md @@ -263,3 +263,22 @@ When building links you usually need to determine the relation type to be used f 1. `@Controller` classes annotated with `@ExposesResourceFor` (see section on [EntityLinks](#entitylinks) for details) will transparently lookup the relation types for the type configured in the annotation, so that you can use `relProvider.getSingleResourceRelFor(MyController.class)` and get the relation type of the domain type exposed. A `RelProvider` is exposed as Spring bean when using `@EnableHypermediaSupport` automatically. You can plug in custom providers by simply implementing the interface and exposing them as Spring bean in turn. + +## 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. + + +```java +Map parameters = new HashMap<>(); +parameters.put("user", 27); + +Traverson traverson = new Traverson("http://localhost:8080/api/", MediaTypes.HAL_JSON); +String name = traverson.follow("movies", "movie", "actor"). + withTemplateParameters(parameters). + toObject("$.name"); +``` + +You set up a `Traverson` instance by pointing it to a REST server and configure the media types you want to set as `Accept` header. You then go ahead and define the relation names you want to discover and follow. relation names can either be simple names or JSONPath expressions (starting with an `$`). + +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. \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/client/Rels.java b/src/main/java/org/springframework/hateoas/client/Rels.java new file mode 100644 index 00000000..df745a17 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/client/Rels.java @@ -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. read(representation, jsonPath).toString(), rel); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/client/Traverson.java b/src/main/java/org/springframework/hateoas/client/Traverson.java new file mode 100644 index 00000000..edca599d --- /dev/null +++ b/src/main/java/org/springframework/hateoas/client/Traverson.java @@ -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 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 mediaTypes) { + + List> converters = new ArrayList>(); + 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(headers); + } + + /** + * Builder API to customize traversals. + * + * @author Oliver Gierke + */ + public class TraversalBuilder { + + private 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 + */ + 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 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 toObject(Class 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 toObject(ParameterizedTypeReference 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 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 ResponseEntity toEntity(Class 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 rels) { + + if (!rels.hasNext()) { + return uri; + } + + HttpEntity request = prepareRequest(headers); + UriTemplate uriTemplate = new UriTemplate(uri); + + ResponseEntity 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); + } + } +} diff --git a/src/test/java/org/springframework/hateoas/client/Actor.java b/src/test/java/org/springframework/hateoas/client/Actor.java new file mode 100644 index 00000000..107adf5b --- /dev/null +++ b/src/test/java/org/springframework/hateoas/client/Actor.java @@ -0,0 +1,30 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonAutoDetect(fieldVisibility = Visibility.ANY) +public class Actor { + + String name; + + Actor(@JsonProperty("name") String name) { + this.name = name; + } +} diff --git a/src/test/java/org/springframework/hateoas/client/Movie.java b/src/test/java/org/springframework/hateoas/client/Movie.java new file mode 100644 index 00000000..f00846f0 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/client/Movie.java @@ -0,0 +1,29 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; + +@JsonAutoDetect(fieldVisibility = Visibility.ANY) +public class Movie { + + String title; + + Movie(String title) { + this.title = title; + } +} diff --git a/src/test/java/org/springframework/hateoas/client/Server.java b/src/test/java/org/springframework/hateoas/client/Server.java new file mode 100644 index 00000000..9d88e9c4 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/client/Server.java @@ -0,0 +1,133 @@ +/* + * 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 net.jadler.Jadler.*; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Collections; +import java.util.UUID; + +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.RelProvider; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.core.EvoInflectorRelProvider; +import org.springframework.hateoas.hal.Jackson2HalModule; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Helper class for integration tests. + * + * @author Oliver Gierke + */ +public class Server implements Closeable { + + private final ObjectMapper mapper; + private final RelProvider relProvider; + + private final MultiValueMap baseResources = new LinkedMultiValueMap(); + + public Server() { + + this.relProvider = new EvoInflectorRelProvider(); + + this.mapper = new ObjectMapper(); + this.mapper.registerModule(new Jackson2HalModule()); + this.mapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(relProvider, null)); + + initJadler(). // + that().// + respondsWithDefaultContentType(MediaTypes.HAL_JSON.toString()). // + respondsWithDefaultStatus(200).// + respondsWithDefaultEncoding(Charset.forName("UTF-8")); + + onRequest(). // + havingPathEqualTo("/"). // + respond(). // + withBody(""); + } + + public String rootResource() { + return "http://localhost:" + port(); + } + + public String mockResourceFor(Resource resource) { + + Object content = resource.getContent(); + + Class type = content.getClass(); + String collectionRel = relProvider.getCollectionResourceRelFor(type); + String singleRel = relProvider.getItemResourceRelFor(type); + + String baseResourceUri = String.format("%s/%s", rootResource(), collectionRel); + String resourceUri = String.format("%s/%s", baseResourceUri, UUID.randomUUID().toString()); + + baseResources.add(new Link(baseResourceUri, collectionRel), new Link(resourceUri, singleRel)); + + register(resourceUri, resource); + + return resourceUri; + } + + public void finishMocking() { + + Resources resources = new Resources(Collections. emptyList()); + + for (Link link : baseResources.keySet()) { + + resources.add(link); + + Resources nested = new Resources(Collections. emptyList()); + nested.add(baseResources.get(link)); + + register(link.getHref(), nested); + } + + register("/", resources); + } + + private void register(String path, Object response) { + + path = path.startsWith(rootResource()) ? path.substring(rootResource().length()) : path; + + try { + onRequest(). // + havingMethodEqualTo("GET"). // + havingPathEqualTo(path). // + respond().// + withBody(mapper.writeValueAsString(response)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + /* + * (non-Javadoc) + * @see java.io.Closeable#close() + */ + @Override + public void close() throws IOException { + closeJadler(); + } +} diff --git a/src/test/java/org/springframework/hateoas/client/TraversonTests.java b/src/test/java/org/springframework/hateoas/client/TraversonTests.java new file mode 100644 index 00000000..6d1f3bb2 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/client/TraversonTests.java @@ -0,0 +1,135 @@ +/* + * 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 net.jadler.Jadler.*; +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import java.io.IOException; +import java.net.URI; + +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; +import org.springframework.hateoas.Resource; +import org.springframework.http.MediaType; + +/** + * Integration tests for {@link Traverson}. + * + * @author Oliver Gierke + * @since 0.11 + */ +public class TraversonTests { + + URI baseUri; + Server server; + Traverson traverson; + + @Before + public void setUp() { + + this.server = new Server(); + this.baseUri = URI.create(server.rootResource()); + this.traverson = new Traverson(baseUri, MediaTypes.HAL_JSON); + + setUpActors(); + } + + @After + public void tearDown() throws IOException { + if (server != null) { + server.close(); + } + } + + /** + * @see #131 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsNullBaseUri() { + new Traverson(null, MediaTypes.HAL_JSON); + } + + /** + * @see #131 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsEmptyMediaTypes() { + new Traverson(baseUri, new MediaType[0]); + } + + /** + * @see #131 + */ + @Test + public void sendsConfiguredMediaTypesInAcceptHeader() { + + traverson.follow().toObject(String.class); + + verifyThatRequest(). // + havingPathEqualTo("/"). // + havingHeader("Accept", hasItem("application/hal+json")); + } + + /** + * @see #131 + */ + @Test + public void readsTraversalIntoJsonPathExpression() { + assertThat(traverson.follow("movies", "movie", "actor"). toObject("$.name"), is("Keanu Reaves")); + } + + /** + * @see #131 + */ + @Test + public void readsJsonPathTraversalIntoJsonPathExpression() { + assertThat(traverson.follow(// + "$._links.movies.href", // + "$._links.movie.href", // + "$._links.actor.href"). toObject("$.name"), is("Keanu Reaves")); + } + + /** + * @see #131 + */ + @Test + public void readsTraversalIntoResourceInstance() { + + ParameterizedTypeReference> typeReference = new ParameterizedTypeReference>() {}; + Resource result = traverson.follow("movies", "movie", "actor").toObject(typeReference); + + assertThat(result.getContent().name, is("Keanu Reaves")); + } + + private void setUpActors() { + + Resource actor = new Resource(new Actor("Keanu Reaves")); + String actorUri = server.mockResourceFor(actor); + + Movie movie = new Movie("The Matrix"); + Resource resource = new Resource(movie); + resource.add(new Link(actorUri, "actor")); + + server.mockResourceFor(resource); + server.finishMocking(); + } +}