Derive a link's description from its title

Previously, a LinkDescriptor had to be created with both a rel and a
description. If a description was not provided a failure would occur.

This commit relaxes the above-described restriction by allowing a
link's title to be used as its default description. If a descriptor
has a description, it will always be used irrespective of whether or
not the link has a title. If the descriptor does not have a
description and the link does have a title, the link's title will be
used. If the descriptor does not have a description and the link does
not have a title a failure will occur.

Closes gh-105
This commit is contained in:
Andy Wilkinson
2016-04-13 14:01:33 +01:00
parent 984f90fa7b
commit c4b7438708
17 changed files with 137 additions and 27 deletions

View File

@@ -38,6 +38,10 @@ include::{examples-dir}/com/example/restassured/Hypermedia.java[tag=links]
The result is a snippet named `links.adoc` that contains a table describing the resource's
links.
TIP: If a link in the response has a `title`, the description can be omitted from its
descriptor and the `title` will be used. If you omit the description and the link does
not have a `title` a failure will occur.
When documenting links, the test will fail if an undocumented link is found in the
response. Similarly, the test will also fail if a documented link is not found in the
response and the link has not been marked as optional.

View File

@@ -51,7 +51,9 @@ class AtomLinkExtractor extends AbstractJsonLinkExtractor {
Object hrefObject = linkMap.get("href");
Object relObject = linkMap.get("rel");
if (relObject instanceof String && hrefObject instanceof String) {
return new Link((String) relObject, (String) hrefObject);
Object titleObject = linkMap.get("title");
return new Link((String) relObject, (String) hrefObject,
titleObject instanceof String ? (String) titleObject : null);
}
return null;
}

View File

