Improve the abstraction that's used for link extraction

Previously, the link extraction abstraction was inadequate as it made
assumptions about the format of the links that did not apply to all
media types. For example, it was suited to the links returned in a
application/hal+json response, but was not suited to the Atom-style
links often found in application/json responses.

This commit improves the abstraction so that the extracted links are
decoupled from the format of the response. In addition to the existing
support for extracting HAL links a new extractor for Atom-style links
has been introduced. Both extractors are now available via static
methods on the new LinkExtractors class. The sample applications have
been updated accordingly.

Closes #6
This commit is contained in:
Andy Wilkinson
2015-01-12 16:25:13 +00:00
parent bbec60bf8e
commit 78736d6c50
19 changed files with 467 additions and 29 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2014 the original author or authors.
* Copyright 2014-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.

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2014 the original author or authors.
* Copyright 2014-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.
@@ -19,7 +19,7 @@ package com.example.notes;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.springframework.restdocs.core.RestDocumentation.document;
import static org.springframework.restdocs.core.RestDocumentation.halLinks;
import static org.springframework.restdocs.core.LinkExtractors.halLinks;
import static org.springframework.restdocs.core.RestDocumentation.linkWithRel;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2014 the original author or authors.
* Copyright 2014-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.
@@ -18,8 +18,8 @@ package com.example.notes;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.springframework.restdocs.core.LinkExtractors.halLinks;
import static org.springframework.restdocs.core.RestDocumentation.document;
import static org.springframework.restdocs.core.RestDocumentation.halLinks;
import static org.springframework.restdocs.core.RestDocumentation.linkWithRel;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;

View File

