From 2b858c5cd982b181bf12fb1a6574f6622fe125eb Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Tue, 4 Nov 2014 21:21:40 +0000 Subject: [PATCH] Add support for automating the documentation of HAL Links --- build.gradle | 1 + .../com/example/notes/ApiDocumentation.java | 27 +++-- .../documentation/asciidoc/api-guide.asciidoc | 30 +---- .../com/example/notes/ApiDocumentation.java | 21 +++- .../restdocs/core/LinkDescriptor.java | 41 +++++++ .../restdocs/core/RestDocumentation.java | 14 ++- .../core/RestDocumentationResultActions.java | 63 ++++++++++ .../core/RestDocumentationResultHandlers.java | 108 +++++++++++++++--- 8 files changed, 244 insertions(+), 61 deletions(-) create mode 100644 spring-restdocs-core/src/main/java/org/springframework/restdocs/core/LinkDescriptor.java create mode 100644 spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentationResultActions.java diff --git a/build.gradle b/build.gradle index 0bf08fc8..47c62655 100644 --- a/build.gradle +++ b/build.gradle @@ -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' } } diff --git a/rest-notes-spring-data-rest/src/documentation/java/com/example/notes/ApiDocumentation.java b/rest-notes-spring-data-rest/src/documentation/java/com/example/notes/ApiDocumentation.java index 52b89673..106d5859 100644 --- a/rest-notes-spring-data-rest/src/documentation/java/com/example/notes/ApiDocumentation.java +++ b/rest-notes-spring-data-rest/src/documentation/java/com/example/notes/ApiDocumentation.java @@ -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 <>"), + linkWithRel("tags").description( + "The <>"), + 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 <>"), + linkWithRel("tags").description( + "This note's <>")); } @@ -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 <>"), + linkWithRel("notes") + .description( + "The <> that have this tag")); } diff --git a/rest-notes-spring-hateoas/src/documentation/asciidoc/api-guide.asciidoc b/rest-notes-spring-hateoas/src/documentation/asciidoc/api-guide.asciidoc index 18bd7701..fb83fc5f 100644 --- a/rest-notes-spring-hateoas/src/documentation/asciidoc/api-guide.asciidoc +++ b/rest-notes-spring-hateoas/src/documentation/asciidoc/api-guide.asciidoc @@ -132,15 +132,7 @@ include::{generated}/index-example/response.asciidoc[] [[resources-index-links]] ==== Links -|=== -| Relation | Description - -| notes -| The <> - -| tags -| The <> -|=== +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 <> - -| note-tags -| This note's <> -|=== +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 <> - -| tagged-notes -| The <> that have this tag -|=== +include::{generated}/tag-get-example/links.asciidoc[] diff --git a/rest-notes-spring-hateoas/src/documentation/java/com/example/notes/ApiDocumentation.java b/rest-notes-spring-hateoas/src/documentation/java/com/example/notes/ApiDocumentation.java index 04ba5f4d..2be15399 100644 --- a/rest-notes-spring-hateoas/src/documentation/java/com/example/notes/ApiDocumentation.java +++ b/rest-notes-spring-hateoas/src/documentation/java/com/example/notes/ApiDocumentation.java @@ -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 <>"), + linkWithRel("tags").description( + "The <>")); } @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 <>"), + linkWithRel("note-tags").description( + "This note's <>")); } @@ -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 <>"), + linkWithRel("tagged-notes") + .description( + "The <> that have this tag")); } diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/LinkDescriptor.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/LinkDescriptor.java new file mode 100644 index 00000000..b6c117f0 --- /dev/null +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/LinkDescriptor.java @@ -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; + } +} diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentation.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentation.java index 6439b9bb..950e79bf 100644 --- a/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentation.java +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentation.java @@ -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); + } } diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentationResultActions.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentationResultActions.java new file mode 100644 index 00000000..621490fa --- /dev/null +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentationResultActions.java @@ -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; + } + +} diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentationResultHandlers.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentationResultHandlers.java index 8fdbd64c..d5920c5f 100644 --- a/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentationResultHandlers.java +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/core/RestDocumentationResultHandlers.java @@ -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 descriptorsByRel = new HashMap(); + + public LinkDocumentingResultHandler(String outputDir, List 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 json = this.objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); + Map links = (Map) json.get("_links"); + + Set actualRels = links.keySet(); + Set expectedRels = this.descriptorsByRel.keySet(); + + Set undocumentedRels = new HashSet(actualRels); + undocumentedRels.removeAll(expectedRels); + + Set missingRels = new HashSet(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 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; } }