@@ -67,9 +67,12 @@ class HalLinkExtractor extends AbstractJsonLinkExtractor {
private static Link maybeCreateLink(String rel, Object possibleLinkObject) {
if (possibleLinkObject instanceof Map) {
Object hrefObject = ((Map<?, ?>) possibleLinkObject).get("href");
Map<?, ?> possibleLinkMap = (Map<?, ?>) possibleLinkObject;
Object hrefObject = possibleLinkMap.get("href");
if (hrefObject instanceof String) {
return new Link(rel, (String) hrefObject);
Object titleObject = possibleLinkMap.get("title");
return new Link(rel, (String) hrefObject,
titleObject instanceof String ? (String) titleObject : null);
}
}
return null;

View File

@@ -53,6 +53,10 @@ public abstract class HypermediaDocumentation {
* If you do not want to document a link, a link descriptor can be marked as
* {@link LinkDescriptor#ignored}. This will prevent it from appearing in the
* generated snippet while avoiding the failure described above.
* <p>
* If a descriptor does not have a {@link LinkDescriptor#description(Object)
* description}, the {@link Link#getTitle() title} of the link will be used. If the
* link does not have a title a failure will occur.
*
* @param descriptors the descriptions of the response's links
* @return the snippet that will document the links

View File

@@ -29,6 +29,8 @@ public class Link {
private final String href;
private final String title;
/**
* Creates a new {@code Link} with the given {@code rel} and {@code href}.
*
@@ -36,8 +38,21 @@ public class Link {
* @param href The link's href
*/
public Link(String rel, String href) {
this(rel, href, null);
}
/**
* Creates a new {@code Link} with the given {@code rel}, {@code href}, and
* {@code title}.
*
* @param rel The link's rel
* @param href The link's href
* @param title The link's title
*/
public Link(String rel, String href, String title) {
this.rel = rel;
this.href = href;
this.title = title;
}
/**
@@ -56,12 +71,21 @@ public class Link {
return this.href;
}
/**
* Returns the link's {@code title}, or {@code null} if it does not have a title.
* @return the link's {@code title} or {@code null}
*/
public String getTitle() {
return this.title;
}
@Override
public int hashCode() {
int prime = 31;
final int prime = 31;
int result = 1;
result = prime * result + this.href.hashCode();
result = prime * result + this.rel.hashCode();
result = prime * result + ((this.title == null) ? 0 : this.title.hashCode());
return result;
}
@@ -83,13 +107,21 @@ public class Link {
if (!this.rel.equals(other.rel)) {
return false;
}
if (this.title == null) {
if (other.title != null) {
return false;
}
}
else if (!this.title.equals(other.title)) {
return false;
}
return true;
}
@Override
public String toString() {
return new ToStringCreator(this).append("rel", this.rel).append("href", this.href)
.toString();
.append("title", this.title).toString();
}
}

View File

@@ -77,12 +77,6 @@ public class LinksSnippet extends TemplatedSnippet {
this.linkExtractor = linkExtractor;
for (LinkDescriptor descriptor : descriptors) {
Assert.notNull(descriptor.getRel(), "Link descriptors must have a rel");
if (!descriptor.isIgnored()) {
Assert.notNull(descriptor.getDescription(),
"The descriptor for link '" + descriptor.getRel()
+ "' must either have a description or be" + " marked as "
+ "ignored");
}
this.descriptorsByRel.put(descriptor.getRel(), descriptor);
}
}
@@ -90,14 +84,16 @@ public class LinksSnippet extends TemplatedSnippet {
@Override
protected Map<String, Object> createModel(Operation operation) {
OperationResponse response = operation.getResponse();
Map<String, List<Link>> links;
try {
validate(this.linkExtractor.extractLinks(response));
links = this.linkExtractor.extractLinks(response);
validate(links);
}
catch (IOException ex) {
throw new ModelCreationException(ex);
}
Map<String, Object> model = new HashMap<>();
model.put("links", createLinksModel());
model.put("links", createLinksModel(links));
return model;
}
@@ -135,17 +131,48 @@ public class LinksSnippet extends TemplatedSnippet {
}
}
private List<Map<String, Object>> createLinksModel() {
private List<Map<String, Object>> createLinksModel(Map<String, List<Link>> links) {
List<Map<String, Object>> model = new ArrayList<>();
for (Entry<String, LinkDescriptor> entry : this.descriptorsByRel.entrySet()) {
LinkDescriptor descriptor = entry.getValue();
if (!descriptor.isIgnored()) {
if (descriptor.getDescription() == null) {
descriptor = createDescriptor(
getDescriptionFromLinkTitle(links, descriptor.getRel()),
descriptor);
}
model.add(createModelForDescriptor(descriptor));
}
}
return model;
}
private String getDescriptionFromLinkTitle(Map<String, List<Link>> links,
String rel) {
List<Link> linksForRel = links.get(rel);
if (linksForRel != null) {
for (Link link : linksForRel) {
if (link.getTitle() != null) {
return link.getTitle();
}
}
}
throw new SnippetException("No description was provided for the link with rel '"
+ rel + "' and no title was available from the link in the payload");
}
private LinkDescriptor createDescriptor(String description, LinkDescriptor source) {
LinkDescriptor newDescriptor = new LinkDescriptor(source.getRel())
.description(description);
if (source.isOptional()) {
newDescriptor.optional();
}
if (source.isIgnored()) {
newDescriptor.ignored();
}
return newDescriptor;
}
/**
* Returns a {@code Map} of {@link LinkDescriptor LinkDescriptors} keyed by their
* {@link LinkDescriptor#getRel() rels}.

View File

@@ -68,14 +68,15 @@ public class LinkExtractorsPayloadTests {
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);
assertLinks(Arrays.asList(new Link("alpha", "http://alpha.example.com", "Alpha")),
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"),
assertLinks(Arrays.asList(new Link("alpha", "http://alpha.example.com", "Alpha"),
new Link("bravo", "http://bravo.example.com")), links);
}
@@ -83,7 +84,8 @@ public class LinkExtractorsPayloadTests {
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"),
assertLinks(Arrays.asList(
new Link("alpha", "http://alpha.example.com/one", "Alpha one"),
new Link("alpha", "http://alpha.example.com/two")), links);
}

View File

@@ -79,4 +79,16 @@ public class LinksSnippetFailureTests {
this.snippet.getOutputDirectory()).build());
}
@Test
public void linkWithNoDescription() throws IOException {
this.thrown.expect(SnippetException.class);
this.thrown.expectMessage(
equalTo("No description was provided for the link with rel 'foo' and no"
+ " title was available from the link in the payload"));
new LinksSnippet(new StubLinkExtractor().withLinks(new Link("foo", "bar")),
Arrays.asList(new LinkDescriptor("foo")))
.document(new OperationBuilder("link-with-no-description",
this.snippet.getOutputDirectory()).build());
}
}

View File

@@ -87,6 +87,20 @@ public class LinksSnippetTests extends AbstractSnippetTests {
.document(operationBuilder("documented-links").build());
}
@Test
public void linkDescriptionFromTitleInPayload() throws IOException {
this.snippet.expectLinks("link-description-from-title-in-payload")
.withContents(tableWithHeader("Relation", "Description").row("a", "one")
.row("b", "Link b"));
new LinksSnippet(
new StubLinkExtractor().withLinks(new Link("a", "alpha", "Link a"),
new Link("b", "bravo", "Link b")),
Arrays.asList(new LinkDescriptor("a").description("one"),
new LinkDescriptor("b"))).document(
operationBuilder("link-description-from-title-in-payload")
.build());
}
@Test
public void linksWithCustomAttributes() throws IOException {
TemplateResourceResolver resolver = mock(TemplateResourceResolver.class);

View File

@@ -1,7 +1,8 @@
{
"links": [ {
"rel": "alpha",
"href": "http://alpha.example.com"
"href": "http://alpha.example.com",
"title": "Alpha"
}, {
"rel": "bravo",
"href": "http://bravo.example.com"

View File

@@ -1,7 +1,8 @@
{
"links": [ {
"rel": "alpha",
"href": "http://alpha.example.com/one"
"href": "http://alpha.example.com/one",
"title": "Alpha one"
}, {
"rel": "alpha",
"href": "http://alpha.example.com/two"

View File

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

View File

@@ -1,7 +1,8 @@
{
"_links": {
"alpha": {
"href": "http://alpha.example.com"
"href": "http://alpha.example.com",
"title": "Alpha"
},
"bravo": {
"href": "http://bravo.example.com"

View File

@@ -1,7 +1,8 @@
{
"_links": {
"alpha": [{
"href": "http://alpha.example.com/one"
"href": "http://alpha.example.com/one",
"title": "Alpha one"
}, {
"href": "http://alpha.example.com/two"
}]

View File

@@ -1,7 +1,8 @@
{
"_links": {
"alpha": {
"href": "http://alpha.example.com"
"href": "http://alpha.example.com",
"title": "Alpha"
}
}
}

View File

@@ -38,7 +38,6 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.restdocs.JUnitRestDocumentation;
import org.springframework.restdocs.hypermedia.Link;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentationIntegrationTests.TestConfiguration;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@@ -461,7 +460,10 @@ public class MockMvcRestDocumentationIntegrationTests {
public ResponseEntity<Map<String, Object>> foo() {
Map<String, Object> response = new HashMap<>();
response.put("a", "alpha");
response.put("links", Arrays.asList(new Link("rel", "href")));
Map<String, String> link = new HashMap<>();
link.put("rel", "rel");
link.put("href", "href");
response.put("links", Arrays.asList(link));
HttpHeaders headers = new HttpHeaders();
headers.add("a", "alpha");
return new ResponseEntity<>(response, headers, HttpStatus.OK);

View File

@@ -40,7 +40,6 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.restdocs.JUnitRestDocumentation;
import org.springframework.restdocs.hypermedia.Link;
import org.springframework.restdocs.restassured.RestAssuredRestDocumentationIntegrationTests.TestApplication;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
@@ -329,7 +328,10 @@ public class RestAssuredRestDocumentationIntegrationTests {
public ResponseEntity<Map<String, Object>> foo() {
Map<String, Object> response = new HashMap<>();
response.put("a", "alpha");
response.put("links", Arrays.asList(new Link("rel", "href")));
Map<String, String> link = new HashMap<>();
link.put("rel", "rel");
link.put("href", "href");
response.put("links", Arrays.asList(link));
HttpHeaders headers = new HttpHeaders();
headers.add("a", "alpha");
headers.add("Foo", "http://localhost:12345/foo/bar");