@@ -0,0 +1,94 @@
/*
* Copyright 2014-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.restdocs.core;
import org.springframework.core.style.ToStringCreator;
/**
* Representation of a link used in a Hypermedia-based API
*
* @author Andy Wilkinson
*/
public class Link {
private final String rel;
private final String href;
/**
* Creates a new {@code Link} with the given {@code rel} and {@code href}
*
* @param rel The link's rel
* @param href The link's href
*/
public Link(String rel, String href) {
this.rel = rel;
this.href = href;
}
/**
* Returns the link's {@code rel}
* @return the link's {@code rel}
*/
public String getRel() {
return rel;
}
/**
* Returns the link's {@code href}
* @return the link's {@code href}
*/
public String getHref() {
return href;
}
@Override
public int hashCode() {
int prime = 31;
int result = 1;
result = prime * result + href.hashCode();
result = prime * result + rel.hashCode();
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
Link other = (Link) obj;
if (!href.equals(other.href)) {
return false;
}
if (!rel.equals(other.rel)) {
return false;
}
return true;
}
public String toString() {
return new ToStringCreator(this).append("rel", this.rel)
.append("href", this.href).toString();
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2014 the original author or authors.
* Copyright 2014-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,10 +16,30 @@
package org.springframework.restdocs.core;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import org.springframework.mock.web.MockHttpServletResponse;
/**
* A {@code LinkExtractor} is used to extract {@link Link links} from a JSON response. The
* expected format of the links in the response is determined by the implementation.
*
* @author Andy Wilkinson
*
*/
public interface LinkExtractor {
Map<String, Object> extractLinks(Map<String, Object> responseJson);
/**
* Extract the links from the given response, returning a {@code Map} of links where
* the keys are the link rels.
*
* @param response The response from which the links are to be extracted
* @return The extracted links, keyed by rel
* @throws IOException if link extraction fails
*/
Map<String, List<Link>> extractLinks(MockHttpServletResponse response)
throws IOException;
}

View File

@@ -0,0 +1,162 @@
/*
* Copyright 2014-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.restdocs.core;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.springframework.mock.web.MockHttpServletResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Static factory methods provided a selection of {@link LinkExtractor link extractors}
* for use when documentating a hypermedia-based API.
*
* @author Andy Wilkinson
*
*/
public class LinkExtractors {
/**
* Returns a {@code LinkExtractor} capable of extracting links in Hypermedia
* Application Language (HAL) format where the links are found in a map named
* {@code _links}.
*
* @return The extract for HAL-style links
*/
public static LinkExtractor halLinks() {
return new HalLinkExtractor();
}
/**
* Returns a {@code LinkExtractor} capable of extracting links in Atom format where
* the links are found in an array named {@code links}.
*
* @return The extractor for Atom-style links
*/
public static LinkExtractor atomLinks() {
return new AtomLinkExtractor();
}
private static abstract class JsonContentLinkExtractor implements LinkExtractor {
private final ObjectMapper objectMapper = new ObjectMapper();
@SuppressWarnings("unchecked")
public Map<String, List<Link>> extractLinks(MockHttpServletResponse response)
throws IOException {
Map<String, Object> jsonContent = this.objectMapper.readValue(
response.getContentAsString(), Map.class);
return extractLinks(jsonContent);
}
protected abstract Map<String, List<Link>> extractLinks(Map<String, Object> json);
}
private static class HalLinkExtractor extends JsonContentLinkExtractor {
@SuppressWarnings("unchecked")
@Override
public Map<String, List<Link>> extractLinks(Map<String, Object> json) {
Map<String, List<Link>> extractedLinks = new HashMap<>();
Object possibleLinks = json.get("_links");
if (possibleLinks instanceof Map) {
Map<String, Object> links = (Map<String, Object>) possibleLinks;
for (Entry<String, Object> entry : links.entrySet()) {
String rel = (String) entry.getKey();
extractedLinks.put(rel, convertToLinks(entry.getValue(), rel));
}
}
return extractedLinks;
}
@SuppressWarnings("unchecked")
private static List<Link> convertToLinks(Object object, String rel) {
List<Link> links = new ArrayList<>();
if (object instanceof Collection) {
Collection<Object> hrefObjects = (Collection<Object>) object;
for (Object hrefObject : hrefObjects) {
maybeAddLink(maybeCreateLink(rel, hrefObject), links);
}
}
else {
maybeAddLink(maybeCreateLink(rel, object), links);
}
return links;
}
private static Link maybeCreateLink(String rel, Object possibleHref) {
if (possibleHref instanceof String) {
return new Link(rel, (String) possibleHref);
}
return null;
}
private static void maybeAddLink(Link possibleLink, List<Link> links) {
if (possibleLink != null) {
links.add(possibleLink);
}
}
}
private static class AtomLinkExtractor extends JsonContentLinkExtractor {
@SuppressWarnings("unchecked")
@Override
public Map<String, List<Link>> extractLinks(Map<String, Object> json) {
Map<String, List<Link>> extractedLinks = new HashMap<>();
Object possibleLinks = json.get("links");
if (possibleLinks instanceof Collection) {
Collection<Object> linksCollection = (Collection<Object>) possibleLinks;
for (Object linkObject : linksCollection) {
if (linkObject instanceof Map) {
Link link = maybeCreateLink((Map<String, Object>) linkObject);
maybeStoreLink(link, extractedLinks);
}
}
}
return extractedLinks;
}
private static Link maybeCreateLink(Map<String, Object> linkMap) {
Object hrefObject = linkMap.get("href");
Object relObject = linkMap.get("rel");
if (relObject instanceof String && hrefObject instanceof String) {
return new Link((String) relObject, (String) hrefObject);
}
return null;
}
private static void maybeStoreLink(Link link,
Map<String, List<Link>> extractedLinks) {
if (link != null) {
List<Link> linksForRel = extractedLinks.get(link.getRel());
if (linksForRel == null) {
linksForRel = new ArrayList<Link>();
extractedLinks.put(link.getRel(), linksForRel);
}
linksForRel.add(link);
}
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2014 the original author or authors.
* Copyright 2014-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,8 +16,6 @@
package org.springframework.restdocs.core;
import java.util.Map;
public class RestDocumentation {
public static RestDocumentationResultHandler document(String outputDir)
@@ -29,14 +27,4 @@ public class RestDocumentation {
return new LinkDescriptor(rel);
}
public static LinkExtractor halLinks() {
return new LinkExtractor() {
@SuppressWarnings("unchecked")
@Override
public Map<String, Object> extractLinks(Map<String, Object> responseJson) {
return (Map<String, Object>) responseJson.get("_links");
}
};
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2014 the original author or authors.
* Copyright 2014-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.
@@ -41,8 +41,6 @@ import org.springframework.util.Assert;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.bind.annotation.RequestMethod;
import com.fasterxml.jackson.databind.ObjectMapper;
public abstract class RestDocumentationResultHandlers {
public static CurlResultHandler documentCurlRequest(String outputDir) {
@@ -245,8 +243,6 @@ public abstract class RestDocumentationResultHandlers {
static class LinkDocumentingResultHandler extends RestDocumentationResultHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, LinkDescriptor> descriptorsByRel = new HashMap<String, LinkDescriptor>();
private final LinkExtractor extractor;
@@ -262,12 +258,10 @@ public abstract class RestDocumentationResultHandlers {
}
}
@SuppressWarnings("unchecked")
@Override
void handle(MvcResult result, DocumentationWriter writer) throws Exception {
Map<String, Object> json = this.objectMapper.readValue(result.getResponse()
.getContentAsString(), Map.class);
Map<String, Object> links = this.extractor.extractLinks(json);
Map<String, List<Link>> links = this.extractor.extractLinks(result
.getResponse());
Set<String> actualRels = links.keySet();
Set<String> expectedRels = this.descriptorsByRel.keySet();

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2014-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.restdocs.core;
import static org.junit.Assert.assertEquals;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.util.FileCopyUtils;
/**
* Tests for {@link LinkExtractors}.
*
* @author Andy Wilkinson
*/
@RunWith(Parameterized.class)
public class LinkExtractorsTests {
private final LinkExtractor linkExtractor;
private final String linkType;
@Parameters
public static Collection<Object[]> data() {
return Arrays.asList(new Object[] { LinkExtractors.halLinks(), "hal" },
new Object[] { LinkExtractors.atomLinks(), "atom" });
}
public LinkExtractorsTests(LinkExtractor linkExtractor, String linkType) {
this.linkExtractor = linkExtractor;
this.linkType = linkType;
}
@Test
public void singleLink() throws IOException {
Map<String, List<Link>> links = this.linkExtractor
.extractLinks(createResponse("single-link"));
assertLinks(Arrays.asList(new Link("alpha", "http://alpha.example.com")), links);
}
@Test
public void multipleLinksWithDifferentRels() throws IOException {
Map<String, List<Link>> links = this.linkExtractor
.extractLinks(createResponse("multiple-links-different-rels"));
assertLinks(Arrays.asList(new Link("alpha", "http://alpha.example.com"),
new Link("bravo", "http://bravo.example.com")), links);
}
@Test
public void multipleLinksWithSameRels() throws IOException {
Map<String, List<Link>> links = this.linkExtractor
.extractLinks(createResponse("multiple-links-same-rels"));
assertLinks(Arrays.asList(new Link("alpha", "http://alpha.example.com/one"),
new Link("alpha", "http://alpha.example.com/two")), links);
}
@Test
public void noLinks() throws IOException {
Map<String, List<Link>> links = this.linkExtractor
.extractLinks(createResponse("no-links"));
assertLinks(Collections.<Link> emptyList(), links);
}
@Test
public void linksInTheWrongFormat() throws IOException {
Map<String, List<Link>> links = this.linkExtractor
.extractLinks(createResponse("wrong-format"));
assertLinks(Collections.<Link> emptyList(), links);
}
private void assertLinks(List<Link> expectedLinks, Map<String, List<Link>> actualLinks) {
Map<String, List<Link>> expectedLinksByRel = new HashMap<>();
for (Link expectedLink : expectedLinks) {
List<Link> expectedlinksWithRel = expectedLinksByRel.get(expectedLink
.getRel());
if (expectedlinksWithRel == null) {
expectedlinksWithRel = new ArrayList<>();
expectedLinksByRel.put(expectedLink.getRel(), expectedlinksWithRel);
}
expectedlinksWithRel.add(expectedLink);
}
assertEquals(expectedLinksByRel, actualLinks);
}
private MockHttpServletResponse createResponse(String contentName) throws IOException {
MockHttpServletResponse response = new MockHttpServletResponse();
FileCopyUtils.copy(new FileReader(getPayloadFile(contentName)),
response.getWriter());
return response;
}
private File getPayloadFile(String name) {
return new File("src/test/resources/link-payloads/" + linkType + "/" + name
+ ".json");
}
}

View File

@@ -0,0 +1,9 @@
{
"links": [ {
"rel": "alpha",
"href": "http://alpha.example.com"
}, {
"rel": "bravo",
"href": "http://bravo.example.com"
} ]
}

View File

@@ -0,0 +1,9 @@
{
"links": [ {
"rel": "alpha",
"href": "http://alpha.example.com/one"
}, {
"rel": "alpha",
"href": "http://alpha.example.com/two"
} ]
}

View File

@@ -0,0 +1,6 @@
{
"links": [ {
"rel": "alpha",
"href": "http://alpha.example.com"
} ]
}

View File

@@ -0,0 +1,5 @@
{
"_links": {
"alpha": ["http://alpha.example.com/one", "http://alpha.example.com/two"]
}
}

View File

@@ -0,0 +1,6 @@
{
"_links": {
"alpha": "http://alpha.example.com",
"bravo": "http://bravo.example.com"
}
}

View File

@@ -0,0 +1,5 @@
{
"_links": {
"alpha": ["http://alpha.example.com/one", "http://alpha.example.com/two"]
}
}

View File

@@ -0,0 +1,5 @@
{
"_links": {
"alpha": "http://alpha.example.com"
}
}

View File

@@ -0,0 +1,9 @@
{
"_links": [ {
"rel": "alpha",
"href": "http://alpha.example.com/one"
}, {
"rel": "alpha",
"href": "http://alpha.example.com/two"
} ]
}