Add support for automating the documentation of HAL Links

This commit is contained in:
Andy Wilkinson
2014-11-04 21:21:40 +00:00
parent d06095ee72
commit 2b858c5cd9
8 changed files with 244 additions and 61 deletions

View File

@@ -12,6 +12,7 @@ project(':spring-restdocs-core') {
compile "org.springframework:spring-test:$springVersion"
compile "org.springframework:spring-web:$springVersion"
compile 'javax.servlet:javax.servlet-api:3.1.0'
compile 'com.fasterxml.jackson.core:jackson-databind:2.3.4'
}
}

View File

@@ -18,8 +18,8 @@ package com.example.notes;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.restdocs.core.RestDocumentation.document;
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;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -94,10 +94,14 @@ public class ApiDocumentation {
@Test
public void indexExample() throws Exception {
document("index-example",
this.mockMvc.perform(get("/"))
.andExpect(status().isOk()))
.andExpect(jsonPath("_links.notes", is(notNullValue())))
.andExpect(jsonPath("_links.tags", is(notNullValue())));
this.mockMvc.perform(get("/")).andExpect(status().isOk()))
.andDocumentHalLinks(
linkWithRel("notes").description(
"The <<resources-notes,Notes resource>>"),
linkWithRel("tags").description(
"The <<resources-tags,Tags resource>>"),
linkWithRel("profile").description(
"The ALPS profile for the service"));
}
@Test
@@ -168,7 +172,11 @@ public class ApiDocumentation {
.andExpect(jsonPath("title", is(note.get("title"))))
.andExpect(jsonPath("body", is(note.get("body"))))
.andExpect(jsonPath("_links.self.href", is(noteLocation)))
.andExpect(jsonPath("_links.tags", is(notNullValue())));
.andExpect(jsonPath("_links.tags", is(notNullValue())))
.andDocumentHalLinks(
linkWithRel("self").description("This <<resources-note,note>>"),
linkWithRel("tags").description(
"This note's <<resources-note-tags,tags>>"));
}
@@ -253,8 +261,11 @@ public class ApiDocumentation {
document("tag-get-example", this.mockMvc.perform(get(tagLocation)))
.andExpect(status().isOk())
.andExpect(jsonPath("name", is(tag.get("name"))))
.andExpect(jsonPath("_links.self.href", is(tagLocation)))
.andExpect(jsonPath("_links.notes", is(notNullValue())));
.andDocumentHalLinks(
linkWithRel("self").description("This <<resources-tag,tag>>"),
linkWithRel("notes")
.description(
"The <<resources-tagged-notes,notes>> that have this tag"));
}

View File

@@ -132,15 +132,7 @@ include::{generated}/index-example/response.asciidoc[]
[[resources-index-links]]
==== Links
|===
| Relation | Description
| notes
| The <<resources-notes,Notes resource>>
| tags
| The <<resources-tags,Tags resource>>
|===
include::{generated}/index-example/links.asciidoc[]
@@ -270,15 +262,7 @@ The Note resource is used to retrieve, update, and delete individual notes
[[resources-note-links]]
=== Links
|===
| Relation | Description
| self
| This <<resources-note,note>>
| note-tags
| This note's <<resources-note-tags,tags>>
|===
include::{generated}/note-get-example/links.asciidoc[]
@@ -347,15 +331,7 @@ The Tag resource is used to retrieve, update, and delete individual tags
[[resources-tag-links]]
=== Links
|===
| Relation | Description
| self
| This <<resources-tag,tag>>
| tagged-notes
| The <<resources-tagged-notes,notes>> that have this tag
|===
include::{generated}/tag-get-example/links.asciidoc[]

View File

@@ -19,6 +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.linkWithRel;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -93,7 +94,12 @@ public class ApiDocumentation {
@Test
public void indexExample() throws Exception {
document("index-example",
this.mockMvc.perform(get("/")).andExpect(status().isOk()));
this.mockMvc.perform(get("/")).andExpect(status().isOk()))
.andDocumentHalLinks(
linkWithRel("notes").description(
"The <<resources-notes,Notes resource>>"),
linkWithRel("tags").description(
"The <<resources-tags,Tags resource>>"));
}
@Test
@@ -164,7 +170,11 @@ public class ApiDocumentation {
.andExpect(jsonPath("title", is(note.get("title"))))
.andExpect(jsonPath("body", is(note.get("body"))))
.andExpect(jsonPath("_links.self.href", is(noteLocation)))
.andExpect(jsonPath("_links.note-tags", is(notNullValue())));
.andExpect(jsonPath("_links.note-tags", is(notNullValue())))
.andDocumentHalLinks(
linkWithRel("self").description("This <<resources-note,note>>"),
linkWithRel("note-tags").description(
"This note's <<resources-note-tags,tags>>"));
}
@@ -249,8 +259,11 @@ public class ApiDocumentation {
document("tag-get-example", this.mockMvc.perform(get(tagLocation)))
.andExpect(status().isOk())
.andExpect(jsonPath("name", is(tag.get("name"))))
.andExpect(jsonPath("_links.self.href", is(tagLocation)))
.andExpect(jsonPath("_links.tagged-notes", is(notNullValue())));
.andDocumentHalLinks(
linkWithRel("self").description("This <<resources-tag,tag>>"),
linkWithRel("tagged-notes")
.description(
"The <<resources-tagged-notes,notes>> that have this tag"));
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 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.restdocs.core;
public class LinkDescriptor {
private final String rel;
private String description;
public LinkDescriptor(String rel) {
this.rel = rel;
}
public LinkDescriptor description(String description) {
this.description = description;
return this;
}
String getRel() {
return rel;
}
String getDescription() {
return description;
}
}

View File

@@ -16,19 +16,23 @@
package org.springframework.restdocs.core;
import org.springframework.test.web.servlet.ResultActions;
import static org.springframework.restdocs.core.RestDocumentationResultHandlers.documentCurlRequest;
import static org.springframework.restdocs.core.RestDocumentationResultHandlers.documentCurlRequestAndResponse;
import static org.springframework.restdocs.core.RestDocumentationResultHandlers.documentCurlResponse;
import org.springframework.test.web.servlet.ResultActions;
public class RestDocumentation {
public static ResultActions document(String outputDir, ResultActions resultActions)
throws Exception {
return resultActions
public static RestDocumentationResultActions document(String outputDir,
ResultActions resultActions) throws Exception {
return new RestDocumentationResultActions(outputDir, resultActions)
.andDo(documentCurlRequest(outputDir).includeResponseHeaders())
.andDo(documentCurlResponse(outputDir).includeResponseHeaders())
.andDo(documentCurlRequestAndResponse(outputDir).includeResponseHeaders());
}
public static LinkDescriptor linkWithRel(String rel) {
return new LinkDescriptor(rel);
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright 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.restdocs.core;
import java.util.Arrays;
import org.springframework.restdocs.core.RestDocumentationResultHandlers.LinkDocumentingResultHandler;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.ResultHandler;
import org.springframework.test.web.servlet.ResultMatcher;
public class RestDocumentationResultActions implements ResultActions {
private final ResultActions delegate;
private final String outputDir;
public RestDocumentationResultActions(String outputDir, ResultActions delegate) {
this.outputDir = outputDir;
this.delegate = delegate;
}
@Override
public RestDocumentationResultActions andExpect(ResultMatcher matcher)
throws Exception {
this.delegate.andExpect(matcher);
return this;
}
@Override
public RestDocumentationResultActions andDo(ResultHandler handler) throws Exception {
this.delegate.andDo(handler);
return this;
}
@Override
public MvcResult andReturn() {
return this.delegate.andReturn();
}
public RestDocumentationResultActions andDocumentHalLinks(LinkDescriptor... descriptors)
throws Exception {
this.delegate.andDo(new LinkDocumentingResultHandler(this.outputDir, Arrays
.asList(descriptors)));
return this;
}
}

View File

@@ -17,6 +17,7 @@
package org.springframework.restdocs.core;
import static org.springframework.restdocs.core.IterableEnumeration.iterable;
import static org.junit.Assert.fail;
import java.io.File;
import java.io.FileNotFoundException;
@@ -24,15 +25,24 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.restdocs.core.DocumentationWriter.DocumentationAction;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultHandler;
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) {
@@ -163,27 +173,19 @@ public abstract class RestDocumentationResultHandlers {
}
public static abstract class CurlResultHandler implements ResultHandler {
private final CurlConfiguration curlConfiguration = new CurlConfiguration();
public static abstract class RestDocumentationResultHandler implements ResultHandler {
private String outputDir;
private String fileName;
public CurlResultHandler(String outputDir, String fileName) {
public RestDocumentationResultHandler(String outputDir, String fileName) {
this.outputDir = outputDir;
this.fileName = fileName;
}
CurlConfiguration getCurlConfiguration() {
return this.curlConfiguration;
}
public CurlResultHandler includeResponseHeaders() {
this.curlConfiguration.includeResponseHeaders = true;
return this;
}
abstract void handle(MvcResult result, DocumentationWriter writer)
throws Exception;
@Override
public void handle(MvcResult result) throws Exception {
@@ -196,7 +198,7 @@ public abstract class RestDocumentationResultHandlers {
}
}
private PrintStream createPrintStream()
protected PrintStream createPrintStream()
throws FileNotFoundException {
File outputFile = new File(this.outputDir, this.fileName + ".asciidoc");
@@ -212,8 +214,80 @@ public abstract class RestDocumentationResultHandlers {
return new File(new DocumentationProperties().getOutputDir(),
outputFile.getPath());
}
}
public static abstract class CurlResultHandler extends RestDocumentationResultHandler {
private final CurlConfiguration curlConfiguration = new CurlConfiguration();
public CurlResultHandler(String outputDir, String fileName) {
super(outputDir, fileName);
}
CurlConfiguration getCurlConfiguration() {
return this.curlConfiguration;
}
public CurlResultHandler includeResponseHeaders() {
this.curlConfiguration.includeResponseHeaders = true;
return this;
}
}
static class LinkDocumentingResultHandler extends RestDocumentationResultHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, LinkDescriptor> descriptorsByRel = new HashMap<String, LinkDescriptor>();
public LinkDocumentingResultHandler(String outputDir, List<LinkDescriptor> descriptors) {
super(outputDir, "links");
for (LinkDescriptor descriptor: descriptors) {
Assert.hasText(descriptor.getRel());
Assert.hasText(descriptor.getDescription());
this.descriptorsByRel.put(descriptor.getRel(), descriptor);
}
}
@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 = (Map<String, Object>) json.get("_links");
Set<String> actualRels = links.keySet();
Set<String> expectedRels = this.descriptorsByRel.keySet();
Set<String> undocumentedRels = new HashSet<String>(actualRels);
undocumentedRels.removeAll(expectedRels);
Set<String> missingRels = new HashSet<String>(expectedRels);
missingRels.removeAll(actualRels);
if (!undocumentedRels.isEmpty() || !missingRels.isEmpty()) {
String message = "";
if (!undocumentedRels.isEmpty()) {
message += "Links with the following relations were not documented: " + undocumentedRels;
}
if (!missingRels.isEmpty()) {
message += "Links with the following relations were not found in the response: " + missingRels;
}
fail(message);
}
Assert.isTrue(actualRels.equals(expectedRels));
writer.println("|===");
writer.println("| Relation | Description");
for (Entry<String, LinkDescriptor> entry : this.descriptorsByRel.entrySet()) {
writer.println();
writer.println("| " + entry.getKey());
writer.println("| " + entry.getValue().getDescription());
}
writer.println("|===");
}
abstract void handle(MvcResult result, DocumentationWriter writer)
throws Exception;
}
}